diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e45de1a60d1..f92639353a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,6 +238,10 @@ jobs: cargo uninstall forc-mcp || true rm -f "${CARGO_HOME:-$HOME/.cargo}/bin/forc-mcp" for dir in ./forc-plugins/*/; do + # Dev-only plugins are not installed for docs / release tooling. + case "$(basename "$dir")" in + forc-ir-inspect) continue ;; + esac cargo install --locked --path "$dir" done cargo install --locked --debug --git https://github.com/FuelLabs/forc forc-crypto diff --git a/Cargo.lock b/Cargo.lock index b1de31b38d1..feeed3856f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,26 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -786,12 +806,24 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -822,12 +854,27 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + [[package]] name = "cast" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.62" @@ -1126,6 +1173,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "completest" version = "0.4.2" @@ -1378,6 +1439,31 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags 2.11.1", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "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 = "crunchy" version = "0.2.4" @@ -1540,6 +1626,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1567,6 +1663,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.20.11" @@ -1589,6 +1698,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -1881,6 +2001,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -2329,6 +2459,12 @@ dependencies = [ "bytes", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fd-lock" version = "4.0.4" @@ -2340,6 +2476,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "ff" version = "0.13.1" @@ -2549,6 +2694,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "forc-ir-inspect" +version = "0.72.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "crossterm", + "dirs", + "prettydiff", + "ratatui", + "serde", + "serde_json", + "walkdir", +] + [[package]] name = "forc-lsp" version = "0.72.0" @@ -3382,6 +3543,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -3644,6 +3815,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -4078,6 +4251,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "impl-codec" version = "0.6.0" @@ -4212,6 +4399,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling 0.24.1", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -4640,6 +4840,15 @@ dependencies = [ "logos-codegen", ] +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -4796,10 +5005,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "multibase" version = "0.9.2" @@ -5046,6 +5266,79 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -5568,6 +5861,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -5887,12 +6193,24 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.39.4" @@ -6072,6 +6390,27 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.11.1", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + [[package]] name = "rayon" version = "1.12.0" @@ -6787,7 +7126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -6809,7 +7148,7 @@ dependencies = [ "nix 0.29.0", "radix_trie", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width 0.2.0", "utf8parse", "windows-sys 0.59.0", ] @@ -7285,6 +7624,17 @@ dependencies = [ "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" @@ -7658,7 +8008,6 @@ name = "sway-ir" version = "0.72.0" dependencies = [ "anyhow", - "clap", "downcast-rs", "filecheck", "indexmap 2.14.0", @@ -7841,6 +8190,17 @@ dependencies = [ "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 = "sync_wrapper" version = "1.0.2" @@ -8092,7 +8452,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.2", + "unicode-width 0.2.0", ] [[package]] @@ -8159,6 +8519,20 @@ dependencies = [ "num_cpus", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" @@ -8707,6 +9081,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width 0.1.14", +] + [[package]] name = "unicode-width" version = "0.1.14" @@ -8715,9 +9100,9 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "unicode-xid" @@ -9055,6 +9440,12 @@ version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "6.0.3" @@ -9575,6 +9966,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xattr" version = "1.6.1" @@ -9728,3 +10136,18 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index b30095707ec..80fa7071e66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,7 @@ fuel-vm = "0.66" annotate-snippets = "0.10" ansiterm = "0.12" anyhow = "1.0" +arboard = "3.4" assert-json-diff = "2.0" assert_matches = "1.5" async-trait = "0.1" @@ -126,6 +127,7 @@ completest-pty = "0.5" comrak = "0.28" criterion = "0.5" crossbeam-channel = "0.5" +crossterm = "0.28" dap = "0.4.1-alpha" dashmap = "6.1" devault = "0.2" @@ -189,6 +191,7 @@ prettydiff = "0.7" proc-macro2 = "1.0" quote = "1.0" rand = "0.8" +ratatui = "0.29" rayon = "1.7" rayon-cond = "0.3" regex = "1.10" diff --git a/forc-plugins/forc-ir-inspect/Cargo.toml b/forc-plugins/forc-ir-inspect/Cargo.toml new file mode 100644 index 00000000000..ded0b200b44 --- /dev/null +++ b/forc-plugins/forc-ir-inspect/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "forc-ir-inspect" +version.workspace = true +description = "Dev-only forc plugin for browsing Sway IR dumps (not released)." +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "forc-ir-inspect" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +arboard.workspace = true +clap = { workspace = true, features = ["derive"] } +crossterm.workspace = true +dirs.workspace = true +prettydiff.workspace = true +ratatui.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +walkdir.workspace = true + +[lints] +workspace = true diff --git a/forc-plugins/forc-ir-inspect/src/classic.rs b/forc-plugins/forc-ir-inspect/src/classic.rs new file mode 100644 index 00000000000..2e4e3b90cbc --- /dev/null +++ b/forc-plugins/forc-ir-inspect/src/classic.rs @@ -0,0 +1,86 @@ +//! Classic (non-TUI) printer — the original inspect output path. + +use std::io::{IsTerminal, Write}; + +use anyhow::{bail, Result}; + +use crate::parse::{ + diff_stats, has_metadata_defs, parse_source_map, prepare_ir_text, print_diff, print_diff_stats, + print_final_ir, FuncStats, MdMode, ParsedIr, PrintMode, VersionMode, +}; + +pub(crate) fn run( + irs: &[ParsedIr], + cleaned: &str, + filter_fn: &[String], + mode: PrintMode, + metadata: MdMode, + version: VersionMode, + source: bool, +) -> Result<()> { + if source && !has_metadata_defs(cleaned) { + eprintln!( + "warning: `--print source` was requested but the input contains no metadata \ + definitions (`!N = ...`).\n \ + Regenerate the IR dump with the `print-md` flag, e.g. \ + `forc build --ir all,print-md`, so the span/file metadata is emitted." + ); + } + + let stdout = std::io::stdout(); + let is_terminal = stdout.is_terminal(); + let mut out = stdout.lock(); + + let mut previous_ir: Option = None; + + for ir in irs { + if ir.is_initial() { + previous_ir = None; + } + + let Some(final_ir) = prepare_ir_text(filter_fn, metadata, version, ir) else { + continue; + }; + + let mut source_map = if source { + Some(parse_source_map(&ir.body)) + } else { + None + }; + + writeln!(out, "// IR: {}", ir.pass_name)?; + + if mode == PrintMode::Diff { + if let Some(prev_text) = previous_ir.as_ref() { + let changeset = prettydiff::diff_lines(prev_text, &final_ir); + let ops = changeset.diff(); + let (adds, removes) = diff_stats(&ops); + if adds == 0 && removes == 0 { + print_diff_stats(&mut out, adds, removes)?; + } else { + let cur_stats = FuncStats::compute_stats(&final_ir); + let prev_stats = previous_ir.as_ref().map(|p| FuncStats::compute_stats(p)); + cur_stats.print_fn_stats(&mut out, prev_stats.as_ref())?; + print_diff_stats(&mut out, adds, removes)?; + print_diff(&mut out, &ops, is_terminal)?; + } + } else { + let cur_stats = FuncStats::compute_stats(&final_ir); + cur_stats.print_fn_stats(&mut out, None)?; + print_final_ir(&mut out, &final_ir, source_map.as_mut())?; + } + } else { + let cur_stats = FuncStats::compute_stats(&final_ir); + cur_stats.print_fn_stats(&mut out, None)?; + print_final_ir(&mut out, &final_ir, source_map.as_mut())?; + } + + previous_ir = Some(final_ir); + } + + if irs.is_empty() { + bail!("no `// IR:` dumps found in input"); + } + + Ok(()) +} diff --git a/forc-plugins/forc-ir-inspect/src/config.rs b/forc-plugins/forc-ir-inspect/src/config.rs new file mode 100644 index 00000000000..e060b050bf4 --- /dev/null +++ b/forc-plugins/forc-ir-inspect/src/config.rs @@ -0,0 +1,239 @@ +//! Persistent TUI session state under the OS config directory. +//! +//! - macOS / Linux: `~/.config/forc-ir-inspect/` (or `$XDG_CONFIG_HOME`) +//! - Windows: `%APPDATA%\forc-ir-inspect\` + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::parse::{MdMode, VersionMode}; + +const CONFIG_VERSION: u32 = 1; +const CONFIG_FILE: &str = "tui.json"; +const LEGACY_SOURCE_ROOT: &str = "source_root"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct TuiConfigFile { + #[serde(default = "default_version")] + pub version: u32, + #[serde(default)] + pub source_root: Option, + #[serde(default)] + pub sessions: BTreeMap, +} + +fn default_version() -> u32 { + CONFIG_VERSION +} + +fn default_true() -> bool { + true +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub(crate) struct SessionState { + #[serde(default)] + pub focus: FocusName, + #[serde(default)] + pub pass_filter: String, + #[serde(default)] + pub fn_filter: String, + #[serde(default)] + pub search: String, + #[serde(default)] + pub scroll: u16, + #[serde(default)] + pub h_scroll: u16, + #[serde(default)] + pub show_diff: bool, + #[serde(default = "default_true")] + pub show_line_numbers: bool, + #[serde(default = "default_true")] + pub syntax_highlight: bool, + #[serde(default)] + pub metadata: MdModeName, + #[serde(default)] + pub version: VersionModeName, + #[serde(default)] + pub show_source: bool, + #[serde(default)] + pub selected_project: Option, + #[serde(default)] + pub selected_pass: Option, + #[serde(default)] + pub expanded_projects: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FocusName { + #[default] + Tree, + Main, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum MdModeName { + #[default] + AsParsed, + With, + Without, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum VersionModeName { + #[default] + AsParsed, + With, + Without, +} + +impl From for MdModeName { + fn from(m: MdMode) -> Self { + match m { + MdMode::AsParsed => Self::AsParsed, + MdMode::With => Self::With, + MdMode::Without => Self::Without, + } + } +} + +impl From for MdMode { + fn from(m: MdModeName) -> Self { + match m { + MdModeName::AsParsed => Self::AsParsed, + MdModeName::With => Self::With, + MdModeName::Without => Self::Without, + } + } +} + +impl From for VersionModeName { + fn from(m: VersionMode) -> Self { + match m { + VersionMode::AsParsed => Self::AsParsed, + VersionMode::With => Self::With, + VersionMode::Without => Self::Without, + } + } +} + +impl From for VersionMode { + fn from(m: VersionModeName) -> Self { + match m { + VersionModeName::AsParsed => Self::AsParsed, + VersionModeName::With => Self::With, + VersionModeName::Without => Self::Without, + } + } +} + +/// Prefer XDG-style `~/.config` on all Unix platforms (including macOS). +/// Windows keeps `%APPDATA%` via `dirs::config_dir`. +pub(crate) fn config_dir() -> Option { + #[cfg(windows)] + { + Some(dirs::config_dir()?.join("forc-ir-inspect")) + } + #[cfg(not(windows))] + { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .or_else(|| dirs::home_dir().map(|h| h.join(".config")))?; + Some(base.join("forc-ir-inspect")) + } +} + +pub(crate) fn config_path() -> Option { + Some(config_dir()?.join(CONFIG_FILE)) +} + +pub(crate) fn session_key(cmd: Option<&str>, input_path: Option<&str>) -> String { + if let Some(cmd) = cmd { + format!("cmd:{cmd}") + } else if let Some(path) = input_path { + let p = PathBuf::from(path); + let abs = p + .canonicalize() + .unwrap_or_else(|_| std::env::current_dir().map(|c| c.join(&p)).unwrap_or(p)); + format!("path:{}", abs.display()) + } else { + String::from("default") + } +} + +pub(crate) fn load_config() -> TuiConfigFile { + let mut cfg: TuiConfigFile = config_path() + .and_then(|path| fs::read_to_string(path).ok()) + .and_then(|raw| serde_json::from_str(&raw).ok()) + .unwrap_or_default(); + cfg.version = CONFIG_VERSION; + + // Migrate the previous single-file source-root setting. + if cfg.source_root.is_none() { + if let Some(legacy) = load_legacy_source_root() { + cfg.source_root = Some(legacy); + } + } + if let Some(root) = &cfg.source_root { + if !root.is_dir() { + cfg.source_root = None; + } + } + cfg +} + +fn load_legacy_source_root() -> Option { + let path = config_dir()?.join(LEGACY_SOURCE_ROOT); + let raw = fs::read_to_string(path).ok()?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + let p = PathBuf::from(trimmed); + p.is_dir().then_some(p) +} + +pub(crate) fn save_config(cfg: &TuiConfigFile) -> std::io::Result<()> { + let path = config_path().ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::NotFound, "no OS config directory") + })?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = serde_json::to_string_pretty(cfg) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + let tmp = path.with_extension("json.tmp"); + fs::write(&tmp, raw)?; + fs::rename(&tmp, &path)?; + Ok(()) +} + +pub(crate) fn load_session( + cmd: Option<&str>, + input_path: Option<&str>, +) -> (TuiConfigFile, Option) { + let cfg = load_config(); + let key = session_key(cmd, input_path); + let session = cfg.sessions.get(&key).cloned(); + (cfg, session) +} + +/// Update `source_root` and the session for this input, then write to disk. +pub(crate) fn persist( + cmd: Option<&str>, + input_path: Option<&str>, + source_root: Option<&Path>, + session: SessionState, +) -> std::io::Result<()> { + let mut cfg = load_config(); + cfg.source_root = source_root.map(Path::to_path_buf); + cfg.sessions.insert(session_key(cmd, input_path), session); + save_config(&cfg) +} diff --git a/forc-plugins/forc-ir-inspect/src/highlight.rs b/forc-plugins/forc-ir-inspect/src/highlight.rs new file mode 100644 index 00000000000..177cda9fd2f --- /dev/null +++ b/forc-plugins/forc-ir-inspect/src/highlight.rs @@ -0,0 +1,1088 @@ +//! Basic Sway-IR syntax highlighting for the TUI main panel. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +/// Token styles used by the highlighter. +#[derive(Clone, Copy)] +enum Kind { + Comment, + Keyword, + Opcode, + Type, + Number, + String, + Metadata, + Label, + Ident, + Punct, + DiffAdd, + DiffRemove, + DiffContext, + /// Embedded Sway source lines (` | …`) from the source overlay. + Source, + Plain, +} + +impl Kind { + fn style(self) -> Style { + match self { + Kind::Comment => Style::default().fg(Color::DarkGray), + Kind::Keyword => Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + Kind::Opcode => Style::default().fg(Color::Cyan), + Kind::Type => Style::default().fg(Color::Yellow), + Kind::Number => Style::default().fg(Color::LightGreen), + Kind::String => Style::default().fg(Color::Green), + Kind::Metadata => Style::default().fg(Color::LightBlue), + Kind::Label => Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + Kind::Ident => Style::default().fg(Color::White), + Kind::Punct => Style::default().fg(Color::Gray), + // Very dark backgrounds so syntax colors stay readable. + Kind::DiffAdd => Style::default() + .fg(Color::Rgb(160, 230, 160)) + .bg(Color::Rgb(0, 32, 0)), + Kind::DiffRemove => Style::default() + .fg(Color::Rgb(230, 160, 160)) + .bg(Color::Rgb(32, 0, 0)), + Kind::DiffContext => Style::default().fg(Color::DarkGray), + Kind::Source => Style::default().fg(Color::Rgb(120, 200, 140)), + Kind::Plain => Style::default(), + } + } +} + +const DIFF_ADD_BG: Color = Color::Rgb(0, 32, 0); +const DIFF_REMOVE_BG: Color = Color::Rgb(32, 0, 0); + +const KEYWORDS: &[&str] = &[ + "fn", + "pub", + "entry", + "entry_orig", + "fallback", + "local", + "mut", + "script", + "contract", + "predicate", + "library", + "global", + "configurable", + "storage_key", + "true", + "false", + "to", + "key", + "x", + "wide", + "inline", + "span", +]; + +const TYPES: &[&str] = &[ + "bool", "u8", "u16", "u32", "u64", "u256", "b256", "unit", "string", "__ptr", "ptr", +]; + +const OPCODES: &[&str] = &[ + "add", + "sub", + "mul", + "div", + "and", + "or", + "xor", + "lsh", + "rsh", + "mod", + "not", + "eq", + "lt", + "gt", + "asm", + "bitcast", + "br", + "call", + "cast_ptr", + "cbr", + "cmp", + "const", + "contract_call", + "get_elem_ptr", + "get_local", + "get_global", + "get_config", + "get_storage_key", + "gtf", + "int_to_ptr", + "alloc", + "load", + "log", + "mem_copy_bytes", + "mem_copy_val", + "mem_clear_val", + "nop", + "ptr_to_int", + "read_register", + "ret", + "retd", + "revert", + "jmp_mem", + "smo", + "state_clear", + "state_clear_slots", + "state_load_quad_word", + "state_read_slot", + "state_load_word", + "state_store_quad_word", + "state_write_slot", + "state_update_slot", + "state_preload", + "state_store_word", + "store", + "init_aggr", +]; + +/// Highlight a full IR (or diff) document into styled lines. +pub(crate) fn highlight_ir(text: &str, is_diff: bool) -> Vec> { + text.lines() + .map(|line| highlight_line(line, is_diff)) + .collect() +} + +fn highlight_line(line: &str, is_diff: bool) -> Line<'static> { + // Diff prefixes from prettydiff-style output. + let (prefix, rest, diff_kind) = if is_diff { + if let Some(r) = line.strip_prefix("+ ") { + ("+ ", r, Some(Kind::DiffAdd)) + } else if let Some(r) = line.strip_prefix("- ") { + ("- ", r, Some(Kind::DiffRemove)) + } else if let Some(r) = line.strip_prefix(" ") { + (" ", r, Some(Kind::DiffContext)) + } else { + ("", line, None) + } + } else { + ("", line, None) + }; + + // Full-line comments / embedded source lines from the source overlay. + let trimmed = rest.trim_start(); + let built = if trimmed.starts_with("//") { + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + diff_kind.unwrap_or(Kind::Plain).style(), + )); + } + spans.push(Span::styled(rest.to_string(), Kind::Comment.style())); + Line::from(spans) + } else if rest.starts_with(" |") || trimmed.starts_with("| ") { + // ` | ` lines inserted by the source overlay. + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + diff_kind.unwrap_or(Kind::Plain).style(), + )); + } + spans.push(Span::styled(rest.to_string(), Kind::Source.style())); + Line::from(spans) + } else if looks_like_label(trimmed) { + // Block labels: `name():` or `name:` + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + diff_kind.unwrap_or(Kind::Plain).style(), + )); + } + let lead = rest.len() - trimmed.len(); + if lead > 0 { + spans.push(Span::raw(rest[..lead].to_string())); + } + spans.push(Span::styled(trimmed.to_string(), Kind::Label.style())); + Line::from(spans) + } else { + let mut spans = Vec::new(); + if !prefix.is_empty() { + spans.push(Span::styled( + prefix.to_string(), + diff_kind.unwrap_or(Kind::Plain).style(), + )); + } + spans.extend(tokenize(rest)); + Line::from(spans) + }; + + apply_diff_background(built, diff_kind) +} + +/// Paint the whole line with a very dark red/green background for diffs. +fn apply_diff_background(line: Line<'static>, diff_kind: Option) -> Line<'static> { + let bg = match diff_kind { + Some(Kind::DiffAdd) => DIFF_ADD_BG, + Some(Kind::DiffRemove) => DIFF_REMOVE_BG, + _ => return line, + }; + let spans: Vec> = line + .spans + .into_iter() + .map(|s| Span::styled(s.content.to_string(), s.style.bg(bg))) + .collect(); + Line::from(spans).style(Style::default().bg(bg)) +} + +fn looks_like_label(trimmed: &str) -> bool { + if !trimmed.ends_with(':') { + return false; + } + let name = trimmed.trim_end_matches(':').trim_end_matches("()"); + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn tokenize(s: &str) -> Vec> { + let bytes = s.as_bytes(); + let mut spans = Vec::new(); + let mut i = 0usize; + + while i < bytes.len() { + // Keep `i` on a char boundary at the top of every iteration. + debug_assert!(s.is_char_boundary(i)); + + // Whitespace (ASCII only; Unicode whitespace is emitted below). + if bytes[i].is_ascii_whitespace() { + let start = i; + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + spans.push(Span::raw(s[start..i].to_string())); + continue; + } + + // Line comment + if bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' { + spans.push(Span::styled(s[i..].to_string(), Kind::Comment.style())); + break; + } + + // String literal + if bytes[i] == b'"' { + let start = i; + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + // Skip the backslash and the next Unicode scalar. Using + // `i + 2` can land mid-UTF-8 and panic on a later slice. + i += 1; + if i < bytes.len() { + i = next_char_boundary(s, i); + } + continue; + } + if bytes[i] == b'"' { + i += 1; + break; + } + i = next_char_boundary(s, i); + } + spans.push(Span::styled(s[start..i].to_string(), Kind::String.style())); + continue; + } + + // Metadata `!123` + if bytes[i] == b'!' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() { + let start = i; + i += 1; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + spans.push(Span::styled( + s[start..i].to_string(), + Kind::Metadata.style(), + )); + continue; + } + + // Number (decimal or hex) + if bytes[i].is_ascii_digit() { + let start = i; + if i + 1 < bytes.len() + && bytes[i] == b'0' + && (bytes[i + 1] == b'x' || bytes[i + 1] == b'X') + { + i += 2; + while i < bytes.len() && bytes[i].is_ascii_hexdigit() { + i += 1; + } + } else { + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + spans.push(Span::styled(s[start..i].to_string(), Kind::Number.style())); + continue; + } + + // Identifier / keyword / opcode / type + if is_ident_start(bytes[i]) { + let start = i; + i += 1; + while i < bytes.len() && is_ident_cont(bytes[i]) { + i += 1; + } + let word = &s[start..i]; + let kind = classify_word(word); + spans.push(Span::styled(word.to_string(), kind.style())); + continue; + } + + // One Unicode scalar (ASCII punct or any non-ASCII). Never slice a + // single byte out of a multi-byte UTF-8 character — that panics. + let next = next_char_boundary(s, i); + spans.push(Span::styled( + s[i..next].to_string(), + Kind::Punct.style(), + )); + i = next; + } + + spans +} + +/// Advance from a char boundary to the start of the following character. +fn next_char_boundary(s: &str, i: usize) -> usize { + s[i..] + .chars() + .next() + .map(|c| i + c.len_utf8()) + .unwrap_or(s.len()) +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' +} + +fn is_ident_cont(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +fn classify_word(word: &str) -> Kind { + if KEYWORDS.contains(&word) { + Kind::Keyword + } else if OPCODES.contains(&word) { + Kind::Opcode + } else if TYPES.contains(&word) { + Kind::Type + } else { + Kind::Ident + } +} + +/// Background palette for multi-SSA focus highlights (overrides syntax bg). +/// Shared palette for SSA / local / method focus highlights. +pub(crate) const SYMBOL_PALETTE: &[Color] = &[ + Color::Rgb(120, 70, 0), // amber + Color::Rgb(0, 90, 110), // teal + Color::Rgb(110, 0, 110), // purple + Color::Rgb(0, 100, 40), // green + Color::Rgb(130, 30, 30), // red + Color::Rgb(30, 50, 140), // blue + Color::Rgb(110, 100, 0), // olive + Color::Rgb(130, 50, 80), // rose +]; + +/// Back-compat alias. +pub(crate) const SSA_PALETTE: &[Color] = SYMBOL_PALETTE; + +/// True for Sway-IR value names like `v0`, `v12`, `v3v1`. +pub(crate) fn is_ssa_name(s: &str) -> bool { + let b = s.as_bytes(); + if b.len() < 2 || b[0] != b'v' { + return false; + } + let mut i = 1; + if i >= b.len() || !b[i].is_ascii_digit() { + return false; + } + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + if i == b.len() { + return true; + } + if b[i] != b'v' { + return false; + } + i += 1; + if i >= b.len() || !b[i].is_ascii_digit() { + return false; + } + while i < b.len() && b[i].is_ascii_digit() { + i += 1; + } + i == b.len() +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' +} + +/// SSA register covering character column `col` (0-based), if any. +pub(crate) fn ssa_at_col(line: &str, col: usize) -> Option { + let chars: Vec<(usize, char)> = line.char_indices().collect(); + if chars.is_empty() || col >= chars.len() { + return None; + } + let byte_pos = chars[col].0; + let bytes = line.as_bytes(); + if !is_ident_byte(bytes[byte_pos]) { + return None; + } + let mut start = byte_pos; + while start > 0 && is_ident_byte(bytes[start - 1]) { + start -= 1; + } + let mut end = byte_pos + chars[col].1.len_utf8(); + while end < bytes.len() && is_ident_byte(bytes[end]) { + end += 1; + } + let tok = line.get(start..end)?; + is_ssa_name(tok).then(|| tok.to_string()) +} + + +/// Identifier token covering character column `col` (0-based), if any. +pub(crate) fn ident_at_col(line: &str, col: usize) -> Option { + let chars: Vec<(usize, char)> = line.char_indices().collect(); + if chars.is_empty() || col >= chars.len() { + return None; + } + let byte_pos = chars[col].0; + let bytes = line.as_bytes(); + if !is_ident_byte(bytes[byte_pos]) { + return None; + } + let mut start = byte_pos; + while start > 0 && is_ident_byte(bytes[start - 1]) { + start -= 1; + } + let mut end = byte_pos + chars[col].1.len_utf8(); + while end < bytes.len() && is_ident_byte(bytes[end]) { + end += 1; + } + line.get(start..end).map(str::to_string) +} + +fn is_ident_name(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() || c == '_' => { + chars.all(|c| c.is_ascii_alphanumeric() || c == '_') + } + _ => false, + } +} + +/// Skip one IR type (`u64`, `__ptr T`, `{…}`, `[…; N]`) and return the remainder. +fn skip_ir_type(s: &str) -> Option<&str> { + let s = s.trim_start(); + if s.is_empty() { + return None; + } + if s.starts_with('{') { + return skip_balanced(s, b'{', b'}'); + } + if s.starts_with('[') { + return skip_balanced(s, b'[', b']'); + } + // Leading ident (e.g. u64, __ptr, b256). + let bytes = s.as_bytes(); + let mut i = 0usize; + if !is_ident_byte(bytes[0]) { + return None; + } + while i < bytes.len() && is_ident_byte(bytes[i]) { + i += 1; + } + let head = &s[..i]; + let rest = s[i..].trim_start(); + if head == "__ptr" || head == "ptr" { + return skip_ir_type(rest); + } + Some(rest) +} + +fn skip_balanced<'a>(s: &'a str, open: u8, close: u8) -> Option<&'a str> { + let bytes = s.as_bytes(); + if bytes.first() != Some(&open) { + return None; + } + let mut depth = 0i32; + for (i, &b) in bytes.iter().enumerate() { + if b == open { + depth += 1; + } else if b == close { + depth -= 1; + if depth == 0 { + return Some(s[i + 1..].trim_start()); + } + } + } + None +} + +/// Name declared by a `local [mut] ` line. +pub(crate) fn local_decl_name(line: &str) -> Option<&str> { + let (body, _) = line_instruction_body(line); + let rest = body.strip_prefix("local")?.trim_start(); + let rest = match rest.strip_prefix("mut") { + Some(r) => r.trim_start(), + None => rest, + }; + let after_ty = skip_ir_type(rest)?; + let name = after_ty + .split(|c: char| c == ' ' || c == '=' || c == ',' || c == '!') + .next() + .unwrap_or(""); + if is_ident_name(name) && !is_ssa_name(name) { + Some(name) + } else { + None + } +} + +/// Local name after the comma in `get_local …, name`. +pub(crate) fn get_local_name(line: &str) -> Option<&str> { + let (body, _) = line_instruction_body(line); + // Match both `get_local` opcode forms, including after `= `. + let idx = body.find("get_local")?; + let after = &body[idx + "get_local".len()..]; + let after_comma = after.rsplit_once(',')?.1.trim(); + let name = after_comma + .split(|c: char| c == ' ' || c == '!' || c == ',') + .next() + .unwrap_or(""); + if is_ident_name(name) && !is_ssa_name(name) { + Some(name) + } else { + None + } +} + +/// Function name from `fn name(` / `pub entry fn name(`. +pub(crate) fn fn_def_name(line: &str) -> Option<&str> { + let (body, _) = line_instruction_body(line); + let idx = body.find("fn ")?; + let after = body[idx + 3..].trim_start(); + let name = after.split(|c: char| c == '(' || c == ' ' || c == '!').next()?; + if is_ident_name(name) && !is_ssa_name(name) { + Some(name) + } else { + None + } +} + +/// Callee from `call name(` / `= call name(`. +pub(crate) fn call_name(line: &str) -> Option<&str> { + let (body, _) = line_instruction_body(line); + let idx = body.find("call ")?; + let after = body[idx + 5..].trim_start(); + let name = after.split(|c: char| c == '(' || c == ' ' || c == '!' || c == ',').next()?; + if is_ident_name(name) && !is_ssa_name(name) { + Some(name) + } else { + None + } +} + +pub(crate) fn text_has_local(text: &str, name: &str) -> bool { + text.lines().any(|line| { + local_decl_name(line) == Some(name) || get_local_name(line) == Some(name) + }) +} + +pub(crate) fn text_has_method(text: &str, name: &str) -> bool { + text.lines().any(|line| fn_def_name(line) == Some(name) || call_name(line) == Some(name)) +} + +/// Highlightable symbol under the cursor: SSA register, local, or method/function name. +pub(crate) fn symbol_at_col(line: &str, col: usize, full_text: &str) -> Option { + let tok = ident_at_col(line, col)?; + if is_ssa_name(&tok) { + return Some(tok); + } + if !is_ident_name(&tok) { + return None; + } + // Prefer context on this line, then dump-wide recognition. + if local_decl_name(line) == Some(tok.as_str()) + || get_local_name(line) == Some(tok.as_str()) + || fn_def_name(line) == Some(tok.as_str()) + || call_name(line) == Some(tok.as_str()) + || text_has_local(full_text, &tok) + || text_has_method(full_text, &tok) + { + return Some(tok); + } + None +} + +/// Count whole-token occurrences of `name` above `view_start` / at-or-after `view_end`. +pub(crate) fn count_token_offscreen( + text: &str, + name: &str, + view_start: usize, + view_end: usize, +) -> (usize, usize) { + if name.is_empty() { + return (0, 0); + } + let name_bytes = name.as_bytes(); + let mut above = 0usize; + let mut below = 0usize; + for (line_idx, line) in text.lines().enumerate() { + let bytes = line.as_bytes(); + let mut start = 0usize; + let mut n = 0usize; + while start + name_bytes.len() <= bytes.len() { + if bytes[start..].starts_with(name_bytes) + && (start == 0 || !is_ident_byte(bytes[start - 1])) + && { + let end = start + name_bytes.len(); + end == bytes.len() || !is_ident_byte(bytes[end]) + } + { + n += 1; + start += name_bytes.len(); + } else { + start += 1; + } + } + if line_idx < view_start { + above += n; + } else if line_idx >= view_end { + below += n; + } + } + (above, below) +} + +/// Highlight IR with optional syntax coloring and search overlay. +/// SSA focus highlights are applied separately via [`apply_ssa_highlights`]. +pub(crate) fn highlight_ir_with_search( + text: &str, + is_diff: bool, + search: &str, + current_match_line: Option, + syntax: bool, +) -> Vec> { + let mut lines: Vec> = if syntax { + highlight_ir(text, is_diff) + } else if is_diff { + text.lines() + .map(|line| { + let style = if line.starts_with("+ ") { + Kind::DiffAdd.style() + } else if line.starts_with("- ") { + Kind::DiffRemove.style() + } else { + Style::default().fg(Color::White) + }; + Line::from(Span::styled(line.to_string(), style)) + }) + .collect() + } else { + text.lines() + .map(|line| { + Line::from(Span::styled( + line.to_string(), + Style::default().fg(Color::White), + )) + }) + .collect() + }; + + if search.is_empty() { + return lines; + } + + let needle = search.to_ascii_lowercase(); + for (idx, styled) in lines.iter_mut().enumerate() { + let line_text: String = styled.spans.iter().map(|s| s.content.as_ref()).collect(); + if !line_text.to_ascii_lowercase().contains(&needle) { + continue; + } + let mark = if Some(idx) == current_match_line { + Style::default() + .bg(Color::Yellow) + .fg(Color::Black) + .add_modifier(Modifier::BOLD) + } else { + Style::default().bg(Color::Rgb(60, 60, 20)) + }; + let content: Vec = styled + .spans + .drain(..) + .map(|s| { + let mut st = s.style; + if let Some(bg) = mark.bg { + st = st.bg(bg); + } + if let Some(fg) = mark.fg { + if Some(idx) == current_match_line { + st = st.fg(fg); + } + } + Span::styled(s.content.to_string(), st) + }) + .collect(); + *styled = Line::from(content); + } + lines +} + +/// Paint SSA token backgrounds. Always overrides whatever bg syntax/search set. +pub(crate) fn apply_symbol_highlights(lines: &mut [Line<'static>], highlights: &[(String, Color)]) { + apply_ssa_highlights(lines, highlights); +} + +pub(crate) fn apply_ssa_highlights(lines: &mut [Line<'static>], highlights: &[(String, Color)]) { + if highlights.is_empty() { + return; + } + for line in lines { + apply_ssa_to_line(line, highlights); + } +} + +fn apply_ssa_to_line(line: &mut Line<'static>, highlights: &[(String, Color)]) { + let text: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); + if text.is_empty() { + return; + } + + let mut styles: Vec