diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d725f9b..4302f8a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,3 +98,38 @@ jobs: --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ --label "$LABEL" \ --head $BRANCH + + # Build the Windows installers from the exact plugin zip the job above just published, and attach + # them to the same release - so they're reproducible from CI rather than built locally and + # committed. Windows-only: the .exe needs csc.exe and 7-Zip, and the standalone script is run + # with Windows PowerShell, which is also what assembles it here (see installer/README.md). + installers: + name: Build Installers + needs: [ release ] + runs-on: windows-latest + permissions: + contents: write + steps: + + # Check out the current repository + - name: Fetch Sources + uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + + # The zip "Upload Release Asset" attached, not a fresh build + - name: Download Published Plugin + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: powershell + run: gh release download "${{ github.event.release.tag_name }}" --repo "${{ github.repository }}" --pattern intellij-ib-plugin.zip --dir build/published + + - name: Build Installers + shell: powershell + run: .\installer\build-installer.ps1 -PluginZip build\published\intellij-ib-plugin.zip + + - name: Upload Installers + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: powershell + run: gh release upload "${{ github.event.release.tag_name }}" --repo "${{ github.repository }}" --clobber build/distributions/install-intellij-ib-plugin.ps1 build/distributions/intellij-ib-plugin.exe diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d3b50..aab00dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,44 @@ ## [Unreleased] +## [1.1.0] + +### Added + +- CLion support for CMake projects: **Build**, **Rebuild** and **Build All** in the **Incredibuild** menu run + `cmake --build` through Incredibuild, against the build directory, toolchain and generator the active CMake profile + is already configured with. +- Rider support for .NET solutions: **Build Solution with Incredibuild**, **Rebuild Solution with Incredibuild** and + **Build Selected Projects with Incredibuild** in the **Build** menu, the Solution Explorer context menu and the + **Incredibuild** menu, bindable to shortcuts in **Settings/Preferences > Keymap**. They build the solution as it is + configured, in its active configuration and platform, through MSBuild accelerated by Incredibuild. While one runs, + the status bar shows the accelerated build in progress, and a notification reports the result. +- **Settings/Preferences > Tools > Incredibuild** can set the MSBuild that Rider builds use, when the automatically + detected one isn't the right one. +- The installers now also install the plugin into CLion and Rider. +- **Use Incredibuild for the IDE's own Build and Rebuild actions** now works in CLion and Rider too, not only in + RustRover. In CLion it covers **Build**, **Rebuild**, **Build Project** and the build that runs before a CMake run + configuration starts. In Rider it covers **Build Solution**, **Rebuild Solution**, **Build Selection** and the + **Build Project**/**Build Solution** before-run steps. In both IDEs that includes the toolbar build button and + keyboard shortcuts. Off by default, and the **Incredibuild** menu actions are available either way. +- **Settings/Preferences > Tools > Incredibuild** shows the Incredibuild installation the plugin uses (folder, version + and, on Windows, whether this machine is licensed), with a **Re-check** button. + +### Changed + +- When IDE build acceleration is on but Incredibuild is not installed, not licensed or too old, the build now runs + with the IDE's own builder and a notification explains why, with links to the settings and the download page. + Previously RustRover fell back silently. The notification is shown once per project session for each problem. +- Only one Incredibuild build runs at a time, in every IDE. Starting a build - from the **Incredibuild** menu or, + with IDE build acceleration on, from the IDE's own build actions - while another is still running offers to stop + the running one, then starts the new build as soon as it has stopped. Previously a second build started alongside + the first, replacing its output in the **Incredibuild** tool window. +- Renamed the setting **Accelerate the IDE's own build actions** to **Use Incredibuild for the IDE's own Build and + Rebuild actions**. The saved value is kept. +- The Windows installers (`install-intellij-ib-plugin.ps1` and `intellij-ib-plugin.exe`) are now attached to each + GitHub release, built by the release workflow from the published plugin, instead of the script being committed to + the repository. + ## [1.0.3] ### Added diff --git a/README.md b/README.md index 4c339c5..5e049d1 100644 --- a/README.md +++ b/README.md @@ -24,19 +24,54 @@ configuration, build profile and target selection are all respected. ### Accelerating the IDE's own build actions -The IDE's build actions can be accelerated too. Turn on **Accelerate the IDE's own build actions** -under **Settings/Preferences > Tools > Incredibuild**, and **Build Project** - along with the build -that runs before **Run** and **Test** - goes through Incredibuild, reporting into the IDE's own -**Build** tool window. The commands and keyboard shortcuts stay exactly where they are. - -The setting is off by default. While it is off - or if Incredibuild isn't installed on the machine - -those actions behave exactly as they do without this plugin. The actions in the **Incredibuild** menu -always use Incredibuild, whichever way the setting is set. +The IDE's build actions can be accelerated too, in RustRover, CLion and Rider. Turn on **Use Incredibuild +for the IDE's own Build and Rebuild actions** under **Settings/Preferences > Tools > Incredibuild**, and the +standard Build and Rebuild go through Incredibuild - from the Build menu, the toolbar, keyboard shortcuts, +and the build that runs before a run configuration starts: + +- **RustRover** - **Build Project**, **Rebuild Project**, and the build before **Run** and **Test**, + reporting into the IDE's own **Build** tool window +- **CLion** - **Build**, **Rebuild**, **Build Project**, and the build before a CMake run configuration + starts, reporting into the **Incredibuild** tool window +- **Rider** - **Build Solution**, **Rebuild Solution**, **Build Selection**, and the **Build Project** / + **Build Solution** before-run steps, reporting into the **Incredibuild** tool window + +The commands and keyboard shortcuts stay exactly where they are. The setting is off by default, and while +it is off those actions behave exactly as they do without this plugin. If Incredibuild is not installed, +not licensed or too old, the build runs with the IDE's own builder instead and a notification explains why +(once per project session for each problem). The same settings page shows the Incredibuild installation the +plugin uses - its folder, version and, on Windows, whether this machine is licensed. The actions in the +**Incredibuild** menu always use Incredibuild, whichever way the setting is set. + +### Rider (.NET) + +In Rider, the **Build** menu gets dedicated Incredibuild actions right after the standard ones (they are +also in the **Incredibuild** menu): + +- **Build Solution with Incredibuild** - builds the solution in its active configuration and platform +- **Rebuild Solution with Incredibuild** - cleans and rebuilds it +- **Build Selected Projects with Incredibuild** - builds the projects selected in the Solution Explorer, + along with the projects they reference + +The same actions are in the Solution Explorer's context menu on the solution, solution folders and +projects, and each can be bound to a shortcut under **Settings/Preferences > Keymap** (search for +"Incredibuild"). They have no default shortcuts, so none of Rider's own are taken over. + +They run MSBuild on the solution exactly as it is configured: the active configuration and platform +are respected (for selected projects, through the solution's own project configuration mapping), and +an open solution filter (`.slnf`) limits the build the same way it limits Rider's own. Nothing about the +solution or its projects is changed. While an accelerated build runs, the status bar shows +*Building with Incredibuild*, the **Incredibuild** tool window shows its output under a banner +naming the configuration and MSBuild used, and a notification reports the result. + +MSBuild is detected automatically: the newest Visual Studio / Build Tools `MSBuild.exe` with the .NET SDK component on Windows, +otherwise the .NET SDK's `dotnet msbuild`. Rider's own MSBuild selection lives in its backend settings, +which plugins can't read, so a different one can be set under **Settings/Preferences > Tools > Incredibuild**. ### Settings **Settings/Preferences > Tools > Incredibuild** configures the number of parallel jobs (`-j`) -accelerated builds request, and the opt-in above. +accelerated Cargo/CMake builds request, the opt-in above, and (in Rider) the MSBuild to use. If Incredibuild isn't installed on the machine, the menu commands offer to open [incredibuild.com](https://www.incredibuild.com/) to download it. @@ -46,6 +81,7 @@ If Incredibuild isn't installed on the machine, the menu commands offer to open - RustRover, or IntelliJ IDEA Ultimate with the Rust plugin - 2026.1 or 2026.2 (builds 261-262). The Rust plugin is released for one IDE line at a time, and this plugin depends on it, so support for a newer line follows a Rust plugin release for it. +- Or Rider 2026.2, with MSBuild (Visual Studio / Build Tools) or the .NET SDK installed. - [Incredibuild](https://www.incredibuild.com/) installed, on Windows or Linux: - Windows: 10.37.1 or later - Linux: 4.29.3 or later, installed under `/opt/incredibuild` diff --git a/build.gradle.kts b/build.gradle.kts index 334b430..c625c3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.gradle.api.tasks.bundling.AbstractArchiveTask import org.gradle.api.tasks.bundling.Zip import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask plugins { id("org.jetbrains.kotlin.jvm") @@ -52,6 +53,9 @@ dependencies { // Verifier against a plain 2026.2 target (only 262.8665+ builds have it). plugin("com.intellij.nativeDebug", "262.8665.176") bundledPlugin("intellij.testRunner.plugin") + // The Rider-only .NET adapter (see rider/build.gradle.kts), merged into this plugin's + // own jar rather than shipped as a separate one - still a single plugin to install. + pluginComposedModule(implementation(project(":rider"))) testFramework(TestFrameworkType.Platform) } } @@ -107,21 +111,86 @@ intellijPlatform { // resolve CMake-only classes - not a bug. Rather than dropping COMPATIBILITY_PROBLEMS // from failureLevel entirely (which would also hide a real, future compatibility // break, e.g. a 2026.x IDE removing an API this plugin uses), those 4 specific, - // already-reviewed problems are listed in ignoredProblemsFile instead - everything + // already-reviewed problems are listed in an ignored-problems file instead - everything // else in that category still fails the task. failureLevel is left at its default // (COMPATIBILITY_PROBLEMS + INTERNAL_API_USAGES; confirmed by observing which // categories actually failed the task even though deprecated/experimental/missing- // optional-dependency problems were also present and reported) - it already caught a // real, fixable internal-API usage (PluginManagerCore.getPlugin(), replaced with the // public isLoaded() instead) and should keep gating future ones the same way. - ignoredProblemsFile.set(rootProject.layout.projectDirectory.file("gradle/pluginVerifier-ignoredProblems.txt")) + // + // The ignored-problems file is set per IDE, not here - see the per-IDE verification + // tasks below. ides { create(IntelliJPlatformType.CLion, "2026.2") create(IntelliJPlatformType.RustRover, "2026.2") + // The only IDE that ever loads the .NET half (rider-support.xml). + // Rider is only published as a Maven artifact, not a regular installer - see + // https://github.com/JetBrains/intellij-platform-gradle-plugin/issues/1852. + create(IntelliJPlatformType.Rider, "2026.2") { + useInstaller = false + } } } } +// One verification run per IDE, each with only the ignored problems that IDE is expected to +// report. A Plugin Verifier ignored-problems file applies to every IDE in a run - its conditions +// can name a plugin but not an IDE - so a single shared file would also hide those same messages +// where they *would* be real problems: "Package 'org.rust' is not found" is expected against +// Rider (no Rust plugin) but would mean a broken build against RustRover, and the CMake-only +// symbols are expected against RustRover but never against CLion. Splitting the runs keeps that +// coverage. +// +// All three take the IDEs pluginVerification.ides resolves above and keep only their own, by the +// productCode in each IDE's product-info.json. `verifyPlugin` itself verifies CLion and runs the +// other two, so CI's `./gradlew verifyPlugin` still covers all three. Reports go to one +// subdirectory each of build/reports/pluginVerifier, which CI uploads as a whole. +data class PerIdeVerification(val productCode: String, val reportsSubdirectory: String, val ignoredProblems: String?) + +val perIdeVerifications = mapOf( + "verifyPlugin" to PerIdeVerification("CL", "clion", null), + "verifyPluginRustRover" to PerIdeVerification("RR", "rustrover", "gradle/pluginVerifier-ignoredProblems-rustrover.txt"), + "verifyPluginRider" to PerIdeVerification("RD", "rider", "gradle/pluginVerifier-ignoredProblems-rider.txt"), +) + +for ((taskName, verification) in perIdeVerifications) { + val configure: VerifyPluginTask.() -> Unit = { + // A snapshot of the sources the plugin wired in, not `ides` itself - filtering a + // collection into its own sources would make it depend on itself. + val allIdes = files(ides.from.toList()) + val productCode = verification.productCode + ides.setFrom(allIdes.filter { ide -> + val productInfo = ide.resolve("product-info.json") + productInfo.isFile && + Regex(""""productCode"\s*:\s*"([A-Z]+)"""").find(productInfo.readText())?.groupValues?.get(1) == productCode + }) + verification.ignoredProblems?.let { ignoredProblemsFile.set(layout.projectDirectory.file(it)) } + verificationReportsDirectory.set(layout.buildDirectory.dir("reports/pluginVerifier/${verification.reportsSubdirectory}")) + // A home directory of its own for each run. These tasks can run in parallel (the + // configuration cache allows it), and every Plugin Verifier run starts by deleting and + // recreating /extracted-plugins - with the shared default home (~/.pluginVerifier), + // one run deleting it while another walks it fails that run with NoSuchFileException. + systemProperty( + "plugin.verifier.home.dir", + layout.buildDirectory.dir("pluginVerifier/${verification.reportsSubdirectory}").get().asFile.absolutePath + ) + } + if (taskName == "verifyPlugin") { + tasks.named(taskName, configure) + } else { + tasks.register(taskName) { + group = "intellij platform" + description = "Verifies the plugin against ${verification.reportsSubdirectory} only - see build.gradle.kts." + configure() + } + } +} + +tasks.named("verifyPlugin") { + dependsOn("verifyPluginRustRover", "verifyPluginRider") +} + // Plain `runIde` launches intellijIdeaUltimate (the platform this plugin's dependencies block // above declares), which never loads the CMake half at all (see plugin.xml's optional // ) - there's nothing to manually exercise there. Add a @@ -132,12 +201,21 @@ intellijPlatformTesting { type = IntelliJPlatformType.CLion version = "2026.2" } + // Likewise for the .NET/Rider actions (rider-support.xml). + register("runIdeForRider") { + type = IntelliJPlatformType.Rider + version = "2026.2" + useInstaller = false + } } } // buildSearchableOptions launches a sandboxed IDE to index Settings UI labels for search. // That launcher doesn't support ARM64 hosts ("Unsupported JVM architecture: aarch64") - it's a // build-time search-indexing convenience only, not required for the plugin to function. -tasks.named("buildSearchableOptions") { - enabled = false +// Switched off through the plugin's own flag rather than by disabling just that one task: the +// follow-up prepareJarSearchableOptions task still expects the output folder buildSearchableOptions +// would have created, so after a `clean` the task-only approach fails the build. +intellijPlatform { + buildSearchableOptions = false } diff --git a/gradle.properties b/gradle.properties index 2b55b6c..18ab9c2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ group = com.incredibuild.ibplugin -version = 1.0.3 +version = 1.1.0 pluginRepositoryUrl = https://github.com/Incredibuild-RND/intellij-ib-plugin diff --git a/gradle/pluginVerifier-ignoredProblems-rider.txt b/gradle/pluginVerifier-ignoredProblems-rider.txt new file mode 100644 index 0000000..5b5f092 --- /dev/null +++ b/gradle/pluginVerifier-ignoredProblems-rider.txt @@ -0,0 +1,12 @@ +// Plugin Verifier "ignored problems" file for the Rider run only (verifyPluginRider) - see the +// per-IDE verification tasks in build.gradle.kts. Each IDE gets its own file because a file's +// conditions apply to every IDE in a run: this line must not hide a real org.rust problem when +// verifying against RustRover. Format: see pluginVerifier-ignoredProblems-rustrover.txt. +// +// The optional-dependency split seen from Rider, which has no Rust plugin: +// RunWithIncredibuildAction is kept in the source tree but not registered anywhere (see the +// commented-out registration in rust-support.xml), and everything that IS registered against +// org.rust only loads through rust-support.xml. Confirmed by the Verifier's own "Missing +// dependencies: com.jetbrains.rust (optional): Unavailable" against Rider. (The CMake problems +// ignored for RustRover don't occur here: Rider bundles com.intellij.cmake itself.) +com.incredibuild.ibplugin:Package 'org\.rust' is not found diff --git a/gradle/pluginVerifier-ignoredProblems.txt b/gradle/pluginVerifier-ignoredProblems-rustrover.txt similarity index 75% rename from gradle/pluginVerifier-ignoredProblems.txt rename to gradle/pluginVerifier-ignoredProblems-rustrover.txt index 3920afc..8f01029 100644 --- a/gradle/pluginVerifier-ignoredProblems.txt +++ b/gradle/pluginVerifier-ignoredProblems-rustrover.txt @@ -1,6 +1,7 @@ -// Plugin Verifier "ignored problems" file - see pluginVerification.ignoredProblemsFile in -// build.gradle.kts for how this is wired in, and its comment for why these specific problems -// are expected rather than bugs. +// Plugin Verifier "ignored problems" file for the RustRover run only (verifyPluginRustRover) - see +// the per-IDE verification tasks in build.gradle.kts for how it's wired in, and the +// pluginVerification comment there for why these specific problems are expected rather than bugs. +// Each IDE gets its own file because a file's conditions apply to every IDE in a run. // // Format (one condition per line, blank lines and "//" comments skipped): either // ":" or just "". No version field, so these keep applying across @@ -11,8 +12,7 @@ // (com.jetbrains.cidr.cpp.toolchains.*, com.jetbrains.cidr.cpp.cmake.*) showing up as // unresolved when verified against RustRover, which has no CMake plugin - the optional- // dependency split (plugin.xml's cmake-support.xml) working as intended, confirmed by the -// Verifier's own "Missing dependencies: com.intellij.cmake (optional): Unavailable". They can -// never match when verifying against CLion, which does have com.intellij.cmake. +// Verifier's own "Missing dependencies: com.intellij.cmake (optional): Unavailable". com.incredibuild.ibplugin:Package 'com\.jetbrains\.cidr\.cpp\.toolchains' is not found com.incredibuild.ibplugin:Package 'com\.jetbrains\.cidr\.cpp\.cmake' is not found com.incredibuild.ibplugin:Access to unresolved class com\.jetbrains\.cidr\.cpp\.execution\.CMakeAppRunConfiguration\.BuildAndRunConfigurations diff --git a/installer/README.md b/installer/README.md index 8fc2eb0..3be25fc 100644 --- a/installer/README.md +++ b/installer/README.md @@ -2,12 +2,16 @@ Produces both distributable installers from `build/distributions/intellij-ib-plugin.zip`: -- `scripts/install-intellij-ib-plugin.ps1` - a single, self-contained PowerShell - script (the plugin zip is embedded as base64) for anyone who'd rather not run - an unsigned `.exe`. +- `build/distributions/install-intellij-ib-plugin.ps1` - a single, self-contained + PowerShell script (the plugin zip is embedded as base64) for anyone who'd + rather not run an unsigned `.exe`. - `build/distributions/intellij-ib-plugin.exe` - a self-extracting installer with zero visible windows, for double-click installs. +Neither is committed to the repo. Publishing a GitHub release runs the +**Release** workflow, whose `installers` job builds both on a Windows runner from +the exact plugin zip the release published, and attaches them to the release. + ## Usage ``` @@ -19,6 +23,13 @@ This runs `.\gradlew.bat buildPlugin` itself first, so source before either installer is packaged - it can never silently package a stale zip left over from an earlier build. +To package an already-built zip instead (what the release workflow does), pass it +with `-PluginZip`: + +``` +powershell -ExecutionPolicy Bypass -File .\installer\build-installer.ps1 -PluginZip path\to\intellij-ib-plugin.zip +``` + Prerequisites (both are standard on a Windows dev machine, neither is bundled): - **7-Zip** (`7z.exe` on `PATH`, or installed at `C:\Program Files\7-Zip`) - used @@ -36,7 +47,7 @@ plugin zip and report progress: | | `install-common.ps1` sandwiched between... | plugin zip | output | |---|---|---|---| -| Standalone script | `standalone-header.ps1` / `standalone-footer.ps1` | embedded as base64 | `scripts/install-intellij-ib-plugin.ps1` (committed to the repo) | +| Standalone script | `standalone-header.ps1` / `standalone-footer.ps1` | embedded as base64 | `build/distributions/install-intellij-ib-plugin.ps1` (attached to each GitHub release) | | SFX installer | `sfx-header.ps1` / `sfx-footer.ps1` | packaged alongside as a sibling file | assembled at build time, only shipped inside the `.exe` | ### The `.exe`'s pieces diff --git a/installer/build-installer.ps1 b/installer/build-installer.ps1 index 7811b3f..81b5960 100644 --- a/installer/build-installer.ps1 +++ b/installer/build-installer.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS -Builds both distributable installers from the current build/distributions/intellij-ib-plugin.zip: - - scripts/install-intellij-ib-plugin.ps1 (self-contained, base64-embedded script) +Builds both distributable installers from the plugin zip: + - build/distributions/install-intellij-ib-plugin.ps1 (self-contained, base64-embedded script) - build/distributions/intellij-ib-plugin.exe (self-extracting installer, zero visible windows) .DESCRIPTION @@ -9,45 +9,68 @@ Just run this script - it runs `.\gradlew buildPlugin` itself first, so build/distributions/intellij-ib-plugin.zip is always current: powershell -ExecutionPolicy Bypass -File .\installer\build-installer.ps1 +Neither output is committed: the release workflow (.github/workflows/release.yml) runs this +with -PluginZip pointing at the zip it just published, so the installers attached to a GitHub +release always carry exactly that build. + +.PARAMETER PluginZip +An already-built plugin zip to package, instead of running `gradlew buildPlugin`. + Both generated scripts share the IDE-detection/validation logic in install-common.ps1 (edit that ONE file for both, rather than either generated script directly - regenerate by re-running this script). See installer/README.md for the full pipeline explanation and prerequisites. #> +param( + [string]$PluginZip +) + $ErrorActionPreference = "Stop" $installerDir = $PSScriptRoot $repoRoot = Split-Path -Parent $installerDir -$pluginZip = Join-Path $repoRoot "build\distributions\intellij-ib-plugin.zip" -$standaloneScriptOut = Join-Path $repoRoot "scripts\install-intellij-ib-plugin.ps1" -$exeOut = Join-Path $repoRoot "build\distributions\intellij-ib-plugin.exe" +$outDir = Join-Path $repoRoot "build\distributions" +$standaloneScriptOut = Join-Path $outDir "install-intellij-ib-plugin.ps1" +$exeOut = Join-Path $outDir "intellij-ib-plugin.exe" # --- 0. Rebuild the plugin so the zip can never be stale ------------------- -$gradlew = Join-Path $repoRoot "gradlew.bat" -if (-not (Test-Path $gradlew)) { - Write-Error "$gradlew not found - expected at the repo root." - exit 1 -} +if ($PluginZip) { + if (-not (Test-Path $PluginZip)) { + Write-Error "$PluginZip not found." + exit 1 + } + $pluginZip = (Resolve-Path $PluginZip).Path +} else { + $pluginZip = Join-Path $outDir "intellij-ib-plugin.zip" -Write-Host "Running '.\gradlew.bat buildPlugin' ..." -Push-Location $repoRoot -try { - & $gradlew buildPlugin --console=plain - if ($LASTEXITCODE -ne 0) { - Write-Error "gradlew buildPlugin failed (exit $LASTEXITCODE)." + $gradlew = Join-Path $repoRoot "gradlew.bat" + if (-not (Test-Path $gradlew)) { + Write-Error "$gradlew not found - expected at the repo root." exit 1 } -} finally { - Pop-Location -} -if (-not (Test-Path $pluginZip)) { - Write-Error "$pluginZip still not found after 'gradlew buildPlugin' - check the buildPlugin task's output archive name/path." - exit 1 + Write-Host "Running '.\gradlew.bat buildPlugin' ..." + Push-Location $repoRoot + try { + & $gradlew buildPlugin --console=plain + if ($LASTEXITCODE -ne 0) { + Write-Error "gradlew buildPlugin failed (exit $LASTEXITCODE)." + exit 1 + } + } finally { + Pop-Location + } + + if (-not (Test-Path $pluginZip)) { + Write-Error "$pluginZip still not found after 'gradlew buildPlugin' - check the buildPlugin task's output archive name/path." + exit 1 + } } +New-Item -ItemType Directory -Path $outDir -Force | Out-Null + # --- Locate prerequisites --------------------------------------------------- $sevenZip = Get-Command "7z.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source @@ -86,7 +109,7 @@ try { exit 1 } - # --- 2. Assemble scripts/install-intellij-ib-plugin.ps1 (standalone) --- + # --- 2. Assemble install-intellij-ib-plugin.ps1 (standalone) ----------- Write-Host "Assembling $standaloneScriptOut ..." $pluginZipBase64 = [System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($pluginZip)) diff --git a/installer/install-common.ps1 b/installer/install-common.ps1 index b923260..3f09292 100644 --- a/installer/install-common.ps1 +++ b/installer/install-common.ps1 @@ -1,12 +1,14 @@ # Shared by both installer/standalone-footer.ps1 (assembled into the -# self-contained scripts/install-intellij-ib-plugin.ps1) and +# self-contained install-intellij-ib-plugin.ps1) and # installer/sfx-footer.ps1 (assembled into the SFX installer's own # install.ps1) via installer/build-installer.ps1 - edit this ONE file rather # than both generated scripts. -# RR = RustRover, IU = IntelliJ IDEA Ultimate. Community editions (IC) don't -# carry com.intellij.modules.ultimate, which the Rust plugin requires there. -$targetProductCodes = @("RR", "IU") +# RR = RustRover, IU = IntelliJ IDEA Ultimate, CL = CLion (the CMake actions - +# see cmake-support.xml), RD = Rider (the .NET actions - see +# rider-support.xml). Community editions (IC) don't carry +# com.intellij.modules.ultimate, which the Rust plugin requires there. +$targetProductCodes = @("RR", "IU", "CL", "RD") # Installs can land in quite a few places, so several roots are searched # recursively rather than assuming one fixed layout: @@ -30,7 +32,7 @@ $registryInstallLocations = @( } | ForEach-Object { Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue } | Where-Object { - $_.DisplayName -match "RustRover|IntelliJ IDEA" -and $_.InstallLocation + $_.DisplayName -match "RustRover|IntelliJ IDEA|CLion|Rider" -and $_.InstallLocation } | ForEach-Object { $_.InstallLocation } | Select-Object -Unique $searchRoots = @( @@ -80,7 +82,7 @@ function Stop-InstallerTranscriptIfRunning { if (-not $installs) { $searched = ($searchRoots | Select-Object -Unique) -join "; " - $message = "No supported JetBrains IDE (RustRover or IntelliJ IDEA Ultimate) found. Searched: $searched." + $message = "No supported JetBrains IDE (RustRover, IntelliJ IDEA Ultimate, CLion or Rider) found. Searched: $searched." if ($allProducts) { $found = ($allProducts | ForEach-Object { "$($_.ProductName) [$($_.ProductCode)] at $($_.InfoPath)" }) -join "; " $message += " Found (but not supported by this plugin): $found." diff --git a/installer/sfx-header.ps1 b/installer/sfx-header.ps1 index de73806..6b47f6f 100644 --- a/installer/sfx-header.ps1 +++ b/installer/sfx-header.ps1 @@ -1,7 +1,7 @@ <# Installs (or updates) the Incredibuild Build Acceleration plugin into every -supported JetBrains IDE found on this machine (RustRover, and IntelliJ IDEA -Ultimate). Meant to be run from inside the self-extracting installer, +supported JetBrains IDE found on this machine (RustRover, IntelliJ IDEA +Ultimate, CLion, and Rider). Meant to be run from inside the self-extracting installer, alongside intellij-ib-plugin.zip. Detects installs by reading each product's own product-info.json rather than diff --git a/installer/standalone-header.ps1 b/installer/standalone-header.ps1 index 2defd0e..ef28994 100644 --- a/installer/standalone-header.ps1 +++ b/installer/standalone-header.ps1 @@ -1,8 +1,8 @@ <# .SYNOPSIS Installs (or updates) the "Incredibuild Build Acceleration" plugin into every -supported JetBrains IDE found on this machine (RustRover, and IntelliJ IDEA -Ultimate). +supported JetBrains IDE found on this machine (RustRover, IntelliJ IDEA +Ultimate, CLion, and Rider). .DESCRIPTION Fully self-contained: the plugin zip is embedded below as base64, so this is diff --git a/rider/build.gradle.kts b/rider/build.gradle.kts new file mode 100644 index 0000000..ee2252d --- /dev/null +++ b/rider/build.gradle.kts @@ -0,0 +1,47 @@ +// The Rider-only adapter for this plugin's .NET support: a thin layer over Rider's frontend +// solution model (which solution is open, its active configuration|platform, what's selected in +// the Solution Explorer), compiled against Rider itself because those classes only exist in the +// Rider product, never as a Marketplace plugin the root module could compile against alongside +// the Rust/CMake ones. It is merged into the root plugin's jar (see pluginComposedModule in the +// root build.gradle.kts), so this is still one plugin and one install - and only ever called +// from code that rider-support.xml registers, i.e. only when running inside Rider. +// +// Deliberately depends on nothing in the root module (that would be a dependency cycle - the +// root module depends on this one), and exposes only platform types in its API so the root +// module never needs Rider classes on its own compile classpath. + +import org.gradle.api.tasks.bundling.AbstractArchiveTask + +plugins { + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.intellij.platform.module") +} + +kotlin { + jvmToolchain(25) + // Same reason as the root module's identical setting. + compilerOptions { + freeCompilerArgs.add("-Xjvm-default=all") + } +} + +dependencies { + intellijPlatform { + // Same 2026.2 (262) line as the root module's other compile targets. + rider("2026.2") { + useInstaller = false + } + // Rider's solution-host extensions (project.solutionFile, isExistingSolution, ...) and its + // Solution Explorer workspace model (ProjectModelEntity) live in this core content module, + // which isn't on the default compile classpath. It's a "required" module of Rider's core + // plugin, so it's always loaded at runtime in Rider. + bundledModule("intellij.rider.rdclient.dotnet") + bundledModule("intellij.rd.ide.model.generated") + } +} + +// Same as the root build.gradle.kts: keep real timestamps on this module's classes, which end up +// merged into the plugin's own jar, instead of Gradle's fixed 1980-02-01 placeholder. +tasks.withType().configureEach { + isPreserveFileTimestamps = true +} diff --git a/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/IncredibuildBeforeRunTaskProviders.kt b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/IncredibuildBeforeRunTaskProviders.kt new file mode 100644 index 0000000..f34fe00 --- /dev/null +++ b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/IncredibuildBeforeRunTaskProviders.kt @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.rider + +import com.intellij.execution.BeforeRunTask +import com.intellij.execution.BeforeRunTaskProvider +import com.intellij.execution.configurations.RunConfiguration +import com.intellij.execution.runners.ExecutionEnvironment +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.Key +import com.jetbrains.rider.build.tasks.BuildProjectBeforeRunTask +import com.jetbrains.rider.build.tasks.BuildProjectBeforeRunTaskProvider +import com.jetbrains.rider.build.tasks.BuildSolutionBeforeRunTask +import com.jetbrains.rider.build.tasks.BuildSolutionBeforeRunTaskProvider +import com.jetbrains.rider.run.configurations.IProjectBasedRunConfiguration +import com.jetbrains.rider.run.configurations.multiPlatform.RiderMultiPlatformConfiguration +import org.jetbrains.concurrency.Promise +import javax.swing.Icon + +private val LOG = Logger.getInstance("com.incredibuild.ibplugin.rider.IncredibuildBeforeRunTaskProviders") + +/** + * Stands in for one of Rider's own before-run build steps, so the build that runs before a run + * configuration starts can go through Incredibuild. + * + * Rider offers no hook into that build - its providers hand it straight to the ReSharper backend + * (`BuildTaskThrottler`/`BuildHost`, both final) - so this is registered with Rider's provider's own + * [Key] and order="first" (rider-support.xml): the platform runs a before-run task with the first + * provider registered for its key (`BeforeRunTaskProvider.getProvider`), so it reaches this one. + * Everything else is left to Rider's provider, so existing run configurations, their saved + * before-run steps and the step's editor are untouched: + * - [createTask] returns null, which keeps this provider out of the "Add before-run task" list and + * out of the default steps a new run configuration gets (both skip a provider that creates + * nothing) - Rider's provider still creates every task, and loading a saved step resolves to it + * as well (the platform's id-to-provider map keeps the last one registered); + * - presentation and configuration are delegated to [rider]; + * - [executeTask] asks [RiderBuildInterceptor] to build, and hands the task back to Rider's own + * provider whenever that declines (the setting is off, Incredibuild is unavailable, ...). + */ +abstract class IncredibuildBeforeRunTaskProvider> : BeforeRunTaskProvider() { + + /** Rider's own provider. Stateless, so a private instance behaves exactly like the registered one. */ + protected abstract val rider: BeforeRunTaskProvider + + /** The project to build for [configuration]/[task], or null for the whole solution. */ + protected abstract fun projectFile(configuration: RunConfiguration, task: T): String? + + override fun getId(): Key = rider.id + + override fun getName(): String = rider.name + + override fun getIcon(): Icon? = rider.icon + + override fun getTaskIcon(task: T): Icon? = rider.getTaskIcon(task) + + override fun getDescription(task: T): String = rider.getDescription(task) + + override fun isConfigurable(): Boolean = rider.isConfigurable + + override fun configureTask(context: DataContext, configuration: RunConfiguration, task: T): Promise = + rider.configureTask(context, configuration, task) + + override fun createTask(runConfiguration: RunConfiguration): T? = null + + override fun canExecuteTask(configuration: RunConfiguration, task: T): Boolean = + rider.canExecuteTask(configuration, task) + + override fun executeTask( + context: DataContext, + configuration: RunConfiguration, + environment: ExecutionEnvironment, + task: T + ): Boolean { + val accelerated = try { + RiderBuildInterceptor.getInstance()?.buildBeforeRun(configuration.project, projectFile(configuration, task)) + } catch (e: Exception) { + LOG.warn("Incredibuild before-run build failed to start; falling back to Rider's build", e) + null + } + return accelerated ?: rider.executeTask(context, configuration, environment, task) + } +} + +/** Rider's default "Build Project" before-run step - builds the run configuration's project. */ +class IncredibuildBuildProjectBeforeRunTaskProvider : IncredibuildBeforeRunTaskProvider() { + + override val rider = BuildProjectBeforeRunTaskProvider() + + /** Resolved the way Rider's own provider resolves it: the step's own project, else the run configuration's. */ + override fun projectFile(configuration: RunConfiguration, task: BuildProjectBeforeRunTask): String? = + task.selectedProject?.path?.toString() + ?: (configuration as? RiderMultiPlatformConfiguration)?.parameters?.projectFilePath?.value + ?: (configuration as? IProjectBasedRunConfiguration)?.getProjectFilePath() + ?: throw IllegalStateException("No project to build for ${configuration.name}") +} + +/** Rider's "Build Solution" before-run step. */ +class IncredibuildBuildSolutionBeforeRunTaskProvider : IncredibuildBeforeRunTaskProvider() { + + override val rider = BuildSolutionBeforeRunTaskProvider() + + override fun projectFile(configuration: RunConfiguration, task: BuildSolutionBeforeRunTask): String? = null +} diff --git a/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderBuildInterceptor.kt b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderBuildInterceptor.kt new file mode 100644 index 0000000..6757648 --- /dev/null +++ b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderBuildInterceptor.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.rider + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project + +/** + * Takes over the build Rider runs before a run configuration starts - see + * [IncredibuildBuildProjectBeforeRunTaskProvider]. Declared here, implemented in the root module + * (which owns Incredibuild and MSBuild), and wired together as an application service in + * rider-support.xml - this module can't call into the root module directly, since the root module + * depends on it. + */ +interface RiderBuildInterceptor { + + /** + * Builds [projectFile] (and the projects it references) - or the whole solution when null - + * through Incredibuild, blocking until it ends. Returns whether it succeeded, or null to let + * Rider build it as usual instead (the setting is off, Incredibuild is unavailable, ...). + * Called off the EDT. + */ + fun buildBeforeRun(project: Project, projectFile: String?): Boolean? + + companion object { + fun getInstance(): RiderBuildInterceptor? = + ApplicationManager.getApplication().getService(RiderBuildInterceptor::class.java) + } +} diff --git a/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderSolutionModel.kt b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderSolutionModel.kt new file mode 100644 index 0000000..46c97e5 --- /dev/null +++ b/rider/src/main/kotlin/com/incredibuild/ibplugin/rider/RiderSolutionModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.rider + +import com.intellij.openapi.actionSystem.DataContext +import com.intellij.openapi.project.Project +import com.jetbrains.rider.projectView.SolutionConfigurationManager +import com.jetbrains.rider.projectView.isExistingSolution +import com.jetbrains.rider.projectView.solutionFile +import com.jetbrains.rider.projectView.solutionFilterPath +import com.jetbrains.rider.projectView.workspace.ProjectModelEntity +import com.jetbrains.rider.projectView.workspace.getProjectModelEntities +import com.jetbrains.rider.projectView.workspace.isProject +import com.jetbrains.rider.projectView.workspace.isSolution +import com.jetbrains.rider.projectView.workspace.isSolutionFolder +import com.jetbrains.rider.projectView.workspace.isUnloadedProject + +/** The solution configuration Rider's own build would use, e.g. "Debug" / "Any CPU". */ +class SolutionConfiguration(val configuration: String, val platform: String) + +/** What the Solution Explorer selection asks to be built - see [RiderSolutionModel.selection]. */ +sealed interface SolutionSelection { + /** The solution node itself is selected: build everything. */ + data object WholeSolution : SolutionSelection + + /** [projectFiles] are absolute paths of project files (.csproj, .vbproj, .fsproj, ...). */ + class Projects(val projectFiles: List) : SolutionSelection + + /** Nothing buildable is selected (e.g. a plain file or folder, or no Solution Explorer focus). */ + data object Nothing : SolutionSelection +} + +/** + * Read-only access to the parts of Rider's frontend solution model the Incredibuild .NET actions + * need. Everything here mirrors what Rider's own build actions consult + * ([com.jetbrains.rider.build.actions.BuildSolutionActionBase], + * [com.jetbrains.rider.build.actions.BuildSelectionActionBase]), so the accelerated build targets + * exactly what Rider's own one would - but deliberately stops short of Rider's + * [com.jetbrains.rider.build.BuildHost]: that hands the build to the ReSharper backend, which runs + * MSBuild in its own process tree, where Incredibuild can't wrap it. + * + * Only platform types cross this API, so the root module (compiled against IntelliJ IDEA, not + * Rider) can call it without Rider classes on its own classpath. + */ +object RiderSolutionModel { + + /** + * Whether [project] is a real .sln/.slnx solution - not a folder opened as a "directory + * solution", a CMake project opened in Rider, or the temporary placeholder solution Rider + * opens with no solution at all, none of which have a solution file MSBuild could build. + */ + fun hasSolution(project: Project): Boolean = project.isExistingSolution + + /** + * The file to hand MSBuild for a whole-solution build. A solution filter (.slnf) wins when one + * is open, because that's what Rider's own build is scoped to then - and MSBuild accepts a + * .slnf directly, building only the projects it includes. + */ + fun buildEntryPoint(project: Project): String? { + if (!hasSolution(project)) return null + project.solutionFilterPath?.let { return it.toString() } + return project.solutionFile.absolutePath + } + + /** The actual .sln/.slnx file, even when a filter is open - see [buildEntryPoint]. */ + fun solutionFile(project: Project): String? = + if (hasSolution(project)) project.solutionFile.absolutePath else null + + /** The configuration|platform selected in Rider's toolbar, the one its own Build uses. */ + fun activeConfiguration(project: Project): SolutionConfiguration? { + val active = SolutionConfigurationManager.tryGetInstance(project)?.activeConfigurationAndPlatform ?: return null + return SolutionConfiguration(active.configuration, active.platform) + } + + /** + * What the selection in [dataContext] asks to build, the same way Rider's own "Build Selection" + * reads it: the solution node means the whole solution, project nodes mean those projects, + * and a solution folder means every project nested in it. Anything else (files, folders, + * references) isn't buildable by itself, matching Rider - which also only offers its own + * build-selection action on those three kinds of node. + */ + fun selection(dataContext: DataContext): SolutionSelection { + val entities = dataContext.getProjectModelEntities() + if (entities.isEmpty()) return SolutionSelection.Nothing + if (entities.any { it.isSolution() }) return SolutionSelection.WholeSolution + + val projectFiles = LinkedHashSet() + for (entity in entities) { + when { + entity.isProject() -> entity.projectFilePath()?.let(projectFiles::add) + entity.isSolutionFolder() -> collectProjects(entity, projectFiles) + } + } + return if (projectFiles.isEmpty()) SolutionSelection.Nothing else SolutionSelection.Projects(projectFiles.toList()) + } + + private fun collectProjects(folder: ProjectModelEntity, into: MutableSet) { + for (child in folder.childrenEntities) { + when { + child.isProject() -> child.projectFilePath()?.let(into::add) + child.isSolutionFolder() -> collectProjects(child, into) + } + } + } + + /** An unloaded project can't be built by Rider either - skipped rather than handed to MSBuild. */ + private fun ProjectModelEntity.projectFilePath(): String? = + if (isUnloadedProject()) null else url?.presentableUrl +} diff --git a/scripts/install-intellij-ib-plugin.ps1 b/scripts/install-intellij-ib-plugin.ps1 deleted file mode 100644 index 8db11a0..0000000 --- a/scripts/install-intellij-ib-plugin.ps1 +++ /dev/null @@ -1,155 +0,0 @@ -<# -.SYNOPSIS -Installs (or updates) the "Incredibuild Build Acceleration" plugin into every -supported JetBrains IDE found on this machine (RustRover, and IntelliJ IDEA -Ultimate). - -.DESCRIPTION -Fully self-contained: the plugin zip is embedded below as base64, so this is -the only file needed. Run it with: - powershell -ExecutionPolicy Bypass -File .\install-intellij-ib-plugin.ps1 - -Detects installs by reading each product's own product-info.json rather than -guessing the config folder name from the install folder's display name - -that heuristic does NOT generalize across products (e.g. RustRover's config -folder is "RustRover2026.2", a simple space-strip of its install folder name, -but IntelliJ IDEA Ultimate's is "IntelliJIdea2026.2", which does not follow -the same pattern at all). - -All detected target IDEs must be closed before running this script - it will -refuse to proceed otherwise, since IntelliJ Platform IDEs load plugins at -startup and don't reliably pick up a plugin folder replaced while already -running. -#> - -$ErrorActionPreference = "Stop" - -# Shared by both installer/standalone-footer.ps1 (assembled into the -# self-contained scripts/install-intellij-ib-plugin.ps1) and -# installer/sfx-footer.ps1 (assembled into the SFX installer's own -# install.ps1) via installer/build-installer.ps1 - edit this ONE file rather -# than both generated scripts. - -# RR = RustRover, IU = IntelliJ IDEA Ultimate. Community editions (IC) don't -# carry com.intellij.modules.ultimate, which the Rust plugin requires there. -$targetProductCodes = @("RR", "IU") - -# Installs can land in quite a few places, so several roots are searched -# recursively rather than assuming one fixed layout: -# - "C:\Program Files\JetBrains\" - the standalone installer's -# default when run elevated. -# - "%LOCALAPPDATA%\JetBrains\..." - covers both JetBrains Toolbox (which -# nests several levels deeper, under "Toolbox\apps\\ch-0\") -# and the standalone installer's own default when run WITHOUT admin -# rights, which silently falls back to a per-user install here instead -# of Program Files. -# - Whatever the Windows registry's own Uninstall entries report as the -# InstallLocation for a matching product - the authoritative source for -# a custom install path (e.g. a different drive), which no fixed-path -# guess can anticipate. -$registryInstallLocations = @( - "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", - "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall" -) | Where-Object { Test-Path $_ } | ForEach-Object { - Get-ChildItem -Path $_ -ErrorAction SilentlyContinue -} | ForEach-Object { - Get-ItemProperty -Path $_.PSPath -ErrorAction SilentlyContinue -} | Where-Object { - $_.DisplayName -match "RustRover|IntelliJ IDEA" -and $_.InstallLocation -} | ForEach-Object { $_.InstallLocation } | Select-Object -Unique - -$searchRoots = @( - "C:\Program Files\JetBrains", - (Join-Path $env:LOCALAPPDATA "JetBrains") -) + $registryInstallLocations - -# Every JetBrains product found (any productCode), so a "not found" error can -# report what actually IS here - e.g. a Community edition, which is filtered -# out below but is useful to see rather than a bare "nothing found". -$allProducts = $searchRoots | Where-Object { Test-Path $_ } | ForEach-Object { - Get-ChildItem -Path $_ -Recurse -Filter "product-info.json" -ErrorAction SilentlyContinue -} | ForEach-Object { - $infoPath = $_.FullName - try { - $info = Get-Content $infoPath -Raw | ConvertFrom-Json - } catch { - return - } - [PSCustomObject]@{ - InfoPath = $infoPath - ProductName = $info.name - ProductCode = $info.productCode - DataDirectoryName = $info.dataDirectoryName - Launch = $info.launch - } -} - -$installs = $allProducts | Where-Object { $targetProductCodes -contains $_.ProductCode } | ForEach-Object { - $product = $_ - $winLaunch = $product.Launch | Where-Object { $_.os -eq "Windows" } | Select-Object -First 1 - if (-not $winLaunch) { return } - - [PSCustomObject]@{ - Name = Split-Path -Leaf (Split-Path -Parent $product.InfoPath) - DataDirectoryName = $product.DataDirectoryName - ProcessName = [System.IO.Path]::GetFileNameWithoutExtension($winLaunch.launcherPath) - } -} | Sort-Object -Property DataDirectoryName -Unique - -# A no-op if no transcript is running (Start-Transcript is only called in the -# SFX installer's copy of this logic, not the standalone script's), safe to -# call unconditionally from both under $ErrorActionPreference = "Stop". -function Stop-InstallerTranscriptIfRunning { - try { Stop-Transcript | Out-Null } catch {} -} - -if (-not $installs) { - $searched = ($searchRoots | Select-Object -Unique) -join "; " - $message = "No supported JetBrains IDE (RustRover or IntelliJ IDEA Ultimate) found. Searched: $searched." - if ($allProducts) { - $found = ($allProducts | ForEach-Object { "$($_.ProductName) [$($_.ProductCode)] at $($_.InfoPath)" }) -join "; " - $message += " Found (but not supported by this plugin): $found." - } - Write-Error $message - Stop-InstallerTranscriptIfRunning - exit 1 -} - -$anyRunning = $false -foreach ($install in $installs) { - if (Get-Process -Name $install.ProcessName -ErrorAction SilentlyContinue) { - Write-Error "$($install.Name) is currently running. Close it, then re-run this installer." - $anyRunning = $true - } -} -if ($anyRunning) { - Stop-InstallerTranscriptIfRunning - exit 1 -} - -$pluginZipBase64 = @' -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 -'@ - -$tempZip = Join-Path $env:TEMP "intellij-ib-plugin-install.zip" -$pluginZipBytes = [System.Convert]::FromBase64String(($pluginZipBase64 -replace '\s', '')) -[System.IO.File]::WriteAllBytes($tempZip, $pluginZipBytes) - -foreach ($install in $installs) { - $pluginsDir = Join-Path $env:APPDATA "JetBrains\$($install.DataDirectoryName)\plugins" - if (-not (Test-Path $pluginsDir)) { - New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null - } - - $targetPluginDir = Join-Path $pluginsDir "intellij-ib-plugin" - if (Test-Path $targetPluginDir) { - Remove-Item -Recurse -Force $targetPluginDir - } - - Expand-Archive -Path $tempZip -DestinationPath $pluginsDir -Force - Write-Host "Installed into $($install.Name) ($pluginsDir)." -} - -Remove-Item $tempZip -Force -Write-Host "Install complete. Start the IDE to use the Incredibuild plugin." diff --git a/settings.gradle.kts b/settings.gradle.kts index 9841bdf..da28989 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -2,6 +2,12 @@ import org.jetbrains.intellij.platform.gradle.extensions.intellijPlatform rootProject.name = "intellij-ib-plugin" +// Rider's frontend API (solution model, active solution configuration) ships inside the Rider +// product itself rather than as a separately published plugin, so unlike the Rust/CMake halves +// it can't be added to the root module's IntelliJ IDEA Ultimate compile target - see +// rider/build.gradle.kts. +include(":rider") + pluginManagement { plugins { id("org.jetbrains.kotlin.jvm") version "2.4.10" diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/IdeBuildAcceleration.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/IdeBuildAcceleration.kt new file mode 100644 index 0000000..6ac4354 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/IdeBuildAcceleration.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions + +import com.incredibuild.ibplugin.analytics.PostHogClient +import com.incredibuild.ibplugin.settings.IncredibuildConfigurable +import com.incredibuild.ibplugin.settings.IncredibuildSettings +import com.intellij.ide.BrowserUtil +import com.intellij.notification.NotificationAction +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import java.io.File +import java.util.Collections + +/** Reasons already reported in this project, so a fallback is announced once per session rather than on every build. */ +private val REPORTED_FALLBACKS = Key.create>("Incredibuild.ReportedIdeBuildFallbacks") + +/** + * What the IDE's own Build/Rebuild actions share when they are routed through Incredibuild (the + * "Use Incredibuild for the IDE's build actions" setting) - in RustRover, CLion and Rider alike: + * whether to route at all, and what happens when Incredibuild can't take the build. + * + * The rule throughout is that a standard IDE command must never fail because of this plugin: if + * Incredibuild is missing, unlicensed or too old, the build goes ahead with the IDE's own builder + * and the user is told why, once, with a way to fix it. + */ +object IdeBuildAcceleration { + + /** The opt-in setting - off by default. */ + val isEnabled: Boolean + get() = IncredibuildSettings.getInstance().accelerateIdeBuilds + + /** + * The install folder to run an IDE build through, or null - after telling the user why - if + * the build should fall back to [standardBuilder] (e.g. "Cargo", "CMake", "Rider's build"). + * Blocking; never call it on the EDT. + */ + fun installFolderOrFallBack(project: Project, flavor: BuildFlavor, standardBuilder: String): File? = + when (val status = IncredibuildInstallation.check(flavor)) { + is InstallationStatus.Ready -> status.installFolder + else -> { + notifyFallback(project, status, standardBuilder) + null + } + } + + /** + * Tells the user an IDE build that should have gone through Incredibuild is running with + * [standardBuilder] instead, because of [status]. Shown once per project session per kind of + * problem, so a team member without Incredibuild isn't nagged on every build - the settings + * page shows the same status permanently. + */ + fun notifyFallback(project: Project, status: InstallationStatus, standardBuilder: String) { + val reasonKey = when (status) { + InstallationStatus.NotInstalled -> "not_installed" + is InstallationStatus.UnsupportedVersion -> "unsupported_version" + is InstallationStatus.NotLicensed -> "not_licensed" + is InstallationStatus.Ready -> return + } + notifyOnce( + project, + reasonKey, + IncredibuildInstallation.describe(status), + standardBuilder, + offerDownload = status !is InstallationStatus.NotLicensed + ) + } + + /** + * Tells the user an IDE build fell back to [standardBuilder] for a reason other than the + * installation itself - e.g. the build command couldn't be prepared. Same once-per-session rule. + */ + fun notifyFallback(project: Project, reason: String, standardBuilder: String) { + notifyOnce(project, "other:$reason", reason, standardBuilder, offerDownload = false) + } + + private fun notifyOnce( + project: Project, + reasonKey: String, + reason: String, + standardBuilder: String, + offerDownload: Boolean + ) { + val reported = synchronized(project) { + project.getUserData(REPORTED_FALLBACKS) + ?: Collections.synchronizedSet(HashSet()).also { project.putUserData(REPORTED_FALLBACKS, it) } + } + if (!reported.add(reasonKey)) return + + PostHogClient.capture("ide_build_fallback", mapOf("reason" to reasonKey.substringBefore(':'))) + val notification = NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification( + "Building without Incredibuild", + "$reason
This build runs with $standardBuilder instead.", + NotificationType.WARNING + ) + .addAction(NotificationAction.createSimple("Incredibuild settings") { + ShowSettingsUtil.getInstance().showSettingsDialog(project, IncredibuildConfigurable::class.java) + }) + if (offerDownload) { + notification.addAction(NotificationAction.createSimple("Download Incredibuild") { + PostHogClient.capture("download_latest_version_opened") + BrowserUtil.browse(INCREDIBUILD_DOWNLOAD_PAGE) + }) + } + notification.notify(project) + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallation.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallation.kt new file mode 100644 index 0000000..f14dc8b --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallation.kt @@ -0,0 +1,163 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions + +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.SystemInfo +import java.io.File + +private val LOG = Logger.getInstance(IncredibuildInstallation::class.java) + +/** How long the license query may take before it's treated as inconclusive - see [IncredibuildInstallation.queryLicense]. */ +private const val LICENSE_QUERY_TIMEOUT_MS = 5_000 + +/** Matches the "InitiatorRegType Floating" / "CIInitiatorRegType None" lines of `BuildConsole /QueryLicense`. */ +private val INITIATOR_REG_TYPE_REGEX = Regex("""^\s*(?:CI)?InitiatorRegType\s+(\S+)""", RegexOption.MULTILINE) + +/** + * Which kind of build an installation is being validated for. Rust build support needed a + * specific Incredibuild release (see [minimumVersion]); the native CMake/MSBuild support has no + * known minimum. + */ +enum class BuildFlavor { + RUST, + NATIVE; + + /** The oldest supported Incredibuild version for this flavor on the current OS, or null if any version is fine. */ + fun minimumVersion(): String? = when (this) { + RUST -> if (SystemInfo.isLinux) MINIMUM_RUST_BUILD_VERSION_LINUX else MINIMUM_RUST_BUILD_VERSION + NATIVE -> null + } + + internal companion object { + const val MINIMUM_RUST_BUILD_VERSION = "10.37.1" + + // The Linux team found an issue with an earlier released Linux Incredibuild + // version during RustRover integration testing, hence this gate. Deliberately a + // separate constant, not shared with the Windows one: Windows and Linux + // Incredibuild use entirely unrelated version-numbering schemes (e.g. + // "10.37.0.12597" vs "3.18.0"). + const val MINIMUM_RUST_BUILD_VERSION_LINUX = "4.29.3" + } +} + +/** + * The Incredibuild installation the plugin would use, and whether it can accelerate a build - + * shown on the settings page, and consulted before the IDE's own build actions are routed + * through Incredibuild (where anything but [Ready] falls back to the IDE's standard build). + */ +sealed interface InstallationStatus { + /** The install folder, when one was found at all. */ + val installFolder: File? + + object NotInstalled : InstallationStatus { + override val installFolder: File? = null + } + + class UnsupportedVersion( + override val installFolder: File, + val installedVersion: String, + val minimumVersion: String + ) : InstallationStatus + + class NotLicensed(override val installFolder: File, val installedVersion: String?) : InstallationStatus + + /** + * Usable. [licensed] is null where the license couldn't be determined (always on Linux, + * which has no license query; on Windows if the query failed) - that isn't treated as a + * reason to fall back, since Incredibuild itself reports a licensing problem in the build + * output anyway. + */ + class Ready( + override val installFolder: File, + val installedVersion: String?, + val licensed: Boolean? + ) : InstallationStatus +} + +object IncredibuildInstallation { + + /** + * Locates and validates the installation for [flavor]. Blocking (registry query, file reads + * and a short-lived BuildConsole process), so never call it on the EDT. + */ + fun check(flavor: BuildFlavor): InstallationStatus { + val installFolder = IncredibuildLocator.findInstallFolder() ?: return InstallationStatus.NotInstalled + val installedVersion = IncredibuildLocator.findVersion() + + val minimumVersion = flavor.minimumVersion() + if (minimumVersion != null && installedVersion != null && + IncredibuildLocator.compareVersions(installedVersion, minimumVersion) < 0 + ) { + return InstallationStatus.UnsupportedVersion(installFolder, installedVersion, minimumVersion) + } + + val licensed = queryLicense(installFolder) + if (licensed == false) return InstallationStatus.NotLicensed(installFolder, installedVersion) + return InstallationStatus.Ready(installFolder, installedVersion, licensed) + } + + /** + * Whether this machine holds an Incredibuild initiator license, from `BuildConsole + * /QueryLicense` - or null if that can't be determined (Linux, a missing BuildConsole, a + * failed or timed-out query, or output in an unrecognized shape). + */ + private fun queryLicense(installFolder: File): Boolean? { + if (!SystemInfo.isWindows) return null + val buildConsole = File(installFolder, "BuildConsole.exe") + if (!buildConsole.exists()) return null + return try { + val output = ExecUtil.execAndGetOutput( + GeneralCommandLine(buildConsole.absolutePath, "/QueryLicense"), + LICENSE_QUERY_TIMEOUT_MS + ) + if (output.isTimeout || output.exitCode != 0) null else parseLicenseQuery(output.stdout) + } catch (e: Exception) { + LOG.info("Could not query the Incredibuild license", e) + null + } + } + + /** + * Reads `BuildConsole /QueryLicense` output: licensed if either the developer + * (`InitiatorRegType`) or CI (`CIInitiatorRegType`) initiator registration is anything but + * "None" - a machine can start builds with either. Null if neither line is present. + */ + internal fun parseLicenseQuery(output: String): Boolean? { + val regTypes = INITIATOR_REG_TYPE_REGEX.findAll(output).map { it.groupValues[1] }.toList() + if (regTypes.isEmpty()) return null + return regTypes.any { !it.equals("None", ignoreCase = true) } + } + + /** One line describing [status], for the settings page and fallback notifications. */ + fun describe(status: InstallationStatus): String = when (status) { + InstallationStatus.NotInstalled -> "Incredibuild is not installed on this machine." + is InstallationStatus.UnsupportedVersion -> + "Incredibuild ${status.installedVersion} (${status.installFolder.absolutePath}) is not supported - " + + "version ${status.minimumVersion} or later is required." + is InstallationStatus.NotLicensed -> + "Incredibuild${status.installedVersion?.let { " $it" } ?: ""} (${status.installFolder.absolutePath}) " + + "is installed, but this machine has no Incredibuild license." + is InstallationStatus.Ready -> buildString { + append("Incredibuild") + status.installedVersion?.let { append(' ').append(it) } + append(" (").append(status.installFolder.absolutePath).append(')') + if (status.licensed == true) append(" - licensed") + } + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildLocator.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildLocator.kt index ffef765..7397534 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildLocator.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildLocator.kt @@ -65,8 +65,8 @@ object IncredibuildLocator { * "10.37.0.12597"), or from `IB_SHORT_VERSION` in `version_info.src` on Linux * (e.g. "3.18.0") - or null if unavailable. The two platforms use unrelated * version-numbering schemes, so callers must compare against a platform-specific - * minimum (see [com.incredibuild.ibplugin.actions.IncredibuildRunner]'s - * `MINIMUM_RUST_BUILD_VERSION`/`MINIMUM_RUST_BUILD_VERSION_LINUX`), never mix them. + * minimum (see [com.incredibuild.ibplugin.actions.BuildFlavor.minimumVersion]), + * never mix them. */ fun findVersion(): String? { if (SystemInfo.isLinux) { diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildProjectTaskRunner.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildProjectTaskRunner.kt index e69cbac..bdec856 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildProjectTaskRunner.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildProjectTaskRunner.kt @@ -28,7 +28,6 @@ import com.intellij.task.ProjectTaskContext import com.intellij.task.ProjectTaskRunner import com.intellij.task.TaskRunnerResults import com.incredibuild.ibplugin.analytics.PostHogClient -import com.incredibuild.ibplugin.settings.IncredibuildSettings import org.jetbrains.concurrency.AsyncPromise import org.jetbrains.concurrency.Promise import org.rust.cargo.runconfig.buildtool.CargoBuildAdapter @@ -82,7 +81,7 @@ class IncredibuildProjectTaskRunner : ProjectTaskRunner() { override fun canRun(projectTask: ProjectTask): Boolean = false override fun canRun(project: Project, projectTask: ProjectTask, context: ProjectTaskContext?): Boolean { - if (!IncredibuildSettings.getInstance().accelerateIdeBuilds) return false + if (!IdeBuildAcceleration.isEnabled) return false val cargoRunner = cargoBuildTaskRunner() ?: return false return try { cargoRunner.canRun(project, projectTask, context) @@ -118,11 +117,11 @@ class IncredibuildProjectTaskRunner : ProjectTaskRunner() { tasks: Array, promise: AsyncPromise ) { - val installFolder = IncredibuildLocator.findInstallFolder() + // No dialog here, unlike the explicit menu actions: this path is a standard IDE build + // the user did not ask Incredibuild for by name, so it must not block - it builds + // normally, with a one-off notification saying why. + val installFolder = IdeBuildAcceleration.installFolderOrFallBack(project, BuildFlavor.RUST, CARGO) if (installFolder == null) { - // No dialog here, unlike the explicit menu actions: this path is a standard IDE - // build the user did not ask Incredibuild for, so it must stay silent and simply - // build normally. delegateToCargo(project, context, tasks, promise) return } @@ -138,6 +137,13 @@ class IncredibuildProjectTaskRunner : ProjectTaskRunner() { // (several Cargo projects in one IDE project) they must not compete for the build // tool window. for (planned in plannedBuilds) { + // One Incredibuild build at a time, as for the Incredibuild menu's builds - this one + // reports into the Build tool window rather than ours, but would still overlap one + // running there. Checked before the clean too, which must not run under a build. + if (!IncredibuildRunner.awaitNoBuildRunning(project)) { + promise.setResult(TaskRunnerResults.ABORTED) + return + } if (!planned.isIncrementalBuild && !cleanBeforeRebuild(project, planned.buildConfiguration)) { promise.setResult(TaskRunnerResults.FAILURE) return @@ -248,7 +254,10 @@ class IncredibuildProjectTaskRunner : ProjectTaskRunner() { resolved.invocation.workingDirectory ) if (prepared !is IncredibuildRunner.PreparedBuild.Ready) { - if (prepared is IncredibuildRunner.PreparedBuild.Failed) LOG.warn(prepared.message) + if (prepared is IncredibuildRunner.PreparedBuild.Failed) { + LOG.warn(prepared.message) + IdeBuildAcceleration.notifyFallback(project, prepared.message, CARGO) + } return null } @@ -342,5 +351,8 @@ class IncredibuildProjectTaskRunner : ProjectTaskRunner() { /** Shown as the build's name and progress text in the Build tool window. */ private const val BUILD_TASK_NAME = "Build" private const val BUILD_PROGRESS_TITLE = "Building with Incredibuild…" + + /** The standard builder a fallback build runs with, as named in the fallback notification. */ + private const val CARGO = "Cargo" } } diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunner.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunner.kt index f2f9285..0f7ba95 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunner.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunner.kt @@ -17,10 +17,12 @@ package com.incredibuild.ibplugin.actions import com.incredibuild.ibplugin.actions.cmake.CMakeBuildKind import com.incredibuild.ibplugin.actions.cmake.CMakeBuildResolution -import com.incredibuild.ibplugin.actions.cmake.ResolvedCMakeBuild import com.incredibuild.ibplugin.actions.cmake.deriveCMakeBuild import com.intellij.execution.configurations.GeneralCommandLine import com.intellij.execution.impl.ConsoleViewImpl +import com.intellij.execution.ui.ConsoleViewContentType +import com.intellij.notification.NotificationGroupManager +import com.intellij.notification.NotificationType import com.intellij.execution.process.ColoredProcessHandler import com.intellij.execution.process.KillableProcessHandler import com.intellij.execution.process.OSProcessHandler @@ -29,6 +31,7 @@ import com.intellij.execution.process.ProcessListener import com.intellij.execution.util.ExecUtil import com.intellij.ide.BrowserUtil import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.progress.ProgressIndicator import com.intellij.openapi.progress.ProgressManager import com.intellij.openapi.progress.Task @@ -39,6 +42,7 @@ import com.intellij.openapi.util.SystemInfo import com.intellij.openapi.wm.ToolWindowManager import com.incredibuild.ibplugin.analytics.PostHogClient import java.io.File +import java.util.concurrent.atomic.AtomicBoolean /** Matches the "Build ID: {}" line BuildConsole prints to stdout when a build starts. * Windows only - ib_console on Linux never prints a build-id, so this simply never matches @@ -46,19 +50,15 @@ import java.io.File private val BUILD_ID_REGEX = Regex("""Build ID:\s*\{([0-9A-Fa-f-]+)}""") // Used for both Windows and Linux - same landing page for both. -private const val INCREDIBUILD_WEBSITE = "https://www.incredibuild.com/integrations/jetbrains-ides" +internal const val INCREDIBUILD_WEBSITE = "https://www.incredibuild.com/integrations/jetbrains-ides" -private const val INCREDIBUILD_DOWNLOAD_PAGE = +internal const val INCREDIBUILD_DOWNLOAD_PAGE = "https://docs.incredibuild.com/site-landing/download-docs-center" -private const val MINIMUM_RUST_BUILD_VERSION = "10.37.1" +/** Declared in plugin.xml. */ +internal const val NOTIFICATION_GROUP_ID = "Incredibuild" -// The Linux team found an issue with an earlier released Linux Incredibuild -// version during RustRover integration testing, hence this gate. Deliberately a -// separate constant, not shared with the Windows one: Windows and Linux -// Incredibuild use entirely unrelated version-numbering schemes (e.g. -// "10.37.0.12597" vs "3.18.0"). -private const val MINIMUM_RUST_BUILD_VERSION_LINUX = "4.29.3" +private const val STATUS_POLL_INTERVAL_MS = 250L /** A POSIX shell variable name - see [IncredibuildRunner]'s CMake Linux environment export. */ internal val VALID_SHELL_IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*") @@ -94,7 +94,7 @@ object IncredibuildRunner { return null } - val minimumVersion = if (SystemInfo.isLinux) MINIMUM_RUST_BUILD_VERSION_LINUX else MINIMUM_RUST_BUILD_VERSION + val minimumVersion = requireNotNull(BuildFlavor.RUST.minimumVersion()) val installedVersion = IncredibuildLocator.findVersion() if (installedVersion != null && IncredibuildLocator.compareVersions(installedVersion, minimumVersion) < 0 @@ -125,7 +125,7 @@ object IncredibuildRunner { } /** The "Incredibuild wasn't found" dialog, shared by [ensureIncredibuildReady] (Rust) and - * [ensureIncredibuildInstalledForCMake] - identical either way, since it's the same + * [ensureIncredibuildInstalled] (CMake, MSBuild) - identical either way, since it's the same * Incredibuild install being looked for regardless of which language triggered the build. */ private fun showNotFoundDialog(project: Project, actionType: String) { PostHogClient.capture("incredibuild_not_found", mapOf("action" to actionType)) @@ -194,10 +194,7 @@ object IncredibuildRunner { ) { when (val prepared = prepareBuild(project, installFolder, cargoCommand, workingDirectory)) { is PreparedBuild.Failed -> showErrorOnEdt(project, prepared.message) - is PreparedBuild.Ready -> launchBuild(project, prepared.commandLine, actionType) { exitCode -> - prepared.cleanUp() - onFinished?.invoke(exitCode) - } + is PreparedBuild.Ready -> launchBuild(project, prepared, actionType, onFinished = onFinished) } } @@ -368,24 +365,86 @@ object IncredibuildRunner { return PreparedBuild.Ready(commandLine) { scriptFile.delete() } } + /** + * Runs [prepared] in the Incredibuild tool window - unless another build is still running + * (see [run]) - and deletes its temporary scripts once it ends, or straight away if it + * never starts. [onFinished] is only called for a build that actually ran. + */ private fun launchBuild( project: Project, - commandLine: GeneralCommandLine, + prepared: PreparedBuild.Ready, actionType: String, - onFinished: (exitCode: Int) -> Unit + presentation: AcceleratedBuildPresentation? = null, + onFinished: ((exitCode: Int) -> Unit)? = null ) { - PostHogClient.capture("build_started", mapOf("action" to actionType)) - PostHogClient.captureOnce( - "com.incredibuild.ibplugin.analytics.firstBuildSent", - "first_build", - mapOf("action" to actionType) - ) - ApplicationManager.getApplication().invokeLater { - run(project, commandLine, "Build", onFinished) + run( + project, + prepared.commandLine, + presentation?.contentName ?: "Build", + presentation?.banner, + onNotStarted = prepared.cleanUp, + onStarted = { processHandler -> + PostHogClient.capture("build_started", mapOf("action" to actionType)) + PostHogClient.captureOnce( + "com.incredibuild.ibplugin.analytics.firstBuildSent", + "first_build", + mapOf("action" to actionType) + ) + if (presentation != null) { + trackInStatusBar(project, processHandler, presentation) + } + } + ) { exitCode -> + prepared.cleanUp() + onFinished?.invoke(exitCode) + } } } + /** + * Keeps a "[AcceleratedBuildPresentation.progressTitle]" progress in the status bar for as + * long as [processHandler] runs - the IDE's usual "something is building" signal, so an + * accelerated build is visible even with the Incredibuild tool window hidden - and reports + * the outcome as a notification once it ends. Cancelling the progress stops the build the + * same graceful way Stop Build does ([OSProcessHandler.destroyProcess] on our handler). + */ + private fun trackInStatusBar( + project: Project, + processHandler: OSProcessHandler, + presentation: AcceleratedBuildPresentation + ) { + ProgressManager.getInstance().run(object : Task.Backgroundable(project, presentation.progressTitle, true) { + override fun run(indicator: ProgressIndicator) { + indicator.isIndeterminate = true + indicator.text = presentation.progressTitle + var cancelHandled = false + while (!processHandler.waitFor(STATUS_POLL_INTERVAL_MS)) { + if (indicator.isCanceled && !cancelHandled) { + cancelHandled = true + processHandler.destroyProcess() + } + // Also reflects a stop from Stop Build or the tool window, not just this + // progress's own cancel button. + if (processHandler.wasStopRequested()) { + indicator.text = "Stopping Incredibuild build..." + } + } + val exitCode = processHandler.exitCode + val (content, type) = when { + processHandler.wasStopRequested() -> "${presentation.contentName} was stopped." to NotificationType.INFORMATION + exitCode == 0 -> "${presentation.contentName} succeeded." to NotificationType.INFORMATION + else -> "${presentation.contentName} failed (exit code $exitCode). See the Incredibuild tool window for details." to + NotificationType.ERROR + } + NotificationGroupManager.getInstance() + .getNotificationGroup(NOTIFICATION_GROUP_ID) + .createNotification("Incredibuild", content, type) + .notify(project) + } + }) + } + private fun showErrorOnEdt(project: Project, message: String) { ApplicationManager.getApplication().invokeLater { Messages.showErrorDialog(project, message, "Incredibuild") @@ -407,17 +466,42 @@ object IncredibuildRunner { * exit code - used both to chain a plain "cargo clean" into an Incredibuild-accelerated * build for Rebuild, and to report build success back to the IDE's build pipeline when * the build was started from there (see [IncredibuildProjectTaskRunner]). + * + * [banner], if given, is printed at the top of the console before any build output. + * + * Must be called on the EDT. Only one build runs at a time (see [whenNoBuildRunning]), so + * this may start the process later - once a running build the user chose to stop has + * stopped - or not at all: [onStarted] gets the running process, [onNotStarted] is called + * instead (and [onFinished] never is) if the user cancels. */ fun run( project: Project, commandLine: GeneralCommandLine, contentName: String, + banner: String? = null, + onNotStarted: () -> Unit = {}, + onStarted: (OSProcessHandler) -> Unit = {}, onFinished: ((exitCode: Int) -> Unit)? = null ) { + whenNoBuildRunning(project, onNotStarted) { + onStarted(start(project, commandLine, contentName, banner, onFinished)) + } + } + + private fun start( + project: Project, + commandLine: GeneralCommandLine, + contentName: String, + banner: String?, + onFinished: ((exitCode: Int) -> Unit)? + ): OSProcessHandler { val processHandler = createProcessHandler(commandLine) val console = ConsoleViewImpl(project, true) console.attachToProcess(processHandler) + if (banner != null) { + console.print(banner + "\n\n", ConsoleViewContentType.SYSTEM_OUTPUT) + } val toolWindow = requireNotNull(ToolWindowManager.getInstance(project).getToolWindow("Incredibuild")) { "The Incredibuild tool window is declared in plugin.xml and should always be registered." @@ -436,6 +520,7 @@ object IncredibuildRunner { }) } processHandler.startNotify() + return processHandler } /** @@ -494,9 +579,14 @@ object IncredibuildRunner { */ private class ColoredIncredibuildProcessHandler( commandLine: GeneralCommandLine - ) : ColoredProcessHandler(commandLine) { + ) : ColoredProcessHandler(commandLine), IncredibuildProcessHandler { + + @Volatile + override var stopRequested = false + private set override fun destroyProcessImpl() { + stopRequested = true if (!requestGracefulStop()) super.destroyProcessImpl() } } @@ -519,17 +609,36 @@ object IncredibuildRunner { */ private class RawIncredibuildProcessHandler( commandLine: GeneralCommandLine - ) : KillableProcessHandler(commandLine) { + ) : KillableProcessHandler(commandLine), IncredibuildProcessHandler { + + @Volatile + override var stopRequested = false + private set override fun destroyProcessImpl() { + stopRequested = true if (!requestGracefulStop()) super.destroyProcessImpl() } override fun killProcess() { + stopRequested = true if (!requestGracefulStop()) super.killProcess() } } + /** + * Implemented by both handlers above, so whoever reports a build's outcome can tell a build + * that was stopped from one that failed. Every stop route - Stop Build, the tool windows' own + * stop buttons, cancelling the status-bar progress - ends in [destroyProcessImpl] or + * `killProcess` on the handler, which is where it's recorded; a stopped build's exit code + * alone (-1, or whatever Incredibuild exits with when cancelled) looks just like a failure. + */ + private interface IncredibuildProcessHandler { + val stopRequested: Boolean + } + + private fun OSProcessHandler.wasStopRequested(): Boolean = (this as? IncredibuildProcessHandler)?.stopRequested == true + /** * Asks Incredibuild to cancel the running build by build-id, returning false if there * is no build-id to cancel by or the request could not be dispatched - in which case @@ -556,6 +665,74 @@ object IncredibuildRunner { * by [StopBuildAction] to disable itself when there's nothing to stop. */ fun isBuildRunning(): Boolean = activeProcessHandler?.isProcessTerminated == false + /** + * One build at a time: calls [start] straight away if nothing is running; otherwise offers to + * stop the running build, and if the user agrees, calls [start] once it has actually stopped - + * never while it's still winding down, so the two never overlap. [onDeclined] if they cancel. + * Without this, a second build replaces the first one's tool window tab (its output disappears + * while it's still running) and becomes the only one Stop Build can reach; and while Windows' + * BuildConsole would queue it behind the first ("Waiting for a build to complete..."), nothing + * guarantees ib_console does the same. + * + * Checked in [run], on the EDT, and [start] registers the build as the active one in the same + * EDT event - so two quick clicks can't both get past it. After a stop the check runs again + * before starting, in case something else started meanwhile. A Rebuild's chained build is + * unaffected: its clean has already ended by the time the build starts. Builds started from a + * background thread wait with [awaitNoBuildRunning] instead. + */ + private fun whenNoBuildRunning(project: Project, onDeclined: () -> Unit, start: () -> Unit) { + val running = activeProcessHandler?.takeUnless { it.isProcessTerminated } + if (running == null) { + start() + return + } + if (!askToStopRunningBuild(project)) { + onDeclined() + return + } + val retried = AtomicBoolean() + val retry = { + if (retried.compareAndSet(false, true)) { + ApplicationManager.getApplication().invokeLater { whenNoBuildRunning(project, onDeclined, start) } + } + } + running.addProcessListener(object : ProcessListener { + override fun processTerminated(event: ProcessEvent) = retry() + }) + // It may have ended between the check above and adding the listener, which then never fires. + if (running.isProcessTerminated) retry() + stopActiveProcess(project) + } + + /** + * [whenNoBuildRunning] for a build started from a background thread - the IDE's build pipeline + * (see [IncredibuildProjectTaskRunner]), which reports its own outcome and so must wait here + * rather than be called back. Blocks until nothing is running and returns true, or returns + * false if the user chose not to stop the running build. + */ + internal fun awaitNoBuildRunning(project: Project): Boolean { + while (true) { + val running = activeProcessHandler?.takeUnless { it.isProcessTerminated } ?: return true + var stop = false + ApplicationManager.getApplication().invokeAndWait({ stop = askToStopRunningBuild(project) }, ModalityState.any()) + if (!stop) return false + PostHogClient.capture("stop_build_clicked", mapOf("hadActiveProcess" to true)) + // Graceful where possible - see stopActiveProcess. + running.destroyProcess() + running.waitFor() + } + } + + private fun askToStopRunningBuild(project: Project): Boolean = + Messages.showYesNoDialog( + project, + "An Incredibuild build is already running. Stop it and start this one once it has stopped?", + "Incredibuild", + "Stop Running Build", + "Cancel", + Messages.getWarningIcon() + ) == Messages.YES + /** * Stops whichever build/clean process is currently running, if any. * @@ -599,28 +776,141 @@ object IncredibuildRunner { } // ----------------------------------------------------------------------------------- - // CMake (CLion) support - mirrors the Cargo/Rust flow above, but a cmake invocation is - // built from a resolved executable + argument list + environment (see - // com.incredibuild.ibplugin.actions.cmake.CMakeBuildCommand) rather than a single - // command string, and needs no custom Incredibuild profile: unlike Rust/cargo, - // Incredibuild's own default profile already covers C/C++ builds. + // Native builds - CMake (CLion) and MSBuild (Rider). Mirrors the Cargo/Rust flow above, + // but each invocation is built from resolved executables + argument lists + environment + // (see [NativeBuild]) rather than a single command string, and needs no custom + // Incredibuild profile: unlike Rust/cargo, Incredibuild's own default profile already + // covers the tools these builds launch. // ----------------------------------------------------------------------------------- /** * Checks that Incredibuild is installed, showing the "not found" dialog and returning * null if the caller should not proceed. Unlike [ensureIncredibuildReady], this has no * minimum-version gate: there is no known minimum Incredibuild version required for its - * native CMake/C++ build support, unlike the specific version bump Rust build support - * needed. + * native CMake/C++ or MSBuild build support, unlike the specific version bump Rust build + * support needed. */ - internal fun ensureIncredibuildInstalledForCMake(project: Project): File? { + internal fun ensureIncredibuildInstalled(project: Project, actionType: String): File? { val installFolder = IncredibuildLocator.findInstallFolder() if (installFolder == null) { - showNotFoundDialog(project, "cmake") + showNotFoundDialog(project, actionType) } return installFolder } + /** One process to run inside an accelerated [NativeBuild]. */ + internal class NativeCommand(val executable: String, val arguments: List) + + /** + * Everything an accelerated native build runs: [commands] in order (stopping at the first + * that fails), from [workingDirectory], with [environment] set on top of the inherited one. + * Kept as executable + argument lists rather than pre-joined strings so quoting is only ever + * done once, by whichever wrapper script actually launches them. + * + * [buildCache] opts the build into Incredibuild's Build Cache - `/BUILDCACHELOCAL=ON` on + * Windows, `--build-cache-local-shared` on Linux. On for the C/C++ compilers a CMake build + * runs, which the cache supports; off for MSBuild, since Build Cache doesn't support .NET + * builds yet, so .NET builds are accelerated without it rather than relying on the cache + * passing their tools through untouched. + */ + internal class NativeBuild( + val commands: List, + val workingDirectory: String, + val environment: Map = emptyMap(), + val buildCache: Boolean = true + ) + + /** + * How an accelerated build announces itself in the IDE - so it's unmistakable that this + * build is going through Incredibuild rather than the IDE's own builder: [contentName] names + * the Incredibuild tool window tab, [banner] is printed at the top of its console, and + * [progressTitle] is shown in the status bar (with a cancel button that stops the build + * gracefully) for as long as the build runs, followed by a success/failure notification. + */ + internal class AcceleratedBuildPresentation( + val contentName: String, + val banner: String, + val progressTitle: String + ) + + /** + * Runs an already-resolved [build] through Incredibuild. Must be called from a background + * thread with an already-verified [installFolder] (see [ensureIncredibuildInstalled]). + */ + internal fun launchNativeBuild( + project: Project, + installFolder: File, + build: NativeBuild, + actionType: String, + presentation: AcceleratedBuildPresentation? = null + ) { + when (val prepared = prepareNativeBuild(project, installFolder, build)) { + is PreparedBuild.Failed -> showErrorOnEdt(project, prepared.message) + is PreparedBuild.Ready -> launchBuild(project, prepared, actionType, presentation) + } + } + + /** How a build started from the IDE's own build pipeline ended - see [launchNativeBuildForIde]. */ + internal sealed interface IdeBuildOutcome { + object Succeeded : IdeBuildOutcome + object Failed : IdeBuildOutcome + object Stopped : IdeBuildOutcome + + /** Another Incredibuild build was running and the user chose not to stop it (see [whenNoBuildRunning]). */ + object NotStarted : IdeBuildOutcome + + /** The command couldn't be prepared - the caller should fall back to the IDE's own build. */ + class NotPrepared(val message: String) : IdeBuildOutcome + } + + /** + * As [launchNativeBuild], for a build the IDE's own Build/Rebuild started (see + * [IdeBuildAcceleration]): the same Incredibuild tool window, status-bar progress and + * notification, but with the outcome handed to [onOutcome] - exactly once, on the EDT, or + * straight away on the calling thread for [IdeBuildOutcome.NotPrepared] - so the caller can + * complete the IDE's build promise. Must be called from a background thread. + */ + internal fun launchNativeBuildForIde( + project: Project, + installFolder: File, + build: NativeBuild, + actionType: String, + presentation: AcceleratedBuildPresentation, + onOutcome: (IdeBuildOutcome) -> Unit + ) { + val prepared = when (val result = prepareNativeBuild(project, installFolder, build)) { + is PreparedBuild.Failed -> return onOutcome(IdeBuildOutcome.NotPrepared(result.message)) + is PreparedBuild.Ready -> result + } + ApplicationManager.getApplication().invokeLater { + var processHandler: OSProcessHandler? = null + run( + project, + prepared.commandLine, + presentation.contentName, + presentation.banner, + onNotStarted = { + prepared.cleanUp() + onOutcome(IdeBuildOutcome.NotStarted) + }, + onStarted = { started -> + processHandler = started + PostHogClient.capture("ide_build_accelerated", mapOf("action" to actionType)) + trackInStatusBar(project, started, presentation) + } + ) { exitCode -> + prepared.cleanUp() + onOutcome( + when { + processHandler?.wasStopRequested() == true -> IdeBuildOutcome.Stopped + exitCode == 0 -> IdeBuildOutcome.Succeeded + else -> IdeBuildOutcome.Failed + } + ) + } + } + } + /** * Resolves [kind]'s cmake invocation and, if Incredibuild is installed, runs it through * Incredibuild. Everything here - locating Incredibuild, reading the CMake project model - @@ -630,7 +920,7 @@ object IncredibuildRunner { internal fun buildCMakeViaIncredibuild(project: Project, actionType: String, kind: CMakeBuildKind) { ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Locating Incredibuild") { override fun run(indicator: ProgressIndicator) { - val installFolder = ensureIncredibuildInstalledForCMake(project) ?: return + val installFolder = ensureIncredibuildInstalled(project, "cmake") ?: return val resolution = deriveCMakeBuild(project, kind) val resolved = when (resolution) { null -> { @@ -647,48 +937,49 @@ object IncredibuildRunner { } is CMakeBuildResolution.Ready -> resolution.build } - when (val prepared = prepareCMakeBuild(project, installFolder, resolved)) { - is PreparedBuild.Failed -> showErrorOnEdt(project, prepared.message) - is PreparedBuild.Ready -> launchBuild(project, prepared.commandLine, actionType) { _ -> - prepared.cleanUp() - } - } + val build = NativeBuild( + listOf(NativeCommand(resolved.executable, resolved.arguments)), + resolved.workingDirectory, + resolved.environment + ) + launchNativeBuild(project, installFolder, build, actionType) } }) } - private fun prepareCMakeBuild( + private fun prepareNativeBuild( project: Project, installFolder: File, - resolved: ResolvedCMakeBuild + build: NativeBuild ): PreparedBuild = if (SystemInfo.isLinux) { - prepareCMakeBuildLinux(project, installFolder, resolved) + prepareNativeBuildLinux(project, installFolder, build) } else { - prepareCMakeBuildWindows(project, installFolder, resolved) + prepareNativeBuildWindows(project, installFolder, build) } /** * BuildConsole needs "/command=" with a single quoted value. Rather than hand it the whole - * `cmake --build ... --target ...` invocation as one string - which would need its own - * space-containing arguments (the build directory, a target name) individually quoted - * *inside* that already-quoted value, an ambiguous nested-quoting shape BuildConsole's own - * parsing for /command= is not documented to support - this writes a tiny wrapper .cmd that - * runs the real invocation with ordinary single-level quoting, and points /command= at just - * that script's path (one simple quoted path, the same shape /profile= above already uses - * uncontroversially). The toolchain environment (PATH/INCLUDE/LIB for an MSVC profile, ...) - * is set inside the same script via `set`, so it doesn't depend on whether BuildConsole - * passes its own parent environment through to what it launches. + * `cmake --build ... --target ...` (or `MSBuild.exe ...`) invocation as one + * string - which would need its own space-containing arguments (the build directory, a + * target name, a solution path) individually quoted *inside* that already-quoted value, an + * ambiguous nested-quoting shape BuildConsole's own parsing for /command= is not documented + * to support - this writes a tiny wrapper .cmd that runs the real invocation with ordinary + * single-level quoting, and points /command= at just that script's path (one simple quoted + * path, the same shape /profile= above already uses uncontroversially). The toolchain + * environment (PATH/INCLUDE/LIB for an MSVC profile, ...) is set inside the same script via + * `set`, so it doesn't depend on whether BuildConsole passes its own parent environment + * through to what it launches. * * No `/profile=`/`/BuildCacheProfile=` here, unlike [prepareBuildWindows]: those point at a - * bespoke Incredibuild profile built specifically for Cargo/rustc, which C/C++ needs no - * equivalent of - Incredibuild's own default profile already covers cmake/ninja/make/nmake - * and the compilers they invoke. + * bespoke Incredibuild profile built specifically for Cargo/rustc, which C/C++ and MSBuild + * need no equivalent of - Incredibuild's own default profile already covers + * cmake/ninja/make/nmake/MSBuild and the compilers they invoke. */ - private fun prepareCMakeBuildWindows( + private fun prepareNativeBuildWindows( project: Project, installFolder: File, - resolved: ResolvedCMakeBuild + build: NativeBuild ): PreparedBuild { val buildConsolePath = File(installFolder, "BuildConsole.exe") if (!buildConsolePath.exists()) { @@ -697,39 +988,19 @@ object IncredibuildRunner { ) } - val wrapperCmd = File.createTempFile("incredibuild-cmake-", ".cmd") + val wrapperCmd = File.createTempFile("incredibuild-native-", ".cmd") wrapperCmd.deleteOnExit() - val cmdLines = StringBuilder("@echo off\r\n") - for ((key, value) in resolved.environment) { - cmdLines.append("set \"").append(batchEscaped(key)).append('=').append(batchEscaped(value)).append("\"\r\n") - } - cmdLines.append(cmdQuoted(resolved.executable)) - for (arg in resolved.arguments) { - cmdLines.append(' ').append(cmdQuoted(arg)) - } - cmdLines.append("\r\nexit /b %ERRORLEVEL%\r\n") - wrapperCmd.writeText(cmdLines.toString()) + wrapperCmd.writeText(windowsWrapperScript(build)) val scriptFile = File.createTempFile("incredibuild-build-", ".ps1") scriptFile.deleteOnExit() scriptFile.writeText( - "& \"${buildConsolePath.absolutePath}\" " + - "${powerShellSingleQuoted("/command=\"${wrapperCmd.absolutePath}\"")} " + - "${powerShellSingleQuoted("/title=\"${project.name}\"")} " + - // Explicitly opts this session into Build Cache, the same way the Linux - // ib_console invocation below already does via --build-cache-local-shared - no - // /BuildCacheProfile= is passed alongside it because cl.exe/clang/gcc are - // already covered by BuildConsole's own built-in default profile (the bespoke - // *.xml sample profiles in the Incredibuild install's "BuildCache Profiles" - // folder are only for tools that default profile doesn't already know, e.g. - // rustc/lib for the Cargo build above, or nvcc/perl). - "/BUILDCACHELOCAL=ON\n" + - "exit \$LASTEXITCODE\n" + windowsLauncherScript(buildConsolePath.absolutePath, wrapperCmd.absolutePath, project.name, build.buildCache) ) val commandLine = GeneralCommandLine("powershell") .withParameters("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptFile.absolutePath) - .withWorkDirectory(resolved.workingDirectory) + .withWorkDirectory(build.workingDirectory) return PreparedBuild.Ready(commandLine) { scriptFile.delete() @@ -737,17 +1008,65 @@ object IncredibuildRunner { } } + /** + * The PowerShell script [prepareNativeBuildWindows] runs: BuildConsole with /command= pointed + * at [wrapperCmd] (see [windowsWrapperScript]), exiting with BuildConsole's own exit code. + */ + internal fun windowsLauncherScript(buildConsole: String, wrapperCmd: String, title: String, buildCache: Boolean): String = + "& \"$buildConsole\" " + + "${powerShellSingleQuoted("/command=\"$wrapperCmd\"")} " + + powerShellSingleQuoted("/title=\"$title\"") + + // Explicitly opts this session into Build Cache, the same way the Linux ib_console + // invocation does via --build-cache-local-shared - no /BuildCacheProfile= is passed + // alongside it because cl.exe/clang/gcc are already covered by BuildConsole's own + // built-in default profile (the bespoke *.xml sample profiles in the Incredibuild + // install's "BuildCache Profiles" folder are only for tools that default profile + // doesn't already know, e.g. rustc/lib for the Cargo build above, or nvcc/perl). + // Left off where the build doesn't want it - see NativeBuild.buildCache. + (if (buildCache) " /BUILDCACHELOCAL=ON" else "") + "\n" + + "exit \$LASTEXITCODE\n" + + /** + * The .cmd [prepareNativeBuildWindows] points BuildConsole's /command= at: [NativeBuild.environment] + * via `set`, then each command in turn, stopping at (and exiting with) the first non-zero + * exit code - so a multi-command build reports failure the same way a single command does. + */ + internal fun windowsWrapperScript(build: NativeBuild): String { + val cmdLines = StringBuilder("@echo off\r\n") + for ((key, value) in build.environment) { + cmdLines.append("set \"").append(batchEscaped(key)).append('=').append(batchEscaped(value)).append("\"\r\n") + } + for ((index, command) in build.commands.withIndex()) { + cmdLines.append(cmdQuoted(command.executable)) + for (arg in command.arguments) { + cmdLines.append(' ').append(cmdQuoted(arg)) + } + cmdLines.append("\r\n") + if (index < build.commands.lastIndex) { + // Not "if errorlevel 1": that only matches codes >= 1, and a crashed process's + // exit code (e.g. 0xC0000005) reads as negative, which would let the chain go on. + cmdLines.append("if not \"%ERRORLEVEL%\"==\"0\" exit /b %ERRORLEVEL%\r\n") + } + } + cmdLines.append("exit /b %ERRORLEVEL%\r\n") + return cmdLines.toString() + } + /** * ib_console has no `/command=`-style text field - the build command is trailing argv * after its own recognized options - so, unlike Windows, there's no nested-quoting - * concern to work around here: the whole invocation (environment exports, then the cmake + * concern to work around here: the whole invocation (environment exports, then the build * command) is written into one script, each token quoted individually and safely via * [shellSingleQuoted]. * + * ib_console runs exactly one command, so a [NativeBuild] with more than one has them + * chained inside a `/bin/sh -c` that ib_console runs instead - + * every process they start is still a descendant of ib_console, so all of it is accelerated. + * * No custom `--profile` here, unlike [prepareBuildLinux]'s RustRover-specific one: C/C++ - * needs no equivalent, ib_console's own shipped default profile already covers it. + * and MSBuild need no equivalent, ib_console's own shipped default profile already covers them. */ - private fun prepareCMakeBuildLinux(project: Project, installFolder: File, resolved: ResolvedCMakeBuild): PreparedBuild { + private fun prepareNativeBuildLinux(project: Project, installFolder: File, build: NativeBuild): PreparedBuild { val consolePath = File(installFolder, "bin/ib_console") if (!consolePath.exists()) { return PreparedBuild.Failed( @@ -757,8 +1076,18 @@ object IncredibuildRunner { val scriptFile = File.createTempFile("incredibuild-build-", ".sh") scriptFile.deleteOnExit() + scriptFile.writeText(linuxWrapperScript(project.name, consolePath.absolutePath, build)) + + val commandLine = GeneralCommandLine("/bin/sh", scriptFile.absolutePath) + .withWorkDirectory(build.workingDirectory) + + return PreparedBuild.Ready(commandLine) { scriptFile.delete() } + } + + /** The script [prepareNativeBuildLinux] runs - see there. */ + internal fun linuxWrapperScript(caption: String, consolePath: String, build: NativeBuild): String { val script = StringBuilder("#!/bin/sh\n") - for ((key, value) in resolved.environment) { + for ((key, value) in build.environment) { // Not every entry CPPBuildUtil.buildCommandLine hands back is necessarily a valid // shell identifier (e.g. an inherited "BASH_FUNC_foo%%" from an exported bash // function) - "export" on such a name fails and takes the whole script down with @@ -767,30 +1096,47 @@ object IncredibuildRunner { if (!VALID_SHELL_IDENTIFIER.matches(key)) continue script.append("export ").append(key).append('=').append(shellSingleQuoted(value)).append('\n') } - script.append("exec ").append(shellSingleQuoted(consolePath.absolutePath)) - .append(" --caption ").append(shellSingleQuoted(project.name)) - .append(" --ib-quiet --build-cache-local-shared --build-cache-basedir=\"\$PWD\" -- ") - .append(shellSingleQuoted(resolved.executable)) - for (arg in resolved.arguments) { - script.append(' ').append(shellSingleQuoted(arg)) + script.append("exec ").append(shellSingleQuoted(consolePath)) + .append(" --caption ").append(shellSingleQuoted(caption)) + .append(" --ib-quiet") + // Left off where the build doesn't want it - see NativeBuild.buildCache. + if (build.buildCache) script.append(" --build-cache-local-shared --build-cache-basedir=\"\$PWD\"") + script.append(" -- ") + val commandTexts = build.commands.map { command -> + (listOf(command.executable) + command.arguments).joinToString(" ") { shellSingleQuoted(it) } + } + if (commandTexts.size == 1) { + script.append(commandTexts.single()) + } else { + script.append("/bin/sh -c ").append(shellSingleQuoted(commandTexts.joinToString(" && "))) } // Merges stderr into stdout, for the same reason prepareBuildLinux's Cargo build does. script.append(" 2>&1\n") - scriptFile.writeText(script.toString()) - - val commandLine = GeneralCommandLine("/bin/sh", scriptFile.absolutePath) - .withWorkDirectory(resolved.workingDirectory) - - return PreparedBuild.Ready(commandLine) { scriptFile.delete() } + return script.toString() } - /** Wraps [value] as a cmd.exe double-quoted token (after [batchEscaped]), needed only when - * it contains whitespace. */ + /** + * Wraps [value] as a cmd.exe double-quoted token (after [batchEscaped]), needed only when it + * is empty, contains whitespace, or contains a character cmd.exe would otherwise treat as an + * operator (`&`, `|`, `<`, `>`, `^`, `(`, `)` - e.g. a solution under `C:\R&D\`). + * + * Inside the quotes, the receiving program (MSBuild, cmake, ... - anything parsing its + * command line the MSVC runtime / CommandLineToArgvW way) treats backslashes right before a + * `"` as escapes, so a value ending in backslashes - `-p:SolutionDir=C:\My Projects\App\` - + * would otherwise turn the closing quote into a literal one and swallow every argument after + * it. Doubling those trailing backslashes is that same parser's escape for them. + */ internal fun cmdQuoted(value: String): String { val escaped = batchEscaped(value) - return if (escaped.any { it.isWhitespace() }) "\"$escaped\"" else escaped + if (escaped.isNotEmpty() && escaped.none { it.isWhitespace() || it in CMD_SPECIAL_CHARACTERS }) { + return escaped + } + val trailingBackslashes = escaped.length - escaped.trimEnd('\\').length + return "\"" + escaped + "\\".repeat(trailingBackslashes) + "\"" } + private const val CMD_SPECIAL_CHARACTERS = "&|<>^()" + /** Escapes [value] for literal use in a .cmd file: cmd.exe expands `%...%` variable * references anywhere on a line it parses - including inside a `set "KEY=VALUE"` value and * inside an already-quoted argument - so a stray `%` in a path or environment value (e.g. diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/CMakeBuildCommand.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/CMakeBuildCommand.kt index da5ae19..74c3bb5 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/CMakeBuildCommand.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/CMakeBuildCommand.kt @@ -19,6 +19,7 @@ import com.incredibuild.ibplugin.settings.IncredibuildSettings import com.intellij.openapi.application.ReadAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project +import com.jetbrains.cidr.cpp.cmake.model.CMakeConfiguration import com.jetbrains.cidr.cpp.cmake.workspace.CMakeWorkspace import com.jetbrains.cidr.cpp.execution.CMakeAppRunConfiguration import com.jetbrains.cidr.cpp.toolchains.CPPBuildUtil @@ -96,10 +97,49 @@ internal fun deriveCMakeBuild(project: Project, kind: CMakeBuildKind): CMakeBuil null } +/** + * The cmake invocation for one [buildConfiguration] (a target in one CMake profile) handed over + * by CLion's own build pipeline - see [IncredibuildCMakeProjectTaskRunner]. [targetName] null + * means every target, cmake's default "all". Must be called from a background thread. + */ +internal fun deriveCMakeBuild( + project: Project, + buildConfiguration: CMakeConfiguration, + targetName: String?, + cleanFirst: Boolean +): CMakeBuildResolution? = + try { + ReadAction.compute { + resolveCMakeBuild(project, buildConfiguration, targetName, cleanFirst) + } + } catch (e: Exception) { + LOG.warn("Could not derive the CMake build command for ${buildConfiguration.name}", e) + null + } + private fun resolveCMakeBuild(project: Project, kind: CMakeBuildKind): CMakeBuildResolution? { val buildAndRun = CMakeAppRunConfiguration.getSelectedBuildAndRunConfigurations(project) ?: return null val buildConfiguration = buildAndRun.buildConfiguration + // An explicit target set on the run configuration (e.g. "Build 'foo'" instead of just + // "Build") wins over the target the configuration would otherwise produce - matching + // CMakeAppRunConfiguration.BuildAndRunConfigurations' own field of the same name. + // wholeProject (Build All) passes no target at all: cmake's own default target already + // means "build everything", the same "all"/"ALL_BUILD" pseudo-target CLion's own Build + // Project action builds. + val targetName = if (kind.wholeProject) { + null + } else { + buildAndRun.explicitBuildTargetName?.takeIf { it.isNotBlank() } ?: buildConfiguration.target.name + } + return resolveCMakeBuild(project, buildConfiguration, targetName, kind.cleanFirst) +} +private fun resolveCMakeBuild( + project: Project, + buildConfiguration: CMakeConfiguration, + targetName: String?, + cleanFirst: Boolean +): CMakeBuildResolution? { val profileInfo = CMakeWorkspace.getInstance(project).getProfileInfoFor(buildConfiguration) val environment = profileInfo.environment ?: return null @@ -139,19 +179,11 @@ private fun resolveCMakeBuild(project: Project, kind: CMakeBuildKind): CMakeBuil // directory itself was configured and just ignore --config. buildConfiguration.buildType.takeIf { it.isNotBlank() }?.let { parameters += listOf("--config", it) } - if (!kind.wholeProject) { - // An explicit target set on the run configuration (e.g. "Build 'foo'" instead of just - // "Build") wins over the target the configuration would otherwise produce - matching - // CMakeAppRunConfiguration.BuildAndRunConfigurations' own field of the same name. - val targetName = buildAndRun.explicitBuildTargetName?.takeIf { it.isNotBlank() } - ?: buildConfiguration.target.name + if (targetName != null) { parameters += listOf("--target", targetName) } - // wholeProject (Build All) omits --target entirely: cmake's own default target already - // means "build everything", the same "all"/"ALL_BUILD" pseudo-target CLion's own Build - // Project action builds. - if (kind.cleanFirst) { + if (cleanFirst) { parameters += "--clean-first" } diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/IncredibuildCMakeProjectTaskRunner.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/IncredibuildCMakeProjectTaskRunner.kt new file mode 100644 index 0000000..fe9e1a7 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/cmake/IncredibuildCMakeProjectTaskRunner.kt @@ -0,0 +1,265 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.cmake + +import com.incredibuild.ibplugin.actions.BuildFlavor +import com.incredibuild.ibplugin.actions.IdeBuildAcceleration +import com.incredibuild.ibplugin.actions.IncredibuildRunner +import com.incredibuild.ibplugin.actions.IncredibuildRunner.AcceleratedBuildPresentation +import com.incredibuild.ibplugin.actions.IncredibuildRunner.IdeBuildOutcome +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.ReadAction +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.project.Project +import com.intellij.task.ModuleBuildTask +import com.intellij.task.ModuleFilesBuildTask +import com.intellij.task.ProjectModelBuildTask +import com.intellij.task.ProjectTask +import com.intellij.task.ProjectTaskContext +import com.intellij.task.ProjectTaskRunner +import com.intellij.task.TaskRunnerResults +import com.jetbrains.cidr.cpp.cmake.model.CMakeConfiguration +import com.jetbrains.cidr.cpp.execution.CMakeExplicitTargetConfiguration +import com.jetbrains.cidr.cpp.execution.build.runners.CMakeProjectTaskRunner +import com.jetbrains.cidr.execution.build.tasks.CidrCleanTask +import org.jetbrains.concurrency.AsyncPromise +import org.jetbrains.concurrency.Promise +import java.util.concurrent.CompletableFuture + +/** + * Runs CLion's own CMake build actions through Incredibuild: Build, Rebuild, Build Project, the + * toolbar's build button, and the build that runs before a CMake run configuration starts. + * + * All of those converge on the platform's [ProjectTaskRunner] dispatch, which CLion serves with + * [CMakeProjectTaskRunner]; `ProjectTaskManagerImpl` gives each task to the *first* runner whose + * `canRun` accepts it, so this is registered ahead of that one (cmake-support.xml) - the same + * arrangement as the Cargo [com.incredibuild.ibplugin.actions.IncredibuildProjectTaskRunner]. + * + * Likewise, everything about *what* to build is left to CLion: [CMakeProjectTaskRunner] is asked + * whether it would handle the task and to expand it into per-profile builds (and the clean that + * a Rebuild adds), and only the execution is replaced - by `cmake --build` under Incredibuild, + * the same invocation the Incredibuild menu's CMake actions run. + * + * Declining is always safe: when [canRun] says no, or anything goes wrong before the accelerated + * build starts, CLion's own runner builds as usual. + */ +class IncredibuildCMakeProjectTaskRunner : ProjectTaskRunner() { + + /** See the identical override in [com.incredibuild.ibplugin.actions.IncredibuildProjectTaskRunner]. */ + override fun canRun(projectTask: ProjectTask): Boolean = false + + /** + * Also called by CLion's Build Project action on every toolbar/menu update (on a background + * thread) to decide whether it's available, so this must stay cheap and must not throw. + */ + override fun canRun(project: Project, projectTask: ProjectTask, context: ProjectTaskContext?): Boolean { + if (!IdeBuildAcceleration.isEnabled) return false + // Only builds: compiling a single file (ModuleFilesBuildTask, itself a ModuleBuildTask), + // installing and plain Clean stay with CLion. + if (projectTask is ModuleFilesBuildTask) return false + if (projectTask !is ModuleBuildTask && projectTask !is ProjectModelBuildTask<*>) return false + val cmakeRunner = cmakeProjectTaskRunner() ?: return false + return try { + cmakeRunner.canRun(project, projectTask, context) + } catch (e: Exception) { + LOG.warn("Could not ask CLion whether it handles this build task", e) + false + } + } + + override fun run(project: Project, context: ProjectTaskContext, vararg tasks: ProjectTask): Promise { + val promise = AsyncPromise() + // Locating Incredibuild and reading the CMake model must both stay off the EDT. + ApplicationManager.getApplication().executeOnPooledThread { + try { + runOnPooledThread(project, context, tasks, promise) + } catch (e: Exception) { + LOG.warn("Incredibuild build failed to start; falling back to CLion's build", e) + delegateToCMake(project, context, tasks, promise) + } + } + return promise + } + + private fun runOnPooledThread( + project: Project, + context: ProjectTaskContext, + tasks: Array, + promise: AsyncPromise + ) { + val installFolder = IdeBuildAcceleration.installFolderOrFallBack(project, BuildFlavor.NATIVE, CLION_BUILD) + if (installFolder == null) { + delegateToCMake(project, context, tasks, promise) + return + } + + val plan = planBuilds(project, tasks) + if (plan.builds.isEmpty()) { + delegateToCMake(project, context, tasks, promise) + return + } + + val resolvedBuilds = plan.builds.map { planned -> + when (val resolution = deriveCMakeBuild(project, planned.configuration, planned.targetName, plan.cleanFirst)) { + is CMakeBuildResolution.Ready -> planned to resolution.build + is CMakeBuildResolution.Unsupported -> return fallBack(project, resolution.reason, context, tasks, promise) + null -> return fallBack( + project, + "Could not determine the cmake command for ${planned.configuration.name}.", + context, + tasks, + promise + ) + } + } + + // CLion's own build saves unsaved editors first, and cmake reads from disk. + ApplicationManager.getApplication().invokeAndWait( + { FileDocumentManager.getInstance().saveAllDocuments() }, + ModalityState.any() + ) + + // One at a time, blocking this pooled thread on each: Build Project expands to one build + // per CMake profile, and those must not compete for the Incredibuild tool window. + for ((planned, resolved) in resolvedBuilds) { + val build = IncredibuildRunner.NativeBuild( + listOf(IncredibuildRunner.NativeCommand(resolved.executable, resolved.arguments)), + resolved.workingDirectory, + resolved.environment + ) + val outcome = CompletableFuture() + IncredibuildRunner.launchNativeBuildForIde( + project, + installFolder, + build, + if (plan.cleanFirst) "cmake_ide_rebuild" else "cmake_ide_build", + presentation(planned, plan.cleanFirst) + ) { outcome.complete(it) } + + when (val result = outcome.get()) { + IdeBuildOutcome.Succeeded -> continue + IdeBuildOutcome.Failed -> { + promise.setResult(TaskRunnerResults.FAILURE) + return + } + IdeBuildOutcome.Stopped, IdeBuildOutcome.NotStarted -> { + promise.setResult(TaskRunnerResults.ABORTED) + return + } + is IdeBuildOutcome.NotPrepared -> { + fallBack(project, result.message, context, tasks, promise) + return + } + } + } + promise.setResult(TaskRunnerResults.SUCCESS) + } + + /** One `cmake --build` to run: a CMake profile's configuration, and the target (null for all). */ + private class PlannedBuild(val configuration: CMakeConfiguration, val targetName: String?) + + private class BuildPlan(val builds: List, val cleanFirst: Boolean) + + /** + * The builds behind [tasks], as CLion's own runner expands them. A Rebuild shows up as a + * [CidrCleanTask] ahead of each build - mapped to `--clean-first` rather than a separate + * clean, so the clean is accelerated along with the build, as the menu's Rebuild does. + */ + private fun planBuilds(project: Project, tasks: Array): BuildPlan { + val cmakeRunner = cmakeProjectTaskRunner() ?: return BuildPlan(emptyList(), false) + val expanded = ReadAction.compute, Throwable> { + tasks.flatMap { task -> + try { + cmakeRunner.runnerForTask(task, project)?.expandTask(task) ?: emptyList() + } catch (e: Exception) { + LOG.warn("Could not expand build task ${task.presentableName}", e) + emptyList() + } + } + } + val builds = expanded.filterIsInstance>().mapNotNull { task -> + when (val element = task.buildableElement) { + is CMakeConfiguration -> PlannedBuild(element, element.target.name) + // Build Project's per-profile "all" target - which, like the menu's Build All, + // is cmake's default target, so no --target at all (also right for the Visual + // Studio generators, whose equivalent is called ALL_BUILD). + is CMakeExplicitTargetConfiguration -> PlannedBuild( + element.cmakeConfiguration, + element.explicitBuildTargetName?.takeUnless { it.isBlank() || it == ALL_TARGET } + ) + else -> null + } + } + return BuildPlan(builds, cleanFirst = expanded.any { it is CidrCleanTask }) + } + + private fun presentation(planned: PlannedBuild, cleanFirst: Boolean): AcceleratedBuildPresentation { + val actionName = if (cleanFirst) "Rebuild" else "Build" + val target = planned.targetName ?: "all targets" + return AcceleratedBuildPresentation( + contentName = actionName, + banner = "Incredibuild: $actionName (accelerated)\n" + + " Building: $target\n" + + " Profile: ${planned.configuration.profileName}", + progressTitle = "${if (cleanFirst) "Rebuilding" else "Building"} $target with Incredibuild" + ) + } + + private fun fallBack( + project: Project, + reason: String, + context: ProjectTaskContext, + tasks: Array, + promise: AsyncPromise + ) { + LOG.warn(reason) + IdeBuildAcceleration.notifyFallback(project, reason, CLION_BUILD) + delegateToCMake(project, context, tasks, promise) + } + + /** Hands the build back to CLion, mirroring its result into [promise]. */ + private fun delegateToCMake( + project: Project, + context: ProjectTaskContext, + tasks: Array, + promise: AsyncPromise + ) { + val cmakeRunner = cmakeProjectTaskRunner() + if (cmakeRunner == null) { + promise.setResult(TaskRunnerResults.FAILURE) + return + } + cmakeRunner.run(project, context, *tasks) + .onSuccess { promise.setResult(it) } + .onError { promise.setError(it) } + } + + /** CLion's own registered runner, taken from the extension point so it's the instance the platform would have used. */ + private fun cmakeProjectTaskRunner(): CMakeProjectTaskRunner? = + EP_NAME.extensionList.filterIsInstance().firstOrNull() + + private companion object { + private val LOG = Logger.getInstance(IncredibuildCMakeProjectTaskRunner::class.java) + + /** The pseudo-target CLion's Build Project expands to - see [planBuilds]. */ + private const val ALL_TARGET = "all" + + /** The standard builder a fallback build runs with, as named in the fallback notification. */ + private const val CLION_BUILD = "CLion's own CMake build" + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildActions.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildActions.kt new file mode 100644 index 0000000..9847a96 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildActions.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.incredibuild.ibplugin.rider.RiderSolutionModel +import com.incredibuild.ibplugin.rider.SolutionSelection +import com.intellij.openapi.actionSystem.ActionUpdateThread +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages + +/** + * The shared shape of the three Rider .NET "... with Incredibuild" actions: each captures what + * Rider's own equivalent would build (solution, active configuration|platform, selection) and + * hands it to [DotNetBuildRunner], which runs it through Incredibuild instead of Rider's builder. + * + * Registered only in Rider (rider-support.xml), so [RiderSolutionModel] - which needs Rider's + * own classes - is only ever reached there. + */ +abstract class DotNetBuildWithIncredibuildActionBase internal constructor( + private val kind: DotNetBuildKind, + private val actionType: String +) : AnAction(), DumbAware { + + override fun getActionUpdateThread() = ActionUpdateThread.BGT + + override fun update(e: AnActionEvent) { + val project = e.project + if (project == null || !RiderSolutionModel.hasSolution(project)) { + e.presentation.isEnabledAndVisible = false + return + } + + // The selection is only read where it matters - the context menu, and Build Selected + // Projects - since this runs on every menu and toolbar update. + if (e.isFromContextMenu) { + // Same rule as Rider's own Build Selection: offered on the solution, solution + // folders and projects in the Solution Explorer, not on files/references inside them. + e.presentation.isEnabledAndVisible = RiderSolutionModel.selection(e.dataContext) != SolutionSelection.Nothing + return + } + + e.presentation.isVisible = true + e.presentation.isEnabled = kind != DotNetBuildKind.BUILD_PROJECTS || + RiderSolutionModel.selection(e.dataContext) != SolutionSelection.Nothing + } + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project ?: return + + var effectiveKind = kind + var projectFiles = emptyList() + if (kind == DotNetBuildKind.BUILD_PROJECTS) { + when (val selection = RiderSolutionModel.selection(e.dataContext)) { + // The solution node itself selected: like Rider's own Build Selection, that's + // just a solution build. + SolutionSelection.WholeSolution -> effectiveKind = DotNetBuildKind.BUILD_SOLUTION + is SolutionSelection.Projects -> projectFiles = selection.projectFiles + SolutionSelection.Nothing -> { + Messages.showInfoMessage( + project, + "Select one or more projects in the Solution Explorer to build them with Incredibuild.", + "Incredibuild" + ) + return + } + } + } + + val request = dotNetBuildRequest(project, effectiveKind, projectFiles) + if (request == null) { + Messages.showErrorDialog( + project, + "Could not determine what to build. Is a solution loaded, with an active configuration selected?", + "Incredibuild" + ) + return + } + DotNetBuildRunner.build(project, request, actionType) + } +} + +/** + * What Rider's own build of [kind] would build right now - the open solution (or solution filter) + * in its active configuration|platform - or null if there is no solution or active configuration. + * Reads Rider's solution model, so call it on the EDT. + */ +internal fun dotNetBuildRequest( + project: Project, + kind: DotNetBuildKind, + projectFiles: List = emptyList() +): DotNetBuildRequest? { + val entryPoint = RiderSolutionModel.buildEntryPoint(project) ?: return null + val solutionFile = RiderSolutionModel.solutionFile(project) ?: return null + val configuration = RiderSolutionModel.activeConfiguration(project) ?: return null + return DotNetBuildRequest(kind, entryPoint, solutionFile, configuration.configuration, configuration.platform, projectFiles) +} + +/** The accelerated equivalent of Rider's own Build Solution. */ +class BuildSolutionWithIncredibuildAction : + DotNetBuildWithIncredibuildActionBase(DotNetBuildKind.BUILD_SOLUTION, "dotnet_build_solution") + +/** The accelerated equivalent of Rider's own Rebuild Solution. */ +class RebuildSolutionWithIncredibuildAction : + DotNetBuildWithIncredibuildActionBase(DotNetBuildKind.REBUILD_SOLUTION, "dotnet_rebuild_solution") + +/** The accelerated equivalent of Rider's own Build Selection. */ +class BuildSelectedProjectsWithIncredibuildAction : + DotNetBuildWithIncredibuildActionBase(DotNetBuildKind.BUILD_PROJECTS, "dotnet_build_projects") diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildRunner.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildRunner.kt new file mode 100644 index 0000000..e8f8a36 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetBuildRunner.kt @@ -0,0 +1,279 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.incredibuild.ibplugin.actions.BuildFlavor +import com.incredibuild.ibplugin.actions.IdeBuildAcceleration +import com.incredibuild.ibplugin.actions.IncredibuildRunner +import com.incredibuild.ibplugin.actions.IncredibuildRunner.AcceleratedBuildPresentation +import com.incredibuild.ibplugin.actions.IncredibuildRunner.IdeBuildOutcome +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.progress.Task +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.ThrowableComputable +import java.io.File +import java.util.concurrent.CompletableFuture + +private val LOG = Logger.getInstance("com.incredibuild.ibplugin.actions.dotnet.DotNetBuildRunner") + +/** The standard builder a fallback build runs with, as named in the fallback notification. */ +private const val RIDER_BUILD = "Rider's own build" + +/** + * Everything about Rider's own state an accelerated .NET build needs, captured up front by the + * action (on the thread it runs on) so the background part never touches Rider's models. + */ +internal class DotNetBuildRequest( + val kind: DotNetBuildKind, + /** The .sln/.slnx, or open .slnf - see [solutionBuild]. */ + val entryPoint: String, + /** The actual .sln/.slnx even when a filter is open - see [projectsBuild]. */ + val solutionFile: String, + val configuration: String, + val platform: String, + /** Only for [DotNetBuildKind.BUILD_PROJECTS]. */ + val projectFiles: List = emptyList(), +) + +internal object DotNetBuildRunner { + + /** The explicit "... with Incredibuild" actions: report problems with dialogs, never fall back. */ + fun build(project: Project, request: DotNetBuildRequest, actionType: String) { + // Rider's own build saves unsaved editors first, so the build sees what's on screen; + // MSBuild reads from disk, so this has to as well. + FileDocumentManager.getInstance().saveAllDocuments() + + ProgressManager.getInstance().run(object : Task.Backgroundable(project, "Locating Incredibuild") { + override fun run(indicator: ProgressIndicator) { + val installFolder = IncredibuildRunner.ensureIncredibuildInstalled(project, actionType) ?: return + when (val prepared = prepare(request)) { + is PreparedDotNetBuild.Failed -> showError(project, prepared.message) + is PreparedDotNetBuild.Ready -> IncredibuildRunner.launchNativeBuild( + project, + installFolder, + prepared.build, + actionType, + presentation(request, prepared.tool) + ) + } + } + }) + } + + /** + * For Rider's own build actions (see [DotNetIdeBuildActionWrapper]): checks Incredibuild and + * prepares the MSBuild invocation under a short modal progress - synchronously, because if + * anything is missing the caller must hand the very same action event back to Rider's own + * action, which it can only do before its own actionPerformed returns. Returns null, after + * telling the user why, when the build should fall back to Rider's. Must be called on the EDT. + */ + fun prepareForIdeAction(project: Project, request: DotNetBuildRequest): IdeBuild? { + FileDocumentManager.getInstance().saveAllDocuments() + return ProgressManager.getInstance().runProcessWithProgressSynchronously( + ThrowableComputable { prepareForIde(project, request) }, + "Preparing Incredibuild build", + false, + project + ) + } + + /** Starts a build [prepareForIdeAction] prepared, in the Incredibuild tool window. */ + fun launchIdeBuild(project: Project, ideBuild: IdeBuild, actionType: String) { + ApplicationManager.getApplication().executeOnPooledThread { + IncredibuildRunner.launchNativeBuild( + project, + ideBuild.installFolder, + ideBuild.prepared.build, + actionType, + presentation(ideBuild.request, ideBuild.prepared.tool) + ) + } + } + + /** + * For the build Rider runs before a run configuration starts: builds through Incredibuild and + * blocks until it ends, returning whether it succeeded - or null, after telling the user why, + * when Rider should build instead. Must be called off the EDT. + */ + fun buildBlockingForIde(project: Project, request: DotNetBuildRequest, actionType: String): Boolean? { + ApplicationManager.getApplication().invokeAndWait( + { FileDocumentManager.getInstance().saveAllDocuments() }, + ModalityState.any() + ) + val ideBuild = prepareForIde(project, request) ?: return null + val outcome = CompletableFuture() + IncredibuildRunner.launchNativeBuildForIde( + project, + ideBuild.installFolder, + ideBuild.prepared.build, + actionType, + presentation(request, ideBuild.prepared.tool) + ) { outcome.complete(it) } + return when (val result = outcome.get()) { + IdeBuildOutcome.Succeeded -> true + IdeBuildOutcome.Failed, IdeBuildOutcome.Stopped, IdeBuildOutcome.NotStarted -> false + is IdeBuildOutcome.NotPrepared -> { + IdeBuildAcceleration.notifyFallback(project, result.message, RIDER_BUILD) + null + } + } + } + + /** A build of Rider's own that is ready to go through Incredibuild. */ + internal class IdeBuild( + val request: DotNetBuildRequest, + val installFolder: File, + val prepared: PreparedDotNetBuild.Ready + ) + + private fun prepareForIde(project: Project, request: DotNetBuildRequest): IdeBuild? { + val installFolder = IdeBuildAcceleration.installFolderOrFallBack(project, BuildFlavor.NATIVE, RIDER_BUILD) + ?: return null + return when (val prepared = prepare(request)) { + is PreparedDotNetBuild.Failed -> { + IdeBuildAcceleration.notifyFallback(project, prepared.message, RIDER_BUILD) + null + } + is PreparedDotNetBuild.Ready -> IdeBuild(request, installFolder, prepared) + } + } + + /** The MSBuild invocation for a request, or why there isn't one. */ + internal sealed interface PreparedDotNetBuild { + class Ready(val tool: MsBuildTool, val build: IncredibuildRunner.NativeBuild) : PreparedDotNetBuild + class Failed(val message: String) : PreparedDotNetBuild + } + + /** Locates MSBuild and builds the command for [request]. Blocking - reads the solution file. */ + private fun prepare(request: DotNetBuildRequest): PreparedDotNetBuild { + val tool = when (val location = MsBuildLocator.locate()) { + is MsBuildLocation.NotFound -> return PreparedDotNetBuild.Failed(location.reason) + is MsBuildLocation.Found -> location.tool + } + + val build = when (request.kind) { + DotNetBuildKind.BUILD_SOLUTION, DotNetBuildKind.REBUILD_SOLUTION -> solutionBuild( + tool, + request.entryPoint, + request.kind, + request.configuration, + request.platform, + restore = needsRestore(readEntryPointProjectFiles(request.entryPoint)) + ) + + DotNetBuildKind.BUILD_PROJECTS -> { + val targetNames = readProjectTargets(request.solutionFile) + val missing = request.projectFiles.filter { normalizedPath(it) !in targetNames } + if (missing.isNotEmpty()) { + return PreparedDotNetBuild.Failed( + "Could not find these projects in ${File(request.solutionFile).name}, so they can't be " + + "built through it:\n" + missing.joinToString("\n") + ) + } + projectsBuild( + tool, + request.solutionFile, + request.projectFiles, + request.configuration, + request.platform, + targetNames, + restore = needsRestore(request.projectFiles) + ) + } + } + return PreparedDotNetBuild.Ready(tool, build) + } + + /** The solution's per-project build targets - see [solutionProjectTargets]. Empty if the + * solution can't be read, which then reports every selected project as not found. */ + private fun readProjectTargets(solutionFile: String): Map { + val file = File(solutionFile) + return try { + solutionProjectTargets( + file.readText(), + file.absoluteFile.parentFile, + isSlnx = file.extension.equals("slnx", ignoreCase = true) + ) + } catch (e: Exception) { + LOG.warn("Could not read the project list from $solutionFile", e) + emptyMap() + } + } + + /** + * The project files a whole-solution build of [entryPoint] builds, for [needsRestore]: the + * solution's, or only those an open solution filter includes - so a project the filter leaves + * out can't add `-restore` to every filtered build. Empty if they can't be read, which then + * leaves restore to Rider, as it is for every already-restored solution. + */ + private fun readEntryPointProjectFiles(entryPoint: String): List { + val file = File(entryPoint).absoluteFile + return try { + if (file.extension.equals("slnf", ignoreCase = true)) { + solutionFilterProjectFiles(file.readText(), file.parentFile) + } else { + solutionProjectFiles(file.readText(), file.parentFile) + } + } catch (e: Exception) { + LOG.warn("Could not read the project list from $entryPoint", e) + emptyList() + } + } + + private fun presentation(request: DotNetBuildRequest, tool: MsBuildTool): AcceleratedBuildPresentation { + val solutionName = File(request.solutionFile).nameWithoutExtension + val actionName = when (request.kind) { + DotNetBuildKind.BUILD_SOLUTION -> "Build Solution" + DotNetBuildKind.REBUILD_SOLUTION -> "Rebuild Solution" + DotNetBuildKind.BUILD_PROJECTS -> "Build Selected Projects" + } + val target = when (request.kind) { + DotNetBuildKind.BUILD_PROJECTS -> + cappedProjectList(request.projectFiles.map { File(it).nameWithoutExtension }) + else -> File(request.entryPoint).name + } + val progressVerb = if (request.kind == DotNetBuildKind.REBUILD_SOLUTION) "Rebuilding" else "Building" + return AcceleratedBuildPresentation( + contentName = actionName, + banner = "Incredibuild: $actionName (accelerated)\n" + + " Building: $target\n" + + " Configuration: ${request.configuration} | ${request.platform}\n" + + " MSBuild: ${tool.description}", + progressTitle = "$progressVerb $solutionName with Incredibuild (${request.configuration} | ${request.platform})" + ) + } + + /** + * [names] for the banner, cut short after [shown] so a large selection doesn't make it + * unreadable: "App, Lib +12 more". + */ + internal fun cappedProjectList(names: List, shown: Int = 2): String { + if (names.size <= shown + 1) return names.joinToString(", ") + return names.take(shown).joinToString(", ") + " +${names.size - shown} more" + } + + private fun showError(project: Project, message: String) { + ApplicationManager.getApplication().invokeLater { + Messages.showErrorDialog(project, message, "Incredibuild") + } + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetIdeBuilds.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetIdeBuilds.kt new file mode 100644 index 0000000..7577248 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/DotNetIdeBuilds.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.incredibuild.ibplugin.actions.IdeBuildAcceleration +import com.incredibuild.ibplugin.rider.RiderBuildInterceptor +import com.incredibuild.ibplugin.rider.RiderSolutionModel +import com.incredibuild.ibplugin.rider.SolutionSelection +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.AnActionWrapper +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.project.Project +import com.intellij.openapi.startup.ProjectActivity + +/** One of Rider's own build actions that is routed through Incredibuild when the setting is on. */ +private class WrappedRiderAction(val actionId: String, val kind: DotNetBuildKind, val actionType: String) + +/** + * Rider's Build menu's Build Solution and Rebuild Solution, and the Solution Explorer's Build + * Selection. Build Solution's id is also what the toolbar's build button (registered as + * "BuildSolutionAction", carrying Rider's Build shortcuts) runs whenever no build is in progress, so + * wrapping it covers that button and those shortcuts too. + */ +private val WRAPPED_RIDER_ACTIONS = listOf( + WrappedRiderAction("BuildWholeSolutionAction", DotNetBuildKind.BUILD_SOLUTION, "dotnet_ide_build_solution"), + WrappedRiderAction("RebuildSolutionAction", DotNetBuildKind.REBUILD_SOLUTION, "dotnet_ide_rebuild_solution"), + WrappedRiderAction("BuildSelection", DotNetBuildKind.BUILD_PROJECTS, "dotnet_ide_build_selection"), +) + +/** + * Wraps Rider's own build actions (see [WRAPPED_RIDER_ACTIONS]) in [DotNetIdeBuildActionWrapper], + * keeping their ids - so menus, toolbars, keymaps and anything else that refers to them by id get + * the wrapper - and keeping Rider's original instance to delegate to. + * + * Rider has no extension point for intercepting a build: its actions hand it straight to the + * ReSharper backend, which runs MSBuild in its own process tree where Incredibuild can't wrap it. + * + * Done when the first project opens rather than through `actionConfigurationCustomizer`, whose + * interface is internal API the Plugin Verifier rejects; nothing can be built before a project is + * open anyway. Idempotent, so later projects find the wrappers already in place. + */ +class DotNetIdeBuildActionInstaller : ProjectActivity { + + override suspend fun execute(project: Project) { + val actionManager = ActionManager.getInstance() + for (wrapped in WRAPPED_RIDER_ACTIONS) { + val original = actionManager.getAction(wrapped.actionId) ?: continue + if (original is DotNetIdeBuildActionWrapper) continue + actionManager.replaceAction( + wrapped.actionId, + DotNetIdeBuildActionWrapper(original, wrapped.kind, wrapped.actionType) + ) + } + } +} + +/** + * One of Rider's own build actions, run through Incredibuild when "Use Incredibuild for the IDE's + * own Build and Rebuild actions" is on - and exactly as Rider would run it otherwise: when the + * setting is off, when there's no solution or selection to build, and (after a notification saying + * why) when Incredibuild or MSBuild isn't available. Presentation is always Rider's. + */ +internal class DotNetIdeBuildActionWrapper( + delegate: AnAction, + private val kind: DotNetBuildKind, + private val actionType: String +) : AnActionWrapper(delegate) { + + override fun actionPerformed(e: AnActionEvent) { + val project = e.project + val request = if (project != null && IdeBuildAcceleration.isEnabled) requestFor(project, e) else null + val ideBuild = request?.let { DotNetBuildRunner.prepareForIdeAction(project!!, it) } + if (ideBuild == null) { + super.actionPerformed(e) + return + } + DotNetBuildRunner.launchIdeBuild(project!!, ideBuild, actionType) + } + + /** What Rider's own action would build, or null to leave the build to it. */ + private fun requestFor(project: Project, e: AnActionEvent): DotNetBuildRequest? { + if (!RiderSolutionModel.hasSolution(project)) return null + if (kind != DotNetBuildKind.BUILD_PROJECTS) return dotNetBuildRequest(project, kind) + return when (val selection = RiderSolutionModel.selection(e.dataContext)) { + // As in Rider's own Build Selection, the solution node means the whole solution. + SolutionSelection.WholeSolution -> dotNetBuildRequest(project, DotNetBuildKind.BUILD_SOLUTION) + is SolutionSelection.Projects -> dotNetBuildRequest(project, kind, selection.projectFiles) + SolutionSelection.Nothing -> null + } + } +} + +/** The [RiderBuildInterceptor] behind the before-run build steps (rider-support.xml). */ +class DotNetIdeBuildInterceptor : RiderBuildInterceptor { + + override fun buildBeforeRun(project: Project, projectFile: String?): Boolean? { + if (!IdeBuildAcceleration.isEnabled) return null + // Rider's solution model is read on the EDT, like the actions above read it. + var request: DotNetBuildRequest? = null + ApplicationManager.getApplication().invokeAndWait({ + if (RiderSolutionModel.hasSolution(project)) { + request = if (projectFile == null) { + dotNetBuildRequest(project, DotNetBuildKind.BUILD_SOLUTION) + } else { + dotNetBuildRequest(project, DotNetBuildKind.BUILD_PROJECTS, listOf(projectFile)) + } + } + }, ModalityState.any()) + val ready = request ?: return null + return DotNetBuildRunner.buildBlockingForIde(project, ready, "dotnet_ide_before_run") + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommand.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommand.kt new file mode 100644 index 0000000..273c7fe --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommand.kt @@ -0,0 +1,334 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.google.gson.JsonParser +import com.incredibuild.ibplugin.actions.IncredibuildRunner.NativeBuild +import com.incredibuild.ibplugin.actions.IncredibuildRunner.NativeCommand +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.xml.sax.InputSource +import java.io.File +import java.io.IOException +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory + +/** Which of the three .NET "... with Incredibuild" actions is asking for a command. */ +internal enum class DotNetBuildKind(val msBuildTarget: String) { + /** "Build Solution with Incredibuild". */ + BUILD_SOLUTION("Build"), + + /** "Rebuild Solution with Incredibuild". */ + REBUILD_SOLUTION("Rebuild"), + + /** "Build Selected Projects with Incredibuild". */ + BUILD_PROJECTS("Build"), +} + +/** + * The MSBuild to run: [executable] plus any [leadingArguments] that must come before the + * project/solution (`msbuild` for `dotnet msbuild`), and a [description] for the console banner. + */ +internal class MsBuildTool(val executable: String, val leadingArguments: List, val description: String) { + companion object { + /** MSBuild.exe itself, or the dotnet host - which needs its `msbuild` verb first. */ + fun fromPath(path: String): MsBuildTool { + // Split on either separator rather than through File, whose notion of one depends on + // the OS this runs on - so a Windows path reads the same everywhere (e.g. in tests). + val name = path.substringAfterLast('/').substringAfterLast('\\').substringBeforeLast('.') + return if (name.equals("dotnet", ignoreCase = true)) { + MsBuildTool(path, listOf("msbuild"), "dotnet msbuild ($path)") + } else { + MsBuildTool(path, emptyList(), path) + } + } + } +} + +/** + * Arguments shared by every accelerated MSBuild invocation, on top of the target and the active + * configuration: + * - No `-restore` (see [RESTORE_ARGUMENT] for when it is added): Rider restores NuGet packages + * itself - on solution load, on project changes and before its own builds - and watches each + * project's restore outputs (obj\*.nuget.dgspec.json, *.nuget.g.props, project.assets.json). + * Any restore by another process rewrites those files, which Rider treats as "restore inputs + * changed": it re-restores every project on its next build, regenerating *.nuget.g.props - an + * import of every project, so an input of every compile - and recompiles the whole solution + * even though this build just did. Leaving restore to Rider keeps the accelerated build and + * Rider's own builds incremental with each other. + * - `-graph`: MSBuild's static graph mode - evaluate the whole project graph up front, then + * build it bottom-up, every project only once its references are done. Without it, a project + * that references many others (an app referencing all of a solution's libraries) builds those + * references itself, from whichever MSBuild node it happens to run on - and MSBuild pins each + * project to the first node that touches it, so every library that project reached first then + * builds on that one node, one after another. Observed on a 100-library solution: 16 libraries + * in parallel, then the remaining 84 strictly one at a time (4 min vs 1:48 with `-graph`) - + * leaving Incredibuild one compiler process at a time to distribute. + * - `-m`: lets MSBuild build independent projects concurrently, which is what gives Incredibuild + * more than one compiler process at a time to distribute. Deliberately unbounded (one node per + * local core) rather than the "Parallel jobs" setting: every MSBuild node is a full, + * long-lived process, and the hundreds of jobs that setting defaults to for Cargo/CMake would + * mean hundreds of them. + * - `-nodeReuse:false`: otherwise MSBuild leaves its worker nodes running after the build for the + * next one to reuse - processes that outlive the build they were started from, which + * Incredibuild would then keep waiting on before it considers the build finished. + * - `-p:UseSharedCompilation=false`: otherwise the C#/VB compilers don't run as their own + * processes at all, but as requests to one shared, long-lived VBCSCompiler server - which + * Incredibuild can't see, let alone distribute. Only changes how the compiler is launched, not + * what it compiles. + * - `-nologo`/`-v:minimal`: the same console verbosity Rider's own build output defaults to. + * + * None of these change anything about how the solution or its projects are configured - they + * only affect this one command-line invocation. + */ +internal val COMMON_MSBUILD_ARGUMENTS = listOf( + "-graph", + "-m", + "-nodeReuse:false", + "-p:UseSharedCompilation=false", + "-nologo", + "-v:minimal", +) + +/** + * Added only when some project being built has never been restored (see [needsRestore]) - then + * the build can't succeed without it, and there's no restore state of Rider's to disturb yet. + */ +internal const val RESTORE_ARGUMENT = "-restore" + +/** + * Whether any of [projectFiles] is a PackageReference-restored project with no NuGet assets file + * yet - i.e. was never restored, so building it would fail with NETSDK1004. + * + * Only such projects ever get a project.assets.json, so everything else is skipped rather than + * counted as unrestored: shared projects (.shproj), C++ projects (.vcxproj), classic projects + * using packages.config, ... Counting those would add `-restore` to every build of a solution + * that contains one - exactly the restore-state churn [COMMON_MSBUILD_ARGUMENTS] explains this + * is meant to avoid. A project file that can't be read is skipped too, leaving restore to Rider. + */ +internal fun needsRestore(projectFiles: List): Boolean = + projectFiles.any { isUnrestoredPackageReferenceProject(File(it).absoluteFile) } + +/** Every SDK-style project restores through PackageReference implicitly, as does a classic one + * that lists a PackageReference or opts in with RestoreProjectStyle. */ +private val PACKAGE_REFERENCE_PROJECT_MARKERS = listOf( + Regex("""\bSdk\s*=\s*""""), // or + Regex(""" + Regex("""\ in the project's folder or any folder above it, with the + * artifacts output layout (UseArtifactsOutput) - whose root defaults to the folder holding + * Directory.Build.props, which can be anywhere above the project. + * A project that moves its intermediate folder anywhere else (a custom BaseIntermediateOutputPath) + * shows up as unrestored, which only costs it a restore. + */ +private fun assetsFileCandidates(projectFile: File): Sequence { + val projectDir = projectFile.parentFile + val artifactsLayout = generateSequence(projectDir) { it.parentFile } + .map { File(it, "artifacts${File.separator}obj${File.separator}${projectFile.nameWithoutExtension}") } + return (sequenceOf(File(projectDir, "obj")) + artifactsLayout).map { File(it, "project.assets.json") } +} + +private val SLNX_PROJECT_PATH = Regex("""]*Path="([^"]+)"""") + +/** + * The project files a .sln or .slnx lists, resolved against its directory - for [needsRestore]. + * Solution folders and non-project entries (anything not ending in "proj") are skipped. + */ +internal fun solutionProjectFiles(solutionText: String, solutionDir: File): List { + val relativePaths = SLN_PROJECT_LINE.findAll(solutionText).map { it.groupValues[2] } + + SLNX_PROJECT_PATH.findAll(solutionText).map { it.groupValues[1] } + return relativePaths + .filter { it.endsWith("proj", ignoreCase = true) } + .map { File(solutionDir, nativeSeparators(it)).path } + .distinct() + .toList() +} + +/** + * The project files a solution filter (.slnf) includes - for [needsRestore], so a filtered build + * only restores when one of *its* projects needs it, not when some project the filter leaves out + * does. A filter is JSON, `{"solution": {"path": ..., "projects": [...]}}`: `path` is the .sln + * relative to the filter's own directory ([filterDir]), and each project is relative to that + * solution's directory, as MSBuild resolves them. + */ +internal fun solutionFilterProjectFiles(filterText: String, filterDir: File): List { + val solution = JsonParser.parseString(filterText).asJsonObject.getAsJsonObject("solution") + val solutionDir = File(filterDir, nativeSeparators(solution.get("path").asString)).normalize().parentFile + return solution.getAsJsonArray("projects") + .map { File(solutionDir, nativeSeparators(it.asString)).path } + .filter { it.endsWith("proj", ignoreCase = true) } + .distinct() +} + +/** + * The accelerated build for a whole solution - the equivalent of Rider's own Build/Rebuild + * Solution. [entryPoint] is the .sln/.slnx, or the .slnf when a solution filter is open (MSBuild + * builds a filter directly, honoring which projects it includes). + */ +internal fun solutionBuild( + tool: MsBuildTool, + entryPoint: String, + kind: DotNetBuildKind, + configuration: String, + platform: String, + restore: Boolean = false +): NativeBuild { + val arguments = tool.leadingArguments + listOf( + entryPoint, + "-t:${kind.msBuildTarget}", + "-p:Configuration=$configuration", + "-p:Platform=$platform", + ) + restoreArguments(restore) + COMMON_MSBUILD_ARGUMENTS + return NativeBuild(listOf(NativeCommand(tool.executable, arguments)), File(entryPoint).absoluteFile.parent, buildCache = false) +} + +/** + * The accelerated build for individually selected projects - the equivalent of Rider's own + * Build Selection, which also builds each selected project together with the projects it + * references. + * + * Built through the solution, asking it for just those projects by the per-project targets its + * build defines ([targetNames], from [solutionProjectTargets] - every one of [projectFiles] must + * be in it), rather than by building each project file directly. Only a solution build maps each + * project, and each project it references, to the configuration|platform the solution's active + * configuration assigns it: a project built directly passes its own -p:Configuration/-p:Platform + * on to its references unchanged, so a library the solution builds as Debug|Any CPU would be + * built as the app's Release|x64, into a different bin\ than Rider's own build uses. Going through + * the solution also sets the SolutionDir/SolutionPath/... properties build logic relies on, and + * builds all the selected projects in one parallel invocation. + */ +internal fun projectsBuild( + tool: MsBuildTool, + solutionFile: String, + projectFiles: List, + configuration: String, + platform: String, + targetNames: Map, + restore: Boolean = false +): NativeBuild { + val targets = projectFiles.map { targetNames.getValue(normalizedPath(it)) } + val arguments = tool.leadingArguments + listOf( + solutionFile, + "-t:${targets.joinToString(";")}", + "-p:Configuration=$configuration", + "-p:Platform=$platform", + ) + restoreArguments(restore) + COMMON_MSBUILD_ARGUMENTS + return NativeBuild(listOf(NativeCommand(tool.executable, arguments)), File(solutionFile).absoluteFile.parent, buildCache = false) +} + +private fun restoreArguments(restore: Boolean): List = if (restore) listOf(RESTORE_ARGUMENT) else emptyList() + +/** `Project("{type}") = "name", "path", "{guid}"` - solution folders included. */ +private val SLN_PROJECT_LINE = Regex( + """^Project\("\{[^}]*}"\)\s*=\s*"([^"]*)"\s*,\s*"([^"]*)"\s*,\s*"\{([^}]*)}"""", + RegexOption.MULTILINE +) + +private val SLN_NESTED_PROJECTS_SECTION = + Regex("""GlobalSection\(NestedProjects\)(.*?)EndGlobalSection""", RegexOption.DOT_MATCHES_ALL) + +/** `{child} = {parent}` - only meaningful inside [SLN_NESTED_PROJECTS_SECTION]: a project's + * ProjectDependencies section has lines of exactly the same shape. */ +private val SLN_NESTED_LINE = Regex("""\{([^}]*)}\s*=\s*\{([^}]*)}""") + +/** The characters MSBuild replaces with `_` in a solution's per-project target names. */ +private const val TARGET_NAME_CHARACTERS_TO_CLEANSE = "%$@;.()'" + +private fun cleansedTargetName(name: String): String = + name.map { if (it in TARGET_NAME_CHARACTERS_TO_CLEANSE) '_' else it }.joinToString("") + +/** + * The target a solution build defines for building just one of its projects, keyed by + * [normalizedPath] of each project file: the project's solution-folder path and its name, joined + * with `\`, each part with [TARGET_NAME_CHARACTERS_TO_CLEANSE] replaced by `_` - e.g. the "My.Lib" + * project in the "src" folder is `src\My_Lib`. That's the name MSBuild's solution build gives it, + * for a .sln (project names as written in it, folders from its NestedProjects section) and a .slnx + * (project file names, folders from each ``) alike. + */ +internal fun solutionProjectTargets(solutionText: String, solutionDir: File, isSlnx: Boolean): Map = + if (isSlnx) slnxProjectTargets(solutionText, solutionDir) else slnProjectTargets(solutionText, solutionDir) + +private fun slnProjectTargets(solutionText: String, solutionDir: File): Map { + class Entry(val name: String, val path: String) + + val entries = SLN_PROJECT_LINE.findAll(solutionText) + .associate { it.groupValues[3].uppercase() to Entry(it.groupValues[1], it.groupValues[2]) } + val parentOf = SLN_NESTED_PROJECTS_SECTION.find(solutionText)?.groupValues?.get(1) + ?.let { section -> SLN_NESTED_LINE.findAll(section).associate { it.groupValues[1].uppercase() to it.groupValues[2].uppercase() } } + .orEmpty() + + fun targetName(guid: String): String { + val parts = ArrayList() + var current: String? = guid + // Bounded by the number of entries, so a malformed (cyclic) NestedProjects can't hang this. + while (current != null && parts.size <= entries.size) { + parts += cleansedTargetName(entries.getValue(current).name) + current = parentOf[current]?.takeIf { it in entries } + } + return parts.asReversed().joinToString("\\") + } + + return entries.filterValues { it.path.endsWith("proj", ignoreCase = true) } + .entries.associate { (guid, entry) -> normalizedPath(File(solutionDir, nativeSeparators(entry.path)).path) to targetName(guid) } +} + +private fun slnxProjectTargets(solutionText: String, solutionDir: File): Map { + val projects = parseXml(solutionText).documentElement.getElementsByTagName("Project") + val result = HashMap() + for (i in 0 until projects.length) { + val project = projects.item(i) as Element + val path = project.attributeOrNull("Path") ?: continue + val projectFile = File(solutionDir, nativeSeparators(path)) + val folder = (project.parentNode as? Element)?.takeIf { it.tagName == "Folder" }?.attributeOrNull("Name") + val parts = folder?.split('/')?.filter { it.isNotEmpty() }.orEmpty() + projectFile.nameWithoutExtension + result[normalizedPath(projectFile.path)] = parts.joinToString("\\") { cleansedTargetName(it) } + } + return result +} + +private fun nativeSeparators(path: String): String = path.replace('\\', File.separatorChar).replace('/', File.separatorChar) + +private fun parseXml(text: String): Document { + val factory = DocumentBuilderFactory.newInstance().apply { + // A solution file never needs a DTD - refusing one rules out entity-expansion tricks. + setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) + isExpandEntityReferences = false + } + return factory.newDocumentBuilder().parse(InputSource(StringReader(text))) +} + +private fun Element.attributeOrNull(name: String): String? = getAttribute(name).takeIf { it.isNotEmpty() } + +/** Canonical, case-insensitive form of a path, so a project path from Rider's model matches the + * one resolved from the solution regardless of `..`/separator/casing differences. */ +internal fun normalizedPath(path: String): String = + File(path).absoluteFile.normalize().path.replace('\\', '/').lowercase() diff --git a/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildLocator.kt b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildLocator.kt new file mode 100644 index 0000000..24aa626 --- /dev/null +++ b/src/main/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildLocator.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.incredibuild.ibplugin.settings.IncredibuildSettings +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.execution.configurations.PathEnvironmentVariableUtil +import com.intellij.execution.util.ExecUtil +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.util.SystemInfo +import java.io.File + +private val LOG = Logger.getInstance("com.incredibuild.ibplugin.actions.dotnet.MsBuildLocator") + +private const val VSWHERE_TIMEOUT_MS = 15_000 + +private const val MSBUILD_COMPONENT = "Microsoft.Component.MSBuild" + +/** + * The ".NET SDK" Visual Studio component. Without it, a Visual Studio install's MSBuild has no + * .NET SDK resolver (Microsoft.DotNet.MSBuildSdkResolver), so it can't build any SDK-style + * project: every `` fails with MSB4236 "The SDK + * 'Microsoft.NET.Sdk' specified could not be found". A C++-only Visual Studio install still has + * Microsoft.Component.MSBuild, though, so requiring MSBuild alone isn't enough to tell them apart. + */ +private const val DOTNET_SDK_COMPONENT = "Microsoft.NetCore.Component.SDK" + +/** What [MsBuildLocator.locate] came back with. */ +internal sealed interface MsBuildLocation { + class Found(val tool: MsBuildTool) : MsBuildLocation + + /** [reason] is shown to the user as-is. */ + class NotFound(val reason: String) : MsBuildLocation +} + +/** + * Finds the MSBuild to run accelerated .NET builds with. + * + * Rider picks its own MSBuild in its backend settings (Build, Execution, Deployment | Toolset and + * Build), which the frontend this plugin runs in has no API to read - so this follows the same + * preference Rider's own automatic choice does instead, and lets the user override it in + * Settings | Tools | Incredibuild when that isn't the one they use: + * 1. The MSBuild configured in Settings | Tools | Incredibuild, if any. + * 2. Windows: the newest Visual Studio / Build Tools MSBuild.exe that has the .NET SDK component, + * found the way Microsoft documents for it (vswhere). It builds both SDK-style and classic + * .NET Framework projects - and mixed solutions containing C++ ones - which `dotnet msbuild` + * can't. Just "the newest Visual Studio" isn't enough: a C++-only install can be newer than + * the one with .NET in it - see [DOTNET_SDK_COMPONENT]. + * 3. The .NET SDK's `dotnet msbuild`, from PATH or a standard install location. + * 4. Windows: any Visual Studio MSBuild.exe, for classic .NET Framework-only solutions. + */ +internal object MsBuildLocator { + + /** + * What [detect] last found, so a build doesn't pay for vswhere (up to [VSWHERE_TIMEOUT_MS]) + * every time. Kept for the session, but dropped by [clearCache] when the MSBuild setting + * changes, and re-detected if the executable has since disappeared (e.g. Visual Studio was + * uninstalled). "Not found" is never cached, so installing MSBuild takes effect on the next + * build without restarting the IDE. + */ + @Volatile + private var detected: MsBuildTool? = null + + fun clearCache() { + detected = null + } + + fun locate(): MsBuildLocation { + val configured = IncredibuildSettings.getInstance().msBuildPath + if (configured.isNotBlank()) { + return if (File(configured).isFile) { + MsBuildLocation.Found(MsBuildTool.fromPath(configured)) + } else { + MsBuildLocation.NotFound( + "The MSBuild executable configured in Settings | Tools | Incredibuild doesn't exist:\n$configured" + ) + } + } + + detected?.takeIf { File(it.executable).isFile }?.let { return MsBuildLocation.Found(it) } + return detect().also { if (it is MsBuildLocation.Found) detected = it.tool } + } + + private fun detect(): MsBuildLocation { + if (SystemInfo.isWindows) { + findVisualStudioMsBuild(MSBUILD_COMPONENT, DOTNET_SDK_COMPONENT) + ?.let { return MsBuildLocation.Found(MsBuildTool(it, emptyList(), it)) } + } + findDotNet()?.let { return MsBuildLocation.Found(MsBuildTool.fromPath(it)) } + // Last resort: a Visual Studio without the .NET SDK component still builds classic + // (non-SDK-style) .NET Framework projects, which is better than finding nothing at all. + if (SystemInfo.isWindows) { + findVisualStudioMsBuild(MSBUILD_COMPONENT) + ?.let { return MsBuildLocation.Found(MsBuildTool(it, emptyList(), it)) } + } + + return MsBuildLocation.NotFound( + "No MSBuild was found on this machine: neither Visual Studio / Build Tools MSBuild nor the .NET SDK's " + + "dotnet command. Install one, or point Settings | Tools | Incredibuild at the MSBuild to use." + ) + } + + /** The newest Visual Studio / Build Tools MSBuild.exe whose install has all [requiredComponents]. */ + private fun findVisualStudioMsBuild(vararg requiredComponents: String): String? { + val programFilesX86 = System.getenv("ProgramFiles(x86)") ?: return null + val vswhere = File(programFilesX86, "Microsoft Visual Studio\\Installer\\vswhere.exe") + if (!vswhere.isFile) return null + return try { + val output = ExecUtil.execAndGetOutput( + GeneralCommandLine(vswhere.absolutePath, "-latest", "-prerelease", "-products", "*", "-requires") + // vswhere's -requires takes several ids and, without -requiresAny, matches only + // installs that have every one of them. + .withParameters(*requiredComponents) + .withParameters("-find", "MSBuild\\**\\Bin\\MSBuild.exe"), + VSWHERE_TIMEOUT_MS + ) + if (output.exitCode != 0) return null + output.stdoutLines.map { it.trim() }.firstOrNull { it.isNotEmpty() && File(it).isFile } + } catch (e: Exception) { + LOG.warn("Could not query vswhere for MSBuild", e) + null + } + } + + private fun findDotNet(): String? { + val executableName = if (SystemInfo.isWindows) "dotnet.exe" else "dotnet" + PathEnvironmentVariableUtil.findInPath(executableName)?.let { return it.absolutePath } + + val candidates = buildList { + System.getenv("DOTNET_ROOT")?.let { add(File(it, executableName)) } + if (SystemInfo.isWindows) { + System.getenv("ProgramFiles")?.let { add(File(it, "dotnet\\$executableName")) } + } else { + add(File("/usr/share/dotnet/dotnet")) + add(File("/usr/lib/dotnet/dotnet")) + add(File("/usr/local/share/dotnet/dotnet")) + } + add(File(System.getProperty("user.home"), ".dotnet${File.separator}$executableName")) + } + return candidates.firstOrNull { it.isFile }?.absolutePath + } +} diff --git a/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildConfigurable.kt b/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildConfigurable.kt index 117af75..dab7f90 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildConfigurable.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildConfigurable.kt @@ -15,36 +15,101 @@ */ package com.incredibuild.ibplugin.settings +import com.incredibuild.ibplugin.actions.BuildFlavor +import com.incredibuild.ibplugin.actions.IncredibuildInstallation +import com.incredibuild.ibplugin.actions.InstallationStatus +import com.intellij.icons.AllIcons import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.extensions.PluginId +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.options.BoundConfigurable +import com.intellij.ui.components.JBLabel +import com.intellij.ui.dsl.builder.AlignX import com.intellij.ui.dsl.builder.bindIntText import com.intellij.ui.dsl.builder.bindSelected +import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel private val RUST_PLUGIN_ID = PluginId.getId("com.jetbrains.rust") +private val CMAKE_PLUGIN_ID = PluginId.getId("com.intellij.cmake") -/** Settings > Tools > Incredibuild - the "-j"/"--parallel" job count and (RustRover only) - * IDE build acceleration. */ +// Registered by rider-support.xml, so present exactly when the .NET half is loaded. Checked instead +// of the com.intellij.modules.rider alias it's gated on: PluginManagerCore.isLoaded() only matches +// real plugin ids, not module aliases, and PlatformUtils.isRider() is internal API. +private const val RIDER_ACTION_ID = "Incredibuild.DotNet.BuildSolution" + +/** Settings > Tools > Incredibuild - the installation in use, the "-j"/"--parallel" job count, IDE + * build acceleration (RustRover, CLion and Rider), and (Rider only) the MSBuild to use. */ class IncredibuildConfigurable : BoundConfigurable("Incredibuild") { + private val isRust = PluginManagerCore.isLoaded(RUST_PLUGIN_ID) + private val isCMake = PluginManagerCore.isLoaded(CMAKE_PLUGIN_ID) + private val isRider = ActionManager.getInstance().getAction(RIDER_ACTION_ID) != null + + private val installationLabel = JBLabel() + override fun createPanel() = panel { + row("Installation:") { + cell(installationLabel).align(AlignX.FILL) + button("Re-check") { refreshInstallation() } + } // "-j" for a Cargo build, "--parallel" for a CMake one - same setting either way. row("Parallel jobs:") { intTextField(1..999).bindIntText(IncredibuildSettings.getInstance()::jobCount) } - // Only meaningful where the Rust plugin's own ProjectTaskRunner exists for this to - // stand in for (see rust-support.xml) - in CLion this checkbox would do nothing at - // all, so it's hidden there rather than shown as a dead setting. + // Only meaningful where there is an IDE build for this to stand in for (see + // rust-support.xml, cmake-support.xml and rider-support.xml) - anywhere else this + // checkbox would do nothing at all, so it's hidden rather than shown as a dead setting. row { - checkBox("Accelerate the IDE's own build actions") + checkBox("Use Incredibuild for the IDE's own Build and Rebuild actions") .bindSelected(IncredibuildSettings.getInstance()::accelerateIdeBuilds) .comment( - "When on, Build Project and the build that runs before Run and " + - "Test go through Incredibuild instead of Cargo.
" + - "When off, those actions behave exactly as they do without this plugin.
" + + "When on, the IDE's standard Build and Rebuild actions go through Incredibuild - " + + "from the Build menu, the toolbar, keyboard shortcuts, and the build that runs before a " + + "run configuration starts.
" + + "If Incredibuild is not installed, not licensed or too old, those builds run normally " + + "instead and a notification explains why.
" + "The actions in the Incredibuild menu always use Incredibuild, either way." ) - }.visible(PluginManagerCore.isLoaded(RUST_PLUGIN_ID)) + }.visible(isRust || isCMake || isRider) + // Only meaningful for the .NET actions (see rider-support.xml), so only shown in Rider. + row("MSBuild executable:") { + textFieldWithBrowseButton( + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Select MSBuild or dotnet") + ) + .align(AlignX.FILL) + .bindText(IncredibuildSettings.getInstance()::msBuildPath) + .comment( + "Used by the .NET builds that go through Incredibuild. Leave empty to detect it " + + "automatically: Visual Studio's MSBuild.exe on Windows if it has the .NET SDK component, otherwise " + + "dotnet msbuild. Either MSBuild.exe or the dotnet executable can be given." + ) + }.visible(isRider) + }.also { refreshInstallation() } + + /** + * Shows the installation the plugin would use, checked on a background thread - locating it + * reads the registry and queries the license, neither of which may block the settings dialog. + */ + private fun refreshInstallation() { + installationLabel.icon = AllIcons.Process.Step_1 + installationLabel.text = "Checking..." + // Validated against the strictest flavor present: Rust builds need a minimum version, + // native (CMake/MSBuild) ones don't. + val flavor = if (isRust) BuildFlavor.RUST else BuildFlavor.NATIVE + ApplicationManager.getApplication().executeOnPooledThread { + val status = IncredibuildInstallation.check(flavor) + ApplicationManager.getApplication().invokeLater({ + installationLabel.icon = when (status) { + is InstallationStatus.Ready -> AllIcons.General.InspectionsOK + InstallationStatus.NotInstalled -> AllIcons.General.Error + else -> AllIcons.General.Warning + } + installationLabel.text = IncredibuildInstallation.describe(status) + }, ModalityState.any()) + } } } diff --git a/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildSettings.kt b/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildSettings.kt index ba1c9e1..664f2b6 100644 --- a/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildSettings.kt +++ b/src/main/kotlin/com/incredibuild/ibplugin/settings/IncredibuildSettings.kt @@ -15,6 +15,7 @@ */ package com.incredibuild.ibplugin.settings +import com.incredibuild.ibplugin.actions.dotnet.MsBuildLocator import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.components.PersistentStateComponent import com.intellij.openapi.components.Service @@ -30,11 +31,19 @@ class IncredibuildSettings : PersistentStateComponent + + + + + + @@ -59,10 +69,11 @@ - + com.jetbrains.rust com.intellij.cmake + com.intellij.modules.rider diff --git a/src/main/resources/META-INF/rider-support.xml b/src/main/resources/META-INF/rider-support.xml new file mode 100644 index 0000000..f1639b2 --- /dev/null +++ b/src/main/resources/META-INF/rider-support.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallationTest.kt b/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallationTest.kt new file mode 100644 index 0000000..277f8ea --- /dev/null +++ b/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildInstallationTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Covers [IncredibuildInstallation.parseLicenseQuery] (reading `BuildConsole /QueryLicense`). */ +class IncredibuildInstallationTest { + + private fun queryOutput(initiator: String, ciInitiator: String) = """ + ----------------------------------------------------------------------- + Build Acceleration Console 10.39.0 (build 18726) + ----------------------------------------------------------------------- + + Agent info: + InitiatorRegType $initiator + CIInitiatorRegType $ciInitiator + HelperRegType Floating + HelperCores 16 + """.trimIndent().replace("\n", "\r\n") + + @Test + fun `a floating initiator license is licensed`() { + assertEquals(true, IncredibuildInstallation.parseLicenseQuery(queryOutput("Floating", "None"))) + } + + @Test + fun `a CI-only initiator license is licensed`() { + assertEquals(true, IncredibuildInstallation.parseLicenseQuery(queryOutput("None", "Pinned"))) + } + + @Test + fun `no initiator license of either kind is unlicensed`() { + assertEquals(false, IncredibuildInstallation.parseLicenseQuery(queryOutput("None", "None"))) + } + + @Test + fun `output without registration lines is inconclusive`() { + assertNull(IncredibuildInstallation.parseLicenseQuery("Fatal Error: could not connect to the Coordinator")) + } +} diff --git a/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunnerTest.kt b/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunnerTest.kt index 12728ef..7e28d85 100644 --- a/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunnerTest.kt +++ b/src/test/kotlin/com/incredibuild/ibplugin/actions/IncredibuildRunnerTest.kt @@ -55,6 +55,24 @@ class IncredibuildRunnerTest { assertEquals("\"%%TEMP%% dir\"", IncredibuildRunner.cmdQuoted("%TEMP% dir")) } + @Test + fun `cmdQuoted doubles trailing backslashes so they don't escape the closing quote`() { + assertEquals( + "\"-p:SolutionDir=C:\\My Projects\\App\\\\\"", + IncredibuildRunner.cmdQuoted("-p:SolutionDir=C:\\My Projects\\App\\") + ) + // Unquoted, a trailing backslash is just a literal one and needs nothing. + assertEquals("-p:SolutionDir=C:\\App\\", IncredibuildRunner.cmdQuoted("-p:SolutionDir=C:\\App\\")) + } + + @Test + fun `cmdQuoted quotes cmd operator characters and empty values`() { + assertEquals("\"C:\\R&D\\App.sln\"", IncredibuildRunner.cmdQuoted("C:\\R&D\\App.sln")) + assertEquals("\"C:\\Apps (old)\\App.sln\"", IncredibuildRunner.cmdQuoted("C:\\Apps (old)\\App.sln")) + assertEquals("\"a^b\"", IncredibuildRunner.cmdQuoted("a^b")) + assertEquals("\"\"", IncredibuildRunner.cmdQuoted("")) + } + @Test fun `VALID_SHELL_IDENTIFIER accepts ordinary environment variable names`() { assertTrue(VALID_SHELL_IDENTIFIER.matches("PATH")) diff --git a/src/test/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommandTest.kt b/src/test/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommandTest.kt new file mode 100644 index 0000000..cbcafd9 --- /dev/null +++ b/src/test/kotlin/com/incredibuild/ibplugin/actions/dotnet/MsBuildCommandTest.kt @@ -0,0 +1,372 @@ +/* + * Copyright 2026 Incredibuild + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.incredibuild.ibplugin.actions.dotnet + +import com.incredibuild.ibplugin.actions.IncredibuildRunner +import com.incredibuild.ibplugin.actions.IncredibuildRunner.NativeBuild +import com.incredibuild.ibplugin.actions.IncredibuildRunner.NativeCommand +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +/** Covers how the .NET actions turn Rider's state into MSBuild invocations, and the wrapper + * scripts those invocations are run through. */ +class MsBuildCommandTest { + + private val solutionDir = File("work", "Sln").absoluteFile + private val solutionFile = File(solutionDir, "App.sln").path + private val appProject = File(solutionDir, "src${File.separator}App${File.separator}App.csproj").path + private val libProject = File(solutionDir, "Lib${File.separator}Lib.csproj").path + + private val msBuild = MsBuildTool("C:\\VS\\MSBuild.exe", emptyList(), "MSBuild") + + private val sln = """ + Microsoft Visual Studio Solution File, Format Version 12.00 + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{33333333-3333-3333-3333-333333333333}" + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "src\App\App.csproj", "{11111111-1111-1111-1111-111111111111}" + ProjectSection(ProjectDependencies) = postProject + {22222222-2222-2222-2222-222222222222} = {22222222-2222-2222-2222-222222222222} + EndProjectSection + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "My.Lib", "Lib\Lib.csproj", "{22222222-2222-2222-2222-222222222222}" + EndProject + Global + GlobalSection(NestedProjects) = preSolution + {11111111-1111-1111-1111-111111111111} = {33333333-3333-3333-3333-333333333333} + EndGlobalSection + EndGlobal + """.trimIndent() + + private val targets = mapOf(normalizedPath(appProject) to "src\\App", normalizedPath(libProject) to "My_Lib") + + @Test + fun `MsBuildTool fromPath runs dotnet through its msbuild verb`() { + val tool = MsBuildTool.fromPath("C:\\Program Files\\dotnet\\dotnet.exe") + assertEquals(listOf("msbuild"), tool.leadingArguments) + } + + @Test + fun `MsBuildTool fromPath runs MSBuild exe directly`() { + assertEquals(emptyList(), MsBuildTool.fromPath("C:\\VS\\MSBuild.exe").leadingArguments) + } + + @Test + fun `solution build passes the target and active configuration unchanged`() { + val build = solutionBuild(msBuild, solutionFile, DotNetBuildKind.REBUILD_SOLUTION, "Debug", "Any CPU") + val command = build.commands.single() + assertEquals(msBuild.executable, command.executable) + assertEquals( + listOf(solutionFile, "-t:Rebuild", "-p:Configuration=Debug", "-p:Platform=Any CPU"), + command.arguments.take(4) + ) + assertTrue(command.arguments.containsAll(COMMON_MSBUILD_ARGUMENTS)) + assertEquals(solutionDir.path, build.workingDirectory) + } + + @Test + fun `solution build through dotnet puts msbuild first`() { + val dotnet = MsBuildTool.fromPath("/usr/bin/dotnet") + val command = solutionBuild(dotnet, solutionFile, DotNetBuildKind.BUILD_SOLUTION, "Debug", "Any CPU").commands.single() + assertEquals(listOf("msbuild", solutionFile, "-t:Build"), command.arguments.take(3)) + } + + @Test + fun `sln project targets follow solution folders and MSBuild's name cleansing`() { + // App's ProjectDependencies line has the same {guid} = {guid} shape as a nesting one - it + // must not make Lib App's parent. + assertEquals(targets, solutionProjectTargets(sln, solutionDir, isSlnx = false)) + } + + @Test + fun `sln project targets nest folders inside folders`() { + val nested = """ + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{33333333-3333-3333-3333-333333333333}" + EndProject + Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "my.dir (x)", "my.dir (x)", "{44444444-4444-4444-4444-444444444444}" + EndProject + Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "Lib\Lib.csproj", "{22222222-2222-2222-2222-222222222222}" + EndProject + Global + GlobalSection(NestedProjects) = preSolution + {44444444-4444-4444-4444-444444444444} = {33333333-3333-3333-3333-333333333333} + {22222222-2222-2222-2222-222222222222} = {44444444-4444-4444-4444-444444444444} + EndGlobalSection + EndGlobal + """.trimIndent() + assertEquals( + mapOf(normalizedPath(libProject) to "src\\my_dir _x_\\Lib"), + solutionProjectTargets(nested, solutionDir, isSlnx = false) + ) + } + + @Test + fun `slnx project targets use folder paths and project file names`() { + val slnx = """ + + + + + + + """.trimIndent() + assertEquals( + mapOf(normalizedPath(libProject) to "src\\my_dir _x_\\Lib", normalizedPath(appProject) to "App"), + solutionProjectTargets(slnx, solutionDir, isSlnx = true) + ) + } + + @Test + fun `projects build goes through the solution with one target per project`() { + // Through the solution, so MSBuild maps every project - and every project it references - + // to the solution's own configuration for it, instead of passing the selected one on. + val build = projectsBuild(msBuild, solutionFile, listOf(appProject, libProject), "Release", "x64", targets) + val command = build.commands.single() + assertEquals( + listOf(solutionFile, "-t:src\\App;My_Lib", "-p:Configuration=Release", "-p:Platform=x64"), + command.arguments.take(4) + ) + assertTrue(command.arguments.containsAll(COMMON_MSBUILD_ARGUMENTS)) + assertEquals(solutionDir.path, build.workingDirectory) + } + + @Test + fun `builds leave restore to Rider by default`() { + val solution = solutionBuild(msBuild, solutionFile, DotNetBuildKind.BUILD_SOLUTION, "Debug", "Any CPU") + val projects = projectsBuild(msBuild, solutionFile, listOf(appProject), "Debug", "Any CPU", targets) + assertFalse(solution.commands.single().arguments.contains(RESTORE_ARGUMENT)) + assertFalse(projects.commands.single().arguments.contains(RESTORE_ARGUMENT)) + } + + @Test + fun `builds restore when asked to`() { + val solution = solutionBuild(msBuild, solutionFile, DotNetBuildKind.BUILD_SOLUTION, "Debug", "Any CPU", restore = true) + val projects = + projectsBuild(msBuild, solutionFile, listOf(appProject), "Debug", "Any CPU", targets, restore = true) + assertTrue(solution.commands.single().arguments.contains(RESTORE_ARGUMENT)) + assertTrue(projects.commands.single().arguments.contains(RESTORE_ARGUMENT)) + } + + private fun withTempDir(block: (File) -> Unit) { + val root = Files.createTempDirectory("msbuild-restore-test").toFile() + try { + block(root) + } finally { + root.deleteRecursively() + } + } + + private fun File.project(relativePath: String, text: String) = + File(this, relativePath).apply { parentFile.mkdirs(); writeText(text) } + + private fun File.assetsFile(relativeDir: String) = + File(this, "$relativeDir/project.assets.json").apply { parentFile.mkdirs(); writeText("{}") } + + private val sdkProject = """""" + + @Test + fun `needsRestore is true only when an SDK-style project has no assets file`() = withTempDir { root -> + val restored = root.project("Restored/Restored.csproj", sdkProject) + root.assetsFile("Restored/obj") + val fresh = root.project("Fresh/Fresh.csproj", sdkProject) + + assertFalse(needsRestore(listOf(restored.path))) + assertTrue(needsRestore(listOf(restored.path, fresh.path))) + assertFalse(needsRestore(emptyList())) + } + + @Test + fun `needsRestore ignores projects that never get an assets file`() = withTempDir { root -> + val classic = root.project("Classic/Classic.csproj", """""") + val shared = root.project("Shared/Shared.shproj", """""") + val cpp = root.project("Native/Native.vcxproj", """""") + val missing = File(root, "Gone/Gone.csproj").path + + assertFalse(needsRestore(listOf(classic.path, shared.path, cpp.path, missing))) + } + + @Test + fun `needsRestore counts classic projects using PackageReference`() = withTempDir { root -> + val classic = root.project( + "Classic/Classic.csproj", + """""" + ) + assertTrue(needsRestore(listOf(classic.path))) + root.assetsFile("Classic/obj") + assertFalse(needsRestore(listOf(classic.path))) + } + + @Test + fun `needsRestore finds the assets file in the artifacts output layout`() = withTempDir { root -> + val app = root.project("src/App/App.csproj", sdkProject) + assertTrue(needsRestore(listOf(app.path))) + root.assetsFile("artifacts/obj/App") + assertFalse(needsRestore(listOf(app.path))) + } + + @Test + fun `solutionProjectFiles lists the projects of an sln`() { + assertEquals( + listOf(appProject, libProject), + solutionProjectFiles(sln, solutionDir) + ) + } + + @Test + fun `solutionProjectFiles lists the projects of an slnx and skips non-project entries`() { + val slnx = """ + + + + + + + + """.trimIndent() + assertEquals(listOf(appProject, libProject), solutionProjectFiles(slnx, solutionDir)) + } + + @Test + fun `solutionFilterProjectFiles lists only the filter's projects, relative to the solution`() { + // The filter in a subfolder of the solution, as Rider allows: its solution path is relative + // to the filter, its projects to the solution - which is how MSBuild resolves them. + val filterDir = File(solutionDir, "filters") + val slnf = """ + { + "solution": { + "path": "..\\Sln.sln", + "projects": [ + "src\\App\\App.csproj" + ] + } + } + """.trimIndent() + assertEquals(listOf(appProject), solutionFilterProjectFiles(slnf, filterDir)) + } + + @Test + fun `solutionFilterProjectFiles accepts forward slashes`() { + val slnf = """{"solution": {"path": "Sln.sln", "projects": ["src/App/App.csproj", "Lib/Lib.csproj"]}}""" + assertEquals(listOf(appProject, libProject), solutionFilterProjectFiles(slnf, solutionDir)) + } + + @Test + fun `a filter excluding the only unrestored project doesn't need restore`() = withTempDir { root -> + // App is restored, Unrestored never was; a filter with only App mustn't ask for -restore, + // though the whole solution would. + val sdkProject = """""" + val app = File(root, "App/App.csproj").apply { parentFile.mkdirs(); writeText(sdkProject) } + File(root, "App/obj/project.assets.json").apply { parentFile.mkdirs(); writeText("{}") } + val unrestored = File(root, "Unrestored/Unrestored.csproj").apply { parentFile.mkdirs(); writeText(sdkProject) } + val slnf = """{"solution": {"path": "Sln.sln", "projects": ["App\\App.csproj"]}}""" + + assertFalse(needsRestore(solutionFilterProjectFiles(slnf, root))) + assertTrue(needsRestore(listOf(app.path, unrestored.path))) + } + + @Test + fun `windows wrapper stops at the first failing command`() { + val build = NativeBuild( + listOf(NativeCommand("msbuild", listOf("a b.csproj")), NativeCommand("msbuild", listOf("c.csproj"))), + "." + ) + assertEquals( + "@echo off\r\n" + + "msbuild \"a b.csproj\"\r\n" + + "if not \"%ERRORLEVEL%\"==\"0\" exit /b %ERRORLEVEL%\r\n" + + "msbuild c.csproj\r\n" + + "exit /b %ERRORLEVEL%\r\n", + IncredibuildRunner.windowsWrapperScript(build) + ) + } + + @Test + fun `windows wrapper for a single command is unchanged from the CMake one`() { + val build = NativeBuild(listOf(NativeCommand("cmake", listOf("--build", "out"))), ".", mapOf("A" to "1")) + assertEquals( + "@echo off\r\nset \"A=1\"\r\ncmake --build out\r\nexit /b %ERRORLEVEL%\r\n", + IncredibuildRunner.windowsWrapperScript(build) + ) + } + + @Test + fun `linux wrapper chains several commands inside one ib_console`() { + val build = NativeBuild( + listOf(NativeCommand("dotnet", listOf("msbuild", "a.csproj")), NativeCommand("dotnet", listOf("msbuild", "b.csproj"))), + "." + ) + val script = IncredibuildRunner.linuxWrapperScript("Sln", "/opt/incredibuild/bin/ib_console", build) + assertTrue( + script, + script.endsWith( + "-- /bin/sh -c ''\\''dotnet'\\'' '\\''msbuild'\\'' '\\''a.csproj'\\'' && " + + "'\\''dotnet'\\'' '\\''msbuild'\\'' '\\''b.csproj'\\''' 2>&1\n" + ) + ) + } + + @Test + fun `banner project list is capped for large selections`() { + assertEquals("App", DotNetBuildRunner.cappedProjectList(listOf("App"))) + // Three still fit: "+1 more" would be no shorter than the name it hides. + assertEquals("App, Lib, Tests", DotNetBuildRunner.cappedProjectList(listOf("App", "Lib", "Tests"))) + assertEquals( + "P0, P1 +12 more", + DotNetBuildRunner.cappedProjectList((0 until 14).map { "P$it" }) + ) + } + + @Test + fun `msbuild builds leave Build Cache off`() { + val tool = MsBuildTool("msbuild", emptyList(), "MSBuild") + assertFalse(solutionBuild(tool, "Sln.sln", DotNetBuildKind.BUILD_SOLUTION, "Debug", "Any CPU").buildCache) + assertFalse( + projectsBuild(tool, "Sln.sln", listOf(appProject), "Debug", "Any CPU", mapOf(normalizedPath(appProject) to "src\\App")) + .buildCache + ) + } + + @Test + fun `linux wrapper passes the build cache options only when the build wants them`() { + val command = listOf(NativeCommand("cmake", listOf("--build", "out"))) + val cached = IncredibuildRunner.linuxWrapperScript("P", "/ib", NativeBuild(command, ".")) + val uncached = IncredibuildRunner.linuxWrapperScript("P", "/ib", NativeBuild(command, ".", buildCache = false)) + assertTrue(cached, cached.contains(" --ib-quiet --build-cache-local-shared --build-cache-basedir=\"\$PWD\" -- ")) + assertTrue(uncached, uncached.contains(" --ib-quiet -- ")) + assertFalse(uncached, uncached.contains("build-cache")) + } + + @Test + fun `windows launcher passes BUILDCACHELOCAL only when the build wants it`() { + val cached = IncredibuildRunner.windowsLauncherScript("BuildConsole.exe", "w.cmd", "P", buildCache = true) + val uncached = IncredibuildRunner.windowsLauncherScript("BuildConsole.exe", "w.cmd", "P", buildCache = false) + assertEquals( + "& \"BuildConsole.exe\" '/command=\"w.cmd\"' '/title=\"P\"' /BUILDCACHELOCAL=ON\nexit \$LASTEXITCODE\n", + cached + ) + assertEquals("& \"BuildConsole.exe\" '/command=\"w.cmd\"' '/title=\"P\"'\nexit \$LASTEXITCODE\n", uncached) + } + + @Test + fun `linux wrapper runs a single command directly`() { + val build = NativeBuild(listOf(NativeCommand("cmake", listOf("--build", "out"))), ".") + val script = IncredibuildRunner.linuxWrapperScript("P", "/ib", build) + assertTrue(script, script.endsWith("-- 'cmake' '--build' 'out' 2>&1\n")) + } +}