feat: implement modular modal editor and isolated Ruby plugins
This commit is contained in:
Generated
+854
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,4 @@
|
||||
Vamp.configure do |config|
|
||||
config.plugin "plugins/greeting.rb"
|
||||
config.bind "g", "greet"
|
||||
end
|
||||
@@ -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
|
||||
+1189
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod editor;
|
||||
pub mod ruby;
|
||||
pub mod tui;
|
||||
+131
-2
@@ -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<PathBuf>,
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn options() -> Result<Option<Options>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
+727
@@ -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<PathBuf>,
|
||||
pub bindings: Vec<(char, String)>,
|
||||
}
|
||||
|
||||
/// Relative plugin paths are resolved against the configuration file's directory.
|
||||
pub fn load_config(path: &Path) -> Result<Config, String> {
|
||||
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<Vec<Effect>, 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<Vec<Effect>, 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<PathBuf, String> {
|
||||
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<String>,
|
||||
commands: Vec<String>,
|
||||
},
|
||||
#[serde(rename = "config")]
|
||||
Config {
|
||||
version: u8,
|
||||
plugins: Vec<String>,
|
||||
bindings: Vec<Binding>,
|
||||
},
|
||||
#[serde(rename = "result")]
|
||||
Result {
|
||||
version: u8,
|
||||
id: u64,
|
||||
effects: Vec<WireEffect>,
|
||||
},
|
||||
#[serde(rename = "error")]
|
||||
Error {
|
||||
version: u8,
|
||||
#[serde(default, deserialize_with = "optional_request_id")]
|
||||
id: Option<u64>,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn optional_request_id<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<u64>, 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<Child>,
|
||||
requests: Option<SyncSender<Vec<u8>>>,
|
||||
writes: Receiver<Result<(), String>>,
|
||||
frames: Receiver<Result<Response, String>>,
|
||||
reader: Option<JoinHandle<()>>,
|
||||
writer: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn spawn(mode: &str, path: &Path) -> Result<Self, String> {
|
||||
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::<Vec<u8>>(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::<Response>(&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<Response, String> {
|
||||
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<T>(receiver: &Receiver<T>, deadline: Instant, phase: &str) -> Result<T, String> {
|
||||
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<u8>);
|
||||
|
||||
impl Write for FrameWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
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<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+238
@@ -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<Self> {
|
||||
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<CrosstermBackend<Stdout>> =
|
||||
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<Input> {
|
||||
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<Line> = 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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user