diff --git a/.clangd.template b/.clangd.template index 3e495b6..b9517f6 100644 --- a/.clangd.template +++ b/.clangd.template @@ -1,88 +1,25 @@ -# .clangd configuration template for MSVC projects -# -# This file should be placed in the root of your project directory, -# next to compile_commands.json +# Optional clangd configuration for MSVC projects. # -# Generated by ms2cc for use with clangd and MSVC compile_commands.json -# See: https://clangd.llvm.org/config for full documentation +# ms2cc preserves the evaluated compiler command. Start without removals: +# current clangd versions strip output flags themselves and understand +# clang-cl-style PCH options. Diagnostics: - # Suppress common MSVC-related warnings that are noisy in clangd - Suppress: - - ignored-pragmas # MSVC-specific #pragma optimize, etc. - - missing-field-initializers # Common in MSVC codebases using aggregate init - - unused-const-variable # Often intentional in headers - - null-pointer-subtraction # Used in some low-level code patterns - - ignored-qualifiers # const on return types (old MSVC pattern) - - unused-but-set-variable # Debug/retail conditional code - - # Don't report unused includes - too noisy for large MSVC projects UnusedIncludes: None - - # Don't report missing includes - MissingIncludes: None -# Uncomment to suppress clang-tidy checks: -# Diagnostics: -# ClangTidy: -# Remove: ['modernize-*', 'readability-*'] - -# The compile_commands.json generated by ms2cc already contains -# correctly formatted flags for clang-cl. clangd will automatically -# use --driver-mode=cl when it sees MSVC-style flags. -# -# Uncomment to add or remove compiler flags if needed: CompileFlags: Add: -# - "-std=c++20" - - "-ferror-limit=0" # No limit - - "-fdelayed-template-parsing" # MSVC-style template parsing - - "-fms-compatibility" # Enable MSVC compatibility - - "-fms-extensions" # Enable MSVC extensions -# Remove: -# - "/some-problematic-flag" - -# For large codebases, you may want to tune indexing performance. -# Uncomment and adjust as needed: -# Index: -# Background: 2 # Limit background indexing threads (default: 4) -# StandardLibrary: No # Don't index STL (faster startup) + - -ferror-limit=0 -Hover: - ShowAKA: Yes # Show type aliases and "also known as" info - -InlayHints: - Enabled: Yes # Enable inline parameter and type hints - ParameterNames: Yes # Show parameter names inline - DeducedTypes: Yes # Show deduced types inline - # Note: Disable these if you find inline hints distracting - -# ============================================================================ -# Per-file or per-directory overrides -# ============================================================================ -# You can add additional YAML documents below (separated by '---') -# to override settings for specific files or directories. +# If clangd reports that a specific MSVC-generated PCH is incompatible, scope a +# removal rule to affected files. Verify first that the PCH header is included +# directly or through /FI; /Yu
can imply inclusion. # -# Example: Suppress warnings in test files # --- # If: -# PathMatch: .*test.* -# Diagnostics: -# Suppress: -# - unused-parameter -# -# Example: Add flags for specific directory -# --- -# If: -# PathMatch: src/SpecificModule/.* +# PathMatch: path/to/affected/files/.* # CompileFlags: -# Add: -# - "-DMODULE_SPECIFIC_FLAG" -# -# Example: Suppress MSVC-specific __m128i union access errors -# --- -# If: -# PathMatch: .*ClMulCrcSSE\.cpp -# Diagnostics: -# Suppress: -# - typecheck_member_reference_struct_union +# Remove: +# - /Yc* +# - /Yu* +# - /Fp* diff --git a/.github/workflows/windows-ci.yml b/.github/workflows/windows-ci.yml new file mode 100644 index 0000000..7bdb902 --- /dev/null +++ b/.github/workflows/windows-ci.yml @@ -0,0 +1,24 @@ +name: Windows CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy,rustfmt + - name: Check formatting + run: cargo fmt --check + - name: Lint + run: cargo clippy --all-targets -- -D warnings + - name: Test + run: cargo test diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index 055d486..a3c57f1 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -11,7 +11,7 @@ jobs: runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true @@ -33,6 +33,8 @@ jobs: } "version=$cargoVersion" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - name: Build project + run: cargo test --release + - name: Build release artifact run: cargo build --release --target x86_64-pc-windows-msvc - name: Zip executable run: | diff --git a/Cargo.lock b/Cargo.lock index 88b7a48..7ca6652 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - [[package]] name = "anstream" version = "1.0.0" @@ -63,32 +54,36 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "atty" -version = "0.2.14" +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "hermit-abi", - "libc", - "winapi", + "bit-vec", ] [[package]] -name = "bitflags" -version = "2.11.0" +name = "bit-vec" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] -name = "bumpalo" -version = "3.20.2" +name = "bitflags" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cfg-if" @@ -98,9 +93,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -108,9 +103,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -120,14 +115,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -143,37 +138,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "console" -version = "0.16.3" +name = "crossbeam-deque" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "crossbeam-epoch" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ - "powerfmt", + "crossbeam-utils", ] [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "crossbeam-utils" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "equivalent" -version = "1.0.2" +name = "either" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "errno" @@ -187,100 +180,45 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "foldhash" -version = "0.1.5" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "getrandom" -version = "0.4.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", - "wasip3", ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ - "foldhash", + "cfg-if", + "libc", + "r-efi 6.0.0", ] -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "indexmap" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "indicatif" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" -dependencies = [ - "console", - "portable-atomic", - "unicode-width", - "unit-prefix", - "web-time", -] - -[[package]] -name = "indicatif-log-bridge" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63703cf9069b85dbe6fe26e1c5230d013dee99d3559cd3d02ba39e099ef7ab02" -dependencies = [ - "indicatif", - "log", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -293,27 +231,11 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "js-sys" -version = "0.3.94" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.184" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -321,49 +243,34 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "ms2cc" version = "3.0.1" dependencies = [ "anyhow", - "atty", "clap", - "indexmap", - "indicatif", - "indicatif-log-bridge", - "log", - "regex", + "proptest", + "quick-xml", + "rayon", "serde", "serde_json", - "simplelog", "tempfile", + "windows-sys", ] [[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num_threads" -version = "0.1.7" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "libc", + "autocfg", ] [[package]] @@ -379,45 +286,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "portable-atomic" -version = "1.13.1" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "proptest" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "proc-macro2", - "syn", + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", ] [[package]] -name = "proc-macro2" -version = "1.0.106" +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-xml" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ - "unicode-ident", + "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -425,33 +359,68 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "regex" -version = "1.12.3" +name = "rand" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "rand_chacha", + "rand_core", ] [[package]] -name = "regex-automata" -version = "0.4.14" +name = "rand_chacha" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", ] [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustix" @@ -467,22 +436,22 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rusty-fork" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -490,29 +459,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -521,17 +490,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "simplelog" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" -dependencies = [ - "log", - "termcolor", - "time", -] - [[package]] name = "strsim" version = "0.11.1" @@ -540,9 +498,20 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -556,53 +525,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "unarray" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicode-ident" @@ -610,24 +543,6 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - [[package]] name = "utf8parse" version = "0.2.2" @@ -635,143 +550,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" +name = "wait-timeout" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" dependencies = [ - "wit-bindgen", + "libc", ] [[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] -[[package]] -name = "wasm-bindgen" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-link" version = "0.2.1" @@ -789,94 +584,32 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" +name = "zerocopy" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ - "anyhow", - "heck", - "wit-parser", + "zerocopy-derive", ] [[package]] -name = "wit-bindgen-rust" -version = "0.51.0" +name = "zerocopy-derive" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", "proc-macro2", "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 9797804..ade2b2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,19 +1,27 @@ [package] name = "ms2cc" version = "3.0.1" -description = "Convert MSBuild logs to compile_commands.json for C/C++ language servers" +description = "Generate compile_commands.json from Visual C++ MSBuild projects" edition = "2024" [dependencies] -anyhow = "1.0" -atty = "0.2" -clap = { version = "4.5", features = ["derive"] } -indicatif = "0.18" -indicatif-log-bridge = "0.2" -log = "0.4" -regex = "1.12" -indexmap = { version = "2.9", features = ["serde"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -simplelog = "0.12" -tempfile = "3.15" +anyhow = "1.0.104" +clap = { version = "4.6.6", features = ["derive"] } +quick-xml = "0.42.0" +rayon = "1.12.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +tempfile = "3.27.0" + +[dev-dependencies] +proptest = "1.11.0" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } diff --git a/LICENSE.txt b/LICENSE similarity index 100% rename from LICENSE.txt rename to LICENSE diff --git a/README.md b/README.md index e115861..4877cd0 100644 --- a/README.md +++ b/README.md @@ -1,460 +1,239 @@ # ms2cc -> Convert MSBuild logs to compile_commands.json for C/C++ language servers +`ms2cc` creates a +[`compile_commands.json`](https://clang.llvm.org/docs/JSONCompilationDatabase.html) +file for C and C++ projects built with Visual Studio and MSBuild. -## Table of Contents - -- [What is ms2cc?](#what-is-ms2cc) -- [Why is ms2cc useful?](#why-is-ms2cc-useful) -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Usage](#usage) -- [Editor Configuration](#editor-configuration) - - [Visual Studio Code](#visual-studio-code) - - [Other Editors](#other-editors) -- [Troubleshooting](#troubleshooting) -- [LSP and AI: Better Together](#lsp-and-ai-better-together) -- [References](#references) -- [License](#license) - -## What is ms2cc? - -ms2cc converts MSBuild build logs into [`compile_commands.json`][compile-commands] format. This compilation database is used by [language servers][lsp], and various IDEs for code intelligence features (IntelliSense, navigation, refactoring, linting). - -[MSBuild][msbuild-cli] does not generate [`compile_commands.json`][compile-commands]. ms2cc extracts compiler invocations from MSBuild's detailed logs and converts them to the standard format. - -## Why is ms2cc Useful? - -C and C++ [language servers][lsp] (like [clangd][clangd], ccls, Microsoft C/C++, etc.) need a [`compile_commands.json`][compile-commands] file because they cannot correctly understand or parse your code without the exact compiler flags that are used when building the project. - -Unlike languages such as Rust, Go, Python, or Java, C and C++ code is not self-describing. A C/C++ source file: - -- depends on preprocessor macros -- depends on include paths (-I) -- depends on system headers -- can change behavior based on platform and compiler -- may use language extensions (-std=gnu++20, -fms-extensions) -- may be compiled differently per file - -This means the same `.cpp` file can produce totally different ASTs depending on its flags. - -A language server must replicate the exact compiler invocation, or it literally cannot parse the file in the correct configuration. +Tools such as clangd and clang-tidy use this file to find your source files and +understand the same include paths, preprocessor definitions, and compiler +options used by your build. ## Requirements -- **Platform:** Windows (Windows 10/11, Windows Server) -- **Build System:** MSBuild (Visual Studio 2019, 2022, or Build Tools) -- **For Building from Source (optional):** Rust toolchain - -## Installation +- Windows +- Visual Studio 2022 or Build Tools 2022, version 17.8 or newer +- The **Desktop development with C++** workload +- Any packages and generated files required by your project -### Option A: Download Pre-built Binary +Run `ms2cc` from a command prompt where your project already builds correctly. +If your repository provides a setup script, run that script first. -1. **Download:** - - Visit the [releases page](https://github.com/freddiehaddad/ms2cc/releases) - - Download the `ms2cc-x.x.x.zip` file +## Install -2. **Extract:** - - Unzip the file to extract `ms2cc.exe` +Download the Windows executable from the +[releases page](https://github.com/freddiehaddad/ms2cc/releases), or build it +from source: -3. **Usage:** - - Run it from anywhere by adding the directory to your PATH, or - - Copy `ms2cc.exe` to your project directory - -### Option B: Build from Source - -1. **Install [Rust][rust]** (if not already installed): - - ```text - # Visit https://rustup.rs and follow the instructions - ``` - -2. **Clone the repository:** - - ```powershell - git clone https://github.com/freddiehaddad/ms2cc.git - cd ms2cc - ``` - -3. **Build the release version:** - - ```powershell - cargo build --release - ``` +```powershell +cargo build --release +``` -4. **Find the executable:** +The executable is written to `target\release\ms2cc.exe`. - ```text - The built executable will be at: target\release\ms2cc.exe - ``` +Place `ms2cc.exe` in a directory listed in `PATH`, or invoke it using its full +path. -## Quick Start +## Quick start -### Build with Detailed Logging +Open a Visual Studio Developer Command Prompt, change to your repository, and +pass `ms2cc` your solution or project: ```powershell -cd C:\path\to\your\project -msbuild YourSolution.sln /fileLogger /fileLoggerParameters:LogFile=msbuild.log;Verbosity=detailed +cd C:\path\to\repository +ms2cc .\Project.sln ``` -The `Verbosity=detailed` parameter is required. Without it, [MSBuild][msbuild-cli] doesn't log enough information. We use MSBuild's built-in file logger (`/fileLogger /fileLoggerParameters:`) rather than PowerShell redirection (`> msbuild.log`) because Windows PowerShell 5.1 writes redirected output as UTF-16 LE, which ms2cc cannot read. The `/fileLogger` approach writes the file as UTF-8 regardless of shell. (Short forms `/fl` and `/flp:` work identically.) - -> **Visual Studio IDE:** In Visual Studio 2019/2022 use **Build > Project Only > Build Only ProjectName**. When the Output window finishes scrolling, right-click inside it, choose [**Save Build Log**][vs-build-logging], and save it as `msbuild.log` with **MSBuild Project Build Log (\*.log)**. +This creates `compile_commands.json` in the current directory. -### Generate compile_commands.json +For a large repository with a top-level `.proj` file, pass only that file. +`ms2cc` follows project references automatically: ```powershell -ms2cc -i msbuild.log -o compile_commands.json -p +ms2cc .\Build.proj ``` -The `-p` flag pretty-prints the output, which is useful for the verification step below. Omit it for production use to keep the file smaller. +You do not need to list every project below it. -### Configure Your Editor - -See the [Editor Configuration](#editor-configuration) section below for your specific editor. - -### Verify the Output +Configuration and platform are optional. `ms2cc` uses the values provided by +MSBuild and the project. If the solution has more than one possible choice and +no clear default, choose one explicitly: ```powershell -Test-Path compile_commands.json -Get-Content compile_commands.json | Select-Object -First 8 -``` - -The file should exist and contain one JSON object per compiler invocation, for example: - -```json -[ - { - "directory": "C:\\path\\to\\your\\project", - "command": "CL.exe /c /nologo /W4 /std:c++20 /Iinclude \"C:\\path\\to\\your\\project\\src\\main.cpp\"", - "file": "C:\\path\\to\\your\\project\\src\\main.cpp" - } -] +ms2cc .\Project.sln --configuration Debug --platform x64 ``` -If the file is missing or empty, revisit the logging step. +## More examples -## Usage - -### Command-Line Options - -ms2cc supports several options for customizing behavior: +Write the database somewhere else: ```powershell -# Basic usage (uses defaults: msbuild.log → compile_commands.json) -ms2cc - -# Specify custom input and output paths -ms2cc -i build\debug.log -o compile_commands.json - -# Pretty-print the JSON output (useful for viewing/debugging) -ms2cc -i msbuild.log -o compile_commands.json -p - -# Quiet mode (only show errors) -ms2cc -i msbuild.log -o compile_commands.json -l error - -# Disable progress bars (useful for scripting) -ms2cc -i msbuild.log -o compile_commands.json --no-progress - -# Overwrite mode (replace instead of merging with existing database) -ms2cc -i msbuild.log -o compile_commands.json --overwrite - -# Show all available options -ms2cc --help - -# Show version -ms2cc --version +ms2cc .\Project.sln --output C:\work\compile_commands.json ``` -### Available Options - -| Option | Description | Default | -| -------------------------- | ---------------------------------------------------- | ----------------------- | -| `-i, --input-file ` | Path to MSBuild log file | `msbuild.log` | -| `-o, --output-file ` | Path to output compile_commands.json | `compile_commands.json` | -| `-l, --log-level ` | Logging level (off, error, warn, info, debug, trace) | `info` | -| `-p, --pretty-print` | Pretty-print JSON output | (disabled) | -| `--overwrite` | Replace output file instead of merging | (merge enabled) | -| `--no-progress` | Disable progress bar output | (progress bars enabled) | -| `-h, --help` | Display help information | - | -| `-V, --version` | Display version information | - | - -### Incremental Builds - -By default, ms2cc **merges** new entries into an existing `compile_commands.json` rather than replacing it. This means incremental builds work correctly — only the recompiled files are updated while entries for unchanged files are preserved. - -Entries are matched by their source file path and project directory. If a file was recompiled, its entry is updated. If a file wasn't recompiled (and therefore not in the new build log), its existing entry is left untouched. - -To start fresh and replace the entire database, use `--overwrite`: +Combine independent solutions or projects into one database: ```powershell -ms2cc -i msbuild.log -o compile_commands.json --overwrite +ms2cc .\Engine.sln .\Tools.sln --output .\compile_commands.json ``` -## Editor Configuration - -Once you've generated `compile_commands.json`, configure your editor to use it. +Pass a property required by your build: -> **Note:** The `compile_commands.json` file should be in your project root directory. - -### Visual Studio Code - -#### Using Microsoft C/C++ Extension - -1. Install the extension - 1. Open Extensions (`Ctrl+Shift+X`) - 2. Search for "C/C++" - 3. Install the official [Microsoft C/C++ extension][ms-cpp-ext] - -2. Configure - - Create or edit `.vscode/c_cpp_properties.json`: - - ```json - { - "configurations": [ - { - "name": "Win32", - "compileCommands": "${workspaceFolder}/compile_commands.json", - "intelliSenseMode": "windows-msvc-x64" - } - ], - "version": 4 - } - ``` - - > **Note:** You can place the `compile_commands.json` file in the `.vscode` directory if you prefer: - - ```json - { - "configurations": [ - { - "name": "Win32", - "compileCommands": "${workspaceFolder}/.vscode/compile_commands.json", - "intelliSenseMode": "windows-msvc-x64" - } - ], - "version": 4 - } - ``` - -3. Reload VSCode - - Press `Ctrl+Shift+P`, type "Reload Window", and press Enter. - -4. Confirm the setup by opening a C/C++ file, pressing `Ctrl+Shift+P`, running `C/C++: Log Diagnostics`, and making sure the output reports no parsing or IntelliSense errors. - -#### Using clangd - -1. Install the [clangd][clangd] extension - 1. Open VSCode Extensions (`Ctrl+Shift+X`) - 2. Search for "clangd" - 3. Install the official `llvm-vs-code-extensions.vscode-clangd` extension - 4. If prompted to download clangd, click "Download" - -2. Disable Microsoft C++ IntelliSense - - Create or edit `.vscode/settings.json` in your project: - - ```json - { - "C_Cpp.intelliSenseEngine": "disabled", - "clangd.arguments": [ - "--compile-commands-dir=${workspaceFolder}", - "--background-index", - "--clang-tidy" - ] - } - ``` - -3. Reload VSCode - - Press `Ctrl+Shift+P`, type "Reload Window", and press Enter. - -> **Note for MSVC Projects**: For optimal clangd compatibility with MSVC code, copy `.clangd.template` to `.clangd` in your project root. This configuration suppresses common MSVC-specific warnings. See [Using clangd with MSVC Projects](docs/CLANGD_USAGE.md) for details. - -### Other Editors - -Most modern editors have excellent clangd support. For detailed setup instructions including: - -- **Neovim** -- **Zed** -- **Cursor** -- **Helix** -- Other LSP-compatible editors - -See the **[Using clangd with MSVC Projects](docs/CLANGD_USAGE.md)** guide for complete configuration examples, compatibility information, and troubleshooting. - -**Quick clangd setup:** - -1. Generate `compile_commands.json` with ms2cc -2. (Optional but recommended) Copy `.clangd.template` to `.clangd` in your project root -3. Configure your editor to use clangd (see guide for editor-specific examples) - -## Troubleshooting - -### No compile_commands.json generated or file is empty +```powershell +ms2cc .\Build.proj --property BuildFlavor=Production +``` -**Cause:** MSBuild log doesn't have enough detail. +`--property` may be repeated. -**Solution:** Ensure you used `Verbosity=detailed` when building: +Use a specific MSBuild installation: ```powershell -msbuild YourSolution.sln /fileLogger /fileLoggerParameters:LogFile=msbuild.log;Verbosity=detailed +ms2cc .\Project.sln ` + --msbuild C:\BuildTools\MSBuild\Current\Bin\MSBuild.exe ``` -### Some source files are missing from compile_commands.json - -**Possible causes:** - -- MSBuild verbosity too low -- Some projects were skipped during build +Run `ms2cc --help` to see every option. -**Solutions:** +## Projects with setup scripts -1. Use `Verbosity=detailed` -2. Do a clean rebuild and use `--overwrite`: `msbuild YourSolution.sln /t:Rebuild /fileLogger /fileLoggerParameters:LogFile=msbuild.log;Verbosity=detailed; ms2cc --overwrite` +Some repositories provide a script that configures package locations, tools, or +environment variables. Run `ms2cc` from the same command prompt after running +that script: -> **Note:** With incremental builds, ms2cc merges new entries into the existing database by default. If you've done a full rebuild and want a clean database, use `--overwrite` to avoid retaining stale entries from a previous build configuration. +```cmd +cd C:\path\to\repository +call setup-build-environment.cmd +ms2cc Project.sln +``` -### Editor doesn't recognize compile_commands.json +`ms2cc` does not require changes to your project files or build scripts. -**Solution:** +## How it works -1. Ensure `compile_commands.json` is in your **project root directory** -2. Check your editor configuration (see [Editor Configuration](#editor-configuration)) -3. For clangd, verify it's installed and accessible: +`ms2cc` asks MSBuild for the C and C++ files in your project and the compiler +options for each file. It follows referenced projects, checks that every source +file has a compiler command, and then writes the database. - ```powershell - clangd --version - ``` +It reads the project settings without compiling object files, so a clean build +is not required. Projects that use C++20 modules or header units may run MSVC's +dependency scanner, but its temporary files are kept outside the project. -4. Reload/restart your editor +Each successful run replaces the output database with the complete set of +projects requested on the command line. If anything fails, `ms2cc` leaves the +existing database unchanged. -### Duplicate symbols or conflicting IntelliSense +## Using the database with clangd -**Cause:** Multiple IntelliSense engines running simultaneously. +Most editors find `compile_commands.json` automatically when it is placed at the +workspace root. You can also point clangd to its directory: -**Solution (VSCode):** Disable Microsoft C++ IntelliSense when using clangd: +```text +clangd --compile-commands-dir=C:\path\to\workspace +``` -In `.vscode/settings.json`: +For VS Code with the clangd extension: ```json { - "C_Cpp.intelliSenseEngine": "disabled" + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}", + "--background-index" + ] } ``` -### clangd not found - -**Solution:** +`ms2cc` keeps the compiler options reported by MSBuild, including precompiled +header settings. Current clangd versions understand most MSVC options and remove +output-only options themselves. Do not remove options from the database unless +clangd reports a specific problem. -1. Download clangd from [https://github.com/clangd/clangd/releases](https://github.com/clangd/clangd/releases) -2. Add to your PATH, or configure the full path in your editor settings +See [docs/CLANGD_USAGE.md](docs/CLANGD_USAGE.md) for help with MSVC precompiled +headers and clangd. -### clangd shows too many warnings with MSVC code +## FAQ -**Cause:** clangd reports some MSVC-specific warnings that aren't relevant (e.g., `#pragma optimize`, missing field initializers). +### What files can I pass to ms2cc? -**Solution:** Use the provided `.clangd` configuration template: +`ms2cc` accepts: -1. Copy `.clangd.template` to `.clangd` in your project root -2. Customize as needed (see comments in the template) -3. For detailed information, see [Using clangd with MSVC Projects](docs/CLANGD_USAGE.md) +- `.sln` and `.slnx` solution files +- `.vcxproj` Visual C++ projects +- `.proj` files that refer to other projects -The template suppresses common MSVC-specific warnings while keeping important diagnostics. +Referenced projects are followed automatically, so one top-level file is +normally enough. -## LSP and AI: Better Together +### Do I need to run a clean build first? -With the rise of AI-powered coding assistants, some developers wonder whether language-server tooling is still relevant. It is -- arguably more than before. The two solve different problems and work best in combination. +No. `ms2cc` reads the project settings directly from MSBuild. -### Right tool for the job +Packages, imported project files, and generated source files still need to +exist. Run any restore, setup, or source-generation steps required by your +repository before running `ms2cc`. -| Task | Best tool | -| ------------------------------------- | ------------------------------------------ | -| "What's the type of this variable?" | Language server | -| "Find every caller of this function" | Language server | -| "Rename this symbol everywhere" | Language server | -| "Show me errors as I type" | Language server | -| "Did my last edit break anything?" | Language server (diagnostics in ms) | -| "Jump to this symbol's definition" | Language server | -| "Write a function that does X" | LLM | -| "Why is this code buggy?" | LLM (often) | -| "Explain this 500-line file" | LLM | -| "Generate boilerplate for this design"| LLM | +### Does ms2cc modify or build my project? -Generation, exploration, and natural-language reasoning are LLM strengths. Symbol-precise queries, refactoring, and millisecond-latency feedback are language-server strengths. Neither replaces the other. +No. It reads project information through MSBuild and writes only the requested +compilation database. For C++20 modules and header units, it may run MSVC's +dependency scanner in a temporary directory, but it does not compile object +files or link the project. -### Why language servers still matter +### Do I need to specify a configuration and platform? -1. Deterministic, low-latency answers +Not when MSBuild or your project already provides a clear default. Use +`--configuration` and `--platform` when you want a specific configuration or +when a solution has more than one possible choice. - Language servers like [clangd][clangd] maintain an up-to-date AST, symbol table, and type information, and typically respond in 10-200 ms warm. LLMs are non-deterministic by design (sampling) and run 500 ms-5 s+. For typing flow, "go to definition", and refactor-rename, you want the correct answer every time, not a probabilistic best-guess. +### Why should I run the repository's setup script first? -2. Project-semantics precision +Some projects use environment variables to locate compilers, packages, SDKs, or +generated files. Running the setup script gives `ms2cc` the same environment as +the normal build. - LLMs reason over tokens, not syntax trees. Even with retrieval or agent-based navigation, they: - - lack precise understanding of type systems (templates, overload resolution, SFINAE) - - can hallucinate unseen functions or APIs - - struggle with multi-file, incremental code state - - cannot maintain a full AST-level view the way a compiler or language server can +### What happens to an existing compile_commands.json? - Language servers plug directly into the compiler toolchain; that precision isn't replaceable by probabilistic models alone. +A successful run replaces it with a complete new database for every root passed +on the command line. It is not merged with the old file. -3. Cost, privacy, and offline +If generation fails, the existing file is left unchanged. - Language servers run locally and free. LLM agents cost money per token and typically send code to a remote server. For interactive feedback (squiggles, hover, completion) you want a local tool. +### Does ms2cc support CMake, Make, or other build systems? -### Agents build *on* LSP, not instead of it +No. `ms2cc` is specifically for Visual C++ projects evaluated by MSBuild. CMake +can generate `compile_commands.json` directly, while tools such as Bear may be +more appropriate for other build systems. -Modern coding assistants -- GitHub Copilot, Cursor, Windsurf, Zed AI, Cline, Aider, Continue -- combine three layers: +### Why is a property from an MSBuild response file missing? -- **LLM** for reasoning and generation -- **Language servers** for semantic signals (types, diagnostics, symbol info, references) -- **Indexers** for global symbol search and embeddings +`ms2cc` does not automatically load `MSBuild.rsp` or `Directory.Build.rsp`. Pass +any required property explicitly: -The LLM is the loud part; the language server is the grounding part. When a chat-style assistant answers "what's the type of `foo`?", the editor passes LSP-derived type info into the prompt -- the model doesn't guess. And the agentic "edit -> check diagnostics -> fix" inner loop that makes coding agents tractable is built on LSP: without millisecond-latency diagnostics, the loop becomes "edit -> run full build -> parse stderr -> fix" with seconds-to-minutes per iteration. - -Concrete evidence that agents lean on LSP: +```powershell +ms2cc .\Build.proj --property Name=Value +``` -- **Cursor's Composer** consumes LSP signals heavily; disabling the language server visibly degrades edit quality. -- **Aider** built its own structural index (a Tree-sitter "repo-map") for setups where LSP wasn't reliable -- they needed structural signals badly enough to reinvent a subset of LSP themselves. -- The **[Model Context Protocol (MCP)][mcp]** ecosystem ships multiple LSP-as-MCP-server implementations precisely so agents can call language servers directly as tools. -- Agentic CLIs (Claude Code, Codex CLI, GitHub Copilot CLI, Cline) all leverage LSP via the editor or a wrapper when available. +### What if MSBuild cannot be found? -If LSP -- or any comparable source of structural code intelligence -- didn't exist, coding agents would be worse, not better. This isn't theoretical; it's observable in every serious agent shipping today. +Use a Visual Studio Developer Command Prompt, or pass the path explicitly with +`--msbuild`. -### Why this matters more in an AI-heavy workflow +### What if a project takes longer than ten minutes? -Coding agents write code faster than humans can hand-check it. Catching their mistakes -- type errors, broken refactors, calls to APIs that don't exist -- requires fast, accurate semantic feedback as you read and review the generated code. That feedback comes from a language server reading `compile_commands.json`. Without it, you're relying on the LLM to have been correct in the first place, which is exactly the failure mode the language server is there to catch. +Increase the timeout: -That's why ms2cc exists -- to give your C/C++ projects the language-server foundation that makes both manual coding and AI assistance dramatically more effective. Run ms2cc whenever you regenerate build logs so `compile_commands.json` stays fresh as projects, configurations, or toolchains change. +```powershell +ms2cc .\Build.proj --timeout 1200 +``` -## References +The timeout is measured in seconds. -- [Language Server Protocol (LSP)][lsp] -- [clangd - C/C++ Language Server][clangd] -- [clangd Configuration][clangd-config] -- [Using clangd with MSVC Projects](docs/CLANGD_USAGE.md) -- [`compile_commands.json` Format][compile-commands] -- [Microsoft C/C++ Extension for VSCode][ms-cpp-ext] -- [MSBuild Command-Line Reference][msbuild-cli] -- [Visual Studio Build Logging][vs-build-logging] -- [Model Context Protocol (MCP)][mcp] -- [Rust Programming Language][rust] +## Development -[lsp]: https://microsoft.github.io/language-server-protocol/ -[clangd]: https://clangd.llvm.org/ -[clangd-config]: https://clangd.llvm.org/config.html -[compile-commands]: https://clang.llvm.org/docs/JSONCompilationDatabase.html -[ms-cpp-ext]: https://marketplace.visualstudio.com/items?itemName=ms-vscode.cpptools -[msbuild-cli]: https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-command-line-reference -[vs-build-logging]: https://learn.microsoft.com/en-us/visualstudio/ide/build-log-file-visual-studio -[rust]: https://www.rust-lang.org/ -[mcp]: https://modelcontextprotocol.io/ -[LICENSE]: LICENSE.txt +```powershell +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +``` ## License -This project is licensed under the MIT License - see the [LICENSE] file for details. +MIT. See [LICENSE](LICENSE). diff --git a/docs/CLANGD_USAGE.md b/docs/CLANGD_USAGE.md index facd98b..6f33d9c 100644 --- a/docs/CLANGD_USAGE.md +++ b/docs/CLANGD_USAGE.md @@ -1,500 +1,117 @@ -# Using clangd with MSVC Projects +# Using clangd with ms2cc -This guide explains how to use clangd with MSVC projects using `compile_commands.json` generated by ms2cc. +## Generate the database -## Table of Contents - -- [Quick Start](#quick-start) -- [Compatibility Overview](#compatibility-overview) -- [Configuration](#configuration) -- [Editor Setup](#editor-setup) -- [Troubleshooting](#troubleshooting) - -## Quick Start - -### 1. Generate compile_commands.json +Run `ms2cc` from the same initialized environment used by MSBuild: ```powershell -# Build your MSVC project with detailed logging -msbuild YourProject.sln /t:Rebuild /p:Configuration=Release /p:Platform=x64 /v:detailed > msbuild.log - -# Convert to compile_commands.json -ms2cc - -# This creates compile_commands.json in the current directory +ms2cc .\YourSolution.sln -c Release --platform x64 ``` -### 2. Copy .clangd Configuration (Optional but Recommended) +For a traversal project: ```powershell -# Copy the template to your project root -copy .clangd.template .clangd -``` - -### 3. Use with Your Editor - -See [Editor Setup](#editor-setup) section below for specific editor configurations. - -## Compatibility Overview - -### ✅ What Works Well - -clangd 19.1.5+ has **excellent compatibility** with MSVC `compile_commands.json`: - -- **C++20 features**: Full support including concepts, ranges, modules -- **MSVC intrinsics**: Most intrinsics work (`_mm_*`, `__cpuid`, etc.) -- **Windows types**: `BYTE`, `DWORD`, `UINT32`, etc. -- **MSVC keywords**: `__declspec`, `__forceinline`, `__assume`, etc. -- **Templates**: Full template support including SFINAE -- **Standard Library**: Both MSVC STL and standard library headers -- **Compiler flags**: Automatically translates MSVC flags via clang-cl driver mode - -**Testing Results**: 75% of tested files compile with zero errors, 12.5% have only warnings. - -### ⚠️ Known Issues - -#### 1. MSVC-Specific `__m128i` Union Members (Rare) - -**Issue**: MSVC treats `__m128i` as a union with named members (`.m128i_u64[]`, `.m128i_i64[]`), but clang treats it as an opaque vector type. - -**Example Code**: - -```cpp -__m128i x = _mm_set_epi64x(0, 0); -uint64_t val = x.m128i_u64[0]; // ❌ Error in clangd +ms2cc .\Build.proj -c Release --platform x64 ``` -**Error**: - -```text -member reference base type '__m128i' (vector of 2 'long long' values) -is not a structure or union -``` - -**Solution**: Use portable intrinsics instead: - -```cpp -__m128i x = _mm_set_epi64x(0, 0); -uint64_t val = _mm_extract_epi64(x, 0); // ✅ Works in both MSVC and clang -``` - -**Impact**: Only affects SIMD-heavy code that directly accesses vector members. Most code uses intrinsics and works fine. - -#### 2. MSVC-Specific `#pragma optimize` (Low Impact) - -**Issue**: clangd doesn't recognize `#pragma optimize("g", on)` or similar MSVC-specific pragmas. - -**Warning**: - -```text -unexpected argument 'gt' to '#pragma optimize'; expected "" -``` - -**Solution**: Suppress in `.clangd` config (see [Configuration](#configuration) section). - -**Impact**: Just warnings, doesn't affect code parsing or functionality. - -#### 3. Precompiled Header (PCH) Incompatibility - -**Issue**: MSVC and clang use incompatible precompiled header formats. Files that rely heavily on precompiled headers may show type errors in clangd, even though they compile fine with MSVC. - -**Technical Background**: +The output defaults to `compile_commands.json` in the current directory. A +successful invocation regenerates the complete requested scope and atomically +replaces the prior file. -- MSVC generates `.pch` files in a proprietary binary format -- clang generates `.pch` files in its own binary format -- These formats are incompatible - clang cannot read MSVC's `.pch` files -- ms2cc strips `/Yu` (use PCH) and `/Fp` (PCH file path) flags to prevent clang errors -- ms2cc keeps `/FI` (force include) so the header text is still included -- However, type information compiled into MSVC's PCH binary may not be visible to clangd +## Start clangd -**Symptoms**: Type mismatch errors in files that compile successfully with MSVC: +Place the database at the workspace root or point clangd at its directory: ```text -Cannot initialize object parameter of type 'SerializationReader' -with an expression of type 'MessageReader' - -no matching constructor for initialization of 'RequestData' - -member reference type 'std::unique_ptr' is not a pointer -``` - -**Root Cause**: The code relies on type definitions and inheritance relationships that exist in MSVC's compiled PCH state. When clangd only sees `/FI stdafx.h` (the header text), it may miss transitive includes or preprocessor state from the PCH. - -**Workarounds**: - -1. **Add explicit includes** (recommended - makes code more maintainable): - - ```cpp - // At the top of affected .cpp file - #include "RequiredHeader.h" - ``` - -2. **Suppress errors for specific files** in `.clangd`: - - ```yaml - --- - If: - PathMatch: .*AffectedFile\.cpp - Diagnostics: - Suppress: - - ovl_no_viable_function_in_init - - ovl_no_viable_function_in_call - - member_function_call_bad_type - ``` - -**Impact**: - -- Most files work fine, even with PCH -- Files with heavy PCH dependencies may show false-positive type errors -- This is a fundamental limitation due to incompatible binary formats -- Does not affect MSVC compilation - only clangd's code analysis - -**Why ms2cc strips PCH flags**: - -- Keeping `/Yu` and `/Fp` would cause clang to fail: "PCH file was compiled with a different compiler" -- Stripping them allows clangd to work for most files, with occasional false errors in PCH-heavy code - -### 📊 Compatibility Statistics - -Based on testing a large production MSVC codebase (16,134 files, C++20): - -| Category | Success Rate | -| ---------------------- | ------------ | -| General C++ code | ~99% | -| Template-heavy code | 100% | -| Standard intrinsics | 95%+ | -| \_\_declspec usage | 100% | -| Windows API calls | 100% | -| MSVC-specific patterns | ~80% | - -## Known Warnings (Can Be Suppressed) - -These warnings are common in MSVC codebases but don't indicate real problems: - -- `-Wmissing-field-initializers`: Aggregate initialization style -- `-Wignored-pragmas`: MSVC-specific pragmas -- `-Wunused-const-variable`: Intentional in headers -- `-Wignored-qualifiers`: `const` on return types (legacy pattern) -- `-Wnull-pointer-subtraction`: Some low-level code patterns - -**Solution**: Use the provided `.clangd` configuration to suppress these. - -## Configuration - -### .clangd Configuration File - -The `.clangd` file controls clangd behavior. Place it in your project root (same directory as compile_commands.json). - -**Minimal Configuration**: - -```yaml -Diagnostics: - Suppress: - - ignored-pragmas - - missing-field-initializers - UnusedIncludes: None -``` - -**Full Template**: See [.clangd.template](../.clangd.template) for a comprehensive configuration with comments. - -### Why You Need .clangd - -While `compile_commands.json` works out-of-the-box with clangd, the `.clangd` config file: - -1. **Suppresses noise**: Filters out MSVC-specific warnings that aren't relevant -2. **Improves performance**: Can disable expensive features on large codebases -3. **Customizes behavior**: Tune hover info, inlay hints, diagnostics, etc. - -## Editor Setup - -### Neovim (with nvim-lspconfig) - -**Prerequisites**: Install clangd and ensure it's in your PATH. - -```lua --- In your init.lua or LSP config file -require('lspconfig').clangd.setup{ - cmd = { - "clangd", - "--background-index", - "--clang-tidy", - "--header-insertion=never", - "--completion-style=detailed", - "--function-arg-placeholders", - }, - filetypes = { "c", "cpp", "objc", "objcpp" }, - root_dir = require('lspconfig').util.root_pattern( - 'compile_commands.json', - '.clangd', - '.git' - ), -} +clangd --compile-commands-dir=C:\path\to\workspace ``` -**Testing**: Open a C++ file and verify: - -```vim -:LspInfo -" Should show clangd attached - -:lua vim.lsp.buf.hover() -" Should show type information -``` - -### Zed - -Zed has built-in clangd support. Just ensure: - -1. `compile_commands.json` is in your project root -2. clangd is installed and in PATH -3. (Optional) `.clangd` config file is present - -**Settings** (in `settings.json`): - -The default settings should work fine. The example below might be helpful if you need to troubleshoot issues. - -```json -{ - "lsp": { - "clangd": { - "binary": { - "arguments": [ - "--log", "verbose", - "--pretty", - "--completion-style", "detailed", - "--all-scopes-completion", - "--background-index", - "--clang-tidy"] - } - } - } -} -``` - -### Cursor - -Cursor uses VS Code's architecture, so configuration is similar to VS Code: - -1. Install the "clangd" extension (llvm-vs-code-extensions.vscode-clangd) -2. Disable the default C++ extension to avoid conflicts: - - ```json - { - "C_Cpp.intelliSenseEngine": "Disabled" - } - ``` - -3. Ensure compile_commands.json is in your workspace root - -**Settings** (in settings.json): +For VS Code: ```json { + "C_Cpp.intelliSenseEngine": "disabled", "clangd.arguments": [ - "--background-index", - "--clang-tidy", - "--header-insertion=never" - ], - "clangd.path": "clangd" + "--compile-commands-dir=${workspaceFolder}", + "--background-index" + ] } ``` -### VS Code - -1. Install the "clangd" extension (llvm-vs-code-extensions.vscode-clangd) -2. Disable C/C++ IntelliSense to avoid conflicts: - - ```json - { - "C_Cpp.intelliSenseEngine": "Disabled" - } - ``` - -### Helix - -Helix has built-in clangd support. Edit `~/.config/helix/languages.toml`: - -```toml -[[language]] -name = "cpp" -language-server = { command = "clangd", args = ["--background-index"] } -``` - -## Troubleshooting - -### clangd Not Finding `compile_commands.json` - -**Symptoms**: No code completion, no diagnostics, "clangd is not running" messages. - -**Solutions**: - -1. Verify file location: - - ```powershell - # compile_commands.json should be in project root or build directory - dir compile_commands.json - ``` +## MSVC arguments -2. Check clangd logs for "Loaded compilation database" message +`ms2cc` preserves the command returned by Visual C++ design-time evaluation, +including `/Fo`, `/Fd`, `/Yc`, `/Yu`, `/Fp`, and `/FI`. -3. Ensure your editor's working directory is correct +Clangd performs its own frontend-only command adjustment and removes output +arguments. Recent clangd versions also understand clang-cl PCH switches. Do not +remove PCH switches unless diagnostics prove that a specific project's +MSVC-generated PCH is incompatible. -4. Try specifying `--compile-commands-dir` explicitly: - - ```powershell - clangd --compile-commands-dir=path/to/dir - ``` - -### Too Many Warnings/Errors - -**Solution**: Use `.clangd` configuration to suppress warnings: +If that occurs, use a narrowly scoped `.clangd` rule: ```yaml -Diagnostics: - Suppress: - - ignored-pragmas - - missing-field-initializers - - unused-const-variable - UnusedIncludes: None +If: + PathMatch: path/to/affected/files/.* +CompileFlags: + Remove: + - /Yc* + - /Yu* + - /Fp* ``` -### "File not in compilation database" Error +`/Yu
` can supply implicit-header semantics. Before removing it, verify +that the source includes the header directly or that the command also contains +an equivalent `/FI
` argument. -**Cause**: The file wasn't compiled during the MSBuild run, so it's not in compile_commands.json. - -**Solutions**: - -1. Do a full rebuild: - - ```powershell - msbuild YourProject.sln /t:Rebuild /v:detailed > build.log - ms2cc build.log - ``` - -2. For header-only files, clangd will infer settings from nearby .cpp files - -### Slow Indexing on Large Codebases - -For projects with 10,000+ files: - -**In .clangd**: +## Troubleshooting -```yaml -Index: - Background: 2 # Reduce background threads - StandardLibrary: No # Don't index STL -``` +### MSBuild cannot be found -**Or disable background indexing**: +Run from a Visual Studio Developer Command Prompt or pass the desired executable: ```powershell -clangd --background-index=false +ms2cc .\App.vcxproj --msbuild C:\BuildTools\MSBuild\Current\Bin\MSBuild.exe ``` -### Hard Errors from MSVC-Specific Code - -If you encounter hard errors from MSVC-specific patterns (like `__m128i` union access): +Repository-provided `.cmd` wrappers are supported. -**Option 1**: Fix the code to be portable: +### Configuration is ambiguous -```cpp -// Instead of: -val = vec.m128i_u64[0]; +Select it explicitly: -// Use: -val = _mm_extract_epi64(vec, 0); +```powershell +ms2cc .\App.sln -c Debug --platform x64 ``` -**Option 2**: Suppress errors for specific files in `.clangd`: +### Project imports or generated files are missing -```yaml ---- -If: - PathMatch: .*problematic_file\.cpp -Diagnostics: - Suppress: - - typecheck_member_reference_struct_union -``` - -### Type Errors in Files Using Precompiled Headers +Restore packages and run the repository's normal generation/bootstrap step +first. `ms2cc` does not compile object code or recreate build-generated +prerequisites. It may invoke MSVC's dependency-only scanner for C++20 modules +and header units; scanner failures are reported rather than producing commands +with unresolved module mappings. -**Symptoms**: clangd shows type mismatch errors or "no matching function" errors in files that compile fine with MSVC. +### Extraction times out -**Example Errors**: +Increase the per-process bound: -```text -Cannot initialize object parameter of type 'BaseClass' with an expression of type 'DerivedClass' -no matching constructor for initialization of 'ClassName' -no matching function for call to 'FunctionName' -member reference type 'std::unique_ptr' is not a pointer +```powershell +ms2cc .\LargeTree.proj --timeout 1200 ``` -**Cause**: The file relies on type definitions that exist in MSVC's precompiled header binary, which clangd cannot read. See [Precompiled Header (PCH) Incompatibility](#3-precompiled-header-pch-incompatibility) for details. - -**Solutions** (in order of preference): - -1. **Make includes explicit** (best practice): - - ```cpp - // In your .cpp file, add the missing header explicitly - #include "MissingHeader.h" - ``` - - This makes your code less dependent on PCH side effects and more maintainable. - -2. **Suppress errors for specific files**: - - Edit your `.clangd` file to add a per-file suppression: - - ```yaml - # At the end of the file - --- - If: - PathMatch: .*YourFileName\.cpp - Diagnostics: - Suppress: - - ovl_no_viable_function_in_init - - ovl_no_viable_function_in_call - - member_function_call_bad_type - - no_member - ``` - -3. **Accept the limitation**: Use clangd for files that work well, and rely on MSVC IntelliSense for PCH-heavy files that show errors. - -**When to use each approach**: - -- **Explicit includes**: When you can modify the source files (best long-term solution) -- **Per-file suppression**: For legacy code you cannot or should not modify -- **Accept limitation**: When the file is heavily PCH-dependent and adding includes isn't practical - -### clangd Crashes or Hangs - -1. Update to clangd 19.1.5 or newer -2. Check clangd logs: `clangd --log=verbose` -3. Report issue with minimal reproduction - -## Performance Tips - -For best performance on large MSVC projects: - -1. **Use SSD**: Place project and compile_commands.json on SSD -2. **Tune indexing**: Reduce background threads in `.clangd` -3. **Disable features**: Turn off UnusedIncludes, MissingIncludes -4. **Update clangd**: Newer versions have performance improvements - -## Getting Help - -- **clangd Issues**: -- **ms2cc Issues**: -- **clangd Documentation**: +The tool emits periodic progress heartbeats and terminates the complete MSBuild +process tree when the bound is exceeded. -## Summary +### clangd reports missing standard headers -**TL;DR**: +Confirm that `arguments` contains the effective Visual C++ and Windows SDK +include directories and that the compiler path exists. Run `ms2cc` from the +initialized build environment so repository-specific `INCLUDE` settings are +available. -1. Generate `compile_commands.json` with ms2cc -2. Copy `.clangd.template` to `.clangd` in your project root -3. Configure your editor to use clangd -4. Enjoy modern C++ tooling with MSVC projects! +### Existing entries disappear -clangd works **well** with MSVC projects when using properly generated `compile_commands.json` from ms2cc. Most issues are cosmetic warnings that can be suppressed with a simple `.clangd` config. +This is intentional. Design-time extraction produces the complete selected +scope, so each run replaces the database rather than merging stale commands. +Pass every desired project or solution root in the same invocation. diff --git a/src/adapter.rs b/src/adapter.rs new file mode 100644 index 0000000..a12c3de --- /dev/null +++ b/src/adapter.rs @@ -0,0 +1,352 @@ +use anyhow::{Context, Result}; +use std::{ + collections::BTreeMap, + fs, + path::{Path, PathBuf}, +}; + +use crate::{model::ProjectInstance, paths}; + +const ORIGINAL_IMPORTS_MARKER: &str = ""; + +const ADAPTER: &str = r#" + + + <_GlobalPropertiesToRemoveFromProjectReferences>$(_GlobalPropertiesToRemoveFromProjectReferences);Ms2ccScratchDirectory + $(GetProjectInfoForReferenceDependsOn);Ms2ccAddPublicBMIsToProjectsInfoForReference + + + + <_Ms2ccUseOnlySourcesInPublicDirectories + Condition="'$(AllProjectBMIsArePublic)' != 'true'">true + + + <_Ms2ccPublicModules Include="@(ClCompile)" + Condition="'%(ClCompile.CompileAs)' == 'CompileAsCppModule'" /> + <_Ms2ccPublicHeaderUnits Include="@(ClCompile)" + Condition="'%(ClCompile.CompileAs)' == 'CompileAsHeaderUnit'" /> + + + + + + + + + + Module + + + HeaderUnit + + <_Ms2ccPublicModules Remove="@(_Ms2ccPublicModules)" /> + <_Ms2ccPublicHeaderUnits Remove="@(_Ms2ccPublicHeaderUnits)" /> + <_Ms2ccPublicModuleBmis Remove="@(_Ms2ccPublicModuleBmis)" /> + <_Ms2ccPublicHeaderUnitBmis Remove="@(_Ms2ccPublicHeaderUnitBmis)" /> + + + + + + + + + $(Ms2ccReferencedHeaderUnits);%(ClCompile.AdditionalHeaderUnitDependencies) + + + $(Ms2ccReferencedModules);%(ClCompile.AdditionalModuleDependencies) + + + + + + + + + $(Ms2ccScratchDirectory)\ + + + + + + + + + + + + + + $(INCLUDE);$(_Ms2ccReferencedHeaderUnitDirectories) + $(IncludePath);$(_Ms2ccReferencedHeaderUnitDirectories) + + + + + <_Ms2ccSourcesWithDependencies Remove="@(_Ms2ccSourcesWithDependencies)" /> + + + + + <_Ms2ccUseOnlyPublicSources + Condition="'$(AllProjectBMIsArePublic)' != 'true'">true + + + <_Ms2ccOwnPublicModules Include="@(ClCompile)" + Condition="'%(ClCompile.CompileAs)' == 'CompileAsCppModule'" /> + <_Ms2ccOwnPublicHeaderUnits Include="@(ClCompile)" + Condition="'%(ClCompile.CompileAs)' == 'CompileAsHeaderUnit'" /> + + + + + + + + + + + <_Ms2ccHasProjectReferences Condition="'@(ProjectReference)' != ''">true + + + <_Ms2ccResult Include="ms2cc-command"> + Command + %(ClCommandLines.Identity) + %(ClCommandLines.WorkingDirectory) + %(ClCommandLines.ToolPath) + %(ClCommandLines.Files) + %(ClCommandLines.ConfigurationOptions) + %(ClCommandLines.ContainsReferencedProjectsData) + $(MSBuildProjectFullPath) + $(Configuration) + $(Platform) + $(CLToolExe) + $(CLToolPath) + $(ExecutablePath) + + <_Ms2ccResult Include="ms2cc-directories"> + Directories + %(_ProjectDirectories.IncludePath) + %(_ProjectDirectories.ExternalIncludePath) + %(_ProjectDirectories.FrameworkIncludePath) + $(CL) + $(_CL_) + $(EXTERNAL_INCLUDE) + $(UseEnv) + $(_Ms2ccHasProjectReferences) + $(ReferenceResolutionDisabled) + $(MSBuildProjectFullPath) + + <_Ms2ccResult Include="ms2cc-source"> + Source + %(ClCompile.FullPath) + %(ClCompile.ExcludedFromBuild) + %(ClCompile.ConfigurationOptions) + %(ClCompile.ForcedUsingFiles) + %(ClCompile.CompileAs) + %(ClCompile.CompilerIteration) + %(ClCompile.ModuleOutputFile) + %(ClCompile.ObjectFileName) + %(ClCompile.GenerateSourceDependencies) + %(ClCompile.SourceDependenciesFile) + $(MSBuildProjectFullPath) + + <_Ms2ccResult Include="ms2cc-public-header-unit"> + PublicHeaderUnit + %(_Ms2ccOwnPublicHeaderUnitBmis.FullPath) + $(MSBuildProjectFullPath) + + <_Ms2ccResult Include="ms2cc-public-module"> + PublicModule + %(_Ms2ccOwnPublicModuleBmis.FullPath) + $(MSBuildProjectFullPath) + + + + +"#; + +pub fn write(directory: &Path, projects: &[ProjectInstance]) -> Result { + let path = directory.join("ms2cc.targets"); + let mut originals: BTreeMap> = BTreeMap::new(); + for project in projects { + if project.force_import_after_cpp_targets.is_some() { + originals + .entry(paths::key(&project.path)) + .or_default() + .push(project); + } + } + let imports = originals + .into_values() + .flat_map(|instances| { + let distinguish_instances = instances.len() > 1; + let property_names: Vec<_> = instances + .iter() + .flat_map(|instance| instance.properties.keys()) + .fold(BTreeMap::new(), |mut names, name| { + names + .entry(name.to_ascii_lowercase()) + .or_insert_with(|| name.clone()); + names + }) + .into_values() + .collect(); + instances.into_iter().filter_map(move |instance| { + let original = instance.force_import_after_cpp_targets.as_deref()?; + let mut condition = format!( + "'$(MSBuildProjectFullPath)' == '{}'", + escape_msbuild_condition_literal(&paths::display(&instance.path)) + ); + if distinguish_instances { + for name in &property_names { + let value = instance + .properties + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + .unwrap_or_default(); + condition.push_str(&format!( + " And '$({name})' == '{}'", + escape_msbuild_condition_literal(value) + )); + } + } + condition.push_str(&format!( + " And Exists('{}')", + escape_msbuild_condition_literal(original) + )); + Some(format!( + " ", + escape_xml(original), + escape_xml(&condition) + )) + }) + }) + .collect::>() + .join("\r\n"); + let contents = ADAPTER.replace(ORIGINAL_IMPORTS_MARKER, &imports); + fs::write(&path, contents) + .with_context(|| format!("failed to write temporary adapter {}", path.display()))?; + Ok(path) +} + +fn escape_msbuild_condition_literal(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if matches!(character, '%' | '$' | '@' | '(' | ')' | ';' | '\'') { + escaped.push_str(&format!("%{:02X}", character as u32)); + } else { + escaped.push(character); + } + } + escaped +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Properties; + use tempfile::TempDir; + + #[test] + fn force_import_dispatch_distinguishes_absent_instance_properties() { + let directory = TempDir::new().unwrap(); + let project_path = PathBuf::from(r"C:\project\app.vcxproj"); + let projects = vec![ + ProjectInstance { + path: project_path.clone(), + properties: Properties::from([("Configuration".to_owned(), "Release".to_owned())]), + force_import_after_cpp_targets: Some(r"C:\project\release.targets".to_owned()), + }, + ProjectInstance { + path: project_path, + properties: Properties::from([ + ("Configuration".to_owned(), "Release".to_owned()), + ("Flavor".to_owned(), "Special".to_owned()), + ]), + force_import_after_cpp_targets: Some(r"C:\project\special.targets".to_owned()), + }, + ]; + + let contents = fs::read_to_string(write(directory.path(), &projects).unwrap()).unwrap(); + assert!(contents.contains( + "release.targets\" Condition=\"'$(MSBuildProjectFullPath)' == 'C:\\project\\app.vcxproj' And '$(Configuration)' == 'Release' And '$(Flavor)' == ''" + )); + assert!(contents.contains( + "special.targets\" Condition=\"'$(MSBuildProjectFullPath)' == 'C:\\project\\app.vcxproj' And '$(Configuration)' == 'Release' And '$(Flavor)' == 'Special'" + )); + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..47def63 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,115 @@ +use anyhow::{Result, bail}; +use clap::Parser; +use std::path::{Path, PathBuf}; + +use crate::paths; + +const PACKAGE_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); + +#[derive(Debug, Clone)] +pub struct Property { + pub name: String, + pub value: String, +} + +fn parse_property(value: &str) -> Result { + let Some((name, property_value)) = value.split_once('=') else { + return Err("expected NAME=VALUE".to_owned()); + }; + if name.is_empty() { + return Err("property name cannot be empty".to_owned()); + } + if !name + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '_' | '.')) + { + return Err(format!("invalid MSBuild property name '{name}'")); + } + Ok(Property { + name: name.to_owned(), + value: property_value.to_owned(), + }) +} + +#[derive(Debug, Parser)] +#[command(version, about = PACKAGE_DESCRIPTION)] +pub struct Args { + /// Visual C++ project, solution, or traversal project roots + #[arg(required = true, value_name = "PROJECT_OR_SOLUTION")] + pub inputs: Vec, + + /// Output compilation database + #[arg(short = 'o', long, default_value = "compile_commands.json")] + pub output: PathBuf, + + /// MSBuild configuration to evaluate + #[arg(short = 'c', long)] + pub configuration: Option, + + /// MSBuild platform to evaluate + #[arg(long)] + pub platform: Option, + + /// Additional MSBuild property + #[arg(long = "property", value_name = "NAME=VALUE", value_parser = parse_property)] + pub properties: Vec, + + /// MSBuild executable or command wrapper + #[arg(long)] + pub msbuild: Option, + + /// Maximum seconds without process completion + #[arg( + long, + default_value_t = 600, + value_name = "SECONDS", + value_parser = clap::value_parser!(u64).range(1..) + )] + pub timeout: u64, +} + +impl Args { + pub fn resolve_inputs(&self, cwd: &Path) -> Result> { + let mut resolved = Vec::with_capacity(self.inputs.len()); + for input in &self.inputs { + let absolute = if input.is_absolute() { + input.clone() + } else { + cwd.join(input) + }; + if !absolute.is_file() { + bail!( + "input does not exist or is not a file: {}", + absolute.display() + ); + } + let extension = absolute + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !matches!( + extension.to_ascii_lowercase().as_str(), + "vcxproj" | "proj" | "sln" | "slnx" + ) { + bail!( + "unsupported input '{}'; expected .vcxproj, .proj, .sln, or .slnx", + absolute.display() + ); + } + resolved.push(paths::absolute_lexical(&absolute, cwd)); + } + Ok(resolved) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn property_requires_name_and_value() { + assert!(parse_property("ProductEdition=Enterprise").is_ok()); + assert!(parse_property("ProductEdition").is_err()); + assert!(parse_property("bad-name=value").is_err()); + } +} diff --git a/src/command_line.rs b/src/command_line.rs new file mode 100644 index 0000000..1a58c3f --- /dev/null +++ b/src/command_line.rs @@ -0,0 +1,846 @@ +use anyhow::{Context, Result, bail}; +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +use crate::paths; + +const MAX_RESPONSE_DEPTH: usize = 16; + +#[derive(Debug, Clone, Default)] +pub struct CompilerEnvironment { + pub leading_arguments: Vec, + pub trailing_arguments: Vec, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct IncludeDirectories<'a> { + pub regular: &'a [PathBuf], + pub external: &'a [PathBuf], + pub framework: &'a [PathBuf], +} + +impl CompilerEnvironment { + pub fn from_values(leading: Option<&str>, trailing: Option<&str>) -> Result { + Ok(Self { + leading_arguments: leading + .filter(|value| !value.is_empty()) + .map(tokenize) + .transpose()? + .unwrap_or_default(), + trailing_arguments: trailing + .filter(|value| !value.is_empty()) + .map(tokenize) + .transpose()? + .unwrap_or_default(), + }) + } +} + +pub fn tokenize(command_line: &str) -> Result> { + let mut arguments = Vec::new(); + let characters: Vec = command_line.chars().collect(); + let mut index = 0; + + while index < characters.len() { + while index < characters.len() && characters[index].is_whitespace() { + index += 1; + } + if index == characters.len() { + break; + } + + let mut argument = String::new(); + let mut quoted = false; + while index < characters.len() { + let mut backslashes = 0; + while index < characters.len() && characters[index] == '\\' { + backslashes += 1; + index += 1; + } + + if index < characters.len() && characters[index] == '"' { + argument.push_str(&"\\".repeat(backslashes / 2)); + if backslashes % 2 == 1 { + argument.push('"'); + index += 1; + } else if quoted && index + 1 < characters.len() && characters[index + 1] == '"' { + argument.push('"'); + index += 2; + } else { + quoted = !quoted; + index += 1; + } + continue; + } + + argument.push_str(&"\\".repeat(backslashes)); + if index == characters.len() || (!quoted && characters[index].is_whitespace()) { + break; + } + argument.push(characters[index]); + index += 1; + } + + if quoted { + bail!("unterminated quote in compiler command line"); + } + arguments.push(argument); + } + + Ok(arguments) +} + +pub fn expand_response_files( + arguments: &[String], + working_directory: &Path, +) -> Result> { + expand_response_files_inner(arguments, working_directory, &mut HashSet::new(), 0) +} + +fn expand_response_files_inner( + arguments: &[String], + working_directory: &Path, + active: &mut HashSet, + depth: usize, +) -> Result> { + if depth > MAX_RESPONSE_DEPTH { + bail!("response-file nesting exceeds {MAX_RESPONSE_DEPTH} levels"); + } + + let mut expanded = Vec::new(); + for argument in arguments { + let Some(path_text) = argument.strip_prefix('@') else { + expanded.push(argument.clone()); + continue; + }; + if path_text.is_empty() { + expanded.push(argument.clone()); + continue; + } + + let path = + paths::absolute_lexical(Path::new(path_text.trim_matches('"')), working_directory); + let key = paths::key(&path); + if !active.insert(key.clone()) { + bail!("response-file cycle detected at {}", path.display()); + } + let bytes = fs::read(&path) + .with_context(|| format!("failed to read response file {}", path.display()))?; + let contents = decode_response_file(&bytes) + .with_context(|| format!("failed to decode response file {}", path.display()))?; + let nested = tokenize(&contents)?; + expanded.extend(expand_response_files_inner( + &nested, + working_directory, + active, + depth + 1, + )?); + active.remove(&key); + } + Ok(expanded) +} + +fn decode_response_file(bytes: &[u8]) -> Result { + if let Some(bytes) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(bytes.to_vec()).context("invalid UTF-8 response file"); + } + if let Some(bytes) = bytes.strip_prefix(&[0xFF, 0xFE]) { + if bytes.len() % 2 != 0 { + bail!("odd byte count in UTF-16LE response file"); + } + let words = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&words).context("invalid UTF-16LE response file"); + } + if let Some(bytes) = bytes.strip_prefix(&[0xFE, 0xFF]) { + if bytes.len() % 2 != 0 { + bail!("odd byte count in UTF-16BE response file"); + } + let words = bytes + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&words).context("invalid UTF-16BE response file"); + } + String::from_utf8(bytes.to_vec()).context("response file is not UTF-8 and has no UTF-16 BOM") +} + +pub fn effective_arguments( + executable: String, + command_line: &str, + source_files: &[PathBuf], + working_directory: &Path, + include_directories: IncludeDirectories<'_>, + environment: &CompilerEnvironment, +) -> Result>> { + let mut command_arguments = expand_response_files(&tokenize(command_line)?, working_directory)?; + if command_arguments + .first() + .is_some_and(|argument| is_compiler_executable(argument)) + { + command_arguments.remove(0); + } + + let source_lookup: HashMap = source_files + .iter() + .map(|source| { + ( + paths::key(&paths::absolute_lexical(source, working_directory)), + source.clone(), + ) + }) + .collect(); + let (base, source_modes) = + remove_source_operands(&command_arguments, working_directory, &source_lookup); + + let mut prefix = Vec::new(); + prefix.push(executable); + prefix.extend(environment.leading_arguments.clone()); + prefix.extend(base); + + let mut result = Vec::with_capacity(source_files.len()); + for source in source_files { + let absolute = paths::absolute_lexical(source, working_directory); + let source_text = paths::display(&absolute); + let mut arguments = prefix.clone(); + if let Some(mode) = source_modes.get(&paths::key(&absolute)) { + arguments.push(format!("{mode}{source_text}")); + } else { + arguments.push(source_text); + } + arguments.extend(environment.trailing_arguments.clone()); + append_missing_include_paths(&mut arguments, include_directories, working_directory); + result.push(arguments); + } + Ok(result) +} + +pub fn add_missing_forced_using( + arguments: &mut Vec, + source_file: &Path, + forced_using_files: &[PathBuf], + working_directory: &Path, +) { + let mut known = HashSet::new(); + let mut index = 1; + while index < arguments.len() { + if arguments[index].eq_ignore_ascii_case("/FU") { + if let Some(path) = arguments.get(index + 1) { + known.insert(paths::key(&paths::absolute_lexical( + Path::new(path), + working_directory, + ))); + index += 2; + continue; + } + } else if arguments[index].starts_with("/FU") && arguments[index].len() > 3 { + known.insert(paths::key(&paths::absolute_lexical( + Path::new(&arguments[index][3..]), + working_directory, + ))); + } + index += 1; + } + + let source_key = paths::key(source_file); + let source_index = arguments + .iter() + .position(|argument| { + paths::key(Path::new(argument)) == source_key + || argument.get(..3).is_some_and(|prefix| { + ["/Tc", "-Tc", "/Tp", "-Tp"] + .iter() + .any(|expected| prefix.eq_ignore_ascii_case(expected)) + }) && argument + .get(3..) + .is_some_and(|value| paths::key(Path::new(value)) == source_key) + }) + .unwrap_or(arguments.len()); + let additions: Vec<_> = forced_using_files + .iter() + .map(|path| paths::absolute_lexical(path, working_directory)) + .filter(|path| known.insert(paths::key(path))) + .map(|path| format!("/FU{}", paths::display(&path))) + .collect(); + arguments.splice(source_index..source_index, additions); +} + +pub fn materialize_module_outputs( + arguments: &mut Vec, + source_file: &Path, + module_output_file: Option<&Path>, + object_file_name: Option<&Path>, + source_dependencies_file: Option<&Path>, + working_directory: &Path, +) -> Result<()> { + let scan_index = arguments + .iter() + .position(|argument| argument.eq_ignore_ascii_case("/scanModules")) + .with_context(|| { + format!( + "resolved module command for {} has no /scanModules placeholder", + source_file.display() + ) + })?; + arguments.remove(scan_index); + + let module_output_file = module_output_file.with_context(|| { + format!( + "resolved module command for {} has no module output file", + source_file.display() + ) + })?; + let object_file_name = object_file_name.with_context(|| { + format!( + "resolved module command for {} has no object output file", + source_file.display() + ) + })?; + replace_path_option( + arguments, + "/ifcOutput", + module_output_file, + working_directory, + source_file, + )?; + let object_end = replace_path_option( + arguments, + "/Fo", + object_file_name, + working_directory, + source_file, + )?; + + if let Some(path) = source_dependencies_file { + if has_option(arguments, "/sourceDependencies") { + replace_path_option( + arguments, + "/sourceDependencies", + path, + working_directory, + source_file, + )?; + } else { + let path = paths::absolute_lexical(path, working_directory); + arguments.splice( + object_end..object_end, + ["/sourceDependencies".to_owned(), paths::display(&path)], + ); + } + } + Ok(()) +} + +fn has_option(arguments: &[String], option: &str) -> bool { + arguments.iter().any(|argument| { + argument.eq_ignore_ascii_case(option) + || argument + .get(..option.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(option)) + && argument.len() > option.len() + }) +} + +fn replace_path_option( + arguments: &mut [String], + option: &str, + path: &Path, + working_directory: &Path, + source_file: &Path, +) -> Result { + let path = paths::display(&paths::absolute_lexical(path, working_directory)); + for index in 1..arguments.len() { + if arguments[index].eq_ignore_ascii_case(option) { + let operand = arguments.get_mut(index + 1).with_context(|| { + format!( + "{option} has no operand in module command for {}", + source_file.display() + ) + })?; + *operand = path; + return Ok(index + 2); + } + if arguments[index] + .get(..option.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(option)) + && arguments[index].len() > option.len() + { + arguments[index] = format!("{option}{path}"); + return Ok(index + 1); + } + } + bail!( + "resolved module command for {} has no {option} option", + source_file.display() + ) +} + +fn is_compiler_executable(argument: &str) -> bool { + Path::new(argument) + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| { + value.eq_ignore_ascii_case("cl") + || value.eq_ignore_ascii_case("cl.exe") + || value.eq_ignore_ascii_case("clang-cl") + || value.eq_ignore_ascii_case("clang-cl.exe") + }) +} + +fn remove_source_operands( + arguments: &[String], + working_directory: &Path, + source_lookup: &HashMap, +) -> (Vec, HashMap) { + let mut base = Vec::new(); + let mut source_modes = HashMap::new(); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if matches!(argument.as_str(), "/Tc" | "-Tc" | "/Tp" | "-Tp") && index + 1 < arguments.len() + { + let candidate = + paths::absolute_lexical(Path::new(&arguments[index + 1]), working_directory); + let key = paths::key(&candidate); + if source_lookup.contains_key(&key) { + source_modes.insert(key, argument.clone()); + index += 2; + continue; + } + } + + if takes_next_operand(argument) && index + 1 < arguments.len() { + base.push(argument.clone()); + base.push(arguments[index + 1].clone()); + index += 2; + continue; + } + + if let Some(prefix) = argument.get(..3) + && (matches_ignore_ascii_case(prefix, "/Tc") + || matches_ignore_ascii_case(prefix, "-Tc") + || matches_ignore_ascii_case(prefix, "/Tp") + || matches_ignore_ascii_case(prefix, "-Tp")) + { + let candidate = paths::absolute_lexical(Path::new(&argument[3..]), working_directory); + let key = paths::key(&candidate); + if source_lookup.contains_key(&key) { + source_modes.insert(key, prefix.to_owned()); + index += 1; + continue; + } + } + + let candidate = paths::absolute_lexical(Path::new(argument), working_directory); + if source_lookup.contains_key(&paths::key(&candidate)) { + index += 1; + continue; + } + base.push(argument.clone()); + index += 1; + } + (base, source_modes) +} + +fn takes_next_operand(argument: &str) -> bool { + [ + "/D", + "-D", + "/I", + "-I", + "/U", + "-U", + "/FI", + "/FU", + "/AI", + "/external:I", + "/sourceDependencies", + "/scanDependencies", + "/ifcOutput", + "/reference", + "/headerUnit", + ] + .iter() + .any(|option| argument.eq_ignore_ascii_case(option)) +} + +fn matches_ignore_ascii_case(left: &str, right: &str) -> bool { + left.eq_ignore_ascii_case(right) +} + +fn append_missing_include_paths( + arguments: &mut Vec, + include_directories: IncludeDirectories<'_>, + working_directory: &Path, +) { + let mut known = HashSet::new(); + let mut index = 0; + while index < arguments.len() { + let argument = &arguments[index]; + if (argument.eq_ignore_ascii_case("/I") + || argument.eq_ignore_ascii_case("-I") + || argument.eq_ignore_ascii_case("/external:I")) + && let Some(path) = arguments.get(index + 1) + { + known.insert(paths::key(&paths::absolute_lexical( + Path::new(path), + working_directory, + ))); + index += 2; + continue; + } + for prefix in ["/external:I", "/I", "-I"] { + if argument.len() > prefix.len() + && argument + .get(..prefix.len()) + .is_some_and(|value| value.eq_ignore_ascii_case(prefix)) + { + known.insert(paths::key(&paths::absolute_lexical( + Path::new(&argument[prefix.len()..]), + working_directory, + ))); + break; + } + } + index += 1; + } + + let external: HashSet<_> = include_directories + .external + .iter() + .map(|path| paths::key(&paths::absolute_lexical(path, working_directory))) + .collect(); + let ignore_standard = arguments + .iter() + .any(|argument| argument.eq_ignore_ascii_case("/X") || argument.eq_ignore_ascii_case("-X")); + + if !ignore_standard { + for include in include_directories.regular { + let absolute = paths::absolute_lexical(include, working_directory); + if known.insert(paths::key(&absolute)) { + let option = if external.contains(&paths::key(&absolute)) { + "/external:I" + } else { + "/I" + }; + arguments.push(format!("{option}{}", paths::display(&absolute))); + } + } + for include in include_directories.framework { + let absolute = paths::absolute_lexical(include, working_directory); + if known.insert(paths::key(&absolute)) { + arguments.push(format!("/I{}", paths::display(&absolute))); + } + } + } + for include in include_directories.external { + let absolute = paths::absolute_lexical(include, working_directory); + if known.insert(paths::key(&absolute)) { + arguments.push(format!("/external:I{}", paths::display(&absolute))); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use std::fs; + use tempfile::TempDir; + + fn quote(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_owned(); + } + let mut output = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + output.push_str(&"\\".repeat(backslashes * 2 + 1)); + output.push('"'); + backslashes = 0; + } + _ => { + output.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + output.push(character); + } + } + } + output.push_str(&"\\".repeat(backslashes * 2)); + output.push('"'); + output + } + + #[test] + fn tokenizes_windows_backslashes_and_quotes() { + assert_eq!( + tokenize(r#"/I"C:\Program Files\inc" /DNAME=\"value\" "a b.cpp""#).unwrap(), + vec![r"/IC:\Program Files\inc", r#"/DNAME="value""#, "a b.cpp"] + ); + } + + #[test] + fn rejects_unterminated_quotes() { + assert!(tokenize(r#"/I"broken"#).is_err()); + } + + #[test] + fn tokenizes_quoted_trailing_backslash_without_corrupting_prefixes() { + assert_eq!( + tokenize(r#"/Fo"C:\project\src\lib\obj\x64\\" /Gd"#).unwrap(), + vec![r"/FoC:\project\src\lib\obj\x64\", "/Gd"] + ); + } + + #[test] + fn splits_sources_using_authoritative_file_list() { + let working = Path::new(r"C:\project"); + let sources = vec![PathBuf::from("a.cpp"), PathBuf::from("b.cpp")]; + let result = effective_arguments( + r"C:\tools\cl.exe".to_owned(), + r#"/c /D VALUE /TP"#, + &sources, + working, + IncludeDirectories::default(), + &CompilerEnvironment::default(), + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].last().unwrap(), r"C:\project\a.cpp"); + assert_eq!(result[1].last().unwrap(), r"C:\project\b.cpp"); + } + + #[test] + fn source_looking_option_operands_are_not_removed() { + let working = Path::new(r"C:\project"); + let sources = vec![PathBuf::from("actual.cpp")]; + let result = effective_arguments( + r"C:\tools\cl.exe".to_owned(), + r#"/c /FI generated.cpp /D value.cpp actual.cpp"#, + &sources, + working, + IncludeDirectories::default(), + &CompilerEnvironment::default(), + ) + .unwrap(); + assert!( + result[0] + .windows(2) + .any(|pair| pair == ["/FI", "generated.cpp"]) + ); + assert!(result[0].windows(2).any(|pair| pair == ["/D", "value.cpp"])); + } + + #[test] + fn ignore_standard_includes_preserves_only_external_directories() { + let working = Path::new(r"C:\project"); + let sources = vec![PathBuf::from("main.cpp")]; + let result = effective_arguments( + r"C:\tools\cl.exe".to_owned(), + r#"/c -X /D名=1 main.cpp"#, + &sources, + working, + IncludeDirectories { + regular: &[PathBuf::from(r"C:\regular")], + external: &[PathBuf::from(r"C:\external")], + framework: &[PathBuf::from(r"C:\framework")], + }, + &CompilerEnvironment::default(), + ) + .unwrap(); + + assert!(!result[0].iter().any(|value| value.contains(r"C:\regular"))); + assert!( + !result[0] + .iter() + .any(|value| value.contains(r"C:\framework")) + ); + assert!( + result[0] + .iter() + .any(|value| value == r"/external:IC:\external") + ); + } + + #[test] + fn overlapping_external_directories_keep_external_classification() { + let working = Path::new(r"C:\project"); + let sources = vec![PathBuf::from("main.cpp")]; + let external = PathBuf::from(r"C:\toolchain\include"); + let regular = PathBuf::from(r"C:\project\include"); + let result = effective_arguments( + r"C:\tools\cl.exe".to_owned(), + "main.cpp", + &sources, + working, + IncludeDirectories { + regular: &[external.clone(), regular.clone()], + external: &[external], + framework: &[], + }, + &CompilerEnvironment::default(), + ) + .unwrap(); + + assert!( + result[0] + .iter() + .any(|value| value == r"/external:IC:\toolchain\include") + ); + assert!( + result[0] + .iter() + .any(|value| value == r"/IC:\project\include") + ); + } + + #[test] + fn materializes_missing_forced_using_files_before_the_source() { + let working = Path::new(r"C:\project"); + let source = Path::new(r"C:\project\main.cpp"); + let mut arguments = vec![ + r"C:\tools\cl.exe".to_owned(), + r"/FUC:\refs\existing.dll".to_owned(), + source.display().to_string(), + ]; + add_missing_forced_using( + &mut arguments, + source, + &[ + PathBuf::from(r"C:\refs\existing.dll"), + PathBuf::from(r"C:\refs\new.dll"), + ], + working, + ); + assert_eq!( + arguments, + vec![ + r"C:\tools\cl.exe", + r"/FUC:\refs\existing.dll", + r"/FUC:\refs\new.dll", + r"C:\project\main.cpp", + ] + ); + } + + #[test] + fn replaces_design_time_module_placeholders_with_compile_outputs() { + let source = Path::new(r"C:\project\Sleeper.h"); + let mut arguments = vec![ + "cl.exe".to_owned(), + "/scanModules".to_owned(), + "/c".to_owned(), + "/ifcOutput".to_owned(), + "obj\\".to_owned(), + "/Foobj\\".to_owned(), + "/Gd".to_owned(), + source.display().to_string(), + ]; + + materialize_module_outputs( + &mut arguments, + source, + Some(Path::new(r"obj\Sleeper.h.ifc")), + Some(Path::new(r"obj\Sleeper.h.obj")), + Some(Path::new(r"obj\Sleeper.h.ifc.d.json")), + Path::new(r"C:\project"), + ) + .unwrap(); + + assert_eq!( + arguments, + vec![ + "cl.exe", + "/c", + "/ifcOutput", + r"C:\project\obj\Sleeper.h.ifc", + r"/FoC:\project\obj\Sleeper.h.obj", + "/sourceDependencies", + r"C:\project\obj\Sleeper.h.ifc.d.json", + "/Gd", + r"C:\project\Sleeper.h", + ] + ); + } + + #[test] + fn expands_nested_response_files_from_the_invocation_directory() { + let temp = TempDir::new().unwrap(); + fs::write(temp.path().join("inner.rsp"), r#"/DVALUE=1"#).unwrap(); + fs::write( + temp.path().join("outer.rsp"), + r#"@inner.rsp /I"with space""#, + ) + .unwrap(); + let result = expand_response_files(&["@outer.rsp".to_owned()], temp.path()).unwrap(); + assert_eq!(result, vec!["/DVALUE=1", "/Iwith space"]); + } + + #[test] + fn rejects_response_file_cycles() { + let temp = TempDir::new().unwrap(); + fs::write(temp.path().join("a.rsp"), "@b.rsp").unwrap(); + fs::write(temp.path().join("b.rsp"), "@a.rsp").unwrap(); + assert!(expand_response_files(&["@a.rsp".to_owned()], temp.path()).is_err()); + } + + #[test] + fn expands_utf16_response_files() { + let temp = TempDir::new().unwrap(); + let mut bytes = vec![0xFF, 0xFE]; + for word in "/DUNICODE_VALUE=1".encode_utf16() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + fs::write(temp.path().join("utf16.rsp"), bytes).unwrap(); + assert_eq!( + expand_response_files(&["@utf16.rsp".to_owned()], temp.path()).unwrap(), + vec!["/DUNICODE_VALUE=1"] + ); + } + + proptest! { + #[test] + fn tokenization_round_trips_windows_arguments( + arguments in prop::collection::vec( + prop::collection::vec( + prop_oneof![ + Just('a'), + Just('Z'), + Just('0'), + Just(' '), + Just('\\'), + Just('"'), + Just(':'), + Just('-'), + ], + 0..24, + ) + .prop_map(|characters| characters.into_iter().collect::()), + 0..12, + ) + ) { + let command_line = arguments + .iter() + .map(|argument| quote(argument)) + .collect::>() + .join(" "); + prop_assert_eq!(tokenize(&command_line).unwrap(), arguments); + } + } +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..bd74aba --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,302 @@ +use anyhow::{Context, Result, bail}; +use rayon::prelude::*; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + path::{Path, PathBuf}, +}; + +use crate::{ + model::{ProjectInstance, Properties}, + msbuild::MsbuildRunner, + paths, query, + solution::{self, set_property}, +}; + +#[derive(Debug)] +pub struct Discovery { + pub projects: Vec, + pub visited_projects: Vec, + pub references: HashMap>, +} + +pub fn discover( + runner: &MsbuildRunner, + inputs: &[PathBuf], + base_properties: &Properties, +) -> Result { + let mut queue = VecDeque::new(); + let mut scheduled = HashSet::new(); + let mut visited: HashMap = HashMap::new(); + for input in inputs { + let roots = solution::projects(input, base_properties)?; + for path in roots.protected_paths { + visited.insert(paths::key(&path), path); + } + for project in roots.projects { + if scheduled.insert(project.key()) { + queue.push_back(project); + } + } + } + + let mut native: HashMap = HashMap::new(); + let mut references: HashMap> = HashMap::new(); + let mut processed = 0usize; + + while !queue.is_empty() { + let mut batch = Vec::with_capacity(64); + while batch.len() < 64 + && let Some(requested) = queue.pop_front() + { + batch.push(requested); + } + let evaluations: Vec<_> = batch + .par_iter() + .map(|project| evaluate_project(runner, project)) + .collect(); + + for (requested, evaluation) in batch.into_iter().zip(evaluations) { + let evaluation = evaluation?; + processed += 1; + if processed == 1 || processed.is_multiple_of(100) { + eprintln!( + "Discovered {processed} project instance(s); {} pending", + queue.len() + ); + } + + let mut evaluated = requested.clone(); + visited.insert(paths::key(&evaluated.path), evaluated.path.clone()); + if let Some(configuration) = query::property(&evaluation, "Configuration") { + set_property(&mut evaluated.properties, "Configuration", configuration); + } + if let Some(platform) = query::property(&evaluation, "Platform") { + set_property(&mut evaluated.properties, "Platform", platform); + } + evaluated.force_import_after_cpp_targets = + query::property(&evaluation, "ForceImportAfterCppTargets") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .map(|path| { + paths::absolute_lexical( + &path, + evaluated.path.parent().unwrap_or(Path::new(".")), + ) + }) + .map(|path| { + if !path.is_file() { + bail!( + "{} defines missing ForceImportAfterCppTargets file {}", + evaluated.path.display(), + path.display() + ); + } + Ok(paths::display(&path)) + }) + .transpose()?; + scheduled.insert(evaluated.key()); + + if extension_is(&evaluated.path, "vcxproj") + || !query::item_groups(&evaluation, "ClCompile")?.is_empty() + { + native.insert(evaluated.key(), evaluated.clone()); + } + + let reference_group = if extension_is(&evaluated.path, "vcxproj") { + "_MSBuildProjectReferenceExistent" + } else { + "ProjectReference" + }; + for item in query::item_groups(&evaluation, reference_group)? { + if query::metadata(item, "BuildReference") + .or_else(|| query::metadata(item, "Build")) + .is_some_and(|value| value.eq_ignore_ascii_case("false")) + { + continue; + } + let full_path = query::metadata(item, "FullPath") + .or_else(|| query::metadata(item, "Identity")) + .context("ProjectReference is missing both FullPath and Identity")?; + let reference_path = PathBuf::from(full_path); + let reference_path = if reference_path.is_absolute() { + reference_path + } else { + evaluated + .path + .parent() + .unwrap_or(Path::new(".")) + .join(reference_path) + }; + if !reference_path.is_file() { + bail!( + "{} references missing project {}", + evaluated.path.display(), + reference_path.display() + ); + } + if !is_traversable(&reference_path) { + continue; + } + + let mut properties = evaluated.properties.clone(); + for metadata_name in ["GlobalPropertiesToRemove", "UndefineProperties"] { + if let Some(names) = query::metadata(item, metadata_name) { + for name in names + .split(';') + .map(str::trim) + .filter(|name| !name.is_empty()) + { + if let Some(existing) = properties + .keys() + .find(|candidate| candidate.eq_ignore_ascii_case(name)) + .cloned() + { + properties.remove(&existing); + } + } + } + } + for metadata_name in [ + "SetConfiguration", + "SetPlatform", + "SetTargetFramework", + "AdditionalProperties", + ] { + if let Some(assignments) = query::metadata(item, metadata_name) { + apply_assignments(&mut properties, assignments)?; + } + } + + let referenced = ProjectInstance { + path: paths::absolute_lexical( + &reference_path, + evaluated.path.parent().unwrap_or(Path::new(".")), + ), + properties, + force_import_after_cpp_targets: None, + }; + references + .entry(evaluated.key()) + .or_default() + .push(referenced.key()); + if scheduled.insert(referenced.key()) { + queue.push_back(referenced); + } + } + } + } + + let mut result: Vec<_> = native.into_values().collect(); + result.sort_by_key(ProjectInstance::key); + let mut visited_projects: Vec<_> = visited.into_values().collect(); + visited_projects.sort_by_key(|path| paths::key(path)); + for children in references.values_mut() { + children.sort(); + children.dedup(); + } + Ok(Discovery { + projects: result, + visited_projects, + references, + }) +} + +fn evaluate_project( + runner: &MsbuildRunner, + project: &ProjectInstance, +) -> Result { + let mut arguments = property_arguments(&project.properties); + arguments.push("-getProperty:Configuration,Platform,ForceImportAfterCppTargets".to_owned()); + if extension_is(&project.path, "vcxproj") { + arguments.push(property_argument("BuildProjectReferences", "false")); + arguments.push("-target:PrepareProjectReferences".to_owned()); + arguments.push("-getItem:_MSBuildProjectReferenceExistent".to_owned()); + } else { + arguments.push("-getItem:ProjectReference,ClCompile".to_owned()); + } + runner.query_json( + &project.path, + &arguments, + &format!("Evaluating {}", project.path.display()), + ) +} + +pub fn property_arguments(properties: &Properties) -> Vec { + properties + .iter() + .map(|(name, value)| property_argument(name, value)) + .collect() +} + +pub fn property_argument(name: &str, value: &str) -> String { + format!("-property:{name}={}", escape_property_value(value)) +} + +fn escape_property_value(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + if matches!( + character, + '%' | '*' | '?' | '@' | '$' | '(' | ')' | ';' | '\'' | ',' + ) { + escaped.push_str(&format!("%{:02X}", character as u32)); + } else { + escaped.push(character); + } + } + escaped +} + +fn apply_assignments(properties: &mut Properties, assignments: &str) -> Result<()> { + for assignment in assignments + .split(';') + .map(str::trim) + .filter(|value| !value.is_empty()) + { + let Some((name, value)) = assignment.split_once('=') else { + bail!("invalid ProjectReference property assignment '{assignment}'"); + }; + set_property(properties, name.trim(), value.trim()); + } + Ok(()) +} + +fn is_traversable(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.to_ascii_lowercase().ends_with("proj")) +} + +fn extension_is(path: &Path, expected: &str) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.eq_ignore_ascii_case(expected)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn applies_reference_property_assignments_case_insensitively() { + let mut properties = Properties::from([("Configuration".to_owned(), "Debug".to_owned())]); + apply_assignments(&mut properties, "Configuration=Release;Platform=x64;").unwrap(); + assert_eq!(properties.len(), 2); + assert_eq!( + properties + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("configuration")) + .unwrap() + .1, + "Release" + ); + } + + #[test] + fn escapes_msbuild_property_separators_and_escape_sequences() { + assert_eq!( + property_argument("Probe", "a;b,c%value"), + "-property:Probe=a%3Bb%2Cc%25value" + ); + } +} diff --git a/src/extractor.rs b/src/extractor.rs new file mode 100644 index 0000000..ac64469 --- /dev/null +++ b/src/extractor.rs @@ -0,0 +1,764 @@ +use anyhow::{Context, Result, bail}; +use rayon::prelude::*; +use serde_json::Value; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + env, fs, + path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, +}; + +use crate::{ + command_line, + discovery::{property_argument, property_arguments}, + model::{CommandRecord, CompileCommand, ProjectInstance, SourceKey}, + msbuild::MsbuildRunner, + paths, query, +}; + +const TARGET_NAME: &str = "Ms2ccGetCompileCommands"; +static SCRATCH_ID: AtomicUsize = AtomicUsize::new(1); + +#[derive(Debug, Default)] +struct SourceMetadata { + source: PathBuf, + forced_using: Vec, + compile_as: String, + compiler_iteration: String, + module_output_file: Option, + object_file_name: Option, + generate_source_dependencies: bool, + source_dependencies_file: Option, +} + +#[derive(Debug, Clone)] +enum PublicBmi { + HeaderUnit { source: PathBuf, bmi: PathBuf }, + Module { name: String, bmi: PathBuf }, +} + +#[derive(Debug, Default)] +struct ReferencedBmis { + header_units: Vec<(PathBuf, PathBuf)>, + modules: Vec<(String, PathBuf)>, +} + +#[derive(Debug)] +pub struct Extraction { + pub commands: Vec, + expected_sources: Vec, + public_bmis: Vec, +} + +impl Extraction { + pub fn prove_complete(&self) -> Result<()> { + let expected = counts(self.expected_sources.iter().cloned()); + let actual = counts(self.commands.iter().map(CommandRecord::source_key)); + let ambiguous: Vec<_> = expected + .iter() + .chain(actual.iter()) + .filter(|(_, count)| **count > 1) + .collect(); + if !ambiguous.is_empty() { + let mut message = format!( + "structured extraction is ambiguous for {} project/source key(s)", + ambiguous.len() + ); + for (key, count) in ambiguous.into_iter().take(20) { + message.push_str(&format!( + "\n x{count}: {} [{}] {}", + key.project, key.properties, key.source + )); + } + bail!(message); + } + let missing = subtract(&expected, &actual); + let extra = subtract(&actual, &expected); + if missing.is_empty() && extra.is_empty() { + return Ok(()); + } + + let mut message = format!( + "structured extraction is incomplete: {} missing and {} extra project/source occurrence(s)", + missing.values().sum::(), + extra.values().sum::() + ); + for (label, entries) in [("missing", missing), ("extra", extra)] { + for (key, count) in entries.into_iter().take(20) { + message.push_str(&format!( + "\n {label} x{count}: {} [{}] {}", + key.project, key.properties, key.source + )); + } + } + bail!(message) + } + + pub fn expected_source_count(&self) -> usize { + self.expected_sources.len() + } +} + +pub fn extract_all( + runner: &MsbuildRunner, + adapter_path: &Path, + projects: &[ProjectInstance], + references: &HashMap>, +) -> Result { + let completed = AtomicUsize::new(0); + let total = projects.len(); + let results: Vec> = projects + .par_iter() + .map(|project| { + let result = extract_project(runner, adapter_path, project, None); + let current = completed.fetch_add(1, Ordering::Relaxed) + 1; + if current == total || current.is_multiple_of(50) { + eprintln!("Extracted {current}/{total} project instance(s)"); + } + result + }) + .collect(); + + let mut project_extractions: Vec = + results.into_iter().collect::>>()?; + let public_by_project: HashMap<_, _> = projects + .iter() + .zip(&project_extractions) + .map(|(project, extraction)| (project.key(), extraction.public_bmis.clone())) + .collect(); + let reruns: Vec<_> = projects + .par_iter() + .enumerate() + .filter_map(|(index, project)| { + let mut referenced = ReferencedBmis::default(); + for child in references.get(&project.key()).into_iter().flatten() { + for public in public_by_project.get(child).into_iter().flatten() { + match public { + PublicBmi::HeaderUnit { source, bmi } => { + referenced.header_units.push((source.clone(), bmi.clone())); + } + PublicBmi::Module { name, bmi } => { + referenced.modules.push((name.clone(), bmi.clone())); + } + } + } + } + deduplicate_referenced_bmis(&mut referenced); + (!referenced.header_units.is_empty() || !referenced.modules.is_empty()).then(|| { + ( + index, + extract_project(runner, adapter_path, project, Some(&referenced)), + ) + }) + }) + .collect(); + for (index, result) in reruns { + project_extractions[index] = result?; + } + + let mut extraction = Extraction { + commands: Vec::new(), + expected_sources: Vec::new(), + public_bmis: Vec::new(), + }; + for mut project in project_extractions { + extraction.commands.append(&mut project.commands); + extraction + .expected_sources + .append(&mut project.expected_sources); + extraction.public_bmis.append(&mut project.public_bmis); + } + Ok(extraction) +} + +fn extract_project( + runner: &MsbuildRunner, + adapter_path: &Path, + project: &ProjectInstance, + referenced_bmis: Option<&ReferencedBmis>, +) -> Result { + let mut arguments = property_arguments(&project.properties); + arguments.push(property_argument("DesignTimeBuild", "true")); + arguments.push(property_argument("BuildingInsideVisualStudio", "true")); + arguments.push(property_argument("BuildProjectReferences", "false")); + arguments.push(property_argument("BuildingProject", "true")); + arguments.push(property_argument( + "AlwaysResolveReferencesDesignTime", + "true", + )); + arguments.push(property_argument( + "ShouldUnsetParentConfigurationAndPlatform", + "false", + )); + arguments.push(property_argument("_FindDependencies", "true")); + arguments.push(property_argument( + "FindDependenciesOfExternallyResolvedReferences", + "true", + )); + if let Some(referenced_bmis) = referenced_bmis { + arguments.extend(referenced_bmi_arguments(referenced_bmis)); + } + let scratch_id = SCRATCH_ID.fetch_add(1, Ordering::Relaxed); + let scratch_directory = runner.temporary_path(format!("module-scan-{scratch_id}")); + arguments.push(property_argument( + "Ms2ccScratchDirectory", + &scratch_directory.to_string_lossy(), + )); + arguments.push(property_argument( + "ForceImportAfterCppTargets", + &adapter_path.to_string_lossy(), + )); + arguments.push(format!("-target:{TARGET_NAME}")); + arguments.push(format!("-getTargetResult:{TARGET_NAME}")); + + let document = runner.query_json( + &project.path, + &arguments, + &format!("Extracting {}", project.path.display()), + )?; + parse_project_result(&document, project, &scratch_directory) + .with_context(|| format!("failed to decode commands for {}", project.path.display())) +} + +fn parse_project_result( + document: &Value, + project: &ProjectInstance, + scratch_directory: &Path, +) -> Result { + let items = query::target_items(document, TARGET_NAME)?; + let mut include_paths = Vec::new(); + let mut external_include_paths = Vec::new(); + let mut framework_include_paths = Vec::new(); + let mut environment = command_line::CompilerEnvironment::default(); + let mut synthetic_source_counts: HashMap = HashMap::new(); + let mut has_project_references = false; + let mut reference_resolution_disabled = false; + let mut expected_sources = Vec::new(); + let mut metadata_by_source: HashMap = HashMap::new(); + let mut public_header_units = HashSet::new(); + let mut public_modules = HashSet::new(); + let mut command_items = Vec::new(); + + for item in items { + let kind = query::metadata(item, "Kind").unwrap_or_default(); + if matches!( + kind, + "Directories" | "Source" | "Command" | "PublicHeaderUnit" | "PublicModule" + ) { + let returned_project = query::metadata(item, "ProjectFile") + .context("adapter item is missing ProjectFile metadata")?; + if paths::key(Path::new(returned_project)) != paths::key(&project.path) { + bail!( + "adapter returned data for {} while evaluating {}", + returned_project, + project.path.display() + ); + } + } + match kind { + "Directories" => { + include_paths.extend( + query::metadata(item, "IncludePath") + .map(split_msbuild_list) + .unwrap_or_default() + .into_iter() + .map(PathBuf::from), + ); + let use_env = query::metadata(item, "UseEnv") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + external_include_paths.extend( + query::metadata( + item, + if use_env { + "EnvironmentExternalInclude" + } else { + "ExternalIncludePath" + }, + ) + .map(split_msbuild_list) + .unwrap_or_default() + .into_iter() + .map(PathBuf::from), + ); + framework_include_paths.extend( + query::metadata(item, "FrameworkIncludePath") + .map(split_msbuild_list) + .unwrap_or_default() + .into_iter() + .map(PathBuf::from), + ); + environment = command_line::CompilerEnvironment::from_values( + query::metadata(item, "EnvironmentCL"), + query::metadata(item, "EnvironmentTrailingCL"), + )?; + has_project_references = query::metadata(item, "HasProjectReferences") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + reference_resolution_disabled = + query::metadata(item, "ReferenceResolutionDisabled") + .is_some_and(|value| value.eq_ignore_ascii_case("true")); + } + "Source" => { + if query::metadata(item, "ExcludedFromBuild") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + continue; + } + let source = query::metadata(item, "Source") + .context("source adapter item is missing Source metadata")?; + if query::metadata(item, "ConfigurationOptions") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + *synthetic_source_counts + .entry(paths::key(Path::new(source))) + .or_insert(0) += 1; + continue; + } + expected_sources.push(SourceKey::new( + &project.path, + &project.properties, + Path::new(source), + )); + metadata_by_source.insert( + paths::key(Path::new(source)), + SourceMetadata { + source: PathBuf::from(source), + forced_using: query::metadata(item, "ForcedUsingFiles") + .map(split_msbuild_list) + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(), + compile_as: query::metadata(item, "CompileAs") + .unwrap_or_default() + .to_owned(), + compiler_iteration: query::metadata(item, "CompilerIteration") + .unwrap_or_default() + .to_owned(), + module_output_file: nonempty_path(item, "ModuleOutputFile"), + object_file_name: nonempty_path(item, "ObjectFileName"), + generate_source_dependencies: query::metadata( + item, + "GenerateSourceDependencies", + ) + .is_some_and(|value| value.eq_ignore_ascii_case("true")), + source_dependencies_file: nonempty_path(item, "SourceDependenciesFile"), + }, + ); + } + "PublicHeaderUnit" => { + if let Some(bmi) = query::metadata(item, "Bmi") { + public_header_units.insert(paths::key(Path::new(bmi))); + } + } + "PublicModule" => { + if let Some(bmi) = query::metadata(item, "Bmi") { + public_modules.insert(paths::key(Path::new(bmi))); + } + } + "Command" => command_items.push(item), + _ => {} + } + } + + deduplicate_paths(&mut include_paths); + deduplicate_paths(&mut external_include_paths); + deduplicate_paths(&mut framework_include_paths); + let module_names = read_module_names(scratch_directory)?; + let mut public_bmis = Vec::new(); + for (source, metadata) in &metadata_by_source { + let Some(output) = &metadata.module_output_file else { + continue; + }; + let output_key = paths::key(output); + if public_header_units.contains(&output_key) + && metadata + .compile_as + .eq_ignore_ascii_case("CompileAsHeaderUnit") + { + public_bmis.push(PublicBmi::HeaderUnit { + source: metadata.source.clone(), + bmi: output.clone(), + }); + } else if public_modules.contains(&output_key) + && metadata + .compile_as + .eq_ignore_ascii_case("CompileAsCppModule") + { + let name = module_names.get(source).with_context(|| { + format!("could not determine the public module name for {source}") + })?; + public_bmis.push(PublicBmi::Module { + name: name.clone(), + bmi: output.clone(), + }); + } + } + let mut commands = Vec::new(); + for item in command_items { + if has_project_references + && (reference_resolution_disabled + || !query::metadata(item, "ContainsReferencedProjectsData") + .is_some_and(|value| value.eq_ignore_ascii_case("true"))) + { + bail!( + "MSBuild did not provide referenced-project compiler data for {}", + project.path.display() + ); + } + let working_directory = query::metadata(item, "WorkingDirectory") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + project + .path + .parent() + .unwrap_or(Path::new(".")) + .to_path_buf() + }); + let working_directory = paths::absolute_lexical( + &working_directory, + project.path.parent().unwrap_or(Path::new(".")), + ); + let mut files: Vec = query::metadata(item, "Files") + .unwrap_or_default() + .split(';') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(unescape_msbuild) + .map(PathBuf::from) + .collect(); + if query::metadata(item, "ConfigurationOptions") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + { + files.retain(|source| { + let key = paths::key(&paths::absolute_lexical(source, &working_directory)); + !synthetic_source_counts.contains_key(&key) || metadata_by_source.contains_key(&key) + }); + } + if files.is_empty() || files.iter().all(|path| is_shader(path)) { + continue; + } + + let executable = resolve_compiler(item, &working_directory)?; + let command_line = query::metadata(item, "CommandLine") + .context("command adapter item is missing CommandLine metadata")?; + let argument_sets = command_line::effective_arguments( + paths::display(&executable), + command_line, + &files, + &working_directory, + command_line::IncludeDirectories { + regular: &include_paths, + external: &external_include_paths, + framework: &framework_include_paths, + }, + &environment, + )?; + + for (source, mut arguments) in files.iter().zip(argument_sets) { + let absolute_source = paths::absolute_lexical(source, &working_directory); + if let Some(metadata) = metadata_by_source.get(&paths::key(&absolute_source)) { + command_line::add_missing_forced_using( + &mut arguments, + &absolute_source, + &metadata.forced_using, + &working_directory, + ); + if metadata.compiler_iteration.eq_ignore_ascii_case("Modules") { + let source_dependencies_file = + if metadata.generate_source_dependencies { + Some(metadata.source_dependencies_file.as_deref().context( + "module source is missing SourceDependenciesFile metadata", + )?) + } else { + None + }; + command_line::materialize_module_outputs( + &mut arguments, + &absolute_source, + metadata.module_output_file.as_deref(), + metadata.object_file_name.as_deref(), + source_dependencies_file, + &working_directory, + )?; + } + } + if arguments + .iter() + .any(|argument| argument.eq_ignore_ascii_case("/scanModules")) + { + bail!( + "module dependency scan did not resolve a compile command for {}", + absolute_source.display() + ); + } + commands.push(CommandRecord { + command: CompileCommand { + directory: paths::display(&working_directory), + arguments, + file: paths::display(&absolute_source), + }, + project: project.path.clone(), + properties: project.properties.clone(), + }); + } + } + + Ok(Extraction { + commands, + expected_sources, + public_bmis, + }) +} + +fn referenced_bmi_arguments(referenced: &ReferencedBmis) -> Vec { + let header_units = referenced + .header_units + .iter() + .map(|(source, bmi)| format!("{}={}", paths::display(source), paths::display(bmi))) + .collect::>(); + let header_bmis = referenced + .header_units + .iter() + .map(|(_, bmi)| paths::display(bmi)) + .collect::>(); + let modules = referenced + .modules + .iter() + .map(|(name, bmi)| format!("{name}={}", paths::display(bmi))) + .collect::>(); + let module_bmis = referenced + .modules + .iter() + .map(|(_, bmi)| paths::display(bmi)) + .collect::>(); + + [ + ("Ms2ccReferencedHeaderUnits", header_units.join(";")), + ("Ms2ccReferencedHeaderUnitBMIs", header_bmis.join(";")), + ("Ms2ccReferencedModules", modules.join(";")), + ("Ms2ccReferencedModuleBMIs", module_bmis.join(";")), + ] + .into_iter() + .filter(|(_, value)| !value.is_empty()) + .map(|(name, value)| property_argument(name, &value)) + .collect() +} + +fn deduplicate_referenced_bmis(referenced: &mut ReferencedBmis) { + let mut headers = HashSet::new(); + referenced + .header_units + .retain(|(source, bmi)| headers.insert((paths::key(source), paths::key(bmi)))); + let mut modules = HashSet::new(); + referenced + .modules + .retain(|(name, bmi)| modules.insert((name.to_ascii_lowercase(), paths::key(bmi)))); +} + +fn read_module_names(directory: &Path) -> Result> { + let mut result = HashMap::new(); + if !directory.is_dir() { + return Ok(result); + } + for entry in fs::read_dir(directory).with_context(|| { + format!( + "failed to read module scan directory {}", + directory.display() + ) + })? { + let path = entry?.path(); + if !path + .file_name() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.to_ascii_lowercase().ends_with(".module.json")) + { + continue; + } + let document: Value = serde_json::from_slice( + &fs::read(&path) + .with_context(|| format!("failed to read module scan {}", path.display()))?, + ) + .with_context(|| format!("failed to parse module scan {}", path.display()))?; + if let Some(data) = document.get("Data").or_else(|| document.get("data")) { + let source = data + .get("Source") + .or_else(|| data.get("source")) + .and_then(Value::as_str); + let module = data + .get("ProvidedModule") + .or_else(|| data.get("provided-module")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if let (Some(source), Some(module)) = (source, module) { + result.insert(paths::key(Path::new(source)), module.to_owned()); + } + } + for rule in document + .get("rules") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + for provided in rule + .get("provides") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let source = provided.get("source-path").and_then(Value::as_str); + let module = provided + .get("logical-name") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()); + if let (Some(source), Some(module)) = (source, module) { + result.insert(paths::key(Path::new(source)), module.to_owned()); + } + } + } + } + Ok(result) +} + +fn nonempty_path(item: &Value, name: &str) -> Option { + query::metadata(item, name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +fn resolve_compiler(item: &Value, working_directory: &Path) -> Result { + let tool_exe = query::metadata(item, "CLToolExe") + .filter(|value| !value.is_empty()) + .unwrap_or("cl.exe"); + let mut candidates = Vec::new(); + if let Some(tool_path) = query::metadata(item, "ToolPath").filter(|value| !value.is_empty()) { + let path = PathBuf::from(tool_path); + if path.is_dir() { + candidates.push(path.join(tool_exe)); + } else { + candidates.push(path.clone()); + candidates.push(path.join(tool_exe)); + } + } + if let Some(tool_path) = query::metadata(item, "CLToolPath").filter(|value| !value.is_empty()) { + candidates.push(PathBuf::from(tool_path).join(tool_exe)); + } + if let Some(executable_path) = + query::metadata(item, "ExecutablePath").filter(|value| !value.is_empty()) + { + candidates.extend( + split_msbuild_list(executable_path) + .into_iter() + .map(PathBuf::from) + .map(|path| path.join(tool_exe)), + ); + } + if let Some(path) = find_on_path(tool_exe) { + candidates.push(path); + } + + for candidate in &candidates { + let absolute = paths::absolute_lexical(candidate, working_directory); + if absolute.is_file() { + return Ok(absolute); + } + } + + bail!( + "could not resolve compiler '{}'; candidates: {}", + tool_exe, + candidates + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + ) +} + +fn find_on_path(name: &str) -> Option { + env::split_paths(&env::var_os("PATH")?).find_map(|directory| { + let candidate = directory.join(name); + candidate.is_file().then_some(candidate) + }) +} + +fn split_msbuild_list(value: &str) -> Vec { + value + .split(';') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(unescape_msbuild) + .collect() +} + +fn unescape_msbuild(value: &str) -> String { + let bytes = value.as_bytes(); + let mut result = Vec::with_capacity(value.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' + && index + 2 < bytes.len() + && let Ok(hex) = u8::from_str_radix(&value[index + 1..index + 3], 16) + { + result.push(hex); + index += 3; + } else { + result.push(bytes[index]); + index += 1; + } + } + String::from_utf8(result).unwrap_or_else(|_| value.to_owned()) +} + +fn is_shader(path: &Path) -> bool { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "hlsl" | "fx" | "hlsli")) +} + +fn deduplicate_paths(values: &mut Vec) { + let mut seen = HashSet::new(); + values.retain(|path| seen.insert(paths::key(path))); +} + +fn counts(values: impl IntoIterator) -> BTreeMap { + let mut result = BTreeMap::new(); + for value in values { + *result.entry(value).or_insert(0) += 1; + } + result +} + +fn subtract( + left: &BTreeMap, + right: &BTreeMap, +) -> BTreeMap { + left.iter() + .filter_map(|(key, left_count)| { + let difference = left_count.saturating_sub(*right.get(key).unwrap_or(&0)); + (difference > 0).then_some((key.clone(), difference)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unescapes_msbuild_hex_sequences() { + assert_eq!(unescape_msbuild(r"C:\a%20b\c"), r"C:\a b\c"); + } + + #[test] + fn multiset_subtraction_preserves_duplicate_counts() { + let key = SourceKey { + project: "p".to_owned(), + properties: String::new(), + source: "s".to_owned(), + }; + let left = counts([key.clone(), key.clone()]); + let right = counts([key.clone()]); + assert_eq!(subtract(&left, &right).get(&key), Some(&1)); + } +} diff --git a/src/main.rs b/src/main.rs index 9e180a6..294c98e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1858 +1,193 @@ -use anyhow::{Context, Result}; -use clap::{Parser, ValueEnum}; -use indexmap::IndexMap; -use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; -use log::{LevelFilter, debug, error, info, trace, warn}; -use regex::Regex; -use simplelog::*; -use std::mem::take; -use std::{ - fs::File, - io::{BufRead, BufReader, BufWriter}, - path::{Path, PathBuf}, - time::{Duration, Instant}, -}; -use tempfile::NamedTempFile; +mod adapter; +mod cli; +mod command_line; +mod discovery; +mod extractor; +mod model; +mod msbuild; +mod output; +mod paths; +mod query; +mod solution; + +use anyhow::Result; +use clap::Parser; +use cli::Args; -// ---------------------------------------------------------------------------- -// Logging -// ---------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum LogLevel { - Off, - Error, - Warn, - Info, - Debug, - Trace, -} - -impl From for LevelFilter { - fn from(level: LogLevel) -> Self { - match level { - LogLevel::Off => LevelFilter::Off, - LogLevel::Error => LevelFilter::Error, - LogLevel::Warn => LevelFilter::Warn, - LogLevel::Info => LevelFilter::Info, - LogLevel::Debug => LevelFilter::Debug, - LogLevel::Trace => LevelFilter::Trace, - } - } -} - -// ---------------------------------------------------------------------------- -// Command-line arguments -// ---------------------------------------------------------------------------- - -const PACKAGE_DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); -const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[derive(Parser)] -#[command(version, about=PACKAGE_DESCRIPTION)] -struct Args { - /// Full path to msbuild.log file - #[arg(short = 'i', long, default_value = "msbuild.log")] - input_file: PathBuf, - - /// Path to output compile_commands.json file - #[arg(short = 'o', long, default_value = "compile_commands.json")] - output_file: PathBuf, - - /// Logging level - #[arg(short = 'l', long, value_enum, default_value = "info")] - log_level: LogLevel, - - /// Pretty-print JSON output - #[arg(short = 'p', long, default_value = "false")] - pretty_print: bool, - - /// Disable progress bar output - #[arg(long, default_value = "false")] - no_progress: bool, - - /// Overwrite the output file instead of merging with existing entries - #[arg(long, default_value = "false")] - overwrite: bool, -} - -// ---------------------------------------------------------------------------- -// Data Structures -// ---------------------------------------------------------------------------- - -/// Context for the current project being processed -#[derive(Debug, Clone)] -struct ProjectContext { - /// Full path to the project file - project_path: PathBuf, - /// Directory containing the project file (for resolving relative paths) - project_dir: PathBuf, -} - -/// Represents a single compilation command entry in compile_commands.json -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -struct CompileCommand { - /// The working directory of the compilation - directory: String, - /// The compile command as a single string - command: String, - /// The main translation unit source processed by this command - file: String, -} - -/// State tracking for MSBuild log processing -#[derive(Debug)] -struct ProcessingState { - /// Maps output prefix (e.g., "7>") to the project being built - prefix_to_project: std::collections::HashMap, - /// Current project context (for sequential builds or fallback) - current_project: Option, - /// Current output prefix being processed - current_prefix: Option, - /// Total number of compile commands found - command_count: usize, -} - -impl ProcessingState { - fn new() -> Self { - Self { - prefix_to_project: std::collections::HashMap::new(), - current_project: None, - current_prefix: None, - command_count: 0, - } - } - - /// Get the active project context based on current prefix or fallback - fn get_active_project(&self) -> Option<&ProjectContext> { - if let Some(prefix) = self.current_prefix { - // Try prefix-aware mapping first (parallel builds) - self.prefix_to_project - .get(&prefix) - .or(self.current_project.as_ref()) - } else { - // Sequential build: use current_project - self.current_project.as_ref() - } - } -} - -/// Bundle of compiled regex patterns for log parsing -struct LogPatterns { - node_prefix: Regex, - project_on_node: Regex, - nested_project: Regex, - from_project: Regex, - compile_command: Regex, -} - -impl LogPatterns { - fn new() -> Result { - Ok(Self { - node_prefix: node_prefix_pattern()?, - project_on_node: project_on_node_pattern()?, - nested_project: nested_project_pattern()?, - from_project: from_project_pattern()?, - compile_command: compile_command_pattern()?, - }) - } -} - -// ---------------------------------------------------------------------------- -// Command Line Parsing -// ---------------------------------------------------------------------------- - -/// Tokenize a command line respecting quoted strings -/// Implements state machine: NORMAL -> IN_QUOTE -> NORMAL -fn tokenize_command_line(line: &str) -> Vec { - let mut tokens = Vec::new(); - let mut current_token = String::new(); - let mut in_quotes = false; - - for ch in line.chars() { - match ch { - '"' => { - in_quotes = !in_quotes; - current_token.push(ch); - } - ' ' | '\t' if !in_quotes => { - if !current_token.is_empty() { - tokens.push(take(&mut current_token)); - } - } - _ => { - current_token.push(ch); - } - } - } - - if !current_token.is_empty() { - tokens.push(current_token); - } - - tokens -} - -/// Check if a flag should be filtered out (PCH-related) -fn should_filter_flag(flag: &str) -> bool { - let flag_upper = flag.to_uppercase(); - // Strip PCH flags: /Yc, /Yu, /Fp - // Keep /FI (force include) - clangd supports this as -include - // Keep /fp: (floating-point model) - has colon, different from /Fp (PCH) - - if flag_upper.starts_with("/YC") || flag_upper.starts_with("/YU") { - return true; - } - - // Check for /Fp (PCH file) but NOT /fp: (floating-point model) - // After uppercasing: - // /fp:precise → /FP:PRECISE (floating-point, keep it) - // /Fp"file.pch" → /FP"FILE.PCH" (PCH, filter it) - // The discriminator is the colon! - if flag_upper.starts_with("/FP") && !flag_upper.starts_with("/FP:") { - return true; - } - - false -} - -/// Check if a token is a source file (.c, .cpp, .cc, .cxx) -fn is_source_file(token: &str) -> bool { - // Remove quotes if present - let clean_token = token.trim_matches('"'); - let token_lower = clean_token.to_lowercase(); - token_lower.ends_with(".c") - || token_lower.ends_with(".cpp") - || token_lower.ends_with(".cc") - || token_lower.ends_with(".cxx") -} - -/// Normalize a path by rebuilding it from components -/// This eliminates double backslashes, redundant separators, and other path anomalies -fn normalize_path(path: &Path) -> PathBuf { - path.components().collect() -} - -/// Convert a PathBuf to a normalized string representation -fn path_to_normalized_string(path: &Path) -> String { - normalize_path(path).display().to_string() +fn main() -> Result<()> { + let args = Args::parse(); + ms2cc::run(args) } -/// Clean include path by removing trailing backslashes that can cause -/// quote-escaping issues in clangd's command-line parser -/// -/// Examples: -/// /I"C:\path\to\dir\\" -> /I"C:\path\to\dir" -/// /I"C:\path\to\dir" -> /I"C:\path\to\dir" (unchanged) -/// /IC:\path\to\dir\ -> /IC:\path\to\dir (unquoted form) -fn clean_include_path(flag: &str) -> String { - // Check if this is an include flag - let flag_upper = flag.to_uppercase(); - if !flag_upper.starts_with("/I") { - return flag.to_string(); - } - - // Pattern: /I followed by optional quotes, path, optional trailing backslashes, optional closing quote - // Cases to handle: - // /I"C:\path\\" -> /I"C:\path" - // /IC:\path\ -> /IC:\path - // /I"C:\path" -> /I"C:\path" (no change) - - if flag.starts_with("/I\"") || flag.starts_with("/i\"") { - // Quoted path: /I"path\\" - if let Some(end_quote_pos) = flag.rfind('"') - && end_quote_pos > 3 - { - // More than just /I" - let prefix = &flag[..3]; // /I" - let path = &flag[3..end_quote_pos]; // The actual path - let suffix = &flag[end_quote_pos..]; // Closing " - - // Remove trailing backslashes from path - // But preserve at least one backslash if it's a root path like "C:\" - let cleaned_path = if path.len() == 3 && path.ends_with(":\\") { - // Root path like "C:\" - keep it - path - } else { - path.trim_end_matches('\\') - }; - - return format!("{}{}{}", prefix, cleaned_path, suffix); +mod ms2cc { + use super::{ + adapter, cli::Args, discovery, extractor, model::Properties, msbuild::MsbuildRunner, + output, paths, + }; + use anyhow::{Context, Result, bail}; + use rayon::ThreadPoolBuilder; + use std::{ + collections::BTreeMap, + env, + path::{Path, PathBuf}, + time::Duration, + }; + use tempfile::TempDir; + + pub fn run(args: Args) -> Result<()> { + let cwd = env::current_dir().context("failed to determine the current directory")?; + let inputs = args.resolve_inputs(&cwd)?; + let output_path = paths::absolute_lexical(&args.output, &cwd); + reject_output_collision( + &output_path, + inputs.iter().map(PathBuf::as_path), + "an input project or solution", + )?; + let properties = build_properties(&args)?; + let temp_dir = TempDir::new().context("failed to create the ms2cc temporary directory")?; + let runner = MsbuildRunner::discover( + args.msbuild.as_deref(), + cwd, + temp_dir.path().to_path_buf(), + Duration::from_secs(args.timeout), + )?; + + runner.require_query_support()?; + + let worker_count = std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1) + .clamp(1, 8); + let pool = ThreadPoolBuilder::new() + .num_threads(worker_count) + .thread_name(|index| format!("ms2cc-{index}")) + .build() + .context("failed to initialize the extraction worker pool")?; + + eprintln!( + "Discovering project graph with {} (up to {} workers)", + runner.display_name(), + worker_count + ); + let discovery = pool.install(|| discovery::discover(&runner, &inputs, &properties))?; + let projects = discovery.projects; + if projects.is_empty() { + bail!("no Visual C++ projects were found in the requested input scope"); } - } else if flag.len() > 2 { - // Unquoted path: /Ipath\ - let prefix = &flag[..2]; // /I - let path = &flag[2..]; // The path - - // Remove trailing backslashes - // But preserve at least one backslash if it's a root path like "C:\" - let cleaned_path = if path.len() == 3 && path.ends_with(":\\") { - // Root path like "C:\" - keep it - path - } else { - path.trim_end_matches('\\') - }; - - return format!("{}{}", prefix, cleaned_path); - } - - // Fallback: return as-is if pattern doesn't match - flag.to_string() -} - -/// Resolve source file path to absolute path -fn resolve_source_file_path(source_file: &str, working_directory: &Path) -> PathBuf { - let file_path = PathBuf::from(source_file.trim_matches('"')); - - if file_path.is_absolute() { - return file_path; - } - - // Resolve relative to working directory - working_directory.join(&file_path) -} - -/// Parse a CL.exe command line and extract compile commands -/// Returns a vector of CompileCommand (one per source file) -fn parse_cl_command( - line: &str, - project_ctx: &ProjectContext, - line_number: usize, -) -> Result> { - // Extract the full CL.exe path using regex BEFORE tokenization - // This handles both quoted and unquoted paths with spaces: - // Quoted: "C:\Program Files\...\CL.exe" - // Unquoted: C:\Program Files\Microsoft Visual Studio\...\CL.exe - // Pattern matches from drive letter to CL.exe, handling spaces in between - let cl_exe_regex = regex::Regex::new(r#"(?i)([A-Z]:[^\r\n]*?\\CL\.exe|"[^"]*\\CL\.exe")"#) - .context("Failed to compile CL.exe regex")?; - - let cl_exe_match = cl_exe_regex - .find(line) - .context("CL.exe not found in command line")? - .as_str(); - - // Remove quotes if present - let cl_exe_path = cl_exe_match.trim_matches('"').to_string(); - - let tokens = tokenize_command_line(line); - - // Find CL.exe position in tokens to know where arguments start - let cl_exe_pos = tokens - .iter() - .position(|t| t.to_uppercase().contains("CL.EXE")) - .context("CL.exe not found in command line")?; - - // Separate source files from flags - let mut source_files = Vec::new(); - let mut filtered_args = Vec::new(); - - // Extract tokens (everything after CL.exe) - for token in tokens.into_iter().skip(cl_exe_pos + 1) { - if is_source_file(&token) { - source_files.push(token); - } else if !should_filter_flag(&token) { - // Clean include paths to remove trailing backslashes - let cleaned_token = clean_include_path(&token); - filtered_args.push(cleaned_token); + reject_output_collision( + &output_path, + discovery.visited_projects.iter().map(PathBuf::as_path), + "discovered project", + )?; + let adapter_path = adapter::write(temp_dir.path(), &projects)?; + + eprintln!( + "Extracting {} Visual C++ project instance(s)", + projects.len() + ); + let extraction = pool.install(|| { + extractor::extract_all(&runner, &adapter_path, &projects, &discovery.references) + })?; + extraction.prove_complete()?; + let expected_source_count = extraction.expected_source_count(); + reject_output_collision( + &output_path, + extraction + .commands + .iter() + .map(|record| Path::new(&record.command.file)), + "source file", + )?; + + let commands = output::prepare(extraction.commands)?; + if commands.is_empty() { + bail!("MSBuild returned no C/C++ compilation commands"); } - } - - if source_files.is_empty() { - warn!( - "No source files found in CL.exe command at line {} for project {}", - line_number, - project_ctx.project_path.display() - ); - return Ok(Vec::new()); - } - - // Create one CompileCommand per source file - let mut commands = Vec::new(); - - // Build the base command string once (combines CL.exe path + filtered args) - let base_command = { - let cl_exe_token = if cl_exe_path.contains(' ') { - format!("\"{}\"", cl_exe_path) - } else { - cl_exe_path - }; - let mut parts = vec![cl_exe_token]; - parts.extend(filtered_args); - parts.join(" ") - }; - - for source_file in source_files { - // Resolve source file to absolute path - let absolute_file_path = resolve_source_file_path(&source_file, &project_ctx.project_dir); - - // Normalize paths to eliminate double backslashes and other anomalies - let normalized_file = path_to_normalized_string(&absolute_file_path); - let normalized_directory = path_to_normalized_string(&project_ctx.project_dir); - - // Reconstruct command with base command + normalized absolute source file path - let command = format!("{} \"{}\"", base_command, normalized_file); - - commands.push(CompileCommand { - directory: normalized_directory, - command, - file: normalized_file, - }); - } - - trace!( - "Parsed {} compile command(s) from line {} for project {}", - commands.len(), - line_number, - project_ctx.project_path.display() - ); - - Ok(commands) -} - -// ---------------------------------------------------------------------------- -// Regular Expression Patterns -// ---------------------------------------------------------------------------- - -/// Pattern to match node prefix (e.g., "7>" or "7:2>" at start of line) -/// Used to track the current build node in parallel builds -/// Handles both simple prefixes (7>) and multi-instance prefixes (7:2>) -fn node_prefix_pattern() -> Result { - let pattern = r"^\s*(\d+)(?::\d+)?>"; - debug!("Compiling node prefix regex: {}", pattern); - Regex::new(pattern).context("Failed to compile node prefix regex") -} - -/// Pattern to match "Project X on node N" (parallel builds) -/// Example: 5>Project "S:\Acme\...\Project.vcxproj" on node 4 (Build target(s)). -/// Also handles multi-instance prefixes like 5:2>Project ... -/// Captures the OUTPUT PREFIX (5 or 5:2) and PROJECT PATH, not the physical node number -fn project_on_node_pattern() -> Result { - let pattern = r#"^\s*(\d+)(?::\d+)?>Project "([^"]+\.vcxproj)" on node \d+"#; - debug!("Compiling project-on-node regex: {}", pattern); - Regex::new(pattern).context("Failed to compile project-on-node regex") -} - -/// Pattern to match nested "Project X is building Y on node N" (parallel builds with dependencies) -/// Example: 44>Project "Parent.proj" (44) is building "Child.vcxproj" (54) on node 13 (default targets). -/// Also handles multi-instance notation: 44:2>Project "..." (44:2) is building "..." (54:3) on node 13 -/// Captures the CHILD PROJECT PATH and CHILD OUTPUT PREFIX (base number only, e.g., 54 from 54:3) -fn nested_project_pattern() -> Result { - let pattern = r#"^\s*\d+(?::\d+)?>Project "[^"]*" \([^\)]+\) is building "([^"]+\.vcxproj)" \((\d+)(?::\d+)?\) on node \d+"#; - debug!("Compiling nested-project regex: {}", pattern); - Regex::new(pattern).context("Failed to compile nested-project regex") -} - -/// Pattern to match "from project X" (sequential builds) -/// Example: Target "ClCompile" ... from project "C:\...\Project.vcxproj" -fn from_project_pattern() -> Result { - let pattern = r#"from project "([^"]+\.vcxproj)""#; - debug!("Compiling from-project regex: {}", pattern); - Regex::new(pattern).context("Failed to compile from-project regex") -} - -/// Pattern to match CL.exe compilation commands -/// Matches lines containing CL.exe followed by arguments -fn compile_command_pattern() -> Result { - let pattern = r"(?i)^\s+.*CL\.exe\s"; - debug!("Compiling CL.exe command regex: {}", pattern); - Regex::new(pattern).context("Failed to compile CL.exe command regex") -} - -// ---------------------------------------------------------------------------- -// Log Processing Helper Functions -// ---------------------------------------------------------------------------- - -/// Setup and configure the progress bar for reading the build log -fn setup_read_progress_bar( - show_progress: bool, - file_size: u64, - multi: &MultiProgress, -) -> Result { - if show_progress { - let pb = multi.add(ProgressBar::new(file_size)); - pb.set_style( - ProgressStyle::default_bar() - .template("[{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")? - .progress_chars("=> "), - ); - pb.set_message("Processing build log..."); - Ok(pb) - } else { - Ok(ProgressBar::hidden()) - } -} - -/// Setup and configure the spinner progress bar for writing output -fn setup_write_progress_bar(show_progress: bool, multi: &MultiProgress) -> Result { - if show_progress { - let pb = multi.add(ProgressBar::new_spinner()); - pb.set_style( - ProgressStyle::default_spinner() - .template("[{elapsed_precise}] {spinner:.cyan} {bytes} {msg}")? - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), - ); - pb.set_message("Writing output..."); - pb.enable_steady_tick(Duration::from_millis(100)); - Ok(pb) - } else { - Ok(ProgressBar::hidden()) - } -} - -/// Finalize processing and log summary information -fn finalize_processing(state: &ProcessingState, pb: ProgressBar, start_time: Instant) { - pb.finish_and_clear(); - - let duration = start_time.elapsed(); - - debug!( - "Found {} projects across {} output prefixes", - state.prefix_to_project.len() - + if state.current_project.is_some() { - 1 - } else { - 0 - }, - state.prefix_to_project.len() - ); - - info!( - "Processing complete: {} compile commands found in {:.2}s", - state.command_count, - duration.as_secs_f64() - ); - - if state.prefix_to_project.is_empty() && state.current_project.is_none() { - warn!( - "No projects found in build log - ensure MSBuild was run with /v:detailed or /v:diagnostic" - ); - } - - if !state.prefix_to_project.is_empty() && state.command_count == 0 { - warn!( - "Found {} output prefixes with projects but no compile commands - build log may be incomplete", - state.prefix_to_project.len() - ); - } -} - -/// Handle node prefix pattern (e.g., "7>") -fn handle_node_prefix(line: &str, pattern: &Regex, state: &mut ProcessingState) { - if let Some(caps) = pattern.captures(line) - && let Ok(prefix_num) = caps[1].parse::() - { - state.current_prefix = Some(prefix_num); - } -} - -/// Handle "Project X on node N" pattern (parallel builds) -fn handle_project_on_node( - line: &str, - pattern: &Regex, - state: &mut ProcessingState, - line_number: usize, -) -> Result<()> { - if let Some(caps) = pattern.captures(line) { - let prefix_num = caps[1] - .parse::() - .context("Failed to parse output prefix")?; - let project_path = PathBuf::from(&caps[2]); - let project_dir = project_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - let ctx = ProjectContext { - project_path: project_path.clone(), - project_dir, - }; - - trace!( - "Assigned project {} to output prefix {} at line {}", - project_path.display(), - prefix_num, - line_number - ); - - state.prefix_to_project.insert(prefix_num, ctx.clone()); - // Also update current_project as fallback for sequential builds - state.current_project = Some(ctx); - } - Ok(()) -} - -/// Handle nested "Project X is building Y on node N" pattern -fn handle_nested_project( - line: &str, - pattern: &Regex, - state: &mut ProcessingState, - line_number: usize, -) -> Result<()> { - if let Some(caps) = pattern.captures(line) { - let project_path = PathBuf::from(&caps[1]); - let prefix_num = caps[2] - .parse::() - .context("Failed to parse nested project output prefix")?; - let project_dir = project_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - let ctx = ProjectContext { - project_path: project_path.clone(), - project_dir, - }; - trace!( - "Assigned nested project {} to output prefix {} at line {}", - project_path.display(), - prefix_num, - line_number - ); - - state.prefix_to_project.insert(prefix_num, ctx.clone()); - // Also update current_project as fallback - state.current_project = Some(ctx); - } - Ok(()) -} - -/// Handle "from project X" pattern (sequential builds) -fn handle_from_project( - line: &str, - pattern: &Regex, - state: &mut ProcessingState, - line_number: usize, -) { - if let Some(caps) = pattern.captures(line) { - let project_path = PathBuf::from(&caps[1]); - let project_dir = project_path - .parent() - .map(|p| p.to_path_buf()) - .unwrap_or_else(|| PathBuf::from(".")); - - let ctx = ProjectContext { - project_path: project_path.clone(), - project_dir, - }; - - trace!( - "Set current project to {} at line {}", - project_path.display(), - line_number - ); - - state.current_project = Some(ctx); - } -} - -/// Handle CL.exe compilation command -fn handle_cl_command( - line: &str, - pattern: &Regex, - state: &ProcessingState, - line_number: usize, -) -> Result> { - if !pattern.is_match(line) { - return Ok(Vec::new()); - } - - // Determine which project this command belongs to - let project_ctx = state.get_active_project(); - - if let Some(proj_ctx) = project_ctx { - match parse_cl_command(line, proj_ctx, line_number) { - Ok(commands) => Ok(commands), - Err(e) => { - error!( - "Failed to parse CL.exe command at line {}: {:?}", - line_number, e + fn reject_output_collision<'a>( + output: &Path, + candidates: impl IntoIterator, + kind: &str, + ) -> Result<()> { + let output_identity = path_identity(output); + if let Some(candidate) = candidates + .into_iter() + .find(|candidate| path_identity(candidate) == output_identity) + { + bail!( + "output path cannot overwrite {kind} {}", + candidate.display() ); - Ok(Vec::new()) - } - } - } else { - warn!( - "Found CL.exe command at line {} but no project context available", - line_number - ); - Ok(Vec::new()) - } -} - -/// Process the MSBuild log file. Tracks projects per output prefix for parallel -/// builds and uses context markers for sequential builds -fn process_msbuild_log( - input_file: &Path, - patterns: LogPatterns, - show_progress: bool, - multi: &MultiProgress, -) -> Result> { - let mut compile_commands = Vec::new(); - let mut state = ProcessingState::new(); - - info!("Starting MSBuild log processing"); - let start_time = Instant::now(); - - // Open file and get size for progress tracking - let file = File::open(input_file) - .with_context(|| format!("Failed to open input file: {}", input_file.display()))?; - let file_size = file.metadata()?.len(); - - // Create progress bar - let pb = setup_read_progress_bar(show_progress, file_size, multi)?; - - // Wrap file with progress tracking - let progress_reader = pb.wrap_read(file); - let input = BufReader::new(progress_reader); - - // Single-pass processing - for (index, line_result) in input.lines().enumerate() { - let line_number = index + 1; - - let line = match line_result { - Ok(l) => l, - Err(e) => { - warn!("Failed to read line {}: {:?}", line_number, e); - continue; } - }; - - // Process each pattern type - handle_node_prefix(&line, &patterns.node_prefix, &mut state); - - if let Err(e) = - handle_project_on_node(&line, &patterns.project_on_node, &mut state, line_number) - { - error!( - "Failed to process project-on-node at line {}: {:?}", - line_number, e - ); - } - - if let Err(e) = - handle_nested_project(&line, &patterns.nested_project, &mut state, line_number) - { - error!( - "Failed to process nested project at line {}: {:?}", - line_number, e - ); + Ok(()) } - handle_from_project(&line, &patterns.from_project, &mut state, line_number); - - match handle_cl_command(&line, &patterns.compile_command, &state, line_number) { - Ok(commands) => { - state.command_count += commands.len(); - compile_commands.extend(commands); + fn path_identity(path: &Path) -> String { + if let Ok(canonical) = path.canonicalize() { + return paths::key(&canonical); } - Err(e) => { - error!( - "Failed to handle CL command at line {}: {:?}", - line_number, e - ); + if let (Some(parent), Some(name)) = (path.parent(), path.file_name()) + && let Ok(canonical_parent) = parent.canonicalize() + { + return paths::key(&canonical_parent.join(name)); } + paths::key(path) } - } - finalize_processing(&state, pb, start_time); - - Ok(compile_commands) -} - -/// Create a temporary file in the same directory as the output file. -/// This validates that the output directory is writable before we begin parsing. -/// The temp file auto-deletes on drop if not persisted. -fn create_temp_output_file(output_path: &Path) -> Result { - let parent = output_path.parent().unwrap_or(Path::new(".")); - debug!("Creating temporary output file in: {}", parent.display()); - NamedTempFile::new_in(parent) - .with_context(|| format!("Failed to create temporary file in: {}", parent.display())) -} - -/// Load an existing compile_commands.json database for merging. -/// Returns an empty Vec if the file doesn't exist or can't be parsed. -fn load_existing_database(path: &Path) -> Result> { - if !path.exists() { - debug!("No existing database at {}", path.display()); - return Ok(Vec::new()); + output::write_atomic(&output_path, &commands)?; + eprintln!( + "Wrote {} compilation command(s) for {} expected source item(s) from {} project instance(s) to {}", + commands.len(), + expected_source_count, + projects.len(), + output_path.display() + ); + Ok(()) } - debug!("Loading existing database: {}", path.display()); - let file = File::open(path) - .with_context(|| format!("Failed to open existing database: {}", path.display()))?; - let reader = BufReader::new(file); - - match serde_json::from_reader(reader) { - Ok(commands) => Ok(commands), - Err(e) => { - warn!( - "Failed to parse existing database ({}), starting fresh: {}", - path.display(), - e - ); - Ok(Vec::new()) + fn build_properties(args: &Args) -> Result { + let mut properties = BTreeMap::new(); + if let Some(configuration) = &args.configuration { + insert_property(&mut properties, "Configuration", configuration)?; } - } -} - -/// Merge new compile commands into an existing database. -/// Entries are keyed by (file, directory). New entries replace existing ones with the -/// same key; entries not present in the new set are preserved unchanged. -fn merge_compile_commands( - existing: Vec, - new_entries: Vec, -) -> Vec { - let mut map: IndexMap<(String, String), CompileCommand> = - IndexMap::with_capacity(existing.len() + new_entries.len()); - - for entry in existing { - let key = (entry.file.clone(), entry.directory.clone()); - map.insert(key, entry); - } - - let mut updated_count = 0usize; - let mut added_count = 0usize; - - for entry in new_entries { - let key = (entry.file.clone(), entry.directory.clone()); - if map.contains_key(&key) { - updated_count += 1; - } else { - added_count += 1; + if let Some(platform) = &args.platform { + insert_property(&mut properties, "Platform", platform)?; } - map.insert(key, entry); - } - - info!( - "Merge result: {} updated, {} added, {} total", - updated_count, - added_count, - map.len() - ); - - map.into_values().collect() -} - -fn run() -> Result<()> { - let args = Args::parse(); - - // Determine if progress bar should be shown - // Disable only if --no-progress flag is set or output is not a TTY - let show_progress = !args.no_progress && atty::is(atty::Stream::Stderr); - - // Create MultiProgress for coordinating progress bars and logging - let multi = MultiProgress::new(); - - // Initialize logging with indicatif-log-bridge - let config = ConfigBuilder::new() - .set_target_level(LevelFilter::Off) - .set_thread_level(LevelFilter::Off) - .build(); - - let log_level_filter: LevelFilter = args.log_level.into(); - - let logger = TermLogger::new( - log_level_filter, - config, - TerminalMode::Mixed, - ColorChoice::Auto, - ); - - indicatif_log_bridge::LogWrapper::new(multi.clone(), logger) - .try_init() - .context("Failed to initialize logging")?; - - info!("ms2cc v{} - {}", PACKAGE_VERSION, PACKAGE_DESCRIPTION); - - // Create a temp file in the output directory to validate writability before parsing. - // The temp file auto-deletes on drop if we don't persist it. - let temp_file = create_temp_output_file(&args.output_file)?; - - // Load existing database for merging (unless --overwrite is set) - let existing = if args.overwrite { - info!("Overwrite mode: existing database will be replaced"); - Vec::new() - } else { - let loaded = load_existing_database(&args.output_file)?; - if !loaded.is_empty() { - info!( - "Loaded {} existing entries from {}", - loaded.len(), - args.output_file.display() - ); - } - loaded - }; - - // Process the MSBuild log file - let patterns = LogPatterns::new()?; - let new_commands = process_msbuild_log(&args.input_file, patterns, show_progress, &multi)?; - - // Merge or replace - let compile_commands = if existing.is_empty() { - new_commands - } else { - merge_compile_commands(existing, new_commands) - }; - - // Write JSON output to the temp file - info!( - "Writing {} commands to {}", - compile_commands.len(), - args.output_file.display() - ); - - // Create progress spinner for write operation if enabled - let write_pb = setup_write_progress_bar(show_progress, &multi)?; - - let output = BufWriter::new(temp_file.as_file()); - let progress_writer = write_pb.wrap_write(output); - - if args.pretty_print { - serde_json::to_writer_pretty(progress_writer, &compile_commands) - .context("Failed to write JSON output")?; - } else { - serde_json::to_writer(progress_writer, &compile_commands) - .context("Failed to write JSON output")?; - } - - write_pb.finish_and_clear(); - - // Atomically replace the output file now that writing succeeded - temp_file.persist(&args.output_file).with_context(|| { - format!( - "Failed to persist output file: {}", - args.output_file.display() - ) - })?; - - info!("Finished"); - - Ok(()) -} - -// ---------------------------------------------------------------------------- -// Main entry point -// ---------------------------------------------------------------------------- - -fn main() -> Result<()> { - if let Err(e) = run() { - error!("Application error: {:?}", e); - std::process::exit(1); - }; - - Ok(()) -} - -// ---------------------------------------------------------------------------- -// Tests -// ---------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - // ---------------------------------------------------------------------------- - // Tests for regex patterns - // ---------------------------------------------------------------------------- - - #[test] - fn test_node_prefix_pattern() { - let re = node_prefix_pattern().unwrap(); - - assert!(re.is_match("4>Project ...")); - assert!(re.is_match(" 7>Something")); - assert!(re.is_match("123>Build")); - assert!(!re.is_match("Project without prefix")); - - // Extract node number - let caps = re.captures(" 4>Project").unwrap(); - assert_eq!(&caps[1], "4"); - - // Test multi-instance prefix notation (e.g., "53:20>") - assert!(re.is_match("53:20>Project ...")); - assert!(re.is_match(" 7:2>Something")); - let caps = re.captures("53:20>ClCompile").unwrap(); - assert_eq!(&caps[1], "53"); // Should capture base number only - } - - #[test] - fn test_project_on_node_pattern() { - let re = project_on_node_pattern().unwrap(); - - let line1 = r#"4>Project "C:\path\to\project.vcxproj" on node 3 (Build target(s))."#; - let caps = re.captures(line1).expect("Should match"); - assert_eq!(&caps[1], "4"); // Output prefix - assert_eq!(&caps[2], r#"C:\path\to\project.vcxproj"#); // Project path - - let line2 = r#" 7>Project "S:\My Project\test.vcxproj" on node 12 (default targets)."#; - let caps = re.captures(line2).expect("Should match path with spaces"); - assert_eq!(&caps[1], "7"); // Output prefix - assert_eq!(&caps[2], r#"S:\My Project\test.vcxproj"#); // Project path - - // Test multi-instance prefix notation - let line3 = r#" 53:20>Project "S:\Azure\test.vcxproj" on node 30 (default targets)."#; - let caps = re - .captures(line3) - .expect("Should match multi-instance prefix"); - assert_eq!(&caps[1], "53"); // Base prefix number - assert_eq!(&caps[2], r#"S:\Azure\test.vcxproj"#); // Project path - } - - #[test] - fn test_nested_project_pattern() { - let re = nested_project_pattern().unwrap(); - - let line1 = r#" 44>Project "S:\Acme\corp\src\foo\baz.proj" (44) is building "S:\Acme\corp\src\foo\bar.vcxproj" (54) on node 13 (default targets)."#; - let caps = re - .captures(line1) - .expect("Should match nested project pattern"); - assert_eq!(&caps[1], r#"S:\Acme\corp\src\foo\bar.vcxproj"#); // Child project path - assert_eq!(&caps[2], "54"); // Child output prefix - - // Another example with spaces - let line2 = r#" 10>Project "C:\Parent.proj" (10) is building "C:\My Projects\Child.vcxproj" (25) on node 5 (Build target(s))."#; - let caps = re.captures(line2).expect("Should match nested with spaces"); - assert_eq!(&caps[1], r#"C:\My Projects\Child.vcxproj"#); // Child project path - assert_eq!(&caps[2], "25"); // Child output prefix - - // Test multi-instance prefix notation - let line3 = r#" 53:20>Project "S:\Azure\Parent.csproj" (53:20) is building "S:\Azure\XStoreUlsNative.vcxproj" (246) on node 30 (default targets)."#; - let caps = re - .captures(line3) - .expect("Should match multi-instance parent prefix"); - assert_eq!(&caps[1], r#"S:\Azure\XStoreUlsNative.vcxproj"#); // Child project path - assert_eq!(&caps[2], "246"); // Child output prefix (base number) - - // Test multi-instance for both parent and child - let line4 = r#" 264:105>Project "S:\Azure\Parent.vcxproj" (264:105) is building "S:\Azure\Child.vcxproj" (266:5) on node 28 (BuiltProjectOutputGroup target(s))."#; - let caps = re - .captures(line4) - .expect("Should match multi-instance both prefixes"); - assert_eq!(&caps[1], r#"S:\Azure\Child.vcxproj"#); // Child project path - assert_eq!(&caps[2], "266"); // Child output prefix (base number only) - } - - #[test] - fn test_from_project_pattern() { - let re = from_project_pattern().unwrap(); - - let line1 = r#"Target "ClCompile" from project "C:\path\to\project.vcxproj""#; - let caps = re.captures(line1).expect("Should match"); - assert_eq!(&caps[1], r#"C:\path\to\project.vcxproj"#); - - let line2 = r#" Some text from project "D:\My Projects\test.vcxproj" more text"#; - let caps = re.captures(line2).expect("Should match path with spaces"); - assert_eq!(&caps[1], r#"D:\My Projects\test.vcxproj"#); - } - - #[test] - fn test_cl_exe_regex() { - let re = compile_command_pattern().unwrap(); - - assert!(re.is_match(r#" CL.exe /c /I"include" main.cpp"#)); - assert!(re.is_match(r#" C:\Program Files\MSVC\bin\CL.exe /nologo"#)); - assert!(!re.is_match(r#"CL.exe"#)); // No space after CL.exe - assert!(!re.is_match(r#"Link.exe /OUT:test.exe"#)); - } - - // ---------------------------------------------------------------------------- - // Tests for argument tokenization and command parsing - // ---------------------------------------------------------------------------- - - #[test] - fn test_tokenize_simple() { - let tokens = tokenize_command_line(r#"cl.exe /c main.cpp"#); - assert_eq!(tokens, vec!["cl.exe", "/c", "main.cpp"]); - } - - #[test] - fn test_tokenize_quoted() { - let tokens = tokenize_command_line(r#"cl.exe /I"C:\Program Files\include" main.cpp"#); - assert_eq!( - tokens, - vec!["cl.exe", r#"/I"C:\Program Files\include""#, "main.cpp"] - ); - } - - #[test] - fn test_tokenize_multiple_spaces() { - let tokens = tokenize_command_line(r#"cl.exe /c main.cpp"#); - assert_eq!(tokens, vec!["cl.exe", "/c", "main.cpp"]); - } - - #[test] - fn test_is_source_file() { - assert!(is_source_file("main.cpp")); - assert!(is_source_file("test.c")); - assert!(is_source_file("code.cc")); - assert!(is_source_file("file.cxx")); - assert!(is_source_file("FILE.CPP")); // Case insensitive - assert!(!is_source_file("header.h")); - assert!(!is_source_file("lib.obj")); - } - - #[test] - fn test_should_filter_flag() { - // Should filter PCH flags - assert!(should_filter_flag("/Yc")); - assert!(should_filter_flag("/YcStdAfx.h")); - assert!(should_filter_flag("/Yu")); - assert!(should_filter_flag("/YuPrecompiled.h")); - assert!(should_filter_flag("/Fp")); - assert!(should_filter_flag("/FpDebug/test.pch")); - assert!(should_filter_flag("/Fp\"C:\\path\\file.pch\"")); - - // Should NOT filter floating-point model flags (they have colons!) - assert!(!should_filter_flag("/fp:precise")); - assert!(!should_filter_flag("/fp:fast")); - assert!(!should_filter_flag("/fp:strict")); - assert!(!should_filter_flag("/Fp:precise")); // Alternative valid form - assert!(!should_filter_flag("/fp:contract")); - assert!(!should_filter_flag("/fp:except")); - assert!(!should_filter_flag("/fp:except-")); - - // Should NOT filter force includes - assert!(!should_filter_flag("/FI")); - assert!(!should_filter_flag("/FIheader.h")); - - // Case insensitive for PCH - assert!(should_filter_flag("/yc")); - assert!(should_filter_flag("/YC")); - assert!(should_filter_flag("/fp\"test.pch\"")); // PCH (no colon!) - - // Should not filter other flags - assert!(!should_filter_flag("/c")); - assert!(!should_filter_flag("/Ox")); - } - - // ---------------------------------------------------------------------------- - // Tests for normalize_path() - // ---------------------------------------------------------------------------- - - #[test] - fn test_normalize_path_with_double_backslash() { - let path = PathBuf::from(r"C:\foo\bar\\baz\file.cpp"); - let normalized = normalize_path(&path); - assert_eq!(normalized, PathBuf::from(r"C:\foo\bar\baz\file.cpp")); - } - - #[test] - fn test_normalize_path_normal() { - let path = PathBuf::from(r"C:\foo\bar\baz\file.cpp"); - let normalized = normalize_path(&path); - assert_eq!(normalized, path); - } - - // ---------------------------------------------------------------------------- - // Tests for path_to_normalized_string() - // ---------------------------------------------------------------------------- - - #[test] - fn test_path_to_normalized_string() { - let path = PathBuf::from(r"S:\Acme\Project\src\project\obj\amd64\\bond\core\file.cpp"); - let normalized = path_to_normalized_string(&path); - // Should not contain double backslashes - assert!(!normalized.contains(r"\\")); - // Should contain the components - assert!(normalized.contains("bond")); - assert!(normalized.contains("core")); - } - - // ---------------------------------------------------------------------------- - // Tests for parse_cl_command() - // ---------------------------------------------------------------------------- - - #[test] - fn test_parse_cl_command_single_file() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with UNQUOTED path (like real MSBuild logs) - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c /I"include" main.cpp"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - assert_eq!(commands[0].directory, r"C:\project"); - // File should now be absolute - assert_eq!(commands[0].file, r"C:\project\main.cpp"); - assert!(commands[0] - .command - .contains(r#""C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe""#)); - assert!(commands[0].command.contains(r#"/I"include""#)); - assert!(commands[0].command.contains(r#"C:\project\main.cpp"#)); - } - - #[test] - fn test_parse_cl_command_multiple_files() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with UNQUOTED path (like real MSBuild logs) - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c /Ox main.cpp util.cpp helper.c"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 3); - // Files should now be absolute - assert_eq!(commands[0].file, r"C:\project\main.cpp"); - assert_eq!(commands[1].file, r"C:\project\util.cpp"); - assert_eq!(commands[2].file, r"C:\project\helper.c"); - - // All should have same directory and flags - for cmd in &commands { - assert_eq!(cmd.directory, r"C:\project"); - assert!(cmd.command.contains("/Ox")); - assert!(cmd - .command - .contains(r#""C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe""#)); + for property in &args.properties { + if [ + "DesignTimeBuild", + "BuildingInsideVisualStudio", + "BuildProjectReferences", + "BuildingProject", + "AlwaysResolveReferencesDesignTime", + "ShouldUnsetParentConfigurationAndPlatform", + "ForceImportAfterCppTargets", + "Ms2ccScratchDirectory", + "Ms2ccReferencedHeaderUnits", + "Ms2ccReferencedHeaderUnitBMIs", + "Ms2ccReferencedModules", + "Ms2ccReferencedModuleBMIs", + "_FindDependencies", + "FindDependenciesOfExternallyResolvedReferences", + ] + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(&property.name)) + { + bail!("MSBuild property '{}' is reserved by ms2cc", property.name); + } + insert_property(&mut properties, &property.name, &property.value)?; } + Ok(properties) } - #[test] - fn test_parse_cl_command_filters_pch() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with UNQUOTED path (like real MSBuild logs) - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c /YuStdafx.h /FpDebug/test.pch /FIcommon.h main.cpp"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - - // Should filter /Yu and /Fp but keep /FI - assert!(!commands[0].command.contains("/Yu")); - assert!(!commands[0].command.contains("/Fp")); - assert!(commands[0].command.contains("/FIcommon.h")); - } - - #[test] - fn test_parse_cl_command_preserves_fp_model() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test that /fp:precise (floating-point model) is preserved while /Fp (PCH) is filtered - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c /fp:precise /YuStdafx.h /Fp"Debug\test.pch" /Od main.cpp"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - - // Should keep /fp:precise (floating-point model) - assert!( - commands[0].command.contains("/fp:precise"), - "Command should contain /fp:precise but got: {}", - commands[0].command - ); - - // Should filter /Yu and /Fp"..." (PCH flags) - assert!( - !commands[0].command.contains("/Yu"), - "Command should not contain /Yu" - ); - assert!( - !commands[0].command.contains("/Fp\""), - "Command should not contain /Fp with quotes" - ); - - // Should keep other flags - assert!(commands[0].command.contains("/Od")); - } - - #[test] - fn test_parse_cl_command_quoted_file() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with UNQUOTED path (like real MSBuild logs) - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c "path with spaces\main.cpp""#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - // File field should be absolute with no quotes - assert_eq!(commands[0].file, r"C:\project\path with spaces\main.cpp"); - // Command should have absolute path with quotes - assert!( - commands[0] - .command - .contains(r#"C:\project\path with spaces\main.cpp"#) - ); - } - - #[test] - fn test_parse_cl_command_full_path_with_spaces() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with QUOTED CL.exe path (ensure backward compatibility) - let line = r#" "C:\Program Files\MSVC\bin\HostX64\x64\CL.exe" /c main.cpp"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - // Should preserve full path with quotes due to spaces - assert!( - commands[0] - .command - .contains(r#""C:\Program Files\MSVC\bin\HostX64\x64\CL.exe""#) - ); - assert!(commands[0].command.contains(r"C:\project\main.cpp")); - } - - #[test] - fn test_parse_cl_command_unquoted_path_with_spaces() { - let project_ctx = ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }; - - // Test with UNQUOTED CL.exe path with spaces (real MSBuild logs) - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c main.cpp"#; - let commands = parse_cl_command(line, &project_ctx, 200).unwrap(); - - assert_eq!(commands.len(), 1); - // Should quote the path with spaces - assert!(commands[0] - .command - .contains(r#""C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe""#)); - assert!(commands[0].command.contains(r"C:\project\main.cpp")); - } - - // ---------------------------------------------------------------------------- - // Tests for clean_include_path() - // ---------------------------------------------------------------------------- - - #[test] - fn test_clean_include_path_quoted_with_trailing_backslash() { - assert_eq!( - clean_include_path(r#"/I"C:\path\to\dir\\""#), - r#"/I"C:\path\to\dir""# - ); - } - - #[test] - fn test_clean_include_path_quoted_no_trailing() { - assert_eq!( - clean_include_path(r#"/I"C:\path\to\dir""#), - r#"/I"C:\path\to\dir""# - ); - } - - #[test] - fn test_clean_include_path_unquoted_with_trailing() { - assert_eq!( - clean_include_path(r#"/IC:\path\to\dir\"#), - r#"/IC:\path\to\dir"# - ); - } - - #[test] - fn test_clean_include_path_multiple_trailing() { - assert_eq!( - clean_include_path(r#"/I"C:\path\to\dir\\\\\""#), - r#"/I"C:\path\to\dir""# - ); - } - - #[test] - fn test_clean_include_path_not_include_flag() { - assert_eq!(clean_include_path(r#"/Od"#), r#"/Od"#); - } - - #[test] - fn test_clean_include_path_root_drive() { - // Root path like C:\ should be preserved - assert_eq!(clean_include_path(r#"/I"C:\""#), r#"/I"C:\""#); - } - - #[test] - fn test_clean_include_path_lowercase_flag() { - // Should work with lowercase /i as well - assert_eq!( - clean_include_path(r#"/i"C:\path\to\dir\\""#), - r#"/i"C:\path\to\dir""# - ); - } - - #[test] - fn test_clean_include_path_real_world_example() { - // Real example from the XStore build - assert_eq!( - clean_include_path( - r#"/I"S:\Azure\Storage-XStore\src\XAggregator\XsdMacroGen\obj\amd64\\""# - ), - r#"/I"S:\Azure\Storage-XStore\src\XAggregator\XsdMacroGen\obj\amd64""# - ); - } - - // ---------------------------------------------------------------------------- - // Tests for resolve_source_file_path() - // ---------------------------------------------------------------------------- - - #[test] - fn test_resolve_source_file_path_relative() { - let working_dir = PathBuf::from(r"C:\project"); - let source = "src\\main.cpp"; - let resolved = resolve_source_file_path(source, &working_dir); - assert_eq!(resolved, PathBuf::from(r"C:\project\src\main.cpp")); - } - - #[test] - fn test_resolve_source_file_path_parent_directory() { - let working_dir = PathBuf::from(r"C:\project\SubDir"); - let source = r"..\Common\shared.cpp"; - let resolved = resolve_source_file_path(source, &working_dir); - assert_eq!( - resolved, - PathBuf::from(r"C:\project\SubDir\..\Common\shared.cpp") - ); - } - - #[test] - fn test_resolve_source_file_path_already_absolute() { - let working_dir = PathBuf::from(r"C:\project"); - let source = r"D:\external\library\file.cpp"; - let resolved = resolve_source_file_path(source, &working_dir); - assert_eq!(resolved, PathBuf::from(r"D:\external\library\file.cpp")); - } - - #[test] - fn test_resolve_source_file_path_quoted() { - let working_dir = PathBuf::from(r"C:\project"); - let source = r#""src\main.cpp""#; - let resolved = resolve_source_file_path(source, &working_dir); - assert_eq!(resolved, PathBuf::from(r"C:\project\src\main.cpp")); - } - - #[test] - fn test_resolve_source_file_path_current_directory() { - let working_dir = PathBuf::from(r"C:\project"); - let source = r".\main.cpp"; - let resolved = resolve_source_file_path(source, &working_dir); - assert_eq!(resolved, PathBuf::from(r"C:\project\.\main.cpp")); - } - - // ---------------------------------------------------------------------------- - // Tests for normalize_path() - // ---------------------------------------------------------------------------- - - #[test] - fn test_normalize_path_triple_backslash() { - let path = PathBuf::from(r"C:\foo\bar\\\baz\file.cpp"); - let normalized = normalize_path(&path); - // Should eliminate all redundant backslashes - assert_eq!(normalized, PathBuf::from(r"C:\foo\bar\baz\file.cpp")); - } - - #[test] - fn test_normalize_path_mixed_separators() { - // On Windows, PathBuf handles / and \ differently depending on the input - let path = PathBuf::from(r"C:\foo/bar\baz/file.cpp"); - let normalized = normalize_path(&path); - // The path has 5 meaningful components, but the mixed separator might create more - // Just verify normalization happened - let normalized_str = normalized.display().to_string(); - assert!(normalized_str.contains("foo")); - assert!(normalized_str.contains("bar")); - assert!(normalized_str.contains("baz")); - assert!(normalized_str.contains("file.cpp")); - } - - // ---------------------------------------------------------------------------- - // Tests for tokenize_command_line() - // ---------------------------------------------------------------------------- - - #[test] - fn test_tokenize_empty_string() { - let tokens = tokenize_command_line(""); - assert_eq!(tokens.len(), 0); - } - - #[test] - fn test_tokenize_only_whitespace() { - let tokens = tokenize_command_line(" \t "); - assert_eq!(tokens.len(), 0); - } - - #[test] - fn test_tokenize_unclosed_quote() { - // Unclosed quote - should still tokenize (quote becomes part of token) - let tokens = tokenize_command_line(r#"cl.exe /I"C:\Program Files"#); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0], "cl.exe"); - assert_eq!(tokens[1], r#"/I"C:\Program Files"#); - } - - #[test] - fn test_tokenize_adjacent_quotes() { - let tokens = tokenize_command_line(r#"cl.exe ""file.cpp"""#); - assert_eq!(tokens.len(), 2); - assert_eq!(tokens[0], "cl.exe"); - assert_eq!(tokens[1], r#"""file.cpp"""#); - } - - #[test] - fn test_tokenize_tabs() { - let tokens = tokenize_command_line("cl.exe\t/c\tmain.cpp"); - assert_eq!(tokens, vec!["cl.exe", "/c", "main.cpp"]); - } - - // ---------------------------------------------------------------------------- - // Tests for is_source_file() - // ---------------------------------------------------------------------------- - - #[test] - fn test_is_source_file_uppercase_extensions() { - assert!(is_source_file("MAIN.CPP")); - assert!(is_source_file("FILE.C")); - assert!(is_source_file("CODE.CXX")); - assert!(is_source_file("TEST.CC")); - } - - #[test] - fn test_is_source_file_mixed_case_extensions() { - assert!(is_source_file("main.CpP")); - assert!(is_source_file("file.Cpp")); - } - - #[test] - fn test_is_source_file_quoted_paths() { - assert!(is_source_file(r#""path\to\file.cpp""#)); - assert!(is_source_file(r#""test.c""#)); - } - - #[test] - fn test_is_source_file_with_path() { - assert!(is_source_file(r"C:\project\src\main.cpp")); - assert!(is_source_file(r"relative\path\file.c")); - } - - #[test] - fn test_is_source_file_not_source() { - assert!(!is_source_file("header.h")); - assert!(!is_source_file("library.lib")); - assert!(!is_source_file("object.obj")); - assert!(!is_source_file("executable.exe")); - assert!(!is_source_file("archive.a")); - assert!(!is_source_file("README.md")); - } - - // ---------------------------------------------------------------------------- - // Tests for handler functions - // ---------------------------------------------------------------------------- - - #[test] - fn test_handle_node_prefix_valid() { - let mut state = ProcessingState::new(); - let pattern = node_prefix_pattern().unwrap(); - - handle_node_prefix(" 7>Project ...", &pattern, &mut state); - - assert_eq!(state.current_prefix, Some(7)); - } - - #[test] - fn test_handle_node_prefix_no_match() { - let mut state = ProcessingState::new(); - let pattern = node_prefix_pattern().unwrap(); - - handle_node_prefix("Project without prefix", &pattern, &mut state); - - assert_eq!(state.current_prefix, None); - } - - #[test] - fn test_handle_project_on_node_parallel_build() { - let mut state = ProcessingState::new(); - let pattern = project_on_node_pattern().unwrap(); - let line = r#"4>Project "C:\path\to\project.vcxproj" on node 3 (Build target(s))."#; - - let result = handle_project_on_node(line, &pattern, &mut state, 100); - - assert!(result.is_ok()); - assert_eq!(state.prefix_to_project.len(), 1); - assert!(state.prefix_to_project.contains_key(&4)); - assert!(state.current_project.is_some()); - assert_eq!( - state.prefix_to_project.get(&4).unwrap().project_path, - PathBuf::from(r"C:\path\to\project.vcxproj") - ); - } - - #[test] - fn test_handle_nested_project_pattern() { - let mut state = ProcessingState::new(); - let pattern = nested_project_pattern().unwrap(); - let line = r#" 44>Project "S:\Acme\corp\src\foo\baz.proj" (44) is building "S:\Acme\corp\src\foo\bar.vcxproj" (54) on node 13 (default targets)."#; - - let result = handle_nested_project(line, &pattern, &mut state, 100); - - assert!(result.is_ok()); - assert_eq!(state.prefix_to_project.len(), 1); - assert!(state.prefix_to_project.contains_key(&54)); - assert_eq!( - state.prefix_to_project.get(&54).unwrap().project_path, - PathBuf::from(r"S:\Acme\corp\src\foo\bar.vcxproj") - ); - } - - #[test] - fn test_handle_from_project_sequential_build() { - let mut state = ProcessingState::new(); - let pattern = from_project_pattern().unwrap(); - let line = r#"Target "ClCompile" from project "C:\path\to\project.vcxproj""#; - - handle_from_project(line, &pattern, &mut state, 100); - - assert!(state.current_project.is_some()); - assert_eq!( - state.current_project.as_ref().unwrap().project_path, - PathBuf::from(r"C:\path\to\project.vcxproj") - ); - } - - #[test] - fn test_handle_cl_command_with_context() { - let mut state = ProcessingState::new(); - state.current_project = Some(ProjectContext { - project_path: PathBuf::from(r"C:\project\test.vcxproj"), - project_dir: PathBuf::from(r"C:\project"), - }); - - let pattern = compile_command_pattern().unwrap(); - let line = r#" C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Tools\MSVC\14.44.35207\bin\HostX64\x64\CL.exe /c main.cpp"#; - - let result = handle_cl_command(line, &pattern, &state, 100); - - assert!(result.is_ok()); - let commands = result.unwrap(); - assert_eq!(commands.len(), 1); - assert_eq!(commands[0].file, r"C:\project\main.cpp"); - } - - #[test] - fn test_handle_cl_command_no_context() { - let state = ProcessingState::new(); - let pattern = compile_command_pattern().unwrap(); - let line = r#" CL.exe /c main.cpp"#; - - let result = handle_cl_command(line, &pattern, &state, 100); - - assert!(result.is_ok()); - let commands = result.unwrap(); - assert_eq!(commands.len(), 0); // No commands when no context - } - - #[test] - fn test_handle_cl_command_not_cl_command() { - let state = ProcessingState::new(); - let pattern = compile_command_pattern().unwrap(); - let line = r#"This is not a CL.exe command"#; - - let result = handle_cl_command(line, &pattern, &state, 100); - - assert!(result.is_ok()); - let commands = result.unwrap(); - assert_eq!(commands.len(), 0); - } - - #[test] - fn test_processing_state_get_active_project_with_prefix() { - let mut state = ProcessingState::new(); - let ctx = ProjectContext { - project_path: PathBuf::from(r"C:\prefix\project.vcxproj"), - project_dir: PathBuf::from(r"C:\prefix"), - }; - state.prefix_to_project.insert(7, ctx.clone()); - state.current_prefix = Some(7); - - let active = state.get_active_project(); - - assert!(active.is_some()); - assert_eq!( - active.unwrap().project_path, - PathBuf::from(r"C:\prefix\project.vcxproj") - ); - } - - #[test] - fn test_processing_state_get_active_project_fallback() { - let mut state = ProcessingState::new(); - state.current_project = Some(ProjectContext { - project_path: PathBuf::from(r"C:\fallback\project.vcxproj"), - project_dir: PathBuf::from(r"C:\fallback"), - }); - state.current_prefix = Some(99); // Prefix not in map - - let active = state.get_active_project(); - - assert!(active.is_some()); - assert_eq!( - active.unwrap().project_path, - PathBuf::from(r"C:\fallback\project.vcxproj") - ); - } - - #[test] - fn test_processing_state_get_active_project_no_prefix() { - let mut state = ProcessingState::new(); - state.current_project = Some(ProjectContext { - project_path: PathBuf::from(r"C:\sequential\project.vcxproj"), - project_dir: PathBuf::from(r"C:\sequential"), - }); - - let active = state.get_active_project(); - - assert!(active.is_some()); - assert_eq!( - active.unwrap().project_path, - PathBuf::from(r"C:\sequential\project.vcxproj") - ); - } - - // ---------------------------------------------------------------------------- - // Tests for progress bar setup functions - // ---------------------------------------------------------------------------- - - #[test] - fn test_setup_read_progress_bar_enabled() { - let multi = MultiProgress::new(); - let pb = setup_read_progress_bar(true, 1000, &multi).unwrap(); - // Should create a visible progress bar (not hidden) - // We can't directly test visibility, but we can verify it doesn't panic - pb.finish_and_clear(); - } - - #[test] - fn test_setup_read_progress_bar_disabled() { - let multi = MultiProgress::new(); - let pb = setup_read_progress_bar(false, 1000, &multi).unwrap(); - // Should create a hidden progress bar - pb.finish_and_clear(); - } - - #[test] - fn test_setup_write_progress_bar_enabled() { - let multi = MultiProgress::new(); - let pb = setup_write_progress_bar(true, &multi).unwrap(); - // Should create a visible spinner - pb.finish_and_clear(); - } - - #[test] - fn test_setup_write_progress_bar_disabled() { - let multi = MultiProgress::new(); - let pb = setup_write_progress_bar(false, &multi).unwrap(); - // Should create a hidden progress bar - pb.finish_and_clear(); - } - - // ---------------------------------------------------------------------------- - // Tests for merge_compile_commands - // ---------------------------------------------------------------------------- - - fn make_entry(file: &str, directory: &str, command: &str) -> CompileCommand { - CompileCommand { - file: file.to_string(), - directory: directory.to_string(), - command: command.to_string(), + fn insert_property(properties: &mut Properties, name: &str, value: &str) -> Result<()> { + if let Some(existing) = properties + .keys() + .find(|existing| existing.eq_ignore_ascii_case(name)) + { + bail!("MSBuild property '{name}' was specified more than once (also as '{existing}')"); } - } - - #[test] - fn test_merge_empty_existing_returns_new() { - let existing = vec![]; - let new_entries = vec![ - make_entry("a.cpp", "C:\\proj", "cl /c a.cpp"), - make_entry("b.cpp", "C:\\proj", "cl /c b.cpp"), - ]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 2); - assert_eq!(result[0].file, "a.cpp"); - assert_eq!(result[1].file, "b.cpp"); - } - - #[test] - fn test_merge_empty_new_returns_existing() { - let existing = vec![ - make_entry("a.cpp", "C:\\proj", "cl /c a.cpp"), - make_entry("b.cpp", "C:\\proj", "cl /c b.cpp"), - ]; - let new_entries = vec![]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 2); - assert_eq!(result[0].file, "a.cpp"); - assert_eq!(result[1].file, "b.cpp"); - } - - #[test] - fn test_merge_updates_matching_entries() { - let existing = vec![ - make_entry("a.cpp", "C:\\proj", "cl /c /O1 a.cpp"), - make_entry("b.cpp", "C:\\proj", "cl /c /O1 b.cpp"), - ]; - let new_entries = vec![make_entry("a.cpp", "C:\\proj", "cl /c /O2 a.cpp")]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 2); - assert_eq!(result[0].command, "cl /c /O2 a.cpp"); - assert_eq!(result[1].command, "cl /c /O1 b.cpp"); - } - - #[test] - fn test_merge_appends_new_entries() { - let existing = vec![make_entry("a.cpp", "C:\\proj", "cl /c a.cpp")]; - let new_entries = vec![make_entry("b.cpp", "C:\\proj", "cl /c b.cpp")]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 2); - assert_eq!(result[0].file, "a.cpp"); - assert_eq!(result[1].file, "b.cpp"); - } - - #[test] - fn test_merge_preserves_same_file_different_directory() { - let existing = vec![ - make_entry("crc.cpp", "C:\\lib", "cl /c /DUSER crc.cpp"), - make_entry("crc.cpp", "C:\\klib", "cl /c /DKERNEL crc.cpp"), - ]; - let new_entries = vec![make_entry("crc.cpp", "C:\\lib", "cl /c /DUSER /O2 crc.cpp")]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 2); - // The userspace entry should be updated - assert_eq!(result[0].command, "cl /c /DUSER /O2 crc.cpp"); - assert_eq!(result[0].directory, "C:\\lib"); - // The kernel entry should be untouched - assert_eq!(result[1].command, "cl /c /DKERNEL crc.cpp"); - assert_eq!(result[1].directory, "C:\\klib"); - } - - #[test] - fn test_merge_mixed_update_and_add() { - let existing = vec![ - make_entry("a.cpp", "C:\\proj", "cl /c a.cpp"), - make_entry("b.cpp", "C:\\proj", "cl /c b.cpp"), - make_entry("c.cpp", "C:\\proj", "cl /c c.cpp"), - ]; - let new_entries = vec![ - make_entry("b.cpp", "C:\\proj", "cl /c /O2 b.cpp"), - make_entry("d.cpp", "C:\\proj", "cl /c d.cpp"), - ]; - let result = merge_compile_commands(existing, new_entries); - assert_eq!(result.len(), 4); - assert_eq!(result[0].file, "a.cpp"); - assert_eq!(result[1].file, "b.cpp"); - assert_eq!(result[1].command, "cl /c /O2 b.cpp"); - assert_eq!(result[2].file, "c.cpp"); - assert_eq!(result[3].file, "d.cpp"); + properties.insert(name.to_owned(), value.to_owned()); + Ok(()) } } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..2c91992 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,113 @@ +use serde::Serialize; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +use crate::paths; + +pub type Properties = BTreeMap; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectInstance { + pub path: PathBuf, + pub properties: Properties, + pub force_import_after_cpp_targets: Option, +} + +impl ProjectInstance { + pub fn key(&self) -> String { + let mut key = self + .path + .to_string_lossy() + .replace('/', "\\") + .to_lowercase(); + let properties = property_key(&self.properties); + if !properties.is_empty() { + key.push('\0'); + key.push_str(&properties); + } + key + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CompileCommand { + pub directory: String, + pub arguments: Vec, + pub file: String, +} + +#[derive(Debug, Clone)] +pub struct CommandRecord { + pub command: CompileCommand, + pub project: PathBuf, + pub properties: Properties, +} + +impl CommandRecord { + pub fn source_key(&self) -> SourceKey { + SourceKey { + project: paths::key(&self.project), + properties: property_key(&self.properties), + source: paths::key(Path::new(&self.command.file)), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct SourceKey { + pub project: String, + pub properties: String, + pub source: String, +} + +impl SourceKey { + pub fn new(project: &Path, properties: &Properties, source: &Path) -> Self { + Self { + project: paths::key(project), + properties: property_key(properties), + source: paths::key(source), + } + } +} + +fn property_key(properties: &Properties) -> String { + let mut entries: Vec<_> = properties + .iter() + .map(|(name, value)| (name.to_ascii_lowercase(), value)) + .collect(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + entries + .into_iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("\0") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn project_keys_normalize_property_name_case_before_sorting() { + let left = ProjectInstance { + path: PathBuf::from(r"C:\project\app.vcxproj"), + properties: Properties::from([ + ("Zeta".to_owned(), "one".to_owned()), + ("alpha".to_owned(), "two".to_owned()), + ]), + force_import_after_cpp_targets: None, + }; + let right = ProjectInstance { + path: PathBuf::from(r"c:\PROJECT\app.vcxproj"), + properties: Properties::from([ + ("Alpha".to_owned(), "two".to_owned()), + ("zeta".to_owned(), "one".to_owned()), + ]), + force_import_after_cpp_targets: None, + }; + + assert_eq!(left.key(), right.key()); + } +} diff --git a/src/msbuild.rs b/src/msbuild.rs new file mode 100644 index 0000000..13479ba --- /dev/null +++ b/src/msbuild.rs @@ -0,0 +1,600 @@ +use anyhow::{Context, Result, bail}; +use serde_json::Value; +use std::{ + env, + ffi::OsStr, + fs, + io::Read, + path::{Path, PathBuf}, + process::{Command, ExitStatus, Stdio}, + sync::atomic::{AtomicU64, Ordering}, + thread, + time::{Duration, Instant}, +}; + +static QUERY_ID: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug, Clone)] +enum Host { + Executable(PathBuf), + CommandScript(PathBuf), +} + +#[derive(Debug, Clone)] +pub struct MsbuildRunner { + host: Host, + working_directory: PathBuf, + temporary_directory: PathBuf, + timeout: Duration, +} + +impl MsbuildRunner { + pub fn discover( + explicit: Option<&Path>, + working_directory: PathBuf, + temporary_directory: PathBuf, + timeout: Duration, + ) -> Result { + #[cfg(not(windows))] + { + let _ = (explicit, working_directory, temporary_directory, timeout); + bail!("ms2cc supports Windows MSBuild hosts only"); + } + + #[cfg(windows)] + { + let path = if let Some(explicit) = explicit { + resolve_explicit(explicit)? + } else { + discover_msbuild().context( + "could not find MSBuild; run from a developer/build shell or pass --msbuild", + )? + }; + let host = match path + .extension() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "cmd" | "bat" => Host::CommandScript(path), + _ => Host::Executable(path), + }; + Ok(Self { + host, + working_directory, + temporary_directory, + timeout, + }) + } + } + + pub fn display_name(&self) -> String { + match &self.host { + Host::Executable(path) | Host::CommandScript(path) => path.display().to_string(), + } + } + + pub fn temporary_path(&self, name: impl AsRef) -> PathBuf { + self.temporary_directory.join(name) + } + + pub fn require_query_support(&self) -> Result<()> { + let output = self.run( + &["-nologo".to_owned(), "-version".to_owned()], + "MSBuild version", + )?; + if !output.status.success() { + bail!( + "MSBuild version query failed with {}\n{}", + output.status, + format_process_output(&output.stdout, &output.stderr) + ); + } + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .chain(String::from_utf8_lossy(&output.stderr).lines()) + .filter_map(parse_version) + .next_back() + .with_context(|| { + format!( + "MSBuild did not report a parseable version\n{}", + format_process_output(&output.stdout, &output.stderr) + ) + })?; + if version.0 < 17 || (version.0 == 17 && version.1 < 8) { + bail!( + "MSBuild {}.{} is too old; ms2cc requires MSBuild 17.8 or newer", + version.0, + version.1 + ); + } + Ok(()) + } + + pub fn query_json(&self, project: &Path, arguments: &[String], label: &str) -> Result { + let id = QUERY_ID.fetch_add(1, Ordering::Relaxed); + let result_path = self.temporary_directory.join(format!("query-{id}.json")); + let mut all_arguments = Vec::with_capacity(arguments.len() + 6); + all_arguments.push(project.display().to_string()); + all_arguments.push("-noAutoResponse".to_owned()); + all_arguments.push("-nologo".to_owned()); + all_arguments.push("-verbosity:quiet".to_owned()); + all_arguments.push("-nodeReuse:false".to_owned()); + all_arguments.extend_from_slice(arguments); + all_arguments.push(format!("-getResultOutputFile:{}", result_path.display())); + + let output = self.run(&all_arguments, label)?; + if !output.status.success() { + bail!( + "{label} failed for {} with {}\n{}", + project.display(), + output.status, + format_process_output(&output.stdout, &output.stderr) + ); + } + let bytes = fs::read(&result_path).with_context(|| { + format!( + "{label} succeeded but did not produce {}", + result_path.display() + ) + })?; + serde_json::from_slice(&bytes).with_context(|| { + format!( + "MSBuild wrote invalid query JSON to {}", + result_path.display() + ) + }) + } + + fn run(&self, arguments: &[String], label: &str) -> Result { + let id = QUERY_ID.fetch_add(1, Ordering::Relaxed); + let response_path = self.temporary_directory.join(format!("args-{id}.rsp")); + let response = arguments + .iter() + .map(|argument| quote_response_argument(argument)) + .collect::>() + .join("\r\n"); + fs::write(&response_path, response) + .with_context(|| format!("failed to write {}", response_path.display()))?; + + let mut command = match &self.host { + Host::Executable(path) => { + let mut command = Command::new(path); + command.arg(format!("@{}", response_path.display())); + command + } + Host::CommandScript(path) => { + let mut command = Command::new(path); + command.arg(format!("@{}", response_path.display())); + command + } + }; + command + .current_dir(&self.working_directory) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + configure_suspended(&mut command); + + let job = ProcessJob::new().context("failed to create the MSBuild process job")?; + let mut child = command + .spawn() + .with_context(|| format!("failed to start {label} using {}", self.display_name()))?; + if let Err(error) = job.assign(&child) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error).context("failed to contain the MSBuild process tree"); + } + if let Err(error) = resume_child(&child) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error).context("failed to resume the contained MSBuild process"); + } + let stdout = child + .stdout + .take() + .context("failed to capture MSBuild stdout")?; + let stderr = child + .stderr + .take() + .context("failed to capture MSBuild stderr")?; + let stdout_reader = thread::spawn(move || read_all(stdout)); + let stderr_reader = thread::spawn(move || read_all(stderr)); + + let started = Instant::now(); + let mut next_heartbeat = Duration::from_secs(30); + let mut timed_out = false; + let status = loop { + if let Some(status) = child.try_wait().context("failed to query MSBuild status")? { + break status; + } + let elapsed = started.elapsed(); + if elapsed >= self.timeout { + timed_out = true; + job.terminate(); + let _ = child.kill(); + break child.wait().context("failed to reap timed-out MSBuild")?; + } + if elapsed >= next_heartbeat { + eprintln!("{label} still running after {}s", elapsed.as_secs()); + next_heartbeat += Duration::from_secs(30); + } + thread::sleep(Duration::from_millis(100)); + }; + + drop(job); + let stdout = stdout_reader + .join() + .map_err(|_| anyhow::anyhow!("MSBuild stdout reader panicked"))??; + let stderr = stderr_reader + .join() + .map_err(|_| anyhow::anyhow!("MSBuild stderr reader panicked"))??; + if timed_out { + bail!( + "{label} exceeded the {} second timeout\n{}", + self.timeout.as_secs(), + format_process_output(&stdout, &stderr) + ); + } + Ok(CapturedOutput { + status, + stdout, + stderr, + }) + } +} + +#[derive(Debug)] +struct CapturedOutput { + status: ExitStatus, + stdout: Vec, + stderr: Vec, +} + +fn read_all(mut reader: impl Read) -> std::io::Result> { + let mut bytes = Vec::new(); + reader.read_to_end(&mut bytes)?; + Ok(bytes) +} + +fn format_process_output(stdout: &[u8], stderr: &[u8]) -> String { + const LIMIT: usize = 32 * 1024; + let mut text = String::new(); + if !stdout.is_empty() { + text.push_str("stdout:\n"); + text.push_str(&String::from_utf8_lossy(stdout)); + } + if !stderr.is_empty() { + if !text.is_empty() { + text.push('\n'); + } + text.push_str("stderr:\n"); + text.push_str(&String::from_utf8_lossy(stderr)); + } + if text.len() > LIMIT { + let mut start = text.len() - LIMIT; + while !text.is_char_boundary(start) { + start += 1; + } + text.split_off(start) + } else { + text + } +} + +fn parse_version(line: &str) -> Option<(u32, u32)> { + let line = line.trim(); + let mut parts = line.split('.'); + let major = parts.next()?.parse().ok()?; + let minor = parts.next()?.parse().ok()?; + if parts.all(|part| part.chars().all(|character| character.is_ascii_digit())) { + Some((major, minor)) + } else { + None + } +} + +fn quote_response_argument(argument: &str) -> String { + if !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.to_owned(); + } + let mut result = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + match character { + '\\' => backslashes += 1, + '"' => { + result.push_str(&"\\".repeat(backslashes * 2 + 1)); + result.push('"'); + backslashes = 0; + } + _ => { + result.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + result.push(character); + } + } + } + result.push_str(&"\\".repeat(backslashes * 2)); + result.push('"'); + result +} + +#[cfg(windows)] +fn resolve_explicit(path: &Path) -> Result { + if path.components().count() > 1 || path.is_absolute() { + if path.is_file() { + return Ok(if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .context("failed to determine the current directory")? + .join(path) + }); + } + bail!("MSBuild host does not exist: {}", path.display()); + } + find_on_path(path.as_os_str()).with_context(|| { + format!( + "could not resolve MSBuild host '{}' through PATH", + path.display() + ) + }) +} + +#[cfg(windows)] +fn discover_msbuild() -> Option { + if let Some(value) = env::var_os("BUILD_MSBUILD_PROGRAM") + && let Some(path) = resolve_environment_candidate(&value) + { + return Some(path); + } + if let Some(value) = env::var_os("MSBUILD_EXE_PATH") { + let path = PathBuf::from(value); + if path.is_file() { + return Some(path); + } + } + if let Some(path) = find_on_path(OsStr::new("msbuild")) { + return Some(path); + } + + let vswhere = PathBuf::from( + env::var_os("ProgramFiles(x86)").unwrap_or_else(|| r"C:\Program Files (x86)".into()), + ) + .join(r"Microsoft Visual Studio\Installer\vswhere.exe"); + if !vswhere.is_file() { + return None; + } + let output = Command::new(vswhere) + .args([ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.Component.MSBuild", + "-find", + r"MSBuild\**\Bin\MSBuild.exe", + ]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .find(|path| path.is_file()) +} + +#[cfg(windows)] +fn resolve_environment_candidate(value: &OsStr) -> Option { + let path = PathBuf::from(value); + if path.is_file() { + Some(path) + } else { + find_on_path(value) + } +} + +#[cfg(windows)] +fn find_on_path(name: &OsStr) -> Option { + let name_path = Path::new(name); + if name_path.is_file() { + return Some(name_path.to_path_buf()); + } + + let has_extension = name_path.extension().is_some(); + let extensions: Vec = if has_extension { + vec![String::new()] + } else { + env::var("PATHEXT") + .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_owned()) + .split(';') + .map(str::to_owned) + .collect() + }; + + env::split_paths(&env::var_os("PATH")?).find_map(|directory| { + extensions.iter().find_map(|extension| { + let candidate = if extension.is_empty() { + directory.join(name_path) + } else { + directory.join(format!("{}{}", name_path.to_string_lossy(), extension)) + }; + candidate.is_file().then_some(candidate) + }) + }) +} + +#[cfg(windows)] +fn configure_suspended(command: &mut Command) { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_SUSPENDED; + + command.creation_flags(CREATE_SUSPENDED); +} + +#[cfg(not(windows))] +fn configure_suspended(_command: &mut Command) {} + +#[cfg(windows)] +fn resume_child(child: &std::process::Child) -> Result<()> { + use std::mem::size_of; + use windows_sys::Win32::{ + Foundation::{CloseHandle, INVALID_HANDLE_VALUE}, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, + Thread32Next, + }, + Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME}, + }, + }; + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()).context("CreateToolhelp32Snapshot failed"); + } + let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() }; + entry.dwSize = size_of::() as u32; + let mut found = false; + let mut success = unsafe { Thread32First(snapshot, &mut entry) } != 0; + while success { + if entry.th32OwnerProcessID == child.id() { + let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; + if thread.is_null() { + unsafe { CloseHandle(snapshot) }; + return Err(std::io::Error::last_os_error()).context("OpenThread failed"); + } + let resumed = unsafe { ResumeThread(thread) }; + unsafe { CloseHandle(thread) }; + if resumed == u32::MAX { + unsafe { CloseHandle(snapshot) }; + return Err(std::io::Error::last_os_error()).context("ResumeThread failed"); + } + found = true; + break; + } + success = unsafe { Thread32Next(snapshot, &mut entry) } != 0; + } + unsafe { CloseHandle(snapshot) }; + if !found { + bail!("could not locate the suspended MSBuild process thread"); + } + Ok(()) +} + +#[cfg(not(windows))] +fn resume_child(_child: &std::process::Child) -> Result<()> { + Ok(()) +} + +#[cfg(windows)] +struct ProcessJob(windows_sys::Win32::Foundation::HANDLE); + +#[cfg(windows)] +impl ProcessJob { + fn new() -> Result { + use std::{mem::size_of, ptr}; + use windows_sys::Win32::{ + Foundation::CloseHandle, + System::JobObjects::{ + CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + }; + + let handle = unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }; + if handle.is_null() { + return Err(std::io::Error::last_os_error()).context("CreateJobObjectW failed"); + } + + let mut information: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + information.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let configured = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + (&information as *const JOBOBJECT_EXTENDED_LIMIT_INFORMATION).cast(), + size_of::() as u32, + ) + }; + if configured == 0 { + unsafe { CloseHandle(handle) }; + return Err(std::io::Error::last_os_error()).context("SetInformationJobObject failed"); + } + Ok(Self(handle)) + } + + fn assign(&self, child: &std::process::Child) -> Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + let assigned = unsafe { AssignProcessToJobObject(self.0, child.as_raw_handle() as _) }; + if assigned == 0 { + return Err(std::io::Error::last_os_error()).context("AssignProcessToJobObject failed"); + } + Ok(()) + } + + fn terminate(&self) { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + unsafe { + TerminateJobObject(self.0, 1); + } + } +} + +#[cfg(windows)] +impl Drop for ProcessJob { + fn drop(&mut self) { + use windows_sys::Win32::Foundation::CloseHandle; + unsafe { + CloseHandle(self.0); + } + } +} + +#[cfg(not(windows))] +struct ProcessJob; + +#[cfg(not(windows))] +impl ProcessJob { + fn new() -> Result { + Ok(Self) + } + + fn assign(&self, _child: &std::process::Child) -> Result<()> { + Ok(()) + } + + fn terminate(&self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_msbuild_versions() { + assert_eq!(parse_version("17.14.23.42201"), Some((17, 14))); + assert_eq!(parse_version("Microsoft Build Engine"), None); + assert_eq!(parse_version("17.x"), None); + } + + #[test] + fn quotes_response_arguments() { + assert_eq!(quote_response_argument("-nologo"), "-nologo"); + assert_eq!( + quote_response_argument(r"C:\Program Files\MSBuild.exe"), + r#""C:\Program Files\MSBuild.exe""# + ); + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..508f0f4 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,176 @@ +use anyhow::{Context, Result, bail}; +#[cfg(not(windows))] +use std::fs; +use std::{ + io::{BufWriter, Write}, + path::Path, +}; +use tempfile::NamedTempFile; + +use crate::model::{CommandRecord, CompileCommand}; +use crate::paths; + +pub fn prepare(mut records: Vec) -> Result> { + records.sort_by(|left, right| { + left.command + .file + .to_ascii_lowercase() + .cmp(&right.command.file.to_ascii_lowercase()) + .then_with(|| left.project.cmp(&right.project)) + .then_with(|| left.command.arguments.cmp(&right.command.arguments)) + }); + + for record in &records { + if record.command.directory.is_empty() { + bail!("empty working directory for {}", record.command.file); + } + if record.command.file.is_empty() { + bail!("compilation database entry has an empty source path"); + } + if record.command.arguments.len() < 2 { + bail!( + "compiler argument vector is incomplete for {}", + record.command.file + ); + } + #[cfg(windows)] + { + if !Path::new(&record.command.directory).is_absolute() { + bail!( + "working directory is not absolute for {}: {}", + record.command.file, + record.command.directory + ); + } + if !Path::new(&record.command.file).is_absolute() { + bail!("source path is not absolute: {}", record.command.file); + } + if !Path::new(&record.command.arguments[0]).is_absolute() { + bail!( + "compiler path is not absolute for {}: {}", + record.command.file, + record.command.arguments[0] + ); + } + } + if record + .command + .arguments + .iter() + .any(|argument| argument.contains("$(") || argument.contains("%(")) + { + bail!( + "unexpanded MSBuild expression remains in command for {}", + record.command.file + ); + } + if !contains_source_argument(&record.command) { + bail!( + "compiler arguments do not contain their translation unit {}", + record.command.file + ); + } + } + Ok(records.into_iter().map(|record| record.command).collect()) +} + +fn contains_source_argument(command: &CompileCommand) -> bool { + let expected = paths::key(Path::new(&command.file)); + command.arguments.iter().skip(1).any(|argument| { + if paths::key(Path::new(argument)) == expected { + return true; + } + if let Some(candidate_prefix) = argument.get(..3) + && ["/Tc", "-Tc", "/Tp", "-Tp"] + .iter() + .any(|prefix| candidate_prefix.eq_ignore_ascii_case(prefix)) + { + return paths::key(Path::new(&argument[3..])) == expected; + } + false + }) +} + +pub fn write_atomic(path: &Path, commands: &[CompileCommand]) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + if !parent.is_dir() { + bail!("output directory does not exist: {}", parent.display()); + } + + let mut temporary = NamedTempFile::new_in(parent) + .with_context(|| format!("failed to create a temporary file in {}", parent.display()))?; + { + let mut writer = BufWriter::new(temporary.as_file_mut()); + serde_json::to_writer_pretty(&mut writer, commands) + .context("failed to serialize the compilation database")?; + writer.write_all(b"\n")?; + writer.flush()?; + } + temporary.as_file().sync_all()?; + let (_, temporary_path) = temporary + .keep() + .context("failed to retain the completed temporary database")?; + replace_file(&temporary_path, path).with_context(|| { + format!( + "failed to atomically replace {} with {}", + path.display(), + temporary_path.display() + ) + }) +} + +#[cfg(windows)] +fn replace_file(source: &Path, destination: &Path) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, + }; + + let source: Vec = source.as_os_str().encode_wide().chain([0]).collect(); + let destination: Vec = destination.as_os_str().encode_wide().chain([0]).collect(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + return Err(std::io::Error::last_os_error()).context("MoveFileExW failed"); + } + Ok(()) +} + +#[cfg(not(windows))] +fn replace_file(source: &Path, destination: &Path) -> Result<()> { + fs::rename(source, destination).context("rename failed") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Properties; + + fn record(file: &str) -> CommandRecord { + let file = format!(r"C:\project\{file}"); + CommandRecord { + command: CompileCommand { + directory: r"C:\project".to_owned(), + arguments: vec![r"C:\tools\cl.exe".to_owned(), file.clone()], + file, + }, + project: r"C:\project\p.vcxproj".into(), + properties: Properties::new(), + } + } + + #[test] + fn sorts_commands_deterministically() { + let output = prepare(vec![record("B.cpp"), record("a.cpp")]).unwrap(); + assert_eq!(output[0].file, r"C:\project\a.cpp"); + assert_eq!(output[1].file, r"C:\project\B.cpp"); + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..70185d5 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,84 @@ +use std::path::{Component, Path, PathBuf}; + +pub fn absolute_lexical(path: &Path, base: &Path) -> PathBuf { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + clean(&joined) +} + +pub fn clean(path: &Path) -> PathBuf { + let mut prefix = None; + let mut has_root = false; + let mut parts = Vec::new(); + + for component in path.components() { + match component { + Component::Prefix(value) => prefix = Some(value.as_os_str().to_os_string()), + Component::RootDir => has_root = true, + Component::CurDir => {} + Component::ParentDir => { + if parts.last().is_some_and(|part| part != "..") { + parts.pop(); + } else if !has_root { + parts.push("..".into()); + } + } + Component::Normal(value) => parts.push(value.to_os_string()), + } + } + + let mut result = PathBuf::new(); + if let Some(value) = prefix { + result.push(value); + } + if has_root { + result.push("\\"); + } + for part in parts { + result.push(part); + } + result +} + +pub fn display(path: &Path) -> String { + let value = clean(path).to_string_lossy().replace('/', "\\"); + if let Some(rest) = value.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = value.strip_prefix(r"\\?\") { + rest.to_owned() + } else { + value + } +} + +pub fn key(path: &Path) -> String { + display(path).to_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cleans_windows_parent_and_current_components() { + assert_eq!( + display(Path::new(r"C:\work\src\.\sub\..\main.cpp")), + r"C:\work\src\main.cpp" + ); + } + + #[test] + fn removes_verbatim_windows_prefixes() { + assert_eq!( + display(Path::new(r"\\?\C:\work\main.cpp")), + r"C:\work\main.cpp" + ); + assert_eq!( + display(Path::new(r"\\?\UNC\server\share\main.cpp")), + r"\\server\share\main.cpp" + ); + } +} diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 0000000..1135325 --- /dev/null +++ b/src/query.rs @@ -0,0 +1,55 @@ +use anyhow::{Context, Result, bail}; +use serde_json::{Map, Value}; + +pub fn item_groups<'a>(document: &'a Value, name: &str) -> Result<&'a [Value]> { + let items = document + .get("Items") + .and_then(Value::as_object) + .context("MSBuild query JSON has no 'Items' object")?; + match get_case_insensitive(items, name) { + Some(value) => value + .as_array() + .map(Vec::as_slice) + .with_context(|| format!("MSBuild item group '{name}' is not an array")), + None => Ok(&[]), + } +} + +pub fn target_items<'a>(document: &'a Value, target: &str) -> Result<&'a [Value]> { + let targets = document + .get("TargetResults") + .and_then(Value::as_object) + .context("MSBuild query JSON has no 'TargetResults' object")?; + let target_result = get_case_insensitive(targets, target) + .with_context(|| format!("MSBuild query JSON has no result for target '{target}'"))?; + let result = metadata(target_result, "Result").unwrap_or_default(); + if !result.eq_ignore_ascii_case("Success") { + bail!("MSBuild target '{target}' reported result '{result}'"); + } + target_result + .get("Items") + .and_then(Value::as_array) + .map(Vec::as_slice) + .with_context(|| format!("MSBuild target '{target}' has no item array")) +} + +pub fn metadata<'a>(item: &'a Value, name: &str) -> Option<&'a str> { + item.as_object() + .and_then(|object| get_case_insensitive(object, name)) + .and_then(Value::as_str) +} + +pub fn property<'a>(document: &'a Value, name: &str) -> Option<&'a str> { + document + .get("Properties") + .and_then(Value::as_object) + .and_then(|object| get_case_insensitive(object, name)) + .and_then(Value::as_str) +} + +fn get_case_insensitive<'a>(object: &'a Map, name: &str) -> Option<&'a Value> { + object + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value) +} diff --git a/src/solution.rs b/src/solution.rs new file mode 100644 index 0000000..8198e1b --- /dev/null +++ b/src/solution.rs @@ -0,0 +1,1049 @@ +use anyhow::{Context, Result, bail}; +use quick_xml::{Reader, XmlVersion, events::Event}; +use std::{ + collections::{HashMap, HashSet}, + fs, + path::{Path, PathBuf}, +}; + +use crate::{ + model::{ProjectInstance, Properties}, + paths, +}; + +#[derive(Debug)] +pub struct SolutionRoots { + pub projects: Vec, + pub protected_paths: Vec, +} + +pub fn projects(path: &Path, base_properties: &Properties) -> Result { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "sln" => parse_sln(path, base_properties), + "slnx" => parse_slnx(path, base_properties), + _ => Ok(SolutionRoots { + projects: vec![ProjectInstance { + path: path.to_path_buf(), + properties: base_properties.clone(), + force_import_after_cpp_targets: None, + }], + protected_paths: vec![path.to_path_buf()], + }), + } +} + +fn parse_sln(path: &Path, base_properties: &Properties) -> Result { + let text = fs::read_to_string(path) + .with_context(|| format!("failed to read solution {}", path.display()))?; + let mut projects = Vec::new(); + let mut solution_configurations = Vec::new(); + let mut mappings: HashMap<(String, String), ProjectMapping> = HashMap::new(); + let mut section = ""; + + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.starts_with("Project(") { + let quoted = quoted_fields(line); + if quoted.len() >= 4 { + projects.push(SolutionProject { + guid: quoted[3].to_ascii_uppercase(), + relative_path: quoted[2].to_owned(), + traversable: is_project_path("ed[2]), + }); + } + continue; + } + if line.starts_with("GlobalSection(SolutionConfigurationPlatforms)") { + section = "solution"; + continue; + } + if line.starts_with("GlobalSection(ProjectConfigurationPlatforms)") { + section = "projects"; + continue; + } + if line.starts_with("EndGlobalSection") { + section = ""; + continue; + } + if section == "solution" + && let Some((left, _)) = line.split_once('=') + { + solution_configurations.push(left.trim().to_owned()); + } else if section == "projects" + && let Some((left, right)) = line.split_once('=') + { + parse_project_mapping(left.trim(), right.trim(), &mut mappings); + } + } + + let selected = select_solution_configuration(base_properties, &solution_configurations)?; + let solution_dir = path.parent().unwrap_or(Path::new(".")); + let configuration_contents = + solution_configuration_contents(solution_dir, &projects, &mappings, &selected); + let protected_paths = projects + .iter() + .filter(|project| project.traversable) + .map(|project| { + paths::absolute_lexical( + &solution_dir.join(project.relative_path.replace('/', "\\")), + solution_dir, + ) + }) + .collect(); + let mut result = Vec::new(); + for project in projects { + if !project.traversable { + continue; + } + let mapping = mappings.get(&(project.guid.clone(), selected.to_ascii_lowercase())); + if mappings + .keys() + .any(|(candidate, _)| candidate == &project.guid) + { + match mapping { + Some(mapping) if mapping.build => {} + _ => continue, + } + } + + let mut properties = base_properties.clone(); + if let Some(mapping) = mapping { + set_property(&mut properties, "Configuration", &mapping.configuration); + set_property(&mut properties, "Platform", &mapping.platform); + } else if let Some((configuration, platform)) = selected.split_once('|') { + set_property_if_absent(&mut properties, "Configuration", configuration); + set_property_if_absent(&mut properties, "Platform", platform); + } + add_solution_properties(&mut properties, path, Some(&configuration_contents)); + let project_path = solution_dir.join(project.relative_path.replace('/', "\\")); + if !project_path.is_file() { + bail!( + "solution {} references missing project {}", + path.display(), + project_path.display() + ); + } + result.push(ProjectInstance { + path: paths::absolute_lexical(&project_path, solution_dir), + properties, + force_import_after_cpp_targets: None, + }); + } + Ok(SolutionRoots { + projects: result, + protected_paths, + }) +} + +#[derive(Debug)] +struct SolutionProject { + guid: String, + relative_path: String, + traversable: bool, +} + +#[derive(Debug, Clone, Default)] +struct ProjectMapping { + configuration: String, + platform: String, + build: bool, +} + +fn solution_configuration_contents( + solution_dir: &Path, + projects: &[SolutionProject], + mappings: &HashMap<(String, String), ProjectMapping>, + selected: &str, +) -> String { + let selected = selected.to_ascii_lowercase(); + let mut xml = String::from(""); + for project in projects { + let Some(mapping) = mappings.get(&(project.guid.clone(), selected.clone())) else { + continue; + }; + let absolute_path = paths::absolute_lexical( + &solution_dir.join(project.relative_path.replace('/', "\\")), + solution_dir, + ); + xml.push_str(""); + xml.push_str(&escape_xml(&format!( + "{}|{}", + mapping.configuration, mapping.platform + ))); + xml.push_str(""); + } + xml.push_str(""); + xml +} + +fn add_solution_properties( + properties: &mut Properties, + solution_path: &Path, + configuration_contents: Option<&str>, +) { + let absolute_path = paths::absolute_lexical( + solution_path, + solution_path.parent().unwrap_or(Path::new(".")), + ); + let mut directory = paths::display(absolute_path.parent().unwrap_or(Path::new("."))); + if !directory.ends_with('\\') { + directory.push('\\'); + } + set_property(properties, "BuildingSolutionFile", "true"); + set_property(properties, "SolutionDir", &directory); + set_property(properties, "SolutionPath", &paths::display(&absolute_path)); + set_property( + properties, + "SolutionName", + absolute_path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or_default(), + ); + set_property( + properties, + "SolutionFileName", + absolute_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(), + ); + set_property( + properties, + "SolutionExt", + absolute_path + .extension() + .and_then(|value| value.to_str()) + .map(|value| format!(".{value}")) + .as_deref() + .unwrap_or_default(), + ); + if let Some(contents) = configuration_contents { + set_property(properties, "CurrentSolutionConfigurationContents", contents); + } +} + +fn escape_xml(value: &str) -> String { + let mut escaped = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(character), + } + } + escaped +} + +fn parse_project_mapping( + left: &str, + right: &str, + mappings: &mut HashMap<(String, String), ProjectMapping>, +) { + let Some(close) = left.find('}') else { + return; + }; + let guid = left[..=close].to_ascii_uppercase(); + let suffix = left[close + 1..].trim_start_matches('.'); + let (solution_configuration, kind) = if let Some(value) = suffix.strip_suffix(".ActiveCfg") { + (value, "active") + } else if let Some(value) = suffix.strip_suffix(".Build.0") { + (value, "build") + } else { + return; + }; + let entry = mappings + .entry((guid, solution_configuration.to_ascii_lowercase())) + .or_default(); + if kind == "build" { + entry.build = true; + } + if let Some((configuration, platform)) = right.split_once('|') { + entry.configuration = configuration.to_owned(); + entry.platform = platform.to_owned(); + } +} + +fn select_solution_configuration( + properties: &Properties, + configurations: &[String], +) -> Result { + let requested_configuration = get_property(properties, "Configuration"); + let requested_platform = get_property(properties, "Platform"); + if let (Some(configuration), Some(platform)) = (requested_configuration, requested_platform) { + let selected = format!("{configuration}|{platform}"); + if configurations.is_empty() + || configurations + .iter() + .any(|value| value.eq_ignore_ascii_case(&selected)) + { + return Ok(selected); + } + bail!( + "solution does not define configuration/platform '{}'; available values: {}", + selected, + configurations.join(", ") + ); + } + + let candidates: Vec<_> = configurations + .iter() + .filter(|value| { + let (configuration, platform) = value.split_once('|').unwrap_or((value.as_str(), "")); + requested_configuration + .is_none_or(|requested| requested.eq_ignore_ascii_case(configuration)) + && requested_platform + .is_none_or(|requested| requested.eq_ignore_ascii_case(platform)) + }) + .collect(); + match candidates.as_slice() { + [only] => Ok((*only).clone()), + [] if configurations.is_empty() => Ok(format!( + "{}|{}", + requested_configuration.unwrap_or(""), + requested_platform.unwrap_or("") + )), + [] => bail!( + "no solution configuration matches the requested configuration/platform; available values: {}", + configurations.join(", ") + ), + _ => bail!( + "solution configuration is ambiguous; pass --configuration and --platform (available: {})", + candidates + .iter() + .map(|value| value.as_str()) + .collect::>() + .join(", ") + ), + } +} + +fn parse_slnx(path: &Path, base_properties: &Properties) -> Result { + let mut reader = Reader::from_file(path) + .with_context(|| format!("failed to read solution {}", path.display()))?; + reader.config_mut().trim_text(true); + let mut buffer = Vec::new(); + let mut stack = Vec::new(); + let mut build_types = Vec::new(); + let mut platforms = Vec::new(); + let mut project_types = Vec::new(); + let mut projects = Vec::new(); + let mut current_project_type = None; + let mut current_project = None; + + loop { + match reader.read_event_into(&mut buffer) { + Ok(Event::Start(event)) => { + let name = event.name().as_ref().to_owned(); + let attributes = xml_attributes(&event)?; + let parent = stack.last().map(String::as_str); + process_slnx_element( + &name, + &attributes, + parent, + false, + &mut build_types, + &mut platforms, + &mut current_project_type, + &mut current_project, + &mut project_types, + &mut projects, + )?; + stack.push(name); + } + Ok(Event::Empty(event)) => { + let name = event.name().as_ref().to_owned(); + let attributes = xml_attributes(&event)?; + let parent = stack.last().map(String::as_str); + process_slnx_element( + &name, + &attributes, + parent, + true, + &mut build_types, + &mut platforms, + &mut current_project_type, + &mut current_project, + &mut project_types, + &mut projects, + )?; + } + Ok(Event::End(event)) => { + let name = event.name().as_ref().to_owned(); + if name.eq_ignore_ascii_case("Project") { + if let Some(project) = current_project.take() { + projects.push(project); + } + } else if name.eq_ignore_ascii_case("ProjectType") + && let Some(project_type) = current_project_type.take() + { + project_types.push(project_type); + } + stack.pop(); + } + Ok(Event::Eof) => break, + Ok(_) => {} + Err(error) => { + bail!("failed to parse solution {}: {error}", path.display()); + } + } + buffer.clear(); + } + + let directory = path.parent().unwrap_or(Path::new(".")); + let (solution_build_type, solution_platform) = + select_slnx_configuration(base_properties, &build_types, &platforms)?; + let mut resolved = Vec::new(); + for project in projects { + if !is_project_path(&project.path) { + continue; + } + let extension = Path::new(&project.path) + .extension() + .and_then(|value| value.to_str()); + let has_custom_type = + find_project_type(&project_types, project.project_type.as_deref(), extension).is_some(); + let mut rules = + if !has_custom_type && project.path.to_ascii_lowercase().ends_with(".vcxproj") { + builtin_vcxproj_rules() + } else { + Vec::new() + }; + append_project_type_rules( + &mut rules, + &project_types, + project.project_type.as_deref(), + extension, + &mut HashSet::new(), + ); + append_default_project_type_rules(&mut rules, &project_types, &mut HashSet::new()); + rules.extend(project.rules); + + let build = rule_value( + &rules, + SlnxDimension::Build, + &solution_build_type, + &solution_platform, + ) + .map(|value| value.parse::()) + .transpose() + .context("invalid boolean in .slnx Build rule")? + .unwrap_or(true); + let project_build_type = mapped_dimension( + &rules, + SlnxDimension::BuildType, + &solution_build_type, + &solution_platform, + ) + .unwrap_or_else(|| solution_build_type.clone()); + let mut project_platform = mapped_dimension( + &rules, + SlnxDimension::Platform, + &solution_build_type, + &solution_platform, + ) + .unwrap_or_else(|| solution_platform.clone()); + if project_platform == "?" { + project_platform.clear(); + } + + let candidate = directory.join(project.path.replace('/', "\\")); + if !candidate.is_file() { + bail!( + "solution {} references missing project {}", + path.display(), + candidate.display() + ); + } + let canonical = paths::absolute_lexical(&candidate, directory); + resolved.push(ResolvedSlnxProject { + path: canonical, + configuration: project_build_type, + platform: project_platform, + build, + }); + } + + let configuration_contents = slnx_configuration_contents(&resolved); + let protected_paths = resolved + .iter() + .map(|project| project.path.clone()) + .collect(); + let mut seen = HashSet::new(); + let mut result = Vec::new(); + for project in resolved { + if !project.build { + continue; + } + let canonical = project.path; + if seen.insert(canonical.to_string_lossy().to_ascii_lowercase()) { + let mut properties = base_properties.clone(); + if !project.configuration.is_empty() { + set_property(&mut properties, "Configuration", &project.configuration); + } + if !project.platform.is_empty() { + set_property(&mut properties, "Platform", &project.platform); + } + add_solution_properties(&mut properties, path, Some(&configuration_contents)); + result.push(ProjectInstance { + path: canonical, + properties, + force_import_after_cpp_targets: None, + }); + } + } + Ok(SolutionRoots { + projects: result, + protected_paths, + }) +} + +#[derive(Debug)] +struct ResolvedSlnxProject { + path: PathBuf, + configuration: String, + platform: String, + build: bool, +} + +fn slnx_configuration_contents(projects: &[ResolvedSlnxProject]) -> String { + let mut xml = String::from(""); + for (index, project) in projects.iter().enumerate() { + xml.push_str(""); + xml.push_str(&escape_xml(&format_project_configuration( + &project.configuration, + &project.platform, + ))); + xml.push_str(""); + } + xml.push_str(""); + xml +} + +fn is_project_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|value| value.to_ascii_lowercase().ends_with("proj")) +} + +#[allow(clippy::too_many_arguments)] +fn process_slnx_element( + name: &str, + attributes: &HashMap, + parent: Option<&str>, + empty: bool, + build_types: &mut Vec, + platforms: &mut Vec, + current_project_type: &mut Option, + current_project: &mut Option, + project_types: &mut Vec, + projects: &mut Vec, +) -> Result<()> { + if name.eq_ignore_ascii_case("ProjectType") + && parent.is_some_and(|value| value.eq_ignore_ascii_case("Configurations")) + { + *current_project_type = Some(SlnxProjectType::from_attributes(attributes)?); + if empty && let Some(project_type) = current_project_type.take() { + project_types.push(project_type); + } + } else if name.eq_ignore_ascii_case("Project") { + *current_project = Some(SlnxProject::from_attributes(attributes)?); + if empty && let Some(project) = current_project.take() { + projects.push(project); + } + } else if current_project_type.is_none() + && current_project.is_none() + && parent.is_some_and(|value| value.eq_ignore_ascii_case("Configurations")) + && name.eq_ignore_ascii_case("BuildType") + { + push_named_dimension(build_types, attributes, "BuildType")?; + } else if current_project_type.is_none() + && current_project.is_none() + && parent.is_some_and(|value| value.eq_ignore_ascii_case("Configurations")) + && name.eq_ignore_ascii_case("Platform") + { + push_named_dimension(platforms, attributes, "Platform")?; + } else if let Some(rule) = slnx_rule(name, attributes)? { + if let Some(project) = current_project { + project.rules.push(rule); + } else if let Some(project_type) = current_project_type { + project_type.rules.push(rule); + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SlnxDimension { + BuildType, + Platform, + Build, + Deploy, +} + +#[derive(Debug, Clone)] +struct SlnxRule { + dimension: SlnxDimension, + solution_build_type: String, + solution_platform: String, + project_value: String, +} + +#[derive(Debug)] +struct SlnxProject { + path: String, + project_type: Option, + rules: Vec, +} + +impl SlnxProject { + fn from_attributes(attributes: &HashMap) -> Result { + Ok(Self { + path: required_attribute(attributes, "Path", "Project")?.to_owned(), + project_type: attribute(attributes, "Type").map(str::to_owned), + rules: Vec::new(), + }) + } +} + +#[derive(Debug)] +struct SlnxProjectType { + type_id: Option, + name: Option, + extension: Option, + based_on: Option, + is_buildable: bool, + supports_platform: bool, + rules: Vec, +} + +impl SlnxProjectType { + fn from_attributes(attributes: &HashMap) -> Result { + Ok(Self { + type_id: attribute(attributes, "TypeId").map(str::to_owned), + name: attribute(attributes, "Name").map(str::to_owned), + extension: attribute(attributes, "Extension").map(str::to_owned), + based_on: attribute(attributes, "BasedOn").map(str::to_owned), + is_buildable: attribute(attributes, "IsBuildable") + .map(str::parse) + .transpose() + .context("invalid .slnx ProjectType IsBuildable value")? + .unwrap_or(true), + supports_platform: attribute(attributes, "SupportsPlatform") + .map(str::parse) + .transpose() + .context("invalid .slnx ProjectType SupportsPlatform value")? + .unwrap_or(true), + rules: Vec::new(), + }) + } + + fn matches(&self, name: Option<&str>, extension: Option<&str>) -> bool { + name.is_some_and(|name| { + self.type_id + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + || self + .name + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case(name)) + }) || name.is_none() + && extension.is_some_and(|extension| { + self.extension.as_deref().is_some_and(|value| { + value + .trim_start_matches('.') + .eq_ignore_ascii_case(extension.trim_start_matches('.')) + }) + }) + } + + fn is_default(&self) -> bool { + self.type_id.is_none() && self.name.is_none() && self.extension.is_none() + } +} + +fn xml_attributes(event: &quick_xml::events::BytesStart<'_>) -> Result> { + let mut result = HashMap::new(); + for attribute in event.attributes() { + let attribute = attribute.context("invalid .slnx attribute")?; + let name = attribute.key.as_ref().to_owned(); + let value = attribute + .normalized_value(XmlVersion::Implicit1_0) + .context("invalid .slnx attribute value")? + .into_owned(); + result.insert(name, value); + } + Ok(result) +} + +fn attribute<'a>(attributes: &'a HashMap, name: &str) -> Option<&'a str> { + attributes + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +fn required_attribute<'a>( + attributes: &'a HashMap, + name: &str, + element: &str, +) -> Result<&'a str> { + attribute(attributes, name) + .with_context(|| format!(".slnx {element} element is missing {name}")) +} + +fn push_named_dimension( + values: &mut Vec, + attributes: &HashMap, + element: &str, +) -> Result<()> { + let value = required_attribute(attributes, "Name", element)?; + if !values.iter().any(|item| item.eq_ignore_ascii_case(value)) { + values.push(value.to_owned()); + } + Ok(()) +} + +fn slnx_rule(name: &str, attributes: &HashMap) -> Result> { + let dimension = if name.eq_ignore_ascii_case("BuildType") { + SlnxDimension::BuildType + } else if name.eq_ignore_ascii_case("Platform") { + SlnxDimension::Platform + } else if name.eq_ignore_ascii_case("Build") { + SlnxDimension::Build + } else if name.eq_ignore_ascii_case("Deploy") { + SlnxDimension::Deploy + } else { + return Ok(None); + }; + let (solution_build_type, solution_platform) = + parse_slnx_rule_scope(attribute(attributes, "Solution").unwrap_or_default())?; + let project_value = attribute(attributes, "Project") + .map(str::to_owned) + .or_else(|| { + matches!(dimension, SlnxDimension::Build | SlnxDimension::Deploy) + .then(|| "true".to_owned()) + }) + .with_context(|| format!(".slnx {name} rule is missing Project"))?; + Ok(Some(SlnxRule { + dimension, + solution_build_type, + solution_platform, + project_value, + })) +} + +fn parse_slnx_rule_scope(value: &str) -> Result<(String, String)> { + if value.is_empty() { + return Ok((String::new(), String::new())); + } + let (build_type, platform) = value + .split_once('|') + .with_context(|| format!("invalid .slnx configuration rule scope '{value}'"))?; + if build_type.is_empty() || platform.is_empty() { + bail!("invalid .slnx configuration rule scope '{value}'"); + } + Ok(( + if build_type != "*" { + build_type.to_owned() + } else { + String::new() + }, + if platform != "*" { + platform.to_owned() + } else { + String::new() + }, + )) +} + +fn select_slnx_configuration( + properties: &Properties, + build_types: &[String], + platforms: &[String], +) -> Result<(String, String)> { + Ok(( + select_slnx_dimension( + get_property(properties, "Configuration"), + build_types, + "configuration", + )?, + select_slnx_dimension(get_property(properties, "Platform"), platforms, "platform")?, + )) +} + +fn select_slnx_dimension( + requested: Option<&str>, + available: &[String], + name: &str, +) -> Result { + if let Some(requested) = requested { + if available.is_empty() { + return Ok(requested.to_owned()); + } + if let Some(value) = available.iter().find(|value| { + if name == "platform" { + canonical_platform(value) == canonical_platform(requested) + } else { + value.eq_ignore_ascii_case(requested) + } + }) { + return Ok(value.clone()); + } + bail!( + ".slnx does not define {name} '{requested}'; available values: {}", + available.join(", ") + ); + } + match available { + [] => Ok(String::new()), + [only] => Ok(only.clone()), + _ => bail!( + ".slnx {name} is ambiguous; specify it explicitly (available: {})", + available.join(", ") + ), + } +} + +fn builtin_vcxproj_rules() -> Vec { + vec![ + SlnxRule { + dimension: SlnxDimension::Platform, + solution_build_type: String::new(), + solution_platform: "Any CPU".to_owned(), + project_value: "x64".to_owned(), + }, + SlnxRule { + dimension: SlnxDimension::Platform, + solution_build_type: String::new(), + solution_platform: "x86".to_owned(), + project_value: "Win32".to_owned(), + }, + ] +} + +fn append_project_type_rules( + destination: &mut Vec, + project_types: &[SlnxProjectType], + project_type_name: Option<&str>, + extension: Option<&str>, + active: &mut HashSet, +) { + let Some(project_type) = find_project_type(project_types, project_type_name, extension) else { + return; + }; + append_project_type_definition(destination, project_types, project_type, active); +} + +fn find_project_type<'a>( + project_types: &'a [SlnxProjectType], + project_type_name: Option<&str>, + extension: Option<&str>, +) -> Option<&'a SlnxProjectType> { + project_types + .iter() + .rev() + .find(|project_type| project_type.matches(project_type_name, extension)) +} + +fn append_default_project_type_rules( + destination: &mut Vec, + project_types: &[SlnxProjectType], + active: &mut HashSet, +) { + if let Some(project_type) = project_types + .iter() + .rev() + .find(|project_type| project_type.is_default()) + { + append_project_type_definition(destination, project_types, project_type, active); + } +} + +fn append_project_type_definition( + destination: &mut Vec, + project_types: &[SlnxProjectType], + project_type: &SlnxProjectType, + active: &mut HashSet, +) { + let key = project_type + .type_id + .as_deref() + .or(project_type.name.as_deref()) + .or(project_type.extension.as_deref()) + .unwrap_or_default() + .to_ascii_lowercase(); + if !active.insert(key.clone()) { + return; + } + if let Some(based_on) = &project_type.based_on { + if is_builtin_vcxproj_type(based_on) { + destination.extend(builtin_vcxproj_rules()); + } else { + append_project_type_rules(destination, project_types, Some(based_on), None, active); + } + } + if !project_type.is_buildable { + destination.push(SlnxRule { + dimension: SlnxDimension::Build, + solution_build_type: String::new(), + solution_platform: String::new(), + project_value: "false".to_owned(), + }); + } + if !project_type.supports_platform { + destination.push(SlnxRule { + dimension: SlnxDimension::Platform, + solution_build_type: String::new(), + solution_platform: String::new(), + project_value: "?".to_owned(), + }); + } + destination.extend(project_type.rules.iter().cloned()); + active.remove(&key); +} + +fn is_builtin_vcxproj_type(value: &str) -> bool { + value.eq_ignore_ascii_case("VC") + || value.eq_ignore_ascii_case("vcxproj") + || value.eq_ignore_ascii_case(".vcxproj") + || value + .trim_matches(['{', '}']) + .eq_ignore_ascii_case("8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942") +} + +fn format_project_configuration(configuration: &str, platform: &str) -> String { + if platform.is_empty() { + configuration.to_owned() + } else { + format!("{configuration}|{platform}") + } +} + +fn mapped_dimension( + rules: &[SlnxRule], + dimension: SlnxDimension, + solution_build_type: &str, + solution_platform: &str, +) -> Option { + rule_value(rules, dimension, solution_build_type, solution_platform).map(|value| { + if value == "*" { + match dimension { + SlnxDimension::BuildType => solution_build_type, + SlnxDimension::Platform => solution_platform, + _ => value, + } + .to_owned() + } else { + value.to_owned() + } + }) +} + +fn rule_value<'a>( + rules: &'a [SlnxRule], + dimension: SlnxDimension, + solution_build_type: &str, + solution_platform: &str, +) -> Option<&'a str> { + rules + .iter() + .rev() + .find(|rule| { + rule.dimension == dimension + && (rule.solution_build_type.is_empty() + || rule + .solution_build_type + .eq_ignore_ascii_case(solution_build_type)) + && (rule.solution_platform.is_empty() + || canonical_platform(&rule.solution_platform) + == canonical_platform(solution_platform)) + }) + .map(|rule| rule.project_value.as_str()) +} + +fn canonical_platform(value: &str) -> String { + if value.eq_ignore_ascii_case("Any CPU") || value.eq_ignore_ascii_case("AnyCPU") { + "anycpu".to_owned() + } else { + value.to_ascii_lowercase() + } +} + +fn quoted_fields(line: &str) -> Vec { + line.split('"') + .enumerate() + .filter(|(index, _)| index % 2 == 1) + .map(|(_, value)| value.to_owned()) + .collect() +} + +pub fn set_property(properties: &mut Properties, name: &str, value: &str) { + if let Some(existing) = properties + .keys() + .find(|candidate| candidate.eq_ignore_ascii_case(name)) + .cloned() + { + properties.remove(&existing); + } + properties.insert(name.to_owned(), value.to_owned()); +} + +pub fn set_property_if_absent(properties: &mut Properties, name: &str, value: &str) { + if get_property(properties, name).is_none() && !value.is_empty() { + properties.insert(name.to_owned(), value.to_owned()); + } +} + +pub fn get_property<'a>(properties: &'a Properties, name: &str) -> Option<&'a str> { + properties + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn reads_quoted_solution_fields() { + let fields = + quoted_fields(r#"Project("{TYPE}") = "App", "src\App\App.vcxproj", "{PROJECT}""#); + assert_eq!( + fields, + vec!["{TYPE}", "App", r"src\App\App.vcxproj", "{PROJECT}"] + ); + } + + #[test] + fn property_names_are_case_insensitive() { + let mut properties = BTreeMap::from([("Configuration".to_owned(), "Debug".to_owned())]); + set_property(&mut properties, "configuration", "Release"); + assert_eq!(properties.len(), 1); + assert_eq!(get_property(&properties, "CONFIGURATION"), Some("Release")); + } +} diff --git a/tests/README.md b/tests/README.md index 003bfca..e2a32e3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,154 +1,16 @@ -# ms2cc Integration Tests +# Integration tests -This directory contains integration tests for ms2cc using sanitized real-world MSBuild logs. +The Windows integration tests invoke an installed MSBuild 17.8 or newer against +the synthetic Visual C++ project under `fixtures\native`. They verify structured +`GetClCommandLines` extraction, project-reference discovery, command fidelity, +complete replacement, failure safety, and the intentionally small CLI. -## Test Structure - -``` -tests/ -├── fixtures/ # Test input/output files -│ ├── sequential_build.log # MSBuild log with sequential compilation -│ ├── sequential_build.expected.json # Expected compile_commands.json output -│ ├── parallel_build.log # MSBuild log with parallel build (/m:4) -│ ├── parallel_build.expected.json # Expected output for parallel build -│ ├── nested_dependencies.log # MSBuild log with nested project dependencies -│ └── nested_dependencies.expected.json # Expected output for nested dependencies -└── integration_tests.rs # Integration test suite -``` - -## Running Tests - -Run all tests (unit + integration): +Run all tests: ```powershell cargo test ``` -Run only integration tests: - -```powershell -cargo test --test integration_tests -``` - -Run a specific integration test: - -```powershell -cargo test test_sequential_build -cargo test test_parallel_build -cargo test test_nested_dependencies -``` - -## How to Add New Integration Tests - -### Step 1: Obtain and Sanitize MSBuild Log - -1. **Capture a real MSBuild log** with `/flp:v=detailed`: - - ```powershell - msbuild YourProject.sln /t:Build /p:Configuration=Release /flp:v=detailed;logfile=build.log - ``` - -2. **Sanitize the log** to remove sensitive information: - - Replace real project names with generic names (ProjectA, ProjectB, etc.) - - Replace real paths with generic paths (C:\TestProject, C:\BuildTools, etc.) - - Replace source file names with generic names (file1.cpp, source1.cpp, etc.) - - Keep representative compiler flags and PCH patterns (/Yc, /Yu, /Fp, /FI) - - Remove any certificate paths, usernames, or proprietary code references - - Simplify compiler version numbers (14.44.35207 → 14.0) - -3. **Save sanitized log** to `tests/fixtures/your_test_case.log` - -### Step 2: Generate Expected Output - -1. **Run ms2cc** on your sanitized log: - - ```powershell - cargo build --release - target/release/ms2cc --input-file tests/fixtures/your_test_case.log --output-file tests/fixtures/your_test_case.expected.json - ``` - -2. **Verify the output** manually to ensure it's correct: - - Check that all source files are present - - Verify paths are absolute - - Confirm PCH flags are removed (/Yc, /Yu, /Fp) - - Confirm /FI flags are preserved - - Ensure no duplicates - -### Step 3: Add Test Function - -Add a new test function to `tests/integration_tests.rs`: - -```rust -#[test] -fn test_your_test_case() { - // Build the binary - let build_result = Command::new("cargo") - .args(&["build"]) - .output() - .expect("Failed to build ms2cc binary"); - - assert!( - build_result.status.success(), - "Failed to build ms2cc binary" - ); - - // Run ms2cc on your_test_case.log - let actual = run_ms2cc("your_test_case.log") - .expect("Failed to run ms2cc on your_test_case.log"); - - // Load expected output - let expected = load_expected_json("your_test_case.expected.json") - .expect("Failed to load expected JSON"); - - // Validate spec compliance - validate_spec_compliance(&actual) - .expect("Actual output violates JSON Compilation Database spec"); - - // Validate ms2cc-specific requirements - validate_ms2cc_specific(&actual) - .expect("Actual output violates ms2cc-specific requirements"); - - // Validate correctness - validate_correctness(&actual, &expected) - .expect("Actual output does not match expected"); -} -``` - -### Step 4: Run and Verify - -```powershell -cargo test test_your_test_case -``` - -## What Tests Validate - -Each integration test validates: - -1. **JSON Compilation Database Spec Compliance** - - Output is a valid JSON array - - Each entry has required fields: `directory`, `file`, and either `command` or `arguments` - - All paths are absolute - - Files have valid C/C++ extensions (.c, .cpp, .cc, .cxx, .C) - -2. **ms2cc-Specific Requirements** - - All paths use backslashes (Windows style) - - PCH flags are removed: `/Yc`, `/Yu`, `/Fp` - - Force include flags are preserved: `/FI` - - No duplicate file entries - -3. **Correctness Against Expected Output** - - Same number of compilation entries - - Same set of files compiled - - Matching directory and command for each file - -## Test Fixtures - -Current test fixtures cover: - -- **sequential_build.log**: 10 files compiled sequentially in a single project -- **parallel_build.log**: 5 files compiled across multiple projects with parallel build -- **nested_dependencies.log**: 3 files with nested project dependencies and PCH usage - -## References - -- JSON Compilation Database Spec: https://clang.llvm.org/docs/JSONCompilationDatabase.html +The fixtures are ordinary MSBuild projects. Most tests run only Visual C++ +design-time targets. The module fixture also invokes MSVC's dependency-only +scanner and verifies that its outputs stay in ms2cc's temporary directory. diff --git a/tests/fixtures/native/basic.sln b/tests/fixtures/native/basic.sln new file mode 100644 index 0000000..f53cfa5 --- /dev/null +++ b/tests/fixtures/native/basic.sln @@ -0,0 +1,16 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.00000.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "basic", "basic\basic.vcxproj", "{D4BD8DB0-A41E-49D8-BDB5-2D09E6C59C37}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|x64 = Debug|x64 + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D4BD8DB0-A41E-49D8-BDB5-2D09E6C59C37}.Release|x64.ActiveCfg = Release|x64 + {D4BD8DB0-A41E-49D8-BDB5-2D09E6C59C37}.Release|x64.Build.0 = Release|x64 + EndGlobalSection +EndGlobal diff --git a/tests/fixtures/native/basic.slnx b/tests/fixtures/native/basic.slnx new file mode 100644 index 0000000..ee95f76 --- /dev/null +++ b/tests/fixtures/native/basic.slnx @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/native/basic/basic.vcxproj b/tests/fixtures/native/basic/basic.vcxproj new file mode 100644 index 0000000..8255953 --- /dev/null +++ b/tests/fixtures/native/basic/basic.vcxproj @@ -0,0 +1,67 @@ + + + + + Release + x64 + + + Release + Win32 + + + + {D4BD8DB0-A41E-49D8-BDB5-2D09E6C59C37} + Win32Proj + $(ProjectDir)original-after.targets + + + Ms2ccFixtureAddGeneratedSource;$(BeforeClCompileTargets) + + + + StaticLibrary + v143 + + + StaticLibrary + v143 + + + + + + + + stdcpp20 + Use + pch.h + $(IntDir)basic.pch + pch.h;%(ForcedIncludeFiles) + $(IntDir) + $(SolutionDir)solution-include;%(AdditionalIncludeDirectories) + MS2CC_SOLUTION_NAME=$(SolutionName);%(PreprocessorDefinitions) + + + + + MS2CC_MAPPED_PLATFORM=1;%(PreprocessorDefinitions) + + + + + Create + + + + + MS2CC_PER_FILE=1;%(PreprocessorDefinitions) + + + + + + + + + diff --git a/tests/fixtures/native/basic/generated.cpp b/tests/fixtures/native/basic/generated.cpp new file mode 100644 index 0000000..a088fb3 --- /dev/null +++ b/tests/fixtures/native/basic/generated.cpp @@ -0,0 +1,4 @@ +int generated() +{ + return MS2CC_VALUE; +} diff --git a/tests/fixtures/native/basic/one.cpp b/tests/fixtures/native/basic/one.cpp new file mode 100644 index 0000000..92463af --- /dev/null +++ b/tests/fixtures/native/basic/one.cpp @@ -0,0 +1,4 @@ +int one() +{ + return MS2CC_PCH_VALUE; +} diff --git a/tests/fixtures/native/basic/original-after.targets b/tests/fixtures/native/basic/original-after.targets new file mode 100644 index 0000000..10df62b --- /dev/null +++ b/tests/fixtures/native/basic/original-after.targets @@ -0,0 +1,9 @@ + + + + + MS2CC_IMPORTED_AFTER=1;%(PreprocessorDefinitions) + + + + diff --git a/tests/fixtures/native/basic/pch.cpp b/tests/fixtures/native/basic/pch.cpp new file mode 100644 index 0000000..1d9f38c --- /dev/null +++ b/tests/fixtures/native/basic/pch.cpp @@ -0,0 +1 @@ +#include "pch.h" diff --git a/tests/fixtures/native/basic/real/__temporary.cpp b/tests/fixtures/native/basic/real/__temporary.cpp new file mode 100644 index 0000000..82f7aa4 --- /dev/null +++ b/tests/fixtures/native/basic/real/__temporary.cpp @@ -0,0 +1,4 @@ +int real_temporary_source() +{ + return 42; +} diff --git a/tests/fixtures/native/basic/space name.cpp b/tests/fixtures/native/basic/space name.cpp new file mode 100644 index 0000000..758e078 --- /dev/null +++ b/tests/fixtures/native/basic/space name.cpp @@ -0,0 +1,4 @@ +int two() +{ + return MS2CC_VALUE; +} diff --git a/tests/fixtures/native/broken.cpp b/tests/fixtures/native/broken.cpp new file mode 100644 index 0000000..13f2a3f --- /dev/null +++ b/tests/fixtures/native/broken.cpp @@ -0,0 +1,4 @@ +int broken() +{ + return 0; +} diff --git a/tests/fixtures/native/broken.proj b/tests/fixtures/native/broken.proj new file mode 100644 index 0000000..0152731 --- /dev/null +++ b/tests/fixtures/native/broken.proj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/native/include/pch.h b/tests/fixtures/native/include/pch.h new file mode 100644 index 0000000..f9289fa --- /dev/null +++ b/tests/fixtures/native/include/pch.h @@ -0,0 +1,3 @@ +#pragma once + +#define MS2CC_PCH_VALUE 7 diff --git a/tests/fixtures/native/mapped.slnx b/tests/fixtures/native/mapped.slnx new file mode 100644 index 0000000..7afd5eb --- /dev/null +++ b/tests/fixtures/native/mapped.slnx @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/tests/fixtures/native/modules/main.cpp b/tests/fixtures/native/modules/main.cpp new file mode 100644 index 0000000..b791514 --- /dev/null +++ b/tests/fixtures/native/modules/main.cpp @@ -0,0 +1,6 @@ +import "widget.h"; + +int main() +{ + return widget_value; +} diff --git a/tests/fixtures/native/modules/modules.vcxproj b/tests/fixtures/native/modules/modules.vcxproj new file mode 100644 index 0000000..e046b18 --- /dev/null +++ b/tests/fixtures/native/modules/modules.vcxproj @@ -0,0 +1,35 @@ + + + + + Release + x64 + + + + {493AC63E-BF0D-48E9-8D46-9255C807E780} + Win32Proj + + + + StaticLibrary + v143 + + + + $(ProjectDir)obj\ + + + + stdcpp20 + $(IntDir) + $(IntDir) + $(IntDir) + + + + + + + + diff --git a/tests/fixtures/native/modules/widget.h b/tests/fixtures/native/modules/widget.h new file mode 100644 index 0000000..c305efa --- /dev/null +++ b/tests/fixtures/native/modules/widget.h @@ -0,0 +1,3 @@ +#pragma once + +inline constexpr int widget_value = 42; diff --git a/tests/fixtures/native/nested.proj b/tests/fixtures/native/nested.proj new file mode 100644 index 0000000..ed184cc --- /dev/null +++ b/tests/fixtures/native/nested.proj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/native/references/consumer/consumer.cpp b/tests/fixtures/native/references/consumer/consumer.cpp new file mode 100644 index 0000000..f81889a --- /dev/null +++ b/tests/fixtures/native/references/consumer/consumer.cpp @@ -0,0 +1,7 @@ +import "library.h"; +import math; + +int consume() +{ + return library_value + module_value(); +} diff --git a/tests/fixtures/native/references/consumer/consumer.vcxproj b/tests/fixtures/native/references/consumer/consumer.vcxproj new file mode 100644 index 0000000..f10eabc --- /dev/null +++ b/tests/fixtures/native/references/consumer/consumer.vcxproj @@ -0,0 +1,36 @@ + + + + + Release + x64 + + + + {AD5A6249-6EB5-44F1-BC9B-8B45616744E2} + Win32Proj + + + + StaticLibrary + v143 + + + + + stdcpp20 + + + + + + + + {F6874737-C5A1-449A-86A3-443F1E499FC8} + + + {C933545A-E4D1-4314-867A-77873F6F9A7A} + + + + diff --git a/tests/fixtures/native/references/excluded/excluded.cpp b/tests/fixtures/native/references/excluded/excluded.cpp new file mode 100644 index 0000000..64f38d7 --- /dev/null +++ b/tests/fixtures/native/references/excluded/excluded.cpp @@ -0,0 +1,4 @@ +int excluded() +{ + return 0; +} diff --git a/tests/fixtures/native/references/excluded/excluded.vcxproj b/tests/fixtures/native/references/excluded/excluded.vcxproj new file mode 100644 index 0000000..e4ede55 --- /dev/null +++ b/tests/fixtures/native/references/excluded/excluded.vcxproj @@ -0,0 +1,23 @@ + + + + + Release + x64 + + + + {C933545A-E4D1-4314-867A-77873F6F9A7A} + Win32Proj + + + + StaticLibrary + v143 + + + + + + + diff --git a/tests/fixtures/native/references/library/include/library.h b/tests/fixtures/native/references/library/include/library.h new file mode 100644 index 0000000..265514c --- /dev/null +++ b/tests/fixtures/native/references/library/include/library.h @@ -0,0 +1,3 @@ +#pragma once + +inline constexpr int library_value = 7; diff --git a/tests/fixtures/native/references/library/library.cpp b/tests/fixtures/native/references/library/library.cpp new file mode 100644 index 0000000..a1e467b --- /dev/null +++ b/tests/fixtures/native/references/library/library.cpp @@ -0,0 +1,6 @@ +#include + +int library() +{ + return library_value; +} diff --git a/tests/fixtures/native/references/library/library.vcxproj b/tests/fixtures/native/references/library/library.vcxproj new file mode 100644 index 0000000..125d32a --- /dev/null +++ b/tests/fixtures/native/references/library/library.vcxproj @@ -0,0 +1,37 @@ + + + + + Release + x64 + + + + {F6874737-C5A1-449A-86A3-443F1E499FC8} + Win32Proj + + + + StaticLibrary + v143 + + + + $(ProjectDir)include + true + + + + stdcpp20 + $(IntDir) + $(IntDir) + $(IntDir) + + + + + + + + + diff --git a/tests/fixtures/native/references/library/math.ixx b/tests/fixtures/native/references/library/math.ixx new file mode 100644 index 0000000..cd47ec7 --- /dev/null +++ b/tests/fixtures/native/references/library/math.ixx @@ -0,0 +1,6 @@ +export module math; + +export int module_value() +{ + return 11; +} diff --git a/tests/fixtures/native/references/references.sln b/tests/fixtures/native/references/references.sln new file mode 100644 index 0000000..510e6b5 --- /dev/null +++ b/tests/fixtures/native/references/references.sln @@ -0,0 +1,22 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.00000.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "consumer", "consumer\consumer.vcxproj", "{AD5A6249-6EB5-44F1-BC9B-8B45616744E2}" +EndProject +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "library", "library\library.vcxproj", "{F6874737-C5A1-449A-86A3-443F1E499FC8}" +EndProject +Project("{BC8A1FFA-BEE3-4634-8014-F334798102B3}") = "excluded", "excluded\excluded.vcxproj", "{C933545A-E4D1-4314-867A-77873F6F9A7A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AD5A6249-6EB5-44F1-BC9B-8B45616744E2}.Release|x64.ActiveCfg = Release|x64 + {AD5A6249-6EB5-44F1-BC9B-8B45616744E2}.Release|x64.Build.0 = Release|x64 + {F6874737-C5A1-449A-86A3-443F1E499FC8}.Release|x64.ActiveCfg = Release|x64 + {F6874737-C5A1-449A-86A3-443F1E499FC8}.Release|x64.Build.0 = Release|x64 + {C933545A-E4D1-4314-867A-77873F6F9A7A}.Release|x64.ActiveCfg = Release|x64 + EndGlobalSection +EndGlobal diff --git a/tests/fixtures/native/references/references.slnx b/tests/fixtures/native/references/references.slnx new file mode 100644 index 0000000..c2e77cd --- /dev/null +++ b/tests/fixtures/native/references/references.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/fixtures/native/settings.props b/tests/fixtures/native/settings.props new file mode 100644 index 0000000..73a8fcb --- /dev/null +++ b/tests/fixtures/native/settings.props @@ -0,0 +1,8 @@ + + + + $(MSBuildThisFileDirectory)include;%(AdditionalIncludeDirectories) + MS2CC_VALUE=42;%(PreprocessorDefinitions) + + + diff --git a/tests/fixtures/native/slow-msbuild.cmd b/tests/fixtures/native/slow-msbuild.cmd new file mode 100644 index 0000000..a796a8f --- /dev/null +++ b/tests/fixtures/native/slow-msbuild.cmd @@ -0,0 +1,2 @@ +@echo off +%SystemRoot%\System32\ping.exe -n 10 127.0.0.1 >nul diff --git a/tests/fixtures/native/temporary-name/__temporary.cpp b/tests/fixtures/native/temporary-name/__temporary.cpp new file mode 100644 index 0000000..6b1f3b8 --- /dev/null +++ b/tests/fixtures/native/temporary-name/__temporary.cpp @@ -0,0 +1,4 @@ +int legitimate_temporary_name() +{ + return 42; +} diff --git a/tests/fixtures/native/temporary-name/temporary-name.vcxproj b/tests/fixtures/native/temporary-name/temporary-name.vcxproj new file mode 100644 index 0000000..12a8935 --- /dev/null +++ b/tests/fixtures/native/temporary-name/temporary-name.vcxproj @@ -0,0 +1,23 @@ + + + + + Release + x64 + + + + {6826C6B9-E4BA-4136-AD2E-8FEF1295DCFB} + Win32Proj + + + + StaticLibrary + v143 + + + + + + + diff --git a/tests/fixtures/native/traversal.proj b/tests/fixtures/native/traversal.proj new file mode 100644 index 0000000..14dbc55 --- /dev/null +++ b/tests/fixtures/native/traversal.proj @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/nested_dependencies.expected.json b/tests/fixtures/nested_dependencies.expected.json deleted file mode 100644 index b73d50e..0000000 --- a/tests/fixtures/nested_dependencies.expected.json +++ /dev/null @@ -1,17 +0,0 @@ -[ - { - "directory": "C:\\ParallelProject\\src\\ChildProject", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectG\" /Zi /nologo /W4 /WX /Od /D _WINDLL /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectG\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\src\\ProjectG\\obj\\amd64\\vc143.pdb\" /Gd /TP /FIstdafx.h /FC \"C:\\ParallelProject\\src\\ChildProject\\source4.cpp\"", - "file": "C:\\ParallelProject\\src\\ChildProject\\source4.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ChildProject", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectG\" /Zi /nologo /W4 /WX /Od /D _WINDLL /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectG\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\src\\ProjectG\\obj\\amd64\\vc143.pdb\" /Gd /TP /FIstdafx.h /FC \"C:\\ParallelProject\\src\\ChildProject\\source5.cpp\"", - "file": "C:\\ParallelProject\\src\\ChildProject\\source5.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ChildProject2", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ChildProject2\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ChildProject2\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\out\\retail-amd64\\ChildProject2\\ChildProject2.pdb\" /Gd /TP /FC \"C:\\ParallelProject\\src\\ChildProject2\\source6.cpp\"", - "file": "C:\\ParallelProject\\src\\ChildProject2\\source6.cpp" - } -] \ No newline at end of file diff --git a/tests/fixtures/nested_dependencies.log b/tests/fixtures/nested_dependencies.log deleted file mode 100644 index c2927e4..0000000 --- a/tests/fixtures/nested_dependencies.log +++ /dev/null @@ -1,34 +0,0 @@ - 33>Project "C:\ParallelProject\src\ParentProject\ParentProject.vcxproj" (33) is building "C:\ParallelProject\src\ChildProject\ChildProject.vcxproj" (46) on node 17 (default targets). - 46>CreateDirectories: - Creating directory "C:\ParallelProject\src\ChildProject\obj\amd64\". - 34>ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ProjectG" /Zi /nologo /W4 /WX /Od /D _WINDLL /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Yu"stdafx.h" /Fp"C:\ParallelProject\src\ProjectG\obj\amd64\ProjectG.pch" /Fo"C:\ParallelProject\src\ProjectG\obj\amd64\\" /Fd"C:\ParallelProject\src\ProjectG\obj\amd64\vc143.pdb" /Gd /TP /FIstdafx.h /FC source4.cpp source5.cpp - 34>ClCompile: - source4.cpp - source5.cpp - 46>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ChildProject\". - Creating directory "C:\ParallelProject\src\ChildProject\obj\amd64\ChildProject.tlog\". - Creating directory "C:\ParallelProject\src\ChildProject\obj\amd64\generated\". - 44>Project "C:\ParallelProject\src\ParentProject2\ParentProject2.proj" (44) is building "C:\ParallelProject\src\ChildProject2\ChildProject2.vcxproj" (54) on node 13 (default targets). - 54>CreateDirectories: - Creating directory "C:\ParallelProject\src\ChildProject2\obj\amd64\". - 46>InitializeBuildStatus: - Creating "C:\ParallelProject\src\ChildProject\obj\amd64\ChildProject.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - 54>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ChildProject2\". - Creating directory "C:\ParallelProject\src\ChildProject2\obj\amd64\ChildProject2.tlog\". - 46>MessageCompile: - C:\BuildTools\WindowsSDK\10.0\bin\x64\mc.exe -h "C:\ParallelProject\src\ChildProject\obj\amd64\\" -r "C:\ParallelProject\src\ChildProject\obj\amd64\\" ChildProject.man - 54>InitializeBuildStatus: - Creating "C:\ParallelProject\src\ChildProject2\obj\amd64\ChildProject2.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - 54>ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ChildProject2" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo"C:\ParallelProject\src\ChildProject2\obj\amd64\\" /Fd"C:\ParallelProject\out\retail-amd64\ChildProject2\ChildProject2.pdb" /Gd /TP /FC source6.cpp - 46>ResourceCompile: - C:\BuildTools\WindowsSDK\10.0\bin\x64\rc.exe /D _UNICODE /D UNICODE /I"C:\ParallelProject\src\ChildProject" /nologo /fo"C:\ParallelProject\src\ChildProject\obj\amd64\ChildProject.res" "C:\ParallelProject\src\ChildProject\obj\amd64\ChildProject.rc" - 54>ClCompile: - source6.cpp - 46>Done Building Project "C:\ParallelProject\src\ChildProject\ChildProject.vcxproj" (default targets). - 33>Done Building Project "C:\ParallelProject\src\ParentProject\ParentProject.vcxproj" (Build target(s)). - 54>Done Building Project "C:\ParallelProject\src\ChildProject2\ChildProject2.vcxproj" (default targets). - 44>Done Building Project "C:\ParallelProject\src\ParentProject2\ParentProject2.proj" (Build target(s)). diff --git a/tests/fixtures/parallel_build.expected.json b/tests/fixtures/parallel_build.expected.json deleted file mode 100644 index 6c45b18..0000000 --- a/tests/fixtures/parallel_build.expected.json +++ /dev/null @@ -1,27 +0,0 @@ -[ - { - "directory": "C:\\ParallelProject\\src\\ProjectC", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectC\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectC\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\out\\retail-amd64\\ProjectC\\ProjectC.pdb\" /Gd /TP /FC \"C:\\ParallelProject\\src\\ProjectC\\source1.cpp\"", - "file": "C:\\ParallelProject\\src\\ProjectC\\source1.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ProjectD", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectD\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\vc143.pdb\" /Gd /TP /FIstdafx.h /FC \"C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\stdafx.cpp\"", - "file": "C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\stdafx.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ProjectF", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectF\" /I\"C:\\ParallelProject\\src\\base\\common\\inc\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectF\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\out\\retail-amd64\\ProjectF\\ProjectF.pdb\" /Gd /TP /FC \"C:\\ParallelProject\\src\\ProjectF\\source2.cpp\"", - "file": "C:\\ParallelProject\\src\\ProjectF\\source2.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ProjectF", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectF\" /I\"C:\\ParallelProject\\src\\base\\common\\inc\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectF\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\out\\retail-amd64\\ProjectF\\ProjectF.pdb\" /Gd /TP /FC \"C:\\ParallelProject\\src\\ProjectF\\source3.cpp\"", - "file": "C:\\ParallelProject\\src\\ProjectF\\source3.cpp" - }, - { - "directory": "C:\\ParallelProject\\src\\ProjectD", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\amd64\\CL.exe /c /I\"C:\\ParallelProject\\src\\ProjectD\" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo\"C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\\\\" /Fd\"C:\\ParallelProject\\src\\ProjectD\\obj\\amd64\\vc143.pdb\" /Gd /TP /FIstdafx.h /FC \"C:\\ParallelProject\\src\\ProjectD\\source4.cpp\"", - "file": "C:\\ParallelProject\\src\\ProjectD\\source4.cpp" - } -] \ No newline at end of file diff --git a/tests/fixtures/parallel_build.log b/tests/fixtures/parallel_build.log deleted file mode 100644 index 7d68370..0000000 --- a/tests/fixtures/parallel_build.log +++ /dev/null @@ -1,50 +0,0 @@ - 4>Project "C:\ParallelProject\src\ProjectC\ProjectC.vcxproj" on node 3 (Build target(s)). - 4>CreateDirectories: - Creating directory "C:\ParallelProject\src\ProjectC\obj\amd64\". - 4>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ProjectC\". - 4>InitializeBuildStatus: - Creating "C:\ParallelProject\src\ProjectC\obj\amd64\ProjectC.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ProjectC" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo"C:\ParallelProject\src\ProjectC\obj\amd64\\" /Fd"C:\ParallelProject\out\retail-amd64\ProjectC\ProjectC.pdb" /Gd /TP /FC source1.cpp - 5>Project "C:\ParallelProject\src\ProjectD\ProjectD.vcxproj" on node 4 (Build target(s)). - 5>CreateDirectories: - Creating directory "C:\ParallelProject\src\ProjectD\obj\amd64\". - 4>ClCompile: - source1.cpp - 5>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ProjectD\". - Creating directory "C:\ParallelProject\src\ProjectD\obj\amd64\ProjectD.tlog\". - InitializeBuildStatus: - Creating "C:\ParallelProject\src\ProjectD\obj\amd64\ProjectD.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ProjectD" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Yc"stdafx.h" /Fp"C:\ParallelProject\src\ProjectD\obj\amd64\ProjectD.pch" /Fo"C:\ParallelProject\src\ProjectD\obj\amd64\\" /Fd"C:\ParallelProject\src\ProjectD\obj\amd64\vc143.pdb" /Gd /TP /FIstdafx.h /FC "C:\ParallelProject\src\ProjectD\obj\amd64\stdafx.cpp" - 6>Project "C:\ParallelProject\src\ProjectE\ProjectE.vcxproj" on node 5 (Build target(s)). - 6>CreateDirectories: - Creating directory "C:\ParallelProject\src\ProjectE\obj\amd64\". - 5>ClCompile: - stdafx.cpp - 6>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ProjectE\". - Creating directory "C:\ParallelProject\src\ProjectE\obj\amd64\ProjectE.tlog\". - InitializeBuildStatus: - Creating "C:\ParallelProject\src\ProjectE\obj\amd64\ProjectE.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - 7>Project "C:\ParallelProject\src\ProjectF\ProjectF.vcxproj" on node 6 (Build target(s)). - 7>CreateDirectories: - Creating directory "C:\ParallelProject\src\ProjectF\obj\amd64\". - 7>PrepareForBuild: - Creating directory "C:\ParallelProject\out\retail-amd64\ProjectF\". - Creating directory "C:\ParallelProject\src\ProjectF\obj\amd64\ProjectF.tlog\". - InitializeBuildStatus: - Creating "C:\ParallelProject\src\ProjectF\obj\amd64\ProjectF.tlog\unsuccessfulbuild" because "AlwaysCreate" was specified. - 7>ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ProjectF" /I"C:\ParallelProject\src\base\common\inc" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Fo"C:\ParallelProject\src\ProjectF\obj\amd64\\" /Fd"C:\ParallelProject\out\retail-amd64\ProjectF\ProjectF.pdb" /Gd /TP /FC source2.cpp source3.cpp - source2.cpp - source3.cpp - 5>ClCompile: - C:\BuildTools\VC\Tools\MSVC\14.0\bin\amd64\CL.exe /c /I"C:\ParallelProject\src\ProjectD" /Zi /nologo /W4 /WX /Od /D _UNICODE /D UNICODE /D WIN64 /D NDEBUG /GF /Gm- /EHsc /MD /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /std:c++20 /Yu"stdafx.h" /Fp"C:\ParallelProject\src\ProjectD\obj\amd64\ProjectD.pch" /Fo"C:\ParallelProject\src\ProjectD\obj\amd64\\" /Fd"C:\ParallelProject\src\ProjectD\obj\amd64\vc143.pdb" /Gd /TP /FIstdafx.h /FC source4.cpp - 5>ClCompile: - source4.cpp - 4>Done Building Project "C:\ParallelProject\src\ProjectC\ProjectC.vcxproj" (Build target(s)). - 7>Done Building Project "C:\ParallelProject\src\ProjectF\ProjectF.vcxproj" (Build target(s)). - 5>Done Building Project "C:\ParallelProject\src\ProjectD\ProjectD.vcxproj" (Build target(s)). diff --git a/tests/fixtures/sequential_build.expected.json b/tests/fixtures/sequential_build.expected.json deleted file mode 100644 index 1d6496e..0000000 --- a/tests/fixtures/sequential_build.expected.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file1.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file1.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file2.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file2.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file3.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file3.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file4.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file4.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file5.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file5.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file6.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file6.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file7.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file7.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file8.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file8.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file9.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file9.cpp" - }, - { - "directory": "C:\\TestProject\\dev\\ProjectB", - "command": "C:\\BuildTools\\VC\\Tools\\MSVC\\14.0\\bin\\HostX64\\x64\\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo\"C:\\TestProject\\obj\\Release\\x64\\ProjectB\\\\\" /Fd\"C:\\TestProject\\BuildOutput\\Release\\x64\\ProjectB\\ProjectB.pdb\" /Gd /TP /FC \"C:\\TestProject\\dev\\ProjectB\\file10.cpp\"", - "file": "C:\\TestProject\\dev\\ProjectB\\file10.cpp" - } -] \ No newline at end of file diff --git a/tests/fixtures/sequential_build.log b/tests/fixtures/sequential_build.log deleted file mode 100644 index fa4f3a1..0000000 --- a/tests/fixtures/sequential_build.log +++ /dev/null @@ -1,35 +0,0 @@ -Project "C:\TestProject\dev\ProjectA\ProjectA.vcxproj" (2) is building "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (3) on node 1 (default targets). -Target "DirectoryBuildPropsInfo" in file "C:\TestProject\Directory.Build.props" from project "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (entry point): -Done building target "DirectoryBuildPropsInfo" in project "ProjectB.vcxproj". -Target "_CheckForInvalidConfigurationAndPlatform" in file "C:\BuildTools\MSBuild\Current\Bin\amd64\Microsoft.Common.CurrentVersion.targets" from project "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (target "SetTelemetryEnvironmentVariables" depends on it): -Task "Message" - Configuration=Release -Done executing task "Message". -Task "Message" - Platform=x64 -Done executing task "Message". -Done building target "_CheckForInvalidConfigurationAndPlatform" in project "ProjectB.vcxproj". -Target "SetTelemetryEnvironmentVariables" in file "C:\BuildTools\MSBuild\Microsoft\VC\v170\Microsoft.Cpp.DesignTime.targets" from project "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (target "Build" depends on it): -Task "SetEnv" - VSTEL_MSBuildProjectFullPath=C:\TestProject\dev\ProjectB\ProjectB.vcxproj -Done executing task "SetEnv". -Done building target "SetTelemetryEnvironmentVariables" in project "ProjectB.vcxproj". -Target "SelectClCompile" in file "C:\BuildTools\MSBuild\Microsoft\VC\v170\Microsoft.CppBuild.targets" from project "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (target "ClCompile" depends on it): -Done building target "SelectClCompile" in project "ProjectB.vcxproj". -Target "ClCompile" in file "C:\BuildTools\MSBuild\Microsoft\VC\v170\Microsoft.CppCommon.targets" from project "C:\TestProject\dev\ProjectB\ProjectB.vcxproj" (target "_ClCompile" depends on it): -Using "CL" task from assembly "C:\BuildTools\MSBuild\Microsoft\VC\v170\Microsoft.Build.CppTasks.Common.dll". -Task "CL" - C:\BuildTools\VC\Tools\MSVC\14.0\bin\HostX64\x64\CL.exe /c /Zi /nologo /W4 /WX /diagnostics:column /Od /D _UNICODE /D UNICODE /GF /Gm- /MT /GS /Gy /fp:precise /Zc:wchar_t /Zc:forScope /GR- /std:c++20 /permissive- /Fo"C:\TestProject\obj\Release\x64\ProjectB\\" /Fd"C:\TestProject\BuildOutput\Release\x64\ProjectB\ProjectB.pdb" /Gd /TP /FC file1.cpp file2.cpp file3.cpp file4.cpp file5.cpp file6.cpp file7.cpp file8.cpp file9.cpp file10.cpp - file1.cpp - file2.cpp - file3.cpp - file4.cpp - file5.cpp - file6.cpp - file7.cpp - file8.cpp - file9.cpp - file10.cpp - Generating Code... -Done executing task "CL". -Done building target "ClCompile" in project "ProjectB.vcxproj". diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 760d4aa..c951c3b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,706 +1,902 @@ -// Integration tests for ms2cc -// Tests use sanitized real MSBuild logs from tests/fixtures/ +#![cfg(windows)] use serde_json::Value; -use std::fs; -use std::path::PathBuf; -use std::process::Command; - -/// Helper function to get the path to the ms2cc binary -fn get_binary_path() -> PathBuf { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("target"); - path.push("debug"); - path.push("ms2cc.exe"); - path +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, + time::{Duration, Instant}, +}; +use tempfile::TempDir; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_ms2cc")) } -/// Helper function to get the path to a test fixture -fn get_fixture_path(fixture_name: &str) -> PathBuf { - let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - path.push("tests"); - path.push("fixtures"); - path.push(fixture_name); - path +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("native") + .join(name) } -/// Run ms2cc with the given input log file and return the generated JSON -fn run_ms2cc(log_file: &str) -> Result { - let binary = get_binary_path(); - let input = get_fixture_path(log_file); - let output = get_fixture_path(&format!("{}.test_output.json", log_file)); - - // Ensure output doesn't exist from previous run - let _ = fs::remove_file(&output); - - // Run ms2cc: ms2cc --input-file --output-file - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--log-level") - .arg("off") +fn run(arguments: &[&str]) -> Output { + Command::new(binary()) + .args(arguments) .output() - .map_err(|e| format!("Failed to execute ms2cc: {}", e))?; - - if !result.status.success() { - return Err(format!( - "ms2cc failed with status: {}\nstderr: {}", - result.status, - String::from_utf8_lossy(&result.stderr) - )); - } - - // Read the generated JSON - let json_str = - fs::read_to_string(&output).map_err(|e| format!("Failed to read output JSON: {}", e))?; - - // Clean up output file - let _ = fs::remove_file(&output); - - // Parse JSON - serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse JSON: {}", e)) + .expect("failed to run ms2cc") } -/// Load expected JSON from fixture file -fn load_expected_json(expected_file: &str) -> Result { - let path = get_fixture_path(expected_file); - let json_str = - fs::read_to_string(&path).map_err(|e| format!("Failed to read expected JSON: {}", e))?; - serde_json::from_str(&json_str).map_err(|e| format!("Failed to parse expected JSON: {}", e)) +fn run_in(directory: &Path, arguments: &[&str]) -> Output { + Command::new(binary()) + .current_dir(directory) + .args(arguments) + .output() + .expect("failed to run ms2cc") } -/// Validate that the JSON follows the Clang JSON Compilation Database spec -/// Spec: https://clang.llvm.org/docs/JSONCompilationDatabase.html -fn validate_spec_compliance(json: &Value) -> Result<(), String> { - // Check JSON is an array - let array = json.as_array().ok_or("JSON is not an array")?; - - for (idx, entry) in array.iter().enumerate() { - let obj = entry - .as_object() - .ok_or(format!("Entry {} is not an object", idx))?; - - // Check required fields - if !obj.contains_key("directory") { - return Err(format!("Entry {} missing 'directory' field", idx)); - } - if !obj.contains_key("file") { - return Err(format!("Entry {} missing 'file' field", idx)); - } - - // Must have either 'command' OR 'arguments' (ms2cc uses 'command') - if !obj.contains_key("command") && !obj.contains_key("arguments") { - return Err(format!( - "Entry {} missing both 'command' and 'arguments' fields", - idx - )); - } - - // Validate 'directory' is a string - obj.get("directory") - .and_then(|v| v.as_str()) - .ok_or(format!("Entry {}: 'directory' is not a string", idx))?; - - // Validate 'file' is a string - let file_str = obj - .get("file") - .and_then(|v| v.as_str()) - .ok_or(format!("Entry {}: 'file' is not a string", idx))?; - - // Validate 'command' is a string (if present) - if let Some(cmd) = obj.get("command") { - cmd.as_str() - .ok_or(format!("Entry {}: 'command' is not a string", idx))?; - } - - // Check file extension is a source file - let valid_extensions = [".c", ".cpp", ".cc", ".cxx", ".C"]; - let has_valid_ext = valid_extensions.iter().any(|ext| file_str.ends_with(ext)); +fn read_database(path: &Path) -> Vec { + serde_json::from_slice::(&fs::read(path).expect("failed to read database")) + .expect("invalid JSON database") + .as_array() + .expect("database is not an array") + .clone() +} - if !has_valid_ext { - return Err(format!( - "Entry {}: file '{}' does not have a valid C/C++ extension", - idx, file_str - )); - } +fn copy_native_fixture(destination: &Path) { + fs::create_dir_all(destination.join("basic")).unwrap(); + fs::create_dir_all(destination.join("basic\\real")).unwrap(); + fs::create_dir_all(destination.join("include")).unwrap(); + for relative in [ + "settings.props", + "traversal.proj", + "nested.proj", + "basic\\basic.vcxproj", + "basic\\generated.cpp", + "basic\\original-after.targets", + "basic\\pch.cpp", + "basic\\one.cpp", + "basic\\real\\__temporary.cpp", + "basic\\space name.cpp", + "include\\pch.h", + ] { + fs::copy(fixture(relative), destination.join(relative)).unwrap(); } - - Ok(()) } -/// Validate ms2cc-specific requirements -fn validate_ms2cc_specific(json: &Value) -> Result<(), String> { - let array = json.as_array().ok_or("JSON is not an array")?; - - for (idx, entry) in array.iter().enumerate() { - let obj = entry - .as_object() - .ok_or(format!("Entry {} is not an object", idx))?; - - let directory = obj - .get("directory") - .and_then(|v| v.as_str()) - .ok_or(format!( - "Entry {}: 'directory' missing or not a string", - idx - ))?; - - let file = obj - .get("file") - .and_then(|v| v.as_str()) - .ok_or(format!("Entry {}: 'file' missing or not a string", idx))?; - - let command = obj - .get("command") - .and_then(|v| v.as_str()) - .ok_or(format!("Entry {}: 'command' missing or not a string", idx))?; - - // Check directory is absolute (starts with drive letter on Windows) - if !directory.starts_with("C:\\") - && !directory.starts_with("D:\\") - && !directory.starts_with("S:\\") - && !directory.starts_with("E:\\") - { - return Err(format!( - "Entry {}: directory '{}' is not absolute", - idx, directory - )); - } - - // Check file is absolute - if !file.starts_with("C:\\") - && !file.starts_with("D:\\") - && !file.starts_with("S:\\") - && !file.starts_with("E:\\") - { - return Err(format!("Entry {}: file '{}' is not absolute", idx, file)); - } - - // Check paths use backslashes (Windows normalized) - if directory.contains('/') { - return Err(format!("Entry {}: directory contains forward slashes", idx)); - } - if file.contains('/') { - return Err(format!("Entry {}: file contains forward slashes", idx)); - } - - // Check command contains CL.exe - if !command.contains("CL.exe") { - return Err(format!("Entry {}: command does not contain CL.exe", idx)); - } - - // Check PCH flags are removed (/Yc, /Yu, /Fp) - if command.contains("/Yc") || command.contains("/Yu") || command.contains("/Fp") { - return Err(format!( - "Entry {}: command contains PCH flags (/Yc, /Yu, or /Fp)", - idx - )); - } - - // Note: /FI flags should be preserved (not checked here as they may or may not be present) +fn copy_reference_fixture(destination: &Path) { + for directory in ["consumer", "library", "library\\include", "excluded"] { + fs::create_dir_all(destination.join(directory)).unwrap(); + } + for relative in [ + "references.sln", + "consumer\\consumer.vcxproj", + "consumer\\consumer.cpp", + "library\\library.vcxproj", + "library\\library.cpp", + "library\\math.ixx", + "library\\include\\library.h", + "excluded\\excluded.vcxproj", + "excluded\\excluded.cpp", + ] { + fs::copy( + fixture(&format!("references\\{relative}")), + destination.join(relative), + ) + .unwrap(); } - - Ok(()) } -/// Validate correctness against expected JSON -fn validate_correctness(actual: &Value, expected: &Value) -> Result<(), String> { - let actual_array = actual.as_array().ok_or("Actual JSON is not an array")?; - let expected_array = expected.as_array().ok_or("Expected JSON is not an array")?; - - // Check entry count - if actual_array.len() != expected_array.len() { - return Err(format!( - "Entry count mismatch: expected {}, got {}", - expected_array.len(), - actual_array.len() - )); - } +#[test] +fn generates_complete_database_from_vcxproj() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); - // Build sets of files for comparison - let mut actual_files: Vec = actual_array + let database = read_database(&output); + assert_eq!(database.len(), 5); + let files: Vec<_> = database .iter() - .filter_map(|e| e.get("file").and_then(|f| f.as_str()).map(String::from)) + .map(|entry| entry["file"].as_str().unwrap().to_ascii_lowercase()) .collect(); - let mut expected_files: Vec = expected_array - .iter() - .filter_map(|e| e.get("file").and_then(|f| f.as_str()).map(String::from)) - .collect(); - - actual_files.sort(); - expected_files.sort(); - - // Check for duplicate files - for i in 1..actual_files.len() { - if actual_files[i] == actual_files[i - 1] { - return Err(format!( - "Duplicate file in actual output: {}", - actual_files[i] - )); - } - } - - // Check file sets match - if actual_files != expected_files { - let missing: Vec<_> = expected_files - .iter() - .filter(|f| !actual_files.contains(f)) - .collect(); - let extra: Vec<_> = actual_files + assert!(files.iter().any(|file| file.ends_with(r"\pch.cpp"))); + assert!(files.iter().any(|file| file.ends_with(r"\one.cpp"))); + assert!(files.iter().any(|file| file.ends_with(r"\generated.cpp"))); + assert!( + files .iter() - .filter(|f| !expected_files.contains(f)) - .collect(); - - let mut msg = String::from("File set mismatch:\n"); - if !missing.is_empty() { - msg.push_str(&format!(" Missing files: {:?}\n", missing)); - } - if !extra.is_empty() { - msg.push_str(&format!(" Extra files: {:?}\n", extra)); - } - return Err(msg); + .any(|file| file.ends_with(r"\real\__temporary.cpp")) + ); + assert!(files.iter().any(|file| file.ends_with(r"\space name.cpp"))); + + for entry in &database { + let arguments = entry["arguments"].as_array().unwrap(); + assert!(Path::new(arguments[0].as_str().unwrap()).is_file()); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value.starts_with("/Fo")) + })); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value.starts_with("/Fp")) + })); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("/FI")) + || argument + .as_str() + .is_some_and(|value| value.starts_with("/FI")) + })); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value == "MS2CC_IMPORTED_AFTER=1") + })); + assert_eq!( + arguments + .iter() + .filter(|argument| { + argument.as_str().is_some_and(|value| { + [".c", ".cc", ".cpp", ".cxx"] + .iter() + .any(|extension| value.to_ascii_lowercase().ends_with(extension)) + }) + }) + .count(), + 1 + ); } - // For each file in expected, find matching entry in actual and compare - for expected_entry in expected_array { - let expected_obj = expected_entry - .as_object() - .ok_or("Expected entry is not an object")?; - let expected_file = expected_obj - .get("file") - .and_then(|f| f.as_str()) - .ok_or("Expected entry missing file")?; - - // Find matching actual entry - let actual_entry = actual_array + let per_file = database + .iter() + .find(|entry| { + entry["file"] + .as_str() + .is_some_and(|file| file.ends_with("space name.cpp")) + }) + .unwrap(); + assert!( + per_file["arguments"] + .as_array() + .unwrap() .iter() - .find(|e| { - e.get("file") - .and_then(|f| f.as_str()) - .map(|f| f == expected_file) - .unwrap_or(false) + .any(|argument| { + argument + .as_str() + .is_some_and(|value| value == "MS2CC_PER_FILE=1") }) - .ok_or(format!("No actual entry found for file: {}", expected_file))?; - - let actual_obj = actual_entry - .as_object() - .ok_or("Actual entry is not an object")?; - - // Compare directory - let expected_dir = expected_obj.get("directory").and_then(|d| d.as_str()); - let actual_dir = actual_obj.get("directory").and_then(|d| d.as_str()); - if expected_dir != actual_dir { - return Err(format!( - "Directory mismatch for {}:\n Expected: {:?}\n Actual: {:?}", - expected_file, expected_dir, actual_dir - )); - } - - // Compare command - let expected_cmd = expected_obj.get("command").and_then(|c| c.as_str()); - let actual_cmd = actual_obj.get("command").and_then(|c| c.as_str()); - if expected_cmd != actual_cmd { - return Err(format!( - "Command mismatch for {}:\n Expected: {:?}\n Actual: {:?}", - expected_file, expected_cmd, actual_cmd - )); - } - } - - Ok(()) -} - -/// Run ms2cc with arbitrary arguments and return the process output -fn run_ms2cc_raw(args: &[&str]) -> std::process::Output { - let binary = get_binary_path(); - Command::new(&binary) - .args(args) - .output() - .expect("Failed to execute ms2cc") + ); } -// ============================================================================ -// Integration Tests -// ============================================================================ - #[test] -fn test_missing_input_does_not_create_output() { - let output_path = get_fixture_path("missing_input_test_output.json"); - - // Ensure output doesn't exist from previous run - let _ = fs::remove_file(&output_path); - - let result = run_ms2cc_raw(&[ - "--input-file", - "nonexistent.log", - "--output-file", - output_path.to_str().unwrap(), - "--log-level", - "off", +fn preserves_a_real_root_source_named_temporary() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("temporary-name\\temporary-name.vcxproj") + .to_str() + .unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), ]); - assert!( - !result.status.success(), - "ms2cc should fail when input file doesn't exist" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); + let database = read_database(&output); + assert_eq!(database.len(), 1); assert!( - !output_path.exists(), - "Output file should not be created when input file doesn't exist" + database[0]["file"] + .as_str() + .is_some_and(|file| file.ends_with(r"\__temporary.cpp")) ); } #[test] -fn test_missing_input_does_not_overwrite_existing_output() { - let output_path = get_fixture_path("existing_output_test.json"); - let sentinel = "existing content that should not be overwritten"; - - // Create an existing output file with known content - fs::write(&output_path, sentinel).expect("Failed to create test output file"); - - let result = run_ms2cc_raw(&[ - "--input-file", - "nonexistent.log", - "--output-file", - output_path.to_str().unwrap(), - "--log-level", - "off", +fn resolves_header_unit_commands_without_leaving_scan_outputs() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let project = fixture("modules\\modules.vcxproj"); + let result = run(&[ + project.to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), ]); - assert!( - !result.status.success(), - "ms2cc should fail when input file doesn't exist" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); - let contents = - fs::read_to_string(&output_path).expect("Existing output file should still exist"); - assert_eq!( - contents, sentinel, - "Existing output file should not be modified on failure" + let database = read_database(&output); + assert_eq!(database.len(), 2); + let header = database + .iter() + .find(|entry| { + entry["file"] + .as_str() + .is_some_and(|file| file.ends_with("widget.h")) + }) + .unwrap(); + let header_arguments: Vec<_> = header["arguments"] + .as_array() + .unwrap() + .iter() + .map(|argument| argument.as_str().unwrap()) + .collect(); + assert!( + !header_arguments + .iter() + .any(|argument| argument.eq_ignore_ascii_case("/scanModules")) + ); + assert!(header_arguments.iter().any(|argument| { + argument + .strip_prefix("/Fo") + .is_some_and(|path| path.ends_with(r"\obj\widget.h.obj")) + })); + assert!(header_arguments.windows(2).any(|pair| { + pair[0].eq_ignore_ascii_case("/ifcOutput") && pair[1].ends_with(r"\obj\widget.h.ifc") + })); + assert!(header_arguments.windows(2).any(|pair| { + pair[0].eq_ignore_ascii_case("/sourceDependencies") + && pair[1].ends_with(r"\obj\widget.h.ifc.d.json") + })); + + let consumer = database + .iter() + .find(|entry| { + entry["file"] + .as_str() + .is_some_and(|file| file.ends_with("main.cpp")) + }) + .unwrap(); + let consumer_arguments: Vec<_> = consumer["arguments"] + .as_array() + .unwrap() + .iter() + .map(|argument| argument.as_str().unwrap()) + .collect(); + assert!(consumer_arguments.windows(2).any(|pair| { + pair[0].eq_ignore_ascii_case("/headerUnit") + && pair[1].contains("widget.h=") + && pair[1].ends_with(r"\obj\widget.h.ifc") + })); + assert!( + !project + .parent() + .unwrap() + .join("obj") + .join("widget.h.module.json") + .exists() ); - - // Clean up - let _ = fs::remove_file(&output_path); } #[test] -fn test_sequential_build() { - // Build the binary first - let build_result = Command::new("cargo") - .args(["build"]) - .output() - .expect("Failed to build ms2cc binary"); - +fn traversal_project_discovers_native_projects() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("traversal.proj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); assert!( - build_result.status.success(), - "Failed to build ms2cc binary" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); - - // Run ms2cc on sequential_build.log - let actual = - run_ms2cc("sequential_build.log").expect("Failed to run ms2cc on sequential_build.log"); - - // Load expected output - let expected = - load_expected_json("sequential_build.expected.json").expect("Failed to load expected JSON"); - - // Validate spec compliance - validate_spec_compliance(&actual) - .expect("Actual output violates JSON Compilation Database spec"); - - // Validate ms2cc-specific requirements - validate_ms2cc_specific(&actual).expect("Actual output violates ms2cc-specific requirements"); - - // Validate correctness - validate_correctness(&actual, &expected).expect("Actual output does not match expected"); + assert_eq!(read_database(&output).len(), 5); } #[test] -fn test_parallel_build() { - // Build the binary first - let build_result = Command::new("cargo") - .args(["build"]) - .output() - .expect("Failed to build ms2cc binary"); - +fn prepared_project_references_preserve_public_includes() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("references\\consumer\\consumer.vcxproj") + .to_str() + .unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); assert!( - build_result.status.success(), - "Failed to build ms2cc binary" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); - // Run ms2cc on parallel_build.log - let actual = - run_ms2cc("parallel_build.log").expect("Failed to run ms2cc on parallel_build.log"); - - // Load expected output - let expected = - load_expected_json("parallel_build.expected.json").expect("Failed to load expected JSON"); - - // Validate spec compliance - validate_spec_compliance(&actual) - .expect("Actual output violates JSON Compilation Database spec"); + let database = read_database(&output); + assert_eq!(database.len(), 5); + let consumer = database + .iter() + .find(|entry| { + entry["file"] + .as_str() + .is_some_and(|file| file.ends_with("consumer.cpp")) + }) + .unwrap(); + assert!( + consumer["arguments"] + .as_array() + .unwrap() + .iter() + .any(|argument| { + argument.as_str().is_some_and(|value| { + value + .to_ascii_lowercase() + .ends_with(r"\references\library\include") + }) + }) + ); + assert!( + consumer["arguments"] + .as_array() + .unwrap() + .windows(2) + .any(|pair| { + pair[0] + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("/reference")) + && pair[1].as_str().is_some_and(|value| { + value.starts_with("math=") && value.ends_with("math.ixx.ifc") + }) + }) + ); + assert!( + consumer["arguments"] + .as_array() + .unwrap() + .windows(2) + .any(|pair| { + pair[0] + .as_str() + .is_some_and(|value| value.eq_ignore_ascii_case("/headerUnit")) + && pair[1].as_str().is_some_and(|value| { + value.contains("library.h=") && value.ends_with("library.h.ifc") + }) + }) + ); +} - // Validate ms2cc-specific requirements - validate_ms2cc_specific(&actual).expect("Actual output violates ms2cc-specific requirements"); +#[test] +fn solution_build_rules_are_preserved_while_following_references() { + for solution in ["references.sln", "references.slnx"] { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture(&format!("references\\{solution}")) + .to_str() + .unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{solution}: {}", + String::from_utf8_lossy(&result.stderr) + ); + + let database = read_database(&output); + assert_eq!(database.len(), 4, "{solution}"); + assert!(!database.iter().any(|entry| { + entry["file"] + .as_str() + .is_some_and(|file| file.ends_with("excluded.cpp")) + })); + } +} - // Validate correctness - validate_correctness(&actual, &expected).expect("Actual output does not match expected"); +#[test] +fn solution_formats_discover_native_projects() { + for solution in ["basic.sln", "basic.slnx"] { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture(solution).to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{solution}: {}", + String::from_utf8_lossy(&result.stderr) + ); + let database = read_database(&output); + assert_eq!(database.len(), 5, "{solution}"); + for entry in database { + let arguments = entry["arguments"].as_array().unwrap(); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value == "MS2CC_SOLUTION_NAME=basic") + })); + assert!(arguments.iter().any(|argument| { + argument + .as_str() + .is_some_and(|value| value.to_ascii_lowercase().ends_with(r"\solution-include")) + })); + } + } } #[test] -fn test_nested_dependencies() { - // Build the binary first - let build_result = Command::new("cargo") - .args(["build"]) - .output() - .expect("Failed to build ms2cc binary"); +fn solution_excludes_projects_not_selected_for_build() { + let temp = TempDir::new().unwrap(); + let result = run(&[ + fixture("basic.sln").to_str().unwrap(), + "--configuration", + "Debug", + "--platform", + "x64", + "--output", + temp.path().join("compile_commands.json").to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("no Visual C++ projects")); +} +#[test] +fn slnx_applies_platform_mappings_and_build_rules() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("mapped.slnx").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "AnyCPU", + "--output", + output.to_str().unwrap(), + ]); assert!( - build_result.status.success(), - "Failed to build ms2cc binary" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); + let database = read_database(&output); + assert_eq!(database.len(), 5); + for entry in database { + assert!( + entry["arguments"] + .as_array() + .unwrap() + .iter() + .any(|argument| { + argument + .as_str() + .is_some_and(|value| value == "MS2CC_MAPPED_PLATFORM=1") + }) + ); + } - // Run ms2cc on nested_dependencies.log - let actual = run_ms2cc("nested_dependencies.log") - .expect("Failed to run ms2cc on nested_dependencies.log"); - - // Load expected output - let expected = load_expected_json("nested_dependencies.expected.json") - .expect("Failed to load expected JSON"); - - // Validate spec compliance - validate_spec_compliance(&actual) - .expect("Actual output violates JSON Compilation Database spec"); - - // Validate ms2cc-specific requirements - validate_ms2cc_specific(&actual).expect("Actual output violates ms2cc-specific requirements"); - - // Validate correctness - validate_correctness(&actual, &expected).expect("Actual output does not match expected"); + let result = run(&[ + fixture("mapped.slnx").to_str().unwrap(), + "--configuration", + "Debug", + "--platform", + "AnyCPU", + "--output", + output.to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("no Visual C++ projects")); } -// ============================================================================ -// Merge Integration Tests -// ============================================================================ - #[test] -fn test_merge_preserves_existing_entries() { - let binary = get_binary_path(); - let input = get_fixture_path("sequential_build.log"); - let output = get_fixture_path("merge_test_output.json"); - - // Clean up - let _ = fs::remove_file(&output); - - // Pre-populate output with an entry that won't appear in the build log - let pre_existing = serde_json::json!([ - { - "directory": "C:\\fake\\project", - "command": "CL.exe /c fake.cpp", - "file": "C:\\fake\\project\\fake.cpp" - } +fn successful_run_replaces_existing_database() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + fs::write( + &output, + r#"[{"directory":"C:\\stale","arguments":["cl.exe","stale.cpp"],"file":"C:\\stale\\stale.cpp"}]"#, + ) + .unwrap(); + + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), ]); - fs::write(&output, serde_json::to_string(&pre_existing).unwrap()) - .expect("Failed to write pre-existing output"); - - // Run ms2cc in default merge mode - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--log-level") - .arg("off") - .output() - .expect("Failed to execute ms2cc"); - assert!( result.status.success(), - "ms2cc failed: {}", + "{}", String::from_utf8_lossy(&result.stderr) ); - - let json_str = fs::read_to_string(&output).expect("Failed to read output"); - let result: Value = serde_json::from_str(&json_str).expect("Failed to parse JSON"); - let array = result.as_array().expect("JSON is not an array"); - - // Should contain the pre-existing entry plus entries from the build log - let has_fake = array.iter().any(|e| { - e.get("file") - .and_then(|f| f.as_str()) - .map(|f| f == "C:\\fake\\project\\fake.cpp") - .unwrap_or(false) - }); + let database = read_database(&output); + assert_eq!(database.len(), 5); assert!( - has_fake, - "Merged output should preserve pre-existing entries" + !database + .iter() + .any(|entry| entry["file"] == "C:\\stale\\stale.cpp") ); +} - let expected = - load_expected_json("sequential_build.expected.json").expect("Failed to load expected JSON"); - let expected_count = expected.as_array().unwrap().len(); +#[test] +fn default_output_is_written_in_the_current_directory() { + let temp = TempDir::new().unwrap(); + let result = run_in( + temp.path(), + &[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + ], + ); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); assert_eq!( - array.len(), - expected_count + 1, - "Merged output should have all build entries plus the pre-existing one" + read_database(&temp.path().join("compile_commands.json")).len(), + 5 ); - - // Clean up - let _ = fs::remove_file(&output); } #[test] -fn test_overwrite_replaces_existing_entries() { - let binary = get_binary_path(); - let input = get_fixture_path("sequential_build.log"); - let output = get_fixture_path("overwrite_test_output.json"); - - // Clean up - let _ = fs::remove_file(&output); - - // Pre-populate output with an extra entry - let pre_existing = serde_json::json!([ - { - "directory": "C:\\fake\\project", - "command": "CL.exe /c fake.cpp", - "file": "C:\\fake\\project\\fake.cpp" - } +fn project_and_output_paths_may_contain_spaces() { + let temp = TempDir::new().unwrap(); + let project_root = temp.path().join("project with spaces"); + let output_root = temp.path().join("output with spaces"); + copy_native_fixture(&project_root); + fs::create_dir(&output_root).unwrap(); + let output = output_root.join("compile commands.json"); + let result = run(&[ + project_root.join("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), ]); - fs::write(&output, serde_json::to_string(&pre_existing).unwrap()) - .expect("Failed to write pre-existing output"); - - // Run ms2cc with --overwrite - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--overwrite") - .arg("--log-level") - .arg("off") - .output() - .expect("Failed to execute ms2cc"); - assert!( result.status.success(), - "ms2cc failed: {}", + "{}", String::from_utf8_lossy(&result.stderr) ); + assert_eq!(read_database(&output).len(), 5); +} - let json_str = fs::read_to_string(&output).expect("Failed to read output"); - let result: Value = serde_json::from_str(&json_str).expect("Failed to parse JSON"); - let array = result.as_array().expect("JSON is not an array"); - - // Should NOT contain the pre-existing entry - let has_fake = array.iter().any(|e| { - e.get("file") - .and_then(|f| f.as_str()) - .map(|f| f == "C:\\fake\\project\\fake.cpp") - .unwrap_or(false) - }); - assert!( - !has_fake, - "Overwrite mode should not preserve pre-existing entries" - ); +#[test] +fn failure_preserves_existing_database() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let sentinel = b"existing database"; + fs::write(&output, sentinel).unwrap(); + + let result = run(&[ + temp.path().join("missing.vcxproj").to_str().unwrap(), + "--output", + output.to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert_eq!(fs::read(output).unwrap(), sentinel); +} - let expected = - load_expected_json("sequential_build.expected.json").expect("Failed to load expected JSON"); - assert_eq!( - array.len(), - expected.as_array().unwrap().len(), - "Overwrite output should match expected count exactly" - ); +#[test] +fn extraction_failure_preserves_existing_database() { + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let sentinel = b"existing database"; + fs::write(&output, sentinel).unwrap(); + + let result = run(&[ + fixture("broken.proj").to_str().unwrap(), + "--output", + output.to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert_eq!(fs::read(output).unwrap(), sentinel); +} - // Clean up - let _ = fs::remove_file(&output); +#[test] +fn text_logs_are_rejected() { + let temp = TempDir::new().unwrap(); + let input = temp.path().join("msbuild.log"); + let output = temp.path().join("compile_commands.json"); + fs::write(&input, "CL.exe /c stale.cpp").unwrap(); + + let result = run(&[ + input.to_str().unwrap(), + "--output", + output.to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("unsupported input")); + assert!(!output.exists()); } #[test] -fn test_merge_updates_matching_entries() { - let binary = get_binary_path(); - let input = get_fixture_path("sequential_build.log"); - let output = get_fixture_path("merge_update_test_output.json"); - - // Clean up - let _ = fs::remove_file(&output); - - // First, run ms2cc to generate a full database - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--overwrite") - .arg("--log-level") - .arg("off") - .output() - .expect("Failed to execute ms2cc"); - assert!(result.status.success()); - - let first_json: Value = serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); - let first_count = first_json.as_array().unwrap().len(); - - // Run again in merge mode with the same log — count should be identical - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--log-level") - .arg("off") - .output() - .expect("Failed to execute ms2cc"); - assert!(result.status.success()); +fn conflicting_properties_are_rejected() { + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--property", + "configuration=Debug", + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("specified more than once")); - let second_json: Value = serde_json::from_str(&fs::read_to_string(&output).unwrap()).unwrap(); - let second_count = second_json.as_array().unwrap().len(); + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--property", + "DesignTimeBuild=false", + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("reserved by ms2cc")); +} - assert_eq!( - first_count, second_count, - "Re-merging same log should not change entry count" - ); +#[test] +fn output_cannot_overwrite_an_input() { + let project = fixture("basic\\basic.vcxproj"); + let result = run(&[ + project.to_str().unwrap(), + "--output", + project.to_str().unwrap(), + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("cannot overwrite an input")); +} - // Clean up - let _ = fs::remove_file(&output); +#[test] +fn output_cannot_overwrite_discovered_projects_or_sources() { + let temp = TempDir::new().unwrap(); + copy_native_fixture(temp.path()); + let root = temp.path().join("traversal.proj"); + + for (relative, expected_error) in [ + ("nested.proj", "discovered project"), + ("basic\\basic.vcxproj", "discovered project"), + ("basic\\one.cpp", "source file"), + ] { + let output = temp.path().join(relative); + let original = fs::read(&output).unwrap(); + let result = run(&[ + root.to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + assert!(!result.status.success(), "{relative} should be protected"); + assert!( + String::from_utf8_lossy(&result.stderr).contains(expected_error), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(fs::read(output).unwrap(), original); + } } #[test] -fn test_merge_recovers_from_corrupted_database() { - let binary = get_binary_path(); - let input = get_fixture_path("sequential_build.log"); - let output = get_fixture_path("corrupted_test_output.json"); - - // Clean up - let _ = fs::remove_file(&output); - - // Write corrupted JSON - fs::write(&output, "this is not valid json {{{").expect("Failed to write corrupted output"); - - // Run ms2cc in default merge mode — should recover gracefully - let result = Command::new(&binary) - .arg("--input-file") - .arg(&input) - .arg("--output-file") - .arg(&output) - .arg("--log-level") - .arg("off") - .output() - .expect("Failed to execute ms2cc"); +fn output_cannot_overwrite_a_build_disabled_solution_member() { + let temp = TempDir::new().unwrap(); + copy_reference_fixture(temp.path()); + let output = temp.path().join("excluded\\excluded.vcxproj"); + let original = fs::read(&output).unwrap(); + let result = run(&[ + temp.path().join("references.sln").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("discovered project")); + assert_eq!(fs::read(output).unwrap(), original); +} + +#[test] +fn removed_legacy_flags_are_rejected() { + for flag in [ + "--input-file", + "--log-level", + "--pretty-print", + "--no-progress", + "--overwrite", + "--merge", + "--allow-empty", + "--allow-partial", + "--msbuild-arg", + "--profile", + ] { + let result = run(&[fixture("basic\\basic.vcxproj").to_str().unwrap(), flag]); + assert!(!result.status.success(), "{flag} should be rejected"); + } +} + +#[test] +fn timeout_stops_a_hung_msbuild_host() { + let started = Instant::now(); + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--msbuild", + fixture("slow-msbuild.cmd").to_str().unwrap(), + "--timeout", + "1", + ]); + assert!(!result.status.success()); + assert!(String::from_utf8_lossy(&result.stderr).contains("timeout")); + assert!(started.elapsed() < Duration::from_secs(8)); +} + +#[test] +fn command_wrapper_paths_quote_cmd_metacharacters() { + let temp = TempDir::new().unwrap(); + let wrapper_directory = temp.path().join("host & wrapper"); + fs::create_dir(&wrapper_directory).unwrap(); + let wrapper = wrapper_directory.join("msbuild.cmd"); + fs::copy(fixture("slow-msbuild.cmd"), &wrapper).unwrap(); + + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--msbuild", + wrapper.to_str().unwrap(), + "--timeout", + "1", + ]); + assert!(!result.status.success()); + assert!( + String::from_utf8_lossy(&result.stderr).contains("timeout"), + "{}", + String::from_utf8_lossy(&result.stderr) + ); +} + +#[test] +fn clangd_accepts_preserved_msvc_pch_and_output_flags_when_available() { + if Command::new("clangd").arg("--version").output().is_err() { + return; + } + + let temp = TempDir::new().unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = run(&[ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--output", + output.to_str().unwrap(), + ]); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let result = Command::new("clangd") + .arg(format!("--check={}", fixture("basic\\one.cpp").display())) + .arg(format!("--compile-commands-dir={}", temp.path().display())) + .arg("--background-index=0") + .arg("--log=error") + .output() + .expect("failed to run clangd"); assert!( result.status.success(), - "ms2cc should recover from corrupted database: {}", + "{}", String::from_utf8_lossy(&result.stderr) ); +} - let json_str = fs::read_to_string(&output).expect("Failed to read output"); - let result: Value = serde_json::from_str(&json_str).expect("Output should be valid JSON"); +#[test] +fn materializes_msvc_environment_arguments() { + let temp = TempDir::new().unwrap(); + let include = temp.path().join("environment include"); + let external_include = temp.path().join("external environment include"); + fs::create_dir(&include).unwrap(); + fs::create_dir(&external_include).unwrap(); + let output = temp.path().join("compile_commands.json"); + let result = Command::new(binary()) + .args([ + fixture("basic\\basic.vcxproj").to_str().unwrap(), + "--configuration", + "Release", + "--platform", + "x64", + "--property", + "UseEnv=true", + "--output", + output.to_str().unwrap(), + ]) + .env("CL", "/DMS2CC_LEADING=1") + .env("_CL_", "/DMS2CC_TRAILING=1") + .env("INCLUDE", include.as_os_str()) + .env("EXTERNAL_INCLUDE", external_include.as_os_str()) + .output() + .expect("failed to run ms2cc"); assert!( - !result.as_array().unwrap().is_empty(), - "Should have entries from the build log" + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) ); - // Clean up - let _ = fs::remove_file(&output); + for entry in read_database(&output) { + let arguments: Vec<_> = entry["arguments"] + .as_array() + .unwrap() + .iter() + .map(|argument| argument.as_str().unwrap()) + .collect(); + assert_eq!( + arguments + .iter() + .filter(|argument| argument.contains("MS2CC_LEADING=1")) + .count(), + 1 + ); + assert_eq!( + arguments + .iter() + .filter(|argument| argument.contains("MS2CC_TRAILING=1")) + .count(), + 1 + ); + assert!(arguments.iter().any(|argument| { + argument + .strip_prefix("/I") + .is_some_and(|path| path.eq_ignore_ascii_case(include.to_str().unwrap())) + })); + assert!(arguments.iter().any(|argument| { + argument + .strip_prefix("/external:I") + .is_some_and(|path| path.eq_ignore_ascii_case(external_include.to_str().unwrap())) + })); + } }