diff --git a/tools/syrup/.gitignore b/tools/syrup/.gitignore new file mode 100644 index 000000000..e7ee294cc --- /dev/null +++ b/tools/syrup/.gitignore @@ -0,0 +1,19 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# rustc will dump stack traces when hitting an internal compiler error to PWD +rustc-ice-*.txt + +!.vscode diff --git a/tools/syrup/Cargo.lock b/tools/syrup/Cargo.lock new file mode 100644 index 000000000..4767e1910 --- /dev/null +++ b/tools/syrup/Cargo.lock @@ -0,0 +1,967 @@ +# 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 = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "console" +version = "1.0.0" +dependencies = [ + "crossterm", + "ratatui", + "serde_json", + "warduino-dap", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[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", + "serde", + "serde_core", +] + +[[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 = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[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.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[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 = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +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.3", +] + +[[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.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "warduino-dap" +version = "1.0.0" +dependencies = [ + "serde", + "serde_json", + "wasmparser", + "wast", + "wdebug", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-encoder" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e974fe6821a8cf64575d51ea2194e2c8f77e7b66e9afe7419ce8a97f9ee0d251" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.258.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" +dependencies = [ + "bitflags", + "hashbrown 0.17.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wast" +version = "258.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97f7defc7ecca8b19ac7f824598eadd0c53985ee00c74060d65051e9da5b58a1" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder", +] + +[[package]] +name = "wdebug" +version = "1.0.0" +dependencies = [ + "prost", +] + +[[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/tools/syrup/Cargo.toml b/tools/syrup/Cargo.toml new file mode 100644 index 000000000..0da979ad9 --- /dev/null +++ b/tools/syrup/Cargo.toml @@ -0,0 +1,52 @@ +[workspace] +resolver = "3" +members = [ + "crates/wdebug", + "crates/dap", + "crates/console", +] +default-members = [ + "crates/dap", +] + +[workspace.package] +version = "1.0.0" +edition = "2024" +license = "MIT" + +[workspace.dependencies] +# WARDuino protobuf ABI +prost = "0.14.4" +prost-build = "0.14.4" + +# DAP wire format +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Source-aware DAP image construction. +wast = "258" +wasmparser = "0.258" + +# Console +ratatui = { + version = "0.30.2", + default-features = false, + features = ["crossterm"], +} +crossterm = { + version = "0.29.0", + default-features = false, + features = [ + "events", + "bracketed-paste", + "windows", + ], +} + +[profile.release] +opt-level = "s" +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = "symbols" +incremental = false diff --git a/tools/syrup/assets/demo.gif b/tools/syrup/assets/demo.gif new file mode 100644 index 000000000..33f33a6e3 Binary files /dev/null and b/tools/syrup/assets/demo.gif differ diff --git a/tools/syrup/assets/demo.tape b/tools/syrup/assets/demo.tape new file mode 100644 index 000000000..b77c6d3c3 --- /dev/null +++ b/tools/syrup/assets/demo.tape @@ -0,0 +1,52 @@ +Output assets/demo.gif + +# Set up a 1200x600 terminal with 28px font. +Set FontSize 21 +Set Width 2100 +Set Height 900 +Set Margin 0 +Set BorderRadius 20 +Set Framerate 60 +Set Theme "Catppuccin Mocha" + +Type@50ms "syrup --device localhost:8192" +Sleep 50ms +Enter + +Sleep 1s + +Type@300ms "Ste" +Tab@800ms 2 +Enter + +Sleep 5ms + +Type@300ms "ste" +Tab@800ms 1 +Enter + +Sleep 5s + +Type@300ms "cont" +Tab@150ms +Enter + +Sleep 6s + +Type@300ms "pau" +Tab@150ms +Enter + +Sleep 2s + +Shift+Tab +Enter + +Up@3s 3 + +Sleep 2s + +Up@200ms 17 + +Sleep 2s + diff --git a/tools/syrup/crates/console/Cargo.toml b/tools/syrup/crates/console/Cargo.toml new file mode 100644 index 000000000..e69ffb55f --- /dev/null +++ b/tools/syrup/crates/console/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "console" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false + +[[bin]] +name = "syrup" +path = "src/main.rs" + +[dependencies] +dap = { + path = "../dap", + package = "warduino-dap", + default-features = false, +} + +ratatui.workspace = true +crossterm.workspace = true +serde_json.workspace = true diff --git a/tools/syrup/crates/console/README.md b/tools/syrup/crates/console/README.md new file mode 100644 index 000000000..cc070b0c7 --- /dev/null +++ b/tools/syrup/crates/console/README.md @@ -0,0 +1,14 @@ +# Console + +Run the live console with: + +```text +cargo run -p console -- --device localhost:8100 +``` + +`continue` and `pause` are sent through the in-process DAP adapter. The VM +state changes only when the target emits the corresponding event. The Session VM frame column shows every complete outgoing WARDuino frame (discriminator, encoded length, and protobuf payload). Ctrl+C sends +`disconnect`; it detaches while leaving the WARDuino VM running. + + +![](assets/mock.png) diff --git a/tools/syrup/crates/console/assets/mock.png b/tools/syrup/crates/console/assets/mock.png new file mode 100644 index 000000000..4dd1a345f Binary files /dev/null and b/tools/syrup/crates/console/assets/mock.png differ diff --git a/tools/syrup/crates/console/src/app.rs b/tools/syrup/crates/console/src/app.rs new file mode 100644 index 000000000..b59b7a9f5 --- /dev/null +++ b/tools/syrup/crates/console/src/app.rs @@ -0,0 +1,781 @@ +use crate::messages::{COMMANDS, CommandIntent, Completion, Direction}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::widgets::TableState; +use serde_json::Value; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VmState { + Connected, + Paused, + Running, + Disconnected, +} + +impl VmState { + pub const fn label(self) -> &'static str { + match self { + Self::Connected => "CONNECTED", + Self::Paused => "PAUSED", + Self::Running => "RUNNING", + Self::Disconnected => "DISCONNECTED", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Focus { + Command, + Session, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EntryType { + DapRequest, + DapResponse, + DapEvent, + DBGCommand, + VmEvent, +} + +impl EntryType { + pub const fn label(self) -> &'static str { + match self { + Self::DapRequest => "DAP request", + Self::DapResponse => "DAP response", + Self::DapEvent => "DAP event", + Self::DBGCommand => "WARDuino msg", + Self::VmEvent => "VM event", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionEntry { + pub sequence: u64, + pub direction: Direction, + pub event: String, + pub entry_type: EntryType, + pub payload: EntryPayload, + pub stop_context: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub enum EntryPayload { + #[default] + None, + DapRequest { + arguments: Value, + }, + DapResponse { + success: bool, + body: Value, + message: Option, + }, + DapEvent { + body: Value, + }, + VmFrame { + direction: Direction, + bytes: Vec, + fields: Value, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Default)] +pub struct StopContext { + pub state: Option, + pub pc: Option, + pub frames: Vec, + pub locals: Vec, + pub detail: Option, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StackFrame { + pub name: String, + pub address: String, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NamedValue { + pub name: String, + pub value: String, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EntryDetails { + pub heading: String, + pub metadata: String, + pub sections: Vec, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DetailSection { + pub title: String, + pub rows: Vec, +} +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DetailRow { + pub label: String, + pub value: String, + pub style: DetailStyle, +} +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DetailStyle { + Normal, + Muted, + Success, + Warning, + Error, + Address, +} + +pub fn details_for(entry: &SessionEntry) -> EntryDetails { + let mut sections = Vec::new(); + let rows = |value: &Value| -> Vec { + value + .as_object() + .map(|object| { + object + .iter() + .map(|(key, value)| DetailRow { + label: key.clone(), + value: value_text(value), + style: DetailStyle::Normal, + }) + .collect() + }) + .unwrap_or_default() + }; + match &entry.payload { + EntryPayload::DapRequest { arguments } => { + let rows = rows(arguments); + if !rows.is_empty() { + sections.push(DetailSection { + title: "Arguments".into(), + rows, + }); + } + } + EntryPayload::DapResponse { + success, + body, + message, + } => { + let mut response = vec![DetailRow { + label: "success".into(), + value: success.to_string(), + style: if *success { + DetailStyle::Success + } else { + DetailStyle::Error + }, + }]; + response.extend(rows(body)); + sections.push(DetailSection { + title: "Response".into(), + rows: response, + }); + if !*success { + if let Some(message) = message { + sections.push(DetailSection { + title: "Error".into(), + rows: vec![DetailRow { + label: String::new(), + value: message.clone(), + style: DetailStyle::Error, + }], + }); + } + } + } + EntryPayload::DapEvent { body } => { + let rows = if entry.event == "stopped" { + Vec::new() + } else { + rows(body) + }; + if !rows.is_empty() { + sections.push(DetailSection { + title: "Event".into(), + rows, + }); + } + } + EntryPayload::VmFrame { + direction, + bytes, + fields, + } => { + let rows = rows(fields); + if !rows.is_empty() { + sections.push(DetailSection { + title: "Fields".into(), + rows, + }); + } + sections.push(DetailSection { + title: "Wire".into(), + rows: vec![ + DetailRow { + label: "direction".into(), + value: direction.symbol().into(), + style: DetailStyle::Muted, + }, + DetailRow { + label: "bytes".into(), + value: bytes + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(" "), + style: DetailStyle::Address, + }, + ], + }); + } + EntryPayload::None => {} + } + if let Some(context) = &entry.stop_context { + let mut execution = Vec::new(); + if let Some(reason) = event_field(&entry.payload, "reason") { + execution.push(DetailRow { + label: "reason".into(), + value: reason, + style: DetailStyle::Normal, + }); + } + if let Some(state) = &context.state { + execution.push(DetailRow { + label: "state".into(), + value: state.clone(), + style: DetailStyle::Warning, + }); + } + if let Some(pc) = &context.pc { + execution.push(DetailRow { + label: "pc".into(), + value: pc.clone(), + style: DetailStyle::Address, + }); + } + if let Some(thread) = event_field(&entry.payload, "threadId") { + execution.push(DetailRow { + label: "thread".into(), + value: thread, + style: DetailStyle::Normal, + }); + } + if !execution.is_empty() { + sections.push(DetailSection { + title: "Execution".into(), + rows: execution, + }); + } + if !context.frames.is_empty() { + sections.push(DetailSection { + title: "Stack".into(), + rows: context + .frames + .iter() + .map(|frame| DetailRow { + label: frame.name.clone(), + value: frame.address.clone(), + style: DetailStyle::Address, + }) + .collect(), + }); + } + if !context.locals.is_empty() { + sections.push(DetailSection { + title: "Locals".into(), + rows: context + .locals + .iter() + .map(|local| DetailRow { + label: local.name.clone(), + value: local.value.clone(), + style: DetailStyle::Normal, + }) + .collect(), + }); + } + if let Some(detail) = &context.detail { + sections.push(DetailSection { + title: "Inspection".into(), + rows: vec![DetailRow { + label: String::new(), + value: detail.clone(), + style: DetailStyle::Error, + }], + }); + } + } + if sections.is_empty() { + sections.push(DetailSection { + title: String::new(), + rows: vec![DetailRow { + label: String::new(), + value: "No additional details".into(), + style: DetailStyle::Muted, + }], + }); + } + EntryDetails { + heading: entry.event.clone(), + metadata: format!("{} · #{}", entry.entry_type.label(), entry.sequence), + sections, + } +} +fn value_text(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Null => "null".into(), + _ => value.to_string(), + } +} +fn event_field(payload: &EntryPayload, name: &str) -> Option { + match payload { + EntryPayload::DapEvent { body } => body.get(name).map(value_text), + _ => None, + } +} + +#[derive(Debug)] +pub struct App { + pub connection: String, + pub vm_name: String, + pub vm_state: VmState, + pub timeline: Vec, + pub focus: Focus, + pub session_table: TableState, + pub details_scroll: u16, + pub follow_latest: bool, + pub prompt: String, + /// A character offset, never a byte offset. + pub cursor: usize, + pub completions: Vec, + pub completion_index: usize, + pub notice: Option, + pub help_visible: bool, + history: Vec, + history_index: Option, + completions_dismissed: bool, +} + +impl App { + #[allow(dead_code)] + pub fn live(connection: String) -> Self { + Self { + connection, + vm_name: "WARDuino".into(), + vm_state: VmState::Connected, + timeline: Vec::new(), + focus: Focus::Command, + session_table: TableState::default(), + details_scroll: 0, + follow_latest: true, + prompt: String::new(), + cursor: 0, + completions: Vec::new(), + completion_index: 0, + notice: None, + help_visible: false, + history: Vec::new(), + history_index: None, + completions_dismissed: false, + } + } + #[allow(dead_code)] + pub fn sample() -> Self { + let timeline = vec![ + sample_entry( + 1042, + Direction::Outgoing, + "continue", + EntryType::DBGCommand, + EntryPayload::VmFrame { + direction: Direction::Outgoing, + bytes: vec![0, 0], + fields: serde_json::json!({}), + }, + ), + sample_entry( + 1043, + Direction::Incoming, + "continued", + EntryType::VmEvent, + EntryPayload::DapEvent { + body: serde_json::json!({"state":"running"}), + }, + ), + sample_entry( + 1044, + Direction::Incoming, + "stopped", + EntryType::DapEvent, + EntryPayload::DapEvent { + body: serde_json::json!({"reason":"pause", "threadId":1}), + }, + ), + ]; + let mut session_table = TableState::default(); + session_table.select(Some(timeline.len() - 1)); + Self { + connection: "localhost:8100".into(), + vm_name: "WARDuino".into(), + vm_state: VmState::Paused, + timeline, + focus: Focus::Command, + session_table, + details_scroll: 0, + follow_latest: true, + prompt: String::new(), + cursor: 0, + completions: Vec::new(), + completion_index: 0, + notice: None, + help_visible: false, + history: Vec::new(), + history_index: None, + completions_dismissed: false, + } + } + + pub fn selected_entry(&self) -> Option<&SessionEntry> { + self.session_table + .selected() + .and_then(|index| self.timeline.get(index)) + } + + #[allow(dead_code)] + pub fn newer_count(&self) -> usize { + if self.follow_latest { + 0 + } else { + self.timeline + .len() + .saturating_sub(self.session_table.selected().unwrap_or(0).saturating_add(1)) + } + } + + pub fn active_completion(&self) -> Option { + self.completions.get(self.completion_index).copied() + } + + pub fn feedback(&self) -> Option<&str> { + self.notice.as_deref().or_else(|| self.usage_hint()) + } + + pub fn usage_hint(&self) -> Option<&str> { + if let Some(completion) = self.active_completion() { + return Some(completion.usage); + } + let command = self.prompt.split_whitespace().next()?; + COMMANDS + .iter() + .find(|candidate| candidate.command.eq_ignore_ascii_case(command)) + .map(|candidate| candidate.usage) + } + + pub fn append(&mut self, entry: SessionEntry) { + self.timeline.push(entry); + if self.follow_latest { + self.session_table.select(Some(self.timeline.len() - 1)); + } + } + + pub fn select_delta(&mut self, delta: isize) { + if self.timeline.is_empty() { + return; + } + let last = self.timeline.len() - 1; + let selected = self + .session_table + .selected() + .unwrap_or(last) + .saturating_add_signed(delta) + .min(last); + self.session_table.select(Some(selected)); + self.details_scroll = 0; + self.follow_latest = selected == last; + } + + pub fn page(&mut self, direction: isize, viewport_rows: u16) { + self.select_delta(direction * isize::try_from(viewport_rows.max(1)).unwrap_or(1)); + } + + pub fn select_latest(&mut self) { + if let Some(last) = self.timeline.len().checked_sub(1) { + self.session_table.select(Some(last)); + self.details_scroll = 0; + self.follow_latest = true; + } + } + + pub fn handle_key(&mut self, key: KeyEvent, viewport_rows: u16) -> Option { + if !self.help_visible && key.code == KeyCode::Char('?') { + self.help_visible = true; + return None; + } + if self.help_visible { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('?')) { + self.help_visible = false; + } + return None; + } + if self.focus == Focus::Command + && (key.code == KeyCode::BackTab + || (key.code == KeyCode::Tab && key.modifiers.contains(KeyModifiers::SHIFT))) + { + self.focus = Focus::Session; + return None; + } + if self.focus == Focus::Session { + match key.code { + KeyCode::Tab | KeyCode::BackTab => self.focus = Focus::Command, + KeyCode::Up => self.select_delta(-1), + KeyCode::Down => self.select_delta(1), + KeyCode::PageUp => self.page(-1, viewport_rows), + KeyCode::PageDown => self.page(1, viewport_rows), + KeyCode::Home => { + if !self.timeline.is_empty() { + self.session_table.select(Some(0)); + self.details_scroll = 0; + self.follow_latest = false; + } + } + KeyCode::End => self.select_latest(), + KeyCode::Char(character) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.focus = Focus::Command; + self.insert(character); + } + _ => {} + } + return None; + } + if key.modifiers.contains(KeyModifiers::SHIFT) { + match key.code { + KeyCode::Up => { + self.select_delta(-1); + return None; + } + KeyCode::Down => { + self.select_delta(1); + return None; + } + _ => {} + } + } + match key.code { + KeyCode::Up => self.previous_choice(), + KeyCode::Down => self.next_choice(), + KeyCode::Tab => self.accept_completion(), + KeyCode::Enter => return self.submit(), + KeyCode::Esc => self.escape(), + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.char_len()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.char_len(), + KeyCode::Backspace => self.backspace(), + KeyCode::Delete => self.delete(), + KeyCode::Char(character) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.insert(character) + } + _ => {} + } + None + } + + fn previous_choice(&mut self) { + if !self.completions.is_empty() { + self.completion_index = self.completion_index.saturating_sub(1); + } else { + self.history_previous(); + } + } + + fn next_choice(&mut self) { + if !self.completions.is_empty() { + self.completion_index = (self.completion_index + 1) % self.completions.len(); + } else { + self.history_next(); + } + } + + fn accept_completion(&mut self) { + let Some(completion) = self.active_completion() else { + return; + }; + if self.prompt == completion.command { + self.next_choice(); + } + let completion = self + .active_completion() + .expect("completion remains selected"); + self.prompt = completion.command.into(); + self.cursor = self.char_len(); + self.notice = None; + self.history_index = None; + self.completions_dismissed = false; + } + + pub fn insert(&mut self, character: char) { + let byte = self.byte_index(self.cursor); + self.prompt.insert(byte, character); + self.cursor += 1; + self.after_edit(); + } + + fn backspace(&mut self) { + if self.cursor == 0 { + return; + } + let end = self.byte_index(self.cursor); + let start = self.byte_index(self.cursor - 1); + self.prompt.replace_range(start..end, ""); + self.cursor -= 1; + self.after_edit(); + } + + fn delete(&mut self) { + if self.cursor == self.char_len() { + return; + } + let start = self.byte_index(self.cursor); + let end = self.byte_index(self.cursor + 1); + self.prompt.replace_range(start..end, ""); + self.after_edit(); + } + + fn after_edit(&mut self) { + self.notice = None; + self.history_index = None; + self.completions_dismissed = false; + self.refresh_completions(); + } + + fn refresh_completions(&mut self) { + if self.completions_dismissed { + self.completions.clear(); + return; + } + let token = self.prompt.split_whitespace().next().unwrap_or(""); + if token.is_empty() || self.prompt.chars().any(char::is_whitespace) { + self.completions.clear(); + } else { + self.completions = COMMANDS + .iter() + .copied() + .filter(|candidate| candidate.command.starts_with(&token.to_ascii_lowercase())) + .collect(); + } + self.completion_index = self + .completion_index + .min(self.completions.len().saturating_sub(1)); + } + + fn submit(&mut self) -> Option { + let typed = self.prompt.trim(); + let completion = COMMANDS + .iter() + .find(|candidate| candidate.command == typed) + .copied(); + let Some(completion) = completion else { + self.notice = Some(format!( + "Unknown command — use {}", + COMMANDS + .iter() + .map(|candidate| candidate.command) + .collect::>() + .join(", ") + )); + self.completions.clear(); + return None; + }; + let command = completion.command; + if self + .history + .last() + .is_none_or(|previous| previous != command) + { + self.history.push(command.into()); + } + self.history_index = None; + self.prompt.clear(); + self.cursor = 0; + self.completions.clear(); + self.completions_dismissed = false; + self.notice = None; + Some(completion.intent) + } + + fn escape(&mut self) { + if !self.completions.is_empty() { + self.completions.clear(); + self.completions_dismissed = true; + } else if !self.prompt.is_empty() || self.notice.is_some() { + self.prompt.clear(); + self.cursor = 0; + self.notice = None; + self.history_index = None; + } else { + self.focus = Focus::Session; + } + } + + fn history_previous(&mut self) { + let Some(last) = self.history.len().checked_sub(1) else { + return; + }; + let next = self + .history_index + .map_or(last, |index| index.saturating_sub(1)); + self.load_history(next); + } + + fn history_next(&mut self) { + let Some(index) = self.history_index else { + return; + }; + if index + 1 >= self.history.len() { + self.history_index = None; + self.prompt.clear(); + self.cursor = 0; + } else { + self.load_history(index + 1); + } + } + + fn load_history(&mut self, index: usize) { + self.prompt = self.history[index].clone(); + self.cursor = self.char_len(); + self.history_index = Some(index); + self.completions.clear(); + } + + fn char_len(&self) -> usize { + self.prompt.chars().count() + } + + fn byte_index(&self, character_index: usize) -> usize { + self.prompt + .char_indices() + .nth(character_index) + .map_or(self.prompt.len(), |(index, _)| index) + } +} + +fn sample_entry( + sequence: u64, + direction: Direction, + event: &str, + entry_type: EntryType, + payload: EntryPayload, +) -> SessionEntry { + SessionEntry { + sequence, + direction, + event: event.into(), + entry_type, + payload, + stop_context: None, + } +} diff --git a/tools/syrup/crates/console/src/completion.rs b/tools/syrup/crates/console/src/completion.rs new file mode 100644 index 000000000..7dc0861c0 --- /dev/null +++ b/tools/syrup/crates/console/src/completion.rs @@ -0,0 +1,157 @@ +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; + +use crate::app::App; + +use super::{ACCENT, MUTED, TEXT}; + +const GAP: usize = 3; +const ELLIPSIS: &str = "..."; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VisibleItem { + Ellipsis, + Completion(usize), + TruncatedCompletion(usize, usize), +} + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + if app.completions.is_empty() || area.width == 0 { + return; + } + + let labels = app + .completions + .iter() + .map(|completion| completion.command) + .collect::>(); + let items = visible_items(&labels, app.completion_index, area.width as usize); + let mut spans = Vec::new(); + for (position, item) in items.into_iter().enumerate() { + if position > 0 { + spans.push(Span::raw(" ".repeat(GAP))); + } + match item { + VisibleItem::Ellipsis => { + spans.push(Span::styled(ELLIPSIS, Style::default().fg(MUTED))); + } + VisibleItem::Completion(index) | VisibleItem::TruncatedCompletion(index, _) => { + let active = index == app.completion_index; + let style = Style::default() + .fg(if active { ACCENT } else { TEXT }) + .add_modifier(if active { + Modifier::BOLD + } else { + Modifier::empty() + }); + let label = match item { + VisibleItem::TruncatedCompletion(_, width) => { + labels[index].chars().take(width).collect::() + } + _ => labels[index].to_owned(), + }; + spans.push(Span::styled(label, style)); + } + } + } + frame.render_widget(Paragraph::new(Line::from(spans)), area); +} + +fn visible_items(labels: &[&str], active: usize, width: usize) -> Vec { + if labels.is_empty() || width == 0 { + return Vec::new(); + } + let active = active.min(labels.len() - 1); + let active_width = labels[active].chars().count(); + if active_width > width { + return vec![VisibleItem::TruncatedCompletion(active, width)]; + } + + let mut start = 0; + while start < active && range_width(labels, start, active) > width { + start += 1; + } + if range_width(labels, start, active) > width { + return vec![VisibleItem::Completion(active)]; + } + + let mut end = active; + while end + 1 < labels.len() && range_width(labels, start, end + 1) <= width { + end += 1; + } + + let mut items = Vec::new(); + if start > 0 { + items.push(VisibleItem::Ellipsis); + } + items.extend((start..=end).map(VisibleItem::Completion)); + if end + 1 < labels.len() { + items.push(VisibleItem::Ellipsis); + } + items +} + +fn range_width(labels: &[&str], start: usize, end: usize) -> usize { + let hidden_left = usize::from(start > 0); + let hidden_right = usize::from(end + 1 < labels.len()); + let item_count = end - start + 1 + hidden_left + hidden_right; + labels[start..=end] + .iter() + .map(|label| label.chars().count()) + .sum::() + + (hidden_left + hidden_right) * ELLIPSIS.len() + + item_count.saturating_sub(1) * GAP +} + +#[cfg(test)] +mod tests { + use super::{VisibleItem, visible_items}; + + #[test] + fn all_completions_are_visible_when_they_fit() { + assert_eq!( + visible_items(&["step", "stepover"], 0, 20), + vec![VisibleItem::Completion(0), VisibleItem::Completion(1)] + ); + } + + #[test] + fn active_completion_pushes_the_visible_window_forward() { + let labels = ["continue", "pause", "step", "stepover"]; + assert_eq!( + visible_items(&labels, 0, 24), + vec![ + VisibleItem::Completion(0), + VisibleItem::Completion(1), + VisibleItem::Ellipsis, + ] + ); + assert_eq!( + visible_items(&labels, 2, 24), + vec![ + VisibleItem::Ellipsis, + VisibleItem::Completion(1), + VisibleItem::Completion(2), + VisibleItem::Ellipsis, + ] + ); + } + + #[test] + fn narrow_width_keeps_the_active_completion_visible() { + let labels = ["continue", "pause", "step"]; + assert_eq!( + visible_items(&labels, 1, 5), + vec![VisibleItem::Completion(1)] + ); + assert_eq!( + visible_items(&labels, 1, 3), + vec![VisibleItem::TruncatedCompletion(1, 3)] + ); + } +} diff --git a/tools/syrup/crates/console/src/details.rs b/tools/syrup/crates/console/src/details.rs new file mode 100644 index 000000000..e69de29bb diff --git a/tools/syrup/crates/console/src/enum.rs b/tools/syrup/crates/console/src/enum.rs new file mode 100644 index 000000000..e69de29bb diff --git a/tools/syrup/crates/console/src/events/dbg.rs b/tools/syrup/crates/console/src/events/dbg.rs new file mode 100644 index 000000000..e69de29bb diff --git a/tools/syrup/crates/console/src/main.rs b/tools/syrup/crates/console/src/main.rs new file mode 100644 index 000000000..d59b6cd94 --- /dev/null +++ b/tools/syrup/crates/console/src/main.rs @@ -0,0 +1,98 @@ +mod app; +mod session; +mod ui; +mod details; +mod r#enum; +mod messages; + +use std::{ + io::{self, stdout}, + time::Duration, +}; + +use crossterm::{ + cursor::{Hide, Show}, + event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{Terminal, backend::CrosstermBackend}; + +use app::App; +use session::Session; + +struct TerminalGuard; + +impl TerminalGuard { + fn enter() -> io::Result { + enable_raw_mode()?; + if let Err(error) = execute!(stdout(), EnterAlternateScreen, Hide) { + let _ = disable_raw_mode(); + return Err(error); + } + Ok(Self) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), Show, LeaveAlternateScreen); + } +} + +fn run(device: String, program: String) -> io::Result<()> { + let mut app = App::live(device.clone()); + let mut session = Session::connect(&device, &program, &mut app).map_err(io::Error::other)?; + let _guard = TerminalGuard::enter()?; + let backend = CrosstermBackend::new(stdout()); + let mut terminal = Terminal::new(backend)?; + + loop { + terminal.draw(|frame| ui::draw(frame, &app))?; + if !event::poll(Duration::from_millis(250))? { + session.poll(&mut app); + continue; + } + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + session.disconnect(&mut app); + return Ok(()); + } + let viewport = ui::calculate( + terminal.size()?.into(), + app.completions.len(), + app.feedback().is_some(), + ) + .timeline + .height + .saturating_sub(1); + if let Some(intent) = app.handle_key(key, viewport) { + session.dispatch(intent, &mut app); + } + session.poll(&mut app); + } + Event::Resize(_, _) => terminal.autoresize()?, + _ => {} + } + } +} + +fn main() -> io::Result<()> { + let mut arguments = std::env::args().skip(1); + match ( + arguments.next().as_deref(), + arguments.next(), + arguments.next().as_deref(), + arguments.next(), + arguments.next(), + ) { + (Some("--device"), Some(device), Some("--program"), Some(program), None) => { + run(device, program) + } + _ => Err(io::Error::other( + "usage: syrup --device --program ", + )), + } +} diff --git a/tools/syrup/crates/console/src/messages.rs b/tools/syrup/crates/console/src/messages.rs new file mode 100644 index 000000000..3d49c600c --- /dev/null +++ b/tools/syrup/crates/console/src/messages.rs @@ -0,0 +1,71 @@ +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Direction { + Outgoing, + Incoming, +} + +impl Direction { + pub const fn symbol(self) -> &'static str { + match self { + Self::Outgoing => "→", + Self::Incoming => "←", + } + } + + pub const fn label(self) -> &'static str { + match self { + Self::Outgoing => "→ Outgoing", + Self::Incoming => "← Incoming", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CommandIntent { + Continue, + Pause, + Step, + Next, + Restart, + Terminate, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Completion { + pub command: &'static str, + pub usage: &'static str, + pub intent: CommandIntent, +} + +pub const COMMANDS: [Completion; 6] = [ + Completion { + command: "continue", + usage: "continue", + intent: CommandIntent::Continue, + }, + Completion { + command: "pause", + usage: "pause", + intent: CommandIntent::Pause, + }, + Completion { + command: "step", + usage: "step", + intent: CommandIntent::Step, + }, + Completion { + command: "stepover", + usage: "stepover", + intent: CommandIntent::Next, + }, + Completion { + command: "restart", + usage: "restart", + intent: CommandIntent::Restart, + }, + Completion { + command: "terminate", + usage: "terminate", + intent: CommandIntent::Terminate, + }, +]; diff --git a/tools/syrup/crates/console/src/session.rs b/tools/syrup/crates/console/src/session.rs new file mode 100644 index 000000000..1b047ac37 --- /dev/null +++ b/tools/syrup/crates/console/src/session.rs @@ -0,0 +1,393 @@ +use std::{ + thread, + time::{Duration, Instant}, +}; + +use serde_json::{Value, json}; + +use dap::{AdapterOutput, Request, WarduinoAdapter, warduino_adapter}; + +use crate::app::{ + App, EntryPayload, EntryType, NamedValue, SessionEntry, StackFrame, + StopContext, VmState, +}; +use crate::messages::{CommandIntent, Direction}; + +pub struct Session { + adapter: WarduinoAdapter, + seq: u64, + thread_id: i64, + closed: bool, +} + +impl Session { + pub fn connect(device: &str, program: &str, app: &mut App) -> Result { + let mut session = Self { + adapter: warduino_adapter(), + seq: 1, + thread_id: 1, + closed: false, + }; + session.request( + "initialize", + json!({"adapterID": "syrup", "warduinoVmFrame": true}), + app, + )?; + let (attach_seq, output) = + session.send("attach", json!({"device": device, "program": program}), app); + if let Some(response) = output + .messages + .iter() + .find(|message| message["request_seq"] == attach_seq) + { + let error = response["message"] + .as_str() + .unwrap_or("attach failed") + .to_owned(); + session.apply(output, app); + return Err(error); + } + session.apply(output, app); + + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let output = session.adapter.pump_events(); + if let Some(response) = output + .messages + .iter() + .find(|message| message["request_seq"] == attach_seq) + { + let error = response["message"] + .as_str() + .unwrap_or("attach failed") + .to_owned(); + session.apply(output, app); + return Err(error); + } + let initialized = output + .messages + .iter() + .any(|message| message["type"] == "event" && message["event"] == "initialized"); + session.apply(output, app); + if initialized { + break; + } + if Instant::now() >= deadline { + return Err("module update acknowledgement timed out".into()); + } + thread::sleep(Duration::from_millis(10)); + } + session.request("configurationDone", json!({}), app)?; + let threads = session.request("threads", json!({}), app)?; + session.thread_id = threads + .pointer("/threads/0/id") + .and_then(Value::as_i64) + .ok_or("adapter returned no thread")?; + Ok(session) + } + + pub fn dispatch(&mut self, intent: CommandIntent, app: &mut App) { + let (command, arguments) = match intent { + CommandIntent::Continue => ("continue", json!({"threadId": self.thread_id})), + CommandIntent::Pause => ("pause", json!({"threadId": self.thread_id})), + CommandIntent::Step => ("stepIn", json!({"threadId": self.thread_id})), + CommandIntent::Next => ("next", json!({"threadId": self.thread_id})), + CommandIntent::Restart => ("restart", json!({})), + CommandIntent::Terminate => ("terminate", json!({})), + }; + if let Err(error) = self.request(command, arguments, app) { + app.notice = Some(error); + } + } + + pub fn poll(&mut self, app: &mut App) { + let output = self.adapter.pump_events(); + self.apply(output, app); + } + + pub fn disconnect(&mut self, app: &mut App) { + if !self.closed { + let _ = self.request("disconnect", json!({}), app); + self.closed = true; + } + } + + fn request(&mut self, command: &str, arguments: Value, app: &mut App) -> Result { + let (seq, output) = self.send(command, arguments, app); + let response = output + .messages + .iter() + .find(|message| message["type"] == "response" && message["request_seq"] == seq) + .cloned(); + self.apply(output, app); + let response = response.ok_or_else(|| format!("{command}: no response"))?; + if response["success"] != true { + return Err(response["message"] + .as_str() + .unwrap_or("request failed") + .into()); + } + Ok(response["body"].clone()) + } + + /// Reads paused-state data without adding three bookkeeping rows to the + /// timeline. The resulting text belongs to the `stopped` event instead. + fn inspection_request(&mut self, command: &str, arguments: Value) -> Result { + let seq = self.seq; + self.seq += 1; + let output = self.adapter.handle_request(Request { + seq, + message_type: "request".into(), + command: command.into(), + arguments, + }); + let response = output + .messages + .iter() + .find(|message| message["type"] == "response" && message["request_seq"] == seq) + .ok_or_else(|| format!("{command}: no response"))?; + if response["success"] != true { + return Err(format!( + "{command}: {}", + response["message"].as_str().unwrap_or("request failed") + )); + } + Ok(response["body"].clone()) + } + + fn send(&mut self, command: &str, arguments: Value, app: &mut App) -> (u64, AdapterOutput) { + let seq = self.seq; + self.seq += 1; + app.append(entry( + seq, + Direction::Outgoing, + command, + EntryType::DapRequest, + EntryPayload::DapRequest { + arguments: arguments.clone(), + }, + )); + let request = Request { + seq, + message_type: "request".into(), + command: command.into(), + arguments, + }; + let output = self.adapter.handle_request(request); + (seq, output) + } + + fn apply(&mut self, output: AdapterOutput, app: &mut App) { + for message in output.messages { + let kind = message["event"].as_str().unwrap_or("response"); + if message["type"] == "response" { + app.append(entry( + message["seq"].as_u64().unwrap_or(0), + Direction::Incoming, + message["command"].as_str().unwrap_or(kind), + EntryType::DapResponse, + EntryPayload::DapResponse { + success: message["success"].as_bool().unwrap_or(false), + body: message["body"].clone(), + message: message["message"].as_str().map(str::to_owned), + }, + )); + continue; + } + if message["type"] != "event" { + continue; + } + if kind == "warduino/vmFrame" { + match parse_vm_frame(&message["body"]["bytes"]) { + Ok(bytes) => { + let direction = if message["body"]["direction"] == "incoming" { + Direction::Incoming + } else { + Direction::Outgoing + }; + app.append(entry( + message["seq"].as_u64().unwrap_or(0), + direction, + message["body"]["command"].as_str().unwrap_or("unknown"), + if direction == Direction::Incoming { + EntryType::VmEvent + } else { + EntryType::DBGCommand + }, + EntryPayload::VmFrame { + direction, + bytes, + fields: message["body"]["fields"].clone(), + }, + )); + } + Err(error) => app.notice = Some(error), + } + continue; + } + app.append(entry( + message["seq"].as_u64().unwrap_or(0), + Direction::Incoming, + kind, + EntryType::DapEvent, + EntryPayload::DapEvent { + body: message["body"].clone(), + }, + )); + let stopped_entry = app.timeline.len() - 1; + match kind { + "continued" => app.vm_state = VmState::Running, + "stopped" => { + app.vm_state = VmState::Paused; + let thread_id = message["body"]["threadId"] + .as_i64() + .unwrap_or(self.thread_id); + app.timeline[stopped_entry].stop_context = + Some(self.inspect_paused_state(thread_id)); + } + "terminated" => { + app.vm_state = VmState::Disconnected; + self.closed = true; + } + "output" => { + app.notice = message["body"]["output"] + .as_str() + .map(str::trim) + .map(str::to_owned) + } + _ => {} + } + } + } + + fn inspect_paused_state(&mut self, thread_id: i64) -> StopContext { + let mut context = StopContext::default(); + let stack = match self.inspection_request("stackTrace", json!({"threadId": thread_id})) { + Ok(body) => body, + Err(error) => { + context.detail = Some(error); + return context; + } + }; + let Some(frames) = stack.get("stackFrames").and_then(Value::as_array) else { + context.detail = Some("stackTrace returned no stackFrames".into()); + return context; + }; + for frame in frames { + context.frames.push(StackFrame { + name: frame + .get("name") + .and_then(Value::as_str) + .unwrap_or("") + .into(), + address: frame + .get("instructionPointerReference") + .and_then(Value::as_str) + .unwrap_or("") + .into(), + }); + } + let Some(frame_id) = frames + .first() + .and_then(|frame| frame.get("id")) + .and_then(Value::as_i64) + else { + context.detail = Some("stack frame has no id".into()); + return context; + }; + if let Some(frame) = frames.first() { + context.pc = frame + .get("instructionPointerReference") + .and_then(Value::as_str) + .map(str::to_owned); + } + context.state = Some("Paused".into()); + let scopes = match self.inspection_request("scopes", json!({"frameId": frame_id})) { + Ok(body) => body, + Err(error) => { + context.detail = Some(format!("Locals unavailable: {error}")); + return context; + } + }; + let Some(reference) = scopes + .get("scopes") + .and_then(Value::as_array) + .and_then(|scopes| { + scopes.iter().find(|scope| { + scope.get("name").and_then(Value::as_str) == Some("VM") + && scope.get("expensive").and_then(Value::as_bool) != Some(true) + }) + }) + .and_then(|scope| scope.get("variablesReference")) + .and_then(Value::as_i64) + else { + return context; + }; + let variables = + match self.inspection_request("variables", json!({"variablesReference": reference})) { + Ok(body) => body, + Err(error) => { + context.detail = Some(format!("Locals unavailable: {error}")); + return context; + } + }; + if let Some(variables) = variables.get("variables").and_then(Value::as_array) { + for variable in variables { + if let (Some(name), Some(value)) = ( + variable.get("name").and_then(Value::as_str), + scalar_text(variable.get("value")), + ) { + match name { + "pc" => context.pc = Some(value), + "state" => context.state = Some(value), + _ => context.locals.push(NamedValue { + name: name.into(), + value, + }), + } + } + } + } + context + } +} + +fn scalar_text(value: Option<&Value>) -> Option { + match value? { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + _ => None, + } +} + +fn entry( + sequence: u64, + direction: Direction, + event: &str, + entry_type: EntryType, + payload: EntryPayload, +) -> SessionEntry { + SessionEntry { + sequence, + direction, + event: event.into(), + entry_type, + payload, + stop_context: None, + } +} + +fn parse_vm_frame(value: &Value) -> Result, String> { + value + .as_array() + .ok_or_else(|| "invalid VM frame bytes".to_owned())? + .iter() + .map(|value| { + value + .as_u64() + .and_then(|byte| u8::try_from(byte).ok()) + .ok_or_else(|| "invalid VM frame byte".to_owned()) + }) + .collect() +} diff --git a/tools/syrup/crates/console/src/ui.rs b/tools/syrup/crates/console/src/ui.rs new file mode 100644 index 000000000..23f0010f3 --- /dev/null +++ b/tools/syrup/crates/console/src/ui.rs @@ -0,0 +1,599 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Borders, Cell, Clear, Paragraph, Row, Table, Wrap}, +}; + +use crate::app::{ + App, DetailStyle, EntryPayload, EntryType, Focus, SessionEntry, + details_for, +}; +use crate::messages::{COMMANDS, Direction}; + +#[path = "completion.rs"] +mod completion; + +const BASE: Color = Color::Rgb(29, 29, 43); +const SURFACE: Color = Color::Rgb(41, 41, 58); +const SURFACE_ACTIVE: Color = Color::Rgb(58, 54, 80); +const TEXT: Color = Color::Rgb(215, 213, 231); +const ACCENT: Color = Color::Rgb(183, 162, 255); +const MUTED: Color = Color::Rgb(143, 139, 168); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LayoutMode { + Wide, + Stacked, + Narrow, + TooSmall, +} + +#[derive(Clone, Copy, Debug)] +pub struct ResponsiveLayout { + pub mode: LayoutMode, + pub timeline: Rect, + pub divider: Option, + pub details: Rect, + pub prompt: Rect, + pub footer: Option, +} + +pub fn calculate(area: Rect, _completion_count: usize, _has_feedback: bool) -> ResponsiveLayout { + let empty = Rect::default(); + if area.width < 50 || area.height < 14 { + return ResponsiveLayout { + mode: LayoutMode::TooSmall, + timeline: empty, + divider: None, + details: empty, + prompt: empty, + footer: None, + }; + } + + let mode = if area.width >= 110 && area.height >= 24 { + LayoutMode::Wide + } else if area.width >= 80 { + LayoutMode::Stacked + } else { + LayoutMode::Narrow + }; + let below_header = area.height; + let footer_height = u16::from(below_header >= 8); + let command_height = 2 + footer_height; + let main_height = below_header.saturating_sub(command_height + 2).max(5); + let main_y = area.y + 1; + + let (timeline, divider, details) = if mode == LayoutMode::Wide { + let timeline_width = ((area.width as u32 * 53) / 100) as u16; + ( + Rect::new(area.x, main_y, timeline_width, main_height), + Some(Rect::new(area.x + timeline_width, main_y, 1, main_height)), + Rect::new( + area.x + timeline_width + 1, + main_y, + area.width - timeline_width - 1, + main_height, + ), + ) + } else { + let result_height = match mode { + LayoutMode::Stacked => (main_height / 3).clamp(3, 7), + LayoutMode::Narrow => (main_height / 3).clamp(4, 5), + _ => unreachable!(), + }; + ( + Rect::new(area.x, main_y, area.width, main_height - result_height), + None, + Rect::new( + area.x, + main_y + main_height - result_height, + area.width, + result_height, + ), + ) + }; + let prompt_y = area.y + area.height - command_height; + let prompt = Rect::new(area.x, prompt_y, area.width, 2); + let footer = (footer_height == 1).then(|| Rect::new(area.x, prompt_y + 2, area.width, 1)); + ResponsiveLayout { + mode, + timeline, + divider, + details, + prompt, + footer, + } +} + +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.area(); + let layout = calculate(area, app.completions.len(), app.feedback().is_some()); + if layout.mode == LayoutMode::TooSmall { + render_too_small(frame, area); + return; + } + frame.render_widget(Paragraph::new("").style(Style::default().bg(BASE)), area); + render_timeline(frame, app, layout.timeline, layout.mode); + if let Some(divider) = layout.divider { + frame.render_widget( + Paragraph::new("│").style(Style::default().fg(MUTED)), + divider, + ); + } + render_details(frame, app, layout.details, layout.mode); + render_metadata( + frame, + app, + Rect::new( + area.x + + if layout.mode == LayoutMode::Wide { + 2 + } else { + 1 + }, + layout.prompt.y.saturating_sub(1), + area.width + .saturating_sub(if layout.mode == LayoutMode::Wide { + 4 + } else { + 2 + }), + 1, + ), + layout.mode, + ); + render_prompt(frame, app, layout.prompt); + if let Some(footer) = layout.footer { + render_footer(frame, app, footer, layout.mode); + } + if app.help_visible { + render_help(frame, area); + } +} + +fn render_metadata(frame: &mut Frame, app: &App, area: Rect, mode: LayoutMode) { + let state = app.vm_state.label().to_ascii_lowercase(); + let color = match app.vm_state { + crate::app::VmState::Paused => Color::Rgb(228, 184, 106), + crate::app::VmState::Disconnected => Color::Rgb(231, 130, 132), + _ => Color::Rgb(119, 199, 160), + }; + let left = if mode == LayoutMode::Narrow { + app.vm_name.clone() + } else { + format!("{} · {}", app.vm_name, app.connection) + }; + let right = if mode == LayoutMode::Narrow { + state + } else { + format!("connected · {state}") + }; + let left_width = area.width.saturating_sub(right.chars().count() as u16 + 1); + frame.render_widget( + Paragraph::new(trim_text(&left, left_width as usize)).style(Style::default().fg(MUTED)), + Rect::new(area.x, area.y, left_width, 1), + ); + frame.render_widget( + Paragraph::new(right) + .style(Style::default().fg(color)) + .alignment(Alignment::Right), + area, + ); +} + +fn render_timeline(frame: &mut Frame, app: &App, area: Rect, mode: LayoutMode) { + if area.height == 0 { + return; + } + let active = app.focus == Focus::Session; + frame.render_widget( + Paragraph::new(Span::styled( + " Session ", + Style::default() + .fg(TEXT) + .bg(if active { SURFACE_ACTIVE } else { SURFACE }) + .add_modifier(Modifier::BOLD), + )), + Rect::new(area.x, area.y, area.width, 1), + ); + if area.height < 4 { + return; + } + let constraints = session_constraints(area.width, mode != LayoutMode::Narrow); + let entries = app.timeline.iter().map(|entry| { + Row::new(session_cells(entry, area.width, mode != LayoutMode::Narrow)) + .style(Style::default().fg(TEXT)) + }); + let header = Row::new(session_header(area.width, mode != LayoutMode::Narrow)) + .style(Style::default().fg(MUTED)); + let selection = if active { + Style::default() + .fg(TEXT) + .bg(SURFACE_ACTIVE) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED).bg(SURFACE) + }; + let table = Table::new(entries, constraints) + .header(header) + .row_highlight_style(selection) + .highlight_symbol("› "); + let mut state = app.session_table.clone(); + frame.render_stateful_widget( + table, + Rect::new(area.x, area.y + 2, area.width, area.height - 2), + &mut state, + ); +} + +fn session_constraints(width: u16, show_sequence: bool) -> Vec { + let mut constraints = Vec::new(); + if show_sequence { + constraints.push(Constraint::Length(4)); + } + constraints.extend([ + Constraint::Length(2), + Constraint::Length(if width >= 110 { 28 } else { 20 }), + Constraint::Length(16), + ]); + if width >= 50 { + constraints.push(Constraint::Length(14)); + } + constraints +} + +fn session_header(width: u16, show_sequence: bool) -> Vec> { + let mut cells = Vec::new(); + if show_sequence { + cells.push(Cell::from(Line::from("#").alignment(Alignment::Right))); + } + cells.push(Cell::from(Line::from(" ").alignment(Alignment::Center))); + cells.push(Cell::from("Event")); + cells.push(Cell::from("Type")); + if width >= 50 { + cells.push(Cell::from("Wire")); + } + cells +} + +fn session_cells(entry: &SessionEntry, width: u16, show_sequence: bool) -> Vec> { + let mut cells = Vec::new(); + if show_sequence { + cells.push( + Cell::from(Line::from(entry.sequence.to_string()).alignment(Alignment::Right)) + .style(Style::default().fg(MUTED)), + ); + } + cells.push( + Cell::from( + Line::from(if entry.entry_type == EntryType::DBGCommand { + Direction::Incoming.symbol() + } else { + entry.direction.symbol() + }) + .alignment(Alignment::Center), + ) + .style(Style::default().fg(ACCENT)), + ); + cells.push(Cell::from(trim_text( + &entry.event, + width.saturating_sub(36) as usize, + ))); + cells.push(Cell::from(entry.entry_type.label())); + if width >= 50 { + cells.push( + Cell::from(match &entry.payload { + EntryPayload::VmFrame { bytes, .. } => format_vm_frame(bytes), + _ => String::new(), + }) + .style(Style::default().fg(MUTED)), + ); + } + cells +} + +fn format_vm_frame(bytes: &[u8]) -> String { + bytes + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(" ") +} + +fn render_details(frame: &mut Frame, app: &App, area: Rect, mode: LayoutMode) { + if area.height == 0 { + return; + } + let Some(entry) = app.selected_entry() else { + frame.render_widget( + Paragraph::new(Span::styled( + " Details ", + Style::default() + .fg(TEXT) + .bg(SURFACE_ACTIVE) + .add_modifier(Modifier::BOLD), + )), + area, + ); + return; + }; + let details = details_for(entry); + let identity = format!("#{} · ", entry.sequence); + let type_label = entry.entry_type.label(); + let compact = area.width >= 68; + let header = if compact { + Line::from(vec![ + Span::styled( + " Details ", + Style::default() + .fg(TEXT) + .bg(SURFACE_ACTIVE) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled(identity.as_str(), Style::default().fg(MUTED)), + Span::styled( + details.heading.as_str(), + Style::default().fg(TEXT).add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(MUTED)), + Span::styled(type_label, Style::default().fg(MUTED)), + ]) + } else { + Line::from(Span::styled( + " Details ", + Style::default() + .fg(TEXT) + .bg(SURFACE_ACTIVE) + .add_modifier(Modifier::BOLD), + )) + }; + frame.render_widget( + Paragraph::new(header), + Rect::new(area.x, area.y, area.width, 1), + ); + let header_height = if compact { 1 } else { 2 }; + if !compact && area.height >= 2 { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(identity.as_str(), Style::default().fg(MUTED)), + Span::styled( + details.heading.as_str(), + Style::default().fg(TEXT).add_modifier(Modifier::BOLD), + ), + Span::styled(" · ", Style::default().fg(MUTED)), + Span::styled(type_label, Style::default().fg(MUTED)), + ])) + .wrap(Wrap { trim: false }), + Rect::new(area.x, area.y + 1, area.width, 1), + ); + } + if area.height <= header_height { + return; + } + let mut lines = vec![Line::from("")]; + let label_width: usize = if mode == LayoutMode::Narrow { 9 } else { 12 }; + for section in details.sections { + if !section.title.is_empty() { + lines.push(Line::from(Span::styled( + section.title, + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ))); + } + for row in section.rows { + let style = detail_style(row.style); + if area.width < 60 + && !row.label.is_empty() + && row.value.chars().count() + > area.width.saturating_sub((label_width + 2) as u16) as usize + { + lines.push(Line::from(Span::styled( + row.label, + Style::default().fg(MUTED), + ))); + lines.push(Line::from(Span::styled(row.value, style))); + } else if row.label.is_empty() { + lines.push(Line::from(Span::styled(row.value, style))); + } else { + lines.push(Line::from(vec![ + Span::styled( + format!("{: Style { + match style { + DetailStyle::Normal => Style::default().fg(TEXT), + DetailStyle::Muted => Style::default().fg(MUTED), + DetailStyle::Success => Style::default().fg(Color::Rgb(119, 199, 160)), + DetailStyle::Warning => Style::default().fg(Color::Rgb(228, 184, 106)), + DetailStyle::Error => Style::default().fg(Color::Rgb(231, 130, 132)), + DetailStyle::Address => Style::default().fg(ACCENT), + } +} + +fn render_prompt(frame: &mut Frame, app: &App, area: Rect) { + let surface = Style::default().bg(if app.focus == Focus::Command { + SURFACE_ACTIVE + } else { + SURFACE + }); + frame.render_widget(Paragraph::new(" ").style(surface), area); + frame.render_widget( + Paragraph::new("▌\n▌").style(Style::default().fg(if app.focus == Focus::Command { + ACCENT + } else { + MUTED + })), + Rect::new(area.x, area.y, 1, area.height), + ); + let input = trim_text(&app.prompt, area.width.saturating_sub(6) as usize); + let line = if input.is_empty() { + Line::from(vec![ + Span::styled("› ", Style::default().fg(ACCENT)), + Span::styled("Type a command…", Style::default().fg(MUTED)), + ]) + } else { + Line::from(vec![ + Span::styled("› ", Style::default().fg(ACCENT)), + Span::styled(input, Style::default().fg(TEXT)), + ]) + }; + frame.render_widget( + Paragraph::new(line), + Rect::new(area.x + 2, area.y + 1, area.width.saturating_sub(4), 1), + ); + if !app.completions.is_empty() { + completion::render( + frame, + app, + Rect::new(area.x + 2, area.y, area.width.saturating_sub(4), 1), + ); + } else if let Some(feedback) = app.feedback() { + frame.render_widget( + Paragraph::new(trim_text(feedback, area.width.saturating_sub(4) as usize)).style( + Style::default().fg(if app.notice.is_some() { + Color::Rgb(231, 130, 132) + } else { + MUTED + }), + ), + Rect::new(area.x + 2, area.y, area.width.saturating_sub(4), 1), + ); + } +} + +fn render_help(frame: &mut Frame, area: Rect) { + let width = area.width.saturating_sub(4).min(48); + let height = u16::try_from(COMMANDS.len() + 4) + .unwrap_or(u16::MAX) + .min(area.height.saturating_sub(4)); + let popup = Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ); + frame.render_widget(Clear, popup); + let block = Block::default() + .title(Span::styled( + " Help ", + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(ACCENT)) + .style(Style::default().bg(BASE).fg(TEXT)); + let mut lines = vec![ + Line::from(Span::styled( + "Supported commands", + Style::default().fg(TEXT).add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + lines.extend(COMMANDS.iter().map(|command| { + Line::from(vec![ + Span::styled( + format!(" {:<11}", command.command), + Style::default().fg(ACCENT), + ), + Span::styled(command.usage, Style::default().fg(MUTED)), + ]) + })); + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "? / esc close", + Style::default().fg(MUTED), + ))); + frame.render_widget(Paragraph::new(lines).block(block), popup); +} + +fn render_footer(frame: &mut Frame, app: &App, area: Rect, mode: LayoutMode) { + let pairs: &[(&str, &str)] = match (app.focus, mode == LayoutMode::Narrow) { + (Focus::Command, true) => &[ + ("↑↓", " choice "), + ("tab", " complete "), + ("enter", " send "), + ("esc", " session"), + ], + (Focus::Command, false) => &[ + ("↑↓", " choice/history "), + ("tab", " complete "), + ("enter", " send "), + ("esc", " cancel/session "), + ("?", " help"), + ], + (Focus::Session, true) => &[("↑↓", " select "), ("tab", " command")], + (Focus::Session, false) => &[ + ("↑↓", " select "), + ("pgup/pgdn", " page "), + ("home/end", " latest "), + ("tab", " command "), + ("?", " help"), + ], + }; + let spans = pairs.iter().flat_map(|(key, description)| { + [ + Span::styled( + *key, + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD), + ), + Span::styled(*description, Style::default().fg(MUTED)), + ] + }); + frame.render_widget(Paragraph::new(Line::from_iter(spans)), area); +} + +fn render_too_small(frame: &mut Frame, area: Rect) { + let message = "Terminal too small — minimum 50×14"; + let width = message.chars().count().min(area.width as usize) as u16; + let x = area.x + area.width.saturating_sub(width) / 2; + let y = area.y + area.height / 2; + frame.render_widget( + Paragraph::new(message).alignment(Alignment::Center), + Rect::new(x, y, width, 1), + ); +} + +fn trim_text(text: &str, width: usize) -> String { + if Line::from(text).width() <= width { + return text.to_owned(); + } + if width == 0 { + return String::new(); + } + if width == 1 { + return "…".into(); + } + let mut output = String::new(); + for character in text.chars() { + let candidate = format!("{output}{character}…"); + if Line::from(candidate.as_str()).width() > width { + break; + } + output.push(character); + } + output.push('…'); + output +} diff --git a/tools/syrup/crates/console/tests/ui.rs b/tools/syrup/crates/console/tests/ui.rs new file mode 100644 index 000000000..88f542c2e --- /dev/null +++ b/tools/syrup/crates/console/tests/ui.rs @@ -0,0 +1,195 @@ +#[path = "../src/app.rs"] +mod app; +#[path = "../src/ui.rs"] +mod ui; + +use app::{ + App, Direction, EntryPayload, EntryType, Focus, NamedValue, SessionEntry, StackFrame, + StopContext, details_for, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{Terminal, backend::TestBackend}; +use serde_json::json; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} +fn render(app: &App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| ui::draw(frame, app)).unwrap(); + let b = terminal.backend().buffer(); + (0..height) + .map(|y| { + (0..width) + .map(|x| b.cell((x, y)).unwrap().symbol()) + .collect::() + }) + .collect::>() + .join("\n") +} +fn entry(sequence: u64, event: &str, payload: EntryPayload) -> SessionEntry { + SessionEntry { + sequence, + direction: Direction::Incoming, + event: event.into(), + entry_type: EntryType::DapEvent, + payload, + stop_context: None, + } +} + +#[test] +fn selection_is_table_state_and_follow_latest_is_deterministic() { + let mut app = App::sample(); + assert_eq!(app.session_table.selected(), Some(2)); + app.select_delta(-1); + assert_eq!(app.selected_entry().unwrap().sequence, 1043); + assert!(!app.follow_latest); + app.append(entry( + 4, + "output", + EntryPayload::DapEvent { + body: json!({"output":"later"}), + }, + )); + assert_eq!(app.selected_entry().unwrap().sequence, 1043); + app.select_latest(); + assert!(app.follow_latest); + assert_eq!(app.selected_entry().unwrap().sequence, 4); +} +#[test] +fn focus_and_keys_preserve_command_buffer() { + let mut app = App::sample(); + app.insert('c'); + assert!(!app.completions.is_empty()); + app.handle_key(key(KeyCode::Esc), 4); + assert_eq!(app.focus, Focus::Command); + app.handle_key(key(KeyCode::Esc), 4); + assert!(app.prompt.is_empty()); + app.handle_key(key(KeyCode::Esc), 4); + assert_eq!(app.focus, Focus::Session); + app.handle_key(key(KeyCode::Down), 4); + let prompt = app.prompt.clone(); + app.handle_key(key(KeyCode::Char('p')), 4); + assert_eq!(app.focus, Focus::Command); + assert_eq!(app.prompt, format!("{prompt}p")); + app.handle_key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT), 4); + assert_eq!(app.focus, Focus::Session); + app.handle_key(key(KeyCode::Tab), 4); + assert_eq!(app.focus, Focus::Command); +} + +#[test] +fn tab_accepts_the_selected_completion_before_enter_submits() { + let mut app = App::sample(); + for character in "ste".chars() { + app.insert(character); + } + assert_eq!( + app.completions + .iter() + .map(|completion| completion.command) + .collect::>(), + vec!["step", "stepover"] + ); + assert_eq!(app.completion_index, 0); + + app.handle_key(key(KeyCode::Tab), 4); + assert_eq!(app.prompt, "step"); + assert_eq!(app.cursor, 4); + app.handle_key(key(KeyCode::Tab), 4); + assert_eq!(app.prompt, "stepover"); + assert_eq!(app.cursor, 8); + app.handle_key(key(KeyCode::Tab), 4); + assert_eq!(app.prompt, "step"); + assert_eq!(app.cursor, 4); + + app.handle_key(key(KeyCode::Down), 4); + assert_eq!(app.completion_index, 1); + app.handle_key(key(KeyCode::Tab), 4); + assert_eq!(app.prompt, "stepover"); + assert_eq!(app.cursor, 8); + assert_eq!( + app.handle_key(key(KeyCode::Enter), 4), + Some(app::CommandIntent::Next) + ); + assert!(app.prompt.is_empty()); +} + +#[test] +fn enter_does_not_submit_an_unaccepted_completion() { + let mut app = App::sample(); + for character in "ste".chars() { + app.insert(character); + } + + assert_eq!(app.handle_key(key(KeyCode::Enter), 4), None); + assert_eq!(app.prompt, "ste"); + assert!( + app.notice + .as_deref() + .is_some_and(|notice| notice.contains("Unknown command")) + ); +} + +#[test] +fn completion_candidates_render_horizontally() { + let mut app = App::sample(); + for character in "ste".chars() { + app.insert(character); + } + let output = render(&app, 72, 24); + assert!(output.contains("step stepover")); +} +#[test] +fn details_are_owned_by_each_entry() { + let request = entry( + 9, + "pause", + EntryPayload::DapRequest { + arguments: json!({"threadId":1}), + }, + ); + assert_eq!(details_for(&request).sections[0].title, "Arguments"); + let mut stopped = entry( + 10, + "stopped", + EntryPayload::DapEvent { + body: json!({"reason":"pause","threadId":1}), + }, + ); + stopped.stop_context = Some(StopContext { + state: Some("Paused".into()), + pc: Some("0x17".into()), + frames: vec![StackFrame { + name: "WARDuino".into(), + address: "0x17".into(), + }], + locals: vec![NamedValue { + name: "x".into(), + value: "1".into(), + }], + detail: None, + }); + let d = details_for(&stopped); + assert!(d.sections.iter().any(|s| s.title == "Execution")); + assert!(d.sections.iter().any(|s| s.title == "Stack")); + assert!(d.sections.iter().any(|s| s.title == "Locals")); + let empty = entry(11, "note", EntryPayload::None); + assert_eq!( + details_for(&empty).sections[0].rows[0].value, + "No additional details" + ); +} +#[test] +fn details_render_responsively() { + let app = App::sample(); + for (w, h) in [(140, 40), (100, 30), (72, 24), (50, 14)] { + let output = render(&app, w, h); + assert!(output.contains("Session")); + assert!(output.contains("Details")); + assert!(output.contains("stopped")); + } + assert!(render(&app, 49, 13).contains("Terminal too small")); +} diff --git a/tools/syrup/crates/dap/Cargo.toml b/tools/syrup/crates/dap/Cargo.toml new file mode 100644 index 000000000..d0adf1e60 --- /dev/null +++ b/tools/syrup/crates/dap/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "warduino-dap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "warduino-dap" +path = "src/server/server.rs" + +[dependencies] +debug = { package = "wdebug", path = "../wdebug" } + +serde.workspace = true +serde_json.workspace = true +wast.workspace = true +wasmparser.workspace = true diff --git a/tools/syrup/crates/dap/README.md b/tools/syrup/crates/dap/README.md new file mode 100644 index 000000000..b76d5ad6b --- /dev/null +++ b/tools/syrup/crates/dap/README.md @@ -0,0 +1,17 @@ +# WARDuino DAP + +`warduino-dap` is a deliberately limited, source-aware Debug Adapter Protocol bridge. It uses only the existing VM ABI: module update acknowledgement, snapshots containing the current PC, and `Step`/`StepOver`. + +Attach requires both `device` and a `program` path to a WAT file. The adapter compiles that file once, uploads those exact WASM bytes, and waits up to five seconds for the existing `UpdateModule` operation result before emitting `initialized`. It retains the immutable WAT text and uses it for DAP `source` requests. + +The adapter exposes one synthetic thread and, while paused at a mapped PC, exactly one current frame (`totalFrames: 1`). The frame name is the mapped WAT function name (or `func[index]`) and its source line/column come from the retained WAT. This is not a complete VM call stack. + +Supported run control is `continue`, `pause`, `stepIn`, `next`, `terminate`, and `disconnect`. Line/statement `stepIn` repeats VM `Step`; line/statement `next` repeats VM `StepOver`, stopping only when the locally mapped source location changes. Instruction granularity sends one VM operation. A five-second safety deadline prevents same-location loops from running indefinitely. + +The current VM scope exposes PC and VM state. Full caller stacks, `stepOut`, breakpoints, locals, evaluate, memory access, disassembly, and source breakpoints are not implemented and are not advertised. + +Run the standalone adapter from `tools/syrup`: + +```text +cargo run -p warduino-dap -- --dap-server 127.0.0.1:4711 +``` diff --git a/tools/syrup/crates/dap/dap-smoke-plan.md b/tools/syrup/crates/dap/dap-smoke-plan.md new file mode 100644 index 000000000..dcaa1e29f --- /dev/null +++ b/tools/syrup/crates/dap/dap-smoke-plan.md @@ -0,0 +1,345 @@ +# Minimal WARDuino DAP Smoke-Test Plan + +## Goal + +Build the smallest protocol-valid DAP executable that demonstrates the preliminary `../debug` library in action. + +The demo proves only this path: + +```text +DAP continue request + → DebugCommand::Continue + → WARDuino/Waffyr + → DebugEvent::Continued + → DAP continued event + +DAP pause request + → DebugCommand::Pause + → WARDuino/Waffyr + → DebugEvent::Stopped + → DAP stopped event +``` + +It is a review harness, not the production DAP adapter. + +## Scope + +Implement only these DAP messages: + +| Direction | Message | Purpose | +|---|---|---| +| Client → adapter | `initialize` | Establish the DAP session | +| Client → adapter | `attach` | Mark the preconfigured Waffyr connection active | +| Adapter → client | `initialized` | Signal readiness for configuration | +| Client → adapter | `configurationDone` | Finish the empty configuration phase | +| Client → adapter | `threads` | Return one synthetic Waffyr thread | +| Client → adapter | `continue` | Send the WARDuino continue/run command | +| Adapter → client | `continued` | Report that execution resumed | +| Client → adapter | `pause` | Send the WARDuino pause command | +| Adapter → client | `stopped` | Report the pause/stop reason | +| Client → adapter | `disconnect` | Close the smoke-test session | + +Every unsupported request receives a normal DAP failure response. It must not panic or silently disappear. + +Explicitly exclude breakpoints, stack traces, scopes, variables, source files, stepping, launch, restart, terminate, configuration screens, and editor-extension packaging. + +## Package layout + +Add one small binary crate or binary target: + +```text +crates/dap/ +├── Cargo.toml +├── src/ +│ ├── main.rs process setup and real DebugSession construction +│ ├── protocol.rs Content-Length framing and minimal JSON helpers +│ └── adapter.rs request dispatch and debug-event translation +└── tests/ + └── smoke.rs deterministic transcript using a fake DebugSession +``` + +If `crates/dap` already exists, add these pieces there instead of introducing another crate. + +## Dependencies + +Use only: + +- `debug` through a workspace path dependency; +- `serde` with derive; +- `serde_json`; +- the Rust standard library. + +Do not add an async runtime, DAP framework, CLI framework, logging framework, or channel crate. Write diagnostics to stderr because stdout is reserved exclusively for DAP frames. + +## Process interface + +Run the adapter over stdio: + +```bash +cargo run -p warduino-dap -- --device localhost:8100 +``` + +The exact connection argument should match the current `debug` constructor. Parse only the one required argument manually. Connect to Waffyr once during startup and retain the session for the process lifetime. + +For this smoke test, `attach` does not establish a second connection. It begins the empty DAP configuration phase for the session that was connected at startup. + +## Minimal DAP framing + +DAP uses a header followed by a JSON body: + +```text +Content-Length: \r\n +\r\n + +``` + +Implement: + +```rust +fn read_message(reader: &mut impl BufRead) -> io::Result>; +fn write_message(writer: &mut impl Write, value: &impl Serialize) -> io::Result<()>; +``` + +Requirements: + +- measure JSON length in bytes, not characters; +- accept additional headers but require one valid `Content-Length`; +- use `read_exact` for the body; +- treat clean EOF before a header as normal shutdown; +- reject malformed or oversized messages; +- flush stdout after every response or event; +- never print ordinary logs to stdout. + +Use a small maximum request size, such as 1 MiB, to avoid unbounded allocation. + +## Minimal message representation + +Only deserialize the common request envelope: + +```rust +#[derive(Deserialize)] +struct Request { + seq: u64, + #[serde(rename = "type")] + message_type: String, + command: String, + #[serde(default)] + arguments: serde_json::Value, +} +``` + +Build responses and events through two helpers rather than defining the entire DAP schema: + +```rust +fn response(request: &Request, success: bool, body: Value) -> Value; +fn event(name: &str, body: Value) -> Value; +``` + +The adapter owns one monotonically increasing outgoing DAP `seq` used by both responses and events. + +## Adapter state + +Keep only the state required to reject obviously invalid requests: + +```rust +enum AdapterState { + AwaitingInitialize, + Ready, + Configuring, + Attached, + Disconnected, +} + +struct Adapter { + session: S, + state: AdapterState, + next_seq: u64, +} +``` + +Use one fixed DAP thread: + +```rust +const THREAD_ID: i64 = 1; +``` + +The `threads` response returns: + +```json +{"threads":[{"id":1,"name":"Waffyr VM"}]} +``` + +Do not construct a second model of VM state. State transitions come from `warduino-debug` events. + +## Request behavior + +### `initialize` + +1. Require `AwaitingInitialize`. +2. Return a successful response with an empty/minimal capabilities body. +3. Advertise only `supportsConfigurationDoneRequest: true`. +4. Enter `Ready`. + +Do not send `initialized` yet. DAP initialization completes before the client sends `launch` or `attach`; the adapter sends `initialized` once it is ready to receive configuration. + +### `attach` + +1. Require `Ready`. +2. Confirm that the already-created debug session is connected. +3. Retain the attach request instead of responding immediately. +4. Emit `initialized`. +5. Enter `Configuring`. + +The configuration phase is empty, but retaining the request preserves the normal DAP launch sequence. + +### `configurationDone` + +1. Require `Configuring`. +2. Return a successful `configurationDone` response. +3. Return the retained successful `attach` response. +4. Enter `Attached`. + +This one extra handler makes the smoke adapter usable by conforming clients without implementing breakpoints or exception configuration. + +### `threads` + +Return the single synthetic thread. Supporting this tiny request makes the adapter understandable to ordinary DAP clients without adding real thread handling. + +### `continue` + +1. Require `Attached` and `threadId == 1` when supplied. +2. Call `session.send(DebugCommand::Continue)`; use `Run` instead only if that is the current public variant. +3. Return a successful DAP response with `{"allThreadsContinued":true}`. +4. When the library reports `DebugEvent::Continued`, emit: + +```json +{ + "threadId": 1, + "allThreadsContinued": true +} +``` + +If the current ABI never sends a continued notification, emit the DAP `continued` event immediately after a successful local send and document that it confirms transmission, not remote execution. + +### `pause` + +1. Require `Attached` and the synthetic thread ID. +2. Call `session.send(DebugCommand::Pause)`. +3. Return a successful empty DAP response. +4. Wait for the normal debug-event pump to observe the stop. +5. Translate the resulting stop to a DAP `stopped` event. + +For the revised debug API: + +```rust +DebugEvent::Stopped(stopped) +``` + +For the preliminary API, temporarily accept its equivalent `Paused` or `BreakpointHit` variant. Keep this compatibility match local to `adapter.rs` so the review shows whether the planned API change improves the translation. + +Emit a body containing only: + +```json +{ + "reason": "pause", + "threadId": 1, + "allThreadsStopped": true +} +``` + +Map other reasons to `step`, `breakpoint`, or `exception` if they arrive, even though the smoke client does not request those operations. + +### `disconnect` + +1. Return success. +2. Close/drop the debug session. +3. Enter `Disconnected` and terminate cleanly. + +Do not implicitly resume or terminate Waffyr. + +## Response timing + +Use the strongest semantics provided by the preliminary library: + +- If `warduino-debug` exposes correlated `CommandResponse` values, retain `CommandId → DAP request seq` and send DAP success/failure when the response arrives. +- If `send` is currently fire-and-forget, respond after successful encoding and transport write. A later VM transition remains a separate DAP event. + +Do not delay a DAP response until a `Stopped` or `Continued` event. A command response and the state-change event are different protocol messages. + +## Event loop + +Avoid async dependencies with one standard-library input thread: + +1. A reader thread owns stdin, decodes DAP requests, and sends them through `std::sync::mpsc`. +2. The main thread owns stdout and `DebugSession`. +3. The main loop receives DAP requests with a short timeout. +4. Between requests, it repeatedly calls `session.try_recv()` until it returns `None`. +5. It translates each debug response/event and writes the corresponding DAP message. + +This allows spontaneous pause/disconnect events to reach the client even while no DAP request is arriving, without adding Tokio. + +## Error handling + +- Malformed DAP input terminates the adapter with a diagnostic on stderr. +- Unsupported or invalid-state DAP requests receive `success: false` with a concise message. +- A local `warduino-debug::DebugError` fails the current DAP request when applicable, then emits an output diagnostic and disconnects. +- A remote `CommandError`, if supported, becomes a failed response to the correlated DAP request. +- `DebugEvent::Disconnected` emits a DAP `terminated` event and ends the loop. + +The adapter must always restore/flush its output path and exit without a panic. + +## Automated smoke test + +Make `Adapter` generic over `DebugSession` and use a deterministic fake session. Test this exact transcript: + +1. `initialize` → success response advertising configuration completion support. +2. `attach` → adapter emits `initialized` and retains the request. +3. `configurationDone` → its response followed by the successful attach response. +4. `threads` → one thread named `Waffyr VM`. +5. `continue` → fake receives `DebugCommand::Continue`; DAP response succeeds. +6. Fake emits `Continued` → adapter emits `continued` for thread 1. +7. `pause` → fake receives `DebugCommand::Pause`; DAP response succeeds. +8. Fake emits `Stopped(Pause)` → adapter emits `stopped` with reason `pause`. +9. `disconnect` → success response and clean exit. + +Also test one unsupported request and one debug-library error. Assert the actual Content-Length-framed JSON output rather than calling handlers directly. + +## Real Waffyr demonstration + +Add a tiny standard-library Python script only if manual frame construction becomes inconvenient. It should: + +1. spawn `warduino-dap` with the Waffyr connection argument; +2. send `initialize`, `attach`, `configurationDone`, `threads`, and `continue`; +3. print the received `continued` event; +4. wait briefly, then send `pause`; +5. print the received `stopped` event; +6. send `disconnect` and verify clean exit. + +This is preferable to creating a VS Code extension for the review. Editor packaging can happen after the API shape is accepted. + +## Implementation order + +1. Add Content-Length read/write helpers and framing tests. +2. Add the minimal request dispatcher and outgoing sequence counter. +3. Implement `initialize`, retained `attach`, `configurationDone`, `threads`, and `disconnect` with a fake session. +4. Map DAP `continue` and `pause` to `warduino-debug` commands. +5. Add the debug-event pump and translate continued/stopped/disconnected events. +6. Run the transcript test through real DAP framing. +7. Run the same sequence against Waffyr and record the transcript for code review. + +## Completion criteria + +- The adapter uses real DAP Content-Length framing over stdio. +- It implements only initialize, attach, configurationDone, threads, continue, pause, and disconnect. +- Continue and pause call the real typed `warduino-debug` API. +- WARDuino state events become correctly ordered DAP events. +- Stdout contains only DAP messages; diagnostics use stderr. +- Unsupported requests fail cleanly. +- The fake-session transcript test is deterministic. +- One real Waffyr run demonstrates continue followed by pause. +- No async runtime, DAP framework, editor extension, or unrelated debugger feature is added. + +## References + +- [DAP overview and launch sequencing](https://microsoft.github.io/debug-adapter-protocol/overview.html) +- [DAP protocol specification](https://microsoft.github.io/debug-adapter-protocol/specification.html) diff --git a/tools/syrup/crates/dap/src/adapter.rs b/tools/syrup/crates/dap/src/adapter.rs new file mode 100644 index 000000000..3dc81c073 --- /dev/null +++ b/tools/syrup/crates/dap/src/adapter.rs @@ -0,0 +1,865 @@ +//! DAP adapter translating debugger requests into WARDuino VM commands. + +use std::{ + path::Path, + time::{Duration, Instant}, +}; + +use debug::{DebugCommand, DebugEvent, DebugSession, ReceivedFrame, StopReason, Stopped}; +use serde_json::{Value, json}; + +use crate::{ + Request, + source::{MappedFrame, ProgramImage, SourceLocation}, +}; + +const THREAD_ID: i64 = 1; +const SOURCE_REFERENCE: i64 = 1; +const INSPECT_TIMEOUT: Duration = Duration::from_secs(1); +const OPERATION_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdapterState { + AwaitingInitialize, + Ready, + AwaitingModuleUpdate, + Configuring, + Attached, + Disconnected, +} + +#[derive(Clone, Debug)] +struct Step { + kind: StepKind, + location: SourceLocation, // current location before step + deadline: Instant, +} + +#[derive(Clone, Copy, Debug)] +enum StepKind { + Step, + StepOver, +} + +impl StepKind { + fn command(self) -> DebugCommand { + match self { + Self::Step => DebugCommand::Step, + Self::StepOver => DebugCommand::StepOver, + } + } + + fn accepts_stop(self, reason: StopReason) -> bool { + match self { + Self::Step => reason == StopReason::Step, + Self::StepOver => matches!(reason, StopReason::Step | StopReason::Breakpoint), + } + } +} + +#[derive(Debug, Default)] +pub struct AdapterOutput { + pub messages: Vec, + pub terminate: bool, +} + +impl AdapterOutput { + fn one(message: Value) -> Self { + Self { + messages: vec![message], + terminate: false, + } + } +} + +pub trait SessionConnector { + fn connect(&mut self, device: &str) -> Result; +} + +impl SessionConnector for F +where + F: FnMut(&str) -> Result, + E: std::fmt::Display, +{ + fn connect(&mut self, device: &str) -> Result { + self(device).map_err(|error| error.to_string()) + } +} + +pub struct Adapter { + session: Option, + connector: C, + state: AdapterState, + paused: bool, + next_seq: u64, + pending_attach: Option, + snapshot: Option, + pending_stop: Option, + generation: i64, + inspect_deadline: Option, + upload_deadline: Option, + source_step: Option, + image: Option, + source_path: Option, + vm_frame_trace: bool, +} + +impl> Adapter { + /// Creates an adapter for a single debug session (and connection). + pub fn new(connector: C) -> Self { + Self { + session: None, + connector, + state: AdapterState::AwaitingInitialize, + paused: false, + next_seq: 1, + pending_attach: None, + snapshot: None, + pending_stop: None, + generation: 0, + inspect_deadline: None, + upload_deadline: None, + source_step: None, + image: None, + source_path: None, + vm_frame_trace: false, + } + } + + /// Handles one incoming DAP request. + pub fn handle_request(&mut self, request: Request) -> AdapterOutput { + match request.command.as_str() { + "initialize" => self.initialize(request), + "attach" => self.attach(request), + "configurationDone" => self.configuration_done(request), + "threads" => self.threads(request), + "stackTrace" => self.stack_trace(request), + "source" => self.source(request), + "scopes" => self.scopes(request), + "variables" => self.variables(request), + "continue" => self.command(request, DebugCommand::Continue, true), + "pause" => self.command(request, DebugCommand::Pause, false), + "stepIn" => self.source_step(request, StepKind::Step), + "next" => self.source_step(request, StepKind::StepOver), + "terminate" => self.terminate_request(request), + "disconnect" => self.disconnect(request), + _ => AdapterOutput::one(self.failure(&request, "unsupported DAP request")), + } + } + + /// Polls the debug session and translates pending events. + pub fn pump_events(&mut self) -> AdapterOutput { + let mut output = AdapterOutput::default(); + let now = Instant::now(); + if self.upload_deadline.is_some_and(|deadline| now >= deadline) { + self.fail_attach(&mut output, "module update acknowledgement timed out"); + return output; + } + if self + .inspect_deadline + .is_some_and(|deadline| now >= deadline) + { + self.inspect_deadline = None; + if let Some(stopped) = self.pending_stop.take() { + output.messages.push( + self.output_event("WARDuino inspect timed out; paused state is unavailable"), + ); + self.finish_stop(&mut output, stopped); + } + return output; + } + if self + .source_step + .as_ref() + .is_some_and(|step| now >= step.deadline) + { + self.source_step = None; + output.messages.push(self.output_event( + "WARDuino source step timed out before reaching a new source location", + )); + return output; + } + loop { + let event = match self.session.as_mut() { + Some(session) => match session.try_recv() { + Ok(event) => event, + Err(error) => return self.debug_error(&mut output, error.to_string()), + }, + None => return output, + }; + match event { + Some(frame) => { + self.emit_received_vm_frame(&mut output, &frame); + self.translate_event(frame.event, &mut output); + } + None => return output, + } + if output.terminate { + return output; + } + } + } + + /// Negotiates capabilities with the DAP client. + fn initialize(&mut self, request: Request) -> AdapterOutput { + if self.state != AdapterState::AwaitingInitialize { + return AdapterOutput::one(self.failure(&request, "initialize is only valid once")); + } + self.vm_frame_trace = request + .arguments + .get("warduinoVmFrame") + .and_then(Value::as_bool) + == Some(true); + self.state = AdapterState::Ready; + AdapterOutput::one(self.success( + &request, + json!({ + "supportsConfigurationDoneRequest": true, + "supportsTerminateRequest": true + }), + )) + } + + /// Connects to the target and uploads the program image. + fn attach(&mut self, request: Request) -> AdapterOutput { + if self.state != AdapterState::Ready { + return AdapterOutput::one(self.failure(&request, "attach requires initialize")); + } + let Some(device) = request + .arguments + .get("device") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return AdapterOutput::one( + self.failure(&request, "attach requires a non-empty device"), + ); + }; + let Some(program) = request + .arguments + .get("program") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return AdapterOutput::one( + self.failure(&request, "attach requires a non-empty WAT program path"), + ); + }; + let image = match ProgramImage::from_path(Path::new(program)) { + Ok(image) => image, + Err(error) => return AdapterOutput::one(self.failure(&request, &error)), + }; + let mut session = match self.connector.connect(device) { + Ok(session) => session, + Err(error) => return AdapterOutput::one(self.failure(&request, &error)), + }; + let upload = DebugCommand::UpdateModule(image.wasm().to_vec()); + let receipt = match session.send(upload.clone()) { + Ok(receipt) => receipt, + Err(error) => return AdapterOutput::one(self.failure(&request, &error.to_string())), + }; + self.session = Some(session); + self.image = Some(image); + self.source_path = Some(program.to_owned()); + self.pending_attach = Some(request); + self.state = AdapterState::AwaitingModuleUpdate; + self.upload_deadline = Some(Instant::now() + OPERATION_TIMEOUT); + let mut output = AdapterOutput::default(); + self.emit_vm_frame( + &mut output, + receipt, + &DebugCommand::UpdateModule(Vec::new()), + ); + output + } + + /// Completes attachment after client configuration. + fn configuration_done(&mut self, request: Request) -> AdapterOutput { + if self.state != AdapterState::Configuring { + return AdapterOutput::one(self.failure( + &request, + "configurationDone requires an acknowledged attach", + )); + } + let Some(attach) = self.pending_attach.take() else { + return AdapterOutput::one(self.failure(&request, "attach request is missing")); + }; + self.state = AdapterState::Attached; + AdapterOutput { + messages: vec![ + self.success(&request, json!({})), + self.success(&attach, json!({})), + ], + terminate: false, + } + } + + /// Returns the synthetic debug thread. + fn threads(&mut self, request: Request) -> AdapterOutput { + if self.state != AdapterState::Attached { + return AdapterOutput::one( + self.failure(&request, "threads requires an attached session"), + ); + } + AdapterOutput::one(self.success( + &request, + json!({"threads": [{"id": THREAD_ID, "name": "WARDuino VM"}]}), + )) + } + + /// Returns the current mapped stack frame. + fn stack_trace(&mut self, request: Request) -> AdapterOutput { + if !has_synthetic_thread(&request.arguments) { + return AdapterOutput::one(self.failure(&request, "unknown threadId")); + } + let Some(snapshot) = self.paused_snapshot() else { + return AdapterOutput::one( + self.failure(&request, "stackTrace requires a ready paused session"), + ); + }; + let Some(image) = self.image.as_ref() else { + return AdapterOutput::one( + self.failure(&request, "no uploaded program image is available"), + ); + }; + let Some(frame) = image.frame_at(snapshot.program_counter.0) else { + return AdapterOutput::one(self.failure( + &request, + "current program counter is not mapped to the uploaded WAT", + )); + }; + let path = self.source_path.as_deref().unwrap_or(image.source_name()); + AdapterOutput::one(self.success(&request, json!({ + "stackFrames": [{ + "id": self.generation, + "name": frame.function, + "source": {"name": image.source_name(), "path": path, "sourceReference": SOURCE_REFERENCE}, + "line": frame.location.line, + "column": frame.location.column, + "instructionPointerReference": format!("0x{:08x}", snapshot.program_counter.0) + }], + "totalFrames": 1 + }))) + } + + /// Returns the uploaded source text. + fn source(&mut self, request: Request) -> AdapterOutput { + if request + .arguments + .get("sourceReference") + .and_then(Value::as_i64) + != Some(SOURCE_REFERENCE) + { + return AdapterOutput::one(self.failure(&request, "unknown sourceReference")); + } + let Some(image) = self.image.as_ref() else { + return AdapterOutput::one( + self.failure(&request, "no uploaded program source is available"), + ); + }; + AdapterOutput::one(self.success( + &request, + json!({"content": image.source(), "mimeType": "text/plain"}), + )) + } + + /// Returns scopes for the current frame. + fn scopes(&mut self, request: Request) -> AdapterOutput { + if self.paused_snapshot().is_none() + || request.arguments.get("frameId").and_then(Value::as_i64) != Some(self.generation) + { + return AdapterOutput::one(self.failure(&request, "unknown or stale frameId")); + } + AdapterOutput::one(self.success(&request, json!({"scopes": [{"name": "VM", "variablesReference": self.generation, "expensive": false}]}))) + } + + /// Returns variables for the current frame. + fn variables(&mut self, request: Request) -> AdapterOutput { + let Some(snapshot) = self.paused_snapshot() else { + return AdapterOutput::one( + self.failure(&request, "variables requires a ready paused session"), + ); + }; + if request + .arguments + .get("variablesReference") + .and_then(Value::as_i64) + != Some(self.generation) + { + return AdapterOutput::one( + self.failure(&request, "unknown or stale variablesReference"), + ); + } + AdapterOutput::one(self.success(&request, json!({"variables": [ + {"name": "pc", "value": format!("0x{:08x}", snapshot.program_counter.0), "type": "u32", "variablesReference": 0}, + {"name": "state", "value": format!("{:?}", snapshot.state), "type": "WARDuino state", "variablesReference": 0} + ]}))) + } + + /// Starts a source-level stepping operation. + fn source_step(&mut self, request: Request, kind: StepKind) -> AdapterOutput { + if request.arguments.get("granularity").and_then(Value::as_str) == Some("instruction") { + return self.command(request, kind.command(), false); + } + if self.state != AdapterState::Attached || !has_synthetic_thread(&request.arguments) { + return AdapterOutput::one(self.failure( + &request, + "source step requires an attached synthetic thread", + )); + } + let Some(snapshot) = self.paused_snapshot() else { + return AdapterOutput::one( + self.failure(&request, "source step requires a ready paused session"), + ); + }; + let Some(location) = self + .image + .as_ref() + .and_then(|image| image.frame_at(snapshot.program_counter.0)) + .map(|frame| frame.location) + else { + return AdapterOutput::one(self.failure( + &request, + "cannot source-step from an unmapped program counter", + )); + }; + let command = kind.command(); + let send = self + .session + .as_mut() + .map(|session| session.send(command.clone())); + match send { + Some(Ok(receipt)) => { + self.paused = false; + self.snapshot = None; + self.source_step = Some(Step { + kind, + location, + deadline: Instant::now() + OPERATION_TIMEOUT, + }); + let mut output = AdapterOutput::one(self.success(&request, json!({}))); + self.emit_vm_frame(&mut output, receipt, &command); + output + } + Some(Err(error)) => { + let mut output = AdapterOutput::one(self.failure(&request, &error.to_string())); + self.disconnect_after_error(&mut output, error.to_string()); + output + } + None => AdapterOutput::one(self.failure(&request, "debug session is disconnected")), + } + } + + /// Returns the snapshot when the adapter is paused and attached. + fn paused_snapshot(&self) -> Option<&debug::Snapshot> { + (self.state == AdapterState::Attached && self.paused).then_some(())?; + self.snapshot.as_ref() + } + + /// Sends a command to the debug session. + fn command( + &mut self, + request: Request, + command: DebugCommand, + continued: bool, + ) -> AdapterOutput { + if self.state != AdapterState::Attached { + return AdapterOutput::one( + self.failure(&request, "request requires an attached session"), + ); + } + if !has_synthetic_thread(&request.arguments) { + return AdapterOutput::one(self.failure(&request, "unknown threadId")); + } + let trace_command = command.clone(); + let send = self.session.as_mut().map(|session| session.send(command)); + match send { + Some(Ok(receipt)) => { + if continued { + self.paused = false; + self.snapshot = None; + } + let body = if continued { + json!({"allThreadsContinued": true}) + } else { + json!({}) + }; + let mut output = AdapterOutput::one(self.success(&request, body)); + self.emit_vm_frame(&mut output, receipt, &trace_command); + output + } + Some(Err(error)) => { + let mut output = AdapterOutput::one(self.failure(&request, &error.to_string())); + self.disconnect_after_error(&mut output, error.to_string()); + output + } + None => AdapterOutput::one(self.failure(&request, "debug session is disconnected")), + } + } + + /// Halts the target in response to a terminate request. + fn terminate_request(&mut self, request: Request) -> AdapterOutput { + if self.state != AdapterState::Attached { + return AdapterOutput::one( + self.failure(&request, "terminate requires an attached session"), + ); + } + let send = self + .session + .as_mut() + .map(|session| session.send(DebugCommand::Halt)); + match send { + Some(Ok(receipt)) => { + let mut output = AdapterOutput::one(self.success(&request, json!({}))); + self.emit_vm_frame(&mut output, receipt, &DebugCommand::Halt); + output + } + Some(Err(error)) => { + let mut output = AdapterOutput::one(self.failure(&request, &error.to_string())); + self.disconnect_after_error(&mut output, error.to_string()); + output + } + None => AdapterOutput::one(self.failure(&request, "debug session is disconnected")), + } + } + + /// Disconnects from the target and terminates the adapter. + fn disconnect(&mut self, request: Request) -> AdapterOutput { + if self.state == AdapterState::Disconnected { + return AdapterOutput::one(self.failure(&request, "session is already disconnected")); + } + let pending_attach = self.pending_attach.take(); + let mut output = AdapterOutput::default(); + if self.state == AdapterState::Attached + && self.paused + && let Some(Ok(receipt)) = self + .session + .as_mut() + .map(|session| session.send(DebugCommand::Continue)) + { + self.emit_vm_frame(&mut output, receipt, &DebugCommand::Continue); + } + self.session.take(); + self.state = AdapterState::Disconnected; + if let Some(attach) = pending_attach { + output + .messages + .push(self.failure(&attach, "attach cancelled by disconnect")); + } + output.messages.push(self.success(&request, json!({}))); + output.messages.push(self.event("terminated", json!({}))); + output.terminate = true; + output + } + + /// Emits optional tracing for an incoming VM frame. + fn emit_received_vm_frame(&mut self, output: &mut AdapterOutput, frame: &ReceivedFrame) { + if self.vm_frame_trace { + output.messages.push(self.event("warduino/vmFrame", json!({"direction":"incoming", "bytes":frame.bytes(), "command":debug_event_name(&frame.event), "fields":debug_event_fields(&frame.event)}))); + } + } + + /// Applies a debug event and emits its DAP messages. + fn translate_event(&mut self, event: DebugEvent, output: &mut AdapterOutput) { + match event { + DebugEvent::Continued => { + self.paused = false; + self.snapshot = None; + self.pending_stop = None; + self.inspect_deadline = None; + output.messages.push(self.event( + "continued", + json!({"threadId": THREAD_ID, "allThreadsContinued": true}), + )); + } + DebugEvent::Stopped(stopped) => { + self.paused = true; + self.snapshot = None; + self.pending_stop = Some(stopped); + self.inspect_deadline = Some(Instant::now() + INSPECT_TIMEOUT); + match self + .session + .as_mut() + .map(|session| session.send(DebugCommand::Inspect(Vec::new()))) + { + Some(Ok(receipt)) => { + self.emit_vm_frame(output, receipt, &DebugCommand::Inspect(Vec::new())) + } + Some(Err(error)) => self.disconnect_after_error(output, error.to_string()), + None => {} + } + } + DebugEvent::Snapshot(snapshot) if self.pending_stop.is_some() => { + self.inspect_deadline = None; + self.snapshot = Some(snapshot); + if let Some(stopped) = self.pending_stop.take() { + self.finish_stop(output, stopped); + } + } + DebugEvent::OperationResult(result) + if result.command == debug::CommandKind::UpdateModule + && self.state == AdapterState::AwaitingModuleUpdate => + { + self.upload_deadline = None; + if result.success { + self.state = AdapterState::Configuring; + output.messages.push(self.event("initialized", json!({}))); + } else { + self.fail_attach(output, "WARDuino target rejected module update"); + } + } + DebugEvent::Halted | DebugEvent::Disconnected(_) => self.terminate(output), + DebugEvent::OperationResult(result) if !result.success => self.remote_failure( + output, + format!( + "WARDuino target rejected {:?} command asynchronously", + result.command + ), + ), + DebugEvent::TargetMalformedCommand => self.remote_failure( + output, + "WARDuino target rejected a malformed command asynchronously".into(), + ), + DebugEvent::TargetUnknownCommand => self.remote_failure( + output, + "WARDuino target rejected an unknown command asynchronously".into(), + ), + _ => {} + } + } + + /// Determines whether a source step should continue. + fn should_continue(&mut self, step: &Step, stopped: Stopped) -> bool { + step.kind.accepts_stop(stopped.reason) + && self + .mapped() + .as_ref() + .is_some_and(|frame| frame.location == step.location) + && Instant::now() < step.deadline + } + + /// Maps the current program counter to source. + fn mapped(&mut self) -> Option { + self.snapshot.as_ref().and_then(|snapshot| { + self.image + .as_ref() + .and_then(|image| image.frame_at(snapshot.program_counter.0)) + }) + } + + /// Completes processing of a stopped target. + fn finish_stop(&mut self, output: &mut AdapterOutput, mut stopped: Stopped) { + let Some(step) = self.source_step.clone() else { + self.emit_stopped(output, stopped); + return; + }; + + if self.should_continue(&step, stopped) { + let command = step.kind.command(); + let send = self + .session + .as_mut() + .map(|session| session.send(command.clone())); + match send { + Some(Ok(receipt)) => { + self.paused = false; + self.snapshot = None; + self.emit_vm_frame(output, receipt, &command); + return; + } + Some(Err(error)) => { + self.source_step = None; + self.disconnect_after_error(output, error.to_string()); + return; + } + None => {} + } + } + if self.mapped().is_none() { + output.messages.push( + self.output_event("WARDuino source step stopped at an unmapped program counter"), + ); + } else if self.should_continue(&step, stopped) { + output.messages.push( + self.output_event("WARDuino source step was interrupted by an unrelated stop"), + ); + } else if Instant::now() >= step.deadline { + output.messages.push(self.output_event( + "WARDuino source step timed out before reaching a new source location", + )); + } + self.source_step = None; + if !self.should_continue(&step, stopped) && stopped.reason == StopReason::Breakpoint { + stopped.reason = StopReason::Step; + } + self.emit_stopped(output, stopped); + } + + /// Emits a DAP stopped event. + fn emit_stopped(&mut self, output: &mut AdapterOutput, stopped: Stopped) { + self.generation += 1; + let reason = match stopped.reason { + StopReason::Pause => "pause", + StopReason::Step => "step", + StopReason::Breakpoint => "breakpoint", + }; + output.messages.push(self.event( + "stopped", + json!({"reason": reason, "threadId": THREAD_ID, "allThreadsStopped": true}), + )); + } + + /// Reports an attachment failure and terminates. + fn fail_attach(&mut self, output: &mut AdapterOutput, message: &str) { + if let Some(attach) = self.pending_attach.take() { + output.messages.push(self.failure(&attach, message)); + } + output.messages.push(self.output_event(message)); + self.terminate(output); + } + + /// Reports an asynchronous target failure. + fn remote_failure(&mut self, output: &mut AdapterOutput, message: String) { + output.messages.push(self.output_event(&message)); + } + + /// Emits termination and closes the session. + fn terminate(&mut self, output: &mut AdapterOutput) { + if self.state == AdapterState::Disconnected { + return; + } + output.messages.push(self.event("terminated", json!({}))); + self.session.take(); + self.state = AdapterState::Disconnected; + output.terminate = true; + } + + /// Emits optional tracing for an outgoing VM frame. + fn emit_vm_frame( + &mut self, + output: &mut AdapterOutput, + frame: debug::SentFrame, + command: &DebugCommand, + ) { + if self.vm_frame_trace { + output.messages.push(self.event("warduino/vmFrame", json!({"direction":"outgoing", "command":debug_command_name(command), "bytes":frame.bytes(), "fields":debug_command_fields(command)}))); + } + } + + /// Converts a debug-session error into adapter output. + fn debug_error(&mut self, output: &mut AdapterOutput, message: String) -> AdapterOutput { + self.disconnect_after_error(output, message); + std::mem::take(output) + } + + /// Disconnects after a fatal debug-session error. + fn disconnect_after_error(&mut self, output: &mut AdapterOutput, message: String) { + output.messages.push(self.output_event(&message)); + self.terminate(output); + } + + /// Builds a successful DAP response. + fn success(&mut self, request: &Request, body: Value) -> Value { + self.response(request, true, body, None) + } + /// Builds a failed DAP response. + fn failure(&mut self, request: &Request, message: &str) -> Value { + self.response(request, false, json!({}), Some(message)) + } + + /// Builds a DAP response envelope. + fn response( + &mut self, + request: &Request, + success: bool, + body: Value, + message: Option<&str>, + ) -> Value { + let mut response = json!({"seq": self.sequence(), "type": "response", "request_seq": request.seq, "success": success, "command": request.command, "body": body}); + if let Some(message) = message { + response["message"] = json!(message); + } + response + } + + /// Builds a DAP event envelope. + fn event(&mut self, name: &str, body: Value) -> Value { + json!({"seq": self.sequence(), "type": "event", "event": name, "body": body}) + } + /// Builds an adapter output event. + fn output_event(&mut self, message: &str) -> Value { + self.event( + "output", + json!({"category": "stderr", "output": format!("{message}\n")}), + ) + } + /// Allocates the next DAP message sequence number. + fn sequence(&mut self) -> u64 { + let sequence = self.next_seq; + self.next_seq += 1; + sequence + } +} + +fn debug_command_name(command: &DebugCommand) -> &'static str { + match command { + DebugCommand::Continue => "continue", + DebugCommand::Halt => "halt", + DebugCommand::Pause => "pause", + DebugCommand::Step => "step", + DebugCommand::StepOver => "next", + DebugCommand::ContinueFor(_) => "continue", + DebugCommand::AddBreakpoint(_) => "setBreakpoint", + DebugCommand::RemoveBreakpoint(_) => "removeBreakpoint", + DebugCommand::RequestSnapshot => "snapshot", + DebugCommand::Inspect(_) => "inspect", + DebugCommand::Reset => "reset", + DebugCommand::UpdateModule(_) => "updateModule", + _ => "unknown", + } +} + +fn debug_command_fields(command: &DebugCommand) -> Value { + match command { + DebugCommand::ContinueFor(count) => json!({"count": count}), + DebugCommand::AddBreakpoint(location) | DebugCommand::RemoveBreakpoint(location) => { + json!({"module": location.module.0, "pc": location.program_counter.0}) + } + DebugCommand::Inspect(state) => json!({"state": state}), + DebugCommand::UpdateModule(wasm) => json!({"bytes": wasm.len()}), + _ => json!({}), + } +} + +fn debug_event_name(event: &DebugEvent) -> &'static str { + match event { + DebugEvent::Stopped(_) => "stopped", + DebugEvent::Continued => "continued", + DebugEvent::Halted => "halted", + DebugEvent::Snapshot(_) => "snapshot", + DebugEvent::OperationResult(_) => "operationResult", + DebugEvent::TargetMalformedCommand => "targetMalformedCommand", + DebugEvent::TargetUnknownCommand => "targetUnknownCommand", + DebugEvent::Disconnected(_) => "disconnected", + _ => "unknown", + } +} + +fn debug_event_fields(event: &DebugEvent) -> Value { + match event { + DebugEvent::Stopped(stopped) => { + json!({"reason": format!("{:?}", stopped.reason), "pc": stopped.location.map(|location| location.program_counter.0)}) + } + DebugEvent::Snapshot(snapshot) => { + json!({"pc": snapshot.program_counter.0, "state": format!("{:?}", snapshot.state)}) + } + DebugEvent::OperationResult(result) => { + json!({"success": result.success, "command": format!("{:?}", result.command)}) + } + _ => json!({}), + } +} + +fn has_synthetic_thread(arguments: &Value) -> bool { + arguments.get("threadId").and_then(Value::as_i64) == Some(THREAD_ID) +} diff --git a/tools/syrup/crates/dap/src/lib.rs b/tools/syrup/crates/dap/src/lib.rs new file mode 100644 index 000000000..a5fd0790d --- /dev/null +++ b/tools/syrup/crates/dap/src/lib.rs @@ -0,0 +1,17 @@ +//! The deliberately small Debug Adapter Protocol bridge used to review the +//! WARDuino debug-session API. + +pub mod adapter; +pub mod protocol; +pub mod source; + +use debug::WarduinoSession; + +pub use adapter::{Adapter, AdapterOutput, SessionConnector}; +pub use protocol::{Request, read_message, write_message}; + +pub type WarduinoAdapter = Adapter Result>; + +pub fn warduino_adapter() -> WarduinoAdapter { + Adapter::new(|device: &str| WarduinoSession::connect(device).map_err(|error| error.to_string())) +} diff --git a/tools/syrup/crates/dap/src/protocol.rs b/tools/syrup/crates/dap/src/protocol.rs new file mode 100644 index 000000000..b35f93d5b --- /dev/null +++ b/tools/syrup/crates/dap/src/protocol.rs @@ -0,0 +1,158 @@ +use std::io::{self, BufRead, Write}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +const MAX_MESSAGE_SIZE: usize = 1024 * 1024; +const MAX_HEADER_LINE_LENGTH: usize = 8 * 1024; + +#[derive(Clone, Debug, Deserialize)] +pub struct Request { + pub seq: u64, + #[serde(rename = "type")] + pub message_type: String, + pub command: String, + #[serde(default)] + pub arguments: Value, +} + +pub fn read_message(reader: &mut impl BufRead) -> io::Result> { + let mut content_length = None; + let mut saw_header = false; + let mut line = Vec::new(); + loop { + if !read_header_line(reader, &mut line)? { + return if saw_header { + Err(invalid_data("unexpected EOF in DAP headers")) + } else { + Ok(None) + }; + } + saw_header = true; + + let line = line + .strip_suffix(b"\n") + .expect("header lines end in newline"); + let line = line.strip_suffix(b"\r").unwrap_or(line); + if line.is_empty() { + break; + } + let line = + std::str::from_utf8(line).map_err(|_| invalid_data("DAP header is not valid UTF-8"))?; + let (name, value) = line + .split_once(':') + .ok_or_else(|| invalid_data("malformed DAP header"))?; + if name.eq_ignore_ascii_case("Content-Length") { + if content_length.is_some() { + return Err(invalid_data("duplicate Content-Length header")); + } + let length = value + .trim() + .parse::() + .map_err(|_| invalid_data("invalid Content-Length header"))?; + if length > MAX_MESSAGE_SIZE { + return Err(invalid_data("DAP message exceeds maximum size")); + } + content_length = Some(length); + } + } + let length = content_length.ok_or_else(|| invalid_data("missing Content-Length header"))?; + let mut body = vec![0; length]; + reader.read_exact(&mut body)?; + serde_json::from_slice(&body) + .map(Some) + .map_err(|error| invalid_data(format!("invalid DAP JSON: {error}"))) +} + +fn read_header_line(reader: &mut impl BufRead, line: &mut Vec) -> io::Result { + line.clear(); + loop { + let buffer = reader.fill_buf()?; + if buffer.is_empty() { + return if line.is_empty() { + Ok(false) + } else { + Err(invalid_data("unexpected EOF in DAP headers")) + }; + } + let count = buffer + .iter() + .position(|byte| *byte == b'\n') + .map_or(buffer.len(), |position| position + 1); + if line.len() + count > MAX_HEADER_LINE_LENGTH { + return Err(invalid_data("DAP header line exceeds maximum size")); + } + line.extend_from_slice(&buffer[..count]); + reader.consume(count); + if line.last() == Some(&b'\n') { + return Ok(true); + } + } +} + +pub fn write_message(writer: &mut impl Write, value: &impl Serialize) -> io::Result<()> { + let body = serde_json::to_vec(value) + .map_err(|error| invalid_data(format!("cannot serialize DAP JSON: {error}")))?; + writer.write_all(format!("Content-Length: {}\r\n\r\n", body.len()).as_bytes())?; + writer.write_all(&body)?; + writer.flush() +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +#[cfg(test)] +mod tests { + use std::io::{BufReader, Cursor}; + + use serde_json::json; + + use super::*; + + #[test] + fn framing_uses_byte_length_and_accepts_extra_headers() { + let value = json!({"message": "é"}); + let mut framed = Vec::new(); + write_message(&mut framed, &value).unwrap(); + let expected = serde_json::to_vec(&value).unwrap().len(); + assert!(framed.starts_with(format!("Content-Length: {expected}\r\n\r\n").as_bytes())); + + let body = br#"{"seq":1,"type":"request","command":"initialize"}"#; + let input = format!("X-Trace: smoke\r\nContent-Length: {}\r\n\r\n", body.len()); + let mut bytes = input.into_bytes(); + bytes.extend_from_slice(body); + let request = read_message(&mut BufReader::new(Cursor::new(bytes))) + .unwrap() + .unwrap(); + assert_eq!(request.command, "initialize"); + } + + #[test] + fn framing_rejects_invalid_lengths_and_accepts_clean_eof() { + assert!( + read_message(&mut BufReader::new(Cursor::new(b""))) + .unwrap() + .is_none() + ); + assert!( + read_message(&mut BufReader::new(Cursor::new( + b"Content-Length: 1\r\nContent-Length: 1\r\n\r\n{}", + ))) + .is_err() + ); + assert!( + read_message(&mut BufReader::new(Cursor::new( + b"Content-Length: 1048577\r\n\r\n", + ))) + .is_err() + ); + } + + #[test] + fn framing_rejects_overlong_header_lines() { + let mut input = vec![b'x'; MAX_HEADER_LINE_LENGTH + 1]; + input.push(b'\n'); + assert!(read_message(&mut BufReader::new(Cursor::new(input))).is_err()); + } +} diff --git a/tools/syrup/crates/dap/src/server/server.rs b/tools/syrup/crates/dap/src/server/server.rs new file mode 100644 index 000000000..9cb50f374 --- /dev/null +++ b/tools/syrup/crates/dap/src/server/server.rs @@ -0,0 +1,216 @@ +use std::{ + env, + io::{self, BufRead, BufReader, BufWriter, Write}, + net::{TcpListener, TcpStream}, + sync::mpsc, + thread, + time::Duration, +}; + +use warduino_dap::{Request, read_message, warduino_adapter, write_message}; + +enum Input { + Request(Request), + End, + Error(io::Error), +} + +enum Transport { + Stdio, + Tcp(String), +} + +fn main() { + match parse_transport() { + Ok(Transport::Stdio) => run_stdio(), + Ok(Transport::Tcp(address)) => run_tcp(&address), + Err(()) => { + eprintln!("usage: warduino-dap [--dap-server ]"); + std::process::exit(2); + } + } +} + +fn parse_transport() -> Result { + let arguments: Vec<_> = env::args().skip(1).collect(); + match arguments.as_slice() { + [] => Ok(Transport::Stdio), + [flag, address] if flag == "--dap-server" => Ok(Transport::Tcp(address.clone())), + _ => Err(()), + } +} + +fn run_stdio() { + let stdin = io::stdin(); + let stdout = io::stdout(); + run_connection( + move |sender| read_requests(BufReader::new(stdin.lock()), sender), + BufWriter::new(stdout.lock()), + warduino_adapter(), + ); +} + +fn run_tcp(address: &str) { + let listener = match TcpListener::bind(address) { + Ok(listener) => listener, + Err(error) => { + eprintln!("cannot bind DAP server at {address}: {error}"); + std::process::exit(1); + } + }; + eprintln!("DAP server listening on {address}"); + + for stream in listener.incoming() { + match stream { + Ok(stream) => { + eprintln!("DAP client connected"); + run_tcp_connection(stream); + } + Err(error) => eprintln!("cannot accept DAP client: {error}"), + } + } +} + +fn run_tcp_connection(stream: TcpStream) { + let reader_stream = match stream.try_clone() { + Ok(stream) => stream, + Err(error) => { + eprintln!("cannot prepare DAP client connection: {error}"); + return; + } + }; + run_connection( + move |sender| read_requests(BufReader::new(reader_stream), sender), + BufWriter::new(stream), + warduino_adapter(), + ); +} + +fn read_requests(mut reader: impl BufRead, sender: mpsc::Sender) { + loop { + match read_message(&mut reader) { + Ok(Some(request)) => { + if sender.send(Input::Request(request)).is_err() { + return; + } + } + Ok(None) => { + let _ = sender.send(Input::End); + return; + } + Err(error) => { + let _ = sender.send(Input::Error(error)); + return; + } + } + } +} + +fn run_connection(read: F, mut writer: W, mut adapter: warduino_dap::WarduinoAdapter) +where + W: Write, + F: FnOnce(mpsc::Sender) + Send + 'static, +{ + let (sender, receiver) = mpsc::channel(); + thread::spawn(move || read(sender)); + + let mut disconnected = false; + loop { + match receiver.recv_timeout(Duration::from_millis(20)) { + Ok(Input::Request(request)) => { + let output = adapter.handle_request(request); + if !write_all(&mut writer, output.messages) { + break; + } + if output.terminate { + disconnected = true; + break; + } + } + Ok(Input::End) => break, + Ok(Input::Error(error)) => { + eprintln!("malformed DAP input: {error}"); + break; + } + Err(mpsc::RecvTimeoutError::Timeout) => {} + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + + let output = adapter.pump_events(); + if !write_all(&mut writer, output.messages) || output.terminate { + disconnected = output.terminate; + break; + } + } + if !disconnected { + detach(&mut adapter); + } +} + +fn detach(adapter: &mut warduino_dap::WarduinoAdapter) { + let _ = adapter.handle_request(Request { + seq: 0, + message_type: "request".into(), + command: "disconnect".into(), + arguments: serde_json::json!({}), + }); +} + +fn write_all(writer: &mut impl Write, messages: Vec) -> bool { + for message in messages { + if let Err(error) = write_message(writer, &message) { + eprintln!("cannot write DAP response: {error}"); + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use std::net::TcpListener; + + use serde_json::json; + + use super::*; + + #[test] + fn tcp_connection_handles_initialize_and_client_eof() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + run_tcp_connection(stream); + }); + let mut client = TcpStream::connect(address).unwrap(); + let initialize = + json!({"seq": 1, "type": "request", "command": "initialize", "arguments": {}}); + write_message(&mut client, &initialize).unwrap(); + + let mut reader = BufReader::new(client.try_clone().unwrap()); + let response = read_response(&mut reader); + assert_eq!(response["success"], true); + assert_eq!(response["body"]["supportsConfigurationDoneRequest"], true); + + drop(reader); + drop(client); + server.join().unwrap(); + } + + fn read_response(reader: &mut impl BufRead) -> serde_json::Value { + let mut header = String::new(); + reader.read_line(&mut header).unwrap(); + let length = header + .strip_prefix("Content-Length: ") + .unwrap() + .trim() + .parse::() + .unwrap(); + header.clear(); + reader.read_line(&mut header).unwrap(); + assert_eq!(header, "\r\n"); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + serde_json::from_slice(&body).unwrap() + } +} diff --git a/tools/syrup/crates/dap/src/source.rs b/tools/syrup/crates/dap/src/source.rs new file mode 100644 index 000000000..2d4cc220f --- /dev/null +++ b/tools/syrup/crates/dap/src/source.rs @@ -0,0 +1,221 @@ +//! Immutable WAT images and the small source map needed by the limited DAP adapter. + +use std::{fs, path::Path}; + +use wasmparser::{Parser, Payload}; +use wast::{ + Wat, + core::{FuncKind, ModuleField}, + parser::{self, ParseBuffer}, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MappedFrame { + pub function: String, + pub location: SourceLocation, +} + +#[derive(Clone, Debug)] +struct PcLocation { + start: u32, + end: u32, + location: SourceLocation, +} + +#[derive(Clone, Debug)] +struct FunctionRange { + name: String, + start: u32, + end: u32, +} + +/// The exact text and binary sent to the VM during attach. +#[derive(Clone, Debug)] +pub struct ProgramImage { + source_name: String, + source: String, + wasm: Vec, + functions: Vec, + locations: Vec, +} + +impl ProgramImage { + pub fn from_path(path: &Path) -> Result { + let source = fs::read_to_string(path) + .map_err(|error| format!("cannot read WAT program {}: {error}", path.display()))?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("module.wat") + .to_owned(); + Self::from_wat(name, source) + } + + pub fn from_wat(source_name: impl Into, source: String) -> Result { + let mut buffer = ParseBuffer::new(&source) + .map_err(|error| format!("cannot parse WAT source: {error}"))?; + buffer.track_instr_spans(true); + let mut wat: Wat<'_> = + parser::parse(&buffer).map_err(|error| format!("cannot parse WAT module: {error}"))?; + let wasm = wat + .encode() + .map_err(|error| format!("cannot compile WAT module: {error}"))?; + let Wat::Module(module) = &wat else { + return Err("WAT components are not supported by WARDuino".into()); + }; + + let mut source_functions = Vec::new(); + if let wast::core::ModuleKind::Text(fields) = &module.kind { + let mut index = 0_u32; + for field in fields { + let ModuleField::Func(function) = field else { + continue; + }; + let name = function + .name + .map(|name| name.name.to_owned()) + .or_else(|| function.id.map(|id| id.name().to_owned())) + .unwrap_or_else(|| format!("func[{index}]")); + if let FuncKind::Inline { expression, .. } = &function.kind { + let spans = expression + .instr_spans + .as_deref() + .ok_or_else(|| "WAT parser did not retain instruction spans".to_owned())?; + source_functions.push((index, name, function.span, spans.to_vec())); + } + index += 1; + } + } + + let mut functions = Vec::with_capacity(source_functions.len()); + let mut locations = Vec::new(); + let mut body_index = 0_usize; + for payload in Parser::new(0).parse_all(&wasm) { + let payload = + payload.map_err(|error| format!("cannot parse generated WASM: {error}"))?; + let Payload::CodeSectionEntry(body) = payload else { + continue; + }; + let (_index, name, function_span, spans) = source_functions + .get(body_index) + .ok_or_else(|| "generated WASM has more function bodies than WAT".to_owned())?; + body_index += 1; + let mut reader = body + .get_operators_reader() + .map_err(|error| format!("cannot read generated WASM operators: {error}"))?; + let mut offsets = Vec::new(); + while !reader.eof() { + let offset = reader.original_position() as u32; + reader + .read() + .map_err(|error| format!("cannot read generated WASM operator: {error}"))?; + offsets.push(offset); + } + let start = offsets + .first() + .copied() + .unwrap_or(body.range().start as u32); + let end = body.range().end as u32; + functions.push(FunctionRange { + name: name.clone(), + start, + end, + }); + for (operator_index, start) in offsets.into_iter().enumerate() { + let span = spans.get(operator_index).copied().unwrap_or(*function_span); + let (line, column) = span.linecol_in(&source); + locations.push(PcLocation { + start, + end, + location: SourceLocation { + line: line as u32 + 1, + column: column as u32 + 1, + }, + }); + } + } + if body_index != source_functions.len() { + return Err("generated WASM has fewer function bodies than WAT".into()); + } + locations.sort_by_key(|location| location.start); + for index in 0..locations.len().saturating_sub(1) { + locations[index].end = locations[index + 1].start; + } + for location in &mut locations { + if location.end == 0 { + location.end = location.start.saturating_add(1); + } + } + + Ok(Self { + source_name: source_name.into(), + source, + wasm, + functions, + locations, + }) + } + + pub fn wasm(&self) -> &[u8] { + &self.wasm + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn source_name(&self) -> &str { + &self.source_name + } + + pub fn instruction_pcs(&self) -> impl Iterator + '_ { + self.locations.iter().map(|location| location.start) + } + + pub fn frame_at(&self, pc: u32) -> Option { + let function = self + .functions + .iter() + .find(|function| function.start <= pc && pc < function.end)?; + let location = self + .locations + .iter() + .find(|location| location.start <= pc && pc < location.end)? + .location + .clone(); + Some(MappedFrame { + function: function.name.clone(), + location, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_operator_offsets_to_named_wat_source_lines() { + let image = ProgramImage::from_wat( + "example.wat", + "(module\n (func $sum (result i32)\n i32.const 1\n i32.const 2\n i32.add))\n" + .into(), + ) + .unwrap(); + let sum = image + .functions + .iter() + .find(|function| function.name == "sum") + .unwrap(); + let first = image.frame_at(sum.start).unwrap(); + assert_eq!(first.function, "sum"); + assert_eq!(first.location.line, 3); + assert_eq!(image.wasm()[0..4], [0, 0x61, 0x73, 0x6d]); + } +} diff --git a/tools/syrup/crates/dap/tests/fixtures/source-step.wat b/tools/syrup/crates/dap/tests/fixtures/source-step.wat new file mode 100644 index 000000000..5dcfdc3e7 --- /dev/null +++ b/tools/syrup/crates/dap/tests/fixtures/source-step.wat @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2604aa00f0b93fb20d5b42f39e251f9e03e01428cf6e7a9c57b022f5298b949d +size 48 diff --git a/tools/syrup/crates/dap/tests/smoke.rs b/tools/syrup/crates/dap/tests/smoke.rs new file mode 100644 index 000000000..139bc7cd9 --- /dev/null +++ b/tools/syrup/crates/dap/tests/smoke.rs @@ -0,0 +1,246 @@ +use std::{ + cell::RefCell, + collections::VecDeque, + io::{BufRead, BufReader, Cursor, Read}, + rc::Rc, +}; + +use debug::{ + CommandKind, DebugCommand, DebugEvent, DebugSession, OperationResult, ProgramCounter, + ReceivedFrame, Result, SentFrame, Snapshot, StopReason, Stopped, VmState, +}; +use serde_json::{Value, json}; +use warduino_dap::{Adapter, AdapterOutput, read_message, source::ProgramImage, write_message}; + +#[derive(Default)] +struct FakeState { + commands: Vec, + events: VecDeque>>, +} + +#[derive(Clone, Default)] +struct FakeSession(Rc>); + +#[derive(Clone)] +struct FakeConnector(FakeSession); + +impl warduino_dap::SessionConnector for FakeConnector { + fn connect(&mut self, _device: &str) -> std::result::Result { + Ok(self.0.clone()) + } +} + +impl DebugSession for FakeSession { + fn send(&mut self, command: DebugCommand) -> Result { + self.0.borrow_mut().commands.push(command); + Ok(SentFrame::from_complete_frame(vec![0, 0])) + } + + fn try_recv(&mut self) -> Result> { + self.0 + .borrow_mut() + .events + .pop_front() + .unwrap_or(Ok(None)) + .map(|event| event.map(|event| ReceivedFrame::from_complete_frame(event, vec![0, 0]))) + } +} + +type FakeAdapter = Adapter; + +fn request(seq: u64, command: &str, arguments: Value) -> Value { + json!({"seq": seq, "type": "request", "command": command, "arguments": arguments}) +} + +fn dispatch(adapter: &mut FakeAdapter, input: Value) -> Vec { + let mut incoming = Vec::new(); + write_message(&mut incoming, &input).unwrap(); + let request = read_message(&mut BufReader::new(Cursor::new(incoming))) + .unwrap() + .unwrap(); + framed_values(adapter.handle_request(request)) +} + +fn framed_values(output: AdapterOutput) -> Vec { + let mut bytes = Vec::new(); + for message in output.messages { + write_message(&mut bytes, &message).unwrap(); + } + let mut reader = BufReader::new(Cursor::new(bytes)); + let mut values = Vec::new(); + loop { + let mut header = String::new(); + if reader.read_line(&mut header).unwrap() == 0 { + return values; + } + let length = header + .strip_prefix("Content-Length: ") + .unwrap() + .trim() + .parse::() + .unwrap(); + header.clear(); + reader.read_line(&mut header).unwrap(); + assert_eq!(header, "\r\n"); + let mut body = vec![0; length]; + reader.read_exact(&mut body).unwrap(); + values.push(serde_json::from_slice(&body).unwrap()); + } +} + +fn program_path() -> &'static str { + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/source-step.wat" + ) +} + +fn acknowledged_adapter() -> (FakeAdapter, Rc>, Vec) { + let fake = FakeSession::default(); + let state = fake.0.clone(); + let mut adapter = Adapter::new(FakeConnector(fake)); + assert_eq!( + dispatch(&mut adapter, request(1, "initialize", json!({})))[0]["success"], + true + ); + assert!( + dispatch( + &mut adapter, + request( + 2, + "attach", + json!({"device": "test", "program": program_path()}) + ) + ) + .is_empty() + ); + assert!( + matches!(state.borrow().commands.as_slice(), [DebugCommand::UpdateModule(bytes)] if !bytes.is_empty()) + ); + state + .borrow_mut() + .events + .push_back(Ok(Some(DebugEvent::OperationResult(OperationResult { + command: CommandKind::UpdateModule, + success: true, + })))); + let initialized = framed_values(adapter.pump_events()); + assert_eq!(initialized[0]["event"], "initialized"); + let configured = dispatch(&mut adapter, request(3, "configurationDone", json!({}))); + assert_eq!( + configured + .iter() + .map(|message| &message["command"]) + .collect::>(), + vec!["configurationDone", "attach"] + ); + let pcs = ProgramImage::from_path(std::path::Path::new(program_path())) + .unwrap() + .instruction_pcs() + .collect(); + (adapter, state, pcs) +} + +fn stop_at(adapter: &mut FakeAdapter, state: &Rc>, pc: u32) -> Vec { + stop_at_reason(adapter, state, pc, StopReason::Step) +} + +fn stop_at_reason( + adapter: &mut FakeAdapter, + state: &Rc>, + pc: u32, + reason: StopReason, +) -> Vec { + state.borrow_mut().events.extend([ + Ok(Some(DebugEvent::Stopped(Stopped { + reason, + location: None, + }))), + Ok(Some(DebugEvent::Snapshot(Snapshot { + program_counter: ProgramCounter(pc), + state: VmState::Paused, + breakpoints: Vec::new(), + }))), + ]); + framed_values(adapter.pump_events()) +} + +#[test] +fn acknowledged_attach_exposes_one_mapped_frame_and_immutable_source() { + let (mut adapter, state, pcs) = acknowledged_adapter(); + assert_eq!(stop_at(&mut adapter, &state, pcs[0])[0]["event"], "stopped"); + + let stack = dispatch( + &mut adapter, + request(4, "stackTrace", json!({"threadId": 1})), + ); + let frame = &stack[0]["body"]["stackFrames"][0]; + assert_eq!(stack[0]["body"]["totalFrames"], 1); + assert_eq!(frame["name"], "main"); + assert_eq!(frame["source"]["sourceReference"], 1); + assert_eq!(frame["line"], 3); + assert!(frame["column"].as_u64().unwrap() > 0); + + let source = dispatch( + &mut adapter, + request(5, "source", json!({"sourceReference": 1})), + ); + assert_eq!( + source[0]["body"]["content"], + include_str!("fixtures/source-step.wat") + ); + let stale = dispatch(&mut adapter, request(6, "scopes", json!({"frameId": 0}))); + assert_eq!(stale[0]["success"], false); + + stop_at(&mut adapter, &state, 0); + let unmapped = dispatch( + &mut adapter, + request(7, "stackTrace", json!({"threadId": 1})), + ); + assert_eq!(unmapped[0]["success"], false); +} + +#[test] +fn source_steps_hide_intermediate_vm_stops_and_instruction_steps_send_once() { + let (mut adapter, state, pcs) = acknowledged_adapter(); + stop_at(&mut adapter, &state, pcs[0]); + + let next = dispatch( + &mut adapter, + request(4, "next", json!({"threadId": 1, "granularity": "line"})), + ); + assert_eq!(next[0]["success"], true); + assert!(stop_at_reason(&mut adapter, &state, pcs[0], StopReason::Breakpoint).is_empty()); + let final_stop = stop_at_reason(&mut adapter, &state, pcs[1], StopReason::Breakpoint); + assert_eq!(final_stop.len(), 1); + assert_eq!(final_stop[0]["event"], "stopped"); + assert_eq!(final_stop[0]["body"]["reason"], "step"); + assert_eq!( + state + .borrow() + .commands + .iter() + .filter(|command| **command == DebugCommand::StepOver) + .count(), + 2 + ); + + let instruction = dispatch( + &mut adapter, + request( + 5, + "stepIn", + json!({"threadId": 1, "granularity": "instruction"}), + ), + ); + assert_eq!(instruction[0]["success"], true); + assert_eq!( + state + .borrow() + .commands + .iter() + .filter(|command| **command == DebugCommand::Step) + .count(), + 1 + ); +} diff --git a/tools/syrup/crates/wdebug/Cargo.toml b/tools/syrup/crates/wdebug/Cargo.toml new file mode 100644 index 000000000..9ca588587 --- /dev/null +++ b/tools/syrup/crates/wdebug/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "wdebug" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +prost.workspace = true diff --git a/tools/syrup/crates/wdebug/README.md b/tools/syrup/crates/wdebug/README.md new file mode 100644 index 000000000..cc2ad6def --- /dev/null +++ b/tools/syrup/crates/wdebug/README.md @@ -0,0 +1,16 @@ +# Low-level WARDuino debug interface + +`wdebug` owns framing and protobuf conversion for the WARDuino debugger channel. The V1 TCP session is synchronous: outgoing frames may block while being written, and receiving is always nonblocking. + +The firmware does not provide request IDs, so `send` only confirms local frame acceptance. On success it returns `SentFrame`; `bytes()` is the exact accepted frame: command discriminator, canonical payload-length varint, then protobuf payload. State changes and operation results arrive as `DebugEvent` values. + +`DebugCommand::UpdateModule(wasm)` encodes the existing protobuf `ModuleUpdate` command (kind 26). Empty modules are rejected locally, and the normal 64 KiB framed-payload limit still applies; callers must wait for `OperationResult { command: CommandKind::UpdateModule, .. }` before treating an upload as accepted by the VM. + +```rust +use wdebug::{DebugCommand, DebugSession}; + +session.send(DebugCommand::Continue)?; +while let Some(event) = session.try_recv()? { + // Handle the asynchronous target event. +} +``` diff --git a/tools/syrup/crates/wdebug/src/codec.rs b/tools/syrup/crates/wdebug/src/codec.rs new file mode 100644 index 000000000..c8ed6baf2 --- /dev/null +++ b/tools/syrup/crates/wdebug/src/codec.rs @@ -0,0 +1,280 @@ +use prost::Message; + +use crate::{ + CodeLocation, CommandKind, DebugCommand, DebugError, DebugEvent, ModuleIndex, OperationResult, + ProgramCounter, Result, Snapshot, StopReason, Stopped, VmState, wire, +}; + +const CONTINUED: u8 = 0; +const HALTED: u8 = 1; +const PAUSED: u8 = 2; +const STEPPED: u8 = 3; +const HIT_BREAKPOINT: u8 = 4; +const SNAPSHOT: u8 = 8; +const MALFORMED: u8 = 12; +const UNKNOWN_COMMAND: u8 = 13; +const OPERATION_RESULT: u8 = 14; + +pub(super) struct EncodedMessage { + pub message_type: u8, + pub payload: Vec, +} + +pub(super) fn encode_command(command: DebugCommand) -> Result { + match command { + DebugCommand::Continue => empty(0), + DebugCommand::Halt => empty(1), + DebugCommand::Pause => empty(2), + DebugCommand::Step => empty(3), + DebugCommand::StepOver => empty(4), + DebugCommand::ContinueFor(count) => { + if count == 0 { + return Err(DebugError::InvalidPayload { + message_type: 22, + reason: "count must not be zero", + }); + } + encode(22, wire::ContinueFor { count }) + } + DebugCommand::AddBreakpoint(location) => encode_breakpoint(5, location), + DebugCommand::RemoveBreakpoint(location) => encode_breakpoint(6, location), + DebugCommand::RequestSnapshot => empty(9), + DebugCommand::Inspect(state) => encode(23, wire::Inspect { state }), + DebugCommand::Reset => empty(24), + DebugCommand::UpdateModule(wasm) => { + if wasm.is_empty() { + return Err(DebugError::InvalidPayload { + message_type: 26, + reason: "module update must not be empty", + }); + } + encode(26, wire::ModuleUpdate { wasm }) + } + } +} + +pub(super) fn decode_event(message_type: u8, payload: &[u8]) -> Result { + match message_type { + CONTINUED => { + require_empty(message_type, payload)?; + Ok(DebugEvent::Continued) + } + HALTED => { + require_empty(message_type, payload)?; + Ok(DebugEvent::Halted) + } + PAUSED => stopped(message_type, payload, StopReason::Pause, None), + STEPPED => stopped(message_type, payload, StopReason::Step, None), + HIT_BREAKPOINT => { + let hit = decode::(message_type, payload)?; + let location = hit.location.ok_or(DebugError::InvalidPayload { + message_type, + reason: "breakpoint location is required", + })?; + stopped( + message_type, + payload, + StopReason::Breakpoint, + Some(location), + ) + } + SNAPSHOT => { + let snapshot = decode::(message_type, payload)?; + Ok(DebugEvent::Snapshot(Snapshot { + program_counter: ProgramCounter(snapshot.program_counter), + state: vm_state(snapshot.state), + breakpoints: snapshot + .breakpoints + .into_iter() + .map(ProgramCounter) + .collect(), + })) + } + MALFORMED => { + require_empty(message_type, payload)?; + Ok(DebugEvent::TargetMalformedCommand) + } + UNKNOWN_COMMAND => { + require_empty(message_type, payload)?; + Ok(DebugEvent::TargetUnknownCommand) + } + OPERATION_RESULT => { + let result = decode::(message_type, payload)?; + Ok(DebugEvent::OperationResult(OperationResult { + command: command_kind(result.command), + success: result.success, + })) + } + other => Err(DebugError::UnknownMessageType(other)), + } +} + +fn empty(message_type: u8) -> Result { + Ok(EncodedMessage { + message_type, + payload: Vec::new(), + }) +} + +fn encode_breakpoint(message_type: u8, location: CodeLocation) -> Result { + encode( + message_type, + wire::Breakpoint { + location: Some(to_wire_location(location)), + }, + ) +} + +fn encode(message_type: u8, message: impl Message) -> Result { + let mut payload = Vec::with_capacity(message.encoded_len()); + message.encode(&mut payload).map_err(DebugError::Encode)?; + Ok(EncodedMessage { + message_type, + payload, + }) +} + +fn stopped( + message_type: u8, + payload: &[u8], + reason: StopReason, + location: Option, +) -> Result { + if location.is_none() { + require_empty(message_type, payload)?; + } + Ok(DebugEvent::Stopped(Stopped { + reason, + location: location.map(from_wire_location), + })) +} + +fn decode(message_type: u8, payload: &[u8]) -> Result { + T::decode(payload).map_err(|source| DebugError::Decode { + message_type, + source, + }) +} + +fn require_empty(message_type: u8, payload: &[u8]) -> Result<()> { + if payload.is_empty() { + Ok(()) + } else { + Err(DebugError::InvalidPayload { + message_type, + reason: "payload must be empty", + }) + } +} + +fn to_wire_location(location: CodeLocation) -> wire::CodeLocation { + wire::CodeLocation { + module_index: location.module.0, + program_counter: location.program_counter.0, + } +} + +fn from_wire_location(location: wire::CodeLocation) -> CodeLocation { + CodeLocation { + module: ModuleIndex(location.module_index), + program_counter: ProgramCounter(location.program_counter), + } +} + +fn vm_state(value: i32) -> VmState { + match value { + 0 => VmState::Running, + 1 => VmState::Paused, + 2 => VmState::Stepping, + 3 => VmState::ProxyRunning, + 4 => VmState::ProxyHalted, + other => VmState::Unknown(other), + } +} + +fn command_kind(value: i32) -> CommandKind { + match value { + 0 => CommandKind::Continue, + 1 => CommandKind::Halt, + 2 => CommandKind::Pause, + 3 => CommandKind::Step, + 4 => CommandKind::StepOver, + 5 => CommandKind::AddBreakpoint, + 6 => CommandKind::RemoveBreakpoint, + 9 => CommandKind::Snapshot, + 22 => CommandKind::ContinueFor, + 24 => CommandKind::Reset, + 26 => CommandKind::UpdateModule, + other => CommandKind::Other(other), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encodes_breakpoint_command() { + let message = encode_command(DebugCommand::AddBreakpoint(CodeLocation { + module: ModuleIndex(0), + program_counter: ProgramCounter(23), + })) + .unwrap(); + assert_eq!(message.message_type, 5); + assert_eq!(message.payload, [10, 2, 16, 23]); + } + + #[test] + fn decodes_breakpoint_stop() { + let event = decode_event(HIT_BREAKPOINT, &[10, 2, 16, 23]).unwrap(); + assert_eq!( + event, + DebugEvent::Stopped(Stopped { + reason: StopReason::Breakpoint, + location: Some(CodeLocation { + module: ModuleIndex(0), + program_counter: ProgramCounter(23) + }), + }) + ); + } + + #[test] + fn encodes_each_command_type() { + let location = CodeLocation { + module: ModuleIndex(1), + program_counter: ProgramCounter(2), + }; + let cases = [ + (DebugCommand::Continue, 0), + (DebugCommand::Halt, 1), + (DebugCommand::Pause, 2), + (DebugCommand::Step, 3), + (DebugCommand::StepOver, 4), + (DebugCommand::AddBreakpoint(location), 5), + (DebugCommand::RemoveBreakpoint(location), 6), + (DebugCommand::RequestSnapshot, 9), + (DebugCommand::Inspect(Vec::new()), 23), + (DebugCommand::Reset, 24), + (DebugCommand::UpdateModule(vec![0]), 26), + (DebugCommand::ContinueFor(1), 22), + ]; + for (command, message_type) in cases { + assert_eq!(encode_command(command).unwrap().message_type, message_type); + } + } + #[test] + fn encodes_module_update_and_rejects_empty_payload() { + let message = + encode_command(DebugCommand::UpdateModule(vec![0, 0x61, 0x73, 0x6d])).unwrap(); + assert_eq!(message.message_type, 26); + assert_eq!(message.payload, [10, 4, 0, 0x61, 0x73, 0x6d]); + assert!(matches!( + encode_command(DebugCommand::UpdateModule(Vec::new())), + Err(DebugError::InvalidPayload { + message_type: 26, + .. + }) + )); + } +} diff --git a/tools/syrup/crates/wdebug/src/error.rs b/tools/syrup/crates/wdebug/src/error.rs new file mode 100644 index 000000000..74b0d06b8 --- /dev/null +++ b/tools/syrup/crates/wdebug/src/error.rs @@ -0,0 +1,71 @@ +use std::{error::Error, fmt, io}; + +pub type Result = std::result::Result; + +#[derive(Debug)] +pub enum DebugError { + NotConnected, + UnsupportedCommand(&'static str), + Transport(io::Error), + FrameTooLarge { + size: usize, + max: usize, + }, + InvalidFrame(&'static str), + UnknownMessageType(u8), + Decode { + message_type: u8, + source: prost::DecodeError, + }, + InvalidPayload { + message_type: u8, + reason: &'static str, + }, + Encode(prost::EncodeError), +} + +impl fmt::Display for DebugError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotConnected => formatter.write_str("debug session is not connected"), + Self::UnsupportedCommand(name) => write!(formatter, "unsupported command: {name}"), + Self::Transport(error) => write!(formatter, "debug transport error: {error}"), + Self::FrameTooLarge { size, max } => { + write!(formatter, "frame payload {size} exceeds {max}") + } + Self::InvalidFrame(reason) => write!(formatter, "invalid debug frame: {reason}"), + Self::UnknownMessageType(kind) => { + write!(formatter, "unknown debug message type: {kind}") + } + Self::Decode { + message_type, + source, + } => write!(formatter, "cannot decode message {message_type}: {source}"), + Self::InvalidPayload { + message_type, + reason, + } => write!( + formatter, + "invalid payload for message {message_type}: {reason}" + ), + Self::Encode(error) => write!(formatter, "cannot encode debug message: {error}"), + } + } +} + +impl Error for DebugError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Transport(error) => Some(error), + Self::Decode { source, .. } => Some(source), + Self::Encode(error) => Some(error), + _ => None, + } + } +} + +impl From for DebugError { + fn from(error: io::Error) -> Self { + Self::Transport(error) + } +} diff --git a/tools/syrup/crates/wdebug/src/framing.rs b/tools/syrup/crates/wdebug/src/framing.rs new file mode 100644 index 000000000..c96b2348d --- /dev/null +++ b/tools/syrup/crates/wdebug/src/framing.rs @@ -0,0 +1,130 @@ +use crate::{DebugError, Result}; + +pub(super) const MAX_PAYLOAD: usize = 64 * 1024; + +pub(super) struct Frame { + pub message_type: u8, + pub payload: Vec, + pub bytes: Vec, +} + +pub(super) fn encode_frame(message_type: u8, payload: &[u8]) -> Result> { + if payload.len() > MAX_PAYLOAD { + return Err(DebugError::FrameTooLarge { + size: payload.len(), + max: MAX_PAYLOAD, + }); + } + + let mut frame = Vec::with_capacity(6 + payload.len()); + frame.push(message_type); + push_varint(&mut frame, payload.len()); + frame.extend_from_slice(payload); + Ok(frame) +} + +pub(super) struct FrameDecoder { + bytes: Vec, +} + +impl FrameDecoder { + pub(super) fn new() -> Self { + Self { bytes: Vec::new() } + } + + pub(super) fn push(&mut self, bytes: &[u8]) { + self.bytes.extend_from_slice(bytes); + } + + pub(super) fn next_frame(&mut self) -> Result> { + if self.bytes.len() < 2 { + return Ok(None); + } + let Some((header_len, payload_len)) = decode_length(&self.bytes)? else { + return Ok(None); + }; + if payload_len > MAX_PAYLOAD { + return Err(DebugError::FrameTooLarge { + size: payload_len, + max: MAX_PAYLOAD, + }); + } + let end = header_len + payload_len; + if self.bytes.len() < end { + return Ok(None); + } + let bytes = self.bytes[..end].to_vec(); + let payload = self.bytes[header_len..end].to_vec(); + let message_type = self.bytes[0]; + self.bytes.drain(..end); + Ok(Some(Frame { + message_type, + payload, + bytes, + })) + } +} + +fn push_varint(output: &mut Vec, mut value: usize) { + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + output.push(byte); + if value == 0 { + return; + } + } +} + +fn decode_length(bytes: &[u8]) -> Result> { + let mut value = 0usize; + for index in 0..5 { + let position = index + 1; + let Some(&byte) = bytes.get(position) else { + return Ok(None); + }; + value |= ((byte & 0x7f) as usize) << (index * 7); + if byte & 0x80 == 0 { + if index > 0 && value < (1usize << (index * 7)) { + return Err(DebugError::InvalidFrame("non-canonical payload length")); + } + return Ok(Some((position + 1, value))); + } + } + Err(DebugError::InvalidFrame( + "payload length varint exceeds five bytes", + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_fragmented_and_concatenated_frames() { + let mut decoder = FrameDecoder::new(); + decoder.push(&[2, 2, 8]); + assert!(decoder.next_frame().unwrap().is_none()); + decoder.push(&[1, 0, 0]); + let first = decoder.next_frame().unwrap().unwrap(); + assert_eq!(first.message_type, 2); + assert_eq!(first.payload, [8, 1]); + assert_eq!(decoder.next_frame().unwrap().unwrap().message_type, 0); + } + + #[test] + fn encodes_complete_outgoing_frames() { + assert_eq!(encode_frame(0, &[]).unwrap(), [0, 0]); + assert_eq!( + encode_frame(5, &[0x0a, 0x02, 0x10, 0x17]).unwrap(), + [5, 4, 0x0a, 0x02, 0x10, 0x17] + ); + let payload = vec![0xa5; 128]; + let frame = encode_frame(7, &payload).unwrap(); + assert_eq!(&frame[..3], &[7, 0x80, 0x01]); + assert_eq!(&frame[3..], payload); + } +} diff --git a/tools/syrup/crates/wdebug/src/lib.rs b/tools/syrup/crates/wdebug/src/lib.rs new file mode 100644 index 000000000..983c53c72 --- /dev/null +++ b/tools/syrup/crates/wdebug/src/lib.rs @@ -0,0 +1,21 @@ +//! A synchronous, nonblocking interface to the WARDuino debugger protocol. +//! +//! Commands are accepted by the local transport only. The current firmware +//! does not attach request identifiers to frames, so notifications cannot be +//! correlated to a particular command. + +mod codec; +mod error; +mod framing; +mod session; +mod transport; +mod types; +mod wire; + +pub use error::{DebugError, Result}; +pub use session::WarduinoSession; +pub use types::{ + CodeLocation, CommandKind, DebugCommand, DebugEvent, DebugSession, DisconnectReason, + ModuleIndex, OperationResult, ProgramCounter, ReceivedFrame, SentFrame, Snapshot, StopReason, + Stopped, VmState, +}; diff --git a/tools/syrup/crates/wdebug/src/session.rs b/tools/syrup/crates/wdebug/src/session.rs new file mode 100644 index 000000000..279def8f3 --- /dev/null +++ b/tools/syrup/crates/wdebug/src/session.rs @@ -0,0 +1,251 @@ +use std::{ + collections::VecDeque, + io, + net::{TcpStream, ToSocketAddrs}, +}; + +use crate::{ + DebugCommand, DebugError, DebugEvent, DebugSession, DisconnectReason, ReceivedFrame, Result, + SentFrame, codec, + framing::{self, FrameDecoder}, + transport::{TcpTransport, Transport}, +}; + +const READ_BUFFER_SIZE: usize = 1024; +const MAX_EVENTS_PER_RECEIVE: usize = 16; + +/// A TCP connection to a WARDuino debugger channel. +pub struct WarduinoSession { + inner: Session, +} + +impl WarduinoSession { + pub fn connect(address: impl ToSocketAddrs) -> Result { + let stream = TcpStream::connect(address).map_err(DebugError::Transport)?; + Self::from_tcp_stream(stream) + } + + pub fn from_tcp_stream(stream: TcpStream) -> Result { + let transport = TcpTransport::new(stream).map_err(DebugError::Transport)?; + Ok(Self { + inner: Session::new(transport), + }) + } +} + +impl DebugSession for WarduinoSession { + fn send(&mut self, command: DebugCommand) -> Result { + self.inner.send(command) + } + + fn try_recv(&mut self) -> Result> { + self.inner.try_recv() + } +} + +struct Session { + transport: T, + decoder: FrameDecoder, + events: VecDeque, + state: ConnectionState, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum ConnectionState { + Connected, + Closed, + Failed, +} + +impl Session { + fn new(transport: T) -> Self { + Self { + transport, + decoder: FrameDecoder::new(), + events: VecDeque::new(), + state: ConnectionState::Connected, + } + } + + fn send(&mut self, command: DebugCommand) -> Result { + if self.state != ConnectionState::Connected { + return Err(DebugError::NotConnected); + } + let message = codec::encode_command(command)?; + let frame = framing::encode_frame(message.message_type, &message.payload)?; + self.transport + .write_all(&frame) + .map_err(|error| self.fail_transport(error))?; + Ok(SentFrame::from_complete_frame(frame)) + } + + fn try_recv(&mut self) -> Result> { + if let Some(event) = self.events.pop_front() { + return Ok(Some(event)); + } + if self.state != ConnectionState::Connected { + return Ok(None); + } + + self.decode_buffered()?; + if let Some(event) = self.events.pop_front() { + return Ok(Some(event)); + } + + let mut bytes = [0; READ_BUFFER_SIZE]; + match self.transport.read_available(&mut bytes) { + Ok(0) => { + self.state = ConnectionState::Closed; + Ok(Some(ReceivedFrame::from_complete_frame( + DebugEvent::Disconnected(DisconnectReason::TransportClosed), + Vec::new(), + ))) + } + Ok(count) => { + self.decoder.push(&bytes[..count]); + self.decode_buffered()?; + Ok(self.events.pop_front()) + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + Ok(None) + } + Err(error) => Err(self.fail_transport(error)), + } + } + + fn decode_buffered(&mut self) -> Result<()> { + for _ in 0..MAX_EVENTS_PER_RECEIVE { + let frame = match self.decoder.next_frame() { + Ok(Some(frame)) => frame, + Ok(None) => return Ok(()), + Err(error) => return Err(self.fail(error)), + }; + let event = codec::decode_event(frame.message_type, &frame.payload) + .map_err(|error| self.fail(error))?; + self.events + .push_back(ReceivedFrame::from_complete_frame(event, frame.bytes)); + } + Ok(()) + } + + fn fail_transport(&mut self, error: io::Error) -> DebugError { + self.fail(DebugError::Transport(error)) + } + + fn fail(&mut self, error: DebugError) -> DebugError { + self.state = ConnectionState::Failed; + error + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MemoryTransport { + input: VecDeque>>, + written: Vec, + } + + impl Transport for MemoryTransport { + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + self.written.extend_from_slice(bytes); + Ok(()) + } + + fn read_available(&mut self, bytes: &mut [u8]) -> io::Result { + match self + .input + .pop_front() + .unwrap_or_else(|| Err(io::Error::from(io::ErrorKind::WouldBlock))) + { + Ok(input) => { + bytes[..input.len()].copy_from_slice(&input); + Ok(input.len()) + } + Err(error) => Err(error), + } + } + } + + #[test] + fn sends_complete_frame() { + let transport = MemoryTransport { + input: VecDeque::new(), + written: Vec::new(), + }; + let mut session = Session::new(transport); + let receipt = session.send(DebugCommand::Continue).unwrap(); + assert_eq!(session.transport.written, [0, 0]); + assert_eq!(receipt.bytes(), session.transport.written); + } + + #[test] + fn returns_fragmented_events_without_waiting() { + let transport = MemoryTransport { + input: VecDeque::from([Ok(vec![4, 4, 10]), Ok(vec![2, 16, 23])]), + written: Vec::new(), + }; + let mut session = Session::new(transport); + assert_eq!(session.try_recv().unwrap(), None); + assert!(matches!( + session.try_recv().unwrap(), + Some(ReceivedFrame { + event: DebugEvent::Stopped(_), + .. + }) + )); + assert_eq!(session.try_recv().unwrap(), None); + } + + #[test] + fn reports_disconnect_once() { + let transport = MemoryTransport { + input: VecDeque::from([Ok(Vec::new())]), + written: Vec::new(), + }; + let mut session = Session::new(transport); + assert!(matches!( + session.try_recv().unwrap(), + Some(ReceivedFrame { + event: DebugEvent::Disconnected(_), + .. + }) + )); + assert_eq!(session.try_recv().unwrap(), None); + } + + struct FailingTransport; + + impl Transport for FailingTransport { + fn write_all(&mut self, _: &[u8]) -> io::Result<()> { + Err(io::Error::other("write failed")) + } + fn read_available(&mut self, _: &mut [u8]) -> io::Result { + Ok(0) + } + } + + #[test] + fn does_not_return_a_receipt_when_writing_fails() { + let mut session = Session::new(FailingTransport); + assert!(session.send(DebugCommand::Continue).is_err()); + } + #[test] + fn rejects_module_updates_that_exceed_the_transport_limit() { + let transport = MemoryTransport { + input: VecDeque::new(), + written: Vec::new(), + }; + let mut session = Session::new(transport); + let error = session + .send(DebugCommand::UpdateModule(vec![0; 65_533])) + .unwrap_err(); + assert!(matches!(error, DebugError::FrameTooLarge { .. })); + } +} diff --git a/tools/syrup/crates/wdebug/src/transport.rs b/tools/syrup/crates/wdebug/src/transport.rs new file mode 100644 index 000000000..05852f47f --- /dev/null +++ b/tools/syrup/crates/wdebug/src/transport.rs @@ -0,0 +1,30 @@ +use std::{ + io::{self, Read, Write}, + net::TcpStream, +}; + +pub(super) trait Transport { + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()>; + fn read_available(&mut self, bytes: &mut [u8]) -> io::Result; +} + +pub(super) struct TcpTransport { + stream: TcpStream, +} + +impl TcpTransport { + pub(super) fn new(stream: TcpStream) -> io::Result { + stream.set_nonblocking(true)?; + Ok(Self { stream }) + } +} + +impl Transport for TcpTransport { + fn write_all(&mut self, bytes: &[u8]) -> io::Result<()> { + self.stream.write_all(bytes) + } + + fn read_available(&mut self, bytes: &mut [u8]) -> io::Result { + self.stream.read(bytes) + } +} diff --git a/tools/syrup/crates/wdebug/src/types.rs b/tools/syrup/crates/wdebug/src/types.rs new file mode 100644 index 000000000..11cbec062 --- /dev/null +++ b/tools/syrup/crates/wdebug/src/types.rs @@ -0,0 +1,146 @@ +use crate::Result; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ModuleIndex(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ProgramCounter(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CodeLocation { + pub module: ModuleIndex, + pub program_counter: ProgramCounter, +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DebugCommand { + Continue, + Halt, + Pause, + Step, + StepOver, + ContinueFor(u32), + AddBreakpoint(CodeLocation), + RemoveBreakpoint(CodeLocation), + RequestSnapshot, + Inspect(Vec), + Reset, + UpdateModule(Vec), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CommandKind { + Continue, + Halt, + Pause, + Step, + StepOver, + AddBreakpoint, + RemoveBreakpoint, + ContinueFor, + Snapshot, + Reset, + UpdateModule, + Other(i32), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopReason { + Pause, + Step, + Breakpoint, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stopped { + pub reason: StopReason, + pub location: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VmState { + Running, + Paused, + Stepping, + ProxyRunning, + ProxyHalted, + Unknown(i32), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snapshot { + pub program_counter: ProgramCounter, + pub state: VmState, + pub breakpoints: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OperationResult { + pub command: CommandKind, + pub success: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisconnectReason { + TransportClosed, +} + +#[non_exhaustive] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DebugEvent { + Stopped(Stopped), + Continued, + Halted, + Snapshot(Snapshot), + OperationResult(OperationResult), + TargetMalformedCommand, + TargetUnknownCommand, + Disconnected(DisconnectReason), +} + +/// A complete WARDuino VM frame accepted by the local transport. +/// +/// The bytes include the command discriminator, canonical payload-length +/// varint, and protobuf payload. This only confirms local transport +/// acceptance; it does not confirm execution by the VM. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SentFrame(Vec); + +impl SentFrame { + /// Constructs a receipt for a complete frame accepted by an implementation + /// of `DebugSession`. This is chiefly useful for custom session implementations. + pub fn from_complete_frame(bytes: Vec) -> Self { + Self(bytes) + } + + pub fn bytes(&self) -> &[u8] { + &self.0 + } +} + +/// A decoded inbound VM event and the exact complete frame that carried it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReceivedFrame { + pub event: DebugEvent, + bytes: Vec, +} + +impl ReceivedFrame { + pub fn from_complete_frame(event: DebugEvent, bytes: Vec) -> Self { + Self { event, bytes } + } + pub fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +/// A WARDuino debug connection. +/// +/// `send` completes when the complete frame has been accepted by the local +/// transport. It does not confirm execution by the VM. `try_recv` never waits +/// for bytes and returns one queued event at a time. +pub trait DebugSession { + fn send(&mut self, command: DebugCommand) -> Result; + fn try_recv(&mut self) -> Result>; +} diff --git a/tools/syrup/crates/wdebug/src/wire/debug.rs b/tools/syrup/crates/wdebug/src/wire/debug.rs new file mode 100644 index 000000000..72656f9fc --- /dev/null +++ b/tools/syrup/crates/wdebug/src/wire/debug.rs @@ -0,0 +1,67 @@ +// Generated from src/Debug/debug.proto. Public conversion lives in codec.rs. + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CodeLocation { + #[prost(uint32, tag = "1")] + pub module_index: u32, + #[prost(uint32, tag = "2")] + pub program_counter: u32, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Breakpoint { + #[prost(message, optional, tag = "1")] + pub location: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct HitBreakpoint { + #[prost(message, optional, tag = "1")] + pub location: Option, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ContinueFor { + #[prost(uint32, tag = "1")] + pub count: u32, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Inspect { + #[prost(bytes = "vec", tag = "1")] + pub state: Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ModuleUpdate { + #[prost(bytes = "vec", tag = "1")] + pub wasm: Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Snapshot { + #[prost(uint32, tag = "1")] + pub program_counter: u32, + #[prost(enumeration = "State", tag = "2")] + pub state: i32, + #[prost(uint32, repeated, tag = "3")] + pub breakpoints: Vec, +} + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OperationResult { + #[prost(int32, tag = "1")] + pub command: i32, + #[prost(bool, tag = "2")] + pub success: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, ::prost::Enumeration)] +#[repr(i32)] +pub enum State { + Running = 0, + Paused = 1, + Stepping = 2, + ProxyRunning = 3, + ProxyHalted = 4, +} diff --git a/tools/syrup/crates/wdebug/src/wire/mod.rs b/tools/syrup/crates/wdebug/src/wire/mod.rs new file mode 100644 index 000000000..8686f544e --- /dev/null +++ b/tools/syrup/crates/wdebug/src/wire/mod.rs @@ -0,0 +1,6 @@ +mod debug; + +pub(super) use debug::{ + Breakpoint, CodeLocation, ContinueFor, HitBreakpoint, Inspect, ModuleUpdate, OperationResult, + Snapshot, +}; diff --git a/tools/syrup/debug.pb b/tools/syrup/debug.pb new file mode 100644 index 000000000..e277ff957 Binary files /dev/null and b/tools/syrup/debug.pb differ diff --git a/tools/syrup/docs/dap.md b/tools/syrup/docs/dap.md new file mode 100644 index 000000000..42e04f684 --- /dev/null +++ b/tools/syrup/docs/dap.md @@ -0,0 +1,7 @@ +# WARDuino Adapter Protocol + +The `syrup` debugger console for `WARDuino` relies on the Debug Adapter Protocol (DAP). However, the protocol is not suited for many of the advanced debug features of WARDuino. We therefore defined an extended version of DAP, the WARDuino Adapter Protocol (WAP). + +## WARDuino Adapter Protocol + + diff --git a/tools/syrup/justfile b/tools/syrup/justfile new file mode 100644 index 000000000..7d102af90 --- /dev/null +++ b/tools/syrup/justfile @@ -0,0 +1,28 @@ +root := "../.." +proto := root + "/src/Debug/debug.proto" + +set default-list := true + +demo: + wdcli assets/fac.wasm --paused --socket 8192 >/dev/null & + vhs assets/demo.tape + pkill wdcli + +schema: + protoc -I {{root}}/src/Debug --include_imports --include_source_info --descriptor_set_out=debug.pb {{proto}} + +check: + cargo check --workspace + +build: schema + cargo build --release -p console + +test: + cargo test --workspace + +run device program: schema + cargo run -p console -- --device {{device}} --program {{program}} + +dap-server address: + cargo run -p warduino-dap -- --dap-server {{address}} + diff --git a/tools/syrup/vscode-warduino/.vscode/launch.json b/tools/syrup/vscode-warduino/.vscode/launch.json new file mode 100644 index 000000000..0af6e18a1 --- /dev/null +++ b/tools/syrup/vscode-warduino/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run WARDuino debugger fixture", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ] + } + ] +} \ No newline at end of file diff --git a/tools/syrup/vscode-warduino/package.json b/tools/syrup/vscode-warduino/package.json new file mode 100644 index 000000000..5e72b8b12 --- /dev/null +++ b/tools/syrup/vscode-warduino/package.json @@ -0,0 +1,55 @@ +{ + "name": "vscode-warduino-dap", + "displayName": "WARDuino DAP Development", + "description": "Development-only VS Code client for the WARDuino DAP server.", + "version": "0.0.0", + "private": true, + "engines": { + "vscode": "^1.85.0" + }, + "categories": [ + "Debuggers" + ], + "contributes": { + "debuggers": [ + { + "type": "warduino", + "label": "WARDuino", + "configurationAttributes": { + "attach": { + "properties": { + "device": { + "type": "string", + "description": "WARDuino VM debug socket address.", + "default": "127.0.0.1:8192" + }, + "program": { + "type": "string", + "description": "Path to the WAT program uploaded and mapped for this debug session." + }, + "debugServer": { + "type": "number", + "description": "WARDuino DAP server port (served at 127.0.0.1).", + "default": 4711 + } + }, + "required": [ + "device", + "program" + ] + } + }, + "initialConfigurations": [ + { + "name": "Attach to WARDuino", + "type": "warduino", + "request": "attach", + "debugServer": 4711, + "device": "127.0.0.1:8192", + "program": "${workspaceFolder}/main.wat" + } + ] + } + ] + } +}