From aa695a710d9c7a828bb6dd61ab5b2b53739a4492 Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 18 Sep 2026 13:05:06 +0100 Subject: [PATCH 1/5] Make SdkVersion the single source of the targeted Rider version Testing against a new Rider (e.g. an EAP) used to mean editing several values in several files, in two version formats, some of which could only be found by failing a restore. Now it's one line: in Directory.Build.props (exact NuGet form). - Gradle derives ProductVersion from it (2026.3.0-eap02 -> 2026.3-EAP2-SNAPSHOT), read via providers.fileContents so the configuration cache notices edits; -PProductVersion still overrides. `./gradlew riderVersions -q` shows both. - WaveVersion is derived by regex rather than fixed substrings; the unused UpperWaveVersion is gone. - JetBrains.Lifetimes/RdFramework/Annotations are no longer referenced directly: the ReferenceSdkCoreLibraries target compiles against the versions the SDK's own graph resolved (the SDK pins them exactly; main floated them to 2026.1.3 against the SDK's 2026.1.2). The ReSharper .nupkg no longer declares them as (leaked) dependencies. - SdkVersion is pinned exactly (2026.1.5.2, what 2026.1.* resolved to); the frontend now builds against the same Rider patch as the backend instead of 2026.1.0. - Plugin/tool versions live only in gradle/libs.versions.toml; the stale duplicates in gradle.properties and settings.gradle.kts are removed (they disagreed with what the build actually used). - :protocol:rdgen works again: RdGenTask is opted out of the configuration cache (it reads Task.project at execution time). Regenerated output is unchanged. See docs/rider-version.md. Co-Authored-By: Claude Opus 5 (1M context) --- Directory.Build.props | 41 +++++++++++++++++++++++++++------------ build.gradle.kts | 39 ++++++++++++++++++++++++++++++++++--- docs/rider-version.md | 40 ++++++++++++++++++++++++++++++++++++++ gradle.properties | 13 ++----------- gradle/libs.versions.toml | 9 ++++++++- protocol/build.gradle.kts | 6 +++++- settings.gradle.kts | 23 ---------------------- 7 files changed, 120 insertions(+), 51 deletions(-) create mode 100644 docs/rider-version.md diff --git a/Directory.Build.props b/Directory.Build.props index 24ebf08..6d5994a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,10 @@ - 2026.1.* + + 2026.1.5.2 Rimworld Development Environment Bring the intelligence of your IDE to Rimworld XML files. Use information backed by Rimworlds DLL file to autocomplete your XML, Ctrl+Click into the C# that your XML gets translated into and see what options you have when adding items in your mods! @@ -30,19 +33,20 @@ - $(SdkVersion.Substring(2,2))$(SdkVersion.Substring(5,1)) - $(WaveVersionBase).0.0$(SdkVersion.Substring(8)) - $(WaveVersionBase).9999.0 + + $([System.Text.RegularExpressions.Regex]::Replace($(SdkVersion), '^\d\d(\d\d)\.(\d+)\..*$', '$1$2')) + $(WaveVersionBase).0.0 @@ -80,4 +81,20 @@ + + + + <_SdkCoreLibrary Include="@(RuntimeCopyLocalItems)" + Condition="'%(RuntimeCopyLocalItems.Extension)' == '.dll' And ('%(RuntimeCopyLocalItems.NuGetPackageId)' == 'JetBrains.Lifetimes' Or '%(RuntimeCopyLocalItems.NuGetPackageId)' == 'JetBrains.RdFramework' Or '%(RuntimeCopyLocalItems.NuGetPackageId)' == 'JetBrains.Annotations')" /> + + + + + diff --git a/build.gradle.kts b/build.gradle.kts index 346e201..f16b1c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,8 +6,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("java") alias(libs.plugins.kotlinJvm) - id("org.jetbrains.intellij.platform") version "2.15.0" // https://github.com/JetBrains/gradle-intellij-plugin/releases - id("me.filippov.gradle.jvm.wrapper") version "0.16.0" + alias(libs.plugins.intellijPlatform) + alias(libs.plugins.gradleJvmWrapper) } @@ -24,7 +24,40 @@ extra["isWindows"] = isWindows val DotnetSolution: String by project val BuildConfiguration: String by project -val ProductVersion: String by project + +// The Rider version comes from one place: in Directory.Build.props (NuGet form). Read through a provider so +// the configuration cache is invalidated when that file changes. -PProductVersion=... still overrides it. +val SdkVersion: String = providers.fileContents(layout.projectDirectory.file("Directory.Build.props")).asText + .map { props -> + Regex("""\s*([^<\s]+)\s*""").find(props)?.groupValues?.get(1) + ?: throw GradleException("No found in Directory.Build.props") + } + .get() + +// Rider's Maven artifacts name the same builds differently from NuGet: +// 2026.3.0-eap02 -> 2026.3-EAP2-SNAPSHOT, 2026.2.0-rc01 -> 2026.2-RC1-SNAPSHOT, 2026.2.0 -> 2026.2, 2026.1.5.2 -> 2026.1.5.2 +fun riderMavenVersion(sdkVersion: String): String { + Regex("""^(\d+\.\d+)\.0-(eap|rc)0*(\d+)$""").matchEntire(sdkVersion)?.let { m -> + return "${m.groupValues[1]}-${m.groupValues[2].uppercase()}${m.groupValues[3]}-SNAPSHOT" + } + Regex("""^(\d+\.\d+)\.0$""").matchEntire(sdkVersion)?.let { return it.groupValues[1] } + return sdkVersion +} + +val ProductVersion: String = providers.gradleProperty("ProductVersion").orNull ?: riderMavenVersion(SdkVersion) + +// ./gradlew riderVersions -q: what this checkout targets, in both formats. +val riderVersions by tasks.registering { + group = "help" + description = "Prints the Rider version this build targets (NuGet SdkVersion and IntelliJ Platform ProductVersion)." + val sdk = SdkVersion + val product = ProductVersion + doLast { + println("SdkVersion (NuGet, Directory.Build.props): $sdk") + println("ProductVersion (IntelliJ Platform/Maven): $product") + } +} + val DotnetPluginId: String by project val RiderPluginId: String by project val PublishToken: String by project diff --git a/docs/rider-version.md b/docs/rider-version.md new file mode 100644 index 0000000..e0391b9 --- /dev/null +++ b/docs/rider-version.md @@ -0,0 +1,40 @@ +# Targeting a Rider version + +**One line:** `` at the top of the root `Directory.Build.props`, exact and in NuGet form (`2026.1.5.2`, +`2026.2.0-rc01`, `2026.3.0-eap02`). Everything else follows from it: + +| Derived | How | +|---|---| +| `JetBrains.Lifetimes` / `RdFramework` / `Annotations` (compile references) | The `ReferenceSdkCoreLibraries` target in `Directory.Build.props` references whatever the SDK's own dependency graph resolved. ~150 SDK packages pin one exact version of each, which changes with every SDK line; there are no direct `PackageReference`s for them, so nothing has to be matched by hand | +| `WaveVersion` (ReSharper `.nupkg` dependency) | Regex over `SdkVersion`: `2026.3.0-eap02` → `263.0.0` (the Wave package only publishes `.0.0`) | +| Gradle `ProductVersion` (the Rider the frontend compiles against and `runIde` launches) | `build.gradle.kts` reads `` through `providers.fileContents` (so the configuration cache notices edits) and maps it to the Maven form: `2026.3.0-eap02` → `2026.3-EAP2-SNAPSHOT`, `2026.2.0-rc01` → `2026.2-RC1-SNAPSHOT`, `2026.2.0` → `2026.2`, otherwise unchanged. `-PProductVersion=…` overrides it | + +`./gradlew riderVersions -q` prints both forms. Rider re-evaluates the solution by itself when `Directory.Build.props` +changes; Gradle picks the change up on its next invocation (the IDE's Gradle model needs a sync, as for any platform +change). + +A one-off without editing, MSBuild side only: `dotnet build -p:SdkVersion=2026.3.0-eap02`. Trying a version is best done +in a separate worktree, so your main checkout's `obj/` isn't flipped between SDKs; each SDK version adds a few GB to the +NuGet cache. + +## Still manual + +Only when a new platform demands it: + +- `gradle/libs.versions.toml` — Kotlin, the IntelliJ Platform Gradle Plugin, jvm-wrapper, and `rdGen`. `rdGen` shares a + release train with JetBrains.Lifetimes and should equal the version the SDK resolves (see + `obj//project.assets.json`), but only matters when regenerating the protocol (`./gradlew :protocol:rdgen`). +- The Gradle wrapper, JVM 21 (toolchain, `jvmTarget`, CI `java-version`). +- The .NET target frameworks and CI's `dotnet-version`, when Rider moves runtime. +- `plugin.xml`'s `since-build`, only when dropping support for older Rider (`untilBuild` is unset). + +## Verified (2026-09-18), editing only `` + +| SdkVersion | Resolved Lifetimes / Annotations | Gradle ProductVersion | Result | +|---|---|---|---| +| 2026.1.5.2 (current) | 2026.1.2 / 2025.2.0 | 2026.1.5.2 | builds; `buildPlugin` OK against RD-261.27258.82 | +| 2026.2.2 | 2026.2.5 / 2026.2.0 | 2026.2.2 | builds (and the `automated-tests` branch's suite passed unchanged) | +| 2026.3.0-eap02 | 2026.3.0 / 2026.2.0 | 2026.3-EAP2-SNAPSHOT | restores; the plugin hits real 2026.3 API breaks (`…AspectLookupItems.Matchers.DeclaredElementMatcher` removed, `ReparsedCodeCompletionContext.Range` inaccessible) | + +Side effect of dropping the direct references: the ReSharper `.nupkg` no longer declares (leaked) dependencies on +JetBrains.Annotations/Lifetimes/RdFramework; its files and the `Wave` dependency are unchanged. diff --git a/gradle.properties b/gradle.properties index 2d2be6c..8a9904d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,23 +10,14 @@ BuildConfiguration=Release PublishToken="_PLACEHOLDER_" -# Possible values (minor is omitted): -# Release: 2020.2 -# Nightly: 2020.3-SNAPSHOT -# EAP: 2020.3-EAP2-SNAPSHOT -ProductVersion=2026.1 +# The Rider version is in Directory.Build.props; build.gradle.kts derives the IntelliJ Platform +# ProductVersion (e.g. 2026.3-EAP2-SNAPSHOT) from it. -PProductVersion=... overrides the derived value. # Kotlin 1.4 will bundle the stdlib dependency by default, causing problems with the version bundled with the IDE # https://blog.jetbrains.com/kotlin/2020/07/kotlin-1-4-rc-released/#stdlib-default kotlin.stdlib.default.dependency=false org.gradle.jvmargs=-Xmx4g -rdVersion=2026.1 -rdKotlinVersion=2.3.0 -intellijPlatformGradlePluginVersion=2.14.0 -gradleJvmWrapperVersion=0.15.0 -riderBaseVersion=2025.1 - # Required to download Rider artifacts from Maven (and not "binary" releases from CDN). org.jetbrains.intellij.platform.buildFeature.useBinaryReleases=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11df626..b9bec51 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,17 @@ [versions] kotlin = "2.3.20" # https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library +# Same release train as JetBrains.Lifetimes/RdFramework: keep it equal to the version the Rider SDK in +# Directory.Build.props resolves (obj/*/project.assets.json). Only matters when regenerating the protocol (:protocol:rdgen). rdGen = "2026.1.3" # https://github.com/JetBrains/rd/releases +intellijPlatform = "2.15.0" # https://github.com/JetBrains/intellij-platform-gradle-plugin/releases +gradleJvmWrapper = "0.16.0" # https://github.com/mfilippov/gradle-jvm-wrapper/releases [libraries] kotlinStdLib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } rdGen = { group = "com.jetbrains.rd", name = "rd-gen", version.ref = "rdGen" } [plugins] -kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } \ No newline at end of file +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +intellijPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intellijPlatform" } +gradleJvmWrapper = { id = "me.filippov.gradle.jvm.wrapper", version.ref = "gradleJvmWrapper" } +rdGen = { id = "com.jetbrains.rdgen", version.ref = "rdGen" } diff --git a/protocol/build.gradle.kts b/protocol/build.gradle.kts index 2ee7928..7ef0378 100644 --- a/protocol/build.gradle.kts +++ b/protocol/build.gradle.kts @@ -2,7 +2,7 @@ import com.jetbrains.rd.generator.gradle.RdGenTask plugins { id("org.jetbrains.kotlin.jvm") - id("com.jetbrains.rdgen") version libs.versions.rdGen + alias(libs.plugins.rdGen) } dependencies { @@ -46,6 +46,10 @@ rdgen { } tasks.withType { + // rd-gen's task reads Task.project while executing, which the configuration cache (on in gradle.properties) + // rejects. Opting this one task out keeps :protocol:rdgen usable without turning the cache off for everything. + notCompatibleWithConfigurationCache("RdGenTask accesses Task.project at execution time") + val classPath = sourceSets["main"].runtimeClasspath dependsOn(classPath) classpath(classPath) diff --git a/settings.gradle.kts b/settings.gradle.kts index 4a6834b..dd9baa8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,18 +1,6 @@ rootProject.name = "rimworlddev" pluginManagement { - val rdVersion: String by settings - val rdKotlinVersion: String by settings - val intellijPlatformGradlePluginVersion: String by settings - val gradleJvmWrapperVersion: String by settings - val DotnetPluginId: String by settings - val DotnetSolution: String by settings - val RiderPluginId: String by settings - val PluginVersion: String by settings - val BuildConfiguration: String by settings - val PublishToken: String by settings - val ProductVersion: String by settings - repositories { maven("https://cache-redirector.jetbrains.com/intellij-dependencies") maven("https://cache-redirector.jetbrains.com/plugins.gradle.org") @@ -20,17 +8,6 @@ pluginManagement { maven("https://cache-redirector.jetbrains.com/dl.bintray.com/kotlin/kotlin-eap") maven("https://cache-redirector.jetbrains.com/myget.org.rd-snapshots.maven") maven("https://cache-redirector.jetbrains.com/intellij-dependencies") - - if (rdVersion == "SNAPSHOT") { - mavenLocal() - } - } - - plugins { - id("com.jetbrains.rdgen") version rdVersion - id("org.jetbrains.kotlin.jvm") version rdKotlinVersion - id("org.jetbrains.intellij.platform") version intellijPlatformGradlePluginVersion - id("me.filippov.gradle.jvm.wrapper") version gradleJvmWrapperVersion } resolutionStrategy { From 42131abc1c83ab209862fe6e0f17d33104bf0d33 Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 18 Sep 2026 14:01:57 +0100 Subject: [PATCH 2/5] Adding a task for viewing available versions and switching to new versions --- .gitignore | 2 +- build.gradle.kts | 30 +-- buildSrc/build.gradle.kts | 4 + buildSrc/settings.gradle.kts | 15 ++ .../kotlin/rimworlddev/gradle/RiderVersion.kt | 56 ++++++ .../rimworlddev/gradle/RiderVersionsTask.kt | 171 ++++++++++++++++++ docs/rider-version.md | 31 +++- 7 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/settings.gradle.kts create mode 100644 buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersion.kt create mode 100644 buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersionsTask.kt diff --git a/.gitignore b/.gitignore index a732bca..f8c1dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,4 @@ packages/ **/NuGetLocks/* # Example Mod -example-mod/.idea +example-mod/.idea \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index f16b1c3..28cab0c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,8 @@ import com.jetbrains.plugin.structure.base.utils.isFile import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.intellij.platform.gradle.Constants import org.jetbrains.kotlin.gradle.dsl.JvmTarget +import rimworlddev.gradle.RiderVersion +import rimworlddev.gradle.RiderVersionsTask plugins { id("java") @@ -29,33 +31,19 @@ val BuildConfiguration: String by project // the configuration cache is invalidated when that file changes. -PProductVersion=... still overrides it. val SdkVersion: String = providers.fileContents(layout.projectDirectory.file("Directory.Build.props")).asText .map { props -> - Regex("""\s*([^<\s]+)\s*""").find(props)?.groupValues?.get(1) + RiderVersion.sdkVersionPattern.find(props)?.groupValues?.get(1) ?: throw GradleException("No found in Directory.Build.props") } .get() -// Rider's Maven artifacts name the same builds differently from NuGet: -// 2026.3.0-eap02 -> 2026.3-EAP2-SNAPSHOT, 2026.2.0-rc01 -> 2026.2-RC1-SNAPSHOT, 2026.2.0 -> 2026.2, 2026.1.5.2 -> 2026.1.5.2 -fun riderMavenVersion(sdkVersion: String): String { - Regex("""^(\d+\.\d+)\.0-(eap|rc)0*(\d+)$""").matchEntire(sdkVersion)?.let { m -> - return "${m.groupValues[1]}-${m.groupValues[2].uppercase()}${m.groupValues[3]}-SNAPSHOT" - } - Regex("""^(\d+\.\d+)\.0$""").matchEntire(sdkVersion)?.let { return it.groupValues[1] } - return sdkVersion -} - -val ProductVersion: String = providers.gradleProperty("ProductVersion").orNull ?: riderMavenVersion(SdkVersion) +val ProductVersion: String = providers.gradleProperty("ProductVersion").orNull ?: RiderVersion.mavenVersion(SdkVersion) -// ./gradlew riderVersions -q: what this checkout targets, in both formats. -val riderVersions by tasks.registering { +// ./gradlew versions [--to ] [--usage]: buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersionsTask.kt +val versions by tasks.registering(RiderVersionsTask::class) { group = "help" - description = "Prints the Rider version this build targets (NuGet SdkVersion and IntelliJ Platform ProductVersion)." - val sdk = SdkVersion - val product = ProductVersion - doLast { - println("SdkVersion (NuGet, Directory.Build.props): $sdk") - println("ProductVersion (IntelliJ Platform/Maven): $product") - } + description = "Lists Rider versions (every build of the current EAP, last three stable lines) or switches with --to.\n\n" + + RiderVersionsTask.USAGE + propsFile.set(layout.projectDirectory.file("Directory.Build.props")) } val DotnetPluginId: String by project diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..621a0ae --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + // Compiles against the Gradle API with Gradle's own embedded Kotlin, like the build scripts themselves. + `kotlin-dsl` +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 0000000..39b9473 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,15 @@ +// Build logic for the root build: Kotlin in src/main/kotlin is compiled before the root build scripts are evaluated and is +// on their classpath (see RiderVersionsTask). Same mirrors as the root settings.gradle.kts. +pluginManagement { + repositories { + maven("https://cache-redirector.jetbrains.com/plugins.gradle.org") + maven("https://cache-redirector.jetbrains.com/maven-central") + } +} + +dependencyResolutionManagement { + repositories { + maven("https://cache-redirector.jetbrains.com/maven-central") + maven("https://cache-redirector.jetbrains.com/plugins.gradle.org") + } +} diff --git a/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersion.kt b/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersion.kt new file mode 100644 index 0000000..8235202 --- /dev/null +++ b/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersion.kt @@ -0,0 +1,56 @@ +package rimworlddev.gradle + +/** + * A JetBrains.Rider.SDK version as NuGet publishes it: 2026.1.5.2, 2026.2.0-rc01, 2026.3.0-eap02. + * + * `` in Directory.Build.props is written in this form; the companion holds the mappings to the other + * version formats derived from it (the IntelliJ Platform's Maven version, ReSharper's wave). + */ +data class RiderVersion(val raw: String, val numbers: List, val preKind: String?, val preNumber: Int) : + Comparable { + + /** "2026.1" */ + val line get() = "${numbers[0]}.${numbers[1]}" + val stable get() = preKind == null + + override fun compareTo(other: RiderVersion): Int { + for (i in 0 until maxOf(numbers.size, other.numbers.size)) { + val c = numbers.getOrElse(i) { 0 }.compareTo(other.numbers.getOrElse(i) { 0 }) + if (c != 0) return c + } + // A release sorts after its prereleases; an RC after the EAPs. + fun rank(v: RiderVersion) = when (v.preKind) { null -> 2; "rc" -> 1; else -> 0 } + return compareValuesBy(this, other, { rank(it) }, { it.preNumber }) + } + + companion object { + private val pattern = Regex("""^(\d{4}(?:\.\d+){1,3})(?:-(eap|rc)(\d+))?$""", RegexOption.IGNORE_CASE) + + /** `…` in Directory.Build.props; group 1 is the version. */ + val sdkVersionPattern = Regex("""\s*([^<\s]+)\s*""") + + fun parse(raw: String) = pattern.matchEntire(raw)?.let { m -> + RiderVersion(raw, m.groupValues[1].split('.').map(String::toInt), + m.groupValues[2].lowercase().ifEmpty { null }, m.groupValues[3].toIntOrNull() ?: 0) + } + + /** "2026.1" -> sortable number, so 2026.10 would sort after 2026.9. */ + fun lineKey(line: String) = line.split('.').let { it[0].toInt() * 1000 + it[1].toInt() } + + /** + * Rider's Maven artifacts (what the IntelliJ Platform Gradle Plugin downloads) name the same builds differently + * from NuGet: 2026.3.0-eap02 -> 2026.3-EAP2-SNAPSHOT, 2026.2.0-rc01 -> 2026.2-RC1-SNAPSHOT, 2026.2.0 -> 2026.2, + * 2026.1.5.2 -> 2026.1.5.2. + */ + fun mavenVersion(sdkVersion: String): String { + Regex("""^(\d+\.\d+)\.0-(eap|rc)0*(\d+)$""", RegexOption.IGNORE_CASE).matchEntire(sdkVersion)?.let { m -> + return "${m.groupValues[1]}-${m.groupValues[2].uppercase()}${m.groupValues[3]}-SNAPSHOT" + } + Regex("""^(\d+\.\d+)\.0$""").matchEntire(sdkVersion)?.let { return it.groupValues[1] } + return sdkVersion + } + + /** Same derivation as WaveVersionBase in Directory.Build.props: 2026.3.0-eap02 -> 263. */ + fun waveBase(sdkVersion: String) = Regex("""^\d\d(\d\d)\.(\d+)\..*$""").replace(sdkVersion, "$1$2") + } +} diff --git a/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersionsTask.kt b/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersionsTask.kt new file mode 100644 index 0000000..837bf9d --- /dev/null +++ b/buildSrc/src/main/kotlin/rimworlddev/gradle/RiderVersionsTask.kt @@ -0,0 +1,171 @@ +package rimworlddev.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +/** + * `./gradlew versions` lists every build of the current Rider EAP cycle (if there is one) and the latest release of the + * last three stable lines, marking the one this checkout targets. `./gradlew versions --to ` switches by + * rewriting `` in Directory.Build.props; `./gradlew versions --usage` lists the accepted targets + * (Gradle keeps `--help` for itself). + * + * Versions come from NuGet's JetBrains.Rider.SDK index (what the backend restores). Each is also checked against the + * JetBrains Maven repository the Gradle side downloads Rider from, and against the Wave package, which can both lag NuGet + * (and Maven drops old EAP snapshots). Everything happens at execution time, so the configuration cache is unaffected. + */ +abstract class RiderVersionsTask : DefaultTask() { + @get:Internal + abstract val propsFile: RegularFileProperty + + @get:Input + @get:Optional + @get:Option(option = "to", description = "Switch to a Rider version: eap, eap3, 2026.2-eap5, latest, 2026.1 or an exact version. See --usage.") + abstract val to: Property + + @get:Input + @get:Optional + @get:Option(option = "usage", description = "Print what --to accepts, with examples.") + abstract val usage: Property + + @TaskAction + fun run() { + if (usage.getOrElse(false)) { + print(USAGE) + return + } + + val file = propsFile.get().asFile + val props = file.readText() + val current = RiderVersion.sdkVersionPattern.find(props)?.groupValues?.get(1) + ?: throw GradleException("No found in $file") + + val all = fetch(NUGET_INDEX).let { json -> + Regex(""""versions"\s*:\s*\[(.*?)]""", RegexOption.DOT_MATCHES_ALL).find(json)?.groupValues?.get(1) + ?.let { list -> Regex(""""([^"]+)"""").findAll(list).map { it.groupValues[1] }.toList() } + ?: throw GradleException("Unexpected response from $NUGET_INDEX") + }.mapNotNull(RiderVersion::parse) + val lines = all.groupBy { it.line }.toSortedMap(compareByDescending { RiderVersion.lineKey(it) }) + // The EAP cycle in progress: the newest line, if it has no stable release yet. All its builds, newest first. + val eaps = lines.entries.firstOrNull()?.value?.takeIf { versions -> versions.none { it.stable } } + ?.sortedDescending().orEmpty() + val stable = lines.values.mapNotNull { versions -> versions.filter { it.stable }.maxOrNull() }.take(3) + + var target = current + to.orNull?.trim()?.let { requested -> + fun fail(message: String): Nothing = + throw GradleException("$message Run `./gradlew versions --usage` for what --to accepts. Available:\n" + + listing(eaps, stable, current)) + + val build = Regex("""^(?:(\d{4}\.\d+)-)?(eap|rc)0*(\d+)$""", RegexOption.IGNORE_CASE).matchEntire(requested) + target = when { + requested.equals("eap", ignoreCase = true) -> + eaps.firstOrNull()?.raw ?: fail("There is no Rider EAP at the moment.") + build != null -> { + val (line, kind, number) = build.destructured + val cycle = if (line.isEmpty()) eaps.ifEmpty { fail("There is no Rider EAP at the moment; name the line, e.g. 2026.2-$kind$number.") } + else lines[line] ?: fail("No Rider $line versions found.") + cycle.firstOrNull { it.preKind == kind.lowercase() && it.preNumber == number.toInt() }?.raw + ?: fail("No ${kind.uppercase()}$number in Rider ${line.ifEmpty { eaps.first().line }}.") + } + requested.equals("latest", ignoreCase = true) -> stable.first().raw + Regex("""^\d{4}\.\d+$""").matches(requested) -> lines[requested] + ?.let { versions -> (versions.filter { it.stable }.maxOrNull() ?: versions.max()).raw } + ?: fail("No Rider $requested versions found.") + else -> all.firstOrNull { it.raw.equals(requested, ignoreCase = true) }?.raw + ?: fail("$requested isn't a published JetBrains.Rider.SDK version.") + } + if (target == current) { + println("Already on $current.") + } else { + file.writeText(RiderVersion.sdkVersionPattern.replace(props) { "$target" }) + println("SdkVersion: $current -> $target (Directory.Build.props)") + println("Rider reloads the solution by itself; the next Gradle run builds against ${RiderVersion.mavenVersion(target)}.") + } + println() + } + + print(listing(eaps, stable, target)) + } + + private fun listing(eaps: List, stable: List, current: String): String { + val maven = listOf(MAVEN_RELEASES, MAVEN_SNAPSHOTS).flatMapTo(mutableSetOf()) { url -> + runCatching { Regex("([^<]+)").findAll(fetch(url)).map { it.groupValues[1] }.toList() } + .getOrDefault(emptyList()) + } + val waves = runCatching { Regex(""""([^"]+)"""").findAll(fetch(WAVE_INDEX)).map { it.groupValues[1] }.toSet() } + .getOrDefault(emptySet()) + + fun row(kind: String, version: String): String { + val product = RiderVersion.mavenVersion(version) + val wave = "${RiderVersion.waveBase(version)}.0.0" + val notes = buildList { + if (maven.isNotEmpty() && product !in maven) add("not on JetBrains Maven") + if (waves.isNotEmpty() && wave !in waves) add("no Wave $wave yet") + if (version == current) add("<- current") + } + return " %-7s %-17s %-22s %s".format(kind, version, product, notes.joinToString(", ")).trimEnd() + "\n" + } + + return buildString { + append("Rider versions (NuGet JetBrains.Rider.SDK / IntelliJ Platform):\n") + if (eaps.isEmpty()) append(" EAP none at the moment\n") + eaps.forEach { append(row(it.preKind!!.uppercase(), it.raw)) } + stable.forEach { append(row("Stable", it.raw)) } + if (eaps.none { it.raw == current } && stable.none { it.raw == current }) append(row("Current", current)) + } + } + + companion object { + val USAGE = """ + |Usage: + | ./gradlew versions List every build of the current Rider EAP and the latest release of the + | last three stable lines, marking the one this checkout targets. + | ./gradlew versions --to Switch by rewriting in Directory.Build.props. + | ./gradlew versions --usage This text. + | + |: + | eap newest build of the current EAP cycle + | eap3, eap03, rc1 that build of the current EAP cycle (e.g. to find which EAP broke something) + | 2026.2-eap5 that build of another line's EAP cycle + | latest newest stable release + | 2026.1 newest release of that line (its newest EAP/RC if it has no release yet) + | 2026.2.0-rc01 an exact JetBrains.Rider.SDK version, as NuGet publishes it + | + |Run it on its own: Rider reloads the solution by itself and the next Gradle run builds against the new + |version. "not on JetBrains Maven" means the Gradle side can't download that Rider build (Maven lags NuGet, + |and drops old EAP snapshots); the backend still restores and builds against it. + |""".trimMargin() + + private const val NUGET_INDEX = "https://api.nuget.org/v3-flatcontainer/jetbrains.rider.sdk/index.json" + private const val WAVE_INDEX = "https://api.nuget.org/v3-flatcontainer/wave/index.json" + private const val MAVEN_RELEASES = + "https://cache-redirector.jetbrains.com/intellij-repository/releases/com/jetbrains/intellij/rider/riderRD/maven-metadata.xml" + private const val MAVEN_SNAPSHOTS = + "https://cache-redirector.jetbrains.com/intellij-repository/snapshots/com/jetbrains/intellij/rider/riderRD/maven-metadata.xml" + + private val http: HttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .build() + + private fun fetch(url: String): String { + val request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).build() + val response = runCatching { http.send(request, HttpResponse.BodyHandlers.ofString()) } + .getOrElse { throw GradleException("Couldn't fetch $url: ${it.message}", it) } + if (response.statusCode() != 200) throw GradleException("Couldn't fetch $url: HTTP ${response.statusCode()}") + return response.body() + } + } +} diff --git a/docs/rider-version.md b/docs/rider-version.md index e0391b9..d493307 100644 --- a/docs/rider-version.md +++ b/docs/rider-version.md @@ -9,7 +9,36 @@ | `WaveVersion` (ReSharper `.nupkg` dependency) | Regex over `SdkVersion`: `2026.3.0-eap02` → `263.0.0` (the Wave package only publishes `.0.0`) | | Gradle `ProductVersion` (the Rider the frontend compiles against and `runIde` launches) | `build.gradle.kts` reads `` through `providers.fileContents` (so the configuration cache notices edits) and maps it to the Maven form: `2026.3.0-eap02` → `2026.3-EAP2-SNAPSHOT`, `2026.2.0-rc01` → `2026.2-RC1-SNAPSHOT`, `2026.2.0` → `2026.2`, otherwise unchanged. `-PProductVersion=…` overrides it | -`./gradlew riderVersions -q` prints both forms. Rider re-evaluates the solution by itself when `Directory.Build.props` +`./gradlew versions` (the `RiderVersionsTask` in `buildSrc/src/main/kotlin/rimworlddev/gradle/`, next to the +`RiderVersion` model that also holds the NuGet → Maven/Wave mappings the root build uses) lists every build of the current EAP cycle and the latest release of the last three stable lines, +marking what you're on; `./gradlew versions --to ` switches (it just rewrites ``), and +`./gradlew versions --usage` prints what `--to` accepts (Gradle intercepts `--help` itself; `./gradlew help --task +versions` shows the same text). + +``` +$ ./gradlew versions -q +Rider versions (NuGet JetBrains.Rider.SDK / IntelliJ Platform): + EAP 2026.3.0-eap02 2026.3-EAP2-SNAPSHOT + EAP 2026.3.0-eap01 2026.3-EAP1-SNAPSHOT + Stable 2026.2.2 2026.2.2 + Stable 2026.1.5.2 2026.1.5.2 <- current + Stable 2025.3.5.2 2025.3.5.2 +``` + +| `--to` | Switches to | +|---|---| +| `eap` | newest build of the current EAP cycle (the newest line with no stable release yet); fails if there isn't one | +| `eap3`, `eap03`, `rc1` | that build of the current EAP cycle — step through them to find which EAP broke something | +| `2026.2-eap5` | that build of another line's EAP cycle | +| `latest` | the newest stable release | +| `2026.1` | that line's newest stable release, or its newest prerelease if it has none yet | +| `2026.2.0-rc01` | exactly that version (must be published) | + +The list comes from NuGet's `JetBrains.Rider.SDK` index. Each row is also checked against the JetBrains Maven repository +the Gradle side downloads Rider from and against the `Wave` package; both can lag NuGet (and Maven drops old EAP snapshots), and the row +says so ("not on JetBrains Maven") rather than letting a later Gradle resolve fail. Network access happens when the task runs, so the +configuration cache is unaffected; after a switch Gradle reports that `Directory.Build.props` changed and re-derives +`ProductVersion`. Run `versions --to` on its own, not in the same invocation as a build. Rider re-evaluates the solution by itself when `Directory.Build.props` changes; Gradle picks the change up on its next invocation (the IDE's Gradle model needs a sync, as for any platform change). From 64308ad6f762a16de268364ddeeef44e917859fe Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 18 Sep 2026 15:27:36 +0100 Subject: [PATCH 3/5] Remove the powershell scripts for vs and use gradle for everything. --- .run/Build ReSharper Plugin.run.xml | 4 +- build.gradle.kts | 17 + buildPlugin.ps1 | 12 - .../rimworlddev/gradle/RunVisualStudioTask.kt | 523 ++++++++++++++++++ publishPlugin.ps1 | 20 - runVisualStudio.ps1 | 89 --- settings.ps1 | 34 -- 7 files changed, 542 insertions(+), 157 deletions(-) delete mode 100644 buildPlugin.ps1 create mode 100644 buildSrc/src/main/kotlin/rimworlddev/gradle/RunVisualStudioTask.kt delete mode 100644 publishPlugin.ps1 delete mode 100644 runVisualStudio.ps1 delete mode 100644 settings.ps1 diff --git a/.run/Build ReSharper Plugin.run.xml b/.run/Build ReSharper Plugin.run.xml index bd5c277..4f25d40 100644 --- a/.run/Build ReSharper Plugin.run.xml +++ b/.run/Build ReSharper Plugin.run.xml @@ -1,10 +1,10 @@ - \ No newline at end of file + diff --git a/build.gradle.kts b/build.gradle.kts index 28cab0c..0520227 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ import org.jetbrains.intellij.platform.gradle.Constants import org.jetbrains.kotlin.gradle.dsl.JvmTarget import rimworlddev.gradle.RiderVersion import rimworlddev.gradle.RiderVersionsTask +import rimworlddev.gradle.RunVisualStudioTask plugins { id("java") @@ -47,6 +48,22 @@ val versions by tasks.registering(RiderVersionsTask::class) { } val DotnetPluginId: String by project + +// ./gradlew runVisualStudio [--plan] [--clean] [--reinstall] [--usage]: the ReSharper build in an experimental Visual +// Studio instance. Generic task in buildSrc/src/main/kotlin/rimworlddev/gradle/RunVisualStudioTask.kt; everything +// specific to this plugin is set here. Same locations as runVisualStudio.ps1, so the two can be compared. +val runVisualStudio by tasks.registering(RunVisualStudioTask::class) { + group = "run" + description = "Runs the ReSharper build of the plugin in an experimental Visual Studio instance (Windows).\n\n" + + RunVisualStudioTask.USAGE + pluginId.set(DotnetPluginId) + projectFile.set(layout.projectDirectory.file("src/dotnet/$DotnetPluginId/$DotnetPluginId.csproj")) + sdkVersion.set(SdkVersion) + rootSuffix.set("RimworldDev") + installerDirectory.set(layout.buildDirectory.dir("installer")) + packageOutputDirectory.set(layout.projectDirectory.dir("output")) + logFile.set(layout.projectDirectory.file("ReSharper.log")) +} val RiderPluginId: String by project val PublishToken: String by project val PluginVersion: String by project diff --git a/buildPlugin.ps1 b/buildPlugin.ps1 deleted file mode 100644 index dfedd6e..0000000 --- a/buildPlugin.ps1 +++ /dev/null @@ -1,12 +0,0 @@ -Param( - $Version = "2024.1-EAP" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" -$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent -Set-Location $PSScriptRoot - -. ".\settings.ps1" - -Invoke-Exe $MSBuildPath "/t:Restore;Rebuild;Pack" "$SolutionPath" "/v:minimal" "/p:PackageVersion=$Version" "/p:PackageOutputPath=`"$OutputDirectory`"" diff --git a/buildSrc/src/main/kotlin/rimworlddev/gradle/RunVisualStudioTask.kt b/buildSrc/src/main/kotlin/rimworlddev/gradle/RunVisualStudioTask.kt new file mode 100644 index 0000000..a6f1e49 --- /dev/null +++ b/buildSrc/src/main/kotlin/rimworlddev/gradle/RunVisualStudioTask.kt @@ -0,0 +1,523 @@ +package rimworlddev.gradle + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.options.Option +import org.gradle.process.ExecOperations +import org.w3c.dom.Document +import org.w3c.dom.Element +import java.io.ByteArrayOutputStream +import java.io.File +import java.net.URI +import java.net.URLDecoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.time.Duration +import java.util.zip.ZipFile +import javax.inject.Inject +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.OutputKeys +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult + +/** + * Runs a ReSharper plugin in an experimental Visual Studio instance (`devenv /rootSuffix`), for any plugin built on the + * ReSharper SDK: everything project-specific is configured on the task (see [USAGE]). + * + * The first run sets the instance up: downloads the ReSharper installer matching [sdkVersion], installs ReSharper into the + * instance, registers the plugin in the instance's packages.config, points `HostFullIdentifier` in `.csproj.user` + * at it, packs the plugin and unpacks the package into %LOCALAPPDATA%\JetBrains\plugins, and runs the installer again so + * the instance picks the plugin up. Every run then rebuilds the plugin (the SDK's CopyPlugin step copies it into the + * instance) and launches Visual Studio with ReSharper's internal mode and trace logging, waiting for it to exit. + * + * Builds use `dotnet msbuild` and the .csproj.user condition is 'Core': CopyPlugin resolves the project's references, + * which are .NET assemblies for current SDKs, and Visual Studio's .NET Framework MSBuild can't load them + * (BadImageFormatException on System.Runtime in InstalledProductsDiscoveryTask). + */ +abstract class RunVisualStudioTask : DefaultTask() { + @get:Inject + abstract val execOperations: ExecOperations + + /** The plugin's ReSharper project; its .csproj.user carries HostFullIdentifier. */ + @get:Internal + abstract val projectFile: RegularFileProperty + + /** NuGet package id of the plugin (the project's PackageId). */ + @get:Input + abstract val pluginId: Property + + /** ReSharper SDK version in NuGet form (2026.1.5.2, 2026.3.0-eap02); picks the ReSharper installer. */ + @get:Input + abstract val sdkVersion: Property + + @get:Input + @get:Option(option = "root-suffix", description = "Experimental Visual Studio instance (devenv /rootSuffix).") + abstract val rootSuffix: Property + + @get:Input + @get:Option(option = "plugin-version", description = "Version the plugin package is installed as. Default: 9999.0.0.") + abstract val pluginVersion: Property + + /** Build configuration passed to MSBuild. Default: Debug. */ + @get:Input + abstract val configuration: Property + + /** Where downloaded ReSharper installers are cached (several GB each). */ + @get:Internal + abstract val installerDirectory: DirectoryProperty + + /** Where the plugin package is packed to during setup. */ + @get:Internal + abstract val packageOutputDirectory: DirectoryProperty + + /** ReSharper's log (devenv /ReSharper.LogFile). */ + @get:Internal + abstract val logFile: RegularFileProperty + + @get:Input + @get:Option(option = "plan", description = "Show what would happen, including what --clean/--reinstall would remove; change nothing.") + abstract val plan: Property + + @get:Input + @get:Option(option = "clean", description = "Remove the experimental instance and everything set up for it (keeps downloaded installers), then stop.") + abstract val clean: Property + + @get:Input + @get:Option(option = "clean-installers", description = "Like --clean, and also delete the downloaded ReSharper installers.") + abstract val cleanInstallers: Property + + @get:Input + @get:Option(option = "reinstall", description = "--clean, then set the instance up from scratch and launch.") + abstract val reinstall: Property + + @get:Input + @get:Option(option = "usage", description = "Print what the task does and its options.") + abstract val usage: Property + + init { + pluginVersion.convention("9999.0.0") + configuration.convention("Debug") + plan.convention(false) + clean.convention(false) + cleanInstallers.convention(false) + reinstall.convention(false) + usage.convention(false) + } + + @TaskAction + fun run() { + if (usage.get()) { + print(USAGE) + return + } + if (!System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) + throw GradleException("$name needs Windows (Visual Studio + ReSharper).") + + val suffix = rootSuffix.orNull?.trim().orEmpty() + // An empty suffix would make the experimental instance's names match the normal ReSharper install's. + if (suffix.isEmpty()) throw GradleException("$name needs a root suffix: set rootSuffix in the build script or pass --root-suffix.") + val project = required(projectFile, "projectFile") + val id = pluginId.get() + val version = pluginVersion.get() + val vs = findVisualStudio() + val instance = Instance(vs, suffix, id, version, project, required(logFile, "logFile"), + File(required(packageOutputDirectory, "packageOutputDirectory"), "$id.$version.nupkg")) + + val cleaning = clean.get() || cleanInstallers.get() || reinstall.get() + val stopAfterClean = (clean.get() || cleanInstallers.get()) && !reinstall.get() + + if (plan.get()) { + print(plan(instance, cleaning, stopAfterClean)) + return + } + + if (cleaning) { + ensureNotRunning(suffix) + cleanSteps(instance, cleanInstallers.get()).forEach { step -> + println("- ${step.description}") + step.action() + } + println("Removed the experimental instance ${instance.hostId}.") + if (stopAfterClean) return + } + + val problem = setupProblem(instance) + if (problem != null) { + ensureNotRunning(suffix) + println("Setting up the experimental instance: $problem") + instance.userFile.delete() + install(instance) + } else if (instance.userFile.readText().contains("== 'Full'")) { + // Set up before builds moved to dotnet: only the .csproj.user condition needs updating + instance.userFile.writeText(instance.userFile.readText().replace("== 'Full'", "== 'Core'")) + println("Updated ${instance.userFile} to deploy from dotnet builds") + } + + exec("dotnet", "msbuild", "/t:Restore;Rebuild", instance.projectFile.path, "/v:minimal", + "/p:Configuration=${configuration.get()}") + exec(vs.devenv.path, "/rootSuffix", suffix, "/ReSharper.Internal", + "/ReSharper.LogFile", instance.logFile.path, "/ReSharper.LogLevel", "Trace") + } + + // --- The experimental instance ------------------------------------------------------------------------------------- + + /** Everything that belongs to one experimental instance: `ReSharperPlatformVs_`. */ + private inner class Instance( + val vs: VisualStudio, val suffix: String, val id: String, val version: String, + val projectFile: File, val logFile: File, val packedPlugin: File, + ) { + val userFile = File(projectFile.path + ".user") + val platform = "ReSharperPlatformVs${vs.majorVersion}" + val hostId = "${platform}_${vs.instanceId}$suffix" + val installFolder = File(localAppData(), "JetBrains/Installations/$hostId") + val uninstaller = File(installFolder, "JetBrains.Platform.Installer.exe") + val pluginPackage = File(localAppData(), "JetBrains/plugins/$id.$version") + val visualStudioData = File(localAppData(), "Microsoft/VisualStudio/${vs.majorVersion}.0_${vs.instanceId}$suffix") + val visualStudioSettings = File(localAppData(), "Microsoft/VisualStudio/$suffix/SettingsV2.${vs.majorVersion}") + + /** `v261_`, `vAny_`: exactly this instance, never a longer suffix or the normal install. */ + fun owns(name: String) = name.startsWith("v") && name.substringAfter('_', "") == "${vs.instanceId}$suffix" + + private fun owned(parent: File) = parent.listFiles { f -> f.isDirectory && owns(f.name) }.orEmpty().toList() + val settingsDirectories get() = owned(File(localAppData(), "JetBrains/$platform")) + val transientDirectories get() = owned(File(localAppData(), "JetBrains/Transient/$platform")) + + /** The instance's NuGet.Config folder; present once ReSharper is installed into it. */ + val hive get() = settingsDirectories.firstOrNull { it.name.startsWith("vAny_") && File(it, "NuGet.Config").exists() } + + val registryKeys get() = registrySubkeys("HKCU\\Software\\JetBrains\\$platform").filter { owns(it.substringAfterLast('\\')) } + val commandLineKeys get() = registrySubkeys("HKCU\\Software\\Microsoft\\VisualStudio\\${vs.majorVersion}.0_${vs.instanceId}\\AppCommandLine") + .filter { key -> key.substringAfterLast('\\').let { it.startsWith("ReSharper.") && it.endsWith(".$suffix", ignoreCase = true) } } + } + + /** Why the instance needs setting up, or null if it's ready. */ + private fun setupProblem(instance: Instance): String? { + val currentHost = if (instance.userFile.exists()) Regex("([^<]*)") + .find(instance.userFile.readText())?.groupValues?.get(1)?.trim() else null + return when { + currentHost == null -> "not set up (no ${instance.userFile})" + currentHost != instance.hostId -> "set up for $currentHost, not ${instance.vs.displayName} (${instance.hostId})" + instance.hive == null -> "ReSharper isn't installed in ${instance.hostId}" + !instance.pluginPackage.exists() -> "${instance.pluginPackage} is missing (an earlier setup didn't finish)" + else -> null + } + } + + private class Step(val description: String, val action: () -> Unit) + + /** What --clean removes, in order: the instance's own uninstaller first, then whatever it leaves behind. */ + private fun cleanSteps(instance: Instance, includeInstallers: Boolean): List = buildList { + fun delete(file: File, what: String) { + if (file.exists()) add(Step("delete $what: $file") { file.deleteRecursively() }) + } + if (instance.uninstaller.exists()) add(Step("uninstall ReSharper from ${instance.hostId} (${instance.uninstaller.name} /HostsToRemove)") { + exec(instance.uninstaller.path, "/HostsToRemove=${instance.hostId}", "/Silent=True") + }) + delete(instance.installFolder, "ReSharper installation") + instance.settingsDirectories.forEach { delete(it, "ReSharper settings") } + instance.transientDirectories.forEach { delete(it, "ReSharper caches") } + (instance.registryKeys + instance.commandLineKeys).forEach { key -> add(Step("delete registry key $key") { regDelete(key) }) } + delete(instance.visualStudioData, "Visual Studio experimental instance data") + if (instance.visualStudioSettings.exists()) add(Step("delete Visual Studio settings: ${instance.visualStudioSettings}") { + instance.visualStudioSettings.deleteRecursively() + instance.visualStudioSettings.parentFile.delete() // only succeeds if nothing else is left in it + }) + delete(instance.pluginPackage, "installed plugin package") + delete(instance.userFile, "HostFullIdentifier marker") + delete(instance.logFile, "ReSharper log") + // ReSharper rotates the log (ReSharper.1.log), splits out errors (ReSharper.err.log) and its process elevator logs + // next to it (JetBrains.Process.Elevator...log) + val logBase = instance.logFile.nameWithoutExtension + val logExtension = instance.logFile.extension + instance.logFile.parentFile.listFiles { f -> + f.isFile && ((f.name.startsWith("$logBase.") && f.name.endsWith(".$logExtension") && f != instance.logFile) || + (f.name.startsWith("JetBrains.Process.Elevator.") && f.name.endsWith(".log"))) + }.orEmpty().sortedBy { it.name }.forEach { delete(it, "ReSharper log") } + delete(instance.packedPlugin, "packed plugin") + if (includeInstallers) delete(required(installerDirectory, "installerDirectory"), "downloaded installers") + } + + private fun plan(instance: Instance, cleaning: Boolean, stopAfterClean: Boolean): String = buildString { + val (release, link) = resolveInstaller(sdkVersion.get()) + val installer = File(required(installerDirectory, "installerDirectory"), link.substringAfterLast('/')) + val current = setupProblem(instance) + appendLine("Visual Studio: ${instance.vs.displayName} ${instance.vs.version} (instance ${instance.vs.instanceId})") + appendLine(" devenv: ${instance.vs.devenv}") + appendLine("SdkVersion: ${sdkVersion.get()} -> ReSharper $release") + appendLine(" installer: $link") + appendLine(" " + if (installer.exists()) "cached at $installer" else "not cached; would download to $installer") + appendLine("Instance: ${instance.hostId} (/rootSuffix ${instance.suffix}), " + + if (instance.hive != null) "ReSharper installed" else "ReSharper not installed") + appendLine("Plugin: ${instance.id} ${instance.version}, " + (current ?: "set up")) + appendLine() + if (cleaning) { + val steps = cleanSteps(instance, cleanInstallers.get()) + appendLine("Would remove:") + if (steps.isEmpty()) appendLine(" - (nothing; already clean)") + steps.forEach { appendLine(" - ${it.description}") } + if (stopAfterClean) return@buildString + appendLine() + } + appendLine("Would run:") + if (cleaning || current != null) { + appendLine(" - install ReSharper into ${instance.hostId} (${installer.name})") + appendLine(" - register ${instance.id} ${instance.version} in its packages.config; write HostFullIdentifier to ${instance.userFile}") + appendLine(" - dotnet msbuild Restore;Rebuild;Pack -> ${instance.packedPlugin.parentFile}, unpack into ${instance.pluginPackage.parentFile}") + appendLine(" - run the installer again so the instance picks the plugin up") + } + appendLine(" - dotnet msbuild Restore;Rebuild ${instance.projectFile} (CopyPlugin deploys into the instance)") + appendLine(" - devenv /rootSuffix ${instance.suffix} /ReSharper.Internal /ReSharper.LogFile ${instance.logFile} /ReSharper.LogLevel Trace") + } + + private fun install(instance: Instance) { + val (release, link) = resolveInstaller(sdkVersion.get()) + println("ReSharper $release for SdkVersion ${sdkVersion.get()}") + val installer = File(required(installerDirectory, "installerDirectory"), link.substringAfterLast('/')) + if (!installer.exists()) { + println("Downloading ${installer.name} (several GB)") + download(link, installer) + } else { + println("Using cached installer from $installer") + } + + println("Installing ReSharper into ${instance.hostId}") + runInstaller(installer, instance) + val hive = instance.hive + ?: throw GradleException("ReSharper didn't install into ${instance.hostId} (no NuGet.Config under %LOCALAPPDATA%\\JetBrains\\${instance.platform})") + println("Found installation directory at $hive") + + // Register the plugin in the instance's packages.config + val packagesConfig = File(hive, "packages.config") + val packages = if (packagesConfig.exists()) parseXml(packagesConfig) + else parseXml("""""") + val entries = packages.getElementsByTagName("package").let { list -> (0 until list.length).map { list.item(it) as Element } } + if (entries.none { it.getAttribute("id") == instance.id }) { + val node = packages.createElement("package") + node.setAttribute("id", instance.id) + node.setAttribute("version", instance.version) + packages.documentElement.appendChild(node) + saveXml(packages, packagesConfig) + } + + // Point dotnet builds at the instance: CopyPlugin copies the built assembly there on every build + instance.userFile.writeText( + "" + + "${instance.hostId}") + + // Pack the plugin and unpack it into the local plugin repository (as `nuget install` would) + instance.pluginPackage.deleteRecursively() + exec("dotnet", "msbuild", "/t:Restore;Rebuild;Pack", instance.projectFile.path, "/v:minimal", + "/p:Configuration=${configuration.get()}", "/p:PackageVersion=${instance.version}", + "/p:PackageOutputPath=${instance.packedPlugin.parentFile.path}") + unpackPackage(instance.packedPlugin, instance.pluginPackage) + + println("Re-running the installer so ${instance.hostId} picks the plugin up") + runInstaller(installer, instance) + } + + private fun runInstaller(installer: File, instance: Instance) = + exec(installer.path, "/VsVersion=${instance.vs.majorVersion}.0", "/SpecificProductNames=ReSharper", + "/Hive=${instance.suffix}", "/Silent=True") + + /** `nuget install` layout (packages.config style): the .nupkg itself plus its files, minus the packaging metadata. */ + private fun unpackPackage(nupkg: File, destination: File) { + if (!nupkg.exists()) throw GradleException("Expected the packed plugin at $nupkg") + destination.mkdirs() + Files.copy(nupkg.toPath(), File(destination, nupkg.name).toPath(), StandardCopyOption.REPLACE_EXISTING) + ZipFile(nupkg).use { zip -> + zip.entries().asSequence() + .filter { !it.isDirectory } + .map { it to URLDecoder.decode(it.name.replace("+", "%2B"), Charsets.UTF_8) } + .filterNot { (_, name) -> + name == "[Content_Types].xml" || name.startsWith("_rels/") || name.startsWith("package/") || + (!name.contains('/') && name.endsWith(".nuspec")) + } + .forEach { (entry, name) -> + val target = File(destination, name) + target.parentFile.mkdirs() + zip.getInputStream(entry).use { input -> target.outputStream().use { input.copyTo(it) } } + } + } + } + + /** The installer and uninstaller can't change an instance that's open. */ + private fun ensureNotRunning(suffix: String) { + // Single quotes only: embedded double quotes don't survive Windows argument quoting + val commandLines = capture("powershell", "-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_Process | Where-Object { \$_.Name -eq 'devenv.exe' } | ForEach-Object { \$_.CommandLine }") + val pattern = Regex("""/rootSuffix\s+"?${Regex.escape(suffix)}"?(\s|$)""", RegexOption.IGNORE_CASE) + if (commandLines.lines().any { pattern.containsMatchIn(it) }) + throw GradleException("Close the experimental Visual Studio (/rootSuffix $suffix) first.") + } + + // --- Visual Studio discovery --------------------------------------------------------------------------------------- + + private class VisualStudio( + val displayName: String, val version: String, val instanceId: String, val channelId: String, + val installationPath: File, + ) { + val majorVersion get() = version.substringBefore('.') + val devenv get() = installationPath.listFiles().orEmpty().map { File(it, "IDE/devenv.exe") }.firstOrNull { it.exists() } + ?: throw GradleException("No devenv.exe under $installationPath") + } + + /** The newest complete Release-channel instance. */ + private fun findVisualStudio(): VisualStudio { + val vswhere = File(System.getenv("ProgramFiles(x86)") ?: "C:/Program Files (x86)", "Microsoft Visual Studio/Installer/vswhere.exe") + if (!vswhere.exists()) throw GradleException("$vswhere not found; is Visual Studio installed?") + val instances = parseXml(capture(vswhere.path, "-format", "xml", "-products", "*")) + .getElementsByTagName("instance").let { list -> (0 until list.length).map { list.item(it) as Element } } + .map { VisualStudio(it.text("displayName"), it.text("installationVersion"), it.text("instanceId"), + it.text("channelId"), File(it.text("installationPath"))) } + .filter { it.channelId.contains("Release") } + return instances.maxWithOrNull(compareBy>(versionOrder) { it.version.split('.').map { part -> part.toIntOrNull() ?: 0 } }) + ?: throw GradleException("No complete Release-channel Visual Studio found by $vswhere." + incompleteInstances(vswhere)) + } + + /** vswhere hides instances that are still installing/updating; say so when that's why none were found. */ + private fun incompleteInstances(vswhere: File): String { + val all = parseXml(capture(vswhere.path, "-all", "-format", "xml", "-products", "*")).getElementsByTagName("instance") + val names = (0 until all.length).map { all.item(it) as Element }.map { "${it.text("displayName")} ${it.text("installationVersion")}" } + return if (names.isEmpty()) "" else " Incomplete (still installing or updating?): ${names.joinToString()}." + } + + // --- ReSharper installer ------------------------------------------------------------------------------------------- + + /** The release matching the SDK version (else the newest of its line, with a warning) and its Checked installer link. */ + private fun resolveInstaller(sdkVersion: String): Pair { + val line = sdkVersion.split('.').take(2).joinToString(".") + val url = "https://data.services.jetbrains.com/products/releases?code=RSU&type=eap&type=release&majorVersion=$line" + @Suppress("UNCHECKED_CAST") + val entries = ((JsonSlurper().parseText(fetch(url)) as Map)["RSU"] as? List>).orEmpty() + if (entries.isEmpty()) throw GradleException("No ReSharper $line releases found at $url") + // The release API names prereleases differently from NuGet: 2026.3.0-eap02 -> 2026.3.EAP2 (releases are identical) + val apiVersion = Regex("""^(\d+\.\d+)\.0-(eap|rc)0*(\d+)$""", RegexOption.IGNORE_CASE).replace(sdkVersion, "$1.$2$3") + val entry = entries.firstOrNull { (it["version"] as? String).equals(apiVersion, ignoreCase = true) } + ?: entries.first().also { logger.warn("No ReSharper release matches SDK version $sdkVersion exactly; using ${it["version"]} (${it["type"]})") } + + @Suppress("UNCHECKED_CAST") + val link = (((entry["downloads"] as? Map)?.get("windows") as? Map)?.get("link") as? String) + ?: throw GradleException("No Windows download for ReSharper ${entry["version"]}") + return entry["version"].toString() to link.replace(".exe", ".Checked.exe") + } + + private fun download(url: String, target: File) { + target.parentFile.mkdirs() + val partial = File(target.path + ".part") + val response = http.send(HttpRequest.newBuilder(URI.create(url)).build(), HttpResponse.BodyHandlers.ofFile(partial.toPath())) + if (response.statusCode() != 200) throw GradleException("Couldn't download $url: HTTP ${response.statusCode()}") + // Only a complete download becomes the cached installer + Files.move(partial.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + + private fun fetch(url: String): String { + val response = http.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).build(), + HttpResponse.BodyHandlers.ofString()) + if (response.statusCode() != 200) throw GradleException("Couldn't fetch $url: HTTP ${response.statusCode()}") + return response.body() + } + + // --- Helpers ------------------------------------------------------------------------------------------------------- + + private fun exec(vararg command: String) { + println("> ${command.joinToString(" ")}") + execOperations.exec { commandLine(*command) } + } + + private fun capture(vararg command: String): String { + val output = ByteArrayOutputStream() + execOperations.exec { commandLine(*command); standardOutput = output } + return output.toString(Charsets.UTF_8) + } + + /** Direct subkeys of a registry key (none if the key doesn't exist). */ + private fun registrySubkeys(key: String): List { + val output = ByteArrayOutputStream() + val result = execOperations.exec { + commandLine("reg", "query", key) + standardOutput = output + errorOutput = ByteArrayOutputStream() + isIgnoreExitValue = true + } + if (result.exitValue != 0) return emptyList() + val prefix = key.replaceFirst("HKCU\\", "HKEY_CURRENT_USER\\") + "\\" + return output.toString(Charsets.UTF_8).lines().map { it.trim() } + .filter { it.startsWith(prefix, ignoreCase = true) && !it.substring(prefix.length).contains('\\') } + .map { "HKCU\\" + it.substringAfter("HKEY_CURRENT_USER\\") } + } + + private fun regDelete(key: String) { + execOperations.exec { + commandLine("reg", "delete", key, "/f") + standardOutput = ByteArrayOutputStream() + isIgnoreExitValue = true + } + } + + private fun required(property: RegularFileProperty, what: String): File = + property.orNull?.asFile ?: throw GradleException("$name: set $what in the build script") + + private fun required(property: DirectoryProperty, what: String): File = + property.orNull?.asFile ?: throw GradleException("$name: set $what in the build script") + + private fun localAppData() = File(System.getenv("LOCALAPPDATA") ?: throw GradleException("LOCALAPPDATA isn't set")) + + private fun Element.text(tag: String) = getElementsByTagName(tag).item(0)?.textContent?.trim().orEmpty() + + private fun parseXml(file: File): Document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file) + private fun parseXml(text: String): Document = + DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(text.byteInputStream()) + + private fun saveXml(document: Document, file: File) { + TransformerFactory.newInstance().newTransformer().apply { + setOutputProperty(OutputKeys.INDENT, "yes") + setOutputProperty(OutputKeys.ENCODING, "utf-8") + }.transform(DOMSource(document), StreamResult(file)) + } + + companion object { + val USAGE = """ + |Usage: ./gradlew runVisualStudio [options] + | + |Runs the plugin's ReSharper build in an experimental Visual Studio instance (devenv /rootSuffix). The first run + |sets the instance up: downloads the ReSharper installer for the configured SDK version, installs ReSharper into + |the instance and installs the plugin package. Later runs rebuild the plugin (the build copies it into the + |instance) and launch Visual Studio with ReSharper's internal mode and trace logging. + | + |Options: + | --plan Show what would happen; change nothing. Combine with --clean/--reinstall to preview them. + | --clean Remove the experimental instance: uninstall ReSharper from it, then delete its settings, + | caches, registry keys, Visual Studio data and installed plugin package, plus the + | .csproj.user marker, the log and the packed plugin. Keeps the downloaded installers. + | Never touches the normal (non-suffixed) Visual Studio or ReSharper. + | --clean-installers --clean, and also delete the downloaded ReSharper installers. + | --reinstall --clean, then set the instance up from scratch and launch. + | --root-suffix Experimental instance name (devenv /rootSuffix); overrides the build script's. + | --plugin-version Version the plugin package is installed as. Default: 9999.0.0. + | --usage This text. + | + |Configured in the build script: pluginId, projectFile, sdkVersion, rootSuffix, installerDirectory, + |packageOutputDirectory, logFile, and optionally pluginVersion and configuration (Debug). + |""".trimMargin() + + private val http: HttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(20)) + .build() + + private val versionOrder = Comparator> { a, b -> + (0 until maxOf(a.size, b.size)).map { a.getOrElse(it) { 0 }.compareTo(b.getOrElse(it) { 0 }) }.firstOrNull { it != 0 } ?: 0 + } + } +} diff --git a/publishPlugin.ps1 b/publishPlugin.ps1 deleted file mode 100644 index e5e62cd..0000000 --- a/publishPlugin.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -Param( - [string]$Configuration = "Release", - [Parameter(Mandatory=$true)] - [string]$Version, - [Parameter(Mandatory=$true)] - [string]$ApiKey -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" -$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent -Set-Location $PSScriptRoot - -. ".\settings.ps1" - -$ChangelogText = ([Regex]::Matches([System.IO.File]::ReadAllText("CHANGELOG.md"), '(?s)(##.+?.+?)(?=##|$)').Captures | Select -First 10) -Join '' - -Invoke-Exe $MSBuildPath "/t:Restore;Rebuild;Pack" "$SolutionPath" "/v:minimal" "/p:Configuration=$Configuration" "/p:PackageOutputPath=$OutputDirectory" "/p:PackageVersion=$Version" "/p:PackageReleaseNotes=`"$ChangelogText`"" -$PackageFile = "$OutputDirectory\$PluginId.$Version*.nupkg" -Invoke-Exe $NuGetPath push $PackageFile -Source "https://plugins.jetbrains.com/api/v2/package" -ApiKey $ApiKey diff --git a/runVisualStudio.ps1 b/runVisualStudio.ps1 deleted file mode 100644 index 5aeff34..0000000 --- a/runVisualStudio.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -Param( - $RootSuffix = "RimworldDev", - $Version = "9999.0.0" -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" -$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent -Set-Location $PSScriptRoot - -. ".\settings.ps1" - -$UserProjectXmlFile = "$SourceBasePath\$PluginId\$PluginId.csproj.user" - -if (!(Test-Path "$UserProjectXmlFile")) { - # Get versions from Plugin.props file - $PluginPropsFile = "$SourceBasePath\Plugin.props" - $PluginPropsXml = [xml] (Get-Content "$PluginPropsFile") - $SdkVersionNode = $PluginPropsXml.SelectSingleNode(".//SdkVersion") - $VersionSplit = $SdkVersionNode.InnerText.Split(".") - $MajorVersion = "$($VersionSplit[0]).$($VersionSplit[1])" - - # Determine download link - $ReleaseUrl = "https://data.services.jetbrains.com/products/releases?code=RSU&type=eap&type=release&majorVersion=$MajorVersion" - $VersionEntry = $(Invoke-WebRequest -UseBasicParsing $ReleaseUrl | ConvertFrom-Json).RSU[0] - ## TODO: check versions - $DownloadLink = [uri] ($VersionEntry.downloads.windows.link.replace(".exe", ".Checked.exe")) - - # Download installer - $InstallerFile = "$PSScriptRoot\build\installer\$($DownloadLink.Segments[-1])" - if (!(Test-Path $InstallerFile)) { - mkdir -Force $(Split-Path $InstallerFile -Parent) > $null - Write-Output "Downloading $($DownloadLink.Segments[-2].TrimEnd("/")) installer" - (New-Object System.Net.WebClient).DownloadFile($DownloadLink, $InstallerFile) - } else { - Write-Output "Using cached installer from $InstallerFile" - } - - # Execute installer - Write-Output "Installing experimental hive" - Invoke-Exe $InstallerFile "/VsVersion=$VisualStudioMajorVersion.0" "/SpecificProductNames=ReSharper" "/Hive=$RootSuffix" "/Silent=True" - - $Installations = @(Get-ChildItem "$env:LOCALAPPDATA\JetBrains\ReSharperPlatformVs$VisualStudioMajorVersion\vAny_$VisualStudioInstanceId$RootSuffix\NuGet.Config") - if ($Installations.Count -ne 1) { Write-Error "Found no or multiple installation directories: $Installations" } - $InstallationDirectory = $Installations.Directory - Write-Host "Found installation directory at $InstallationDirectory" - - # Adapt packages.config - if (Test-Path "$InstallationDirectory\packages.config") { - $PackagesXml = [xml] (Get-Content "$InstallationDirectory\packages.config") - } else { - $PackagesXml = [xml] ("") - } - - if ($null -eq $PackagesXml.SelectSingleNode(".//package[@id='$PluginId']/@id")) { - $PluginNode = $PackagesXml.CreateElement('package') - $PluginNode.setAttribute("id", "$PluginId") - $PluginNode.setAttribute("version", "$Version") - - $PackagesNode = $PackagesXml.SelectSingleNode("//packages") - $PackagesNode.AppendChild($PluginNode) > $null - - $PackagesXml.Save("$InstallationDirectory\packages.config") - } - - # Adapt user project file - $HostIdentifier = "$($InstallationDirectory.Parent.Name)_$($InstallationDirectory.Name.Split('_')[-1])" - - Set-Content -Path "$UserProjectXmlFile" -Value "" - - $ProjectXml = [xml] (Get-Content "$UserProjectXmlFile") - $HostIdentifierNode = $ProjectXml.SelectSingleNode(".//HostFullIdentifier") - $HostIdentifierNode.InnerText = $HostIdentifier - $ProjectXml.Save("$UserProjectXmlFile") - - # Install plugin - $PluginRepository = "$env:LOCALAPPDATA\JetBrains\plugins" - Remove-Item "$PluginRepository\${PluginId}.${Version}" -Recurse -ErrorAction Ignore - Invoke-Exe $MSBuildPath "/t:Restore;Rebuild;Pack" "$SolutionPath" "/v:minimal" "/p:PackageVersion=$Version" "/p:PackageOutputPath=`"$OutputDirectory`"" - Invoke-Exe $NuGetPath install $PluginId -OutputDirectory "$PluginRepository" -Source "$OutputDirectory" -DependencyVersion Ignore - - Write-Output "Re-installing experimental hive" - Invoke-Exe "$InstallerFile" "/VsVersion=$VisualStudioMajorVersion.0" "/SpecificProductNames=ReSharper" "/Hive=$RootSuffix" "/Silent=True" -} else { - Write-Warning "Plugin is already installed. To trigger reinstall, delete $UserProjectXmlFile." -} - -Invoke-Exe $MSBuildPath "/t:Restore;Rebuild" "$SolutionPath" "/v:minimal" -Invoke-Exe $DevEnvPath "/rootSuffix $RootSuffix" "/ReSharper.Internal" "/ReSharper.LogFile $PSScriptRoot\ReSharper.log" "/ReSharper.LogLevel Trace" diff --git a/settings.ps1 b/settings.ps1 deleted file mode 100644 index cdd2df1..0000000 --- a/settings.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -$PluginId = "ReSharperPlugin.RimworldDev" -$SolutionPath = "$PSScriptRoot\ReSharperPlugin.RimworldDev.sln" -$SourceBasePath = "$PSScriptRoot\src\dotnet" - -$VsWhereOutput = [xml] (& "$PSScriptRoot\tools\vswhere.exe" -format xml -products *) -$VisualStudio = $VsWhereOutput.instances.instance | - Where-Object { $_.channelId -match "Release" } | - Sort-Object -Property installationVersion | - Select-Object -Last 1 - -$VisualStudioBaseDirectory = $VisualStudio.installationPath -$VisualStudioMajorVersion = ($VisualStudio.installationVersion -split '\.')[0] -$VisualStudioInstanceId = $VisualStudio.instanceId -$DevEnvPath = Get-ChildItem "$VisualStudioBaseDirectory\*\IDE\devenv.exe" -$MSBuildPath = Get-ChildItem "$VisualStudioBaseDirectory\MSBuild\*\Bin\MSBuild.exe" - -$OutputDirectory = "$PSScriptRoot\output" -$NuGetPath = "$PSScriptRoot\tools\nuget.exe" - -Function Invoke-Exe { - param( - [parameter(mandatory=$true,position=0)] [ValidateNotNullOrEmpty()] [string] $Executable, - [Parameter(ValueFromRemainingArguments=$true)][String[]] $Arguments, - [parameter(mandatory=$false)] [array] $ValidExitCodes = @(0) - ) - - Write-Host "> $Executable $Arguments" - $rc = Start-Process -FilePath $Executable -ArgumentList $Arguments -NoNewWindow -Passthru - $rc.Handle # to initialize handle according to https://stackoverflow.com/a/23797762/2684760 - $rc.WaitForExit() - if (-Not $ValidExitCodes.Contains($rc.ExitCode)) { - throw "'$Executable $Arguments' failed with exit code $($rc.ExitCode), valid exit codes: $ValidExitCodes" - } -} From 408a74699f641bc2542488ce01362692937f12f0 Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 18 Sep 2026 15:36:19 +0100 Subject: [PATCH 4/5] Add some more improvements to the build system --- .github/workflows/CI.yml | 6 +- .github/workflows/Deploy.yml | 3 +- .../Rider__Frontend__Windows_.xml | 3 - .../.idea/runConfigurations/VisualStudio.xml | 4 +- .run/Build Plugin.run.xml | 3 - Directory.Build.props | 1 + build.gradle.kts | 2 +- docs/rider-version.md | 69 ------------------ global.json | 8 +- tools/nuget.exe | Bin 6512008 -> 0 bytes tools/vswhere.exe | Bin 397944 -> 0 bytes 11 files changed, 14 insertions(+), 85 deletions(-) delete mode 100644 docs/rider-version.md delete mode 100644 tools/nuget.exe delete mode 100644 tools/vswhere.exe diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 912ccfd..0813a47 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -23,7 +23,8 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + # The SDK version is pinned in global.json (one place for local builds and CI) + global-json-file: global.json - run: ./gradlew :buildPlugin --no-daemon - run: ./gradlew :buildResharperPlugin --no-daemon - uses: actions/upload-artifact@v4 @@ -47,5 +48,6 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + # The SDK version is pinned in global.json (one place for local builds and CI) + global-json-file: global.json - run: ./gradlew :testDotNet --no-daemon \ No newline at end of file diff --git a/.github/workflows/Deploy.yml b/.github/workflows/Deploy.yml index 265fa27..9696fb6 100644 --- a/.github/workflows/Deploy.yml +++ b/.github/workflows/Deploy.yml @@ -23,7 +23,8 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + # The SDK version is pinned in global.json (one place for local builds and CI) + global-json-file: global.json - name: Publish Rider Package run: ./gradlew :publishPlugin -PBuildConfiguration="Release" -PPluginVersion="${{ github.ref_name }}" -PPublishToken="${{ secrets.PUBLISH_TOKEN }}" env: diff --git a/.idea/.idea.ReSharperPlugin.RimworldDev/.idea/runConfigurations/Rider__Frontend__Windows_.xml b/.idea/.idea.ReSharperPlugin.RimworldDev/.idea/runConfigurations/Rider__Frontend__Windows_.xml index 799ed8b..7cb4cb6 100644 --- a/.idea/.idea.ReSharperPlugin.RimworldDev/.idea/runConfigurations/Rider__Frontend__Windows_.xml +++ b/.idea/.idea.ReSharperPlugin.RimworldDev/.idea/runConfigurations/Rider__Frontend__Windows_.xml @@ -4,9 +4,6 @@