From 396ffc4f3a3267dce1c3d09ebf54ab9c53577aee Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 5 Oct 2026 13:48:01 +0200 Subject: [PATCH] feat: implement modular modal editor and isolated Ruby plugins --- Cargo.lock | 854 ++++++++++++++++++++++++ Cargo.toml | 6 + README.md | 98 +++ examples/config.rb | 4 + examples/plugins/greeting.rb | 12 + src/editor.rs | 1189 ++++++++++++++++++++++++++++++++++ src/lib.rs | 3 + src/main.rs | 133 +++- src/ruby.rs | 727 +++++++++++++++++++++ src/ruby_host.rb | 188 ++++++ src/tui.rs | 238 +++++++ 11 files changed, 3450 insertions(+), 2 deletions(-) create mode 100644 Cargo.lock create mode 100644 README.md create mode 100644 examples/config.rb create mode 100644 examples/plugins/greeting.rb create mode 100644 src/editor.rs create mode 100644 src/lib.rs create mode 100644 src/ruby.rs create mode 100644 src/ruby_host.rb create mode 100644 src/tui.rs diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bc06e77 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,854 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "vamp" +version = "0.1.0" +dependencies = [ + "crossterm", + "ratatui", + "serde", + "serde_json", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6dea2de..8fa4a90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,3 +4,9 @@ version = "0.1.0" edition = "2024" [dependencies] +crossterm = "0.29" +ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +unicode-segmentation = "1.12" +unicode-width = "0.2" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c0c509 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# vamp + +A modal Rust editor with a replaceable frontend and subprocess-isolated Ruby plugins. + +## Run + +Requires current stable Rust and an interactive terminal. Ruby 2.6+ with its standard-library JSON support is required only for configuration/plugins; no Ruby gems are needed. + +```sh +cargo run -- --no-config notes.txt +cargo run -- --config examples/config.rb notes.txt +``` + +Without a file, vamp opens an unnamed buffer. A nonexistent file opens an empty buffer. Use `:w path` to save it. `cargo run -- --help` lists the command-line options. + +### Editing + +| Mode | Keys | +| --- | --- | +| Normal | `i` insert before cursor; `a` insert after cursor; `o`/`O` open line below/above | +| Normal | `h`/`j`/`k`/`l` or arrows; `0`/`$` or Home/End | +| Normal | `x`/Delete delete; `u` undo; Ctrl-r redo; `:` enter command mode | +| Insert | Type, Enter, Tab, Backspace/Delete, arrows, Home/End; Esc returns to normal | +| Command | Enter executes; Backspace edits; Esc cancels | + +Commands: `:w [path]`, `:q`, `:q!`, `:wq [path]`, `:plugins`, and `:plugin-command [arguments]`. + +`:q` refuses unsaved changes. `:wq` quits only after a successful save. Saves use a same-directory temporary file and rename, preserve existing permissions, and reject target symlinks. UTF-8 text is edited by extended grapheme; tabs render at four-cell stops. LF and trailing-newline structure are preserved; CRLF and CR are normalized to LF. Binary/terminal-control-containing text is rejected. Bracketed paste inserts only in Insert mode and never executes pasted commands in Normal mode. + +## Module seams + +One crate, three modules, no workspace or frontend-specific types in the editing backend: + +- `editor`: owns buffer, cursor, modes, undo/redo, saving, command ownership, capabilities, and validated edit effects. Its interface accepts `Input`, exposes state, and returns an `Invocation` when a plugin command is selected. +- `tui`: translates terminal events to `Input`, renders state, and advertises `text` and `styled_text` capabilities. Another frontend can drive the same backend; the backend does not import terminal libraries. +- `ruby`: loads configuration and runs one Ruby subprocess per plugin. It returns feature manifests and edit effects; it does not own the buffer or terminal. +- The executable wires the modules together and disables a faulted plugin without changing core editing availability. + +Built-in Ex commands register as the protected `core` feature in the same command registry used by plugins. External plugins cannot claim core status, replace existing commands, disable core, or bind over essential modal editing keys. `:plugins` lists active features and frontend capabilities. + +A feature declares all capabilities it needs. Registration is atomic: if any requirement is unavailable or any command conflicts, none of its commands are installed. For example, `Vamp.plugin "canvas", requires: ["graphics"]` will be refused by this TUI. Capability names are extensible strings, not a terminal-vs-GUI switch. Capability declarations express compatibility; they are not security permissions. + +## Ruby configuration + +By default, vamp loads `$XDG_CONFIG_HOME/vamp/config.rb`, or `~/.config/vamp/config.rb` when that environment variable is unset. `--config path` selects a file explicitly; `--no-config` bypasses Ruby entirely. Set `VAMP_RUBY` to select a different Ruby executable. + +```ruby +Vamp.configure do |config| + config.plugin "plugins/greeting.rb" + config.bind "g", "greet" +end +``` + +Plugin paths resolve relative to the config file, not the shell's working directory. Bindings are single-character Normal-mode keys targeting registered command names. Configuration errors fall back to built-in editing and appear in the status message; one plugin's load failure does not prevent other plugins loading. + +## Ruby plugins + +```ruby +Vamp.plugin "greeting", requires: ["text"] + +Vamp.command "greet" do |editor, args| + name = args.empty? ? "world" : args + editor.insert "Hello, #{name}!\n" + editor.message "Greeting inserted by Ruby" +end +``` + +Run `:greet vamp`, or press `g` with the example configuration. `examples/plugins/greeting.rb` also provides `:upper` to uppercase the buffer. + +Callbacks receive an immutable context snapshot: `editor.text`, `editor.row`, `editor.column`, `editor.mode`, and `editor.path`. Row/column are **one-based grapheme positions**; mode is `NORMAL`; path is a string or `nil`. Arguments are the remaining command text, without surrounding whitespace. + +`editor.insert(text)`, `editor.replace(text)`, and `editor.message(text)` queue effects. The context remains the pre-command snapshot even after queuing an effect. All effects are validated before the backend changes anything; buffer changes from one successful invocation form one undo action. Exceptions, process exits, timeouts, malformed replies, and invalid effects disable that plugin and discard its effects. Other plugins and built-ins remain available. Plugin `puts`, explicit `STDOUT.write`, and stderr cannot overwrite the TUI; diagnostic output is discarded, while exceptions appear in the status message. + +This is crash isolation, **not a security sandbox**: Ruby configuration and plugins have your user account's filesystem/process permissions. Load only trusted scripts. On Unix, hosts run in their own process groups and their groups are killed on shutdown. + +### Messaging + +Rust and Ruby exchange UTF-8, newline-delimited JSON objects with `version: 1`. Startup returns `manifest` (name, required capabilities, commands) or `config` (plugin paths and bindings). `invoke` requests carry an ID, command, arguments, and context; `result` replies carry the matching ID and typed effects (`insert`, `replace`, `message`). Errors use `error` frames. Unknown fields, wrong versions, and mismatched replies are rejected. Frames and aggregate effect text are bounded to 4 MiB. + +JSON uses Ruby's standard library. Protocol Buffers is not installed or required; there is no frontend/network wire protocol yet. The frontend currently calls the Rust library directly. + +## Current limits + +- One text buffer; Normal, Insert, and Command modes. No Visual mode, Vim counts/operators, splits, search, LSP, or graphical frontend. +- Undo uses full-buffer snapshots and movement scans the current line. This favors straightforward correctness over large-file performance. +- Plugin invocation is synchronous and may pause input for up to 500 ms before a host timeout. Config/plugin startup has a 2-second deadline per host. Buffer context must fit within a 4 MiB JSON request; a too-large request disables that plugin, not editing. +- Disabling a plugin removes its commands and bindings until the next launch. There is no hot reload. + +## Verify + +```sh +cargo test +cargo clippy --all-targets -- -D warnings +cargo fmt --check +ruby -c src/ruby_host.rb +``` + +Regression checks exercise Unicode edits, undo/redo, saving and failed saves, capability/command ownership, effect atomicity, real Ruby execution, protocol errors, crash/timeout recovery, and inherited-pipe cleanup. The executable was also exercised in PTY sessions for editing/saving, plugin crash and hang recovery, bracketed paste, scrolling, resize, and terminal restoration. diff --git a/examples/config.rb b/examples/config.rb new file mode 100644 index 0000000..3cf4d48 --- /dev/null +++ b/examples/config.rb @@ -0,0 +1,4 @@ +Vamp.configure do |config| + config.plugin "plugins/greeting.rb" + config.bind "g", "greet" +end diff --git a/examples/plugins/greeting.rb b/examples/plugins/greeting.rb new file mode 100644 index 0000000..23c238b --- /dev/null +++ b/examples/plugins/greeting.rb @@ -0,0 +1,12 @@ +Vamp.plugin "greeting", requires: ["text"] + +Vamp.command "greet" do |editor, args| + name = args.empty? ? "world" : args + editor.insert "Hello, #{name}!\n" + editor.message "Greeting inserted by Ruby" +end + +Vamp.command "upper" do |editor, _args| + editor.replace editor.text.upcase + editor.message "Buffer uppercased by Ruby" +end diff --git a/src/editor.rs b/src/editor.rs new file mode 100644 index 0000000..3253eca --- /dev/null +++ b/src/editor.rs @@ -0,0 +1,1189 @@ +use serde::Serialize; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use unicode_segmentation::UnicodeSegmentation; + +const MAX_EFFECT_BYTES: usize = 4 * 1024 * 1024; +static TEMP_ID: AtomicU64 = AtomicU64::new(0); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Input { + Char(char), + Ctrl(char), + Escape, + Enter, + Backspace, + Delete, + Left, + Right, + Up, + Down, + Home, + End, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Mode { + Normal, + Insert, + Command, +} + +impl fmt::Display for Mode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Normal => "NORMAL", + Self::Insert => "INSERT", + Self::Command => "COMMAND", + }) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct Cursor { + pub row: usize, + /// Zero-based extended grapheme index, not a byte or display-cell offset. + pub column: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Feature { + pub name: String, + pub required_capabilities: Vec, + pub commands: Vec, + pub core: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Effect { + Insert(String), + Replace(String), + Message(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Invocation { + pub plugin: String, + pub command: String, + pub args: String, +} + +#[derive(Debug, Serialize)] +pub struct Context { + pub text: String, + /// One-based row, including the empty row after a trailing newline. + pub row: usize, + /// One-based extended grapheme column. + pub column: usize, + pub mode: String, + pub path: Option, +} + +struct Snapshot { + lines: Vec, + cursor: Cursor, +} + +pub struct Editor { + lines: Vec, + saved_lines: Vec, + cursor: Cursor, + mode: Mode, + command_line: String, + message: String, + path: Option, + quit: bool, + capabilities: Vec, + features: HashMap, + bindings: HashMap, + // ponytail: full-buffer history costs O(buffer size) per action; use edit deltas for large files. + undo: Vec, + redo: Vec, + insert_start: Option, +} + +impl Editor { + pub fn open(path: Option, capabilities: Vec) -> io::Result { + let text = match &path { + Some(path) => match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(error), + }, + None => String::new(), + }; + validate_text(&text).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + let lines = split_lines(&text); + let mut editor = Self { + saved_lines: lines.clone(), + lines, + cursor: Cursor::default(), + mode: Mode::Normal, + command_line: String::new(), + message: String::new(), + path, + quit: false, + capabilities, + features: HashMap::new(), + bindings: HashMap::new(), + undo: Vec::new(), + redo: Vec::new(), + insert_start: None, + }; + editor + .install( + Feature { + name: "core".into(), + required_capabilities: Vec::new(), + commands: ["w", "q", "q!", "wq", "plugins"] + .into_iter() + .map(str::to_owned) + .collect(), + core: true, + }, + true, + ) + .map_err(io::Error::other)?; + Ok(editor) + } + + pub fn lines(&self) -> &[String] { + &self.lines + } + pub fn cursor(&self) -> Cursor { + self.cursor + } + pub fn mode(&self) -> Mode { + self.mode + } + pub fn command_line(&self) -> &str { + &self.command_line + } + pub fn message(&self) -> &str { + &self.message + } + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + pub fn dirty(&self) -> bool { + self.lines != self.saved_lines + } + pub fn should_quit(&self) -> bool { + self.quit + } + + pub fn context(&self) -> Context { + Context { + text: self.lines.join("\n"), + row: self.cursor.row + 1, + column: self.cursor.column + 1, + mode: self.mode.to_string(), + path: self + .path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()), + } + } + + pub fn set_message(&mut self, message: impl Into) { + let message = message.into(); + self.message = if message.chars().any(char::is_control) { + message + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect() + } else { + message + }; + } + + pub fn register(&mut self, feature: Feature) -> Result<(), String> { + self.install(feature, false) + } + + fn install(&mut self, feature: Feature, privileged: bool) -> Result<(), String> { + if feature.core && !privileged { + return Err("Plugins cannot register core features".into()); + } + if feature.name.trim().is_empty() || feature.name.chars().any(char::is_control) { + return Err("Feature names must be nonempty and contain no controls".into()); + } + if self.features.contains_key(&feature.name) { + return Err(format!("Feature already registered: {}", feature.name)); + } + for capability in &feature.required_capabilities { + if !self.capabilities.contains(capability) { + return Err(format!( + "Feature {} requires unavailable capability: {capability}", + feature.name + )); + } + } + let mut names = HashSet::new(); + for command in &feature.commands { + if !valid_command(command) { + return Err(format!("Invalid command name: {command}")); + } + if !names.insert(command.as_str()) || self.owner(command).is_some() { + return Err(format!("Command already registered: {command}")); + } + } + self.features.insert(feature.name.clone(), feature); + Ok(()) + } + + fn owner(&self, command: &str) -> Option<&Feature> { + self.features + .values() + .find(|feature| feature.commands.iter().any(|name| name == command)) + } + + pub fn disable_plugin(&mut self, name: &str, reason: &str) { + if self.features.get(name).is_some_and(|feature| feature.core) { + self.set_message("Core features cannot be disabled"); + return; + } + if let Some(feature) = self.features.remove(name) { + self.bindings + .retain(|_, command| !feature.commands.contains(command)); + } + self.set_message(format!("Plugin {name} disabled: {reason}")); + } + + pub fn bind(&mut self, key: char, command: String) -> Result<(), String> { + if key.is_control() || "iaoOhjklx0$u:".contains(key) { + return Err(format!("Key {key:?} is reserved for editing")); + } + if self.owner(&command).is_none() { + return Err(format!("Unknown command: {command}")); + } + self.bindings.insert(key, command); + Ok(()) + } + + pub fn handle(&mut self, input: Input) -> Option { + match self.mode { + Mode::Normal => match input { + Input::Char('i') => self.begin_insert(), + Input::Char('a') => { + self.begin_insert(); + self.cursor.column = + (self.cursor.column + 1).min(grapheme_count(&self.lines[self.cursor.row])); + } + Input::Char('o') | Input::Char('O') => { + self.begin_insert(); + if input == Input::Char('o') { + self.cursor.row += 1; + } + self.lines.insert(self.cursor.row, String::new()); + self.cursor.column = 0; + } + Input::Char('h') | Input::Left => self.move_horizontal(false), + Input::Char('l') | Input::Right => self.move_horizontal(true), + Input::Char('j') | Input::Down => self.move_vertical(true), + Input::Char('k') | Input::Up => self.move_vertical(false), + Input::Char('0') | Input::Home => self.cursor.column = 0, + Input::Char('$') | Input::End => self.move_end(), + Input::Char('x') | Input::Delete => { + if self.cursor.column < grapheme_count(&self.lines[self.cursor.row]) { + let before = self.snapshot(); + self.delete_forward(); + self.clamp_cursor(); + self.record(before); + } + } + Input::Char('u') => self.undo(), + Input::Ctrl('r') => self.redo(), + Input::Char(':') => { + self.mode = Mode::Command; + self.command_line.clear(); + } + Input::Char(key) => { + if let Some(command) = self.bindings.get(&key).cloned() { + return self.execute(&command); + } + } + _ => {} + }, + Mode::Insert => match input { + Input::Escape => { + self.finish_insert(); + self.mode = Mode::Normal; + self.cursor.column = self.cursor.column.saturating_sub(1); + self.clamp_cursor(); + } + Input::Char(ch) if !ch.is_control() || ch == '\t' => { + let mut bytes = [0; 4]; + self.insert_text(ch.encode_utf8(&mut bytes)); + } + Input::Enter => self.insert_text("\n"), + Input::Backspace => self.backspace(), + Input::Delete => self.delete_forward(), + Input::Left => self.move_horizontal(false), + Input::Right => self.move_horizontal(true), + Input::Down => self.move_vertical(true), + Input::Up => self.move_vertical(false), + Input::Home => self.cursor.column = 0, + Input::End => self.move_end(), + _ => {} + }, + Mode::Command => match input { + Input::Escape => { + self.command_line.clear(); + self.mode = Mode::Normal; + } + Input::Enter => { + let command = std::mem::take(&mut self.command_line); + self.mode = Mode::Normal; + return self.execute(&command); + } + Input::Backspace => { + if let Some((byte, _)) = self.command_line.grapheme_indices(true).next_back() { + self.command_line.truncate(byte); + } + } + Input::Char(ch) if !ch.is_control() => self.command_line.push(ch), + _ => {} + }, + } + None + } + + pub fn execute(&mut self, command: &str) -> Option { + if self.mode == Mode::Insert { + self.finish_insert(); + } + self.mode = Mode::Normal; + self.clamp_cursor(); + let command = command.trim().strip_prefix(':').unwrap_or(command.trim()); + let (name, args) = command + .split_once(char::is_whitespace) + .unwrap_or((command, "")); + let args = args.trim(); + let Some(feature) = self.owner(name) else { + self.set_message(format!("Unknown command: {name}")); + return None; + }; + if !feature.core { + return Some(Invocation { + plugin: feature.name.clone(), + command: name.into(), + args: args.into(), + }); + } + match name { + "w" | "wq" => { + if self.save(if args.is_empty() { + None + } else { + Some(PathBuf::from(args)) + }) && name == "wq" + { + self.quit = true; + } + } + "q" | "q!" if !args.is_empty() => self.set_message("Quit commands take no arguments"), + "q" if self.dirty() => self.set_message("Unsaved changes; use :w or :q!"), + "q" | "q!" => self.quit = true, + "plugins" if !args.is_empty() => { + self.set_message("The plugins command takes no arguments") + } + "plugins" => { + let mut features: Vec<_> = self.features.values().collect(); + features.sort_unstable_by(|left, right| left.name.cmp(&right.name)); + let mut message = format!("Capabilities: {}", self.capabilities.join(", ")); + for feature in features { + message.push_str(" | "); + message.push_str(&feature.name); + if feature.core { + message.push_str(" [core]"); + } + message.push_str(": "); + for (index, command) in feature.commands.iter().enumerate() { + if index > 0 { + message.push_str(", "); + } + message.push_str(command); + } + } + self.set_message(message); + } + _ => unreachable!("privately installed core commands"), + } + None + } + + pub fn apply(&mut self, effects: Vec) -> Result<(), String> { + let mut total = 0usize; + for effect in &effects { + let text = match effect { + Effect::Insert(text) | Effect::Replace(text) | Effect::Message(text) => text, + }; + total = total + .checked_add(text.len()) + .ok_or("Plugin effects exceed 4 MiB")?; + if total > MAX_EFFECT_BYTES { + return Err("Plugin effects exceed 4 MiB".into()); + } + validate_text(text)?; + } + // Validation precedes even history/message changes, so a rejected response is atomic. + let inserting = self.mode == Mode::Insert; + if inserting { + self.finish_insert(); + } + let before = effects + .iter() + .any(|effect| { + matches!(effect, Effect::Insert(text) if !text.is_empty()) + || matches!(effect, Effect::Replace(_)) + }) + .then(|| self.snapshot()); + for effect in effects { + match effect { + Effect::Insert(text) => self.insert_text(&normalize_newlines(&text)), + Effect::Replace(text) => { + self.lines = split_lines(&text); + self.clamp_cursor(); + } + Effect::Message(text) => self.set_message(text), + } + } + self.clamp_cursor(); + if let Some(before) = before { + self.record(before); + } + if inserting { + self.insert_start = Some(self.snapshot()); + } + Ok(()) + } + + fn save(&mut self, path: Option) -> bool { + let Some(path) = path.or_else(|| self.path.clone()) else { + self.set_message("No file name; use :w path"); + return false; + }; + match atomic_save(&path, &self.lines) { + Ok(()) => { + self.saved_lines.clone_from(&self.lines); + self.path = Some(path); + self.set_message("Written"); + true + } + Err(error) => { + self.set_message(format!("Write failed: {error}")); + false + } + } + } + + fn snapshot(&self) -> Snapshot { + Snapshot { + lines: self.lines.clone(), + cursor: self.cursor, + } + } + + fn record(&mut self, before: Snapshot) { + if before.lines != self.lines { + self.undo.push(before); + self.redo.clear(); + } + } + + fn begin_insert(&mut self) { + self.insert_start = Some(self.snapshot()); + self.mode = Mode::Insert; + } + + fn finish_insert(&mut self) { + if let Some(before) = self.insert_start.take() { + self.record(before); + } + } + + fn undo(&mut self) { + if let Some(before) = self.undo.pop() { + let current = self.snapshot(); + self.lines = before.lines; + self.cursor = before.cursor; + self.redo.push(current); + self.clamp_cursor(); + } else { + self.set_message("Nothing to undo"); + } + } + + fn redo(&mut self) { + if let Some(after) = self.redo.pop() { + let current = self.snapshot(); + self.lines = after.lines; + self.cursor = after.cursor; + self.undo.push(current); + self.clamp_cursor(); + } else { + self.set_message("Nothing to redo"); + } + } + + fn max_column(&self) -> usize { + let count = grapheme_count(&self.lines[self.cursor.row]); + if self.mode == Mode::Insert { + count + } else { + count.saturating_sub(1) + } + } + + fn clamp_cursor(&mut self) { + self.cursor.row = self.cursor.row.min(self.lines.len() - 1); + self.cursor.column = self.cursor.column.min(self.max_column()); + } + + fn move_horizontal(&mut self, right: bool) { + self.cursor.column = if right { + (self.cursor.column + 1).min(self.max_column()) + } else { + self.cursor.column.saturating_sub(1) + }; + } + + fn move_vertical(&mut self, down: bool) { + self.cursor.row = if down { + (self.cursor.row + 1).min(self.lines.len() - 1) + } else { + self.cursor.row.saturating_sub(1) + }; + self.clamp_cursor(); + } + + fn move_end(&mut self) { + self.cursor.column = self.max_column(); + } + + fn insert_text(&mut self, text: &str) { + if text.is_empty() { + return; + } + let row = self.cursor.row; + let byte = grapheme_byte(&self.lines[row], self.cursor.column); + let mut parts = text.split('\n'); + let first = parts.next().expect("split always has a first part"); + if !text.contains('\n') { + self.lines[row].insert_str(byte, first); + self.cursor.column = column_after_byte(&self.lines[row], byte + first.len()); + return; + } + let suffix = self.lines[row].split_off(byte); + self.lines[row].push_str(first); + let mut added: Vec = parts.map(str::to_owned).collect(); + let last = added.last_mut().expect("newline supplies another part"); + let inserted_bytes = last.len(); + last.push_str(&suffix); + self.cursor.column = column_after_byte(last, inserted_bytes); + self.cursor.row += added.len(); + self.lines.splice(row + 1..row + 1, added); + } + + fn delete_forward(&mut self) { + let row = self.cursor.row; + let line = &mut self.lines[row]; + let byte = grapheme_byte(line, self.cursor.column); + if byte < line.len() { + let end = grapheme_byte(line, self.cursor.column + 1); + line.replace_range(byte..end, ""); + self.cursor.column = column_after_byte(line, byte); + } else if self.mode == Mode::Insert && row + 1 < self.lines.len() { + let next = self.lines.remove(row + 1); + self.lines[row].push_str(&next); + self.cursor.column = column_after_byte(&self.lines[row], byte); + } + } + + fn backspace(&mut self) { + let row = self.cursor.row; + if self.cursor.column > 0 { + let line = &mut self.lines[row]; + let start = grapheme_byte(line, self.cursor.column - 1); + let end = grapheme_byte(line, self.cursor.column); + line.replace_range(start..end, ""); + self.cursor.column = column_after_byte(line, start); + } else if row > 0 { + let line = self.lines.remove(row); + self.cursor.row -= 1; + let previous = &mut self.lines[self.cursor.row]; + let byte = previous.len(); + previous.push_str(&line); + self.cursor.column = column_after_byte(previous, byte); + } + } +} + +fn valid_command(command: &str) -> bool { + let command = command.strip_suffix('!').unwrap_or(command); + let mut chars = command.chars(); + chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) + && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') +} + +fn validate_text(text: &str) -> Result<(), String> { + if let Some(ch) = text + .chars() + .find(|ch| ch.is_control() && !matches!(ch, '\t' | '\n' | '\r')) + { + Err(format!("Unsupported control character U+{:04X}", ch as u32)) + } else { + Ok(()) + } +} + +fn normalize_newlines(text: &str) -> Cow<'_, str> { + if !text.contains('\r') { + return Cow::Borrowed(text); + } + let mut normalized = String::with_capacity(text.len()); + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\r' { + if chars.peek() == Some(&'\n') { + chars.next(); + } + normalized.push('\n'); + } else { + normalized.push(ch); + } + } + Cow::Owned(normalized) +} + +fn split_lines(text: &str) -> Vec { + normalize_newlines(text) + .split('\n') + .map(str::to_owned) + .collect() +} + +fn grapheme_count(line: &str) -> usize { + line.graphemes(true).count() +} +fn grapheme_byte(line: &str, column: usize) -> usize { + line.grapheme_indices(true) + .nth(column) + .map_or(line.len(), |(byte, _)| byte) +} +fn column_after_byte(line: &str, byte: usize) -> usize { + line.grapheme_indices(true) + .take_while(|(index, _)| *index < byte) + .count() +} + +fn target_metadata(path: &Path) -> io::Result> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Refusing to save through a symbolic link", + )), + Ok(metadata) if !metadata.is_file() => Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Save target is not a regular file", + )), + Ok(metadata) => Ok(Some(metadata)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn atomic_save(path: &Path, lines: &[String]) -> io::Result<()> { + let metadata = target_metadata(path)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let (temporary, mut file) = loop { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let temporary = parent.join(format!(".vamp-{}-{id}.tmp", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + match options.open(&temporary) { + Ok(file) => break (temporary, file), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + }; + let result = (|| { + for (index, line) in lines.iter().enumerate() { + if index > 0 { + file.write_all(b"\n")?; + } + file.write_all(line.as_bytes())?; + } + if let Some(metadata) = metadata { + file.set_permissions(metadata.permissions())?; + } + file.sync_all()?; + drop(file); + // Recheck immediately before rename: never knowingly replace a symlink. + target_metadata(path)?; + fs::rename(&temporary, path)?; + #[cfg(unix)] + fs::File::open(parent)?.sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Directory(PathBuf); + impl Directory { + fn new() -> Self { + let id = TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("vamp-editor-test-{}-{id}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + impl Drop for Directory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + fn editor() -> Editor { + Editor::open(None, vec!["text".into()]).unwrap() + } + fn type_text(editor: &mut Editor, text: &str) { + for ch in text.chars() { + editor.handle(if ch == '\n' { + Input::Enter + } else { + Input::Char(ch) + }); + } + } + fn plugin(name: &str, commands: &[&str]) -> Feature { + Feature { + name: name.into(), + required_capabilities: vec!["text".into()], + commands: commands.iter().map(|name| (*name).into()).collect(), + core: false, + } + } + + #[test] + fn insert_sessions_and_open_lines_are_single_undo_actions() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "hello\n世界"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\n世界"); + assert!(editor.dirty()); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + assert!(!editor.dirty()); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, "hello\n世界"); + editor.handle(Input::Char('O')); + type_text(&mut editor, "above"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\nabove\n世界"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "hello\n世界"); + editor.handle(Input::Char('o')); + type_text(&mut editor, "below"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "hello\n世界\nbelow"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "hello\n世界"); + } + + #[test] + fn grapheme_navigation_and_deletion_do_not_split_unicode() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("a\u{301}👩‍💻界".into())]) + .unwrap(); + editor.handle(Input::Char('l')); + assert_eq!(editor.cursor().column, 1); + editor.handle(Input::Char('x')); + assert_eq!(editor.context().text, "a\u{301}界"); + editor.handle(Input::Char('0')); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "界"); + editor.handle(Input::Char('a')); + type_text(&mut editor, "👨‍👩‍👧"); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "界"); + editor.handle(Input::Escape); + assert_eq!(editor.cursor().column, 0); + assert_eq!(editor.context().column, 1); + } + + #[test] + fn insertion_merges_combining_graphemes_and_joins_lines() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a\u{301}\nb"); + editor.handle(Input::Home); + editor.handle(Input::Backspace); + assert_eq!(editor.context().text, "a\u{301}b"); + assert_eq!(editor.cursor(), Cursor { row: 0, column: 1 }); + editor.handle(Input::Enter); + editor.handle(Input::Up); + editor.handle(Input::End); + editor.handle(Input::Delete); + assert_eq!(editor.context().text, "a\u{301}b"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + } + + #[test] + fn cursor_is_clamped_on_short_and_empty_lines() { + let mut editor = editor(); + editor + .apply(vec![Effect::Replace("long\nx\n".into())]) + .unwrap(); + editor.handle(Input::End); + assert_eq!(editor.cursor().column, 3); + editor.handle(Input::Down); + assert_eq!(editor.cursor(), Cursor { row: 1, column: 0 }); + editor.handle(Input::Char('j')); + editor.handle(Input::Char('l')); + editor.handle(Input::Char('x')); + assert_eq!(editor.cursor(), Cursor { row: 2, column: 0 }); + assert_eq!(editor.context().row, 3); + editor.handle(Input::Char('i')); + editor.handle(Input::Left); + editor.handle(Input::Delete); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "long\nx\n"); + } + + #[test] + fn registry_rejects_incompatible_duplicate_and_forged_core_atomically() { + let mut editor = editor(); + let mut feature = plugin("graphics", &["draw"]); + feature.required_capabilities.push("graphics".into()); + assert!(editor.register(feature).is_err()); + assert!(editor.bind('g', "draw".into()).is_err()); + assert!( + editor + .register(plugin("duplicate", &["newcmd", "w"])) + .is_err() + ); + assert!(editor.bind('g', "newcmd".into()).is_err()); + assert!( + editor + .register(plugin("repeat", &["same", "same"])) + .is_err() + ); + assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); + assert!(editor.register(plugin("", &["emptyname"])).is_err()); + let mut feature = plugin("forged", &["override"]); + feature.core = true; + assert!(editor.register(feature).is_err()); + editor.register(plugin("working", &["greet"])).unwrap(); + assert!(editor.register(plugin("working", &["different"])).is_err()); + assert!(editor.register(plugin("other", &["greet"])).is_err()); + assert!(editor.register(plugin("collision", &["plugins"])).is_err()); + editor.disable_plugin("core", "attempt"); + editor.execute("q"); + assert!(editor.should_quit()); + } + + #[test] + fn plugins_command_lists_active_features_and_capabilities_deterministically() { + let mut editor = editor(); + editor.register(plugin("zebra", &["last"])).unwrap(); + editor.register(plugin("alpha", &["first"])).unwrap(); + editor.execute("plugins"); + assert_eq!( + editor.message(), + "Capabilities: text | alpha: first | core [core]: w, q, q!, wq, plugins | zebra: last" + ); + editor.disable_plugin("alpha", "fault"); + editor.execute("plugins"); + assert!(!editor.message().contains("alpha")); + assert!(editor.message().contains("core [core]")); + } + + #[test] + fn bindings_dispatch_only_normal_mode_and_essential_keys_stay_protected() { + let mut editor = editor(); + editor.register(plugin("greeting", &["greet"])).unwrap(); + for key in "iaoOhjklx0$u:\u{1b}\r".chars() { + assert!(editor.bind(key, "greet".into()).is_err()); + } + editor.bind('g', "greet".into()).unwrap(); + assert_eq!( + editor.handle(Input::Char('g')), + Some(Invocation { + plugin: "greeting".into(), + command: "greet".into(), + args: String::new(), + }) + ); + editor.handle(Input::Char('i')); + assert_eq!(editor.handle(Input::Char('g')), None); + assert_eq!(editor.context().text, "g"); + editor.handle(Input::Escape); + editor.disable_plugin("greeting", "crashed\u{1b}[31m"); + assert_eq!(editor.handle(Input::Char('g')), None); + assert!(editor.bind('g', "greet".into()).is_err()); + assert!(!editor.message().contains('\u{1b}')); + } + + #[test] + fn command_mode_dispatches_arguments_and_backspaces_whole_graphemes() { + let mut editor = editor(); + editor.register(plugin("greeting", &["greet"])).unwrap(); + editor.handle(Input::Char(':')); + type_text(&mut editor, "greet a\u{301}"); + editor.handle(Input::Backspace); + assert_eq!(editor.command_line(), "greet "); + type_text(&mut editor, "world"); + assert_eq!( + editor.handle(Input::Enter), + Some(Invocation { + plugin: "greeting".into(), + command: "greet".into(), + args: "world".into(), + }) + ); + assert_eq!(editor.mode(), Mode::Normal); + assert_eq!(editor.command_line(), ""); + editor.handle(Input::Char(':')); + type_text(&mut editor, "q!"); + editor.handle(Input::Escape); + assert!(!editor.should_quit()); + assert_eq!(editor.command_line(), ""); + } + + #[test] + fn plugin_effects_validate_before_any_mutation_and_group_undo() { + let mut editor = editor(); + editor.set_message("unchanged"); + assert!( + editor + .apply(vec![ + Effect::Insert("good".into()), + Effect::Message("bad\u{1b}".into()) + ]) + .is_err() + ); + assert_eq!(editor.context().text, ""); + assert_eq!(editor.message(), "unchanged"); + assert!(!editor.dirty()); + assert!( + editor + .apply(vec![ + Effect::Insert("a".repeat(MAX_EFFECT_BYTES)), + Effect::Insert("x".into()) + ]) + .is_err() + ); + assert_eq!(editor.context().text, ""); + editor + .apply(vec![ + Effect::Insert("one".into()), + Effect::Insert("\ntwo".into()), + Effect::Message("ok".into()), + ]) + .unwrap(); + assert_eq!(editor.context().text, "one\ntwo"); + assert_eq!(editor.message(), "ok"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, "one\ntwo"); + editor + .apply(vec![Effect::Replace("replacement".into())]) + .unwrap(); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "one\ntwo"); + } + + #[test] + fn plugin_effect_transactions_preserve_insert_sessions() { + let mut editor = editor(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "before"); + let cursor = editor.cursor(); + assert!( + editor + .apply(vec![Effect::Replace("invalid\0".into())]) + .is_err() + ); + assert_eq!(editor.mode(), Mode::Insert); + assert_eq!(editor.cursor(), cursor); + type_text(&mut editor, "after"); + editor.handle(Input::Escape); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + editor.handle(Input::Char('i')); + type_text(&mut editor, "a"); + editor + .apply(vec![ + Effect::Insert("b".into()), + Effect::Message("plugin".into()), + ]) + .unwrap(); + type_text(&mut editor, "c"); + editor.handle(Input::Escape); + assert_eq!(editor.context().text, "abc"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "ab"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, "a"); + editor.handle(Input::Char('u')); + assert_eq!(editor.context().text, ""); + } + + #[test] + fn no_op_edits_preserve_redo_but_new_edits_clear_it() { + let mut editor = editor(); + editor.apply(vec![Effect::Insert("a".into())]).unwrap(); + editor.handle(Input::Char('u')); + editor.handle(Input::Char('i')); + editor.handle(Input::Escape); + editor + .apply(vec![Effect::Message("notice".into())]) + .unwrap(); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, "a"); + editor.handle(Input::Char('u')); + editor.apply(vec![Effect::Insert("b".into())]).unwrap(); + editor.handle(Input::Ctrl('r')); + assert_eq!(editor.context().text, "b"); + } + + #[test] + fn saves_preserve_empty_files_lf_trailing_newlines_and_dirty_history() { + let directory = Directory::new(); + for (name, text) in [ + ("empty", ""), + ("no-newline", "hello"), + ("newline", "hello\n"), + ("two-newlines", "hello\n\n"), + ] { + let path = directory.path(name); + fs::write(&path, text).unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), text); + assert!(!editor.dirty()); + } + let path = directory.path("new file"); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor.handle(Input::Char('i')); + type_text(&mut editor, "saved\n"); + editor.handle(Input::Escape); + editor.execute("q"); + assert!(!editor.should_quit()); + editor.execute("w"); + assert_eq!(fs::read_to_string(&path).unwrap(), "saved\n"); + assert!(!editor.dirty()); + editor.handle(Input::Char('u')); + assert!(editor.dirty()); + editor.handle(Input::Ctrl('r')); + assert!(!editor.dirty()); + editor.execute("q"); + assert!(editor.should_quit()); + } + + #[test] + fn save_as_and_failed_wq_keep_path_dirty_state_and_quit_correct() { + let directory = Directory::new(); + let mut editor = editor(); + editor + .apply(vec![Effect::Insert("unsaved".into())]) + .unwrap(); + editor.execute("w"); + assert!(editor.dirty()); + let bad = directory.path("absent/file"); + editor.execute(&format!("wq {}", bad.display())); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + assert_eq!(editor.path(), None); + let path = directory.path("save as.txt"); + editor.execute(&format!("w {}", path.display())); + assert_eq!(editor.path(), Some(path.as_path())); + assert!(!editor.dirty()); + editor.apply(vec![Effect::Insert("new".into())]).unwrap(); + editor.execute("q!"); + assert!(editor.should_quit()); + assert_eq!(fs::read_to_string(path).unwrap(), "unsaved"); + } + + #[test] + fn loaded_controls_are_rejected_and_cr_line_endings_are_normalized() { + let directory = Directory::new(); + let path = directory.path("control"); + for text in ["nul\0", "escape\u{1b}[31m", "backspace\u{8}", "c1\u{85}"] { + fs::write(&path, text).unwrap(); + assert!( + matches!(Editor::open(Some(path.clone()), vec![]), Err(error) if error.kind() == io::ErrorKind::InvalidData) + ); + } + fs::write(&path, "a\t界\r\nb\rc\n").unwrap(); + let editor = Editor::open(Some(path), vec![]).unwrap(); + assert_eq!(editor.context().text, "a\t界\nb\nc\n"); + assert!(!editor.dirty()); + assert!(Editor::open(Some(directory.0.clone()), vec![]).is_err()); + } + + #[cfg(unix)] + #[test] + fn saves_preserve_permissions_and_reject_symlinks_without_replacing_them() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let directory = Directory::new(); + let path = directory.path("original"); + fs::write(&path, "original").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let mut editor = Editor::open(Some(path.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("changed".into())]) + .unwrap(); + editor.execute("w"); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); + let link = directory.path("link"); + symlink(&path, &link).unwrap(); + let mut editor = Editor::open(Some(link.clone()), vec![]).unwrap(); + editor + .apply(vec![Effect::Replace("not saved".into())]) + .unwrap(); + editor.execute("wq"); + assert!(editor.dirty()); + assert!(!editor.should_quit()); + assert!( + fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink() + ); + assert_eq!(fs::read_to_string(&path).unwrap(), "changed"); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 2); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a5a169c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +pub mod editor; +pub mod ruby; +pub mod tui; diff --git a/src/main.rs b/src/main.rs index e7a11a9..8898a89 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,132 @@ -fn main() { - println!("Hello, world!"); +use std::{collections::BTreeMap, env, path::PathBuf}; + +use vamp::{ + editor::Editor, + ruby::{Config, RubyPlugin, load_config}, + tui, +}; + +const HELP: &str = "vamp — modal editor with isolated Ruby plugins + +Usage: vamp [--config PATH | --no-config] [FILE] + +Normal: i/a insert, o/O new line, h/j/k/l move, 0/$ line ends, + x delete, u undo, Ctrl-r redo, : command +Insert: Esc returns to normal +Commands: :w [PATH], :q, :q!, :wq [PATH], :plugins, :PLUGIN [ARGS] + +Ruby config: $XDG_CONFIG_HOME/vamp/config.rb or ~/.config/vamp/config.rb +Set VAMP_RUBY to choose the Ruby executable."; + +struct Options { + file: Option, + config: Option, +} + +fn options() -> Result, String> { + let mut args = env::args_os().skip(1); + let mut file = None; + let mut config = None; + let mut config_selected = false; + let mut positional = false; + while let Some(arg) = args.next() { + if !positional && (arg == "--help" || arg == "-h") { + println!("{HELP}"); + return Ok(None); + } else if !positional && arg == "--" { + positional = true; + } else if !positional && arg == "--config" { + if config_selected { + return Err("use only one of --config and --no-config".into()); + } + config = Some(PathBuf::from( + args.next().ok_or("--config requires a path")?, + )); + config_selected = true; + } else if !positional && arg == "--no-config" { + if config_selected { + return Err("use only one of --config and --no-config".into()); + } + config_selected = true; + } else if !positional && arg.to_string_lossy().starts_with('-') { + return Err(format!("unknown option: {}", arg.to_string_lossy())); + } else if file.replace(PathBuf::from(arg)).is_some() { + return Err("only one file can be opened".into()); + } + } + if !config_selected { + let home = env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))); + config = home + .map(|home| home.join("vamp/config.rb")) + .filter(|path| path.exists()); + } + Ok(Some(Options { file, config })) +} + +fn run() -> Result<(), String> { + let Some(options) = options()? else { + return Ok(()); + }; + let mut editor = Editor::open( + options.file, + tui::CAPABILITIES.iter().map(|s| (*s).to_owned()).collect(), + ) + .map_err(|error| format!("cannot open file: {error}"))?; + let mut notices = Vec::new(); + let config = match options.config { + Some(path) => load_config(&path).unwrap_or_else(|error| { + notices.push(format!("config disabled: {error}")); + Config::default() + }), + None => Config::default(), + }; + let mut plugins = BTreeMap::new(); + for path in config.plugins { + match RubyPlugin::start(&path) { + Ok((plugin, feature)) => { + let name = feature.name.clone(); + match editor.register(feature) { + Ok(()) => { + plugins.insert(name, plugin); + } + Err(error) => notices.push(format!("{}: {error}", path.display())), + } + } + Err(error) => notices.push(format!("{}: {error}", path.display())), + } + } + for (key, command) in config.bindings { + if let Err(error) = editor.bind(key, command) { + notices.push(format!("binding {key:?}: {error}")); + } + } + if notices.is_empty() { + editor.set_message("i: insert • Esc: normal • :w save • :q quit • :plugins"); + } else { + editor.set_message(notices.join(" | ")); + } + tui::run(&mut editor, |editor, invocation| { + let result = match plugins.get_mut(&invocation.plugin) { + Some(plugin) => plugin + .invoke(&invocation.command, &invocation.args, &editor.context()) + .and_then(|effects| editor.apply(effects)), + None => Err("plugin host is unavailable".into()), + }; + if let Err(error) = result { + if let Some(mut plugin) = plugins.remove(&invocation.plugin) { + plugin.disable(); + } + editor.disable_plugin(&invocation.plugin, &error); + } + }) + .map_err(|error| format!("terminal error: {error}")) +} + +fn main() { + if let Err(error) = run() { + eprintln!("vamp: {error}"); + std::process::exit(1); + } } diff --git a/src/ruby.rs b/src/ruby.rs new file mode 100644 index 0000000..866b40d --- /dev/null +++ b/src/ruby.rs @@ -0,0 +1,727 @@ +use crate::editor::{Context, Effect, Feature}; +use serde::{Deserialize, Serialize}; +use std::env; +use std::io::{self, BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +const RUNNER: &str = include_str!("ruby_host.rb"); +const MAX_FRAME: usize = 4 * 1024 * 1024; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); +const INVOKE_TIMEOUT: Duration = Duration::from_millis(500); + +#[derive(Debug, Default)] +pub struct Config { + pub plugins: Vec, + pub bindings: Vec<(char, String)>, +} + +/// Relative plugin paths are resolved against the configuration file's directory. +pub fn load_config(path: &Path) -> Result { + let deadline = Instant::now() + STARTUP_TIMEOUT; + let path = absolute_file(path)?; + let mut host = Host::spawn("config", &path)?; + let response = host.receive(deadline)?; + match response { + Response::Config { + plugins, bindings, .. + } => { + let mut config = Config::default(); + let directory = path.parent().expect("absolute file has a parent"); + for plugin in plugins { + if plugin.is_empty() || plugin.contains('\0') { + return Err("Ruby config contains an invalid plugin path".into()); + } + config.plugins.push(directory.join(plugin)); + } + for binding in bindings { + let mut chars = binding.key.chars(); + let key = chars + .next() + .ok_or("Ruby binding key must be one character")?; + if chars.next().is_some() || binding.command.is_empty() { + return Err("Ruby binding requires one character and a nonempty command".into()); + } + config.bindings.push((key, binding.command)); + } + Ok(config) + } + Response::Error { + id: None, message, .. + } => Err(format!("Ruby config: {message}")), + _ => Err("Ruby config returned an unexpected protocol frame".into()), + } +} + +pub struct RubyPlugin { + host: Host, + next_id: u64, +} + +impl RubyPlugin { + pub fn start(path: &Path) -> Result<(Self, Feature), String> { + let deadline = Instant::now() + STARTUP_TIMEOUT; + let path = absolute_file(path)?; + let mut host = Host::spawn("plugin", &path)?; + match host.receive(deadline)? { + Response::Manifest { + name, + requires, + commands, + .. + } => { + if name.is_empty() + || requires.iter().any(String::is_empty) + || commands.iter().any(String::is_empty) + { + return Err("Ruby plugin manifest contains empty names".into()); + } + let feature = Feature { + name, + required_capabilities: requires, + commands, + core: false, + }; + Ok((Self { host, next_id: 1 }, feature)) + } + Response::Error { + id: None, message, .. + } => Err(format!("Ruby plugin startup: {message}")), + _ => Err("Ruby plugin returned an unexpected startup frame".into()), + } + } + + /// Context coordinates are one-based; effects are queued until a full valid reply arrives. + /// Any failure shuts down this host. Starting a fresh plugin is the recovery path. + pub fn invoke( + &mut self, + command: &str, + args: &str, + context: &Context, + ) -> Result, String> { + let deadline = Instant::now() + INVOKE_TIMEOUT; + let result = self.invoke_inner(command, args, context, deadline); + if result.is_err() { + self.disable(); + } + result + } + + fn invoke_inner( + &mut self, + command: &str, + args: &str, + context: &Context, + deadline: Instant, + ) -> Result, String> { + let id = self.next_id; + self.next_id = id.checked_add(1).ok_or("Ruby request IDs exhausted")?; + let request = Request { + version: 1, + kind: "invoke", + id, + command, + args, + context, + }; + self.host.send(&request, deadline)?; + match self.host.receive(deadline)? { + Response::Result { + id: response_id, + effects, + .. + } if response_id == id => Ok(effects + .into_iter() + .map(|effect| match effect { + WireEffect::Insert { text } => Effect::Insert(text), + WireEffect::Replace { text } => Effect::Replace(text), + WireEffect::Message { text } => Effect::Message(text), + }) + .collect()), + Response::Error { + id: Some(response_id), + message, + .. + } if response_id == id => Err(format!("Ruby plugin: {message}")), + _ => Err("Ruby plugin response does not match the invocation".into()), + } + } + + pub fn disable(&mut self) { + self.host.shutdown(); + } +} + +fn absolute_file(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + env::current_dir() + .map(|directory| directory.join(path)) + .map_err(|error| format!("Cannot resolve Ruby file path: {error}")) + } +} + +#[derive(Serialize)] +struct Request<'a> { + version: u8, + #[serde(rename = "type")] + kind: &'static str, + id: u64, + command: &'a str, + args: &'a str, + context: &'a Context, +} + +#[derive(Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +enum Response { + #[serde(rename = "manifest")] + Manifest { + version: u8, + name: String, + requires: Vec, + commands: Vec, + }, + #[serde(rename = "config")] + Config { + version: u8, + plugins: Vec, + bindings: Vec, + }, + #[serde(rename = "result")] + Result { + version: u8, + id: u64, + effects: Vec, + }, + #[serde(rename = "error")] + Error { + version: u8, + #[serde(default, deserialize_with = "optional_request_id")] + id: Option, + message: String, + }, +} + +fn optional_request_id<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + u64::deserialize(deserializer).map(Some) +} + +impl Response { + fn version(&self) -> u8 { + match self { + Self::Manifest { version, .. } + | Self::Config { version, .. } + | Self::Result { version, .. } + | Self::Error { version, .. } => *version, + } + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Binding { + key: String, + command: String, +} + +#[derive(Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +enum WireEffect { + #[serde(rename = "insert")] + Insert { text: String }, + #[serde(rename = "replace")] + Replace { text: String }, + #[serde(rename = "message")] + Message { text: String }, +} + +struct Host { + child: Option, + requests: Option>>, + writes: Receiver>, + frames: Receiver>, + reader: Option>, + writer: Option>, +} + +impl Host { + fn spawn(mode: &str, path: &Path) -> Result { + let executable = env::var_os("VAMP_RUBY").unwrap_or_else(|| "ruby".into()); + let mut command = Command::new(&executable); + command + .args(["-e", RUNNER, "--", mode]) + .arg(path) + .current_dir(path.parent().expect("absolute file has a parent")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().map_err(|error| { + format!( + "Ruby runtime {:?} unavailable or failed to start: {error}", + executable + ) + })?; + let mut stdin = child.stdin.take().expect("piped child stdin"); + let stdout = child.stdout.take().expect("piped child stdout"); + let (request_tx, request_rx) = mpsc::sync_channel::>(1); + let (write_tx, write_rx) = mpsc::sync_channel(1); + let (frame_tx, frame_rx) = mpsc::sync_channel(1); + let mut host = Self { + child: Some(child), + requests: Some(request_tx), + writes: write_rx, + frames: frame_rx, + reader: None, + writer: None, + }; + host.reader = Some( + thread::Builder::new() + .name("vamp-ruby-reader".into()) + .spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let frame = read_frame(&mut reader).and_then(|bytes| { + serde_json::from_slice::(&bytes) + .map_err(|error| format!("Malformed Ruby protocol frame: {error}")) + }); + let failed = frame.is_err(); + // Never block on a flooded host; the bounded queue disconnects after draining. + if frame_tx.try_send(frame).is_err() || failed { + break; + } + } + }) + .map_err(|error| format!("Cannot start Ruby reader: {error}"))?, + ); + host.writer = Some( + thread::Builder::new() + .name("vamp-ruby-writer".into()) + .spawn(move || { + while let Ok(bytes) = request_rx.recv() { + let result = stdin + .write_all(&bytes) + .and_then(|_| stdin.flush()) + .map_err(|error| format!("Ruby host write failed: {error}")); + let failed = result.is_err(); + if write_tx.try_send(result).is_err() || failed { + break; + } + } + }) + .map_err(|error| format!("Cannot start Ruby writer: {error}"))?, + ); + Ok(host) + } + + fn send(&mut self, request: &Request<'_>, deadline: Instant) -> Result<(), String> { + let sender = self.requests.as_ref().ok_or("Ruby plugin is disabled")?; + match self.frames.try_recv() { + Ok(_) => return Err("Ruby host emitted an unsolicited protocol frame".into()), + Err(mpsc::TryRecvError::Disconnected) => { + return Err("Ruby host crashed or closed its output".into()); + } + Err(mpsc::TryRecvError::Empty) => {} + } + let mut frame = FrameWriter(Vec::new()); + serde_json::to_writer(&mut frame, request) + .map_err(|error| format!("Cannot encode bounded Ruby request: {error}"))?; + frame.0.push(b'\n'); + sender + .try_send(frame.0) + .map_err(|_| "Ruby host request channel failed")?; + receive_until(&self.writes, deadline, "write")? + } + + fn receive(&mut self, deadline: Instant) -> Result { + let response = receive_until(&self.frames, deadline, "response")??; + if response.version() != 1 { + return Err("Unsupported Ruby protocol version".into()); + } + Ok(response) + } + + fn shutdown(&mut self) { + self.requests.take(); + if let Some(mut child) = self.child.take() { + // Descendants can inherit pipes; kill the host group before joining IO threads. + #[cfg(unix)] + unsafe { + kill(-(child.id() as i32), 9); + } + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +impl Drop for Host { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(unix)] +unsafe extern "C" { + fn kill(pid: i32, signal: i32) -> i32; +} + +fn receive_until(receiver: &Receiver, deadline: Instant, phase: &str) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!("Ruby host {phase} timeout")); + } + receiver + .recv_timeout(remaining) + .map_err(|error| match error { + RecvTimeoutError::Timeout => format!("Ruby host {phase} timeout"), + RecvTimeoutError::Disconnected => { + format!("Ruby host crashed or closed its {phase} channel") + } + }) +} + +struct FrameWriter(Vec); + +impl Write for FrameWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() > MAX_FRAME - self.0.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "protocol frame exceeds 4 MiB", + )); + } + self.0.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +fn read_frame(reader: &mut impl BufRead) -> Result, String> { + let mut frame = Vec::new(); + loop { + let chunk = reader + .fill_buf() + .map_err(|error| format!("Ruby host read failed: {error}"))?; + if chunk.is_empty() { + return Err("Ruby host crashed or closed its output before a complete frame".into()); + } + let newline = chunk.iter().position(|byte| *byte == b'\n'); + let length = newline.unwrap_or(chunk.len()); + if length > MAX_FRAME - frame.len() { + return Err("Ruby protocol frame exceeds 4 MiB".into()); + } + frame.extend_from_slice(&chunk[..length]); + reader.consume(length + usize::from(newline.is_some())); + if newline.is_some() { + return Ok(frame); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); + + struct Fixture(PathBuf); + + impl Fixture { + fn new() -> Self { + let path = env::temp_dir().join(format!( + "vamp-ruby-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn file(&self, name: &str, text: &str) -> PathBuf { + let path = self.0.join(name); + std::fs::write(&path, text).unwrap(); + path + } + + fn plugin(&self, text: &str) -> (RubyPlugin, Feature) { + RubyPlugin::start(&self.file("plugin.rb", text)).unwrap_or_else(|error| { + panic!("Actual Ruby runtime required (VAMP_RUBY or ruby): {error}") + }) + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn context() -> Context { + Context { + text: "α\nhello".into(), + row: 2, + column: 3, + mode: "NORMAL".into(), + path: Some("buffer.txt".into()), + } + } + + #[test] + fn actual_ruby_dsl_context_queued_effects_and_output_isolation() { + let fixture = Fixture::new(); + let (mut plugin, feature) = fixture.plugin(r#" +puts "startup noise" +STDOUT.write "direct startup noise\n" +warn "warning" +Vamp.plugin "greeting", requires: ["text"] +Vamp.command "greet" do |editor, args| + puts "invocation noise" + STDOUT.write "direct invocation noise\n" + raise "bad context" unless editor.text == "α\nhello" && editor.row == 2 && editor.column == 3 && editor.mode == "NORMAL" && editor.path == "buffer.txt" + editor.insert "Hello #{args}!" + editor.replace editor.text + editor.message "Greeting inserted" +end +"#); + assert_eq!(feature.name, "greeting"); + assert_eq!(feature.required_capabilities, ["text"]); + assert_eq!(feature.commands, ["greet"]); + assert!(!feature.core); + for _ in 0..2 { + let effects = plugin.invoke("greet", "friend", &context()).unwrap(); + assert!( + matches!(&effects[..], [Effect::Insert(text), Effect::Replace(replacement), Effect::Message(message)] + if text == "Hello friend!" && replacement == "α\nhello" && message == "Greeting inserted") + ); + } + plugin.disable(); + plugin.disable(); + assert!( + plugin + .invoke("greet", "", &context()) + .unwrap_err() + .contains("disabled") + ); + assert!( + plugin.host.child.is_none() + && plugin.host.reader.is_none() + && plugin.host.writer.is_none() + ); + } + + #[test] + fn config_resolves_relative_paths_and_rejects_invalid_types_and_core() { + let fixture = Fixture::new(); + let config = fixture.file( + "config.rb", + r#" +puts "configuration noise" +Vamp.configure do |config| + config.plugin "plugins/greeting.rb" + config.bind "g", "greet" +end +"#, + ); + let loaded = load_config(&config).unwrap(); + assert_eq!(loaded.plugins, [fixture.0.join("plugins/greeting.rb")]); + assert_eq!(loaded.bindings, [('g', "greet".into())]); + let invalid = fixture.file("invalid.rb", "Vamp.configure { |config| config.plugin 7 }"); + assert!( + load_config(&invalid) + .unwrap_err() + .contains("must be a String") + ); + let invalid = fixture.file("core.rb", "Vamp.plugin 'evil', core: true"); + assert!(RubyPlugin::start(&invalid).is_err()); + let invalid = fixture.file("syntax.rb", "this is not valid ruby ("); + assert!(RubyPlugin::start(&invalid).is_err()); + } + + #[test] + fn crash_hang_callback_failure_and_fresh_host_recovery() { + let fixture = Fixture::new(); + for callback in [ + "exit! 19", + "sleep 10", + "editor.insert 'must not escape'; raise 'callback failed'", + "editor.insert 7", + ] { + let (mut plugin, _) = fixture.plugin(&format!( + "Vamp.plugin 'failure'\nVamp.command('fail') {{ |editor, args| {callback} }}" + )); + let error = plugin.invoke("fail", "", &context()).unwrap_err(); + if callback == "sleep 10" { + assert!(error.contains("timeout"), "{error}"); + } + assert!( + plugin.host.child.is_none() + && plugin.host.reader.is_none() + && plugin.host.writer.is_none() + ); + assert!( + plugin + .invoke("fail", "", &context()) + .unwrap_err() + .contains("disabled") + ); + let (mut recovered, _) = fixture.plugin( + "Vamp.plugin 'recovered'\nVamp.command('ok') { |editor, args| editor.message 'alive' }"); + assert!( + matches!(&recovered.invoke("ok", "", &context()).unwrap()[..], + [Effect::Message(message)] if message == "alive") + ); + } + } + + #[test] + fn startup_and_config_hangs_are_bounded() { + let fixture = Fixture::new(); + let path = fixture.file("hang.rb", "sleep 10"); + assert!(RubyPlugin::start(&path).err().unwrap().contains("timeout")); + assert!(load_config(&path).unwrap_err().contains("timeout")); + } + + #[test] + fn invalid_protocol_version_shape_effect_and_request_id_shutdown() { + let fixture = Fixture::new(); + for mutation in [ + "frame['version'] = 2", + "frame['id'] += 1", + "frame['effects'] = [{'type' => 'delete', 'text' => 'x'}]", + "frame['effects'] = [{'type' => 'insert', 'text' => 7}]", + "frame['unexpected'] = true", + ] { + let source = format!( + r#" +Vamp.plugin 'protocol' +Vamp.command('ok') {{ |editor, args| editor.insert 'x' }} +module JSON + class << self + alias_method :original_generate, :generate + def generate(frame) + if frame['type'] == 'result' + {mutation} + end + original_generate(frame) + end + end +end +"# + ); + let (mut plugin, _) = fixture.plugin(&source); + assert!(plugin.invoke("ok", "", &context()).is_err(), "{mutation}"); + assert!(plugin.host.child.is_none()); + } + } + + #[test] + fn bounded_frames_and_stalled_writes_shutdown() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + "Vamp.plugin 'large'\nVamp.command('large') { |editor, args| editor.insert 'x' * (4 * 1024 * 1024 + 1) }"); + assert!(plugin.invoke("large", "", &context()).is_err()); + assert!(plugin.host.child.is_none()); + + // Stop Ruby reading the request pipe after it sends the manifest. + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'blocked' +Vamp.command('ok') { |editor, args| editor.message 'ok' } +class IO + def gets(*args) + sleep 10 + end +end +"#, + ); + let mut big_context = context(); + big_context.text = "x".repeat(2 * 1024 * 1024); + assert!( + plugin + .invoke("ok", "", &big_context) + .unwrap_err() + .contains("timeout") + ); + assert!(plugin.host.child.is_none()); + + let (mut plugin, _) = fixture.plugin( + "Vamp.plugin 'request'\nVamp.command('ok') { |editor, args| editor.message 'ok' }", + ); + big_context.text = "x".repeat(MAX_FRAME + 1); + assert!( + plugin + .invoke("ok", "", &big_context) + .unwrap_err() + .contains("4 MiB") + ); + assert!(plugin.host.child.is_none()); + + for payload in ["'not json'", "'x' * (4 * 1024 * 1024 + 1)"] { + let source = format!( + r#" +Vamp.plugin 'bad_frame' +Vamp.command('ok') {{ |editor, args| editor.message 'ok' }} +class IO + alias_method :original_write, :write + def write(payload) + if payload.include?('"type":"result"') + original_write({payload}) + else + original_write(payload) + end + end +end +"# + ); + let (mut plugin, _) = fixture.plugin(&source); + assert!(plugin.invoke("ok", "", &context()).is_err()); + assert!(plugin.host.child.is_none()); + } + } + + #[cfg(unix)] + #[test] + fn inherited_pipes_do_not_prevent_drop_or_timeout_cleanup() { + let fixture = Fixture::new(); + let (mut plugin, _) = fixture.plugin( + r#" +Vamp.plugin 'forked' +Vamp.command('hang') do |editor, args| + fork { sleep 10 } + sleep 10 +end +"#, + ); + assert!( + plugin + .invoke("hang", "", &context()) + .unwrap_err() + .contains("timeout") + ); + assert!(plugin.host.reader.is_none() && plugin.host.writer.is_none()); + let (plugin, _) = fixture.plugin("Vamp.plugin 'drop'\nfork { sleep 10 }"); + drop(plugin); + } +} diff --git a/src/ruby_host.rb b/src/ruby_host.rb new file mode 100644 index 0000000..cfb9ee8 --- /dev/null +++ b/src/ruby_host.rb @@ -0,0 +1,188 @@ +require "json" + +# Keep protocol IO separate even from explicit STDOUT.write and ordinary puts. +protocol_out = STDOUT.dup +protocol_out.binmode +protocol_out.sync = true +protocol_in = STDIN.dup +protocol_in.binmode +STDOUT.reopen(STDERR) +$stdout = STDERR + +module Vamp + MAX_FRAME = 4 * 1024 * 1024 + + def self.string!(value, label) + raise TypeError, "#{label} must be a String" unless value.is_a?(String) + raise ArgumentError, "#{label} must be valid UTF-8" unless value.dup.force_encoding(Encoding::UTF_8).valid_encoding? + value.dup.force_encoding(Encoding::UTF_8) + end + + class Config + def initialize + @plugins = [] + @bindings = [] + end + + def plugin(path) + path = Vamp.string!(path, "plugin path") + raise ArgumentError, "plugin path must not be empty" if path.empty? + @plugins << path + nil + end + + def bind(key, command) + key = Vamp.string!(key, "binding key") + command = Vamp.string!(command, "binding command") + raise ArgumentError, "binding key must be one character" unless key.length == 1 + raise ArgumentError, "binding command must not be empty" if command.empty? + @bindings << { "key" => key, "command" => command } + nil + end + + def response + { "version" => 1, "type" => "config", "plugins" => @plugins, "bindings" => @bindings } + end + end + + class Editor + attr_reader :text, :row, :column, :mode, :path, :effects + + def initialize(context) + unless context.is_a?(Hash) && context.keys.sort == %w[column mode path row text] + raise ArgumentError, "invalid editor context" + end + @text = Vamp.string!(context["text"], "context text").freeze + @mode = Vamp.string!(context["mode"], "context mode").freeze + @path = context["path"].nil? ? nil : Vamp.string!(context["path"], "context path").freeze + @row = context["row"] + @column = context["column"] + unless @row.is_a?(Integer) && @row > 0 && @column.is_a?(Integer) && @column > 0 + raise ArgumentError, "context row and column must be positive integers" + end + @effects = [] + @effect_bytes = 0 + end + + def insert(text) + queue("insert", text) + end + + def replace(text) + queue("replace", text) + end + + def message(text) + queue("message", text) + end + + private + + def queue(type, text) + text = Vamp.string!(text, "effect text") + @effect_bytes += text.bytesize + raise ArgumentError, "effects exceed 4 MiB" if @effect_bytes > MAX_FRAME + @effects << { "type" => type, "text" => text } + nil + end + end + + def self.setup(mode) + @mode = mode + @config = Config.new + @name = nil + @requires = [] + @commands = {} + end + + def self.configure + raise ArgumentError, "configure is only available in config files" unless @mode == "config" + raise ArgumentError, "configure requires a block" unless block_given? + yield @config + end + + def self.plugin(name, requires: []) + raise ArgumentError, "plugin is only available in plugin files" unless @mode == "plugin" + raise ArgumentError, "plugin already declared" unless @name.nil? + name = string!(name, "plugin name") + raise ArgumentError, "plugin name must not be empty" if name.empty? + raise TypeError, "requires must be an Array of Strings" unless requires.is_a?(Array) + @requires = requires.map { |value| string!(value, "capability") } + raise ArgumentError, "capability must not be empty" if @requires.any?(&:empty?) + @name = name + nil + end + + def self.command(name, &callback) + raise ArgumentError, "declare a plugin before its commands" unless @mode == "plugin" && @name + name = string!(name, "command name") + raise ArgumentError, "command name must not be empty" if name.empty? + raise ArgumentError, "command requires a block" unless callback + raise ArgumentError, "duplicate command #{name}" if @commands.key?(name) + @commands[name] = callback + nil + end + + def self.startup + return @config.response if @mode == "config" + raise ArgumentError, "plugin file must declare Vamp.plugin" unless @name + { "version" => 1, "type" => "manifest", "name" => @name, + "requires" => @requires, "commands" => @commands.keys } + end + + def self.invoke(request) + unless request.is_a?(Hash) && request.keys.sort == %w[args command context id type version] && + request["version"].is_a?(Integer) && request["version"] == 1 && request["type"] == "invoke" && + request["id"].is_a?(Integer) && request["id"] > 0 + raise ArgumentError, "invalid invocation frame" + end + command = string!(request["command"], "command") + args = string!(request["args"], "args") + callback = @commands[command] + raise ArgumentError, "unknown command #{command}" unless callback + editor = Editor.new(request["context"]) + callback.call(editor, args) + { "version" => 1, "type" => "result", "id" => request["id"], "effects" => editor.effects } + end +end + +send_frame = lambda do |frame| + payload = JSON.generate(frame) + raise ArgumentError, "protocol frame exceeds 4 MiB" if payload.bytesize > Vamp::MAX_FRAME + protocol_out.write(payload) + protocol_out.write("\n") +end + +request_id = nil +begin + mode, path = ARGV + raise ArgumentError, "expected config or plugin mode and a path" unless %w[config plugin].include?(mode) && path + Vamp.setup(mode) + load path + send_frame.call(Vamp.startup) + if mode == "plugin" + loop do + request_id = nil + line = protocol_in.gets(Vamp::MAX_FRAME + 2) + break if line.nil? + unless line.end_with?("\n") && line.bytesize <= Vamp::MAX_FRAME + 1 + raise ArgumentError, "invalid or oversized protocol frame" + end + request = JSON.parse(line) + request_id = request["id"] if request.is_a?(Hash) && request["id"].is_a?(Integer) && request["id"] > 0 + send_frame.call(Vamp.invoke(request)) + end + end +rescue Exception => error + # No callback effects are emitted when loading or invoking raises. + message = "#{error.class}: #{error.message}".encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + message = message.each_char.take(2048).join + frame = { "version" => 1, "type" => "error", "message" => message } + frame["id"] = request_id unless request_id.nil? + begin + send_frame.call(frame) + rescue Exception + # Rust observes EOF if even the error cannot be framed. + end + exit 1 +end diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..7cdd5ec --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,238 @@ +use std::io::{self, Stdout}; + +use crossterm::{ + cursor::Show, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, + }, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::Rect, + style::{Color, Style}, + text::Line, + widgets::Paragraph, +}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use crate::editor::{Editor, Effect, Input, Invocation, Mode}; + +pub const CAPABILITIES: &[&str] = &["text", "styled_text"]; +const TAB_WIDTH: usize = 4; + +struct TerminalSession; + +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!( + io::stdout(), + DisableBracketedPaste, + LeaveAlternateScreen, + Show + ); +} + +impl TerminalSession { + fn enter() -> io::Result { + enable_raw_mode()?; + let session = Self; + execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste)?; + Ok(session) + } +} + +impl Drop for TerminalSession { + fn drop(&mut self) { + restore_terminal(); + } +} + +/// The frontend only translates input and renders backend state. +/// Plugin dispatch is supplied by the executable, not the terminal adapter. +pub fn run(editor: &mut Editor, mut invoke: impl FnMut(&mut Editor, Invocation)) -> io::Result<()> { + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + restore_terminal(); + previous_hook(info); + })); + let _session = TerminalSession::enter()?; + let mut terminal: Terminal> = + Terminal::new(CrosstermBackend::new(io::stdout()))?; + let (mut top, mut left) = (0, 0); + while !editor.should_quit() { + terminal.draw(|frame| draw(frame, editor, &mut top, &mut left))?; + match event::read()? { + Event::Key(key) if key.kind != KeyEventKind::Release => { + if let Some(input) = translate(key) + && let Some(invocation) = editor.handle(input) + { + invoke(editor, invocation); + } + } + // Bracketed paste must never execute pasted Ex commands. + Event::Paste(text) if editor.mode() == Mode::Insert => { + if let Err(error) = editor.apply(vec![Effect::Insert(text)]) { + editor.set_message(error); + } + } + _ => {} + } + } + Ok(()) +} + +fn translate(key: KeyEvent) -> Option { + if key + .modifiers + .intersects(KeyModifiers::ALT | KeyModifiers::SUPER) + { + return None; + } + Some(match key.code { + KeyCode::Char(c) if key.modifiers.contains(KeyModifiers::CONTROL) => Input::Ctrl(c), + KeyCode::Char(c) => Input::Char(c), + KeyCode::Esc => Input::Escape, + KeyCode::Enter => Input::Enter, + KeyCode::Backspace => Input::Backspace, + KeyCode::Delete => Input::Delete, + KeyCode::Left => Input::Left, + KeyCode::Right => Input::Right, + KeyCode::Up => Input::Up, + KeyCode::Down => Input::Down, + KeyCode::Home => Input::Home, + KeyCode::End => Input::End, + KeyCode::Tab => Input::Char('\t'), + _ => return None, + }) +} + +fn display_column(line: &str, grapheme_column: usize) -> usize { + line.graphemes(true) + .take(grapheme_column) + .fold(0, |column, grapheme| { + column + + if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + } + }) +} + +fn visible_line(line: &str, left: usize, width: usize) -> String { + let mut visible = String::new(); + let mut column = 0; + let right = left.saturating_add(width); + for grapheme in line.graphemes(true) { + let cells = if grapheme == "\t" { + TAB_WIDTH - column % TAB_WIDTH + } else { + UnicodeWidthStr::width(grapheme) + }; + let end = column + cells; + if column >= right { + break; + } + if end > left || (cells == 0 && column >= left) { + if grapheme == "\t" || column < left || end > right { + for _ in column.max(left)..end.min(right) { + visible.push(' '); + } + } else { + visible.push_str(grapheme); + } + } + column = end; + } + visible +} + +fn draw(frame: &mut Frame, editor: &Editor, top: &mut usize, left: &mut usize) { + let area = frame.area(); + if area.width == 0 || area.height < 3 { + return; + } + let height = usize::from(area.height - 2); + let width = usize::from(area.width); + let cursor = editor.cursor(); + if cursor.row < *top { + *top = cursor.row; + } else if cursor.row >= top.saturating_add(height) { + *top = cursor.row + 1 - height; + } + let column = display_column(&editor.lines()[cursor.row], cursor.column); + if column < *left { + *left = column; + } else if column >= left.saturating_add(width) { + *left = column + 1 - width; + } + let lines: Vec = editor + .lines() + .iter() + .skip(*top) + .take(height) + .map(|line| Line::from(visible_line(line, *left, width))) + .collect(); + frame.render_widget( + Paragraph::new(lines), + Rect::new(area.x, area.y, area.width, area.height - 2), + ); + let path = editor + .path() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| "[No Name]".into()); + let path: String = path + .chars() + .map(|c| if c.is_control() { '�' } else { c }) + .collect(); + let status = format!( + " {} {}{} {}:{}", + editor.mode(), + path, + if editor.dirty() { " [+]" } else { "" }, + cursor.row + 1, + cursor.column + 1 + ); + frame.render_widget( + Paragraph::new(status).style(Style::default().fg(Color::Black).bg(Color::Cyan)), + Rect::new(area.x, area.y + area.height - 2, area.width, 1), + ); + let bottom = area.y + area.height - 1; + if editor.mode() == Mode::Command { + let command = format!(":{}", editor.command_line()); + let command_width = UnicodeWidthStr::width(command.as_str()); + let offset = command_width.saturating_sub(width - 1); + frame.render_widget( + Paragraph::new(visible_line(&command, offset, width)), + Rect::new(area.x, bottom, area.width, 1), + ); + frame.set_cursor_position((area.x + (command_width - offset) as u16, bottom)); + } else { + frame.render_widget( + Paragraph::new(editor.message()), + Rect::new(area.x, bottom, area.width, 1), + ); + frame.set_cursor_position(( + area.x + (column - *left) as u16, + area.y + (cursor.row - *top) as u16, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tabs_and_wide_graphemes_scroll_in_terminal_cells() { + let text = "a\t界e\u{301}z"; + assert_eq!(display_column(text, 4), 7); + assert_eq!(visible_line(text, 2, 5), " 界e\u{301}"); + assert_eq!(visible_line(text, 5, 2), " e\u{301}"); + } +}