diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 848005a8..8ef81aac 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -48,10 +48,14 @@ jobs: cargo install --force --path mutest-driver cargo install --force --path cargo-mutest + - name: Install mutest-inspector + run: cargo install --force --path mutest-inspector + - name: Run unit tests run: | cargo test -p cargo-mutest --no-fail-fast -- --color=always cargo test -p mutest-runtime --no-fail-fast -- --color=always + cargo test -p mutest-inspector --no-fail-fast -- --color=always - name: Run UI tests run: cargo ui-test diff --git a/Cargo.lock b/Cargo.lock index 11706fa0..01cf0528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,36 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "anstream" version = "0.6.21" @@ -52,6 +82,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-wait" version = "1.1.0" @@ -62,6 +103,117 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + [[package]] name = "camino" version = "1.2.1" @@ -107,6 +259,16 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cc" +version = "1.2.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -167,6 +329,44 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -190,6 +390,74 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -208,6 +476,94 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -236,18 +592,92 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memory-serve" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b5bbad2035f57b1e95f66da606832edd935b47d82312e38e1ccffbcfb8a427" +dependencies = [ + "axum", + "brotli", + "flate2", + "mime_guess", + "sha256", + "tracing", + "urlencoding", + "walkdir", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "mutest-driver" version = "0.0.0" @@ -280,6 +710,24 @@ dependencies = [ "rand", ] +[[package]] +name = "mutest-inspector" +version = "0.0.0" +dependencies = [ + "axum", + "clap", + "color-print", + "memory-serve", + "mutest-json", + "opener", + "pulldown-cmark-escape", + "serde", + "serde_json", + "tokio", + "tree-sitter-highlight", + "tree-sitter-rust", +] + [[package]] name = "mutest-json" version = "0.0.0" @@ -341,18 +789,73 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normpath" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opener" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2fa337e0cf13357c13ef1dc108df1333eb192f75fc170bea03fcf1fd404c2ee" +dependencies = [ + "bstr", + "normpath", + "windows-sys 0.61.2", +] + +[[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 = "path-slash" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e91099d4268b0e11973f036e885d652fb0b21fedcf69738c627f94db6a44f42" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "phf" version = "0.13.1" @@ -395,6 +898,18 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -413,6 +928,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.42" @@ -466,12 +987,65 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[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.27" @@ -530,6 +1104,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", @@ -557,6 +1132,63 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha256" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f880fc8562bdeb709793f00eb42a2ad0e672c4f883bbe59122b926eca935c8f6" +dependencies = [ + "async-trait", + "bytes", + "hex", + "sha2", + "tokio", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -578,6 +1210,22 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + [[package]] name = "strsim" version = "0.11.1" @@ -595,6 +1243,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "test-metadata-shim" version = "0.0.0" @@ -619,6 +1273,34 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.9.8" @@ -658,24 +1340,166 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-highlight" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" +dependencies = [ + "regex", + "streaming-iterator", + "thiserror", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4013970217383f67b18aef68f6fb2e8d409bc5755227092d32efb0422ba24b8" + +[[package]] +name = "tree-sitter-rust" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b18034c684a2420722be8b2a91c9c44f2546b631c039edf575ccba8c61be1" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -685,6 +1509,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -697,13 +1530,22 @@ version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", ] [[package]] @@ -715,48 +1557,113 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.13" diff --git a/Cargo.toml b/Cargo.toml index c0144feb..5e1d2e6f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "mutest-driver", "mutest-driver-cli", "mutest-emit", + "mutest-inspector", "mutest-json", "mutest-operators", "mutest-runtime", diff --git a/cargo-mutest/src/main.rs b/cargo-mutest/src/main.rs index c302f484..0533999e 100644 --- a/cargo-mutest/src/main.rs +++ b/cargo-mutest/src/main.rs @@ -1,3 +1,5 @@ +#![feature(if_let_guard)] + use std::collections::HashSet; use std::env; use std::fs; @@ -44,9 +46,9 @@ fn test_strip_arg() { strip_arg(&mut args, true, None, Some("features")); assert_eq!(&[] as &[String], &args[..]); - let mut args = vec!["--features=all".to_owned(), "--Zwrite-json=target/mutest/json".to_owned(), "--print=code".to_owned()]; + let mut args = vec!["--features=all".to_owned(), "--json-out-root-dir=target/mutest/json".to_owned(), "--print=code".to_owned()]; strip_arg(&mut args, true, None, Some("features")); - assert_eq!(&["--Zwrite-json=target/mutest/json".to_owned(), "--print=code".to_owned()] as &[String], &args[..]); + assert_eq!(&["--json-out-root-dir=target/mutest/json".to_owned(), "--print=code".to_owned()] as &[String], &args[..]); } mod run_isolate { @@ -90,6 +92,7 @@ fn main() { .subcommand(clap::Command::new("run") .display_order(0) .about("Build and run the test harness.") + .arg(clap::arg!(-i --inspect [INSPECT_OPTS] "Inspect mutations after evaluation. Options may be specified in the form `--inspect=[open][:]`.").num_args(0..=1).require_equals(true).display_order(100)) // Evaluation-related Arguments .arg(clap::arg!(--simulate [MUTATION_ID] "Evaluate tests for a single mutation.").value_parser(clap::value_parser!(u32)).conflicts_with_all(["flakes", "exhaustive", "print"]).display_order(110)) .arg(clap::arg!(--flakes [ITERATIONS_COUNT] "Perform mutation analysis multiple times to find flaky test-mutation pairs.").value_parser(clap::value_parser!(usize)).display_order(111)) @@ -99,7 +102,7 @@ fn main() { // Printing-related Arguments .arg(clap::arg!(--print [PRINT] "Print additional information during mutation evaluation. Multiple may be specified, separated by commas.").value_delimiter(',').value_parser(run_print::possible_values()).display_order(101)) // Experimental Flags - .arg(clap::arg!(--"Zwrite-json-eval-stream" "Write JSONL stream file into JSON output directory specified by `--Zwrite-json`.").display_order(500)) + .arg(clap::arg!(--"Zwrite-json-eval-stream" "Write JSONL stream file into JSON output directory specified by `--json-out-root-dir`.").display_order(500)) // Passed arguments .arg(clap::Arg::new("PASSED_ARGS").trailing_var_arg(true).allow_hyphen_values(true)) ) @@ -134,12 +137,43 @@ fn main() { let embedded = matches.get_flag("Zembedded"); - let (cargo_subcommand, cargo_args, mutest_driver_subcommand, passed_args): (_, &[&str], _, _) = match matches.subcommand() { - Some(("print", _)) => ("check", &["--profile", "test"], "print", None), - Some(("build", _)) => ("test", &["--no-run"], "build", None), + let (cargo_subcommand, cargo_args, mutest_driver_subcommand, passed_args, inspect_opts): (_, &[&str], _, _, _) = match matches.subcommand() { + Some(("print", _)) => ("check", &["--profile", "test"], "print", None, None), + Some(("build", _)) => ("test", &["--no-run"], "build", None, None), Some(("run", matches)) => { let mut passed_args = matches.get_many::("PASSED_ARGS").unwrap_or_default().map(ToOwned::to_owned).collect::>(); + let inspect_opts = match matches.value_source("inspect") { + Some(clap::parser::ValueSource::CommandLine) if let Some(inspect_opts_str) = matches.get_one::("inspect")=> { + let mut unparsed_str: &str = inspect_opts_str; + + let mut open = false; + if let Some(rest) = unparsed_str.strip_prefix("open") { + open = true; + unparsed_str = rest; + } + if !unparsed_str.is_empty() && !unparsed_str.starts_with(":") { + color_print::ceprintln!("error: invalid inspect options `{}`: must match `[open][:<>]`", inspect_opts_str); + process::exit(101); + } + + let mut port = None; + if let Some(port_str) = unparsed_str.strip_prefix(":") { + port = match port_str.parse::() { + Ok(v) => Some(v), + Err(_) => { + color_print::ceprintln!("error: invalid inspect options `{}`: invalid port number `{}`", inspect_opts_str, port_str); + process::exit(101); + } + }; + } + + Some((open, port)) + } + Some(clap::parser::ValueSource::CommandLine) => Some((false, None)), + _ => None, + }; + if let Some(mutation_id) = matches.get_one::("simulate") { passed_args.push(format!("--simulate={mutation_id}")); } if let Some(iterations_count) = matches.get_one::("flakes") { passed_args.push(format!("--flakes={iterations_count}")); } @@ -156,7 +190,7 @@ fn main() { if matches.get_flag("Zwrite-json-eval-stream") { passed_args.push("--Zwrite-json-eval-stream".to_owned()); } - ("test", &[], "build", Some(passed_args)) + ("test", &[], "build", Some(passed_args), inspect_opts) } _ => unreachable!(), }; @@ -278,54 +312,54 @@ fn main() { } // Target selection. - let mut any_specific_targets_selected = false; + let mut explicit_targetings_count = 0; if matches.get_flag("lib") { - any_specific_targets_selected = true; + explicit_targetings_count += 1; cmd.arg("--lib"); strip_arg(&mut mutest_args, false, None, Some("lib")); } if let Some(bins) = matches.get_many::("bin") { - any_specific_targets_selected = true; for bin in bins { + explicit_targetings_count += 1; cmd.args(["--bin", bin]); } strip_arg(&mut mutest_args, true, None, Some("bin")); } if matches.get_flag("bins") { - any_specific_targets_selected = true; + explicit_targetings_count += 1; cmd.arg("--bins"); strip_arg(&mut mutest_args, false, None, Some("bins")); } if let Some(examples) = matches.get_many::("example") { - any_specific_targets_selected = true; for example in examples { + explicit_targetings_count += 1; cmd.args(["--example", example]); } strip_arg(&mut mutest_args, true, None, Some("example")); } if matches.get_flag("examples") { - any_specific_targets_selected = true; + explicit_targetings_count += 1; cmd.arg("--examples"); strip_arg(&mut mutest_args, false, None, Some("examples")); } if let Some(tests) = matches.get_many::("test") { - any_specific_targets_selected = true; for test in tests { + explicit_targetings_count += 1; cmd.args(["--test", test]); } strip_arg(&mut mutest_args, true, None, Some("test")); } if matches.get_flag("tests") { - any_specific_targets_selected = true; + explicit_targetings_count += 1; cmd.arg("--tests"); strip_arg(&mut mutest_args, false, None, Some("tests")); } if matches.get_flag("all-targets") { - any_specific_targets_selected = true; + explicit_targetings_count += 1; cmd.arg("--all-targets"); strip_arg(&mut mutest_args, false, None, Some("all-targets")); } - if !any_specific_targets_selected { + if explicit_targetings_count == 0 { // NOTE: We specifically do not target the following: // * `--bench`/`--benches`: Benchmarks, for two reasons. // First, the `#[bench]` attribute is currently a nigthly-only feature. @@ -352,14 +386,14 @@ fn main() { cmd.arg("--"); cmd.args((0..matches.get_count("verbose")).map(|_| "-v")); if matches.get_flag("timings") { cmd.arg("--timings"); } - if let Some(clap::parser::ValueSource::CommandLine) = matches.value_source("Zwrite-json") { - let out_dir = matches.get_one::("Zwrite-json").cloned().unwrap_or_else(|| target_dir.join("json")); + if !matches.get_flag("no-write-json") { + let out_dir = matches.get_one::("json-out-root-dir").cloned().unwrap_or_else(|| target_dir.join("json")); fs::create_dir_all(&out_dir).expect(&format!("cannot create JSON output directory at `{}`", out_dir.display())); // NOTE: The out dir path passed to the generated test binary must be canonicalized, // as it will likely be run under a different cwd. let out_dir = out_dir.canonicalize().expect("cannot canonicalize out dir path"); let out_dir = out_dir.as_os_str().to_str().expect("non-UTF-8 path"); - cmd.arg(format!("--Zwrite-json={out_dir}")); + cmd.arg(format!("--json-out-root-dir={out_dir}")); } cmd.args(&passed_args); } @@ -368,5 +402,56 @@ fn main() { .spawn().expect("failed to run Cargo") .wait().expect("failed to run Cargo"); + let exit_code = exit_status.code(); + if exit_code != Some(0) && exit_code != Some(101) { + process::exit(exit_code.unwrap_or(-1)); + } + + let Some((open, port)) = inspect_opts else { + process::exit(exit_code.unwrap_or(-1)); + }; + + // NOTE: This replicates Cargo's action message styling, including the color and justification. + color_print::ceprintln!("{:>12} inspector", "Running"); + + let json_root_dir = matches.get_one::("json-out-root-dir").cloned().unwrap_or_else(|| target_dir.join("json")); + + let mut path = env::current_exe().expect("current executable path invalid"); + path.set_file_name("mutest-inspector"); + if cfg!(windows) { path.set_extension("exe"); } + + let mut cmd = Command::new(path); + + cmd.arg("--json-root-dir"); + cmd.arg(json_root_dir); + + if let Some(port) = port { cmd.arg(format!("--port={port}")); } + + if open { + // NOTE: Only attempt to open a specific target if only one was selected. + if let Some(package) = matches.get_one::("package") { + if explicit_targetings_count == 1 { + match () { + _ if matches.get_flag("lib") => { cmd.arg(format!("--open={}/lib", package)); } + _ if let Some(target_name) = matches.get_one::("bin") => { cmd.arg(format!("--open={}/bin:{}", package, target_name)); } + _ if let Some(target_name) = matches.get_one::("example") => { cmd.arg(format!("--open={}/example:{}", package, target_name)); } + _ if let Some(target_name) = matches.get_one::("test") => { cmd.arg(format!("--open={}/test:{}", package, target_name)); } + // NOTE: Only open the package if no specific target was selected. + // This includes generic selectors, such as `--bins`, `--examples`, `--tests`, and `--all-targets`. + _ => { cmd.arg(format!("--open={}", package)); } + } + } else { + // NOTE: Only open the package if no specific (none or multiple) target was selected. + cmd.arg(format!("--open={}", package)); + } + } else { + cmd.arg("--open"); + } + } + + let exit_status = cmd + .spawn().expect("failed to run mutest-inspector") + .wait().expect("failed to run mutest-inspector"); + process::exit(exit_status.code().unwrap_or(-1)); } diff --git a/mutest-driver-cli/src/lib.rs b/mutest-driver-cli/src/lib.rs index 2977ea5d..1cfc8c5b 100644 --- a/mutest-driver-cli/src/lib.rs +++ b/mutest-driver-cli/src/lib.rs @@ -156,8 +156,10 @@ pub fn command() -> clap::Command { .arg(clap::arg!(--"graph-format" [GRAPH_FORMAT] "Format to print the graph in.").value_parser(graph_format::possible_values()).default_value(graph_format::SIMPLE).display_order(102)) .arg(clap::arg!(--"call-graph-non-local-calls" [CALL_GRAPH_NON_LOCAL_CALL_VIEW] "Mode to display non-local calls in the call graph.").value_parser(call_graph_non_local_call_view::possible_values()).default_value(call_graph_non_local_call_view::COLLAPSE).display_order(103)) .arg(clap::arg!(--"call-graph-filter-entry-points" [ENTRY_POINTS] "Filter entry points to display the call graph for. Multiple may be specified, separated by commas.").value_delimiter(',').display_order(103)) + // Metadata-related Arguments + .arg(clap::arg!(--"json-out-root-dir" [JSON_ROOT_DIR] "Write JSON metadata files into the specified output directory. By default, JSON metadata files are written to `target/mutest/json`.").value_parser(clap::value_parser!(PathBuf)).display_order(105)) + .arg(clap::arg!(--"no-write-json" "Do not write JSON metadata files.").display_order(105)) // Experimental Flags - .arg(clap::arg!(--"Zwrite-json" [OUT_DIR] "Write JSON metadata files. An output directory may be optionally specified.").num_args(0..=1).require_equals(true).value_parser(clap::value_parser!(PathBuf)).display_order(500)) .arg(clap::arg!(--Zverify [VERIFY] "Perform additional checks to verify correctness and completeness. Multiple may be specified, separated by commas.").value_delimiter(',').value_parser(verify::possible_values()).display_order(500)) .arg(clap::arg!(--Zembedded "Enable experimental support for embedded-test tests and embedded firmware generation with no_std support using a tethered embedded mutation runtime.").display_order(500)) .arg(clap::arg!(--"Zno-sanitize-macro-expns" "Skip sanitizing the identifiers and paths in the expanded output of macro invocations. This was the previous behavior and is not recommended.").display_order(500)) diff --git a/mutest-driver/src/config.rs b/mutest-driver/src/config.rs index b272bbfe..fe1810ac 100644 --- a/mutest-driver/src/config.rs +++ b/mutest-driver/src/config.rs @@ -164,6 +164,7 @@ pub struct Options<'op, 'm> { pub verbosity: u8, pub report_timings: bool, pub print_opts: PrintOptions, + pub write_opts: Option, pub unsafe_targeting: UnsafeTargeting, pub operators: Operators<'op, 'm>, pub call_graph_depth_limit: Option, @@ -171,7 +172,6 @@ pub struct Options<'op, 'm> { pub mutation_depth: usize, pub mutation_parallelism: Option, - pub write_opts: Option, pub verify_opts: VerifyOptions, pub embedded: bool, pub sanitize_macro_expns: bool, diff --git a/mutest-driver/src/main.rs b/mutest-driver/src/main.rs index 2bce0fbe..ce150144 100644 --- a/mutest-driver/src/main.rs +++ b/mutest-driver/src/main.rs @@ -334,6 +334,46 @@ pub fn main() { print_opts }; + let write_opts = 'write_opts: { + if mutest_arg_matches.get_flag("no-write-json") { break 'write_opts None; } + + let mut out_dir = mutest_arg_matches.get_one::("json-out-root-dir").cloned() + .unwrap_or_else(|| mutest_target_dir_root.clone().unwrap_or(PathBuf::from("target/mutest")).join("json")); + + if let Some(cargo_package_name) = env::var("CARGO_PKG_NAME").ok() { + // NOTE: `CARGO_PKG_NAME` always corresponds to the root package name, even for other targets. + out_dir.push(cargo_package_name); + + let Some(cargo_crate_name) = env::var("CARGO_CRATE_NAME").ok() else { + panic!("invalid Cargo invocation: missing `CARGO_CRATE_NAME` environment variable"); + }; + + match cargo_target_kind { + None => {} + + Some(config::CargoTargetKind::Lib) => out_dir.push("lib"), + Some(config::CargoTargetKind::MainBin) => out_dir.push("bin"), + + Some(config::CargoTargetKind::Bin) => { + out_dir.push("bins"); + out_dir.push(cargo_crate_name); + } + Some(config::CargoTargetKind::Example) => { + out_dir.push("examples"); + out_dir.push(cargo_crate_name); + } + Some(config::CargoTargetKind::Test) => { + out_dir.push("tests"); + out_dir.push(cargo_crate_name); + } + } + + fs::create_dir_all(&out_dir).expect(&format!("cannot create JSON output directory for crate at `{}`", out_dir.display())); + } + + Some(config::WriteOptions { out_dir }) + }; + let unsafe_targeting = match () { _ if mutest_arg_matches.get_flag("safe") => UnsafeTargeting::None, _ if mutest_arg_matches.get_flag("cautious") => UnsafeTargeting::OnlyEnclosing(Safety::Unsafe), @@ -520,48 +560,6 @@ pub fn main() { })) }; - let write_opts = 'write_opts: { - let Some(clap::parser::ValueSource::CommandLine) = mutest_arg_matches.value_source("Zwrite-json") else { - break 'write_opts None; - }; - - let mut out_dir = mutest_arg_matches.get_one::("Zwrite-json").cloned() - .unwrap_or_else(|| mutest_target_dir_root.clone().unwrap_or(PathBuf::from("target/mutest")).join("json")); - - if let Some(cargo_package_name) = env::var("CARGO_PKG_NAME").ok() { - // NOTE: `CARGO_PKG_NAME` always corresponds to the root package name, even for other targets. - out_dir.push(cargo_package_name); - - let Some(cargo_crate_name) = env::var("CARGO_CRATE_NAME").ok() else { - panic!("invalid Cargo invocation: missing `CARGO_CRATE_NAME` environment variable"); - }; - - match cargo_target_kind { - None => {} - - Some(config::CargoTargetKind::Lib) => out_dir.push("lib"), - Some(config::CargoTargetKind::MainBin) => out_dir.push("bin"), - - Some(config::CargoTargetKind::Bin) => { - out_dir.push("bins"); - out_dir.push(cargo_crate_name); - } - Some(config::CargoTargetKind::Example) => { - out_dir.push("examples"); - out_dir.push(cargo_crate_name); - } - Some(config::CargoTargetKind::Test) => { - out_dir.push("tests"); - out_dir.push(cargo_crate_name); - } - } - - fs::create_dir_all(&out_dir).expect(&format!("cannot create JSON output directory for crate at `{}`", out_dir.display())); - } - - Some(config::WriteOptions { out_dir }) - }; - let verify_opts = { use mutest_driver_cli::verify as opts; diff --git a/mutest-driver/src/write.rs b/mutest-driver/src/write.rs index bb51797a..042e6331 100644 --- a/mutest-driver/src/write.rs +++ b/mutest-driver/src/write.rs @@ -70,7 +70,7 @@ pub fn write_call_graph<'tcx, 'ent>( definitions.push(mutest_json::Definition { def_id: json_def_id, name: tcx.opt_item_name(def_id).map(|symbol| symbol.as_str().to_owned()), - path: Some(tcx.def_path_str(def_id)), + path: tcx.def_path_str(def_id), span: mutest_json::Span::from_rustc_span(tcx.sess, tcx.def_span(def_id)), }); @@ -107,14 +107,12 @@ pub fn write_call_graph<'tcx, 'ent>( let mut json_entry_points = mutest_json::IdxVec::new(); for entry_point in entry_points.iter() { - let _ = register_def!(entry_point.local_def_id.to_def_id()); + let json_def_id = register_def!(entry_point.local_def_id.to_def_id()); let json_entry_point_id = json_entry_points.next_index(); let mut json_entry_point = mutest_json::call_graph::EntryPoint { entry_point_id: json_entry_point_id, - name: tcx.opt_item_name(entry_point.local_def_id.to_def_id()).map(|symbol| symbol.as_str().to_owned()).unwrap_or_default(), - path: tcx.def_path_str(entry_point.local_def_id.to_def_id()), - span: mutest_json::Span::from_rustc_span(tcx.sess, tcx.def_span(entry_point.local_def_id.to_def_id())), + def_id: json_def_id, calls: Default::default(), }; diff --git a/mutest-inspector/Cargo.toml b/mutest-inspector/Cargo.toml new file mode 100644 index 00000000..4aa41084 --- /dev/null +++ b/mutest-inspector/Cargo.toml @@ -0,0 +1,40 @@ +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[package] +name = "mutest-inspector" +version = "0.0.0" +authors = ["Zalán Bálint Lévai"] +description = "Interactive mutation inspector for mutest-rs." +keywords = ["mutation", "mutation-analysis", "mutation-testing", "testing"] +categories = ["development-tools::testing", "development-tools::cargo-plugins", "security"] +homepage = "https://mutest.rs" +repository = "https://github.com/zalanlevai/mutest-rs" +license = "Apache-2.0 OR MIT" +edition = "2024" + +[dependencies] +mutest-json = { path = "../mutest-json" } + +clap = { version = "4", features = ["cargo"] } +color-print = "0.3" + +serde = "1.0" +serde_json = "1.0" + +tokio = { version = "1", features = ["full"] } +axum = "0.8" +memory-serve = "2" + +tree-sitter-highlight = "0.25" +tree-sitter-rust = "0.24" + +pulldown-cmark-escape = "0.11" + +opener = "0.8" + +[build-dependencies] +memory-serve = "2" + +[features] +standalone = ["embed-static-files"] +embed-static-files = [] diff --git a/mutest-inspector/build.rs b/mutest-inspector/build.rs new file mode 100644 index 00000000..a93b6e56 --- /dev/null +++ b/mutest-inspector/build.rs @@ -0,0 +1,14 @@ +use std::env; +use std::path::Path; + +fn main() { + let static_dir_path = Path::new("./src/static"); + let static_dir_path = static_dir_path.canonicalize().expect("cannot canonicalize static dir path"); + + // NOTE: Force memory-serve not to print build-time log messages. + // SAFETY: No other thread is running. + unsafe { env::set_var("MEMORY_SERVE_QUIET", "1") }; + + let embed_static_files = env::var("CARGO_FEATURE_EMBED_STATIC_FILES").is_ok(); + memory_serve::load_directory_with_embed(static_dir_path, embed_static_files); +} diff --git a/mutest-inspector/src/call_graph.rs b/mutest-inspector/src/call_graph.rs new file mode 100644 index 00000000..b414d2e1 --- /dev/null +++ b/mutest-inspector/src/call_graph.rs @@ -0,0 +1,105 @@ +use std::collections::HashSet; +use std::fmt::Write; + +use mutest_json::DefId; +use mutest_json::call_graph::{CallGraph, EntryPoint, EntryPointId, CalleeId}; +use mutest_json::mutations::MutationId; + +#[derive(Clone, Debug)] +pub struct MonoCallTrace { + pub entry_point_id: EntryPointId, + pub nested_calls: Vec, +} + +pub(crate) fn build_mono_call_traces(call_graph: &CallGraph, mono_call_traces: &mut Vec, mono_call_trace: &mut MonoCallTrace, target_def_id: DefId) { + let [.., callee_id] = &mono_call_trace.nested_calls[..] else { return; }; + let callee = &call_graph.callees[*callee_id]; + + if callee.def_id == target_def_id { + mono_call_traces.push(mono_call_trace.clone()); + return; + } + + for (nested_callee_id, _) in &callee.calls { + if mono_call_trace.nested_calls.iter().any(|callee_id| callee_id == nested_callee_id) { continue; } + + mono_call_trace.nested_calls.push(*nested_callee_id); + build_mono_call_traces(call_graph, mono_call_traces, mono_call_trace, target_def_id); + mono_call_trace.nested_calls.pop(); + } +} + +pub fn entry_point_mono_call_traces(call_graph: &CallGraph, entry_point: &EntryPoint, target_def_id: DefId) -> Vec { + let mut mono_call_traces = Vec::new(); + + for (callee_id, _) in &entry_point.calls { + let mut mono_call_trace = MonoCallTrace { entry_point_id: entry_point.entry_point_id, nested_calls: vec![*callee_id] }; + build_mono_call_traces(&call_graph, &mut mono_call_traces, &mut mono_call_trace, target_def_id); + } + + mono_call_traces +} + +#[derive(Clone, Debug)] +#[derive(Eq, PartialEq, Hash)] +pub struct DefCallTrace { + pub entry_point_id: EntryPointId, + pub nested_calls: Vec, +} + +pub fn reduce_mono_call_traces(call_graph: &CallGraph, mono_call_traces: &[MonoCallTrace]) -> Vec { + let mut def_call_traces = HashSet::new(); + + for mono_call_trace in mono_call_traces { + let nested_calls = mono_call_trace.nested_calls.iter() + .map(|callee_id| { + let callee = &call_graph.callees[*callee_id]; + callee.def_id + }) + .collect(); + def_call_traces.insert(DefCallTrace { entry_point_id: mono_call_trace.entry_point_id, nested_calls }); + } + + def_call_traces.into_iter().collect() +} + +pub struct TraceSpec<'a> { + pub entry_point_def_path: &'a str, + pub callee_def_ids: Vec, + pub mutation_id: MutationId, +} + +impl<'a> TraceSpec<'a> { + pub fn write(&self, out: &mut String) { + write!(out, "{}", self.entry_point_def_path).unwrap(); + for &def_id in &self.callee_def_ids { + write!(out, "->D{}", def_id.0).unwrap(); + } + write!(out, "+M{}", self.mutation_id.0).unwrap(); + } + + pub fn to_string(&self) -> String { + let mut s = String::new(); + self.write(&mut s); + s + } + + pub fn parse(trace_spec_str: &'a str) -> Option { + let (entry_point_def_path, rest) = trace_spec_str.split_once("->")?; + let (trace_str, mutation_id_str) = rest.split_once('+')?; + let callee_def_ids = trace_str.split("->").filter_map(|entry_str| entry_str.strip_prefix('D')?.parse::().ok()).map(mutest_json::DefId).collect::>(); + let mutation_id = MutationId(mutation_id_str.strip_prefix('M')?.parse::().ok()?); + + if callee_def_ids.is_empty() { return None; } + + Some(Self { entry_point_def_path, callee_def_ids, mutation_id }) + } + + pub fn root_callee(&self) -> DefId { + *self.callee_def_ids.first().expect("invalid trace spec: no callees") + } + + pub fn target(&self) -> DefId { + *self.callee_def_ids.last().expect("invalid trace spec: no callees") + } +} diff --git a/mutest-inspector/src/config.rs b/mutest-inspector/src/config.rs new file mode 100644 index 00000000..2ad61b8b --- /dev/null +++ b/mutest-inspector/src/config.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use crate::ctxt::TargetSpec; + +pub enum OpenTarget { + Workspace, + Specific(String, Option), +} + +pub struct Options { + pub port: u16, + pub open: Option, +} + +pub struct Config { + pub json_root_dir_path: PathBuf, + pub opts: Options, +} diff --git a/mutest-inspector/src/ctxt.rs b/mutest-inspector/src/ctxt.rs new file mode 100644 index 00000000..df966a51 --- /dev/null +++ b/mutest-inspector/src/ctxt.rs @@ -0,0 +1,373 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use mutest_json::DefId; +use mutest_json::call_graph::CallGraph; +use mutest_json::data_structures::IdxVec; +use mutest_json::mutations::{MutationId, TargetId}; +use serde::Deserialize; + +use crate::evaluation::EvaluationInfo; +use crate::html::{SourceFileHtml, render_def_path_html}; +use crate::html::mutations::{OverlappingGroupOfMutations, update_overlapping_groups}; +use crate::metadata::{Definition, Mutation, Target, Test}; +use crate::source_file::{SourceFile, nudge_span_prefix_lines}; + +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Debug)] +pub enum TargetSpec { + Lib, + MainBin, + Bin(String), + Example(String), + Test(String), +} + +impl TargetSpec { + pub fn from_path_str(string: &str) -> Result { + match string { + "lib" => Ok(TargetSpec::Lib), + "bin" => Ok(TargetSpec::MainBin), + _ if let Some(name) = string.strip_prefix("bin:") => Ok(TargetSpec::Bin(name.to_owned())), + _ if let Some(name) = string.strip_prefix("example:") => Ok(TargetSpec::Example(name.to_owned())), + _ if let Some(name) = string.strip_prefix("test:") => Ok(TargetSpec::Test(name.to_owned())), + _ => Err(()), + } + } + + pub fn path_str(&self) -> String { + match self { + TargetSpec::Lib => "lib".to_owned(), + TargetSpec::MainBin => "bin".to_owned(), + TargetSpec::Bin(name) => format!("bin:{}", name), + TargetSpec::Example(name) => format!("example:{}", name), + TargetSpec::Test(name) => format!("test:{}", name), + } + } +} + +impl<'de> Deserialize<'de> for TargetSpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let string = <&str>::deserialize(deserializer)?; + TargetSpec::from_path_str(string).map_err(|_| D::Error::custom(format!("invalid target selector `{}`", string))) + } +} + +pub fn workspace_target_display_str(package: &str, target: &TargetSpec) -> String { + match target { + TargetSpec::Lib => format!("{} (lib)", package), + TargetSpec::MainBin => format!("{} (bin)", package), + TargetSpec::Bin(name) => format!("{}/{} (bin)", package, name), + TargetSpec::Example(name) => format!("{}/{} (example)", package, name), + TargetSpec::Test(name) => format!("{}/{} (test)", package, name), + } +} + +pub struct PackageCtxt { + pub lib: Option, + pub bin: Option, + pub bins: HashMap, + pub examples: HashMap, + pub tests: HashMap, +} + +impl PackageCtxt { + pub fn empty() -> Self { + Self { + lib: None, + bin: None, + bins: Default::default(), + examples: Default::default(), + tests: Default::default(), + } + } + + pub fn target(&self, target: &TargetSpec) -> Option<&TargetCtxt> { + match target { + TargetSpec::Lib => self.lib.as_ref(), + TargetSpec::MainBin => self.bin.as_ref(), + TargetSpec::Bin(name) => self.bins.get(name), + TargetSpec::Example(name) => self.examples.get(name), + TargetSpec::Test(name) => self.tests.get(name), + } + } + + pub fn iter_targets(&self) -> impl Iterator { + self.lib.as_ref().map(|tcx| (TargetSpec::Lib, tcx)).into_iter() + .chain(self.bin.as_ref().map(|tcx| (TargetSpec::MainBin, tcx))) + .chain(self.bins.iter().map(|(name, tcx)| (TargetSpec::Bin(name.to_owned()), tcx))) + .chain(self.examples.iter().map(|(name, tcx)| (TargetSpec::Example(name.to_owned()), tcx))) + .chain(self.tests.iter().map(|(name, tcx)| (TargetSpec::Test(name.to_owned()), tcx))) + } +} + +pub struct WorkspaceCtxt { + packages: HashMap, + loaded_targets: Vec<(String, TargetSpec)>, + loaded_source_files: HashMap, + source_file_htmls: HashMap, +} + +impl WorkspaceCtxt { + pub fn empty() -> Self { + Self { + packages: Default::default(), + loaded_targets: Vec::new(), + loaded_source_files: Default::default(), + source_file_htmls: Default::default(), + } + } + + pub(crate) fn register_target(&mut self, package: String, target: TargetSpec, tcx: TargetCtxt) { + let target_entry = (package.clone(), target.clone()); + + let pcx = self.packages.entry(package).or_insert_with(PackageCtxt::empty); + match target { + TargetSpec::Lib => { pcx.lib = Some(tcx); } + TargetSpec::MainBin => { pcx.bin = Some(tcx); } + TargetSpec::Bin(name) => { pcx.bins.insert(name, tcx); } + TargetSpec::Example(name) => { pcx.examples.insert(name, tcx); } + TargetSpec::Test(name) => { pcx.tests.insert(name, tcx); } + } + + // NOTE: Ensure that the targets list is always sorted by always using sorted insertions. + match self.loaded_targets.binary_search(&target_entry) { + Ok(_) => {} + Err(i) => self.loaded_targets.insert(i, target_entry), + }; + } + + pub(crate) fn register_loaded_source_file(&mut self, file_path: &Path, source_file: SourceFile, source_file_html: SourceFileHtml) { + self.loaded_source_files.insert(file_path.to_owned(), source_file); + self.source_file_htmls.insert(file_path.to_owned(), source_file_html); + } + + #[inline] + pub fn targets(&self) -> &[(String, TargetSpec)] { + &self.loaded_targets + } + + #[inline] + pub fn target(&self, package: &str, target: &TargetSpec) -> Option<&TargetCtxt> { + self.packages.get(package).and_then(|pcx| pcx.target(target)) + } + + #[inline] + pub fn loaded_source_file(&self, path: &Path) -> Option<&SourceFile> { + self.loaded_source_files.get(path) + } + + #[inline] + pub fn source_file_html(&self, path: &Path) -> Option<&SourceFileHtml> { + self.source_file_htmls.get(path) + } +} + +pub struct TargetCtxt { + unique_source_file_paths: Vec, + local_source_file_paths: Vec, + mutations_per_file: HashMap>, + mutations_on_overlapping_lines: HashMap>, + definitions: IdxVec, + tests: BTreeMap, + call_graph: CallGraph, + targets: IdxVec, + targets_reached_by_tests: HashMap>, + mutations_per_target: IdxVec>, + mutations: IdxVec, + evaluation_info: Option, +} + +impl TargetCtxt { + pub fn new( + wcx: &WorkspaceCtxt, + file_paths: HashSet, + call_graph_metadata: &mutest_json::call_graph::CallGraphInfo, + mutations_metadata: &mutest_json::mutations::MutationsInfo, + ) -> Self { + let mut unique_source_file_paths = file_paths.into_iter().collect::>(); + unique_source_file_paths.sort(); + + let local_source_file_paths = unique_source_file_paths.iter() + .filter(|path| path.is_relative()) + .cloned() + .collect::>(); + + let mut mutations_per_file = HashMap::>::new(); + for mutation in &mutations_metadata.mutations { + for subst in &mutation.substs { + let file_entry = mutations_per_file.entry(subst.location.span().path.to_owned()).or_default(); + if file_entry.contains(&mutation.mutation_id) { continue; } + // NOTE: The insertions in this loop guarantee that each entry's mutation ID list is ordered. + file_entry.push(mutation.mutation_id); + } + } + + let mut mutations_on_overlapping_lines = HashMap::>::new(); + for mutation in &mutations_metadata.mutations { + update_overlapping_groups(&mut mutations_on_overlapping_lines, mutation); + } + + let mut definitions = IdxVec::with_capacity(call_graph_metadata.definitions.len()); + for definition in &call_graph_metadata.definitions { + let mut nudge_prefix_lines = 0; + if let Some(span) = &definition.span && let Some(source_file) = wcx.loaded_source_file(&span.path) { + nudge_prefix_lines = nudge_span_prefix_lines(&source_file.lines, span); + } + + definitions.push(Definition { + def_id: definitions.next_index(), + name: definition.name.clone(), + def_path: definition.path.clone(), + def_path_html: render_def_path_html(&definition.path), + span: definition.span.clone(), + nudge_prefix_lines, + }); + } + + let mut targets = IdxVec::with_capacity(mutations_metadata.targets.len()); + let mut mutations_per_target = IdxVec::with_capacity(mutations_metadata.targets.len()); + for target in &mutations_metadata.targets { + targets.push(Target { + def_id: target.def_id, + reachable_from: target.reachable_from.iter() + .map(|(def_path, _association)| (def_path.clone(), ())) + .collect(), + }); + + let mutations = mutations_metadata.mutations.iter() + .filter(|mutation| mutation.target_id == target.target_id) + .map(|mutation| mutation.mutation_id) + .collect::>(); + // NOTE: Data is populated in target ID order. + mutations_per_target.push(mutations); + } + + let mut tests = BTreeMap::new(); + let mut targets_reached_by_tests = HashMap::with_capacity(call_graph_metadata.call_graph.entry_points.len()); + for entry_point in &call_graph_metadata.call_graph.entry_points { + let def = &call_graph_metadata.definitions[entry_point.def_id]; + + tests.insert(def.path.clone(), Test { + def_id: entry_point.def_id, + ignore: false, + span: def.span.clone().expect("encountered test without span"), + }); + + let reachable_targets = mutations_metadata.targets.iter() + .filter(|target| target.reachable_from.contains_key(&def.path)) + .map(|target| target.target_id) + .collect::>(); + let mut reachable_targets = reachable_targets.into_iter().collect::>(); + reachable_targets.sort_unstable_by_key(|target_id| target_id.0); + targets_reached_by_tests.insert(def.path.clone(), reachable_targets); + } + + Self { + unique_source_file_paths, + local_source_file_paths, + mutations_per_file, + mutations_on_overlapping_lines, + definitions, + tests, + call_graph: call_graph_metadata.call_graph.clone(), + targets, + targets_reached_by_tests, + mutations_per_target, + mutations: IdxVec::new(), + evaluation_info: None, + } + } + + pub(crate) fn register_loaded_mutations(&mut self, mutations: IdxVec) { + self.mutations = mutations; + } + + pub(crate) fn update_evaluation_info(&mut self, evaluation_info: Option) { + self.evaluation_info = evaluation_info; + } + + #[inline] + pub fn unique_source_file_paths(&self) -> &[PathBuf] { + &self.unique_source_file_paths + } + + #[inline] + pub fn local_source_file_paths(&self) -> &[PathBuf] { + &self.local_source_file_paths + } + + #[inline] + pub fn file_mutations(&self, path: &Path) -> &[MutationId] { + self.mutations_per_file.get(path).map(|vec| -> &[_] { vec }).unwrap_or_default() + } + + #[inline] + pub fn definition(&self, def_id: DefId) -> Option<&Definition> { + self.definitions.get(def_id) + } + + #[inline] + pub fn lookup_definition_by_path(&self, def_path: &str) -> Option<&Definition> { + self.definitions.iter().find(|definition| definition.def_path == def_path) + } + + #[inline] + pub fn tests(&self) -> impl Iterator { + self.tests.values() + } + + #[inline] + pub fn tests_count(&self) -> usize { + self.tests.len() + } + + #[inline] + pub fn test(&self, def_path: &str) -> Option<&Test> { + self.tests.get(def_path) + } + + #[inline] + pub fn call_graph(&self) -> &CallGraph { + &self.call_graph + } + + #[inline] + pub fn targets_reached_by_test(&self, def_path: &str) -> &[TargetId] { + self.targets_reached_by_tests.get(def_path).map(|vec| -> &[_] { vec }).unwrap_or_default() + } + + #[inline] + pub fn target(&self, target_id: TargetId) -> Option<&Target> { + self.targets.get(target_id) + } + + #[inline] + pub fn target_mutations(&self, target_id: TargetId) -> &[MutationId] { + self.mutations_per_target.get(target_id).map(|vec| -> &[_] { vec }).unwrap_or_default() + } + + #[inline] + pub fn mutations_count(&self) -> usize { + self.mutations.len() + } + + #[inline] + pub fn mutation(&self, mutation_id: MutationId) -> Option<&Mutation> { + self.mutations.get(mutation_id) + } + + #[inline] + pub fn overlapping_groups_of_mutations_in_file(&self, path: &Path) -> &[OverlappingGroupOfMutations] { + self.mutations_on_overlapping_lines.get(path).map(|vec| -> &[_] { vec }).unwrap_or_default() + } + + #[inline] + pub fn evaluation_info(&self) -> Option<&EvaluationInfo> { + self.evaluation_info.as_ref() + } +} diff --git a/mutest-inspector/src/evaluation.rs b/mutest-inspector/src/evaluation.rs new file mode 100644 index 00000000..c1bc297e --- /dev/null +++ b/mutest-inspector/src/evaluation.rs @@ -0,0 +1,56 @@ +use std::collections::HashMap; + +use mutest_json::data_structures::IdxVec; +use mutest_json::mutations::MutationId; + +#[derive(Copy, Clone, Debug)] +pub enum MutationDetection { + Undetected, + Detected, + TimedOut, + Crashed +} + +impl MutationDetection { + pub fn badge_html(&self) -> &'static str { + match self { + MutationDetection::Undetected => "undetected", + MutationDetection::Detected => "detected", + MutationDetection::TimedOut => "timed out", + MutationDetection::Crashed => "crashed", + } + } +} + +#[derive(Copy, Clone, Debug)] +pub enum TestMutationResult { + NotRan, + Ran(MutationDetection), +} + +impl TestMutationResult { + pub fn badge_html(&self) -> &'static str { + match self { + TestMutationResult::NotRan => "not ran", + TestMutationResult::Ran(mutation_detection) => mutation_detection.badge_html(), + } + } +} + +#[derive(Debug)] +pub struct TestRuns { + pub mutation_detections: IdxVec>, +} + +#[derive(Debug)] +pub struct EvaluationInfo { + pub mutation_detections: IdxVec, + pub test_runs: HashMap, +} + +impl EvaluationInfo { + pub fn test_mutation_result(&self, mutation_id: MutationId, test_def_path: &str) -> Option { + let test_runs = self.test_runs.get(test_def_path)?; + test_runs.mutation_detections[mutation_id] + } +} diff --git a/mutest-inspector/src/html/base.rs b/mutest-inspector/src/html/base.rs new file mode 100644 index 00000000..72f76592 --- /dev/null +++ b/mutest-inspector/src/html/base.rs @@ -0,0 +1,61 @@ +use std::fmt::{self, Write}; + +use crate::ctxt::{TargetCtxt, TargetSpec, WorkspaceCtxt, workspace_target_display_str}; + +#[derive(Eq, PartialEq)] +pub enum Tab { + Sources, + Mutations, + Tests, +} + +pub(crate) fn base_html(body: &mut String, title: &str) -> Result<(), fmt::Error> { + write!(body, "")?; + write!(body, "")?; + write!(body, "")?; + write!(body, "")?; + write!(body, "{}", title)?; + write!(body, "")?; + write!(body, "")?; + Ok(()) +} + +pub(crate) fn topbar_html(body: &mut String, wcx: &WorkspaceCtxt, package: &str, target: &TargetSpec, tcx: &TargetCtxt, active_tab: Option) -> Result<(), fmt::Error> { + let target_path = format!("{}/{}", package, target.path_str()); + + let mutations_count = tcx.mutations_count(); + let tests_count = tcx.tests_count(); + + write!(body, "")?; + Ok(()) +} diff --git a/mutest-inspector/src/html/mod.rs b/mutest-inspector/src/html/mod.rs new file mode 100644 index 00000000..3fc375d7 --- /dev/null +++ b/mutest-inspector/src/html/mod.rs @@ -0,0 +1,75 @@ +use std::fmt::Write; + +use mutest_json::data_structures::IdxVec; +use pulldown_cmark_escape::escape_html_body_text; + +use crate::source_file::LineNo; +use crate::syntax_highlight::MappedLine; + +pub mod base; +pub mod mutations; + +#[derive(Debug)] +pub struct SourceFileHtml { + pub display_path_html: String, + pub highlighted_lines_html: IdxVec, +} + +pub fn escape_html_body_text_with_inline_code(text: &str) -> String { + let mut s = String::new(); + + let parts = text.split('`'); + for (i, part) in parts.enumerate() { + if part.is_empty() { continue; } + let code_part = (i % 2) == 1; + + if code_part { write!(s, "").unwrap(); } + escape_html_body_text(&mut s, part).unwrap(); + if code_part { write!(s, "").unwrap(); } + } + + s +} + +pub fn source_code_line_content(line_html: &str) -> &str { + match line_html.is_empty() { + false => line_html, + // NOTE: This is used to ensure both that empty lines render correctly, and + // that copying them in browsers results in a newline. + true => "\n", + } +} + +pub fn render_def_path_html(def_path: &str) -> String { + let mut s = String::new(); + + write!(s, "").unwrap(); + + let mut char_iter = def_path.char_indices().peekable(); + + let mut segment_start_offset = 0; + while let Some((offset, c)) = char_iter.next() { + let prefix = &def_path[segment_start_offset..offset]; + + match (c, char_iter.peek()) { + (':', Some((_, ':'))) => { + segment_start_offset = offset + 2; + let _ = char_iter.next(); + write!(s, "{prefix}::").unwrap(); + } + ('<', _) => { + segment_start_offset = offset + 1; + write!(s, "{prefix}<").unwrap(); + } + ('>', _) => { + segment_start_offset = offset + 1; + write!(s, "{prefix}>").unwrap(); + } + (_, _) => {} + } + } + + write!(s, "{}", &def_path[segment_start_offset..]).unwrap(); + + s +} diff --git a/mutest-inspector/src/html/mutations.rs b/mutest-inspector/src/html/mutations.rs new file mode 100644 index 00000000..4da185a8 --- /dev/null +++ b/mutest-inspector/src/html/mutations.rs @@ -0,0 +1,313 @@ +use std::collections::HashMap; +use std::iter; +use std::path::PathBuf; + +use mutest_json::mutations::MutationId; + +use crate::ctxt::WorkspaceCtxt; +use crate::source_file::LineNo; +use crate::syntax_highlight::{MappedLine, SyntaxHighlighter}; + +#[derive(Debug)] +pub struct SubstHtml { + pub start_line: LineNo, + pub original_lines_html: Vec, + pub replacement_lines_html: Vec, +} + +impl SubstHtml { + #[inline(always)] + pub fn end_line(&self) -> LineNo { + LineNo(self.start_line.0 + self.original_lines_html.len() as u32 - (!self.original_lines_html.is_empty() as u32)) + } + + #[inline(always)] + pub fn is_insertion(&self) -> bool { + self.original_lines_html.is_empty() + } + + #[inline(always)] + pub fn after_source_line_no(&self) -> LineNo { + LineNo(self.end_line().0 + !self.is_insertion() as u32) + } +} + +pub fn render_mutation_subst_lines(wcx: &WorkspaceCtxt, syntax_highlighter: &mut SyntaxHighlighter, subst: &mutest_json::mutations::Substitution) -> Option { + let subst_span = subst.location.span(); + let subst_start_line = LineNo(subst_span.begin.0 as u32); + let subst_end_line = LineNo(subst_span.end.0 as u32); + + let Some(source_file) = wcx.loaded_source_file(&subst_span.path) else { return None; }; + let Some(source_file_html) = wcx.source_file_html(&subst_span.path) else { return None; }; + + let start_line_start_byte_offset = { + let mut char_indices_iter = source_file.lines[subst_start_line].char_indices(); + for _ in 1..subst_span.begin.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + let prefix_original_source = &source_file.lines[subst_start_line][..start_line_start_byte_offset]; + + let end_line_end_byte_offset = { + let mut char_indices_iter = source_file.lines[subst_end_line].char_indices(); + for _ in 1..subst_span.end.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + let suffix_original_source = &source_file.lines[subst_end_line][end_line_end_byte_offset..]; + + let start_line_whitespace_prefix_byte_offset = 'offset: { + let mut char_indices_iter = source_file.lines[subst_start_line].char_indices(); + while let Some((offset, c)) = char_indices_iter.next() { + if !c.is_whitespace() { break 'offset offset; } + } + 0 + }; + let original_source_start_line_whitespace_prefix = &source_file.lines[subst_start_line][..start_line_whitespace_prefix_byte_offset]; + + let (mut original_lines_html, replacement_text) = match subst.location { + mutest_json::mutations::SubstitutionLocation::Replace(_) => { + let mut replacement_text = String::new(); + replacement_text.push_str(prefix_original_source); + + let mut replacement_lines_iter = subst.substitute.replacement.lines(); + if let Some(replacement_line) = replacement_lines_iter.next() { + replacement_text.push_str(replacement_line); + } + // Indent subsequent lines with the whitespace indentation of the original source line. + for replacement_line in replacement_lines_iter { + replacement_text.push_str("\n"); + replacement_text.push_str(original_source_start_line_whitespace_prefix); + replacement_text.push_str(replacement_line); + } + + replacement_text.push_str(suffix_original_source); + + let original_lines_html = source_file_html.highlighted_lines_html[subst_start_line..=subst_end_line].to_vec(); + (original_lines_html, replacement_text) + } + mutest_json::mutations::SubstitutionLocation::InsertBefore(_) => { + let is_inline_insertion = !prefix_original_source.chars().all(|c| c.is_whitespace()); + match is_inline_insertion { + true => { + let mut replacement_text = String::new(); + replacement_text.push_str(prefix_original_source); + replacement_text.push_str(&subst.substitute.replacement); + replacement_text.push_str(" "); + replacement_text.push_str(&source_file.lines[subst_start_line][start_line_start_byte_offset..]); + + let original_lines_html = vec![source_file_html.highlighted_lines_html[subst_start_line].clone()]; + (original_lines_html, replacement_text) + } + false => { + let mut replacement_text = String::new(); + + replacement_text.push_str(prefix_original_source); + + let mut replacement_lines_iter = subst.substitute.replacement.lines(); + if let Some(replacement_line) = replacement_lines_iter.next() { + replacement_text.push_str(replacement_line); + } + // Indent subsequent lines with the whitespace indentation of the original source line. + for replacement_line in replacement_lines_iter { + replacement_text.push_str("\n"); + replacement_text.push_str(original_source_start_line_whitespace_prefix); + replacement_text.push_str(replacement_line); + } + + (vec![], replacement_text) + } + } + } + mutest_json::mutations::SubstitutionLocation::InsertAfter(_) => { + let is_inline_insertion = !suffix_original_source.chars().all(|c| c.is_whitespace()); + match is_inline_insertion { + true => { + let mut replacement_text = String::new(); + replacement_text.push_str(&source_file.lines[subst_end_line][..end_line_end_byte_offset]); + replacement_text.push_str(" "); + replacement_text.push_str(&subst.substitute.replacement); + replacement_text.push_str(suffix_original_source); + + let original_lines_html = vec![source_file_html.highlighted_lines_html[subst_end_line].clone()]; + (original_lines_html, replacement_text) + } + false => { + let mut replacement_text = String::new(); + + let mut replacement_lines_iter = subst.substitute.replacement.lines(); + if let Some(replacement_line) = replacement_lines_iter.next() { + replacement_text.push_str(replacement_line); + } + // Indent subsequent lines with the whitespace indentation of the original source line. + for replacement_line in replacement_lines_iter { + replacement_text.push_str("\n"); + replacement_text.push_str(original_source_start_line_whitespace_prefix); + replacement_text.push_str(replacement_line); + } + + replacement_text.push_str(suffix_original_source); + + (vec![], replacement_text) + } + } + } + }; + + match &mut original_lines_html[..] { + [] => {} + [original_line_html] => { + original_line_html.insert_unbreakable_segment(start_line_start_byte_offset, end_line_end_byte_offset, "", ""); + } + [original_start_line_html, original_between_lines_html @ .., original_end_line_html] => { + original_start_line_html.insert_unbreakable_segment(start_line_start_byte_offset, source_file.lines[subst_start_line].len(), "", ""); + for (original_line_html, source_file_line) in iter::zip(original_between_lines_html, &source_file.lines[LineNo(subst_start_line.0 + 1)..]) { + original_line_html.insert_unbreakable_segment(0, source_file_line.len(), "", ""); + } + original_end_line_html.insert_unbreakable_segment(0, end_line_end_byte_offset, "", ""); + } + } + + let mut highlighted_lines_html = syntax_highlighter.highlight_lines_html(&replacement_text) + .expect(&format!("cannot syntax highlight mutation substitution `{}`", subst.substitute.replacement)); + + match &mut highlighted_lines_html[..] { + [] => {} + [highlighted_line_html] => { + let end_line_end_replacement_byte_offset = replacement_text.len() - (source_file.lines[subst_end_line].len() - end_line_end_byte_offset); + highlighted_line_html.insert_unbreakable_segment(start_line_start_byte_offset, end_line_end_replacement_byte_offset, "", ""); + } + [highlighted_start_line_html, highlighted_between_lines_html @ .., highlighted_end_line_html] => { + let mut replacement_text_lines = replacement_text.lines(); + + let Some(replacement_text_start_line) = replacement_text_lines.next() else { unreachable!() }; + highlighted_start_line_html.insert_unbreakable_segment(start_line_start_byte_offset, replacement_text_start_line.len(), "", ""); + + for highlighted_line_html in highlighted_between_lines_html { + let Some(replacement_text_line) = replacement_text_lines.next() else { unreachable!() }; + highlighted_line_html.insert_unbreakable_segment(0, replacement_text_line.len(), "", ""); + } + + let Some(replacement_text_end_line) = replacement_text_lines.next() else { unreachable!() }; + let end_line_end_replacement_byte_offset = replacement_text_end_line.len() - (source_file.lines[subst_end_line].len() - end_line_end_byte_offset); + highlighted_end_line_html.insert_unbreakable_segment(0, end_line_end_replacement_byte_offset, "", ""); + } + } + + Some(SubstHtml { + start_line: subst_start_line, + original_lines_html, + replacement_lines_html: highlighted_lines_html, + }) +} +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub enum OverlappingGroupKind { + InsertBefore, + Replace, + InsertAfter, +} + +#[derive(Debug)] +pub struct OverlappingGroupOfMutations { + pub kind: OverlappingGroupKind, + pub start_line: LineNo, + pub end_line: LineNo, + pub mutations: Vec, +} + +impl OverlappingGroupOfMutations { + pub fn contains(&self, line: LineNo) -> bool { + self.start_line <= line && line <= self.end_line + } + + pub fn overlaps(&self, start_line: LineNo, end_line: LineNo) -> bool { + self.start_line <= end_line && start_line <= self.end_line + } +} + +#[test] +fn test_overlapping_group_overlaps() { + let group = OverlappingGroupOfMutations { + kind: OverlappingGroupKind::Replace, + start_line: LineNo(3), + end_line: LineNo(5), + mutations: vec![], + }; + + // Identical + assert_eq!(true, group.overlaps(LineNo(3), LineNo(5))); + + // Inside + assert_eq!(true, group.overlaps(LineNo(4), LineNo(4))); + // Inside, touching start. + assert_eq!(true, group.overlaps(LineNo(3), LineNo(4))); + // Inside, touching end. + assert_eq!(true, group.overlaps(LineNo(4), LineNo(5))); + + // Bottom overlap. + assert_eq!(true, group.overlaps(LineNo(2), LineNo(3))); + // Top overlap. + assert_eq!(true, group.overlaps(LineNo(5), LineNo(6))); + + // Non-overlapping neighbor before. + assert_eq!(false, group.overlaps(LineNo(1), LineNo(2))); + // Non-overlapping neighbor after. + assert_eq!(false, group.overlaps(LineNo(6), LineNo(7))); + + // Outside, before. + assert_eq!(false, group.overlaps(LineNo(1), LineNo(1))); + // Outside, after. + assert_eq!(false, group.overlaps(LineNo(8), LineNo(9))); +} + +pub fn update_overlapping_groups(groups_in_files: &mut HashMap>, mutation: &mutest_json::mutations::Mutation) { + 'subst: for subst in &mutation.substs { + let subst_span = subst.location.span(); + let (subst_group_kind, subst_start_line, subst_end_line) = match subst.location { + mutest_json::mutations::SubstitutionLocation::InsertBefore(_) => { + let line_no = LineNo(subst_span.begin.0 as u32); + (OverlappingGroupKind::InsertBefore, line_no, line_no) + } + mutest_json::mutations::SubstitutionLocation::InsertAfter(_) => { + let line_no = LineNo(subst_span.end.0 as u32); + (OverlappingGroupKind::InsertAfter, line_no, line_no) + } + mutest_json::mutations::SubstitutionLocation::Replace(_) => { + let subst_start_line = LineNo(subst_span.begin.0 as u32); + let subst_end_line = LineNo(subst_span.end.0 as u32); + (OverlappingGroupKind::Replace, subst_start_line, subst_end_line) + } + }; + + let groups_in_file = groups_in_files.entry(subst_span.path.to_owned()).or_default(); + + let mut groups_in_file_iter = groups_in_file.iter_mut().enumerate(); + while let Some((i, g)) = groups_in_file_iter.next() { + // Found matching overlapping group; update group, and see if it now absorbs other groups. + if g.kind == subst_group_kind && g.overlaps(subst_start_line, subst_end_line) { + g.start_line = Ord::min(subst_start_line, g.start_line); + g.end_line = Ord::max(subst_end_line, g.end_line); + g.mutations.push(mutation.mutation_id); + // FIXME: Absorb other, newly overlapping groups. + continue 'subst; + } + + // Found (the first) matching group that would come after this mutation substitution; insert a new group before. + if g.start_line >= subst_start_line && g.kind > subst_group_kind { + groups_in_file.insert(i, OverlappingGroupOfMutations { + kind: subst_group_kind, + start_line: subst_start_line, + end_line: subst_end_line, + mutations: vec![mutation.mutation_id], + }); + continue 'subst; + } + } + + // Did not find previous overlapping or proceeding group; create new one at the end. + groups_in_file.push(OverlappingGroupOfMutations { + kind: subst_group_kind, + start_line: subst_start_line, + end_line: subst_end_line, + mutations: vec![mutation.mutation_id], + }); + } +} diff --git a/mutest-inspector/src/lib.rs b/mutest-inspector/src/lib.rs new file mode 100644 index 00000000..3b4920d1 --- /dev/null +++ b/mutest-inspector/src/lib.rs @@ -0,0 +1,310 @@ +#![feature(array_windows)] +#![feature(if_let_guard)] + +pub mod call_graph; +pub mod config; +pub mod ctxt; +pub mod evaluation; +pub mod html; +pub mod metadata; +pub mod server; +pub mod source_file; +pub mod syntax_highlight; + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::process; +use std::time::Instant; + +use mutest_json::data_structures::IdxVec; +use mutest_json::mutations::MutationId; + +use crate::config::Config; +use crate::ctxt::{TargetCtxt, TargetSpec, WorkspaceCtxt}; +use crate::evaluation::{EvaluationInfo, MutationDetection, TestMutationResult, TestRuns}; +use crate::html::{SourceFileHtml, escape_html_body_text_with_inline_code}; +use crate::html::mutations::render_mutation_subst_lines; +use crate::metadata::Mutation; +use crate::source_file::SourceFile; +use crate::syntax_highlight::SyntaxHighlighter; + +struct TargetMetadataDir { + package: String, + target: TargetSpec, + json_dir_path: PathBuf, +} + +fn register_target_metadata_dir_if_exists(targets: &mut Vec, package: &str, target: TargetSpec, json_dir_path: PathBuf) { + let mutations_json_file_path = json_dir_path.join("mutations.json"); + if !fs::exists(&mutations_json_file_path).expect(&format!("cannot read mutations metadata file `{}`", mutations_json_file_path.display())) { return; } + + targets.push(TargetMetadataDir { + package: package.to_owned(), + target, + json_dir_path, + }); +} + +fn register_nested_target_metadata_dirs(targets: &mut Vec, package: &str, into_target_spec: fn(String) -> TargetSpec, json_nested_targets_dir_path: &Path) { + let read_json_nested_targets_dir = match fs::read_dir(&json_nested_targets_dir_path) { + Ok(v) => v, + Err(err) if let io::ErrorKind::NotFound = err.kind() => { return; } + Err(err) => panic!("cannot read json package nested targets directory `{}`: {err}", json_nested_targets_dir_path.display()), + }; + + for entry in read_json_nested_targets_dir { + let entry = entry.expect(&format!("cannot read entry in json package nested targets directory `{}`", json_nested_targets_dir_path.display())); + let json_nested_target_dir_path = entry.path(); + let target_name = json_nested_target_dir_path.file_name().expect("invalid json nested target directory path") + .to_str().expect("invalid target directory name") + .to_owned(); + + register_target_metadata_dir_if_exists(targets, package, into_target_spec(target_name), json_nested_target_dir_path); + } +} + +fn discover_target_metadata_dirs_in_json_root_dir(json_root_dir_path: &Path) -> Vec { + let mut target_metadata_dirs = Vec::new(); + + for entry in fs::read_dir(json_root_dir_path).expect(&format!("cannot read json root directory `{}`", json_root_dir_path.display())) { + let entry = entry.expect(&format!("cannot read entry in json root directory `{}`", json_root_dir_path.display())); + let json_package_dir_path = entry.path(); + if !json_package_dir_path.is_dir() { continue; } + + let package_name = json_package_dir_path.file_name().expect("invalid json package directory path") + .to_str().expect("invalid package directory name") + .to_owned(); + + register_target_metadata_dir_if_exists(&mut target_metadata_dirs, &package_name, TargetSpec::Lib, json_package_dir_path.join("lib")); + register_target_metadata_dir_if_exists(&mut target_metadata_dirs, &package_name, TargetSpec::MainBin, json_package_dir_path.join("bin")); + register_nested_target_metadata_dirs(&mut target_metadata_dirs, &package_name, TargetSpec::Bin, &json_package_dir_path.join("bins")); + register_nested_target_metadata_dirs(&mut target_metadata_dirs, &package_name, TargetSpec::Example, &json_package_dir_path.join("examples")); + register_nested_target_metadata_dirs(&mut target_metadata_dirs, &package_name, TargetSpec::Test, &json_package_dir_path.join("tests")); + } + + target_metadata_dirs +} + +struct TargetMetadata { + package: String, + target: TargetSpec, + call_graph_metadata: mutest_json::call_graph::CallGraphInfo, + mutations_metadata: mutest_json::mutations::MutationsInfo, + evaluation_metadata: Option, + unique_source_file_paths: HashSet, +} + +fn load_target_metadata(target_metadata_dir: TargetMetadataDir) -> TargetMetadata { + let call_graph_metadata_file_path = target_metadata_dir.json_dir_path.join("call_graph.json"); + let call_graph_metadata_file_str = fs::read_to_string(&call_graph_metadata_file_path).expect(&format!("cannot read call graph metadata file `{}`", call_graph_metadata_file_path.display())); + let call_graph_metadata = serde_json::from_str::(&call_graph_metadata_file_str).expect("cannot parse call graph metadata file"); + + let mutations_metadata_file_path = target_metadata_dir.json_dir_path.join("mutations.json"); + let mutations_metadata_file_str = fs::read_to_string(&mutations_metadata_file_path).expect(&format!("cannot read mutations metadata file `{}`", mutations_metadata_file_path.display())); + let mutations_metadata = serde_json::from_str::(&mutations_metadata_file_str).expect("cannot parse mutations metadata file"); + + let evaluation_metadata_file_path = target_metadata_dir.json_dir_path.join("evaluation.json"); + let evaluation_metadata_file_str = match fs::read_to_string(&evaluation_metadata_file_path) { + Ok(v) => Some(v), + Err(err) if let io::ErrorKind::NotFound = err.kind() => None, + Err(err) => panic!("cannot read evaluation metadata file `{}`: {err}", evaluation_metadata_file_path.display()), + }; + let evaluation_metadata = evaluation_metadata_file_str.as_ref().map(|evaluation_metadata_file_str| { + serde_json::from_str::(evaluation_metadata_file_str).expect("cannot parse evaluation metadata file") + }); + + // NOTE: First collect path references into a unique set to avoid having to allocate every path for comparisons. + let unique_source_file_path_refs = call_graph_metadata.definitions.iter() + .filter_map(|def| def.span.as_ref().map(|span| -> &Path { &span.path })) + .collect::>(); + let unique_source_file_paths = unique_source_file_path_refs.into_iter().map(|p| p.to_owned()).collect::>(); + + TargetMetadata { + package: target_metadata_dir.package, + target: target_metadata_dir.target, + call_graph_metadata, + mutations_metadata, + evaluation_metadata, + unique_source_file_paths, + } +} + +pub async fn run(config: Config) { + let t_start = Instant::now(); + + let target_metadata_dirs = discover_target_metadata_dirs_in_json_root_dir(&config.json_root_dir_path); + + if target_metadata_dirs.is_empty() { + println!("could not discover any targets in `{}`", config.json_root_dir_path.display()); + process::exit(1); + } + + let package_count = target_metadata_dirs.iter().map(|target_metadata_dir| &target_metadata_dir.package).collect::>().len(); + println!("found {} packages with {} targets", package_count, target_metadata_dirs.len()); + + let mut loaded_target_metadatas = Vec::new(); + let mut source_file_paths = HashSet::new(); + + for target_metadata_dir in target_metadata_dirs { + let target_metadata = load_target_metadata(target_metadata_dir); + source_file_paths.extend(target_metadata.unique_source_file_paths.clone()); + loaded_target_metadatas.push(target_metadata); + } + + let mut wcx = WorkspaceCtxt::empty(); + + println!("processing {} source files", source_file_paths.len()); + + let mut syntax_highlighter = SyntaxHighlighter::init(); + + // NOTE: Ensure that we attempt to load source files in a deterministic order. + let mut source_file_paths = source_file_paths.into_iter().map(|p| p.to_owned()).collect::>(); + source_file_paths.sort(); + for source_file_path in &source_file_paths { + let Ok(source) = fs::read_to_string(source_file_path) else { continue; }; + + println!("processing {}", source_file_path.display()); + + let mut source_file_lines = IdxVec::new(); + for line in source.lines() { + source_file_lines.push(line.to_owned()); + } + // NOTE: The `str::lines()` iterator ignores trailing newlines, which we want to keep. + if source.ends_with("\n") || source.ends_with("\r\n") { + source_file_lines.push(String::new()); + } + + let source_file = SourceFile { + lines: source_file_lines, + }; + + let highlighted_lines_html = syntax_highlighter.highlight_lines_html(&source) + .expect(&format!("cannot syntax highlight source file `{}`", source_file_path.display())); + + // HACK: Remove this, figure out a better API on Idx* to perform such an operation. + let highlighted_lines_html = { + let mut idx_vec = IdxVec::with_capacity(highlighted_lines_html.len()); + for highlighted_line_html in highlighted_lines_html { + idx_vec.push(highlighted_line_html); + } + idx_vec + }; + + let source_file_html = SourceFileHtml { + display_path_html: format!("{}", source_file_path.display()), + highlighted_lines_html, + }; + + wcx.register_loaded_source_file(source_file_path, source_file, source_file_html); + } + + for target_metadata in loaded_target_metadatas { + let mut tcx = TargetCtxt::new(&wcx, target_metadata.unique_source_file_paths, &target_metadata.call_graph_metadata, &target_metadata.mutations_metadata); + + let mut mutations_per_file = HashMap::>::new(); + for mutation in &target_metadata.mutations_metadata.mutations { + for subst in &mutation.substs { + let file_entry = mutations_per_file.entry(subst.location.span().path.to_owned()).or_default(); + if file_entry.contains(&mutation.mutation_id) { continue; } + // NOTE: The insertions in this loop guarantee that each entry's mutation ID list is ordered. + file_entry.push(mutation.mutation_id); + } + } + + println!("processing {} mutations", target_metadata.mutations_metadata.mutations.len()); + + let mut mutation_htmls = IdxVec::::with_capacity(target_metadata.mutations_metadata.mutations.len()); + for mutation in &target_metadata.mutations_metadata.mutations { + let mut mutation_html = Mutation { + target_id: mutation.target_id, + origin_span: mutation.origin_span.clone(), + mutation_op: mutation.mutation_op.clone(), + display_name: mutation.display_name.clone(), + display_name_html: escape_html_body_text_with_inline_code(&mutation.display_name), + subst_htmls: Vec::with_capacity(mutation.substs.len()), + safety: mutation.safety, + }; + + for subst in &mutation.substs { + let Some(subst_html) = render_mutation_subst_lines(&wcx, &mut syntax_highlighter, subst) else { continue; }; + mutation_html.subst_htmls.push(subst_html); + } + + // NOTE: Mutation HTML data is populated in mutation ID order. + mutation_htmls.push(mutation_html); + } + tcx.register_loaded_mutations(mutation_htmls); + + if let Some(evaluation_metadata) = &target_metadata.evaluation_metadata { + let mut evaluation_info = EvaluationInfo { + mutation_detections: IdxVec::with_capacity(target_metadata.mutations_metadata.mutations.len()), + test_runs: HashMap::with_capacity(tcx.tests_count()), + }; + + let mutation_run = match &evaluation_metadata.mutation_runs[..] { + [] => panic!("evaluation metadata contains no mutation runs"), + [mutation_run] => mutation_run, + // TODO: Warn user that we are ignoring all subsequent mutation runs. + [first_mutation_run, ..] => first_mutation_run, + }; + + for mutation_detection in &mutation_run.mutation_detection_matrix.overall_detections.0 { + let mutation_detection = match mutation_detection { + mutest_json::evaluation::MutationDetection::NotRun => panic!("encountered unexpected mutation detection value"), + mutest_json::evaluation::MutationDetection::Undetected => MutationDetection::Undetected, + mutest_json::evaluation::MutationDetection::Detected => MutationDetection::Detected, + mutest_json::evaluation::MutationDetection::TimedOut => MutationDetection::TimedOut, + mutest_json::evaluation::MutationDetection::Crashed => MutationDetection::Crashed, + }; + + // NOTE: Mutation detection data is populated in mutation ID order. + evaluation_info.mutation_detections.push(mutation_detection); + } + + for (runtime_test_id, mutation_detections) in mutation_run.mutation_detection_matrix.test_detections.iter_enumerated() { + let test_path = &evaluation_metadata.tests[runtime_test_id].name; + + let reachable_targets = target_metadata.mutations_metadata.targets.iter() + .filter(|target| target.reachable_from.contains_key(test_path)) + .map(|target| target.target_id) + .collect::>(); + + let mut test_runs = TestRuns { + mutation_detections: IdxVec::with_capacity(target_metadata.mutations_metadata.mutations.len()), + }; + + for (mutation_id, mutation_detection) in mutation_detections.0.iter_enumerated() { + let mutation = &target_metadata.mutations_metadata.mutations[mutation_id]; + + let test_mutation_result = match mutation_detection { + mutest_json::evaluation::MutationDetection::NotRun if !reachable_targets.contains(&mutation.target_id) => None, + mutest_json::evaluation::MutationDetection::NotRun => Some(TestMutationResult::NotRan), + mutest_json::evaluation::MutationDetection::Undetected => Some(TestMutationResult::Ran(MutationDetection::Undetected)), + mutest_json::evaluation::MutationDetection::Detected => Some(TestMutationResult::Ran(MutationDetection::Detected)), + mutest_json::evaluation::MutationDetection::TimedOut => Some(TestMutationResult::Ran(MutationDetection::TimedOut)), + mutest_json::evaluation::MutationDetection::Crashed => Some(TestMutationResult::Ran(MutationDetection::Crashed)), + }; + + // NOTE: Mutation detection data is populated in mutation ID order. + test_runs.mutation_detections.push(test_mutation_result); + } + + evaluation_info.test_runs.insert(test_path.clone(), test_runs); + } + + tcx.update_evaluation_info(Some(evaluation_info)); + } + + wcx.register_target(target_metadata.package, target_metadata.target, tcx); + } + + println!("finished in {:.2?}", t_start.elapsed()); + + let state = server::ServerState { + wcx, + }; + + server::run(&config.opts, state).await; +} diff --git a/mutest-inspector/src/main.rs b/mutest-inspector/src/main.rs new file mode 100644 index 00000000..4ac906b0 --- /dev/null +++ b/mutest-inspector/src/main.rs @@ -0,0 +1,65 @@ +use std::path::PathBuf; + +use mutest_inspector::config::{self, Config}; +use mutest_inspector::ctxt::TargetSpec; + +#[tokio::main] +async fn main() { + let matches = clap::command!() + .propagate_version(true) + .disable_help_flag(true) + .disable_version_flag(true) + .styles({ + use clap::builder::styling::*; + Styles::styled() + .header(Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))).bold()) + .usage(Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightGreen))).bold()) + .literal(Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue))).bold()) + .placeholder(Style::new().fg_color(Some(Color::Ansi(AnsiColor::BrightBlue)))) + }) + .arg(clap::arg!(--"json-root-dir" [JSON_ROOT_DIR] "Path to JSON metadata files from a mutest-rs analysis.").value_parser(clap::value_parser!(PathBuf)).default_value("target/mutest/json")) + .arg(clap::arg!(--port [PORT] "Port number to serve interface on.").value_parser(clap::value_parser!(u16)).default_value("3000")) + .arg(clap::arg!(--open [TARGET_SPEC] "Open the interface in a browser. Optionally, a specific package to open may be specified by name, or a target by target spec syntax (e.g., `package/lib`, or `package/test:integration_test`).").num_args(0..=1).require_equals(true)) + .arg(clap::arg!(-h --help "Print help information; this message or the help of the given subcommand.").action(clap::ArgAction::Help).global(true)) + .arg(clap::arg!(-V --version "Print version information.").action(clap::ArgAction::Version).global(true)) + .get_matches(); + + let Some(json_root_dir_path) = matches.get_one::("json-root-dir").cloned() else { unreachable!() }; + + let port = *matches.get_one::("port").unwrap(); + + let open = match matches.value_source("open") { + Some(clap::parser::ValueSource::CommandLine) => { + match matches.get_one::("open") { + Some(target_str) => { + match target_str.split_once("/") { + None => { + let package = target_str.to_owned(); + Some(config::OpenTarget::Specific(package, None)) + } + Some((package, target_spec_str)) => 'specific_target_arm: { + let package = package.to_owned(); + let Ok(target) = TargetSpec::from_path_str(target_spec_str) else { + color_print::ceprintln!("warning: invalid target selector `{}` specified", target_spec_str); + break 'specific_target_arm Some(config::OpenTarget::Specific(package, None)); + }; + Some(config::OpenTarget::Specific(package, Some(target))) + } + } + } + None => Some(config::OpenTarget::Workspace), + } + } + _ => None, + }; + + let config = Config { + json_root_dir_path, + opts: config::Options { + port, + open, + }, + }; + + mutest_inspector::run(config).await; +} diff --git a/mutest-inspector/src/metadata.rs b/mutest-inspector/src/metadata.rs new file mode 100644 index 00000000..24b61748 --- /dev/null +++ b/mutest-inspector/src/metadata.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use mutest_json::{DefId, Span}; +use mutest_json::mutations::{MutationSafety, TargetId}; + +use crate::html::mutations::SubstHtml; +use crate::source_file::LineNo; + +pub struct Definition { + pub def_id: DefId, + pub name: Option, + pub def_path: String, + pub def_path_html: String, + pub span: Option, + pub nudge_prefix_lines: usize, +} + +impl Definition { + pub fn display_start_line(&self) -> Option { + self.span.as_ref().map(|span| LineNo((span.begin.0 - self.nudge_prefix_lines) as u32)) + } +} + +pub struct Test { + pub def_id: DefId, + pub span: Span, + pub ignore: bool, +} + +pub struct Target { + pub def_id: DefId, + pub reachable_from: HashMap, +} + +pub struct Mutation { + pub target_id: TargetId, + pub origin_span: Span, + pub mutation_op: String, + pub display_name: String, + pub display_name_html: String, + pub subst_htmls: Vec, + pub safety: MutationSafety, +} diff --git a/mutest-inspector/src/server.rs b/mutest-inspector/src/server.rs new file mode 100644 index 00000000..2e5b2965 --- /dev/null +++ b/mutest-inspector/src/server.rs @@ -0,0 +1,1225 @@ +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::cmp::Ordering; +use std::fmt::Write; +use std::iter; +use std::path::PathBuf; +use std::sync::Arc; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use serde::Deserialize; + +use mutest_json::{DefId, Span}; +use mutest_json::mutations::{MutationId, TargetId}; + +use crate::call_graph::{TraceSpec, entry_point_mono_call_traces, reduce_mono_call_traces}; +use crate::config::{self, Options}; +use crate::ctxt::{TargetSpec, WorkspaceCtxt}; +use crate::evaluation::{TestMutationResult, MutationDetection}; +use crate::html::source_code_line_content; +use crate::html::base::{Tab, base_html, topbar_html}; +use crate::html::mutations::{OverlappingGroupOfMutations, OverlappingGroupKind}; +use crate::source_file::{LineNo, TreeEntry, file_tree_entries, line_region_byte_offsets_within_span}; + +pub(crate) struct ServerState { + pub wcx: WorkspaceCtxt, +} + +pub(crate) async fn run(opts: &Options, state: ServerState) { + let targets = state.wcx.targets().to_vec(); + + let state = Arc::new(state); + + let router = axum::Router::new() + .route("/", axum::routing::get(handle_root_request)) + .nest("/static", memory_serve::load!().into_router()) + .nest("/{package}/{target}", axum::Router::new() + .route("/", axum::routing::get(handle_target_root_request)) + .route("/source", axum::routing::get(handle_source_request)) + .route("/source/{*path}", axum::routing::get(handle_source_request)) + .route("/mutations", axum::routing::get(handle_mutations_request)) + .route("/mutations/{mutation_id}", axum::routing::get(handle_mutation_request)) + .route("/traces/{trace_spec}", axum::routing::get(handle_trace_request)) + .route("/tests", axum::routing::get(handle_tests_request)) + .route("/tests/{test_name}", axum::routing::get(handle_test_request)) + ) + .fallback(handle_unknown) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(("0.0.0.0", opts.port)).await.expect("cannot bind port"); + println!("listening on 0.0.0.0:{}", opts.port); + + let base_uri = format!("http://127.0.0.1:{}", opts.port); + match &opts.open { + None => println!("visit {base_uri}"), + Some(open_target) => { + let mut selected_path = "".to_owned(); + 'open_selected_path: { + let config::OpenTarget::Specific(package, target) = open_target else { break 'open_selected_path; }; + let mut package_targets = targets.iter().filter(|(p, _)| p == package).map(|(_, t)| t).peekable(); + + let Some(&first_package_target) = package_targets.peek() else { + color_print::ceprintln!("warning: specified package `{}` has no mutated targets", package); + break 'open_selected_path; + }; + + let mut selected_target = first_package_target; + if let Some(target) = target { + match package_targets.any(|t| t == target) { + true => selected_target = &target, + // Warn, and keep the first package target. + false => { + color_print::ceprintln!("warning: package `{}` has no specified mutated target `{}`", package, target.path_str()); + } + } + } + + selected_path = format!("/{}/{}", package, selected_target.path_str()); + } + + let mut uri = base_uri; + uri.push_str(&selected_path); + + println!("opening {uri}"); + if let Err(error) = opener::open_browser(uri) { + println!("error opening browser: {error}"); + } + } + } + + axum::serve(listener, router).await.unwrap(); +} + +async fn handle_unknown() -> impl IntoResponse { + (StatusCode::NOT_FOUND, "404") +} + +async fn handle_root_request(State(state): State>) -> Response { + let wcx = &state.wcx; + let [(package, target), ..] = wcx.targets() else { + return (StatusCode::OK, "no targets loaded").into_response(); + }; + Redirect::to(&format!("/{}/{}", package, target.path_str())).into_response() +} + +async fn handle_target_root_request(Path((package, target)): Path<(String, TargetSpec)>) -> Response { + Redirect::to(&format!("/{}/{}/source", package, target.path_str())).into_response() +} + +// NOTE: This struct is required because an optional trailing path segment cannot be declared in tuple form. +#[derive(Deserialize)] +struct SourceRequestPathParams { + package: String, + target: TargetSpec, + path: Option, +} + +async fn handle_source_request(State(state): State>, Path(path_params): Path) -> (StatusCode, Html) { + let SourceRequestPathParams { package, target, path } = path_params; + + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + let title = match &path { + Some(path) => &path.to_string_lossy(), + None => "source", + }; + base_html(&mut body, title).unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, Some(Tab::Sources)).unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + + write!(body, "
").unwrap(); + + let Some(path) = path else { + // NOTE: Empty main content for root source page. + write!(body, "").unwrap(); + write!(body, "").unwrap(); + write!(body, "").unwrap(); + return (StatusCode::OK, Html(body)); + }; + + let Some(source_file_html) = wcx.source_file_html(&path) else { + return (StatusCode::NOT_FOUND, Html(format!("source file `{}` not found", path.display()))); + }; + + let overlapping_groups_of_mutations_in_file = tcx.overlapping_groups_of_mutations_in_file(&path); + + let mut total_mutations_count = 0; + let mut undetected_mutations_count = 0; + let mut detected_mutations_count = 0; + let mut timed_out_mutations_count = 0; + let mut crashed_mutations_count = 0; + for group in overlapping_groups_of_mutations_in_file { + for &mutation_id in &group.mutations { + match evaluation_info { + Some(evaluation_info) => { + match evaluation_info.mutation_detections[mutation_id] { + MutationDetection::Undetected => undetected_mutations_count += 1, + MutationDetection::Detected => detected_mutations_count += 1, + MutationDetection::TimedOut => timed_out_mutations_count += 1, + MutationDetection::Crashed => crashed_mutations_count += 1, + } + } + None => {} + } + + total_mutations_count += 1; + } + } + + write!(body, "
").unwrap(); + write!(body, "

{}

", path.display()).unwrap(); + match total_mutations_count { + 0 => { + write!(body, "
").unwrap(); + write!(body, "no mutations").unwrap(); + write!(body, "
").unwrap(); + } + _ => { + let coverage = (total_mutations_count - undetected_mutations_count) as f64 / total_mutations_count as f64; + + write!(body, "
").unwrap(); + write!(body, "{}mutations", total_mutations_count).unwrap(); + match evaluation_info { + None => write!(body, "unknowncoverage").unwrap(), + Some(_) => write!(body, "{:.2}%coverage", coverage * 100_f64).unwrap(), + } + write!(body, "{}undetected", undetected_mutations_count).unwrap(); + write!(body, "{}detected", detected_mutations_count).unwrap(); + write!(body, "{}timed out", timed_out_mutations_count).unwrap(); + write!(body, "{}crashed", crashed_mutations_count).unwrap(); + write!(body, "
").unwrap(); + } + } + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + let mut highlighted_lines_html_iter = source_file_html.highlighted_lines_html.iter_enumerated(); + while let Some((line_no, highlighted_line_html)) = highlighted_lines_html_iter.next() { + let mut groups_starting_on_current_line = overlapping_groups_of_mutations_in_file.iter().enumerate() + .filter(|(_, g)| g.start_line == line_no) + .map(|(idx, g)| (idx + 1, g)) + .peekable(); + + if groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::InsertBefore).count() > 1 { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + if groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::Replace).count() > 1 { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + if groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::InsertAfter).count() > 1 { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + + if let None = groups_starting_on_current_line.peek() { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no.0, line_no.0, line_content).unwrap(); + continue; + } + + let mut write_overlapping_mutation_group = |body: &mut String, local_group_id: usize, g: &OverlappingGroupOfMutations| { + let group_heading = format!("{mutations} {action} {lines}", + mutations = match g.mutations.len() { + 1 => "1 mutation".to_owned(), + n => format!("{n} mutations"), + }, + action = match g.kind { + OverlappingGroupKind::InsertBefore => "inserting before", + OverlappingGroupKind::Replace => "replacing", + OverlappingGroupKind::InsertAfter => "inserting after", + }, + lines = match g.end_line.0 - g.start_line.0 + 1 { + 1 => format!("line {}", g.start_line.0), + _ => format!("lines {} to {}", g.start_line.0, g.end_line.0), + }, + ); + + write!(body, "", group_heading).unwrap(); + // Write out original lines of code that this overlapping group of mutations replaces. + if g.kind == OverlappingGroupKind::Replace { + for (line_no, highlighted_line_html) in iter::zip(g.start_line.0..=g.end_line.0, &source_file_html.highlighted_lines_html[g.start_line..=g.end_line]) { + let line_content = match highlighted_line_html.as_str().is_empty() { + false => highlighted_line_html.as_str(), + true => "\n", + }; + write!(body, "", line_no, line_no, line_content).unwrap(); + } + + // Skip remaining source lines covered by the group. + // NOTE: The first source line has already been taken from the iterator, + // so we must only move the cursor for the remaining source lines. + for _ in g.start_line.0..g.end_line.0 { let _ = highlighted_lines_html_iter.next(); } + } + + write!(body, "").unwrap(); + + for &mutation_id in &g.mutations { + let Some(mutation) = tcx.mutation(mutation_id) else { continue; }; + + let mutation_detection_html = match evaluation_info { + Some(evaluation_info) => evaluation_info.mutation_detections[mutation_id].badge_html(), + None => "", + }; + + // Write out mutation heading. + write!(body, "", mutation_id.0, mutation_detection_html, target_path, mutation_id.0, mutation_id.0, mutation.display_name_html).unwrap(); + + // Filter to relevant substitutions that fall within this overlap group. + let substs_within_group = mutation.subst_htmls.iter().filter(|subst_html| { + g.overlaps(subst_html.start_line, subst_html.end_line()) + }); + + for subst_html in substs_within_group { + let subst_start_line = subst_html.start_line; + let subst_end_line = subst_html.end_line(); + + // Write out prefix lines that are part of the group but are not modified by this particular mutation substitution. + if g.kind == OverlappingGroupKind::Replace { + let before_subst_start_line = LineNo(subst_start_line.0 - 1); + for (line_no, highlighted_line_html) in iter::zip(g.start_line.0.., &source_file_html.highlighted_lines_html[g.start_line..=before_subst_start_line]) { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + } + // Write out the original lines that this mutation subsitution modifies. + for (line_no, highlighted_line_html) in iter::zip(subst_start_line.0.., &subst_html.original_lines_html) { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + // Write out replacement lines. + for highlighted_line_html in &subst_html.replacement_lines_html { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_content).unwrap(); + } + // Write out suffix lines that are part of the group but are not modified by this particular mutation substitution. + if g.kind == OverlappingGroupKind::Replace { + let after_subst_start_line = LineNo(subst_end_line.0 + 1); + for (line_no, highlighted_line_html) in iter::zip(after_subst_start_line.0.., &source_file_html.highlighted_lines_html[after_subst_start_line..=g.end_line]) { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + } + } + } + write!(body, "").unwrap(); + }; + + let mut insert_before = groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::InsertBefore); + if let Some((local_group_id, group)) = insert_before.next() { + if let Some(_) = insert_before.next() { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + write_overlapping_mutation_group(&mut body, local_group_id, group); + } + + let mut replace = groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::Replace); + if let Some((local_group_id, group)) = replace.next() { + if let Some(_) = replace.next() { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + write_overlapping_mutation_group(&mut body, local_group_id, group); + } else { + let line_content = match highlighted_line_html.as_str().is_empty() { + false => highlighted_line_html.as_str(), + true => "\n", + }; + write!(body, "", line_no.0, line_no.0, line_content).unwrap(); + } + + let mut insert_after = groups_starting_on_current_line.clone().filter(|(_, g)| g.kind == OverlappingGroupKind::InsertAfter); + if let Some((local_group_id, group)) = insert_after.next() { + if let Some(_) = insert_after.next() { + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("encountered invalid grouping of overlapping mutations for source file `{}`", path.display()))); + } + write_overlapping_mutation_group(&mut body, local_group_id, group); + } + } + write!(body, "
{}{}
{}
{}{}
{} mutation {}: {}
{}{}
{}-{}
+{}
{}{}
{}{}
").unwrap(); + + write!(body, "").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} + +async fn handle_mutations_request(State(state): State>, Path((package, target)): Path<(String, TargetSpec)>) -> (StatusCode, Html) { + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + base_html(&mut body, "mutations").unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, Some(Tab::Mutations)).unwrap(); + + write!(body, "
").unwrap(); + + // NOTE: No sidebar, emit placeholder element. + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + let total_mutations_count = tcx.mutations_count(); + let mut undetected_mutations_count = 0; + let mut detected_mutations_count = 0; + let mut timed_out_mutations_count = 0; + let mut crashed_mutations_count = 0; + match evaluation_info { + Some(evaluation_info) => { + for mutation_detection in &evaluation_info.mutation_detections { + match mutation_detection { + MutationDetection::Undetected => undetected_mutations_count += 1, + MutationDetection::Detected => detected_mutations_count += 1, + MutationDetection::TimedOut => timed_out_mutations_count += 1, + MutationDetection::Crashed => crashed_mutations_count += 1, + } + } + } + None => {} + } + + write!(body, "
").unwrap(); + match total_mutations_count { + 0 => { + write!(body, "
").unwrap(); + write!(body, "no mutations").unwrap(); + write!(body, "
").unwrap(); + } + _ => { + let coverage = (total_mutations_count - undetected_mutations_count) as f64 / total_mutations_count as f64; + + write!(body, "
").unwrap(); + write!(body, "{}mutations", total_mutations_count).unwrap(); + match evaluation_info { + None => write!(body, "unknowncoverage").unwrap(), + Some(_) => write!(body, "{:.2}%coverage", coverage * 100_f64).unwrap(), + } + write!(body, "{}undetected", undetected_mutations_count).unwrap(); + write!(body, "{}detected", detected_mutations_count).unwrap(); + write!(body, "{}timed out", timed_out_mutations_count).unwrap(); + write!(body, "{}crashed", crashed_mutations_count).unwrap(); + write!(body, "
").unwrap(); + } + } + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + for file_path in tcx.local_source_file_paths() { + write!(body, "", file_path.display()).unwrap(); + + for &mutation_id in tcx.file_mutations(file_path) { + let Some(mutation) = tcx.mutation(mutation_id) else { continue; }; + + let mutation_detection_html = match evaluation_info { + Some(evaluation_info) => evaluation_info.mutation_detections[mutation_id].badge_html(), + None => "", + }; + + let source_path_str = mutation.origin_span.path.to_string_lossy(); + let (lo_line, lo_col) = mutation.origin_span.begin; + let (hi_line, hi_col) = mutation.origin_span.end; + + write!(body, "", mutation_id.0).unwrap(); + write!(body, "", target_path, source_path_str, mutation_id.0, lo_line, lo_col, hi_line, hi_col).unwrap(); + write!(body, "", mutation_detection_html).unwrap(); + write!(body, "", target_path, mutation_id.0, mutation_id.0, mutation.display_name_html).unwrap(); + write!(body, "").unwrap(); + } + } + write!(body, "
{}
{}:{} {}:{}{}mutation {}: {}
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} + +async fn handle_mutation_request(State(state): State>, Path((package, target, mutation_id)): Path<(String, TargetSpec, MutationId)>) -> (StatusCode, Html) { + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let Some(mutation) = tcx.mutation(mutation_id) else { + return (StatusCode::NOT_FOUND, Html(format!("mutation `{}` not found", mutation_id.0))); + }; + let Some(mutation_target) = tcx.target(mutation.target_id) else { + return (StatusCode::NOT_FOUND, Html(format!("target of mutation `{}` not found", mutation_id.0))); + }; + let Some(def) = tcx.definition(mutation_target.def_id) else { + return (StatusCode::NOT_FOUND, Html(format!("target definition of mutation `{}` not found", mutation_id.0))); + }; + + let Some(source_file_html) = wcx.source_file_html(&mutation.origin_span.path) else { + return (StatusCode::NOT_FOUND, Html(format!("source file `{}` not found", mutation.origin_span.path.display()))); + }; + + let call_graph = tcx.call_graph(); + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + base_html(&mut body, &format!("mutation {}: {}", mutation_id.0, mutation.display_name)).unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, None).unwrap(); + + write!(body, "
").unwrap(); + + // NOTE: No sidebar, emit placeholder element. + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + let mutation_detection_html = match evaluation_info { + Some(evaluation_info) => evaluation_info.mutation_detections[mutation_id].badge_html(), + None => "", + }; + + write!(body, "
").unwrap(); + write!(body, "

{} mutation {}: {}

", mutation_detection_html, mutation_id.0, mutation.display_name_html).unwrap(); + if let Some(def_span) = &def.span { + write!(body, " in {}", target_path, def_span.path.display(), mutation_id.0, def_span.path.display()).unwrap(); + } + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "").unwrap(); + + // Set start line for source snippet. + let mut source_start_line = match def.display_start_line() { + // Use target display start line, if available. + Some(def_display_start_line) => def_display_start_line, + // Fall back to the start line of the first substitution. + None if let [first_subst_html, ..] = &mutation.subst_htmls[..] => first_subst_html.start_line, + // Mutations without substitutions are not valid. + _ => { return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("mutation `{}` is invalid: no substitutions", mutation_id.0))); } + }; + + for subst_html in &mutation.subst_htmls { + let subst_start_line = subst_html.start_line; + + // Write out prefix source lines from end of previous segment (or start of target def). + for (line_no, highlighted_line_html) in iter::zip(source_start_line.0.., &source_file_html.highlighted_lines_html[source_start_line..subst_start_line]) { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + // Write out the original lines that this mutation subsitution modifies. + for (line_no, highlighted_line_html) in iter::zip(subst_start_line.0.., &subst_html.original_lines_html) { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + // Write out replacement lines. + for highlighted_line_html in &subst_html.replacement_lines_html { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_content).unwrap(); + } + + source_start_line = subst_html.after_source_line_no(); + } + + write!(body, "
{}{}
{}-{}
+{}
").unwrap(); + write!(body, "
").unwrap(); + + let mut total_tests_count = 0; + let mut not_ran_tests_count = 0; + let mut undetected_tests_count = 0; + let mut detected_tests_count = 0; + let mut timed_out_tests_count = 0; + let mut crashed_tests_count = 0; + for (def_path, _) in &mutation_target.reachable_from { + match evaluation_info { + Some(evaluation_info) => { + let Some(test_runs) = evaluation_info.test_runs.get(def_path) else { continue; }; + + match test_runs.mutation_detections[mutation_id] { + None => continue, + Some(TestMutationResult::NotRan) => not_ran_tests_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Undetected)) => undetected_tests_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Detected)) => detected_tests_count += 1, + Some(TestMutationResult::Ran(MutationDetection::TimedOut)) => timed_out_tests_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Crashed)) => crashed_tests_count += 1, + } + } + None => not_ran_tests_count += 1, + } + + total_tests_count += 1; + } + + write!(body, "
").unwrap(); + write!(body, "

tests

").unwrap(); + match total_tests_count { + 0 => { + write!(body, "

mutation not reached by any test

").unwrap(); + } + _ => { + let ran_tests_count = total_tests_count - not_ran_tests_count; + let coverage = (ran_tests_count - undetected_tests_count) as f64 / ran_tests_count as f64; + + write!(body, "
").unwrap(); + write!(body, "
reached by
{}
", total_tests_count).unwrap(); + write!(body, "
").unwrap(); + write!(body, "
ran
{}
", ran_tests_count).unwrap(); + write!(body, "
skipped
{}
", not_ran_tests_count).unwrap(); + write!(body, "
").unwrap(); + match ran_tests_count { + 0 => write!(body, "
coverage
unknown
").unwrap(), + _ => write!(body, "
coverage
{:.2}%
", coverage * 100_f64).unwrap(), + } + write!(body, "
undetected
{}
", undetected_tests_count).unwrap(); + write!(body, "
detected
{}
", detected_tests_count).unwrap(); + write!(body, "
timed out
{}
", timed_out_tests_count).unwrap(); + write!(body, "
crashed
{}
", crashed_tests_count).unwrap(); + write!(body, "
").unwrap(); + } + } + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "").unwrap(); + let mut reaching_def_paths = mutation_target.reachable_from.keys().collect::>(); + reaching_def_paths.sort_unstable(); + for def_path in &reaching_def_paths { + let Some(test) = tcx.test(def_path) else { continue; }; + let Some(test_def) = tcx.definition(test.def_id) else { continue; }; + + let test_mutation_result = match evaluation_info { + Some(evaluation_info) => evaluation_info.test_mutation_result(mutation_id, def_path).unwrap_or(TestMutationResult::NotRan), + None => TestMutationResult::NotRan, + }; + + write!(body, "", test_mutation_result.badge_html(), target_path, def_path, test_def.def_path_html).unwrap(); + + let Some(entry_point) = call_graph.entry_points.iter().find(|entry_point| entry_point.def_id == test.def_id) else { continue; }; + + let mono_call_traces = entry_point_mono_call_traces(&call_graph, entry_point, mutation_target.def_id); + let mut def_call_traces = reduce_mono_call_traces(&call_graph, &mono_call_traces); + + def_call_traces.sort_unstable_by_key(|call_trace| call_trace.nested_calls.len()); + def_call_traces.sort_by(|call_trace_a, call_trace_b| { + for (def_id_a, def_id_b) in iter::zip(call_trace_a.nested_calls.iter().rev(), call_trace_b.nested_calls.iter().rev()) { + match Ord::cmp(&def_id_a.0, &def_id_b.0) { + Ordering::Equal => {} + ord => return ord, + } + } + + Ordering::Equal + }); + + for call_trace in &def_call_traces { + let trace_spec = TraceSpec { + entry_point_def_path: def_path, + callee_def_ids: call_trace.nested_calls.clone(), + mutation_id, + }; + + write!(body, "").unwrap(); + } + } + write!(body, "
{}{}
trace ({})", call_trace.nested_calls.len()).unwrap(); + write!(body, "").unwrap(); + + let [in_between_callees @ .., _] = &call_trace.nested_calls[..] else { + // NOTE: Not a valid case: there should always be at least one callee, the target, and we never mutate entry points. + return (StatusCode::INTERNAL_SERVER_ERROR, Html(format!("invalid call trace `{}`: no calllees", trace_spec.to_string()))); + }; + + for &def_id in in_between_callees { + match tcx.definition(def_id) { + Some(def) => write!(body, " {}", def.def_path_html).unwrap(), + None => write!(body, " unknown").unwrap(), + } + } + + // NOTE: Since the last def is the same mutation target in all cases, we simplify how it is shown. + write!(body, " mutation").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} + +async fn handle_trace_request(State(state): State>, Path((package, target, trace_spec)): Path<(String, TargetSpec, String)>) -> (StatusCode, Html) { + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let Some(trace_spec) = TraceSpec::parse(&trace_spec) else { + return (StatusCode::BAD_REQUEST, Html(format!("invalid trace spec: `{}`", trace_spec))); + }; + let Some(entry_point_def) = tcx.lookup_definition_by_path(trace_spec.entry_point_def_path) else { + return (StatusCode::NOT_FOUND, Html(format!("definition `{}` not found", trace_spec.entry_point_def_path))); + }; + let Some(target_def) = tcx.definition(trace_spec.target()) else { + return (StatusCode::NOT_FOUND, Html(format!("definition `{:?}` not found", trace_spec.target()))); + }; + + let call_graph = tcx.call_graph(); + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + let mut title = String::new(); + write!(title, "trace ").unwrap(); + match trace_spec.callee_def_ids.len() { + 1 => write!(title, "1 call").unwrap(), + count => write!(title, "{} calls", count).unwrap(), + } + write!(title, " deep from `{}` to `{}` with mutation {}", entry_point_def.def_path, target_def.def_path, trace_spec.mutation_id.0).unwrap(); + base_html(&mut body, &title).unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, None).unwrap(); + + write!(body, "
").unwrap(); + + // NOTE: No sidebar, emit placeholder element. + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "

").unwrap(); + // NOTE: This is technically the detection status of the mutation for the test along any call trace, not just this one. + if let Some(evaluation_info) = evaluation_info && let Some(test_mutation_result) = evaluation_info.test_mutation_result(trace_spec.mutation_id, &entry_point_def.def_path) { + write!(body, "{} ", test_mutation_result.badge_html()).unwrap(); + } + write!(body, "trace ").unwrap(); + match trace_spec.callee_def_ids.len() { + 1 => write!(body, "1 call").unwrap(), + count => write!(body, "{} calls", count).unwrap(), + } + write!(body, " deep from {}", target_path, entry_point_def.def_path, entry_point_def.def_path_html).unwrap(); + write!(body, " to {}", target_def.def_path_html).unwrap(); + write!(body, " with mutation {}

", target_path, trace_spec.mutation_id.0, trace_spec.mutation_id.0).unwrap(); + write!(body, "
").unwrap(); + + enum TraceSnippetKind<'a> { + Calls { + callee_def_id: DefId, + call_spans: Vec<&'a Span>, + }, + Mutation { + mutation_id: MutationId, + }, + } + + struct TraceSnippet<'a> { + def_id: DefId, + kind: TraceSnippetKind<'a>, + } + + let mut trace_snippets = Vec::with_capacity(trace_spec.callee_def_ids.len() + 1); + + let Some(entry_point) = call_graph.entry_points.iter().find(|entry_point| entry_point.def_id == entry_point_def.def_id) else { + return (StatusCode::BAD_REQUEST, Html(format!("definition `{}` not an entry point", entry_point_def.def_path))); + }; + + let mut call_spans = entry_point.calls.iter() + .filter(|&(&callee_id, _)| call_graph.callees[callee_id].def_id == trace_spec.root_callee()) + .flat_map(|(_, calls)| calls.iter().flat_map(|call| &call.span)) + .collect::>(); + call_spans.sort_by(|a, b| Ord::cmp(&a.end.0, &b.end.0)); + call_spans.dedup(); + + let Some(root_callee_def) = tcx.definition(trace_spec.root_callee()) else { unreachable!(); }; + + trace_snippets.push(TraceSnippet { + def_id: entry_point_def.def_id, + kind: TraceSnippetKind::Calls { + callee_def_id: root_callee_def.def_id, + call_spans, + }, + }); + + for &[def_id, next_def_id] in trace_spec.callee_def_ids.array_windows::<2>() { + let mut call_spans = call_graph.callees.iter() + .filter(|caller| caller.def_id == def_id) + .flat_map(|caller| caller.calls.iter().filter(|&(&callee_id, _)| call_graph.callees[callee_id].def_id == next_def_id)) + .flat_map(|(_, calls)| calls.iter().flat_map(|call| &call.span)) + .collect::>(); + call_spans.sort_by(|a, b| Ord::cmp(&a.end.0, &b.end.0)); + call_spans.dedup(); + + trace_snippets.push(TraceSnippet { + def_id, + kind: TraceSnippetKind::Calls { + callee_def_id: next_def_id, + call_spans, + }, + }); + } + + trace_snippets.push(TraceSnippet { + def_id: trace_spec.target(), + kind: TraceSnippetKind::Mutation { mutation_id: trace_spec.mutation_id }, + }); + + for trace_snippet in trace_snippets { + let Some(def) = tcx.definition(trace_snippet.def_id) else { continue; }; + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "").unwrap(); + if let Some(def_span) = &def.span { + let Some(def_display_start_line) = def.display_start_line() else { unreachable!("definition with span has no display start line") }; + write!(body, "{}: ", target_path, def_span.path.display(), def_display_start_line.0, def_span.path.display()).unwrap(); + } + write!(body, "{}", def.def_path_html).unwrap(); + match &trace_snippet.kind { + &TraceSnippetKind::Calls { callee_def_id, ref call_spans } => { + let Some(callee_def) = tcx.definition(callee_def_id) else { continue }; + + write!(body, " calls ").unwrap(); + write!(body, "{}", callee_def.def_path_html).unwrap(); + if let Some(_) = &def.span { + match call_spans.len() { + 1 => write!(body, " in 1 place").unwrap(), + count => write!(body, " in {} places", count).unwrap(), + } + } + } + &TraceSnippetKind::Mutation { mutation_id } => { + let Some(mutation) = tcx.mutation(mutation_id) else { + return (StatusCode::NOT_FOUND, Html(format!("mutation `{}` not found", mutation_id.0))); + }; + + write!(body, " with mutation {}: {}", target_path, mutation_id.0, mutation_id.0, mutation.display_name_html).unwrap(); + } + } + write!(body, "").unwrap(); + write!(body, "
").unwrap(); + + let Some(def_span) = &def.span else { + write!(body, "
source location not available
").unwrap(); + write!(body, "
").unwrap(); + continue; + }; + + let Some(source_file) = wcx.loaded_source_file(&def_span.path) else { + write!(body, "
source file `{}` not found
", def_span.path.display()).unwrap(); + write!(body, "").unwrap(); + continue; + }; + let Some(source_file_html) = wcx.source_file_html(&def_span.path) else { + return (StatusCode::NOT_FOUND, Html(format!("source file `{}` not rendered", def_span.path.display()))); + }; + + write!(body, "").unwrap(); + let Some(start_line) = def.display_start_line() else { unreachable!("definition with span has no display start line") }; + match &trace_snippet.kind { + TraceSnippetKind::Calls { callee_def_id: _, call_spans } => { + let end_line = match call_spans.last() { + Some(last_call_span) => LineNo(last_call_span.end.0 as u32), + None => match &def.span { + Some(def_span) => LineNo(def_span.end.0 as u32), + None => start_line, + }, + }; + + for (line_no, highlighted_line_html) in iter::zip(start_line.0.., &source_file_html.highlighted_lines_html[start_line..=end_line]) { + let def_line_region = line_region_byte_offsets_within_span(LineNo(line_no), &source_file.lines[LineNo(line_no)], def_span); + let call_line_regions = call_spans.iter().flat_map(|call_span| line_region_byte_offsets_within_span(LineNo(line_no), &source_file.lines[LineNo(line_no)], call_span)); + + let mut highlighted_line_html = Cow::Borrowed(highlighted_line_html); + if let Some((start_offset, end_offset)) = def_line_region { + highlighted_line_html.to_mut().insert_unbreakable_segment(start_offset, end_offset, "", ""); + } + for (start_offset, end_offset) in call_line_regions { + highlighted_line_html.to_mut().insert_unbreakable_segment(start_offset, end_offset, "", ""); + } + + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + } + &TraceSnippetKind::Mutation { mutation_id } => { + let Some(mutation) = tcx.mutation(mutation_id) else { + return (StatusCode::NOT_FOUND, Html(format!("mutation `{}` not found", mutation_id.0))); + }; + + for subst_html in &mutation.subst_htmls { + let subst_start_line = subst_html.start_line; + + let before_subst_start_line = LineNo(subst_start_line.0 - 1); + + // Write out prefix source lines from start of target def. + for (line_no, highlighted_line_html) in iter::zip(start_line.0.., &source_file_html.highlighted_lines_html[start_line..=before_subst_start_line]) { + let def_line_region = line_region_byte_offsets_within_span(LineNo(line_no), &source_file.lines[LineNo(line_no)], def_span); + let mut highlighted_line_html = Cow::Borrowed(highlighted_line_html); + if let Some((start_offset, end_offset)) = def_line_region { + highlighted_line_html.to_mut().insert_unbreakable_segment(start_offset, end_offset, "", ""); + } + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + // Write out the original lines that this mutation subsitution modifies. + for (line_no, highlighted_line_html) in iter::zip(subst_start_line.0.., &subst_html.original_lines_html) { + let def_line_region = line_region_byte_offsets_within_span(LineNo(line_no), &source_file.lines[LineNo(line_no)], def_span); + let mut highlighted_line_html = Cow::Borrowed(highlighted_line_html); + if let Some((start_offset, end_offset)) = def_line_region { + highlighted_line_html.to_mut().insert_unbreakable_segment(start_offset, end_offset, "", ""); + } + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_no, line_content).unwrap(); + } + // Write out replacement lines. + for highlighted_line_html in &subst_html.replacement_lines_html { + let line_content = source_code_line_content(highlighted_line_html.as_str()); + write!(body, "", line_content).unwrap(); + } + } + } + } + write!(body, "
{}{}
{}{}
{}-{}
+{}
").unwrap(); + + write!(body, "").unwrap(); + } + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} + +async fn handle_tests_request(State(state): State>, Path((package, target)): Path<(String, TargetSpec)>) -> (StatusCode, Html) { + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let tests = tcx.tests(); + + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + base_html(&mut body, "tests").unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, Some(Tab::Tests)).unwrap(); + + write!(body, "
").unwrap(); + + // NOTE: No sidebar, emit placeholder element. + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + write!(body, "").unwrap(); + for test in tests { + let Some(def) = tcx.definition(test.def_id) else { + return (StatusCode::NOT_FOUND, Html(format!("definition `{:?}` not found", test.def_id))); + }; + + let mut total_mutations_count = 0; + let mut not_ran_mutations_count = 0; + let mut undetected_mutations_count = 0; + let mut detected_mutations_count = 0; + let mut timed_out_mutations_count = 0; + let mut crashed_mutations_count = 0; + let test_runs = evaluation_info.and_then(|evaluation_info| evaluation_info.test_runs.get(&def.def_path)); + for &target_id in tcx.targets_reached_by_test(&def.def_path) { + for &mutation_id in tcx.target_mutations(target_id) { + match test_runs { + Some(test_runs) => { + match test_runs.mutation_detections[mutation_id] { + None => continue, + Some(TestMutationResult::NotRan) => not_ran_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Undetected)) => undetected_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Detected)) => detected_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::TimedOut)) => timed_out_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Crashed)) => crashed_mutations_count += 1, + } + } + None => not_ran_mutations_count += 1, + } + + total_mutations_count += 1; + } + } + + write!(body, "").unwrap(); + write!(body, "", target_path, def.def_path, def.def_path_html).unwrap(); + let ran_mutations_count = total_mutations_count - not_ran_mutations_count; + match (total_mutations_count, ran_mutations_count) { + (0, _) => { + write!(body, "").unwrap(); + write!(body, "").unwrap(); + } + (_, 0) => { + write!(body, "", total_mutations_count).unwrap(); + write!(body, "").unwrap(); + write!(body, "").unwrap(); + } + _ => { + let coverage = (ran_mutations_count - undetected_mutations_count) as f64 / ran_mutations_count as f64; + + write!(body, "", total_mutations_count).unwrap(); + write!(body, "", ran_mutations_count).unwrap(); + write!(body, "", coverage * 100_f64).unwrap(); + write!(body, "", undetected_mutations_count).unwrap(); + write!(body, "", detected_mutations_count).unwrap(); + write!(body, "", timed_out_mutations_count).unwrap(); + write!(body, "", crashed_mutations_count).unwrap(); + } + } + write!(body, "").unwrap(); + } + write!(body, "
testreachable mutationsrancoverageundetecteddetectedtimed outcrashed
{}0{}0{}{}{:.2}%{}{}{}{}
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} + +async fn handle_test_request(State(state): State>, Path((package, target, test_name)): Path<(String, TargetSpec, String)>) -> (StatusCode, Html) { + let wcx = &state.wcx; + + let target_path = format!("{}/{}", package, target.path_str()); + let Some(tcx) = wcx.target(&package, &target) else { + return (StatusCode::NOT_FOUND, Html(format!("target `{}` not found", target_path))); + }; + + let Some(test) = tcx.test(&test_name) else { + return (StatusCode::NOT_FOUND, Html(format!("test `{}` not found", test_name))); + }; + let Some(def) = tcx.definition(test.def_id) else { + return (StatusCode::NOT_FOUND, Html(format!("definition `{:?}` not found", test.def_id))); + }; + + let evaluation_info = tcx.evaluation_info(); + + let mut body = String::new(); + + base_html(&mut body, &format!("test `{}`", def.def_path)).unwrap(); + write!(body, "").unwrap(); + + topbar_html(&mut body, wcx, &package, &target, tcx, None).unwrap(); + + write!(body, "
").unwrap(); + + // NOTE: No sidebar, emit placeholder element. + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + write!(body, "

test {}

", def.def_path_html).unwrap(); + if let Some(def_span) = &def.span { + let Some(def_display_start_line) = def.display_start_line() else { unreachable!("definition with span has no display start line") }; + write!(body, " in {}", target_path, def_span.path.display(), def_display_start_line.0, def_span.path.display()).unwrap(); + } + write!(body, "
").unwrap(); + + let mut total_mutations_count = 0; + let mut not_ran_mutations_count = 0; + let mut undetected_mutations_count = 0; + let mut detected_mutations_count = 0; + let mut timed_out_mutations_count = 0; + let mut crashed_mutations_count = 0; + let test_runs = evaluation_info.and_then(|evaluation_info| evaluation_info.test_runs.get(&def.def_path)); + for &target_id in tcx.targets_reached_by_test(&def.def_path) { + for &mutation_id in tcx.target_mutations(target_id) { + match test_runs { + Some(test_runs) => { + match test_runs.mutation_detections[mutation_id] { + None => continue, + Some(TestMutationResult::NotRan) => not_ran_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Undetected)) => undetected_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Detected)) => detected_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::TimedOut)) => timed_out_mutations_count += 1, + Some(TestMutationResult::Ran(MutationDetection::Crashed)) => crashed_mutations_count += 1, + } + } + None => not_ran_mutations_count += 1, + } + + total_mutations_count += 1; + } + } + + write!(body, "
").unwrap(); + write!(body, "

mutations

").unwrap(); + match total_mutations_count { + 0 => { + write!(body, "

no reachable mutations

").unwrap(); + } + _ => { + let ran_mutations_count = total_mutations_count - not_ran_mutations_count; + let coverage = (ran_mutations_count - undetected_mutations_count) as f64 / ran_mutations_count as f64; + + write!(body, "
").unwrap(); + write!(body, "
reachable
{}
", total_mutations_count).unwrap(); + write!(body, "
").unwrap(); + write!(body, "
ran
{}
", ran_mutations_count).unwrap(); + write!(body, "
skipped
{}
", not_ran_mutations_count).unwrap(); + write!(body, "
").unwrap(); + match ran_mutations_count { + 0 => write!(body, "
coverage
unknown
").unwrap(), + _ => write!(body, "
coverage
{:.2}%
", coverage * 100_f64).unwrap(), + } + write!(body, "
undetected
{}
", undetected_mutations_count).unwrap(); + write!(body, "
detected
{}
", detected_mutations_count).unwrap(); + write!(body, "
timed out
{}
", timed_out_mutations_count).unwrap(); + write!(body, "
crashed
{}
", crashed_mutations_count).unwrap(); + write!(body, "
").unwrap(); + } + } + write!(body, "
").unwrap(); + + let mut reachable_targets_per_file = BTreeMap::>::new(); + for &target_id in tcx.targets_reached_by_test(&def.def_path) { + let Some(target) = tcx.target(target_id) else { continue; }; + let Some(def) = tcx.definition(target.def_id) else { continue; }; + let Some(def_span) = &def.span else { continue; }; + + // NOTE: Skip targets for which no mutations were generated for. + if tcx.target_mutations(target_id).is_empty() { continue; } + + let file_entry = reachable_targets_per_file.entry(def_span.path.to_owned()).or_default(); + if file_entry.contains(&target_id) { continue; } + file_entry.push(target_id); + } + + write!(body, "
").unwrap(); + write!(body, "").unwrap(); + for (file_path, reachable_targets) in &reachable_targets_per_file { + write!(body, "", file_path.display()).unwrap(); + + for &mutation_id in tcx.file_mutations(file_path) { + let Some(mutation) = tcx.mutation(mutation_id) else { continue; }; + + if !reachable_targets.contains(&mutation.target_id) { continue; } + + let source_path_str = mutation.origin_span.path.to_string_lossy(); + let (lo_line, lo_col) = mutation.origin_span.begin; + let (hi_line, hi_col) = mutation.origin_span.end; + + write!(body, "", mutation_id.0).unwrap(); + write!(body, "", target_path, source_path_str, mutation_id.0, lo_line, lo_col, hi_line, hi_col).unwrap(); + write!(body, "").unwrap(); + write!(body, "", target_path, mutation_id.0, mutation_id.0, mutation.display_name_html).unwrap(); + write!(body, "").unwrap(); + } + } + write!(body, "
{}
{}:{} {}:{}").unwrap(); + if let Some(evaluation_info) = evaluation_info && let Some(test_mutation_result) = evaluation_info.test_mutation_result(mutation_id, &def.def_path) { + write!(body, "{}", test_mutation_result.badge_html()).unwrap(); + } + write!(body, "mutation {}: {}
").unwrap(); + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "
").unwrap(); + + write!(body, "").unwrap(); + write!(body, "").unwrap(); + + (StatusCode::OK, Html(body)) +} diff --git a/mutest-inspector/src/source_file.rs b/mutest-inspector/src/source_file.rs new file mode 100644 index 00000000..fb3afc09 --- /dev/null +++ b/mutest-inspector/src/source_file.rs @@ -0,0 +1,249 @@ +use std::ffi::OsStr; +use std::iter; +use std::path::{self, Path, PathBuf}; + +use mutest_json::Span; +use mutest_json::data_structures::{Idx, IdxVec, IdxSlice}; + +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct LineNo(pub u32); + +impl Idx for LineNo { + fn as_index(self) -> usize { + self.0 as usize - 1 + } + + fn from_index(idx: usize) -> Self { + Self(idx as u32 + 1) + } +} + +pub struct SourceFile { + pub lines: IdxVec, +} + +pub fn nudge_span_prefix_lines(lines: &IdxSlice, span: &Span) -> usize { + let start_line_no = LineNo(span.begin.0 as u32); + + // NOTE: If span is preceeded by anything else in the same line, then we do not add prefix lines to it, + // as they might pertain to the preceeding tokens. + // FIXME: This heuristic does not work for oddly formatted code, such as `#[test]\n#[ignore] fn test()`. + if !lines[start_line_no].chars().take(span.begin.1 - 1).all(char::is_whitespace) { return 0; } + + lines[..start_line_no].iter().rev() + .take_while(|line| line.trim().starts_with("#[")) + .count() +} + +/// Returns the start and end byte offsets of the region of the specified line that falls within the span. +pub fn line_region_byte_offsets_within_span(line_no: LineNo, line: &str, span: &Span) -> Option<(usize, usize)> { + let line_no = line_no.0 as usize; + + if line_no == span.begin.0 && line_no == span.end.0 { + let start_line_start_byte_offset = { + let mut char_indices_iter = line.char_indices(); + for _ in 1..span.begin.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + let end_line_end_byte_offset = { + let mut char_indices_iter = line.char_indices(); + for _ in 1..span.end.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + + return Some((start_line_start_byte_offset, end_line_end_byte_offset)); + } + + if line_no == span.begin.0 { + let start_line_start_byte_offset = { + let mut char_indices_iter = line.char_indices(); + for _ in 1..span.begin.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + + return Some((start_line_start_byte_offset, line.len())); + } + if line_no == span.end.0 { + let end_line_end_byte_offset = { + let mut char_indices_iter = line.char_indices(); + for _ in 1..span.end.1 { char_indices_iter.next(); } + char_indices_iter.offset() + }; + + return Some((0, end_line_end_byte_offset)); + } + + if line_no > span.begin.0 && line_no < span.end.0 { + return Some((0, line.len())); + } + + None +} + +#[derive(PartialEq, Eq, Debug)] +pub enum TreeEntry<'a> { + Dir(&'a Path), + File(&'a Path), + EndDir, +} + +pub fn file_tree_entries<'a>(file_paths: &'a[PathBuf]) -> impl Iterator> { + assert!(file_paths.is_sorted(), "file paths are not sorted"); + + let mut file_path_cursor = 0; + let mut dir_cursor = Vec::<&OsStr>::new(); + + iter::from_fn(move || { + let Some(file_path) = file_paths.get(file_path_cursor) else { + let _ = dir_cursor.pop()?; + return Some(TreeEntry::EndDir); + }; + + let mut file_path_components = file_path.components(); + + for dir in &dir_cursor { + let Some(component) = file_path_components.next() else { + dir_cursor.pop(); + return Some(TreeEntry::EndDir); + }; + + if component != path::Component::Normal(dir) { + dir_cursor.pop(); + return Some(TreeEntry::EndDir); + } + } + + let Some(component) = file_path_components.next() else { unreachable!() }; + + let normal_component = match component { + path::Component::Prefix(_) => panic!("encountered path `{}` with prefix component", file_path.display()), + path::Component::RootDir => panic!("encountered path `{}` with root directory component", file_path.display()), + path::Component::CurDir => panic!("encountered path `{}` with current directory component", file_path.display()), + path::Component::ParentDir => panic!("encountered path `{}` with parent directory component", file_path.display()), + path::Component::Normal(normal_component) => normal_component, + }; + + let remaining_components_count = file_path_components.count(); + + let Some(path) = file_path.ancestors().skip(remaining_components_count).next() else { unreachable!() }; + + // NOTE: We can assume that this component is a file component if it is the last one, + // because we are explicitly dealing with file paths. + if remaining_components_count >= 1 { + dir_cursor.push(normal_component); + return Some(TreeEntry::Dir(path)); + } + + file_path_cursor += 1; + return Some(TreeEntry::File(path)); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_span(lo_line: usize, lo_col: usize, hi_line: usize, hi_col: usize) -> Span { + Span { path: PathBuf::new(), begin: (lo_line, lo_col), end: (hi_line, hi_col) } + } + + #[test] + fn test_nudge_span_prefix_lines() { + // No preceeding lines. + let mut lines = IdxVec::new(); + lines.push(" fn test() {".to_owned()); + assert_eq!(0, nudge_span_prefix_lines(&lines, &dummy_span(1, 5, 1, 12))); + + // No preceeding attributes. + let mut lines = IdxVec::new(); + lines.push(" const OTHER: () = ();".to_owned()); + lines.push("".to_owned()); + lines.push(" fn test() {".to_owned()); + assert_eq!(0, nudge_span_prefix_lines(&lines, &dummy_span(3, 5, 3, 12))); + + // Span is preceeded by other tokens on the same line. + let mut lines = IdxVec::new(); + lines.push(" #[test]".to_owned()); + lines.push(" fn test() { let _ = || ();".to_owned()); + assert_eq!(0, nudge_span_prefix_lines(&lines, &dummy_span(2, 25, 2, 27))); + + // Single preceeding attribute. + let mut lines = IdxVec::new(); + lines.push(" #[test]".to_owned()); + lines.push(" fn test() {".to_owned()); + assert_eq!(1, nudge_span_prefix_lines(&lines, &dummy_span(2, 5, 2, 12))); + + // Multiple preceeding attributes. + let mut lines = IdxVec::new(); + lines.push(" #[test]".to_owned()); + lines.push(" #[ignore]".to_owned()); + lines.push(" fn test() {".to_owned()); + assert_eq!(2, nudge_span_prefix_lines(&lines, &dummy_span(3, 5, 3, 12))); + + // Preceeding attribute applies to the parent, not the node it preceeds. + let mut lines = IdxVec::new(); + lines.push(" #![cfg(test)]".to_owned()); + lines.push(" #[test]".to_owned()); + lines.push(" fn test() {".to_owned()); + assert_eq!(1, nudge_span_prefix_lines(&lines, &dummy_span(3, 5, 3, 12))); + + // Span is preceeded by attributes, comments, and doc comments. + // NOTE: Comments and doc comments are ignored because they can sometimes span a lot of lines. + let mut lines = IdxVec::new(); + lines.push("".to_owned()); + lines.push(" /// Documentation.".to_owned()); + lines.push(" // Comment.".to_owned()); + lines.push(" #[cfg(feature = \"foo\")]".to_owned()); + lines.push(" fn foo() {".to_owned()); + assert_eq!(1, nudge_span_prefix_lines(&lines, &dummy_span(5, 5, 5, 12))); + } + + #[test] + fn test_line_region_byte_offsets_within_span() { + // Empty string. + assert_eq!(Some((0, 0)), line_region_byte_offsets_within_span(LineNo(1), "", &dummy_span(1, 1, 1, 1))); + + // Span selecting part of a line. + assert_eq!(Some((2, 5)), line_region_byte_offsets_within_span(LineNo(1), "abcdef", &dummy_span(1, 3, 1, 6))); + // Partially out of bounds span. + assert_eq!(Some((0, 3)), line_region_byte_offsets_within_span(LineNo(1), "abc", &dummy_span(1, 1, 1, 5))); + // Completely out of bounds span. + assert_eq!(Some((3, 3)), line_region_byte_offsets_within_span(LineNo(1), "abc", &dummy_span(1, 4, 1, 5))); + // Span selecting a different line. + assert_eq!(None, line_region_byte_offsets_within_span(LineNo(3), "abc", &dummy_span(1, 1, 2, 15))); + + // First line of multiline span. + assert_eq!(Some((3, 7)), line_region_byte_offsets_within_span(LineNo(1), "abc def", &dummy_span(1, 4, 3, 1))); + // Last line of multiline span. + assert_eq!(Some((0, 1)), line_region_byte_offsets_within_span(LineNo(5), "};", &dummy_span(3, 23, 5, 2))); + // In-between line of multiline span. + assert_eq!(Some((0, 8)), line_region_byte_offsets_within_span(LineNo(8), " foo,", &dummy_span(6, 19, 9, 1))); + } + + #[test] + fn test_file_tree_entries() { + let file_paths = &[ + PathBuf::from("src/lib.rs"), + PathBuf::from("src/mod/nested/mod.rs"), + PathBuf::from("src/mod/nested/other.rs"), + PathBuf::from("src/other/src.rs"), + PathBuf::from("src/root.rs"), + ]; + + let mut entries = file_tree_entries(file_paths); + assert_eq!(Some(TreeEntry::Dir(&Path::new("src"))), entries.next()); + assert_eq!(Some(TreeEntry::File(&Path::new("src/lib.rs"))), entries.next()); + assert_eq!(Some(TreeEntry::Dir(&Path::new("src/mod"))), entries.next()); + assert_eq!(Some(TreeEntry::Dir(&Path::new("src/mod/nested"))), entries.next()); + assert_eq!(Some(TreeEntry::File(&Path::new("src/mod/nested/mod.rs"))), entries.next()); + assert_eq!(Some(TreeEntry::File(&Path::new("src/mod/nested/other.rs"))), entries.next()); + assert_eq!(Some(TreeEntry::EndDir), entries.next()); + assert_eq!(Some(TreeEntry::EndDir), entries.next()); + assert_eq!(Some(TreeEntry::Dir(&Path::new("src/other"))), entries.next()); + assert_eq!(Some(TreeEntry::File(&Path::new("src/other/src.rs"))), entries.next()); + assert_eq!(Some(TreeEntry::EndDir), entries.next()); + assert_eq!(Some(TreeEntry::File(&Path::new("src/root.rs"))), entries.next()); + assert_eq!(Some(TreeEntry::EndDir), entries.next()); + assert_eq!(None, entries.next()); + } +} diff --git a/mutest-inspector/src/static/THIRD_PARTY_LICENSES.md b/mutest-inspector/src/static/THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000..624e46b4 --- /dev/null +++ b/mutest-inspector/src/static/THIRD_PARTY_LICENSES.md @@ -0,0 +1,47 @@ +## Lucide Icons + +``` +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` diff --git a/mutest-inspector/src/static/icons.svg b/mutest-inspector/src/static/icons.svg new file mode 100644 index 00000000..f0789309 --- /dev/null +++ b/mutest-inspector/src/static/icons.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/mutest-inspector/src/static/styles.css b/mutest-inspector/src/static/styles.css new file mode 100644 index 00000000..fa9a0d80 --- /dev/null +++ b/mutest-inspector/src/static/styles.css @@ -0,0 +1,739 @@ +:root { + --interface-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --monospace-font: monospace; + + --topbar-height: 32px; + --main-heading-height: 32px; + + --main-background-color: #353535; + --main-color: #ddd; + --scrollbar-track-color: #717171; + --scrollbar-thumb-color: rgba(32, 34, 37, 0.6); + --hover-background-color: #676767; + --select-background-color: #444444; + --border-color: #4e4e4e; + --code-block-background-color: #2a2a2a; + + --mutation-group-background-color: #635556; + + --status-not-ran-background-color: rgba(0, 0, 0, 0.9); + --status-undetected-background-color: rgba(255, 31, 31, 0.9); + --status-detected-background-color: rgba(0, 128, 0, 0.9); + --status-timed-out-background-color: rgba(115, 0, 128, 0.9); + --status-crashed-background-color: rgba(0, 20, 128, 0.9); + --status-undetected-color: rgba(255, 31, 31, 0.9); + --status-detected-color: rgba(0, 255, 0, 0.9); + --status-timed-out-color: rgba(230, 0, 255, 0.9); + --status-crashed-color: rgba(0, 38, 255, 0.9); + + /* NOTE: Syntax highlight colors taken from rustdoc. */ + --code-highlight-kw-color: #ab8ac1; + --code-highlight-kw-2-color: #769acb; + --code-highlight-lifetime-color: #d97f26; + --code-highlight-prelude-color: #769acb; + --code-highlight-prelude-val-color: #ee6868; + --code-highlight-number-color: #83a300; + --code-highlight-string-color: #83a300; + --code-highlight-literal-color: #ee6868; + --code-highlight-attribute-color: #ee6868; + --code-highlight-self-color: #ee6868; + --code-highlight-macro-color: #3e999f; + --code-highlight-question-mark-color: #ff9011; + --code-highlight-comment-color: #8d8d8b; + --code-highlight-doc-comment-color: #8ca375; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + background-color: var(--main-background-color); + color: var(--main-color); + + font-family: var(--interface-font); + font-size: 1rem; +} + +* { + scrollbar-color: var(--scrollbar-thumb-color) var(--scrollbar-track-color); +} + +body { + display: flex; + flex-direction: column; + + width: 100vw; + height: 100vh; + overflow-y: hidden; +} + +a { + color: var(--main-color); + + &:has(> .def-path) { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.icon { + width: 18px; + height: 18px; + + &:has(+ span) { + margin-inline-end: 0.3em; + } + span + & { + margin-inline-start: 0.3em; + } +} + +.badge { + display: inline; + margin-inline: 0.3em; + padding-inline: 0.3em; + padding-block: 0.1em; + border-radius: 4px; + + background-color: var(--code-block-background-color); + + /* NOTE: Avoid tab badges expanding tab margins. */ + .tab > &:last-child { + margin-inline-end: 0; + } + + > * { + padding-inline: 0.2em; + } + + .undetected { + color: var(--status-undetected-color); + } +} + +.status { + padding-inline: 0.3em; + border-radius: 4px; + + font-family: var(--interface-font); + + background-color: red; + + &.not-ran { + background-color: var(--status-not-ran-background-color); + } + &.undetected { + background-color: var(--status-undetected-background-color); + } + &.detected { + background-color: var(--status-detected-background-color); + } + &.timed-out { + background-color: var(--status-timed-out-background-color); + } + &.crashed { + background-color: var(--status-crashed-background-color); + } +} + +.value { + font-weight: 700; + + &.undetected { + color: var(--status-undetected-color); + } + &.detected { + color: var(--status-detected-color); + } + &.timed-out { + color: var(--status-timed-out-color); + } + &.crashed { + color: var(--status-crashed-color); + } +} + +.inline-stats { + .label, .value { + padding-inline: 0.2em; + } + + .label { + padding-inline-end: 0.4em; + + color: var(--main-color); + } +} + +.labeled-stats { + display: flex; + flex-direction: row; + + * { + margin-inline: 0.5em; + } + + .divider { + width: 1px; + min-height: 100%; + background-color: var(--border-color); + } +} + +.inline-code { + padding-inline: 0.3em; + border-radius: 4px; + + font-family: var(--monospace-font); + + background-color: rgba(0, 0, 0, 0.3); +} + +.def-path { + font-family: var(--monospace-font); + + > .sep { + color: gray; + } +} + +.inline-trace { + display: flex; + flex-direction: row; + + /* NOTE: Ensure that the containing table cell does not expand to the full content width. */ + td:has(> &) { + max-width: 0px; + } + + /* NOTE: Ensure that non-ellipsized items do not expand the container. */ + overflow: hidden; + + > .def-path { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + /* NOTE: Prevent the first (first non-entry call) and last (function directly calling the mutated target) def path in the trace from being collapsed. */ + &:nth-child(1 of &), &:nth-last-child(1 of &) { + overflow: visible; + } + } + + > .sep { + padding-inline: 0.5em; + color: yellow; + } +} + +.topbar { + display: flex; + flex-direction: row; + align-items: center; + + width: 100%; + height: var(--topbar-height); + + border-bottom: 1px solid var(--border-color); + + & > :not([popover]) { + display: flex; + align-items: center; + height: 100%; + } + + & > .logo, & > .tab, & > button { + /* NOTE: Reset for button elements. */ + background: none; + padding: 0; + border: none; + color: inherit; + font: inherit; + cursor: pointer; + + padding-inline: 0.75em; + + user-select: none; + + text-decoration: none; + color: var(--main-color); + + &:hover { + background-color: var(--hover-background-color); + } + } + + /* Keep button highlighted when corresponding popover is open. */ + & > button:has(+ [popover]:popover-open) { + background-color: var(--hover-background-color); + } + + & .logo { + font-weight: 700; + } + + & .tab { + &.active { + box-shadow: 0px 1px var(--main-color); + } + } + + & [popover] { + /* NOTE: These are required to be overridden from the default stylesheet for popovers for positioning, see https://github.com/w3c/csswg-drafts/issues/10258. */ + margin: 0; + inset: auto; + + top: anchor(bottom); + left: anchor(left); + + background-color: var(--main-background-color); + color: var(--main-color); + border: 1px solid var(--border-color); + + &:popover-open { + display: flex; + flex-direction: column; + } + + & > .item { + width: 100%; + padding-inline: 0.75em; + padding-block: 0.35em; + display: flex; + align-items: center; + + user-select: none; + + text-decoration: none; + + &:hover { + background-color: var(--hover-background-color); + } + } + } +} + +.page-layout { + display: flex; + flex-direction: row; + + width: 100%; + height: calc(100% - var(--topbar-height)); +} + +.sidebar { + width: 300px; + height: auto; + min-height: 100%; + overflow-y: auto; + + border-right: 1px solid var(--border-color); + + ol { + list-style: none; + } + + li { + & > .item { + display: flex; + justify-content: space-between; + align-items: center; + + width: 100%; + padding-inline: 1em 0; + + user-select: none; + + text-decoration: none; + color: var(--main-color); + + &.selected { + background-color: var(--select-background-color); + } + + &:hover { + background-color: var(--hover-background-color); + } + + .name { + padding-block: 0.25em; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + & > ol { + padding-inline-start: 1em; + } + } +} + +.main-content { + width: 100%; + height: auto; + min-height: 100%; + overflow-y: scroll; + + /* Determine whether we need to apply a sticky offset to subheaders depending on whether the page has a main sticky header. */ + --sticky-offset: 0; + &:has(> header) { + --sticky-offset: var(--main-heading-height); + } + /* Ensure that the main sticky header does not cover the scrolled to element. */ + scroll-padding-top: var(--sticky-offset); + + header { + display: flex; + flex-direction: row; + align-items: center; + + position: sticky; + top: 0px; + left: 0px; + width: 100%; + min-height: var(--main-heading-height); + padding-block: 0.3em; + padding-inline: 0.75em; + + /* NOTE: The main header must be rendered above all other headers so that the section headers can slide underneath it. */ + z-index: 1; + + section > & { + top: var(--main-heading-height); + + /* NOTE: The section headers must be rendered below the main header so that the main header can stay on top. */ + z-index: 0; + } + + /* Keep headers to one line of text, replacing overflow with ellipsis. */ + /* NOTE: This must be applied to each child element individually to apply correctly. */ + &, > * { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + background-color: var(--main-background-color); + + border-bottom: 1px solid var(--border-color); + + h1 { + padding-inline-end: 1em; + + font-size: 1em; + font-weight: 700; + } + } +} + +section.text { + margin-block: 1em; + + > * { + padding-inline: 0.75em; + } + + > h2 { + margin-block: 0.25em; + padding-block: 0.25em; + border-bottom: 1px solid var(--border-color); + + font-size: 1.2rem; + line-height: 1.75rem; + } +} + +.page-table { + width: 100%; + border-spacing: 0px; + + thead, tr.heading { + th { + position: sticky; + top: var(--sticky-offset); + height: var(--main-heading-height); + /* NOTE: Keep sticky headings on top. */ + z-index: 2; + + background-color: var(--main-background-color); + + text-align: left; + font-weight: 700; + } + } + + /* Ensure that the table sticky headers do not cover the scrolled to element by applying additional scroll offset to the table rows. */ + :has(thead, tr.heading) { + tr, td { + scroll-margin-top: var(--main-heading-height); + } + } + + th, td { + padding-inline: 0.75em; + + border-bottom: 1px solid var(--border-color); + + white-space: nowrap; + + &.expand { + width: 100%; + } + + &.right { + text-align: right !important; + } + + &.right-tight { + padding-right: 0px; + } + + &.sticky-content > * { + position: sticky; + left: 0.75em; + } + } + + tbody { + th, td { + padding-block: 0.3em; + + background-color: var(--code-block-background-color); + } + } +} + +.notice { + width: 100%; + padding-inline: 1em; + padding-block: 0.3em; + + background-color: rgba(255, 0, 0, 0.5); +} + +.source-code { + padding-inline: 0.75em; + border-spacing: 0; + + white-space: pre; + tab-size: 4; + + font-family: var(--monospace-font); + + background-color: var(--code-block-background-color); + color: var(--main-color); + + .line { + & .line-no { + background-color: var(--main-background-color); + padding-left: 1.5em; + padding-right: 0.75em; + + user-select: none; + + text-align: right; + } + + & .diff-marker { + padding-left: 0.75em; + + user-select: none; + + text-align: right; + } + + & .line-content { + width: 100%; + padding-left: 0.75em; + + .hi-def { + background-color: rgba(115, 0, 255, 0.5); + } + + .hi-call { + background-color: rgba(255, 0, 255, 0.5); + } + } + + &.original { + & .line-content, & .diff-marker { + background-color: rgba(255, 31, 31, 0.25); + + .hi-mut { + background-color: rgba(255, 31, 31, 0.5); + } + } + } + &.mutated { + & .line-content, & .diff-marker { + background-color: rgba(0, 128, 0, 0.25); + + .hi-mut { + background-color: rgba(0, 128, 0, 0.5); + } + } + } + } + + --mutation-group-margin: 2px; + --mutation-group-border-width: 2px; + --mutation-group-border-radius: 8px; + + .group, .mutation { + & .heading { + & td { + padding-block: 0.1em; + padding-inline: 0.5em; + } + + user-select: none; + + background-color: var(--mutation-group-background-color); + } + + /* Sides of the border that surrounds all overlapping mutation groups. */ + & .line { + & td:first-child { + border-left: solid var(--mutation-group-border-width) var(--mutation-group-background-color); + } + & td:last-child { + border-right: solid var(--mutation-group-border-width) var(--mutation-group-background-color); + } + } + } + + .group { + .heading { + & td { + border-top-left-radius: var(--mutation-group-border-radius); + border-top-right-radius: var(--mutation-group-border-radius); + } + } + + /* Gap before each group. */ + /* NOTE: This needs to be a before pseudo-element because the table layout does not support margins. */ + &::before { + content: ""; + display: table-row; + height: var(--mutation-group-margin); + } + } + + .mutation { + &:has(+ .line) { + /* Gap after each group. */ + /* NOTE: This needs to be a before pseudo-element because the table layout does not support margins. */ + &::after { + content: ""; + display: table-row; + height: var(--mutation-group-margin); + } + } + + &:has(+ .line, + .group) { + /* Bottom of the border that surrounds all overlapping mutation groups. */ + /* NOTE: The border needs to be applied specifically to the td elements. */ + & tr:last-child td { + border-bottom: solid var(--mutation-group-border-width) var(--mutation-group-background-color); + + &:first-child { + border-bottom-left-radius: var(--mutation-group-border-radius); + } + &:last-child { + border-bottom-right-radius: var(--mutation-group-border-radius); + } + } + } + } +} + +/* Assignment of syntax highlighting colors. */ +.attr { + color: var(--code-highlight-attribute-color); +} +.comment { + color: var(--code-highlight-comment-color); +} +.const { + color: var(--main-color); +} +.const\! { + color: var(--main-color); +} +.fn { + color: var(--main-color); +} +.fn\! { + color: var(--main-color); +} +.kw { + color: var(--code-highlight-kw-color); +} +.label { + color: var(--code-highlight-lifetime-color); +} +.macro { + color: var(--code-highlight-macro-color); +} +.mod { + color: var(--main-color); +} +.num { + color: var(--code-highlight-number-color); +} +.op { + color: var(--code-highlight-kw-2-color); +} +.prop { + color: var(--main-color); +} +.prop\! { + color: var(--main-color); +} +.p { + color: var(--main-color); +} +.pb { + color: var(--main-color); +} +.pd { + color: var(--main-color); +} +.p\! { + color: var(--main-color); +} +.str { + color: var(--code-highlight-string-color); +} +.escape { + color: var(--code-highlight-string-color); +} +.str\! { + color: var(--code-highlight-string-color); +} +.tag { + color: var(--main-color); +} +.ty { + color: var(--main-color); +} +.ty\! { + color: var(--code-highlight-prelude-color); +} +.var { + color: var(--main-color); +} +.var\! { + color: var(--main-color); +} +.param { + color: var(--main-color); +} diff --git a/mutest-inspector/src/syntax_highlight.rs b/mutest-inspector/src/syntax_highlight.rs new file mode 100644 index 00000000..a7428e2b --- /dev/null +++ b/mutest-inspector/src/syntax_highlight.rs @@ -0,0 +1,582 @@ +use std::fmt::Write; + +use pulldown_cmark_escape::escape_html_body_text; + +#[derive(Clone, Debug)] +pub struct MappedLineLoc { + pub source_line_byte_offset: usize, + pub mapped_line_byte_offset: usize, +} + +#[derive(Clone, Debug)] +pub struct MappedLineSegment { + pub start_loc: MappedLineLoc, + pub end_loc: MappedLineLoc, + pub breakable: bool, +} + +#[derive(Clone, Debug)] +pub struct MappedLine { + line: String, + mappings: Vec, +} + +impl MappedLine { + #[inline] + pub fn as_str(&self) -> &str { + &self.line + } + + #[inline] + pub fn mappings(&self) -> &[MappedLineSegment] { + &self.mappings + } + + pub fn insert_unbreakable_segment(&mut self, start_source_offset: usize, end_source_offset: usize, prefix: &str, suffix: &str) { + let Some(start_segment_idx) = self.mappings.iter().position(|segment| segment.end_loc.source_line_byte_offset > start_source_offset) else { + panic!("invalid span overlay: cannot find source segment starting at or before span overlay start"); + }; + let start_segment = self.mappings[start_segment_idx].clone(); + + let Some(end_segment_idx) = self.mappings.iter().position(|segment| segment.end_loc.source_line_byte_offset >= end_source_offset) else { + panic!("invalid span overlay: cannot find source segment starting at or after span overlay end"); + }; + let end_segment = self.mappings[end_segment_idx].clone(); + + let start_source_line_adjusted_byte_offset = match start_segment.breakable { + true => start_source_offset, + false => start_segment.start_loc.source_line_byte_offset, + }; + let start_mapped_line_byte_offset = match start_segment.breakable { + true => start_segment.start_loc.mapped_line_byte_offset + (start_source_offset - start_segment.start_loc.source_line_byte_offset), + false => start_segment.start_loc.mapped_line_byte_offset, + }; + + let end_source_line_adjusted_byte_offset = match end_segment.breakable { + true => end_source_offset, + false => end_segment.end_loc.source_line_byte_offset, + }; + let end_mapped_line_byte_offset = match end_segment.breakable { + true => end_segment.start_loc.mapped_line_byte_offset + (end_source_offset - end_segment.start_loc.source_line_byte_offset), + false => end_segment.end_loc.mapped_line_byte_offset, + }; + + self.line.insert_str(end_mapped_line_byte_offset, suffix); + self.line.insert_str(start_mapped_line_byte_offset, prefix); + let insert_offset = prefix.len() + suffix.len(); + + let replacement_segment_start_loc = MappedLineLoc { + source_line_byte_offset: start_source_line_adjusted_byte_offset, + mapped_line_byte_offset: start_mapped_line_byte_offset, + }; + let replacement_segment_end_loc = MappedLineLoc { + source_line_byte_offset: end_source_line_adjusted_byte_offset, + mapped_line_byte_offset: end_mapped_line_byte_offset + insert_offset, + }; + + self.mappings.splice(start_segment_idx..=end_segment_idx, [ + MappedLineSegment { + start_loc: replacement_segment_start_loc.clone(), + end_loc: replacement_segment_end_loc.clone(), + breakable: false, + }, + ]); + // NOTE: The previous splice invalidates the original end segment index. + let mut tail_segments_start_idx = start_segment_idx + 1; + + // Add the rest of the broken apart end segment as a breakable suffix segment. + if end_source_line_adjusted_byte_offset != end_segment.end_loc.source_line_byte_offset { + tail_segments_start_idx += 1; + + // Adjust suffix segment's mapped end offset with insertion offset. + let mut suffix_segment_end_loc = end_segment.end_loc; + suffix_segment_end_loc.mapped_line_byte_offset += insert_offset; + + self.mappings.insert(start_segment_idx + 1, MappedLineSegment { + start_loc: replacement_segment_end_loc, + end_loc: suffix_segment_end_loc, + breakable: true, + }); + } + // Add the rest of the broken apart start segment as a breakable prefix segment. + if start_source_line_adjusted_byte_offset != start_segment.start_loc.source_line_byte_offset { + tail_segments_start_idx += 1; + self.mappings.insert(start_segment_idx, MappedLineSegment { + start_loc: start_segment.start_loc, + end_loc: replacement_segment_start_loc, + breakable: true, + }); + } + + // Adjust tail segments' mapped start and end offsets with insertion offsets. + for segment in &mut self.mappings[tail_segments_start_idx..] { + segment.start_loc.mapped_line_byte_offset += insert_offset; + segment.end_loc.mapped_line_byte_offset += insert_offset; + } + } +} + +macro_rules! highlight_names { + ($($name:literal => $shortcode:literal,)+) => { + const HIGHLIGHT_NAMES: &[&str] = &[$($name,)+]; + const HIGHLIGHT_SHORTCODES: &[&str] = &[$($shortcode,)+]; + } +} + +highlight_names! { + "attribute" => "attr", + "comment" => "comment", + "constant" => "const", + "constant.builtin" => "const!", + "function" => "fn", + "function.builtin" => "fn!", + "keyword" => "kw", + "label" => "label", + "macro" => "macro", + "module" => "mod", + "number" => "num", + "operator" => "op", + "property" => "prop", + "property.builtin" => "prop!", + "punctuation" => "p", + "punctuation.bracket" => "pb", + "punctuation.delimiter" => "pd", + "punctuation.special" => "p!", + "string" => "str", + "string.escape" => "escape", + "string.special" => "str!", + "tag" => "tag", + "type" => "ty", + "type.builtin" => "ty!", + "variable" => "var", + "variable.builtin" => "var!", + "variable.parameter" => "param", +} + +pub struct SyntaxHighlighter { + highlighter: tree_sitter_highlight::Highlighter, + highlight_config: tree_sitter_highlight::HighlightConfiguration, +} + +impl SyntaxHighlighter { + pub fn init() -> Self { + let highlighter = tree_sitter_highlight::Highlighter::new(); + let mut highlight_config = tree_sitter_highlight::HighlightConfiguration::new( + tree_sitter_rust::LANGUAGE.into(), + "rust", + tree_sitter_rust::HIGHLIGHTS_QUERY, + tree_sitter_rust::INJECTIONS_QUERY, + "", + ).expect("cannot load tree-sitter language definition"); + highlight_config.configure(HIGHLIGHT_NAMES); + + SyntaxHighlighter { highlighter, highlight_config } + } + + pub fn highlight_lines_html(&mut self, source: &str) -> Result, tree_sitter_highlight::Error> { + let highlight_events = self.highlighter.highlight(&self.highlight_config, source.as_bytes(), None, |_| None)?; + + let mut highlighted_lines_html = vec![MappedLine { line: String::new(), mappings: vec![] }]; + + let mut active_highlight: Option = None; + let mut current_line_source_offset = 0; + + for highlight_event in highlight_events { + let highlight_event = highlight_event?; + + match highlight_event { + tree_sitter_highlight::HighlightEvent::Source { start, end } => { + // NOTE: Based on `pulldown_cmark_escape::escape_html_body_text`. + fn escape_html_in_source_line_snippet_with_unbreakable_segments(out: &mut MappedLine, source_line_snippet: &str, snippet_start_loc: MappedLineLoc) { + let mut last_unescaped_segment_start_loc = snippet_start_loc.clone(); + + for (source_snippet_byte_offset, &source_snippet_byte) in source_line_snippet.as_bytes().iter().enumerate() { + let html_escape_str = match source_snippet_byte { + b'&' => "&", + b'<' => "<", + b'>' => ">", + _ => { continue; } + }; + + let last_unescaped_segment_start_source_snippet_byte_offset = last_unescaped_segment_start_loc.source_line_byte_offset - snippet_start_loc.source_line_byte_offset; + out.line.push_str(&source_line_snippet[last_unescaped_segment_start_source_snippet_byte_offset..source_snippet_byte_offset]); + + let escape_start_loc = MappedLineLoc { + source_line_byte_offset: snippet_start_loc.source_line_byte_offset + source_snippet_byte_offset, + mapped_line_byte_offset: out.line.len(), + }; + + if escape_start_loc.source_line_byte_offset > last_unescaped_segment_start_loc.source_line_byte_offset { + out.mappings.push(MappedLineSegment { + start_loc: last_unescaped_segment_start_loc, + end_loc: escape_start_loc.clone(), + breakable: true, + }); + } + + out.line.push_str(html_escape_str); + + let escape_end_loc = MappedLineLoc { + source_line_byte_offset: escape_start_loc.source_line_byte_offset + 1, + mapped_line_byte_offset: out.line.len(), + }; + + out.mappings.push(MappedLineSegment { + start_loc: escape_start_loc, + end_loc: escape_end_loc.clone(), + breakable: false, + }); + + last_unescaped_segment_start_loc = escape_end_loc; + } + + let last_unescaped_segment_start_source_snippet_byte_offset = last_unescaped_segment_start_loc.source_line_byte_offset - snippet_start_loc.source_line_byte_offset; + out.line.push_str(&source_line_snippet[last_unescaped_segment_start_source_snippet_byte_offset..]); + + let snippet_end_loc = MappedLineLoc { + source_line_byte_offset: snippet_start_loc.source_line_byte_offset + source_line_snippet.len(), + mapped_line_byte_offset: out.line.len(), + }; + + if snippet_end_loc.source_line_byte_offset > last_unescaped_segment_start_loc.source_line_byte_offset { + out.mappings.push(MappedLineSegment { + start_loc: last_unescaped_segment_start_loc, + end_loc: snippet_end_loc, + breakable: true, + }); + } + } + + let source_snippet = &source[start..end]; + + let mut source_snippet_lines = source_snippet.lines(); + if let Some(source_snippet) = source_snippet_lines.next() && !source_snippet.is_empty() { + let current_highlighted_line = highlighted_lines_html.last_mut().unwrap(); + + // NOTE: The explicit remapping of HTML escape codes through unbreakable segments + // only needs to be done if we are not in a highlight, + // because we already make unbreakable segments for highlights: + // one for the entire source snippet (including the prefix and suffix). + match active_highlight { + None => { + let start_loc = MappedLineLoc { + source_line_byte_offset: current_line_source_offset, + mapped_line_byte_offset: current_highlighted_line.line.len(), + }; + escape_html_in_source_line_snippet_with_unbreakable_segments(current_highlighted_line, source_snippet, start_loc); + current_line_source_offset += source_snippet.len(); + } + Some(highlight) => { + let line_highlight_start = MappedLineLoc { + source_line_byte_offset: current_line_source_offset, + mapped_line_byte_offset: current_highlighted_line.line.len(), + }; + + write!(current_highlighted_line.line, "", HIGHLIGHT_SHORTCODES[highlight.0]).unwrap(); + + escape_html_body_text(&mut current_highlighted_line.line, source_snippet).unwrap(); + current_line_source_offset += source_snippet.len(); + + write!(current_highlighted_line.line, "").unwrap(); + + let line_highlight_end = MappedLineLoc { + source_line_byte_offset: current_line_source_offset, + mapped_line_byte_offset: current_highlighted_line.line.len(), + }; + + current_highlighted_line.mappings.push(MappedLineSegment { + start_loc: line_highlight_start, + end_loc: line_highlight_end, + breakable: false, + }); + } + } + } + for source_snippet in source_snippet_lines { + current_line_source_offset = 0; + + let mut new_highlighted_line = MappedLine { line: String::new(), mappings: vec![] }; + + // NOTE: The explicit remapping of HTML escape codes through unbreakable segments + // only needs to be done if we are not in a highlight, + // because we already make unbreakable segments for highlights: + // one for the entire source snippet (including the prefix and suffix). + match active_highlight { + None => { + let start_loc = MappedLineLoc { + source_line_byte_offset: 0, + mapped_line_byte_offset: 0, + }; + escape_html_in_source_line_snippet_with_unbreakable_segments(&mut new_highlighted_line, source_snippet, start_loc); + current_line_source_offset += source_snippet.len(); + } + Some(highlight) => { + let line_highlight_start = MappedLineLoc { + source_line_byte_offset: 0, + mapped_line_byte_offset: 0, + }; + + write!(new_highlighted_line.line, "", HIGHLIGHT_SHORTCODES[highlight.0]).unwrap(); + + escape_html_body_text(&mut new_highlighted_line.line, source_snippet).unwrap(); + current_line_source_offset += source_snippet.len(); + + write!(new_highlighted_line.line, "").unwrap(); + + let line_highlight_end = MappedLineLoc { + source_line_byte_offset: current_line_source_offset, + mapped_line_byte_offset: new_highlighted_line.line.len(), + }; + + new_highlighted_line.mappings.push(MappedLineSegment { + start_loc: line_highlight_start, + end_loc: line_highlight_end, + breakable: false, + }); + } + } + + highlighted_lines_html.push(new_highlighted_line); + } + + // NOTE: The `str::lines()` iterator ignores trailing newlines, which we want to keep. + if source_snippet.ends_with("\n") || source_snippet.ends_with("\r\n") { + highlighted_lines_html.push(MappedLine { line: String::new(), mappings: vec![] }); + current_line_source_offset = 0; + } + } + tree_sitter_highlight::HighlightEvent::HighlightStart(highlight) => { + active_highlight = Some(highlight); + } + tree_sitter_highlight::HighlightEvent::HighlightEnd => { + active_highlight = None; + } + } + } + + Ok(highlighted_lines_html) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl MappedLine { + fn source_segments_debug_repr(&self, source: &str) -> String { + let mut out = source.to_owned(); + for segment in self.mappings.iter().rev() { + let (start_token, end_token) = match segment.breakable { + true => ("<:", ":>"), + false => ("<|", "|>"), + }; + out.insert_str(segment.end_loc.source_line_byte_offset, end_token); + out.insert_str(segment.start_loc.source_line_byte_offset, start_token); + } + out + } + + fn mapped_segments_debug_repr(&self) -> String { + let mut out = self.line.to_owned(); + for segment in self.mappings.iter().rev() { + let (start_token, end_token) = match segment.breakable { + true => ("<:", ":>"), + false => ("<|", "|>"), + }; + out.insert_str(segment.end_loc.mapped_line_byte_offset, end_token); + out.insert_str(segment.start_loc.mapped_line_byte_offset, start_token); + } + out + } + } + + macro_rules! check_line_segments { + ( + $mapped_line:expr, $source_line:expr, + source: $source:literal + mapped: $mapped:literal + ) => { + assert_eq!($source, $mapped_line.source_segments_debug_repr($source_line), "source line segments mismatch"); + assert_eq!($mapped, $mapped_line.mapped_segments_debug_repr(), "mapped line segments mismatch"); + }; + } + + macro_rules! check_segmented_lines { + ( + $mapped_lines:expr, $source:expr, + $( + source: $source_line:literal + mapped: $mapped_line:literal + )+ + ) => { + let mut line_no = 1; + let mut mapped_lines = $mapped_lines.iter(); + let mut source_lines = $source.lines(); + + $( + let Some(mapped_line) = mapped_lines.next() else { panic!("too few mapped lines: expected line {line_no}"); }; + let Some(source_line) = source_lines.next() else { panic!("too few source lines: expected line {line_no}"); }; + assert_eq!($source_line, mapped_line.source_segments_debug_repr(source_line), "line {line_no}: source line segments mismatch"); + assert_eq!($mapped_line, mapped_line.mapped_segments_debug_repr(), "line {line_no}: mapped line segments mismatch"); + line_no += 1; + )+ + + if let Some(mapped_line) = mapped_lines.next() { + panic!("too few lines checked: found line {line_no} {:?}", mapped_line.mapped_segments_debug_repr()); + } + }; + } + + #[test] + fn test_highlight_lines_keeps_empty_last_line() { + let source = concat!( + "fn foo() {}\n", + "", + ); + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + + assert_eq!("", mapped_lines[1].mapped_segments_debug_repr()); + } + + #[test] + fn test_highlight_lines_ensures_no_empty_line_delimiter_segments() { + let source = concat!( + " let mut a = 0;\n", + " let mut b = a;", + ); + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + + check_segmented_lines!(mapped_lines, source, + source: "<: :><|let|><: :><|mut|><: a = :><|0|><|;|>" + mapped: "<: :><|let|><: :><|mut|><: a = :><|0|><|;|>" + + source: "<: :><|let|><: :><|mut|><: b = a:><|;|>" + mapped: "<: :><|let|><: :><|mut|><: b = a:><|;|>" + ); + } + + #[test] + fn test_multiline_highlights() { + let source = concat!( + " println!(\"\\\n", + " testing\\\n", + " 123\\\n", + "\n", // Empty line in the middle of a multiline highlight. + " \");", + ); + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + + check_segmented_lines!(mapped_lines, source, + source: "<: :><|println|><|!|><|(|><|\"\\|>" + mapped: "<: :><|println|><|!|><|(|><|\"\\|>" + + source: "<| testing\\|>" + mapped: "<| testing\\|>" + + source: "<| 123\\|>" + mapped: "<| 123\\|>" + + // Empty line in the middle of a multiline highlight. + source: "<||>" + mapped: "<||>" + + source: "<| \"|><|)|><|;|>" + mapped: "<| \"|><|)|><|;|>" + ); + } + + #[test] + fn test_highlight_lines_mappings_record_html_escapes_as_unbreakable_segments() { + let source = " Ok(_) => i += 1,"; + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + + check_segmented_lines!(mapped_lines, source, + source: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + ); + } + + #[test] + fn test_highlight_lines_ensures_no_empty_segments_around_html_escapes() { + let source = "::default()"; + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + + check_segmented_lines!(mapped_lines, source, + source: "<|<|><|T|><: :><|as|><: :><|Default|><|>|><|::|><|default|><|(|><|)|>" + mapped: "<|<|><|T|><: :><|as|><: :><|Default|><|>|><|::|><|default|><|(|><|)|>" + ); + } + + #[test] + fn test_mapped_line_insert_unbreakable_segment() { + // TODO: Change source to something different / more complex. + let source = " Ok(_) => i += 1,"; + + let mut syntax_highlighter = SyntaxHighlighter::init(); + let mapped_lines = syntax_highlighter.highlight_lines_html(source).unwrap(); + let [mapped_line] = &mapped_lines[..] else { unreachable!() }; + + check_line_segments!(mapped_line, source, + source: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + ); + + // TEST: Start at existing segment boundary / end at existing segment boundary. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(4, 9, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><|Ok(_)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><|Ok(_)|><: =:><|>|><: i += :><|1|><|,|>" + ); + + // TEST: Start inside unbreakable segment / end inside the same unbreakable segment. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(5, 6, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + ); + + // TEST: Start inside breakable segment / end inside the same breakable segment. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(1, 2, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><| |><: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><| |><: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + ); + + // TEST: Start inside breakable segment / end inside another breakable segment. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(10, 17, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><|Ok|><|(|><:_:><|)|><: :><|=> i +=|><: :><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><: :><|=> i +=|><: :><|1|><|,|>" + ); + + // TEST: Start at breakable segment boundary / end at another breakable segment boundary. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(9, 18, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><|Ok|><|(|><:_:><|)|><| => i += |><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><| => i += |><|1|><|,|>" + ); + + // TEST: Start at last segment boundary / end at last segment boundary. + let mut new_mapped_line = mapped_line.clone(); + new_mapped_line.insert_unbreakable_segment(19, 20, "", ""); + check_line_segments!(new_mapped_line, source, + source: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + mapped: "<: :><|Ok|><|(|><:_:><|)|><: =:><|>|><: i += :><|1|><|,|>" + ); + } +} diff --git a/mutest-json/src/call_graph.rs b/mutest-json/src/call_graph.rs index bffcca0f..e732294c 100644 --- a/mutest-json/src/call_graph.rs +++ b/mutest-json/src/call_graph.rs @@ -57,12 +57,8 @@ impl Idx for EntryPointId { pub struct EntryPoint { pub entry_point_id: EntryPointId, - /// Definition name. - pub name: String, - /// Definition path. - pub path: String, - /// Definition span. - pub span: Option, + /// The corresponding definition. + pub def_id: DefId, /// Calls made by the entry point, grouped by the calles it is calling, /// with associated instance data for each call occurance. @@ -99,7 +95,7 @@ pub struct Callee { pub calls: HashMap>, } -/// Data associated with the instance of call from a particular caller. +/// Data associated with the instance of a call from a particular caller. #[derive(Clone, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)] pub struct CallInstance { /// Span of the call's location. diff --git a/mutest-json/src/lib.rs b/mutest-json/src/lib.rs index 333f22dc..228e62c2 100644 --- a/mutest-json/src/lib.rs +++ b/mutest-json/src/lib.rs @@ -30,10 +30,21 @@ pub struct Span { #[cfg(feature = "rustc")] impl Span { pub fn from_rustc_span(sess: &rustc_session::Session, span: rustc_span::Span) -> Option { + static CURRENT_DIR: std::sync::LazyLock = std::sync::LazyLock::new(|| { + std::env::current_dir().expect("cannot read current directory") + }); + let (Some(source_file), begin_line, begin_col, end_line, end_col) = sess.source_map().span_to_location_info(span) else { return None; }; let rustc_span::FileName::Real(file_name) = &source_file.name else { return None; }; - let path = file_name.local_path()?.to_owned(); + + let mut path = file_name.local_path()?; + // Remove workspace directory prefix if the span points to a workspace file. + // NOTE: rustc uses absolute paths for any remote crate's spans, including workspace-local ones. + if let Ok(local_path) = path.strip_prefix(&*CURRENT_DIR) { + path = local_path; + } + let path = path.to_owned(); Some(Self { path, begin: (begin_line, begin_col), end: (end_line, end_col) }) } @@ -63,7 +74,7 @@ impl Idx for DefId { pub struct Definition { pub def_id: DefId, pub name: Option, - pub path: Option, + pub path: String, pub span: Option, } diff --git a/mutest-runtime-embedded-host-driver/src/main.rs b/mutest-runtime-embedded-host-driver/src/main.rs index 89742e8a..632d56d5 100644 --- a/mutest-runtime-embedded-host-driver/src/main.rs +++ b/mutest-runtime-embedded-host-driver/src/main.rs @@ -42,9 +42,10 @@ fn main() { .arg(clap::arg!(--timings "Print timing information for each completed pass.").display_order(100)) .arg(clap::arg!(-v --verbose "Print more verbose information during execution.").action(clap::ArgAction::Count).default_value("0").display_order(100)) .arg(clap::arg!(--print [PRINT] "Print additional information during mutation evaluation. Multiple may be specified, separated by commas.").value_delimiter(',').action(clap::ArgAction::Append).value_parser(print::possible_values()).display_order(101)) + // Metadata-related Arguments + .arg(clap::arg!(--"json-out-root-dir" [OUT_DIR] "Write JSON metadata files into the specified output directory.").value_parser(clap::value_parser!(PathBuf)).display_order(105)) // Experimental Flags - .arg(clap::arg!(--"Zwrite-json" [OUT_DIR] "Write JSON metadata files into the specified output directory.").value_parser(clap::value_parser!(PathBuf)).display_order(500)) - .arg(clap::arg!(--"Zwrite-json-eval-stream" "Write JSONL stream file into JSON output directory specified by `--Zwrite-json`.").display_order(500)) + .arg(clap::arg!(--"Zwrite-json-eval-stream" "Write JSONL stream file into JSON output directory specified by `--json-out-root-dir`.").display_order(500)) // Information // FIXME: Regression; the `help` subcommand can no longer be customized, // so the about text does not match that of the help flags. @@ -91,7 +92,7 @@ fn main() { }; let write_opts = 'write_opts: { - let Some(out_dir) = matches.get_one::("Zwrite-json").cloned() else { + let Some(out_dir) = matches.get_one::("json-out-root-dir").cloned() else { break 'write_opts None; }; // TODO: Determine specific package target out dir path from the test bin being tested. diff --git a/mutest-runtime/src/harness.rs b/mutest-runtime/src/harness.rs index 4eeab7e0..59e5e746 100644 --- a/mutest-runtime/src/harness.rs +++ b/mutest-runtime/src/harness.rs @@ -751,7 +751,7 @@ pub fn mutest_main(args: &[&str], tests: Vec, external_test detection_matrix: args.contains(&"--print=detection-matrix").then_some(()), subsumption_matrix: args.contains(&"--print=subsumption-matrix").then_some(()), }, - write_opts: args.iter().flat_map(|arg| arg.strip_prefix("--Zwrite-json=")).next().map(|out_dir_str| { + write_opts: args.iter().flat_map(|arg| arg.strip_prefix("--json-out-root-dir=")).next().map(|out_dir_str| { let mut out_dir = PathBuf::from(out_dir_str); if let Some(cargo_package_name) = meta_mutant.cargo_package_name { diff --git a/tests/src/main.rs b/tests/src/main.rs index db8c012c..c26a6930 100644 --- a/tests/src/main.rs +++ b/tests/src/main.rs @@ -449,7 +449,7 @@ fn run_test(path: &Path, aux_dir_path: &Path, root_dir: &Path, opts: &Opts, resu cmd.args(["-L", AUX_OUT_DIR]); } - let mut mutest_args = vec![]; + let mut mutest_args = vec!["--no-write-json".to_owned()]; let mut verifications = directives.iter().filter_map(|d| d.strip_prefix("verify:").map(str::trim)) .flat_map(|flags| flags.split(",").map(str::trim).filter(|flag| !flag.is_empty())) .peekable();