From ae0f936e9a9cc18e943ba9297d6734dc0b05809b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 16:19:02 +0900 Subject: [PATCH 01/18] fix: locate the CLion backend and verify stable IDE runtimes --- ...ion-backend-location-and-stable-runtime.md | 66 ++++++++ .github/workflows/ci.yml | 8 + .github/workflows/clion-stable.yml | 90 ++++++++++ .gitignore | 1 + docs/architecture.md | 7 + mide-plugins/clion/README.md | 20 +++ .../clion/integration-tests/.gitignore | 3 + .../clion/integration-tests/build.gradle.kts | 38 +++++ .../integration-tests/settings.gradle.kts | 1 + .../src/test/kotlin/PackagedPluginTest.kt | 157 ++++++++++++++++++ .../mcppcommunity/mcppclion/McppSession.kt | 30 +++- .../mcppclion/backend/Protocol.kt | 16 ++ .../mcppcommunity/mcppclion/ProtocolTest.kt | 22 +++ 13 files changed, 455 insertions(+), 4 deletions(-) create mode 100644 .agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md create mode 100644 .github/workflows/clion-stable.yml create mode 100644 mide-plugins/clion/integration-tests/.gitignore create mode 100644 mide-plugins/clion/integration-tests/build.gradle.kts create mode 100644 mide-plugins/clion/integration-tests/settings.gradle.kts create mode 100644 mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt create mode 100644 mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt create mode 100644 mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md new file mode 100644 index 0000000..553888d --- /dev/null +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -0,0 +1,66 @@ +# CLion backend location and stable runtime validation + +Status: implemented on the review branch; stable CI validation pending. + +## Observed failure + +The published mcpp-clion 0.2.0 package contains `backend/mcpp-ide.okb` and its runners. Its +`pluginDirectory()` nevertheless derives the installation directory from the class loader's +`CodeSource.location`. A full local CLion 2026.1.5.2 run, build CL-261.27258.141, verified that +the actual `McppSession` class has a null location. The original package consequently fails +to find the bundled backend in an isolated environment. + +A separate user log records downloading 0.2.0, dynamically loading the plugin, and starting a +backend with source `xlings`. It records neither the external backend's version nor the +unknown-method response. The log establishes fallback selection, not the selected backend's +protocol. No user log excerpts, account names, project names or local paths are included here. + +An independent wire experiment with the real released 0.1.0 backend reproduces error -32601 +for `mcpp-ide/i18n/bundle`; that backend reports protocol 1. The 0.2.0 bundled backend reports +protocol 2 and answers the request. This is a sufficient reproduction of the notification, +not proof of the user's external backend version. The shorter reported `mcpp-ide/bundle` +spelling has not been observed on the wire; the current client sends `mcpp-ide/i18n/bundle`. + +## Responsibilities and implementation + +The CLion adapter owns installation lookup and protocol acceptance. It obtains its directory +from the IDE plugin descriptor and retains the existing backend preference: explicit setting, +bundle, xlings, PATH. The initializer rejects incompatible or missing protocol values before +sending any custom requests. No legacy method aliases are introduced. + +Startup errors previously reached an action's notification handler without a corresponding +log entry. Startup failures now write a warning with backend source, negotiated protocol, +and, for RPC failures, wire method and error code. Process launch errors and a missing backend +also produce a warning. The existing startup line continues to identify the selected command +locally; private diagnostics are not published as CI artifacts. + +The mcppls language service is a separate connection. Its successful initialization in the +user log does not validate the mcpp-ide session, and this lookup failure does not require a +clangd rebuild or a runtime dependency release. + +## Controlled local result + +The original input is the Marketplace 0.2.0 ZIP, SHA-256 +`7e3fec81186d897e4185413ffbf3b69b08f9185f0da78842f4826f2408d274ba`. +Only the plugin JAR was changed for a diagnostic comparison. The backend bundle and every +runner remained byte-identical. With the descriptor lookup and handshake diagnostics, the +same CLion build completes protocol 2 startup using backend 0.2.0, loads translations and +commands, opens the mcpp tool window, and creates all three views. + +This patched ZIP is an experiment, not a published release or the canonical Gradle package. +The normal CI build must still compile and package the complete plugin and run that same +candidate ZIP on both stable versions. + +## Runtime coverage and limits + +`clion-stable.yml` pins CLion 2026.1.5.2 and 2026.2.4.1. Each test starts the actual IDE, +installs the same packaged plugin ZIP with mcppls 0.0.19, opens a synthetic mcpp project, +checks the actual IDE version and build, completes the session, and verifies the visible +tool window and three views. The standalone test build does not compile the product plugin +against each tested SDK. A manual release check can reuse the published ZIP without rebuilding +the backend. Only a report of public versions and assertions is uploaded. + +Plugin Verifier remains a separate static compatibility check. These runtime checks cover +Linux startup and views on the two stable IDE families; native Windows/macOS GUI behavior, +dynamic uninstall/reinstall, debugging and every project action require their own evidence. +The null CodeSource reproduction uses a cold IDE start and does not depend on dynamic installation. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12a4706..6531600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -471,6 +471,14 @@ jobs: name: mcpp-clion-zip path: mide-plugins/clion/build/distributions/*.zip + # Both stable families load the exact same packaged ZIP. These are actual + # IDE startup/session checks, in addition to the static Plugin Verifier. + clion-stable: + needs: clion + uses: ./.github/workflows/clion-stable.yml + with: + plugin_artifact: mcpp-clion-zip + # The Neovim plugin (mide-plugins/nvim) in a headless Neovim, against the backend built here: over # the mock mcpp on the oldest and the newest Neovim it supports, then over the real mcpp, which # builds the test's project. Neovim comes from xlings (`nvim` in xim-pkgindex), each version diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml new file mode 100644 index 0000000..ef5fce1 --- /dev/null +++ b/.github/workflows/clion-stable.yml @@ -0,0 +1,90 @@ +name: CLion stable runtime + +on: + workflow_call: + inputs: + plugin_artifact: + type: string + required: true + workflow_dispatch: + inputs: + release_tag: + description: Published plugin release to install without rebuilding the backend + type: string + default: v0.2.0 + +permissions: + contents: read + actions: read + +jobs: + runtime: + name: packaged plugin on CLion ${{ matrix.version }} + runs-on: ubuntu-24.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + version: ['2026.1.5.2', '2026.2.4.1'] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + - name: Gradle and a virtual display + run: | + xlings install gradle -y -g + sudo apt-get update -qq + sudo apt-get install -y xvfb + - uses: actions/cache@v5 + with: + path: ~/.gradle/caches/modules-2 + key: clion-stable-runtime-${{ hashFiles('mide-plugins/clion/integration-tests/*.gradle.kts') }} + - name: The candidate ZIP built once by CI + if: inputs.plugin_artifact != '' + uses: actions/download-artifact@v5 + with: + name: ${{ inputs.plugin_artifact }} + path: stable-plugin + - name: The published ZIP for an isolated release check + if: inputs.plugin_artifact == '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag || 'v0.2.0' }} + run: | + set -euo pipefail + [[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + mkdir -p stable-plugin + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ + --pattern 'mcpp-clion-*.zip' --dir stable-plugin + - name: The pinned language-service plugin + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(sed -n 's/^mcpplsPluginVersion *= *//p' mide-plugins/clion/gradle.properties)" + gh release download "v$version" --repo Sunrisepeak/mcpp-language-server \ + --pattern "mcppls-clion-$version-linux-x64.zip" --dir stable-plugin + - name: Start the real IDE and complete the plugin startup chain + env: + IDE_VERSION: ${{ matrix.version }} + run: | + set -euo pipefail + plugin="$(find stable-plugin -maxdepth 1 -name 'mcpp-clion-*.zip' -print)" + language="$(find stable-plugin -maxdepth 1 -name 'mcppls-clion-*.zip' -print)" + test -n "$plugin" && test -n "$language" + xvfb-run -a gradle --no-daemon --max-workers=4 \ + -p mide-plugins/clion/integration-tests test \ + -PpluginZip="$GITHUB_WORKSPACE/$plugin" \ + -PmcpplsZip="$GITHUB_WORKSPACE/$language" \ + -PideVersion="$IDE_VERSION" \ + -Poutput="$RUNNER_TEMP/clion-stable-result" + # The report contains public versions and assertions only. Private user + # logs and arbitrary IDE process output are not uploaded as artifacts. + - uses: actions/upload-artifact@v5 + if: always() + with: + name: clion-stable-${{ matrix.version }} + path: ${{ runner.temp }}/clion-stable-result/result.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 708cb65..26e4a91 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ webui/dist/ compile_commands.json dist-upload/ .xlings/ +out/ide-tests/ diff --git a/docs/architecture.md b/docs/architecture.md index d74ae17..711ffe9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,13 @@ bundle and the target set's tree (`mcpp-ide--openkal-bundle-all.tar.gz` one pack on Linux, takes okrun from that tree, opens both editor packages and the tree on every system and starts what they carry for it (`.github/scripts/check-package.sh`). +The CLion adapter obtains its installation directory from the IDE's plugin descriptor. Its +class loader's `CodeSource` URL is not required. The session accepts protocol 2 before sending +`mcpp-ide/i18n/bundle` and `mcpp-ide/commands/list`. Backend selection and startup failures are +recorded in `idea.log`; RPC startup failures include the method and error code. The stable +CLion runtime checks install one packaged plugin ZIP in both stable IDE families and exercise +the complete startup chain in each IDE process. + ## Generated declarations `spec/` is the source of everything the editors declare statically. `mcpp-ide-dev gen vscode` writes diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index f44542b..e925472 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -42,3 +42,23 @@ docs/architecture.md, Distribution. Without them the plugin finds the backend th `MCPP_IDE_MOCK_MCPP` name the programs `mcpp build` produced, and the views' models and the settings form headless. `npm run test:webui` in `../vscode` runs the graph page in Chromium over the backend. + +The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 IDEs by +`clion-stable.yml`. Both versions load the same ZIP and the pinned mcppls plugin. The test opens +an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, +and activates the mcpp tool window. It supplements Plugin Verifier's static compatibility checks. + +An individual packaged plugin can be checked without rebuilding the backend: + +```bash +xvfb-run -a gradle --no-daemon -p integration-tests test \ + -PpluginZip=/absolute/path/mcpp-clion-0.2.0.zip \ + -PmcpplsZip=/absolute/path/mcppls-clion-0.0.19-linux-x64.zip \ + -PideVersion=2026.1.5.2 +``` + +The CLion session writes backend selection and startup failures to `idea.log`, under +`io.github.mcppcommunity.mcppclion.McppSession`. A protocol mismatch stops initialization before +custom requests. For an explicitly configured older backend, update it or clear +`mcpp.ide.backendPath` to select the bundled backend. Error notifications can also appear in +the IDE's notification area. diff --git a/mide-plugins/clion/integration-tests/.gitignore b/mide-plugins/clion/integration-tests/.gitignore new file mode 100644 index 0000000..ac0e82c --- /dev/null +++ b/mide-plugins/clion/integration-tests/.gitignore @@ -0,0 +1,3 @@ +.gradle/ +.kotlin/ +build/ diff --git a/mide-plugins/clion/integration-tests/build.gradle.kts b/mide-plugins/clion/integration-tests/build.gradle.kts new file mode 100644 index 0000000..f4e0243 --- /dev/null +++ b/mide-plugins/clion/integration-tests/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + kotlin("jvm") version "2.4.0" +} + +repositories { + mavenCentral() + maven("https://www.jetbrains.com/intellij-repository/releases") + maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies") + maven("https://download.jetbrains.com/teamcity-repository") + maven("https://cache-redirector.jetbrains.com/packages.jetbrains.team/maven/p/grazi/grazie-platform-public") +} + +dependencies { + testImplementation("com.jetbrains.intellij.tools:ide-starter-squashed:262.10968.63") + testImplementation("com.jetbrains.intellij.tools:ide-starter-junit5:262.10968.63") + testImplementation("com.jetbrains.intellij.tools:ide-starter-driver:262.10968.63") + testImplementation("com.jetbrains.intellij.driver:driver-client:262.10968.63") + testImplementation("com.jetbrains.intellij.driver:driver-sdk:262.10968.63") + testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") + testImplementation("org.kodein.di:kodein-di-jvm:7.20.2") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +kotlin { jvmToolchain(25) } + +// This build has no dependency on the plugin's main output. Both IDE versions +// load the same packaged ZIP; compiling the plugin against each tested SDK +// would hide binary compatibility defects in the actual distributed plugin. +tasks.test { + useJUnitPlatform() + maxHeapSize = "2g" + for (key in listOf("pluginZip", "mcpplsZip", "ideVersion", "ideHome", "output")) { + providers.gradleProperty(key).orNull?.let { systemProperty("mcpp.clion.$key", it) } + } + providers.gradleProperty("testHome").orNull?.let { systemProperty("user.home", it) } + testLogging { events("passed", "failed", "skipped") } +} diff --git a/mide-plugins/clion/integration-tests/settings.gradle.kts b/mide-plugins/clion/integration-tests/settings.gradle.kts new file mode 100644 index 0000000..f361a5f --- /dev/null +++ b/mide-plugins/clion/integration-tests/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "mcpp-clion-integration-tests" diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt new file mode 100644 index 0000000..ffe6803 --- /dev/null +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -0,0 +1,157 @@ +import com.intellij.driver.client.Remote +import com.intellij.driver.client.service +import com.intellij.driver.client.utility +import com.intellij.driver.sdk.invokeAction +import com.intellij.driver.sdk.Project +import com.intellij.driver.sdk.singleProject +import com.intellij.driver.sdk.waitForProjectOpen +import com.intellij.ide.starter.ci.CIServer +import com.intellij.ide.starter.ci.NoCIServer +import com.intellij.ide.starter.di.di +import com.intellij.ide.starter.driver.engine.runIdeWithDriver +import com.intellij.ide.starter.ide.IdeDistributionFactory +import com.intellij.ide.starter.ide.IdeInstaller +import com.intellij.ide.starter.models.IdeInfo +import com.intellij.ide.starter.models.TestCase +import com.intellij.ide.starter.plugins.PluginConfigurator +import com.intellij.ide.starter.project.LocalProjectInfo +import com.intellij.ide.starter.runner.Starter +import com.intellij.platform.testFramework.teamCity.TeamCityReporter.SyntheticTestKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.direct +import org.kodein.di.instance +import java.nio.file.Files +import java.nio.file.Path + +private const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" + +@Remote("io.github.mcppcommunity.mcppclion.McppSession", plugin = PLUGIN_ID) +interface McppSessionRemote { + fun start(): StartupFuture + fun getCommands(): List + fun getDegraded(): Boolean +} + + +@Remote("java.util.concurrent.CompletableFuture") +interface StartupFuture { + fun isDone(): Boolean + fun get(): RemoteJson +} + +@Remote("com.google.gson.JsonObject") +interface RemoteJson { fun get(name: String): RemoteJsonValue } + +@Remote("com.google.gson.JsonElement") +interface RemoteJsonValue { + fun getAsInt(): Int + fun getAsString(): String + fun getAsJsonObject(): RemoteJson +} + +@Remote("com.intellij.openapi.application.ApplicationInfo") +interface ApplicationInfoFactory { fun getInstance(): ApplicationInfoRemote } + +@Remote("com.intellij.openapi.application.ApplicationInfo") +interface ApplicationInfoRemote { + fun getFullVersion(): String + fun getBuild(): BuildNumberRemote +} + +@Remote("com.intellij.openapi.util.BuildNumber") +interface BuildNumberRemote { fun asString(): String } + +@Remote("com.intellij.openapi.wm.ToolWindowManager") +interface ToolWindowManagerFactory { fun getInstance(project: Project): ToolWindowManagerRemote } +@Remote("com.intellij.openapi.wm.ToolWindowManager") +interface ToolWindowManagerRemote { fun getToolWindow(id: String): ToolWindowRemote? } +@Remote("com.intellij.openapi.wm.ToolWindow") +interface ToolWindowRemote { + fun isVisible(): Boolean + fun getContentManager(): ContentManagerRemote +} +@Remote("com.intellij.ui.content.ContentManager") +interface ContentManagerRemote { fun getContentCount(): Int } + +class PackagedPluginTest { + init { + // A successful process exit alone cannot prove the plugin loaded: + // exceptions reported by the IDE must fail the integration test. + di = DI { + extend(di) + bindSingleton(overrides = true) { + object : CIServer by NoCIServer { + override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?, kind: SyntheticTestKind, generifyTestName: Boolean) { + throw AssertionError("IDE failure: $testName: $message") + } + } + } + } + } + + @Test + fun packagedPluginStartsItsBundledBackend() { + val version = System.getProperty("mcpp.clion.ideVersion") ?: error("ideVersion is required") + val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() + Files.createDirectories(output) + val fixture = output.resolve("project") + Files.createDirectories(fixture.resolve("src")) + Files.writeString(fixture.resolve("mcpp.toml"), "[package]\nname = \"clion-smoke\"\nversion = \"0.1.0\"\n") + Files.writeString(fixture.resolve("src/main.cpp"), "int main() { return 0; }\n") + val localHome = System.getProperty("mcpp.clion.ideHome")?.let(Path::of) + var ide = IdeInfo(productCode = "CL", platformPrefix = "CLion", executableFileName = "clion", fullName = "CLion", version = version, buildType = "release") + if (localHome != null) { + // Reuse the verified full local distribution without copying an + // additional SDK into the ordinary disk cache. + ide = ide.copy(getInstaller = { + object : IdeInstaller { + override suspend fun install(ideInfo: IdeInfo) = + Files.readString(localHome.resolve("build.txt")).trim().removePrefix("CL-") to + di.direct.instance().installIDE(localHome, "clion") + } + }) + } + val context = Starter.newContext("mcpp-clion-stable-$version-${System.currentTimeMillis()}", TestCase(ide, LocalProjectInfo(fixture))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.mcpplsZip") ?: error("mcpplsZip is required"))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.pluginZip") ?: error("pluginZip is required"))) + context.applyVMOptionsPatch { + withXmx(2048) + addSystemProperty("idea.trust.all.projects", true) + addSystemProperty("ide.show.tips.on.startup.default.value", false) + } + context.runIdeWithDriver().useDriverAndCloseIde { + waitForProjectOpen() + withContext { + val application = utility().getInstance() + assertEquals(version, application.getFullVersion(), "the requested stable IDE actually runs") + val ideBuild = application.getBuild().asString() + val session = service(singleProject()) + val future = session.start() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + while (!future.isDone() && System.nanoTime() < deadline) Thread.sleep(100) + assertTrue(future.isDone(), "the complete plugin startup chain finishes") + val initialized = future.get() + assertEquals(2, initialized.get("protocol").getAsInt()) + val backendVersion = initialized.get("serverInfo").getAsJsonObject().get("version").getAsString() + assertFalse(session.getCommands().isEmpty(), "i18n/bundle and commands/list complete before startup succeeds") + assertFalse(session.getDegraded()) + invokeAction("ActivatemcppToolWindow") + val window = utility().getInstance(singleProject()).getToolWindow("mcpp") + assertNotNull(window, "the mcpp tool window is registered") + val visibleDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (!window!!.isVisible() && System.nanoTime() < visibleDeadline) Thread.sleep(100) + assertTrue(window!!.isVisible(), "the tool window opens") + assertEquals(3, window.getContentManager().getContentCount(), "Project, Libraries and Cache views load") + // Store only public versions and assertions. Raw IDE logs can + // contain usernames, private paths or workspace details. + Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","ideBuild":"$ideBuild","backendVersion":"$backendVersion","protocol":2,"startup":true,"commands":true,"toolWindowVisible":true,"toolWindowTabs":3}""" + "\n") + } + } + } +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index e820d75..54ee858 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -20,6 +20,9 @@ import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project import io.github.mcppcommunity.mcppclion.backend.Backend import io.github.mcppcommunity.mcppclion.backend.Locate +import io.github.mcppcommunity.mcppclion.backend.BackendError +import io.github.mcppcommunity.mcppclion.backend.requireProtocol +import io.github.mcppcommunity.mcppclion.backend.wireName import io.github.mcppcommunity.mcppclion.settings.McppSettings import java.io.File import java.util.concurrent.CompletableFuture @@ -146,12 +149,19 @@ class McppSession(private val project: Project) : Disposable { val configured = if (TrustedProjects.isProjectTrusted(project)) McppSettings.get().string("mcpp.ide.backendPath") else "" val located = Locate.backend(configured, pluginRoot()) if (located == null) { + log.warn("mcpp-ide: startup failed (source=none, protocol=unknown): backend not found") notify(t("The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath."), NotificationType.ERROR) future.completeExceptionally(IllegalStateException("mcpp-ide was not found")) return future } log.info("mcpp-ide: starting ${located.command.joinToString(" ")} (${located.source})") - val b = Backend(located.command + "serve") { line -> log.info("[backend] $line") } + val b = try { + Backend(located.command + "serve") { line -> log.info("[backend] $line") } + } catch (error: Exception) { + log.warn("mcpp-ide: startup failed (source=${located.source}, protocol=unknown)", error) + future.completeExceptionally(error) + return future + } backend = b b.on("*") { method, params -> listeners.filter { it.first == method || it.first == "*" }.forEach { (m, fn) -> runCatching { fn(if (m == "*") JsonObject().apply { addProperty("method", method); add("params", params) } else params) } @@ -174,7 +184,11 @@ class McppSession(private val project: Project) : Disposable { addProperty("mcpplsServes", mcpplsServes()) }) } + var protocol = "unknown" b.request("initialize", params).thenCompose { init -> + protocol = init.asJsonObject["protocol"]?.toString() ?: "missing" + requireProtocol(init.asJsonObject) + log.info("mcpp-ide: initialized (source=${located.source}, protocol=$protocol)") initialized = init.asJsonObject b.notify("initialized", JsonObject()) b.request("i18n/bundle") @@ -182,7 +196,13 @@ class McppSession(private val project: Project) : Disposable { bundle = strings.asJsonObject.entrySet().associate { it.key to it.value.asString } b.request("commands/list", JsonObject().apply { addProperty("editor", "jetbrains") }) }.whenComplete { table, error -> - if (error != null) { future.completeExceptionally(error); return@whenComplete } + if (error != null) { + val cause = (error as? java.util.concurrent.CompletionException)?.cause ?: error + val rpc = (cause as? BackendError)?.let { ", method=${wireName(it.method)}, code=${it.code}" } ?: "" + log.warn("mcpp-ide: startup failed (source=${located.source}, protocol=$protocol$rpc)", cause) + future.completeExceptionally(error) + return@whenComplete + } commands = table.asJsonObject.getAsJsonArray("commands").map { it.asJsonObject } future.complete(initialized!!) startedListeners.forEach { runCatching { it() } } @@ -330,9 +350,11 @@ class McppSession(private val project: Project) : Disposable { } companion object { - /** /lib/ -> , from where this class was loaded. */ + /** The platform owns plugin paths; modern plugin class loaders can omit CodeSource URLs. */ fun pluginDirectory(): File? = runCatching { - File(McppSession::class.java.protectionDomain.codeSource.location.toURI()).parentFile?.parentFile + com.intellij.ide.plugins.PluginManagerCore.getPlugin( + com.intellij.openapi.extensions.PluginId.getId(PLUGIN_ID) + )?.pluginPath?.toFile() }.getOrNull() const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt new file mode 100644 index 0000000..e26ec49 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt @@ -0,0 +1,16 @@ +package io.github.mcppcommunity.mcppclion.backend + +import com.google.gson.JsonObject +import java.math.BigDecimal + +/** Check the handshake before issuing any namespaced extension requests. */ +fun requireProtocol(initialized: JsonObject) { + val value = initialized["protocol"] + val compatible = runCatching { + value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber && + value.asBigDecimal.compareTo(BigDecimal(2)) == 0 + }.getOrDefault(false) + check(compatible) { + "The selected mcpp-ide backend does not support protocol 2. Update the backend or clear mcpp.ide.backendPath to use the bundled backend." + } +} diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt new file mode 100644 index 0000000..95d39c3 --- /dev/null +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt @@ -0,0 +1,22 @@ +package io.github.mcppcommunity.mcppclion + +import com.google.gson.JsonObject +import org.junit.Assert.assertThrows +import org.junit.Test + +class ProtocolTest { + @Test + fun theHandshakeRejectsIncompatibleProtocolsBeforeCustomRequests() { + for (protocol in listOf("1", "3", "null", "\"2\"", "{}", "[]", "true", "2.5")) { + val initialized = com.google.gson.JsonParser.parseString("""{"protocol":$protocol}""").asJsonObject + assertThrows(IllegalStateException::class.java) { + io.github.mcppcommunity.mcppclion.backend.requireProtocol(initialized) + } + } + assertThrows(IllegalStateException::class.java) { + io.github.mcppcommunity.mcppclion.backend.requireProtocol(JsonObject()) + } + io.github.mcppcommunity.mcppclion.backend.requireProtocol(JsonObject().apply { addProperty("protocol", 2) }) + } + +} From 6e44d5444cd0e856937105ac442984ba1b679886 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 16:27:43 +0900 Subject: [PATCH 02/18] test: exercise CLion build run and test actions --- ...ion-backend-location-and-stable-runtime.md | 38 +++++++++++ .github/workflows/clion-stable.yml | 5 +- .../src/test/kotlin/PackagedPluginTest.kt | 64 ++++++++++++++++++- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 553888d..3c53276 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -64,3 +64,41 @@ Plugin Verifier remains a separate static compatibility check. These runtime che Linux startup and views on the two stable IDE families; native Windows/macOS GUI behavior, dynamic uninstall/reinstall, debugging and every project action require their own evidence. The null CodeSource reproduction uses a cold IDE start and does not depend on dynamic installation. + +## Functional and user-experience coverage + +The denominator is the current command registry and the adapter's additional UI surfaces. +`spec/commands.json` declares 59 commands, of which 55 include JetBrains (five are deprecated). +The runtime table can offer fewer entries according to the language service's advertised +capabilities. Every offered command and each forwarded action must resolve to a registered +IDE action; hidden or deprecated commands are not counted as executed functionality. + +The local 2026.1.5.2 experiment also invokes the actual Build, Run and Test actions, waits for +their IDE process handlers, requires exit code zero, and checks a file written by the program. +These results establish real action execution; test-tree presentation and debugging need +additional assertions. Compilation uses the installed GCC 16.1.0 toolchain. CI installs the +same compiler before running this functional scenario. + +| Area | Required scenario | Current local evidence | +|---|---|---| +| Backend/session | Bundle lookup, protocol mismatch, missing backend, restart and recovery | Original lookup failure and corrected startup reproduced; protocol data validation passes; recovery pending | +| Command integration | Registry, context menu, shortcuts, forwarded mcppls commands | All offered actions resolve; individual forwarded operations and interactive discoverability pending | +| Build | Success, compilation failure, incremental build, cancellation, console source links | Successful IDE action; negative, cancellation and links pending | +| Run | Executable selection, arguments, working directory, PTY, stop | Successful IDE action and program-produced file; other interactions pending | +| Test | Discovery, success, failure, filter, test tree and rerun | Successful IDE action; tree details and negative/filter/rerun pending | +| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Pending actual debugger session | +| Project | Data rows, targets, result/artifact links, refresh | Three views instantiate; populated rows and links pending | +| Libraries | Search, detail, add dependency, index update, error feedback | Pending functional interaction | +| Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Pending; no global user cache deletion is part of this test | +| Settings | Registry form, persist/reload, invalid value, locale | Pending interactive validation | +| Graphs | Package/module graph render, source navigation, empty/error view, no-JCEF fallback | Pending actual JCEF rendering | +| Editor | Manifest/buildscript completion, hover, diagnostics, navigation, snippets; C++ module handoff | Pending actual editor interaction | +| Project/toolchain setup | New project, target/profile choice, install/default toolchain | Compiler installation is test setup; plugin flows pending | +| Diagnostics | Self-check, report export/redaction, logs, degraded operation | Startup logging implemented; export and recovery interactions pending | + +Availability requires an observed operation and its expected effect. Completeness additionally +requires the specified negative and recovery paths. User experience requires checking visible +feedback, discoverability, absence of stalled progress, selection persistence and actionable +errors. Static API compatibility and registered actions alone do not satisfy these criteria. +The two stable IDE versions run the same candidate; platform-specific GUI behavior remains +separately bounded. Findings and fixes continue in the same review PR. diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index ef5fce1..41902c9 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -15,7 +15,6 @@ on: permissions: contents: read - actions: read jobs: runtime: @@ -37,6 +36,10 @@ jobs: xlings install gradle -y -g sudo apt-get update -qq sudo apt-get install -y xvfb + - name: The compiler for actual build, run and test actions + run: | + mcpp toolchain install gcc 16.1.0 + mcpp toolchain default gcc@16.1.0 - uses: actions/cache@v5 with: path: ~/.gradle/caches/modules-2 diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index ffe6803..5b44abe 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -79,6 +79,29 @@ interface ToolWindowRemote { @Remote("com.intellij.ui.content.ContentManager") interface ContentManagerRemote { fun getContentCount(): Int } +@Remote("com.intellij.openapi.actionSystem.ActionManager") +interface ActionManagerFactory { fun getInstance(): ActionManagerRemote } +@Remote("com.intellij.openapi.actionSystem.ActionManager") +interface ActionManagerRemote { fun getAction(id: String): ActionRemote? } +@Remote("com.intellij.openapi.actionSystem.AnAction") +interface ActionRemote + +@Remote("com.intellij.execution.ui.RunContentManager") +interface RunContentManagerFactory { fun getInstance(project: Project): RunContentManagerRemote } +@Remote("com.intellij.execution.ui.RunContentManager") +interface RunContentManagerRemote { fun getAllDescriptors(): List } +@Remote("com.intellij.execution.ui.RunContentDescriptor") +interface RunDescriptorRemote { + fun getDisplayName(): String + fun getExecutionId(): Long + fun getProcessHandler(): ProcessHandlerRemote? +} +@Remote("com.intellij.execution.process.ProcessHandler") +interface ProcessHandlerRemote { + fun isProcessTerminated(): Boolean + fun getExitCode(): Int? +} + class PackagedPluginTest { init { // A successful process exit alone cannot prove the plugin loaded: @@ -100,10 +123,13 @@ class PackagedPluginTest { val version = System.getProperty("mcpp.clion.ideVersion") ?: error("ideVersion is required") val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() Files.createDirectories(output) - val fixture = output.resolve("project") + Files.deleteIfExists(output.resolve("result.json")) + val fixture = Files.createTempDirectory(output, "project-") Files.createDirectories(fixture.resolve("src")) Files.writeString(fixture.resolve("mcpp.toml"), "[package]\nname = \"clion-smoke\"\nversion = \"0.1.0\"\n") - Files.writeString(fixture.resolve("src/main.cpp"), "int main() { return 0; }\n") + Files.writeString(fixture.resolve("src/main.cpp"), "#include \nint main() { auto* f = std::fopen(\"clion-run-ok.txt\", \"w\"); if (!f) return 1; std::fputs(\"run-ok\\n\", f); std::fclose(f); return 0; }\n") + Files.createDirectories(fixture.resolve("tests")) + Files.writeString(fixture.resolve("tests/check.cpp"), "int main() { return 0; }\n") val localHome = System.getProperty("mcpp.clion.ideHome")?.let(Path::of) var ide = IdeInfo(productCode = "CL", platformPrefix = "CLion", executableFileName = "clion", fullName = "CLion", version = version, buildType = "release") if (localHome != null) { @@ -140,6 +166,16 @@ class PackagedPluginTest { assertEquals(2, initialized.get("protocol").getAsInt()) val backendVersion = initialized.get("serverInfo").getAsJsonObject().get("version").getAsString() assertFalse(session.getCommands().isEmpty(), "i18n/bundle and commands/list complete before startup succeeds") + val actions = utility().getInstance() + for (command in session.getCommands()) { + val id = command.get("id").getAsString() + assertNotNull(actions.getAction(id), "offered command $id is registered in this IDE") + val action = command.get("action").getAsJsonObject() + if (action.get("kind").getAsString() == "forward") { + val target = action.get("command").getAsString() + assertNotNull(actions.getAction(target), "forwarded command $id resolves to $target") + } + } assertFalse(session.getDegraded()) invokeAction("ActivatemcppToolWindow") val window = utility().getInstance(singleProject()).getToolWindow("mcpp") @@ -148,9 +184,31 @@ class PackagedPluginTest { while (!window!!.isVisible() && System.nanoTime() < visibleDeadline) Thread.sleep(100) assertTrue(window!!.isVisible(), "the tool window opens") assertEquals(3, window.getContentManager().getContentCount(), "Project, Libraries and Cache views load") + val runs = utility().getInstance(singleProject()) + val actionResults = mutableMapOf() + for (command in listOf("build", "run", "test")) { + val before = runs.getAllDescriptors().map { it.getExecutionId() }.toSet() + val startedAt = System.nanoTime() + invokeAction("mcpp.$command") + val deadline = startedAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(180) + var completed: RunDescriptorRemote? = null + while (System.nanoTime() < deadline) { + completed = runs.getAllDescriptors().firstOrNull { + it.getDisplayName().startsWith("mcpp $command ") && + it.getExecutionId() !in before && it.getProcessHandler()?.isProcessTerminated() == true + } + if (completed != null) break + Thread.sleep(100) + } + assertNotNull(completed, "the real $command action finishes in the IDE") + assertEquals(0, completed!!.getProcessHandler()!!.getExitCode(), "$command exits successfully") + actionResults[command] = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + } + assertEquals("run-ok\n", Files.readString(fixture.resolve("clion-run-ok.txt")), "the program actually ran") // Store only public versions and assertions. Raw IDE logs can // contain usernames, private paths or workspace details. - Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","ideBuild":"$ideBuild","backendVersion":"$backendVersion","protocol":2,"startup":true,"commands":true,"toolWindowVisible":true,"toolWindowTabs":3}""" + "\n") + val actionJson = actionResults.entries.joinToString(",") { "\"${it.key}\":${it.value}" } + Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","ideBuild":"$ideBuild","backendVersion":"$backendVersion","protocol":2,"startup":true,"commands":true,"registeredCommands":${session.getCommands().size},"toolWindowVisible":true,"toolWindowTabs":3,"actionDurationMs":{$actionJson},"programExecuted":true}""" + "\n") } } } From 6a726f73562efa39f12bf2fb914a3a5980de103b Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 17:46:49 +0900 Subject: [PATCH 03/18] fix: keep CLion completion and native views usable during analysis --- ...ion-backend-location-and-stable-runtime.md | 34 +++- .github/workflows/clion-stable.yml | 4 +- mide-plugins/clion/README.md | 5 +- .../clion/integration-tests/build.gradle.kts | 1 + .../src/test/kotlin/PackagedPluginTest.kt | 176 +++++++++++++++++- .../mcppcommunity/mcppclion/McppSession.kt | 5 +- .../mcppclion/editor/McppEditor.kt | 3 +- .../mcppclion/ui/McppToolWindow.kt | 33 +++- 8 files changed, 231 insertions(+), 30 deletions(-) diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 3c53276..17ed4ef 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -73,26 +73,42 @@ The runtime table can offer fewer entries according to the language service's ad capabilities. Every offered command and each forwarded action must resolve to a registered IDE action; hidden or deprecated commands are not counted as executed functionality. -The local 2026.1.5.2 experiment also invokes the actual Build, Run and Test actions, waits for -their IDE process handlers, requires exit code zero, and checks a file written by the program. -These results establish real action execution; test-tree presentation and debugging need -additional assertions. Compilation uses the installed GCC 16.1.0 toolchain. CI installs the -same compiler before running this functional scenario. +The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 20 checks. They invoke the actual +Build, Run and Test actions, wait for fresh process handlers, require the expected exit code, +and check a file written by the program. A compilation failure must appear in the console; +restoring the source must restore a successful build. Test failure and recovery must reach the +native test tree. The project path contains Unicode and spaces. Compilation uses GCC 16.1.0; +CI installs the same compiler. These are diagnostic adapter packages with unchanged backend +bytes; the canonical package must still pass CI. + +Two additional adapter defects were reproduced and corrected. The completion contributor did +not implement `DumbAware`, so an index-independent backend feature was suppressed during IDE +analysis. Marking that contributor allows a real completion popup and exact replacement of +`[dep` by `[dependencies]` while indexing is active. Opening Cache through its command also +left the tree empty until manual refresh. Content selection now focuses and refreshes the view, +including command selection and tab selection, and the runtime check requires real inventory +rows without an error. The same selection path handles existing graph tabs and refresh requests. + +Verifier 1.411 confirms both stable builds are compatible with zero internal API usages after +using the public plugin-aware class loader descriptor. The original deprecated/experimental API +warnings remain. Tests resolve the test-runner classes through the product plugin's class loader: +the classes are in the platform on 2026.1 and a separate optional plugin on 2026.2. Local GUI +tests serialize use of the Driver's ports; CI matrix entries use independent runners. | Area | Required scenario | Current local evidence | |---|---|---| | Backend/session | Bundle lookup, protocol mismatch, missing backend, restart and recovery | Original lookup failure and corrected startup reproduced; protocol data validation passes; recovery pending | | Command integration | Registry, context menu, shortcuts, forwarded mcppls commands | All offered actions resolve; individual forwarded operations and interactive discoverability pending | -| Build | Success, compilation failure, incremental build, cancellation, console source links | Successful IDE action; negative, cancellation and links pending | +| Build | Success, compilation failure, incremental build, cancellation, console source links | Success, compiler error text, failure and recovery pass; cancellation and links pending | | Run | Executable selection, arguments, working directory, PTY, stop | Successful IDE action and program-produced file; other interactions pending | -| Test | Discovery, success, failure, filter, test tree and rerun | Successful IDE action; tree details and negative/filter/rerun pending | +| Test | Discovery, success, failure, filter, test tree and rerun | Native tree success, failure and corrected rerun pass; filter pending | | Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Pending actual debugger session | | Project | Data rows, targets, result/artifact links, refresh | Three views instantiate; populated rows and links pending | | Libraries | Search, detail, add dependency, index update, error feedback | Pending functional interaction | -| Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Pending; no global user cache deletion is part of this test | +| Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Opening the view loads real inventory without manual refresh; plan/apply/refusal pending; no global user cache deletion | | Settings | Registry form, persist/reload, invalid value, locale | Pending interactive validation | | Graphs | Package/module graph render, source navigation, empty/error view, no-JCEF fallback | Pending actual JCEF rendering | -| Editor | Manifest/buildscript completion, hover, diagnostics, navigation, snippets; C++ module handoff | Pending actual editor interaction | +| Editor | Manifest/buildscript completion, hover, diagnostics, navigation, snippets; C++ module handoff | Manifest popup and exact insertion during indexing pass; other interactions pending | | Project/toolchain setup | New project, target/profile choice, install/default toolchain | Compiler installation is test setup; plugin flows pending | | Diagnostics | Self-check, report export/redaction, logs, degraded operation | Startup logging implemented; export and recovery interactions pending | diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index 41902c9..e27dbb3 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -89,5 +89,7 @@ jobs: if: always() with: name: clion-stable-${{ matrix.version }} - path: ${{ runner.temp }}/clion-stable-result/result.json + path: | + ${{ runner.temp }}/clion-stable-result/result.json + ${{ runner.temp }}/clion-stable-result/checks.json if-no-files-found: warn diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index e925472..066f106 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -46,7 +46,10 @@ backend. The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 IDEs by `clion-stable.yml`. Both versions load the same ZIP and the pinned mcppls plugin. The test opens an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, -and activates the mcpp tool window. It supplements Plugin Verifier's static compatibility checks. +and verifies the visible mcpp tool window and cache inventory. It executes build, run and test +actions in a project whose path contains Unicode and spaces, checks the native test tree, +compiler-error output, build/test failure and recovery, and exact manifest completion insertion +during project analysis. It supplements Plugin Verifier's static compatibility checks. An individual packaged plugin can be checked without rebuilding the backend: diff --git a/mide-plugins/clion/integration-tests/build.gradle.kts b/mide-plugins/clion/integration-tests/build.gradle.kts index f4e0243..8287cb5 100644 --- a/mide-plugins/clion/integration-tests/build.gradle.kts +++ b/mide-plugins/clion/integration-tests/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { testImplementation("com.jetbrains.intellij.tools:ide-starter-driver:262.10968.63") testImplementation("com.jetbrains.intellij.driver:driver-client:262.10968.63") testImplementation("com.jetbrains.intellij.driver:driver-sdk:262.10968.63") + testImplementation("com.jetbrains.intellij.driver:driver-model:262.10968.63") testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") testImplementation("org.kodein.di:kodein-di-jvm:7.20.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1") diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 5b44abe..509b2b1 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -1,8 +1,13 @@ import com.intellij.driver.client.Remote import com.intellij.driver.client.service import com.intellij.driver.client.utility +import com.intellij.driver.client.impl.RefWrapper import com.intellij.driver.sdk.invokeAction import com.intellij.driver.sdk.Project +import com.intellij.driver.sdk.FileEditorManager +import com.intellij.driver.sdk.openEditor +import com.intellij.driver.sdk.VirtualFile +import com.intellij.driver.model.OnDispatcher import com.intellij.driver.sdk.singleProject import com.intellij.driver.sdk.waitForProjectOpen import com.intellij.ide.starter.ci.CIServer @@ -77,7 +82,40 @@ interface ToolWindowRemote { fun getContentManager(): ContentManagerRemote } @Remote("com.intellij.ui.content.ContentManager") -interface ContentManagerRemote { fun getContentCount(): Int } +interface ContentManagerRemote { + fun getContentCount(): Int + fun getSelectedContent(): ContentRemote? +} +@Remote("com.intellij.ui.content.Content") +interface ContentRemote { + fun getDisplayName(): String + fun getComponent(): ContainerRemote +} +@Remote("java.awt.Container") +interface ContainerRemote { + fun getClass(): ClassNameRemote + fun getComponents(): Array +} +@Remote("java.lang.Class") +interface ClassNameRemote { fun getName(): String } +@Remote("javax.swing.JTree") +interface TreeRemote { + fun getRowCount(): Int + fun getModel(): TreeModelRemote +} +@Remote("javax.swing.tree.TreeModel") +interface TreeModelRemote { fun getRoot(): TreeNodeRemote } +@Remote("javax.swing.tree.DefaultMutableTreeNode") +interface TreeNodeRemote { + fun getChildAt(index: Int): TreeNodeRemote + fun getUserObject(): ViewRowRemote +} +@Remote("io.github.mcppcommunity.mcppclion.ui.Row", plugin = PLUGIN_ID) +interface ViewRowRemote { fun getError(): Boolean } +@Remote("com.intellij.openapi.project.DumbService") +interface DumbServiceFactory { fun getInstance(project: Project): DumbServiceRemote } +@Remote("com.intellij.openapi.project.DumbService") +interface DumbServiceRemote { fun isDumb(): Boolean } @Remote("com.intellij.openapi.actionSystem.ActionManager") interface ActionManagerFactory { fun getInstance(): ActionManagerRemote } @@ -95,6 +133,7 @@ interface RunDescriptorRemote { fun getDisplayName(): String fun getExecutionId(): Long fun getProcessHandler(): ProcessHandlerRemote? + fun getExecutionConsole(): ExecutionConsoleRemote } @Remote("com.intellij.execution.process.ProcessHandler") interface ProcessHandlerRemote { @@ -102,6 +141,40 @@ interface ProcessHandlerRemote { fun getExitCode(): Int? } +@Remote("com.intellij.execution.ui.ExecutionConsole") +interface ExecutionConsoleRemote +@Remote("com.intellij.execution.impl.ConsoleViewImpl") +interface TextConsoleRemote { fun getText(): String } + +@Remote("com.intellij.execution.testframework.sm.runner.ui.SMTRunnerConsoleView", plugin = PLUGIN_ID) +interface TestConsoleRemote { fun getResultsViewer(): TestResultsRemote } +@Remote("com.intellij.execution.testframework.sm.runner.ui.SMTestRunnerResultsForm", plugin = PLUGIN_ID) +interface TestResultsRemote { fun getTestsRootNode(): TestNodeRemote } +@Remote("com.intellij.execution.testframework.sm.runner.SMTestProxy", plugin = PLUGIN_ID) +interface TestNodeRemote { + fun getChildren(): List + fun isPassed(): Boolean + fun isDefect(): Boolean +} + +@Remote("com.intellij.openapi.fileEditor.FileEditorManager") +interface FileEditorManagerFactory { fun getInstance(project: Project): FileEditorManager } +@Remote("com.intellij.openapi.vfs.LocalFileSystem") +interface LocalFileSystemFactory { fun getInstance(): LocalFileSystemRemote } +@Remote("com.intellij.openapi.vfs.LocalFileSystem") +interface LocalFileSystemRemote { fun refreshAndFindFileByPath(path: String): VirtualFile? } +@Remote("com.intellij.codeInsight.lookup.LookupManager") +interface LookupManagerFactory { fun getInstance(project: Project): LookupManagerRemote } +@Remote("com.intellij.codeInsight.lookup.LookupManager") +interface LookupManagerRemote { fun getActiveLookup(): LookupRemote? } +@Remote("com.intellij.codeInsight.lookup.impl.LookupImpl") +interface LookupRemote { + fun getItems(): List + fun setCurrentItem(item: LookupItemRemote) +} +@Remote("com.intellij.codeInsight.lookup.LookupElement") +interface LookupItemRemote { fun getLookupString(): String } + class PackagedPluginTest { init { // A successful process exit alone cannot prove the plugin loaded: @@ -124,7 +197,14 @@ class PackagedPluginTest { val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() Files.createDirectories(output) Files.deleteIfExists(output.resolve("result.json")) - val fixture = Files.createTempDirectory(output, "project-") + Files.deleteIfExists(output.resolve("checks.json")) + val checked = linkedSetOf() + fun record(name: String) { + checked += name + val checks = checked.joinToString(",") { "\"$it\"" } + Files.writeString(output.resolve("checks.json"), """{"passed":[$checks]}""" + "\n") + } + val fixture = Files.createTempDirectory(output, "mcpp-用例 space-") Files.createDirectories(fixture.resolve("src")) Files.writeString(fixture.resolve("mcpp.toml"), "[package]\nname = \"clion-smoke\"\nversion = \"0.1.0\"\n") Files.writeString(fixture.resolve("src/main.cpp"), "#include \nint main() { auto* f = std::fopen(\"clion-run-ok.txt\", \"w\"); if (!f) return 1; std::fputs(\"run-ok\\n\", f); std::fclose(f); return 0; }\n") @@ -164,6 +244,7 @@ class PackagedPluginTest { assertTrue(future.isDone(), "the complete plugin startup chain finishes") val initialized = future.get() assertEquals(2, initialized.get("protocol").getAsInt()) + record("protocol2-startup") val backendVersion = initialized.get("serverInfo").getAsJsonObject().get("version").getAsString() assertFalse(session.getCommands().isEmpty(), "i18n/bundle and commands/list complete before startup succeeds") val actions = utility().getInstance() @@ -176,6 +257,7 @@ class PackagedPluginTest { assertNotNull(actions.getAction(target), "forwarded command $id resolves to $target") } } + record("offered-actions-and-forward-targets") assertFalse(session.getDegraded()) invokeAction("ActivatemcppToolWindow") val window = utility().getInstance(singleProject()).getToolWindow("mcpp") @@ -184,10 +266,13 @@ class PackagedPluginTest { while (!window!!.isVisible() && System.nanoTime() < visibleDeadline) Thread.sleep(100) assertTrue(window!!.isVisible(), "the tool window opens") assertEquals(3, window.getContentManager().getContentCount(), "Project, Libraries and Cache views load") + record("visible-three-view-tool-window") val runs = utility().getInstance(singleProject()) val actionResults = mutableMapOf() - for (command in listOf("build", "run", "test")) { + fun execute(command: String, label: String, success: Boolean = true): RunDescriptorRemote { val before = runs.getAllDescriptors().map { it.getExecutionId() }.toSet() + val previousHandlers = runs.getAllDescriptors().mapNotNull { it.getProcessHandler() } + .map { (it as RefWrapper).getRef().identityHashCode() }.toSet() val startedAt = System.nanoTime() invokeAction("mcpp.$command") val deadline = startedAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(180) @@ -195,20 +280,97 @@ class PackagedPluginTest { while (System.nanoTime() < deadline) { completed = runs.getAllDescriptors().firstOrNull { it.getDisplayName().startsWith("mcpp $command ") && - it.getExecutionId() !in before && it.getProcessHandler()?.isProcessTerminated() == true + it.getExecutionId() !in before && it.getProcessHandler()?.let { handler -> + (handler as RefWrapper).getRef().identityHashCode() !in previousHandlers && handler.isProcessTerminated() + } == true } if (completed != null) break Thread.sleep(100) } assertNotNull(completed, "the real $command action finishes in the IDE") - assertEquals(0, completed!!.getProcessHandler()!!.getExitCode(), "$command exits successfully") - actionResults[command] = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + assertEquals(success, completed!!.getProcessHandler()!!.getExitCode() == 0, "$label has the expected exit status") + if (command == "test") { + val testRoot = cast(completed.getExecutionConsole(), TestConsoleRemote::class).getResultsViewer().getTestsRootNode() + assertFalse(testRoot.getChildren().isEmpty(), "the native test tree contains discovered tests") + assertEquals(!success, testRoot.isDefect(), "the test tree records the actual outcome") + if (success) assertTrue(testRoot.getChildren().all { it.isPassed() }, "test results reach the native tree") + record("native-test-tree-$label") + } + actionResults[label] = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) + record("action-$label") + return completed } + for (command in listOf("build", "run", "test")) execute(command, command) assertEquals("run-ok\n", Files.readString(fixture.resolve("clion-run-ok.txt")), "the program actually ran") + record("program-executed") + val source = fixture.resolve("src/main.cpp") + val sourceBefore = Files.readString(source) + Files.writeString(source, "int main() { return missing_clion_smoke_symbol; }\n") + val failedBuild = execute("build", "build-failure", success = false) + val console = cast(failedBuild.getExecutionConsole(), TextConsoleRemote::class) + val consoleDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (!console.getText().contains("missing_clion_smoke_symbol") && System.nanoTime() < consoleDeadline) Thread.sleep(100) + assertTrue(console.getText().contains("missing_clion_smoke_symbol"), "the compiler error reaches the visible run console") + record("compiler-error-visible") + Files.writeString(source, sourceBefore) + execute("build", "build-recovery") + val testSource = fixture.resolve("tests/check.cpp") + Files.writeString(testSource, "int main() { return 1; }\n") + execute("test", "test-failure", success = false) + Files.writeString(testSource, "int main() { return 0; }\n") + execute("test", "test-recovery") + for ((command, title) in listOf("mcpp.library.search" to "Libraries", "mcpp.showCachePanel" to "Cache")) { + invokeAction(command) + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (window.getContentManager().getSelectedContent()?.getDisplayName() != title && System.nanoTime() < deadline) Thread.sleep(100) + assertEquals(title, window.getContentManager().getSelectedContent()?.getDisplayName(), "$command selects its native view") + record("view-$title") + } + val manifest = utility().getInstance().refreshAndFindFileByPath(fixture.resolve("mcpp.toml").toString()) + openEditor(requireNotNull(manifest)) + val editor = requireNotNull(utility().getInstance(singleProject()).getSelectedTextEditor()) + val manifestBefore = editor.getDocument().getText() + withWriteAction { editor.getDocument().setText(manifestBefore + "\n[dep") } + withContext(OnDispatcher.EDT) { editor.getCaretModel().moveToOffset(editor.getDocument().getText().length) } + val indexingAtCompletion = utility().getInstance(singleProject()).isDumb() + invokeAction("CodeCompletion") + val lookups = utility().getInstance(singleProject()) + val lookupDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) + var dependencies: LookupItemRemote? = null + while (System.nanoTime() < lookupDeadline) { + dependencies = lookups.getActiveLookup()?.getItems()?.firstOrNull { it.getLookupString() == "[dependencies]" } + if (dependencies != null) break + Thread.sleep(100) + } + assertNotNull(dependencies, "mcpp.toml completion is visible in the actual editor") + withContext(OnDispatcher.EDT) { lookups.getActiveLookup()!!.setCurrentItem(dependencies!!) } + invokeAction("EditorChooseLookupItem") + assertEquals(manifestBefore + "\n[dependencies]", editor.getDocument().getText(), "completion replaces exactly the requested range") + record("manifest-completion-and-insertion") + if (indexingAtCompletion) record("manifest-completion-during-indexing") + withContext(OnDispatcher.EDT) { + val pending = ArrayDeque() + pending.add(window.getContentManager().getSelectedContent()!!.getComponent()) + var cacheTree: TreeRemote? = null + while (pending.isNotEmpty()) { + val component = pending.removeFirst() + if (component.getClass().getName() == "com.intellij.ui.treeStructure.Tree") { + cacheTree = cast(component, TreeRemote::class) + break + } + pending.addAll(component.getComponents()) + } + assertNotNull(cacheTree, "the native cache tree exists") + val cacheDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) + while (cacheTree!!.getRowCount() == 0 && System.nanoTime() < cacheDeadline) Thread.sleep(100) + assertTrue(cacheTree!!.getRowCount() > 0, "opening the cache view loads its inventory without manual refresh") + assertFalse(cacheTree.getModel().getRoot().getChildAt(0).getUserObject().getError(), "the inventory query succeeds") + record("cache-inventory-visible") + } // Store only public versions and assertions. Raw IDE logs can // contain usernames, private paths or workspace details. val actionJson = actionResults.entries.joinToString(",") { "\"${it.key}\":${it.value}" } - Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","ideBuild":"$ideBuild","backendVersion":"$backendVersion","protocol":2,"startup":true,"commands":true,"registeredCommands":${session.getCommands().size},"toolWindowVisible":true,"toolWindowTabs":3,"actionDurationMs":{$actionJson},"programExecuted":true}""" + "\n") + Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","ideBuild":"$ideBuild","backendVersion":"$backendVersion","protocol":2,"startup":true,"commands":true,"registeredCommands":${session.getCommands().size},"toolWindowVisible":true,"toolWindowTabs":3,"actionDurationMs":{$actionJson},"programExecuted":true,"indexingAtCompletion":$indexingAtCompletion}""" + "\n") } } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 54ee858..221d91e 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -352,9 +352,8 @@ class McppSession(private val project: Project) : Disposable { companion object { /** The platform owns plugin paths; modern plugin class loaders can omit CodeSource URLs. */ fun pluginDirectory(): File? = runCatching { - com.intellij.ide.plugins.PluginManagerCore.getPlugin( - com.intellij.openapi.extensions.PluginId.getId(PLUGIN_ID) - )?.pluginPath?.toFile() + (McppSession::class.java.classLoader as? com.intellij.ide.plugins.cl.PluginAwareClassLoader) + ?.pluginDescriptor?.pluginPath?.toFile() }.getOrNull() const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt index 0207c90..1d69887 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt @@ -31,6 +31,7 @@ import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileEditorManagerListener import com.intellij.openapi.project.Project +import com.intellij.openapi.project.DumbAware import com.intellij.openapi.util.Key import com.intellij.openapi.util.TextRange import com.intellij.openapi.vfs.LocalFileSystem @@ -265,7 +266,7 @@ internal fun expandSnippet(snippet: String): Pair { } /** The backend's completion in mcpp.toml and build.mcpp. */ -class McppCompletion : CompletionContributor() { +class McppCompletion : CompletionContributor(), DumbAware { override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) { val vf = parameters.originalFile.virtualFile ?: return if (!served(vf)) return diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt index afc1cad..4aca84e 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt @@ -15,6 +15,8 @@ import com.intellij.openapi.wm.ToolWindowFactory import com.intellij.openapi.wm.ToolWindowManager import com.intellij.ui.content.Content import com.intellij.ui.content.ContentFactory +import com.intellij.ui.content.ContentManagerListener +import com.intellij.ui.content.ContentManagerEvent import io.github.mcppcommunity.mcppclion.McppSession import io.github.mcppcommunity.mcppclion.actions.Dispatch import io.github.mcppcommunity.mcppclion.settings.McppConfigurable @@ -25,12 +27,19 @@ interface McppView { val id: String fun refresh() /** Shows what `params` names (a package of the libraries); the view itself when nothing. */ - fun focus(params: JsonObject) {} + fun focus(params: JsonObject) { refresh() } } object McppPanels { const val TOOL_WINDOW = "mcpp" private val VIEW = Key.create("mcpp.view") + private val FOCUS_PARAMS = Key.create("mcpp.focus.params") + + private fun focus(content: Content) { + val params = content.getUserData(FOCUS_PARAMS) ?: JsonObject() + content.putUserData(FOCUS_PARAMS, null) + content.getUserData(VIEW)?.focus(params) + } /** The command table's `panel` actions: a view brought on screen, a graph opened, settings shown. */ fun show(project: Project, panel: String, params: JsonObject = JsonObject()) { @@ -52,16 +61,20 @@ object McppPanels { private fun select(window: ToolWindow, id: String, params: JsonObject) { val content = window.contentManager.contents.firstOrNull { it.getUserData(VIEW)?.id == id } ?: return - window.contentManager.setSelectedContent(content, true) - content.getUserData(VIEW)?.focus(params) + select(window, content, params) + } + + private fun select(window: ToolWindow, content: Content, params: JsonObject) { + content.putUserData(FOCUS_PARAMS, params) + if (window.contentManager.selectedContent === content) focus(content) + else window.contentManager.setSelectedContent(content, true) } /** A graph is a tab of its own, closeable; one per kind, refreshed when it is asked again. */ private fun openGraph(project: Project, window: ToolWindow, kind: String) { val id = "graph:$kind" window.contentManager.contents.firstOrNull { it.getUserData(VIEW)?.id == id }?.let { - window.contentManager.setSelectedContent(it, true) - it.getUserData(VIEW)?.refresh() + select(window, it, JsonObject()) return } val session = McppSession.get(project) @@ -72,8 +85,7 @@ object McppPanels { content.putUserData(VIEW, page) Disposer.register(content, page) window.contentManager.addContent(content) - window.contentManager.setSelectedContent(content, true) - page.refresh() + select(window, content, JsonObject()) } class Factory : ToolWindowFactory, DumbAware { @@ -94,13 +106,18 @@ object McppPanels { add(projectView, projectView.component, session.t("Project")) add(libraryView, libraryView.component, session.t("Libraries")) add(cacheView, cacheView.component, session.t("Cache")) + toolWindow.contentManager.addContentManagerListener(object : ContentManagerListener { + override fun selectionChanged(event: ContentManagerEvent) { + if (event.operation == ContentManagerEvent.ContentOperation.add) focus(event.content) + } + }) // The strings and the data arrive with the backend. session.whenStarted { com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { if (project.isDisposed) return@invokeLater val titles = mapOf("project" to "Project", "libraries" to "Libraries", "cache" to "Cache") for (c in toolWindow.contentManager.contents) c.getUserData(VIEW)?.id?.let { id -> titles[id]?.let { c.displayName = session.t(it) } } - projectView.refresh() + toolWindow.contentManager.selectedContent?.let(::focus) } } projectView.refresh() From c5aed8e3d8e33268c511d1c537ebe09eb24c12ee Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 18:49:34 +0900 Subject: [PATCH 04/18] fix: start native CLion debug sessions and repair stable CI setup --- ...ion-backend-location-and-stable-runtime.md | 23 ++++++-- .github/workflows/clion-stable.yml | 3 + .gitignore | 2 +- docs/architecture.md | 4 +- mide-plugins/clion/README.md | 3 +- .../src/test/kotlin/PackagedPluginTest.kt | 57 +++++++++++++------ .../mcppclion/run/McppDebugRunner.kt | 7 ++- 7 files changed, 72 insertions(+), 27 deletions(-) diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 17ed4ef..4a32720 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -61,8 +61,9 @@ against each tested SDK. A manual release check can reuse the published ZIP with the backend. Only a report of public versions and assertions is uploaded. Plugin Verifier remains a separate static compatibility check. These runtime checks cover -Linux startup and views on the two stable IDE families; native Windows/macOS GUI behavior, -dynamic uninstall/reinstall, debugging and every project action require their own evidence. +Linux startup and the functional scenarios below on the two stable IDE families; native +Windows/macOS GUI behavior, dynamic uninstall/reinstall and remaining interactions require +their own evidence. The null CodeSource reproduction uses a cold IDE start and does not depend on dynamic installation. ## Functional and user-experience coverage @@ -73,7 +74,7 @@ The runtime table can offer fewer entries according to the language service's ad capabilities. Every offered command and each forwarded action must resolve to a registered IDE action; hidden or deprecated commands are not counted as executed functionality. -The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 20 checks. They invoke the actual +The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 23 checks. They invoke the actual Build, Run and Test actions, wait for fresh process handlers, require the expected exit code, and check a file written by the program. A compilation failure must appear in the console; restoring the source must restore a successful build. Test failure and recovery must reach the @@ -88,6 +89,20 @@ analysis. Marking that contributor allows a real completion popup and exact repl left the tree empty until manual refresh. Content selection now focuses and refreshes the view, including command selection and tab selection, and the runtime check requires real inventory rows without an error. The same selection path handles existing graph tabs and refresh requests. +Both versions also pass physical mouse selection of the Libraries and Cache tabs. + +The native debugger adapter constructed `CidrLocalDebugProcess` without starting it. The +published adapter consequently opened a debug view without loading the target. Starting the +process inside the session starter follows the native launch lifecycle. Both stable versions +now execute the actual Debug action, finish with exit code zero through a native +`CidrDebugProcess` process handler, and expose the Debug tool window. Breakpoints, variables, +stepping and stop remain separate pending scenarios. + +The first canonical CI run passed the plugin build, static verification and other ecosystem +checks. Both stable runtime jobs stopped during xlings installation when the installer tried +to read `/dev/tty`; neither reached IDE startup. The reusable workflow now sets +`XLINGS_NON_INTERACTIVE=1` itself, because the caller's workflow environment is not inherited. +The corrected canonical runtime jobs still require a successful run. Verifier 1.411 confirms both stable builds are compatible with zero internal API usages after using the public plugin-aware class loader descriptor. The original deprecated/experimental API @@ -102,7 +117,7 @@ tests serialize use of the Driver's ports; CI matrix entries use independent run | Build | Success, compilation failure, incremental build, cancellation, console source links | Success, compiler error text, failure and recovery pass; cancellation and links pending | | Run | Executable selection, arguments, working directory, PTY, stop | Successful IDE action and program-produced file; other interactions pending | | Test | Discovery, success, failure, filter, test tree and rerun | Native tree success, failure and corrected rerun pass; filter pending | -| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Pending actual debugger session | +| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Actual native debug launch, process completion and Debug view pass on both stable versions; breakpoint interactions and runner-backed program pending | | Project | Data rows, targets, result/artifact links, refresh | Three views instantiate; populated rows and links pending | | Libraries | Search, detail, add dependency, index update, error feedback | Pending functional interaction | | Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Opening the view loads real inventory without manual refresh; plan/apply/refusal pending; no global user cache deletion | diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index e27dbb3..304f5c7 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -16,6 +16,9 @@ on: permissions: contents: read +env: + XLINGS_NON_INTERACTIVE: '1' + jobs: runtime: name: packaged plugin on CLion ${{ matrix.version }} diff --git a/.gitignore b/.gitignore index 26e4a91..b6c05ef 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,4 @@ webui/dist/ compile_commands.json dist-upload/ .xlings/ -out/ide-tests/ +out/ide-tests diff --git a/docs/architecture.md b/docs/architecture.md index 711ffe9..0f48cbb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,7 +99,9 @@ class loader's `CodeSource` URL is not required. The session accepts protocol 2 `mcpp-ide/i18n/bundle` and `mcpp-ide/commands/list`. Backend selection and startup failures are recorded in `idea.log`; RPC startup failures include the method and error code. The stable CLion runtime checks install one packaged plugin ZIP in both stable IDE families and exercise -the complete startup chain in each IDE process. +the complete startup chain in each IDE process. Functional checks cover build, run, test, +native debug launch, error recovery, manifest completion and view selection. The debugger +adapter starts the native Cidr process from the session starter before returning it. ## Generated declarations diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 066f106..e5d763b 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -47,7 +47,8 @@ The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 `clion-stable.yml`. Both versions load the same ZIP and the pinned mcppls plugin. The test opens an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, and verifies the visible mcpp tool window and cache inventory. It executes build, run and test -actions in a project whose path contains Unicode and spaces, checks the native test tree, +actions in a project whose path contains Unicode and spaces, launches the native debugger, +checks its process completion and visible view, checks the native test tree, compiler-error output, build/test failure and recovery, and exact manifest completion insertion during project analysis. It supplements Plugin Verifier's static compatibility checks. diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 509b2b1..0ff43cc 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -7,6 +7,7 @@ import com.intellij.driver.sdk.Project import com.intellij.driver.sdk.FileEditorManager import com.intellij.driver.sdk.openEditor import com.intellij.driver.sdk.VirtualFile +import com.intellij.driver.sdk.ui.ui import com.intellij.driver.model.OnDispatcher import com.intellij.driver.sdk.singleProject import com.intellij.driver.sdk.waitForProjectOpen @@ -33,6 +34,7 @@ import org.kodein.di.direct import org.kodein.di.instance import java.nio.file.Files import java.nio.file.Path +import kotlin.time.Duration.Companion.minutes private const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" @@ -139,8 +141,16 @@ interface RunDescriptorRemote { interface ProcessHandlerRemote { fun isProcessTerminated(): Boolean fun getExitCode(): Int? + fun getClass(): ClassNameRemote } +@Remote("com.jetbrains.cidr.cpp.toolchains.CPPToolchains", plugin = PLUGIN_ID) +interface CppToolchainsFactory { fun getInstance(): CppToolchainsRemote } +@Remote("com.jetbrains.cidr.cpp.toolchains.CPPToolchains", plugin = PLUGIN_ID) +interface CppToolchainsRemote { fun getDefaultToolchain(): CppToolchainRemote? } +@Remote("com.jetbrains.cidr.cpp.toolchains.CPPToolchains\$Toolchain", plugin = PLUGIN_ID) +interface CppToolchainRemote + @Remote("com.intellij.execution.ui.ExecutionConsole") interface ExecutionConsoleRemote @Remote("com.intellij.execution.impl.ConsoleViewImpl") @@ -231,7 +241,7 @@ class PackagedPluginTest { addSystemProperty("idea.trust.all.projects", true) addSystemProperty("ide.show.tips.on.startup.default.value", false) } - context.runIdeWithDriver().useDriverAndCloseIde { + context.runIdeWithDriver(runTimeout = 3.minutes).useDriverAndCloseIde { waitForProjectOpen() withContext { val application = utility().getInstance() @@ -275,7 +285,7 @@ class PackagedPluginTest { .map { (it as RefWrapper).getRef().identityHashCode() }.toSet() val startedAt = System.nanoTime() invokeAction("mcpp.$command") - val deadline = startedAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(180) + val deadline = startedAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(if (command == "debug") 60 else 180) var completed: RunDescriptorRemote? = null while (System.nanoTime() < deadline) { completed = runs.getAllDescriptors().firstOrNull { @@ -319,6 +329,12 @@ class PackagedPluginTest { execute("test", "test-failure", success = false) Files.writeString(testSource, "int main() { return 0; }\n") execute("test", "test-recovery") + assertNotNull(utility().getInstance().getDefaultToolchain(), "CLion has a debugger toolchain") + val debug = execute("debug", "debug-native-launch") + assertTrue(debug.getProcessHandler()!!.getClass().getName().contains("CidrDebugProcess"), "CLion's native debugger owns the process") + val debugWindow = utility().getInstance(singleProject()).getToolWindow("Debug") + assertTrue(debugWindow?.isVisible() == true, "the native debugger view is visible") + record("native-debugger-process-and-view") for ((command, title) in listOf("mcpp.library.search" to "Libraries", "mcpp.showCachePanel" to "Cache")) { invokeAction(command) val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) @@ -326,6 +342,11 @@ class PackagedPluginTest { assertEquals(title, window.getContentManager().getSelectedContent()?.getDisplayName(), "$command selects its native view") record("view-$title") } + for (title in listOf("Libraries", "Cache")) { + ui.x("//div[@visible_text='$title' and contains(@javaclass,'ContentTabLabel')]").click() + assertEquals(title, window.getContentManager().getSelectedContent()?.getDisplayName(), "a mouse click selects $title") + record("mouse-tab-$title") + } val manifest = utility().getInstance().refreshAndFindFileByPath(fixture.resolve("mcpp.toml").toString()) openEditor(requireNotNull(manifest)) val editor = requireNotNull(utility().getInstance(singleProject()).getSelectedTextEditor()) @@ -349,23 +370,23 @@ class PackagedPluginTest { record("manifest-completion-and-insertion") if (indexingAtCompletion) record("manifest-completion-during-indexing") withContext(OnDispatcher.EDT) { - val pending = ArrayDeque() - pending.add(window.getContentManager().getSelectedContent()!!.getComponent()) - var cacheTree: TreeRemote? = null - while (pending.isNotEmpty()) { - val component = pending.removeFirst() - if (component.getClass().getName() == "com.intellij.ui.treeStructure.Tree") { - cacheTree = cast(component, TreeRemote::class) - break - } - pending.addAll(component.getComponents()) + val pending = ArrayDeque() + pending.add(window.getContentManager().getSelectedContent()!!.getComponent()) + var cacheTree: TreeRemote? = null + while (pending.isNotEmpty()) { + val component = pending.removeFirst() + if (component.getClass().getName() == "com.intellij.ui.treeStructure.Tree") { + cacheTree = cast(component, TreeRemote::class) + break } - assertNotNull(cacheTree, "the native cache tree exists") - val cacheDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) - while (cacheTree!!.getRowCount() == 0 && System.nanoTime() < cacheDeadline) Thread.sleep(100) - assertTrue(cacheTree!!.getRowCount() > 0, "opening the cache view loads its inventory without manual refresh") - assertFalse(cacheTree.getModel().getRoot().getChildAt(0).getUserObject().getError(), "the inventory query succeeds") - record("cache-inventory-visible") + pending.addAll(component.getComponents()) + } + assertNotNull(cacheTree, "the native cache tree exists") + val cacheDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) + while (cacheTree!!.getRowCount() == 0 && System.nanoTime() < cacheDeadline) Thread.sleep(100) + assertTrue(cacheTree!!.getRowCount() > 0, "opening the cache view loads its inventory without manual refresh") + assertFalse(cacheTree.getModel().getRoot().getChildAt(0).getUserObject().getError(), "the inventory query succeeds") + record("cache-inventory-visible") } // Store only public versions and assertions. Raw IDE logs can // contain usernames, private paths or workspace details. diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt index f82dcf3..890213b 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt @@ -48,8 +48,11 @@ class McppDebugRunner : GenericProgramRunner() { session.notify(session.t("This program runs through {0}. LLDB reads its symbols once `settings set plugin.jit-loader.gdb.enable on` is in ~/.lldbinit; GDB reads them as it is.", launch["via"].asString), NotificationType.INFORMATION) val parameters = TrivialRunParameters(driver, commandLine(launch, pty = false)) return XDebuggerManager.getInstance(project).newSessionBuilder(object : XDebugProcessStarter() { - override fun start(xs: XDebugSession): XDebugProcess = - CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) + override fun start(xs: XDebugSession): XDebugProcess { + val process = CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) + process.start() + return process + } }).environment(env).startSession().runContentDescriptor } } From 4bb18689600f5c5ff43d0ba5390d38b3cde1bde2 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 19:14:16 +0900 Subject: [PATCH 05/18] test: verify native CLion breakpoints stepping and stop --- ...ion-backend-location-and-stable-runtime.md | 18 +++-- mide-plugins/clion/README.md | 2 +- .../src/test/kotlin/PackagedPluginTest.kt | 75 +++++++++++++++++-- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 4a32720..d8bd0bf 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -1,6 +1,6 @@ # CLion backend location and stable runtime validation -Status: implemented on the review branch; stable CI validation pending. +Status: fixes and the initial stable runtime suite pass canonical CI; expanded debugger CI pending. ## Observed failure @@ -74,7 +74,7 @@ The runtime table can offer fewer entries according to the language service's ad capabilities. Every offered command and each forwarded action must resolve to a registered IDE action; hidden or deprecated commands are not counted as executed functionality. -The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 23 checks. They invoke the actual +The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 29 checks. They invoke the actual Build, Run and Test actions, wait for fresh process handlers, require the expected exit code, and check a file written by the program. A compilation failure must appear in the console; restoring the source must restore a successful build. Test failure and recovery must reach the @@ -95,14 +95,20 @@ The native debugger adapter constructed `CidrLocalDebugProcess` without starting published adapter consequently opened a debug view without loading the target. Starting the process inside the session starter follows the native launch lifecycle. Both stable versions now execute the actual Debug action, finish with exit code zero through a native -`CidrDebugProcess` process handler, and expose the Debug tool window. Breakpoints, variables, -stepping and stop remain separate pending scenarios. +`CidrDebugProcess` process handler, and expose the Debug tool window. Both versions then stop +at a real source breakpoint, step to the next source line, resume to normal completion and +check program output. A separate debug run stops at the breakpoint and must terminate before +the program writes its output. Variable inspection and runner-backed debugging remain pending. The first canonical CI run passed the plugin build, static verification and other ecosystem checks. Both stable runtime jobs stopped during xlings installation when the installer tried to read `/dev/tty`; neither reached IDE startup. The reusable workflow now sets `XLINGS_NON_INTERACTIVE=1` itself, because the caller's workflow environment is not inherited. -The corrected canonical runtime jobs still require a successful run. +Canonical run 42005596492 at commit `c5aed8e` subsequently passed all 25 jobs, including +the actual packaged plugin on both stable IDE builds. The stable runtime reports contain +23 successful checks on 2026.1 and 24 on 2026.2, including completion while indexing on the +latter. The added breakpoint, step, resume and stop cases have passed locally on both IDEs; +the expanded canonical suite still requires its own successful run. Verifier 1.411 confirms both stable builds are compatible with zero internal API usages after using the public plugin-aware class loader descriptor. The original deprecated/experimental API @@ -117,7 +123,7 @@ tests serialize use of the Driver's ports; CI matrix entries use independent run | Build | Success, compilation failure, incremental build, cancellation, console source links | Success, compiler error text, failure and recovery pass; cancellation and links pending | | Run | Executable selection, arguments, working directory, PTY, stop | Successful IDE action and program-produced file; other interactions pending | | Test | Discovery, success, failure, filter, test tree and rerun | Native tree success, failure and corrected rerun pass; filter pending | -| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Actual native debug launch, process completion and Debug view pass on both stable versions; breakpoint interactions and runner-backed program pending | +| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Native debug launch, breakpoint, step over, resume, normal completion and stop pass on both stable versions; variables and runner-backed program pending | | Project | Data rows, targets, result/artifact links, refresh | Three views instantiate; populated rows and links pending | | Libraries | Search, detail, add dependency, index update, error feedback | Pending functional interaction | | Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Opening the view loads real inventory without manual refresh; plan/apply/refusal pending; no global user cache deletion | diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index e5d763b..129bbf6 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -48,7 +48,7 @@ The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, and verifies the visible mcpp tool window and cache inventory. It executes build, run and test actions in a project whose path contains Unicode and spaces, launches the native debugger, -checks its process completion and visible view, checks the native test tree, +checks its visible view, source breakpoint, step over, resume and stop, checks the native test tree, compiler-error output, build/test failure and recovery, and exact manifest completion insertion during project analysis. It supplements Plugin Verifier's static compatibility checks. diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 0ff43cc..d3742d3 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -6,6 +6,7 @@ import com.intellij.driver.sdk.invokeAction import com.intellij.driver.sdk.Project import com.intellij.driver.sdk.FileEditorManager import com.intellij.driver.sdk.openEditor +import com.intellij.driver.sdk.XDebuggerUtil import com.intellij.driver.sdk.VirtualFile import com.intellij.driver.sdk.ui.ui import com.intellij.driver.model.OnDispatcher @@ -151,6 +152,21 @@ interface CppToolchainsRemote { fun getDefaultToolchain(): CppToolchainRemote? } @Remote("com.jetbrains.cidr.cpp.toolchains.CPPToolchains\$Toolchain", plugin = PLUGIN_ID) interface CppToolchainRemote +@Remote("com.intellij.xdebugger.XDebuggerManager") +interface DebuggerManagerFactory { fun getInstance(project: Project): DebuggerManagerRemote } +@Remote("com.intellij.xdebugger.XDebuggerManager") +interface DebuggerManagerRemote { fun getCurrentSession(): DebugSessionRemote? } +@Remote("com.intellij.xdebugger.XDebugSession") +interface DebugSessionRemote { + fun isSuspended(): Boolean + fun getCurrentPosition(): DebugPositionRemote? + fun resume() + fun stepOver(ignoreBreakpoints: Boolean) + fun stop() +} +@Remote("com.intellij.xdebugger.XSourcePosition") +interface DebugPositionRemote { fun getLine(): Int } + @Remote("com.intellij.execution.ui.ExecutionConsole") interface ExecutionConsoleRemote @Remote("com.intellij.execution.impl.ConsoleViewImpl") @@ -217,7 +233,18 @@ class PackagedPluginTest { val fixture = Files.createTempDirectory(output, "mcpp-用例 space-") Files.createDirectories(fixture.resolve("src")) Files.writeString(fixture.resolve("mcpp.toml"), "[package]\nname = \"clion-smoke\"\nversion = \"0.1.0\"\n") - Files.writeString(fixture.resolve("src/main.cpp"), "#include \nint main() { auto* f = std::fopen(\"clion-run-ok.txt\", \"w\"); if (!f) return 1; std::fputs(\"run-ok\\n\", f); std::fclose(f); return 0; }\n") + Files.writeString(fixture.resolve("src/main.cpp"), """ + #include + int main() { + int answer = 40; + answer += 2; + auto* f = std::fopen("clion-run-ok.txt", "w"); + if (!f) return 1; + std::fputs("run-ok\n", f); + std::fclose(f); + return answer == 42 ? 0 : 2; + } + """.trimIndent() + "\n") Files.createDirectories(fixture.resolve("tests")) Files.writeString(fixture.resolve("tests/check.cpp"), "int main() { return 0; }\n") val localHome = System.getProperty("mcpp.clion.ideHome")?.let(Path::of) @@ -279,12 +306,13 @@ class PackagedPluginTest { record("visible-three-view-tool-window") val runs = utility().getInstance(singleProject()) val actionResults = mutableMapOf() - fun execute(command: String, label: String, success: Boolean = true): RunDescriptorRemote { + fun execute(command: String, label: String, success: Boolean? = true, whileRunning: (() -> Unit)? = null): RunDescriptorRemote { val before = runs.getAllDescriptors().map { it.getExecutionId() }.toSet() val previousHandlers = runs.getAllDescriptors().mapNotNull { it.getProcessHandler() } .map { (it as RefWrapper).getRef().identityHashCode() }.toSet() val startedAt = System.nanoTime() invokeAction("mcpp.$command") + whileRunning?.invoke() val deadline = startedAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(if (command == "debug") 60 else 180) var completed: RunDescriptorRemote? = null while (System.nanoTime() < deadline) { @@ -298,17 +326,18 @@ class PackagedPluginTest { Thread.sleep(100) } assertNotNull(completed, "the real $command action finishes in the IDE") - assertEquals(success, completed!!.getProcessHandler()!!.getExitCode() == 0, "$label has the expected exit status") + val finished = requireNotNull(completed) + if (success != null) assertEquals(success, finished.getProcessHandler()!!.getExitCode() == 0, "$label has the expected exit status") if (command == "test") { - val testRoot = cast(completed.getExecutionConsole(), TestConsoleRemote::class).getResultsViewer().getTestsRootNode() + val testRoot = cast(finished.getExecutionConsole(), TestConsoleRemote::class).getResultsViewer().getTestsRootNode() assertFalse(testRoot.getChildren().isEmpty(), "the native test tree contains discovered tests") - assertEquals(!success, testRoot.isDefect(), "the test tree records the actual outcome") - if (success) assertTrue(testRoot.getChildren().all { it.isPassed() }, "test results reach the native tree") + assertEquals(success == false, testRoot.isDefect(), "the test tree records the actual outcome") + if (success == true) assertTrue(testRoot.getChildren().all { it.isPassed() }, "test results reach the native tree") record("native-test-tree-$label") } actionResults[label] = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt) record("action-$label") - return completed + return finished } for (command in listOf("build", "run", "test")) execute(command, command) assertEquals("run-ok\n", Files.readString(fixture.resolve("clion-run-ok.txt")), "the program actually ran") @@ -335,6 +364,38 @@ class PackagedPluginTest { val debugWindow = utility().getInstance(singleProject()).getToolWindow("Debug") assertTrue(debugWindow?.isVisible() == true, "the native debugger view is visible") record("native-debugger-process-and-view") + val debugger = utility().getInstance(singleProject()) + val debugFile = requireNotNull(utility().getInstance().refreshAndFindFileByPath(source.toString())) + openEditor(debugFile) + withContext(OnDispatcher.EDT) { service().toggleLineBreakpoint(singleProject(), debugFile, 3, false) } + fun suspendedAt(line: Int): DebugSessionRemote { + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (System.nanoTime() < deadline) { + val current = debugger.getCurrentSession() + if (current?.isSuspended() == true && current.getCurrentPosition()?.getLine() == line) return current + Thread.sleep(100) + } + error("the native debugger did not suspend at fixture line $line") + } + Files.delete(fixture.resolve("clion-run-ok.txt")) + execute("debug", "debug-breakpoint-step-resume", whileRunning = { + val paused = suspendedAt(3) + record("debug-breakpoint") + withContext(OnDispatcher.EDT) { paused.stepOver(false) } + val stepped = suspendedAt(4) + record("debug-step-over") + withContext(OnDispatcher.EDT) { stepped.resume() } + }) + assertEquals("run-ok\n", Files.readString(fixture.resolve("clion-run-ok.txt")), "resume completes the debugged program") + record("debug-resume-program-output") + Files.delete(fixture.resolve("clion-run-ok.txt")) + execute("debug", "debug-stop", success = null, whileRunning = { + val paused = suspendedAt(3) + withContext(OnDispatcher.EDT) { paused.stop() } + }) + assertFalse(Files.exists(fixture.resolve("clion-run-ok.txt")), "stop at the breakpoint prevents subsequent program work") + record("debug-stop-prevents-program-work") + withContext(OnDispatcher.EDT) { service().toggleLineBreakpoint(singleProject(), debugFile, 3, false) } for ((command, title) in listOf("mcpp.library.search" to "Libraries", "mcpp.showCachePanel" to "Cache")) { invokeAction(command) val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) From 9f8b48638a99204ab3c8601c924d2380e6a39080 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Thu, 5 Nov 2026 20:43:42 +0900 Subject: [PATCH 06/18] test: check native debugger values and keyboard completion --- ...ion-backend-location-and-stable-runtime.md | 26 +++++++--- mide-plugins/clion/README.md | 3 +- .../src/test/kotlin/PackagedPluginTest.kt | 49 ++++++++++++++++++- 3 files changed, 68 insertions(+), 10 deletions(-) diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index d8bd0bf..7fe43ce 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -1,6 +1,6 @@ # CLion backend location and stable runtime validation -Status: fixes and the initial stable runtime suite pass canonical CI; expanded debugger CI pending. +Status: review branch; canonical and diagnostic evidence are distinguished below. ## Observed failure @@ -74,13 +74,13 @@ The runtime table can offer fewer entries according to the language service's ad capabilities. Every offered command and each forwarded action must resolve to a registered IDE action; hidden or deprecated commands are not counted as executed functionality. -The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 29 checks. They invoke the actual +The local 2026.1.5.2 and 2026.2.4.1 experiments pass the same 31 checks. They invoke the actual Build, Run and Test actions, wait for fresh process handlers, require the expected exit code, and check a file written by the program. A compilation failure must appear in the console; restoring the source must restore a successful build. Test failure and recovery must reach the native test tree. The project path contains Unicode and spaces. Compilation uses GCC 16.1.0; -CI installs the same compiler. These are diagnostic adapter packages with unchanged backend -bytes; the canonical package must still pass CI. +CI installs the same compiler. Earlier controls used diagnostic adapter packages with unchanged +backend bytes. The variable-view checks reuse the successful canonical CI package identified below. Two additional adapter defects were reproduced and corrected. The completion contributor did not implement `DumbAware`, so an index-independent backend feature was suppressed during IDE @@ -98,7 +98,16 @@ now execute the actual Debug action, finish with exit code zero through a native `CidrDebugProcess` process handler, and expose the Debug tool window. Both versions then stop at a real source breakpoint, step to the next source line, resume to normal completion and check program output. A separate debug run stops at the breakpoint and must terminate before -the program writes its output. Variable inspection and runner-backed debugging remain pending. +the program writes its output. The native variables tree shows `answer=40` before the step and +`answer=42` afterward. These extra checks reuse the exact canonical ZIP from run 42009038494, +SHA256 `00820eb70fec31b6a105cd4f0ac069f6b90e72121271121bcf937d147cbbc3a3`. +Runner-backed debugging remains pending. + +The variable-view interaction changes keyboard focus. The manifest test explicitly focuses +its editor before completion, then accepts the selected lookup item with a real Enter event. +The popup, selected item and exact inserted text remain assertions. Generic SDK action +invocation can reject lookup acceptance without the corresponding editor event context; +this test adaptation does not change the product or bypass completion. The first canonical CI run passed the plugin build, static verification and other ecosystem checks. Both stable runtime jobs stopped during xlings installation when the installer tried @@ -107,8 +116,9 @@ to read `/dev/tty`; neither reached IDE startup. The reusable workflow now sets Canonical run 42005596492 at commit `c5aed8e` subsequently passed all 25 jobs, including the actual packaged plugin on both stable IDE builds. The stable runtime reports contain 23 successful checks on 2026.1 and 24 on 2026.2, including completion while indexing on the -latter. The added breakpoint, step, resume and stop cases have passed locally on both IDEs; -the expanded canonical suite still requires its own successful run. +latter. The added breakpoint, step, resume and stop cases also passed canonical run +42009038494 at commit `4bb1868`, with all 25 jobs successful and 30 checks in each stable IDE. +The additional variable-view and keyboard-acceptance cases require their own canonical run. Verifier 1.411 confirms both stable builds are compatible with zero internal API usages after using the public plugin-aware class loader descriptor. The original deprecated/experimental API @@ -123,7 +133,7 @@ tests serialize use of the Driver's ports; CI matrix entries use independent run | Build | Success, compilation failure, incremental build, cancellation, console source links | Success, compiler error text, failure and recovery pass; cancellation and links pending | | Run | Executable selection, arguments, working directory, PTY, stop | Successful IDE action and program-produced file; other interactions pending | | Test | Discovery, success, failure, filter, test tree and rerun | Native tree success, failure and corrected rerun pass; filter pending | -| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Native debug launch, breakpoint, step over, resume, normal completion and stop pass on both stable versions; variables and runner-backed program pending | +| Debug | Native breakpoint, variables, step, continue, stop; runner-backed program boundary | Native launch, breakpoint, step, resume, normal completion and stop pass canonical CI on both stable versions; variable values before/after stepping pass locally with that CI ZIP; runner-backed program pending | | Project | Data rows, targets, result/artifact links, refresh | Three views instantiate; populated rows and links pending | | Libraries | Search, detail, add dependency, index update, error feedback | Pending functional interaction | | Cache | Inventory, plan, confirmation/cancel, isolated apply, refused untrusted actions | Opening the view loads real inventory without manual refresh; plan/apply/refusal pending; no global user cache deletion | diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 129bbf6..4e1d65e 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -48,7 +48,8 @@ The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, and verifies the visible mcpp tool window and cache inventory. It executes build, run and test actions in a project whose path contains Unicode and spaces, launches the native debugger, -checks its visible view, source breakpoint, step over, resume and stop, checks the native test tree, +checks its visible view, source breakpoint, variable values before and after stepping, resume +and stop, checks the native test tree, compiler-error output, build/test failure and recovery, and exact manifest completion insertion during project analysis. It supplements Plugin Verifier's static compatibility checks. diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index d3742d3..29027c6 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -166,6 +166,17 @@ interface DebugSessionRemote { } @Remote("com.intellij.xdebugger.XSourcePosition") interface DebugPositionRemote { fun getLine(): Int } +@Remote("javax.swing.JTree") +interface DebugTreeRemote { fun getModel(): DebugTreeModelRemote } +@Remote("javax.swing.tree.TreeModel") +interface DebugTreeModelRemote { fun getRoot(): DebugNodeRemote } +@Remote("com.intellij.xdebugger.impl.ui.tree.nodes.XDebuggerTreeNode") +interface DebugNodeRemote { + fun getLoadedChildren(): List + fun getText(): ColoredTextRemote +} +@Remote("com.intellij.ui.SimpleColoredText") +interface ColoredTextRemote { fun getTexts(): List } @Remote("com.intellij.execution.ui.ExecutionConsole") interface ExecutionConsoleRemote @@ -377,13 +388,44 @@ class PackagedPluginTest { } error("the native debugger did not suspend at fixture line $line") } + fun visibleVariable(value: Int) { + ui.x("//div[@visible_text='Threads & Variables']").click() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + var found = false + do { + found = withContext(OnDispatcher.EDT) { + val components = ArrayDeque() + components.add(debugWindow!!.getContentManager().getSelectedContent()!!.getComponent()) + val trees = mutableListOf() + while (components.isNotEmpty()) { + val component = components.removeFirst() + if (component.getClass().getName().contains("DebuggerTree")) trees += cast(component, DebugTreeRemote::class) + components.addAll(component.getComponents()) + } + val nodes = ArrayDeque() + trees.forEach { nodes.add(it.getModel().getRoot()) } + var visible = false + while (nodes.isNotEmpty()) { + val node = nodes.removeFirst() + if (Regex("\\banswer\\b.*\\b$value\\b").containsMatchIn(node.getText().getTexts().joinToString(""))) visible = true + nodes.addAll(node.getLoadedChildren()) + } + visible + } + if (!found) Thread.sleep(100) + } while (!found && System.nanoTime() < deadline) + assertTrue(found, "the native variables view shows answer = $value") + record("debug-variable-$value") + } Files.delete(fixture.resolve("clion-run-ok.txt")) execute("debug", "debug-breakpoint-step-resume", whileRunning = { val paused = suspendedAt(3) record("debug-breakpoint") + visibleVariable(40) withContext(OnDispatcher.EDT) { paused.stepOver(false) } val stepped = suspendedAt(4) record("debug-step-over") + visibleVariable(42) withContext(OnDispatcher.EDT) { stepped.resume() } }) assertEquals("run-ok\n", Files.readString(fixture.resolve("clion-run-ok.txt")), "resume completes the debugged program") @@ -415,6 +457,11 @@ class PackagedPluginTest { withWriteAction { editor.getDocument().setText(manifestBefore + "\n[dep") } withContext(OnDispatcher.EDT) { editor.getCaretModel().moveToOffset(editor.getDocument().getText().length) } val indexingAtCompletion = utility().getInstance(singleProject()).isDumb() + val manifestEditor = ui.x("//div[@class='EditorComponentImpl' and @accessiblename='Editor for mcpp.toml']") + manifestEditor.click() + val focusDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (!manifestEditor.isFocusOwner() && System.nanoTime() < focusDeadline) Thread.sleep(100) + assertTrue(manifestEditor.isFocusOwner(), "the manifest editor owns completion keyboard actions") invokeAction("CodeCompletion") val lookups = utility().getInstance(singleProject()) val lookupDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) @@ -426,7 +473,7 @@ class PackagedPluginTest { } assertNotNull(dependencies, "mcpp.toml completion is visible in the actual editor") withContext(OnDispatcher.EDT) { lookups.getActiveLookup()!!.setCurrentItem(dependencies!!) } - invokeAction("EditorChooseLookupItem") + manifestEditor.keyboard { hotKey(java.awt.event.KeyEvent.VK_ENTER) } assertEquals(manifestBefore + "\n[dependencies]", editor.getDocument().getText(), "completion replaces exactly the requested range") record("manifest-completion-and-insertion") if (indexingAtCompletion) record("manifest-completion-during-indexing") From 95a6a1c2b0ca8e2c57396c3277e366fbcecd9358 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 06:25:15 +0900 Subject: [PATCH 07/18] fix: load and validate settings in stable CLion dialogs --- ...ion-backend-location-and-stable-runtime.md | 49 +++++ .github/workflows/clion-stable.yml | 2 + README.md | 4 + README.zh-CN.md | 3 + mide-plugins/clion/README.md | 7 + mide-plugins/clion/build.gradle.kts | 4 +- .../test/kotlin/SettingsInteractionTest.kt | 207 ++++++++++++++++++ .../mcppclion/settings/McppConfigurable.kt | 47 +++- .../github/mcppcommunity/mcppclion/UiTest.kt | 29 +++ spec/i18n/runtime.zh-cn.json | 2 + 10 files changed, 342 insertions(+), 12 deletions(-) create mode 100644 mide-plugins/clion/integration-tests/src/test/kotlin/SettingsInteractionTest.kt diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 7fe43ce..615cefe 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -149,3 +149,52 @@ feedback, discoverability, absence of stalled progress, selection persistence an errors. Static API compatibility and registered actions alone do not satisfy these criteria. The two stable IDE versions run the same candidate; platform-specific GUI behavior remains separately bounded. Findings and fixes continue in the same review PR. + +## Settings loading and validation supplement, 2026-11-05 + +The preceding variable-view and keyboard-completion changes subsequently passed canonical run +42021317951 at 9f8b486, with all 25 jobs successful. The following settings changes have separate +local controls and still require their own canonical package and final CI. + +The actual Settings dialog on CLion 2026.1.5.2 remained at `Reading the settings…` with the +canonical ZIP from run 42009038494. Its SHA256 is +`00820eb70fec31b6a105cd4f0ac069f6b90e72121271121bcf937d147cbbc3a3`. +The registry response was scheduled as a nonmodal UI callback while Settings was modal. +The callback now only fills Swing controls using any modality; a generation guard rejects +responses for a disposed or replaced form. A corrected test observer still failed to obtain +the original form within its bounded wait, whereas the loading-only diagnostic ZIP reached +the actual fields with identical backend and runner bytes. + +The generated labels also had no associated input. The form now sets `labelFor`. Numeric +validation was independently reproduced after those prerequisites: real keyboard input saved +12, then Apply incorrectly replaced it with −1 although the registry's minimum is zero. +The form now validates all numeric fields before returning any changes. Empty, nonnumeric, +non-finite and out-of-range values produce a native configuration error and no partial save. +Large finite values outside the Long range remain numeric rather than saturating to Long.MAX_VALUE. + +The same diagnostic ZIP passed the following actual interactions on 2026.1.5.2 and 2026.2.4.1: +form loading, label association, a valid value, refusal of a second otherwise-valid edit while +another field is invalid, visible errors for six invalid inputs, unchanged stored values, +serialization into the real settings XML, reopening the form, and removal of a default override. +The test does not claim an IDE restart or multi-project scope validation. The diagnostic package +changes only the adapter JAR; the complete Gradle package remains the final CI input. + +Three test-driver failures are retained separately. The first observer requested Swing DOM +discovery inside an EDT remote call while the modal dialog was open; the corrected observer +still reproduced the original loading failure. Direct `setText` did not notify the native +Settings editor that user input occurred, so the final test uses actual keyboard events. +A global OK selector matched two windows on 2026.1; it is now scoped to the Settings window +using the Driver SDK's window type and accessible name. These changes preserve the required +visible effects and do not bypass Apply or persistence. + +One later launch failed before opening the project because the local test output's actual +storage was full. The IDE reported ENOSPC and could not initialize VFS; numeric validation was +not reached. Logs and configuration from ended contexts were archived and verified before +their duplicate plugin installations and system caches were removed, freeing 2.376 GiB. +The identical candidate then passed. SDKs and frozen performance artifacts were retained. + +The runtime workflow uploads only public versions, package hashes and assertions for these +settings checks. User logs, screenshots, private paths and configuration contents are excluded. +Remaining settings coverage includes locale interactions, application/project scope behavior, +registry error recovery and dynamic backend replacement. The broader functional matrix above +remains incomplete; this supplement closes only the observed loading and value-validation defects. diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index 304f5c7..023ad71 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -95,4 +95,6 @@ jobs: path: | ${{ runner.temp }}/clion-stable-result/result.json ${{ runner.temp }}/clion-stable-result/checks.json + ${{ runner.temp }}/clion-stable-result/settings/result.json + ${{ runner.temp }}/clion-stable-result/settings/observations.json if-no-files-found: warn diff --git a/README.md b/README.md index 4fff171..888eab2 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,10 @@ those of mcpp.toml and build.mcpp come from the backend ([docs/rules.md](docs/ru | Targets (target sets among them), pack | Quick picks | Popups | `vim.ui.select` | | Language features | mcppls (installed with the extension) | mcppls CLion plugin (a dependency) | mcppls-nvim (`xlings install mcppls-nvim`) | +CLion's **Settings | Tools | mcpp** validates finite numbers and their allowed ranges before +saving the form. Invalid input displays an error and leaves every stored value unchanged. +Entering a setting's default removes its stored override. + - **One answer everywhere** -- what a build runs, how a test is reported, what a cleanup costs: the backend's - **Only mcpp's contract** -- no manifest parsing, no dependency resolution of its own - **Jobs in order** -- one at a time per project, a newer build replaces a waiting one, cancelling ends cleanly diff --git a/README.zh-CN.md b/README.zh-CN.md index aa67629..6288085 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,6 +31,9 @@ quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。C++ 文 | 目标(含目标集合)、打包 | 快速选择 | 弹出列表 | `vim.ui.select` | | 语言功能 | mcppls(随扩展一并安装) | mcppls CLion 插件(依赖) | mcppls-nvim(`xlings install mcppls-nvim`) | +CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限、是否处于允许范围内。 +无效输入会显示错误,并保持所有已存储的值不变。输入某项设置的默认值会移除其存储覆盖。 + - **处处同一答案**——构建运行什么、测试如何报告、清理的代价:都由后端决定 - **只读 mcpp 的契约**——不自行解析清单,不自行解析依赖 - **任务有序**——每个工程同一时间一个任务,新构建替换等待中的构建,取消会正常结束 diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 4e1d65e..919a827 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -21,6 +21,10 @@ Language features of C++ files come from the mcppls CLion plugin, which this plu Those of `mcpp.toml` and `build.mcpp` -- completion, documentation, the jumps and the diagnostics -- come from the backend over the session's connection (mcpp-ide's `docs/rules.md`). +In **Settings | Tools | mcpp**, Apply validates every numeric value against the registry's +range and rejects non-finite values. An invalid value displays an error and leaves all stored +settings unchanged. Entering a setting's default removes its stored override. + ## Build ```bash @@ -43,6 +47,9 @@ docs/architecture.md, Distribution. Without them the plugin finds the backend th settings form headless. `npm run test:webui` in `../vscode` runs the graph page in Chromium over the backend. +The stable IDE tests also use the actual settings dialog: keyboard input, visible validation +errors, refusal of partial saves, the persisted XML, reopening the form and restoring defaults. + The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 IDEs by `clion-stable.yml`. Both versions load the same ZIP and the pinned mcppls plugin. The test opens an mcpp project, completes protocol 2 initialization, loads runtime translations and commands, diff --git a/mide-plugins/clion/build.gradle.kts b/mide-plugins/clion/build.gradle.kts index cb4046c..f317d57 100644 --- a/mide-plugins/clion/build.gradle.kts +++ b/mide-plugins/clion/build.gradle.kts @@ -126,8 +126,8 @@ tasks.prepareSandbox { } intellijPlatform { - // The settings page is the shared web panel: there is nothing for the settings search to index, - // and the index is built by starting an IDE, which a machine without a display cannot. + // The native settings form receives its fields from the backend registry + // at runtime; there is no static field catalogue for the options index. buildSearchableOptions = false pluginConfiguration { id = "io.github.mcpp-community.mcpp-clion" diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/SettingsInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/SettingsInteractionTest.kt new file mode 100644 index 0000000..707e8cc --- /dev/null +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/SettingsInteractionTest.kt @@ -0,0 +1,207 @@ +import com.intellij.driver.client.Remote +import com.intellij.driver.client.service +import com.intellij.driver.client.utility +import com.intellij.driver.model.OnDispatcher +import com.intellij.driver.sdk.invokeAction +import com.intellij.driver.sdk.singleProject +import com.intellij.driver.sdk.waitForProjectOpen +import com.intellij.driver.sdk.ui.ui +import com.intellij.driver.sdk.ui.remote.Component +import com.intellij.ide.starter.ci.CIServer +import com.intellij.ide.starter.ci.NoCIServer +import com.intellij.ide.starter.di.di +import com.intellij.ide.starter.driver.engine.runIdeWithDriver +import com.intellij.ide.starter.ide.IdeDistributionFactory +import com.intellij.ide.starter.ide.IdeInstaller +import com.intellij.ide.starter.models.IdeInfo +import com.intellij.ide.starter.models.TestCase +import com.intellij.ide.starter.plugins.PluginConfigurator +import com.intellij.ide.starter.project.LocalProjectInfo +import com.intellij.ide.starter.runner.Starter +import com.intellij.platform.testFramework.teamCity.TeamCityReporter.SyntheticTestKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.direct +import org.kodein.di.instance +import java.nio.file.Files +import java.nio.file.Path + +@Remote("io.github.mcppcommunity.mcppclion.settings.McppSettings", plugin = "io.github.mcpp-community.mcpp-clion") +interface InteractiveSettingsStore { + fun get(key: String): RemoteJsonValue? +} + +@Remote("javax.swing.JLabel") +interface SettingsLabelRemote { + fun getLabelFor(): Component? +} + +@Remote("javax.swing.text.JTextComponent") +interface SettingsTextRemote { + fun getText(): String + fun setText(value: String) + fun requestFocusInWindow(): Boolean +} + +@Remote("com.intellij.openapi.application.PathManager") +interface SettingsPathsRemote { fun getConfigPath(): String } + +class SettingsInteractionTest { + init { + di = DI { + extend(di) + bindSingleton(overrides = true) { + object : CIServer by NoCIServer { + override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?, kind: SyntheticTestKind, generifyTestName: Boolean) { + throw AssertionError("IDE failure: $testName: $message") + } + } + } + } + } + + @Test + fun settingsValidateBeforeSavingInTheRealDialog() { + val version = System.getProperty("mcpp.clion.ideVersion") ?: error("ideVersion is required") + val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath().resolve("settings") + Files.createDirectories(output) + val observations = mutableListOf() + fun record(name: String, value: String) { + observations += "\"$name\":\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\"" + Files.writeString(output.resolve("observations.json"), "{${observations.joinToString(",")}}\n") + } + val fixture = Files.createTempDirectory(output, "settings-project-") + Files.createDirectories(fixture.resolve("src")) + Files.writeString(fixture.resolve("mcpp.toml"), "[package]\nname = \"settings-control\"\nversion = \"0.1.0\"\n") + Files.writeString(fixture.resolve("src/main.cpp"), "int main() { return 0; }\n") + val localHome = System.getProperty("mcpp.clion.ideHome")?.let(Path::of) + var ide = IdeInfo(productCode = "CL", platformPrefix = "CLion", executableFileName = "clion", fullName = "CLion", version = version, buildType = "release") + if (localHome != null) { + ide = ide.copy(getInstaller = { + object : IdeInstaller { + override suspend fun install(ideInfo: IdeInfo) = + Files.readString(localHome.resolve("build.txt")).trim().removePrefix("CL-") to + di.direct.instance().installIDE(localHome, "clion") + } + }) + } + val context = Starter.newContext("mcpp-settings-$version-${System.currentTimeMillis()}", TestCase(ide, LocalProjectInfo(fixture))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.mcpplsZip") ?: error("mcpplsZip is required"))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.pluginZip") ?: error("pluginZip is required"))) + context.applyVMOptionsPatch { + withXmx(2048) + addSystemProperty("idea.trust.all.projects", true) + addSystemProperty("ide.show.tips.on.startup.default.value", false) + } + context.runIdeWithDriver().useDriverAndCloseIde { + waitForProjectOpen() + withContext { + assertEquals(version, utility().getInstance().getFullVersion()) + record("ideVersion", version) + record("ideBuild", utility().getInstance().getBuild().asString()) + val session = service(singleProject()) + val future = session.start() + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + while (!future.isDone() && System.nanoTime() < deadline) Thread.sleep(100) + assertTrue(future.isDone()) + assertEquals(2, future.get().get("protocol").getAsInt()) + record("startup", "protocol2") + val store = service() + val key = "mcpp.runtime.timeoutSeconds" + invokeAction("mcpp.openSettings") + var settingsOpen = true + val dialog = ui.x { + and(or(byType(javax.swing.JDialog::class.java), byType(javax.swing.JFrame::class.java)), + byAccessibleName("Settings")) + } + try { + val label = ui.x("//div[@visible_text='Set the command timeout:' or @text='Set the command timeout:']") + val labelComponent = label.component + record("settingsForm", "visible") + val linked = cast(labelComponent, SettingsLabelRemote::class).getLabelFor() + record("labelLinked", (linked != null).toString()) + assertNotNull(linked, "the visible setting label is associated with its input") + var field = cast(linked!!, SettingsTextRemote::class) + val apply = dialog.x("//div[@visible_text='Apply' or @text='Apply']") + fun typeValue(target: SettingsTextRemote, value: String) { + assertTrue(withContext(OnDispatcher.EDT) { target.requestFocusInWindow() }) + label.keyboard { + // This check exercises actual text-input events, which + // notify the native Settings editor of modifications. + @Suppress("OPT_IN_USAGE") + hotKeyWithDefaultModifierKey(java.awt.event.KeyEvent.VK_A) + backspace() + if (value.isNotEmpty()) typeText(value) + } + assertEquals(value, target.getText(), "the real input receives the typed value") + } + fun setAndApply(value: String) { + typeValue(field, value) + val ready = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (!apply.isEnabled() && System.nanoTime() < ready) Thread.sleep(100) + assertTrue(apply.isEnabled(), "Apply becomes available for the edited field") + apply.click() + } + setAndApply("12") + assertEquals("12", store.get(key)?.getAsString(), "a valid number reaches the settings store") + record("validNumber", store.get(key)!!.getAsString()) + val outputLabel = ui.x("//div[@visible_text='Cap captured command output:' or @text='Cap captured command output:']") + val outputInput = cast(outputLabel.component, SettingsLabelRemote::class).getLabelFor() + assertNotNull(outputInput) + val outputField = cast(outputInput!!, SettingsTextRemote::class) + typeValue(outputField, "32") + setAndApply("-1") + assertEquals(null, store.get("mcpp.runtime.maxOutputMiB"), "another valid edit is not partially saved when the form is invalid") + assertEquals("12", store.get(key)?.getAsString()) + record("atomicValidation", "no-partial-save") + typeValue(outputField, "16") + for (invalid in listOf("-1", "3601", "NaN", "Infinity", "not-a-number", "")) { + setAndApply(invalid) + val actual = store.get(key)?.getAsString() ?: "default" + record("afterInvalid-$invalid", actual) + assertEquals("12", actual, "invalid input $invalid must not replace the previous valid setting") + assertTrue(ui.x("//div[contains(@visible_text,'Invalid value') or contains(@text,'Invalid value')]").present(), + "invalid input is explained visibly before saving") + } + typeValue(field, "12") + dialog.x("//div[@visible_text='OK' or @text='OK']").click() + settingsOpen = false + invokeAction("SaveAll") + val settingsFile = Path.of(utility().getConfigPath()).resolve("options/mcpp-ide.xml") + fun persistedValue(): String? { + if (!Files.isRegularFile(settingsFile)) return null + return runCatching { + val document = javax.xml.parsers.DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(settingsFile.toFile()) + val entries = document.getElementsByTagName("entry") + (0 until entries.length).map { entries.item(it) as org.w3c.dom.Element } + .firstOrNull { it.getAttribute("key") == key }?.getAttribute("value") + }.getOrNull() + } + val savedAt = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15) + while (persistedValue() != "12" && System.nanoTime() < savedAt) Thread.sleep(100) + assertEquals("12", persistedValue(), "the actual persistent settings file contains the saved value") + record("persistedXml", "12") + invokeAction("mcpp.openSettings") + settingsOpen = true + val reopened = ui.x("//div[@visible_text='Set the command timeout:' or @text='Set the command timeout:']") + field = cast(cast(reopened.component, SettingsLabelRemote::class).getLabelFor()!!, SettingsTextRemote::class) + assertEquals("12", field.getText(), "reopening the native form restores the saved value") + record("reopenedForm", "12") + setAndApply("30") + assertEquals(null, store.get(key), "restoring the default removes the stored override") + record("resetDefault", "override-removed") + } finally { + if (settingsOpen) dialog.x("//div[@visible_text='Cancel' or @text='Cancel']").click() + } + } + } + val plugin = Path.of(System.getProperty("mcpp.clion.pluginZip")) + val digest = java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(plugin)) + .joinToString("") { "%02x".format(it) } + Files.writeString(output.resolve("result.json"), """{"ideVersion":"$version","pluginSha256":"$digest","settingsValidated":true,"atomicValidation":true,"persistedXml":true,"reopenedForm":true,"defaultOverrideRemoved":true}""" + "\n") + } +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt index 72f1a84..a6f6db4 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt @@ -11,7 +11,9 @@ import com.google.gson.JsonNull import com.google.gson.JsonObject import com.google.gson.JsonPrimitive import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.options.ConfigurationException import com.intellij.openapi.project.Project import com.intellij.openapi.ui.ComboBox import com.intellij.ui.SearchTextField @@ -46,7 +48,7 @@ class SettingField(val entry: JsonObject) { type == "array" -> JBTextArea(2, 40).apply { lineWrap = false } else -> JBTextField(30) } - val label: JBLabel? = if (type == "boolean") null else JBLabel("$title:") + val label: JBLabel? = if (type == "boolean") null else JBLabel("$title:").apply { labelFor = component } val comment: JBLabel = JBLabel("${escape(description)}
$key").apply { foreground = UIUtil.getContextHelpForeground() font = JBUI.Fonts.smallFont() @@ -70,8 +72,9 @@ class SettingField(val entry: JsonObject) { is JBCheckBox -> JsonPrimitive(c.isSelected) is ComboBox<*> -> enumValues.firstOrNull { label(it) == c.selectedItem } ?: default is JBTextArea -> JsonArray().apply { c.text.lines().map { it.trim() }.filter { it.isNotEmpty() }.forEach { add(it) } } - is JBTextField -> if (type == "number") c.text.trim().toDoubleOrNull()?.let { d -> - if (d == Math.floor(d) && !d.isInfinite()) JsonPrimitive(d.toLong()) else JsonPrimitive(d) + is JBTextField -> if (type == "number") c.text.trim().toDoubleOrNull()?.takeIf { it.isFinite() }?.let { d -> + if (d == Math.floor(d) && d >= Long.MIN_VALUE.toDouble() && d < Long.MAX_VALUE.toDouble()) + JsonPrimitive(d.toLong()) else JsonPrimitive(d) } else JsonPrimitive(c.text) else -> null } @@ -89,7 +92,7 @@ class SettingField(val entry: JsonObject) { } /** The form from a registry. Pure but for Swing: the tests build it from the backend's answer. */ -class SettingsForm(registry: JsonObject, t: (String) -> String, private val stored: (String) -> JsonElement?) { +class SettingsForm(registry: JsonObject, private val t: (String) -> String, private val stored: (String) -> JsonElement?) { val fields: List val component: JComponent private val search = SearchTextField(false) @@ -148,10 +151,28 @@ class SettingsForm(registry: JsonObject, t: (String) -> String, private val stor fun isModified(): Boolean = fields.any { f -> f.value()?.let { it != current(f) } ?: true } /** What changed, each value to store (null: back to the default). */ - fun changes(): Map = fields.mapNotNull { f -> - val v = f.value() ?: return@mapNotNull null - if (v == current(f)) null else f.key to (if (v == f.default) null else v) - }.toMap() + fun changes(): Map { + // Validate the complete form before apply() writes any of its values. + val values = fields.map { f -> + val value = f.value() + if (f.type == "number") { + val number = value?.asDouble + val minimum = f.entry["minimum"]?.asDouble + val maximum = f.entry["maximum"]?.asDouble + if (number == null || !number.isFinite() || (minimum != null && number < minimum) || + (maximum != null && number > maximum)) { + val range = "${f.entry["minimum"]?.asString ?: "−∞"} … ${f.entry["maximum"]?.asString ?: "∞"}" + throw ConfigurationException(t("Invalid value for {0}. Enter a finite number in the range {1}.") + .replace("{0}", f.title).replace("{1}", range)) + } + } + if (value == null) throw ConfigurationException(t("Invalid value for {0}.").replace("{0}", f.title)) + f to value + } + return values.mapNotNull { (f, value) -> + if (value == current(f)) null else f.key to (if (value == f.default) null else value) + }.toMap() + } fun reset() = fields.forEach { it.show(stored(it.key)) } } @@ -159,16 +180,21 @@ class SettingsForm(registry: JsonObject, t: (String) -> String, private val stor class McppConfigurable(private val project: Project) : SearchableConfigurable { private var form: SettingsForm? = null private val holder = JPanel(BorderLayout()) + private var generation = 0L override fun getId(): String = "io.github.mcppcommunity.mcppclion.settings" override fun getDisplayName(): String = "mcpp" override fun createComponent(): JComponent { + val requestGeneration = ++generation val session = McppSession.get(project) holder.removeAll() holder.add(JBLabel(session.t("Reading the settings…")), BorderLayout.NORTH) session.start().thenCompose { session.request("settings/registry") }.whenComplete { registry, error -> - ApplicationManager.getApplication().invokeLater { + // This callback only fills Swing controls. A nonmodal callback + // waits until the Settings dialog closes, leaving its form loading. + ApplicationManager.getApplication().invokeLater({ + if (generation != requestGeneration || project.isDisposed) return@invokeLater holder.removeAll() if (error != null || registry == null || !registry.isJsonObject) { holder.add(JBLabel(session.t("The settings come from the mcpp-ide backend, which is not running: {0}", (error?.cause ?: error)?.message ?: "")), BorderLayout.NORTH) @@ -179,7 +205,7 @@ class McppConfigurable(private val project: Project) : SearchableConfigurable { } holder.revalidate() holder.repaint() - } + }, ModalityState.any()) } return holder } @@ -197,6 +223,7 @@ class McppConfigurable(private val project: Project) : SearchableConfigurable { } override fun disposeUIResources() { + ++generation form = null holder.removeAll() } diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt index a43d5c9..5e205c9 100644 --- a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt @@ -11,6 +11,7 @@ import com.google.gson.JsonPrimitive import com.intellij.testFramework.ApplicationRule import com.intellij.testFramework.EdtRule import com.intellij.testFramework.RunsInEdt +import com.intellij.openapi.options.ConfigurationException import com.intellij.ui.components.JBCheckBox import com.intellij.ui.components.JBTextField import io.github.mcppcommunity.mcppclion.settings.SettingsForm @@ -21,6 +22,7 @@ import io.github.mcppcommunity.mcppclion.ui.RowNode import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Assert.assertThrows import org.junit.ClassRule import org.junit.Rule import org.junit.Test @@ -116,4 +118,31 @@ class UiTest { form.filter("") assertTrue(form.fields.all { it.component.isVisible }) } + + @Test + fun invalidNumbersRefuseTheWholeApplyAndKeepLargeFiniteValues() { + val registry = json("""{"groups":[{"id":"runtime","title":"Runtime"}],"settings":[ + {"key":"enabled","type":"boolean","default":false,"group":"runtime"}, + {"key":"timeout","type":"number","minimum":0,"maximum":3600,"default":30,"group":"runtime","title":"Timeout"}, + {"key":"budget","type":"number","minimum":0,"default":0,"group":"runtime","title":"Budget"}]}""") + val form = SettingsForm(registry, { it }) { null } + val timeout = form.fields.first { it.key == "timeout" }.component as JBTextField + val enabled = form.fields.first { it.key == "enabled" }.component as JBCheckBox + enabled.isSelected = true + for (invalid in listOf("", "not-a-number", "NaN", "Infinity", "-1", "3601", "1e309")) { + timeout.text = invalid + val error = assertThrows(ConfigurationException::class.java) { form.changes() } + assertTrue(error.message!!.contains("Timeout")) + assertTrue(error.message!!.contains("3600")) + } + timeout.text = "0.5" + assertEquals(JsonPrimitive(0.5), form.changes()["timeout"]) + assertEquals(JsonPrimitive(true), form.changes()["enabled"]) + timeout.text = "3600" + assertEquals(JsonPrimitive(3600L), form.changes()["timeout"]) + val budget = form.fields.first { it.key == "budget" }.component as JBTextField + budget.text = "1e20" + assertEquals(1e20, form.changes()["budget"]!!.asDouble, 0.0) + assertTrue(form.fields.filter { it.label != null }.all { it.label!!.labelFor === it.component }) + } } diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index d824f34..92f2995 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -253,6 +253,8 @@ "Installable: {0}": "可安装:{0}", "Installed": "已安装", "Invalid": "无效", + "Invalid value for {0}. Enter a finite number in the range {1}.": "{0} 的值无效。请输入 {1} 范围内的有限数值。", + "Invalid value for {0}.": "{0} 的值无效。", "Keep the shared build cache under": "将共享构建缓存保留在", "Keep the shared build cache under how many GiB?": "共享构建缓存保留在多少 GiB 以内?", "Label": "包", From 570835171c0ae71928558939e3494c041482047f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 07:29:39 +0900 Subject: [PATCH 08/18] fix: report graph query failures and verify stable CLion rendering --- ...ion-backend-location-and-stable-runtime.md | 35 +++ .github/workflows/clion-stable.yml | 4 + README.md | 3 + README.zh-CN.md | 2 + mide-plugins/clion/README.md | 5 + .../clion/integration-tests/build.gradle.kts | 2 +- .../src/test/kotlin/GraphInteractionTest.kt | 237 ++++++++++++++++++ src/server.cppm | 8 + tests/test_protocol.cpp | 19 ++ 9 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt diff --git a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md index 615cefe..d15a97f 100644 --- a/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -198,3 +198,38 @@ settings checks. User logs, screenshots, private paths and configuration content Remaining settings coverage includes locale interactions, application/project scope behavior, registry error recovery and dynamic backend replacement. The broader functional matrix above remains incomplete; this supplement closes only the observed loading and value-validation defects. + +## Graph error propagation and actual JCEF controls, 2026-11-06 + +The settings changes at `95a6a1c` passed all 25 jobs in canonical run `42112497367`. +Both stable runtime reports contain 31 checks, and their settings reports identify the same +packaged ZIP, SHA256 `e9c6fb50e7c210d3b713de1ce61c0265b7561451a3cb8225eae6aee0df8b013c`. +The new graph controls use that ZIP with the publicly supported backend-path setting pointing +to the fresh native backend. They are local backend-only controls; the next canonical CI must +exercise the packaged universal backend with that override absent. + +On CLion 2026.1.5.2 the original package rendered the package and module graphs, and clicking +the rendered module opened its actual source. After introducing a malformed manifest, the +graph displayed the ordinary empty-state instruction to build the project. The backend had +cached the failed `emit build-database` response as a successful database. It now returns a +request error containing the exit code and diagnostics before populating the cache. The protocol +regression changes the mock failure flag without editing the manifest; both graph queries recover +immediately, proving that recovery does not rely on a file watcher clearing a failed response. + +The fresh native backend passed the protocol regression and the actual JCEF scenario on both +2026.1.5.2 and 2026.2.4.1: real build, visible package SVG, visible module SVG, rendered-node +source navigation, visible manifest error with no SVG, and restored graph after correcting the +manifest. The disabled-JCEF explanation passed on 2026.1 with the canonical backend and on +2026.2 with the native backend control. A legitimate empty graph remains a separate unverified +scenario; an error rendered as empty is not evidence for it. + +Observer failures are retained separately. Tool-window content creation required EDT access; +a close-all-editors action was disabled because no editor was open. The navigation test now +verifies that the target source is not selected before clicking the rendered node. On 2026.2, +the Performance Testing plugin loader could not resolve the JCEF browser classes. The remote +interfaces now use the actual product plugin's public class-loader identity. With that correction, +both JCEF and disabled-JCEF scenarios passed. No product class-loading change was required. + +Only version, package identity, control mode and assertion names are uploaded. Local private +logs and screenshots remain outside repository documents and CI artifacts. The broader matrix +above, including runner debugging and native Windows/macOS GUI behavior, is still incomplete. diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index 023ad71..0064b49 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -97,4 +97,8 @@ jobs: ${{ runner.temp }}/clion-stable-result/checks.json ${{ runner.temp }}/clion-stable-result/settings/result.json ${{ runner.temp }}/clion-stable-result/settings/observations.json + ${{ runner.temp }}/clion-stable-result/graphs/result.json + ${{ runner.temp }}/clion-stable-result/graphs/checks.json + ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/result.json + ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/checks.json if-no-files-found: warn diff --git a/README.md b/README.md index 888eab2..1b768f1 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,9 @@ CLion's **Settings | Tools | mcpp** validates finite numbers and their allowed r saving the form. Invalid input displays an error and leaves every stored value unchanged. Entering a setting's default removes its stored override. +Graph queries report build-database errors instead of showing an empty graph. After correcting +the reported problem, reopen the graph to retry. + - **One answer everywhere** -- what a build runs, how a test is reported, what a cleanup costs: the backend's - **Only mcpp's contract** -- no manifest parsing, no dependency resolution of its own - **Jobs in order** -- one at a time per project, a newer build replaces a waiting one, cancelling ends cleanly diff --git a/README.zh-CN.md b/README.zh-CN.md index 6288085..18c5c96 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,6 +34,8 @@ quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。C++ 文 CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限、是否处于允许范围内。 无效输入会显示错误,并保持所有已存储的值不变。输入某项设置的默认值会移除其存储覆盖。 +图查询失败时显示构建数据库错误。修正所报告的问题后,重新打开图即可重试。 + - **处处同一答案**——构建运行什么、测试如何报告、清理的代价:都由后端决定 - **只读 mcpp 的契约**——不自行解析清单,不自行解析依赖 - **任务有序**——每个工程同一时间一个任务,新构建替换等待中的构建,取消会正常结束 diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 919a827..cb9a4c4 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -25,6 +25,9 @@ In **Settings | Tools | mcpp**, Apply validates every numeric value against the range and rejects non-finite values. An invalid value displays an error and leaves all stored settings unchanged. Entering a setting's default removes its stored override. +The graph view reports a failed build-database query as an error. Correct the reported problem +and reopen the graph to retry. An unavailable JCEF browser displays an explanation in the tool window. + ## Build ```bash @@ -49,6 +52,8 @@ backend. The stable IDE tests also use the actual settings dialog: keyboard input, visible validation errors, refusal of partial saves, the persisted XML, reopening the form and restoring defaults. +Graph tests inspect the real JCEF document, click a rendered module to open its source, +check visible query errors and recovery, and check the explanation when JCEF is disabled. The packaged ZIP is also run in the real stable CLion 2026.1.5.2 and 2026.2.4.1 IDEs by `clion-stable.yml`. Both versions load the same ZIP and the pinned mcppls plugin. The test opens diff --git a/mide-plugins/clion/integration-tests/build.gradle.kts b/mide-plugins/clion/integration-tests/build.gradle.kts index 8287cb5..be3797e 100644 --- a/mide-plugins/clion/integration-tests/build.gradle.kts +++ b/mide-plugins/clion/integration-tests/build.gradle.kts @@ -31,7 +31,7 @@ kotlin { jvmToolchain(25) } tasks.test { useJUnitPlatform() maxHeapSize = "2g" - for (key in listOf("pluginZip", "mcpplsZip", "ideVersion", "ideHome", "output")) { + for (key in listOf("pluginZip", "mcpplsZip", "ideVersion", "ideHome", "output", "backendPath")) { providers.gradleProperty(key).orNull?.let { systemProperty("mcpp.clion.$key", it) } } providers.gradleProperty("testHome").orNull?.let { systemProperty("user.home", it) } diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt new file mode 100644 index 0000000..0852360 --- /dev/null +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -0,0 +1,237 @@ +import com.intellij.driver.client.Remote +import com.intellij.driver.client.service +import com.intellij.driver.client.utility +import com.intellij.driver.model.OnDispatcher +import com.intellij.driver.sdk.invokeAction +import com.intellij.driver.sdk.singleProject +import com.intellij.driver.sdk.waitForProjectOpen +import com.intellij.driver.sdk.ui.ui +import com.intellij.driver.sdk.ui.remote.Component +import com.intellij.ide.starter.ci.CIServer +import com.intellij.ide.starter.ci.NoCIServer +import com.intellij.ide.starter.di.di +import com.intellij.ide.starter.driver.engine.runIdeWithDriver +import com.intellij.ide.starter.ide.IdeDistributionFactory +import com.intellij.ide.starter.ide.IdeInstaller +import com.intellij.ide.starter.models.IdeInfo +import com.intellij.ide.starter.models.TestCase +import com.intellij.ide.starter.plugins.PluginConfigurator +import com.intellij.ide.starter.project.LocalProjectInfo +import com.intellij.ide.starter.runner.Starter +import com.intellij.platform.testFramework.teamCity.TeamCityReporter.SyntheticTestKind +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Test +import org.kodein.di.DI +import org.kodein.di.bindSingleton +import org.kodein.di.direct +import org.kodein.di.instance +import java.nio.file.Files +import java.nio.file.Path + +@Remote("javax.swing.JComponent") +interface GraphComponentRemote { fun getClientProperty(key: Any): GraphBrowserRemote? } + +@Remote("com.intellij.ui.jcef.JBCefBrowserBase", plugin = "io.github.mcpp-community.mcpp-clion") +interface GraphBrowserRemote { + fun isCefBrowserCreated(): Boolean + fun getCefBrowser(): GraphCefRemote +} + +@Remote("org.cef.browser.CefBrowser", plugin = "io.github.mcpp-community.mcpp-clion") +interface GraphCefRemote { + fun hasDocument(): Boolean + fun getURL(): String + fun getDevToolsClient(): GraphDevToolsRemote +} + +@Remote("org.cef.browser.CefDevToolsClient", plugin = "io.github.mcpp-community.mcpp-clion") +interface GraphDevToolsRemote { + fun executeDevToolsMethod(method: String, parameters: String): GraphStringFutureRemote +} + +@Remote("java.util.concurrent.CompletableFuture") +interface GraphStringFutureRemote { + fun isDone(): Boolean + fun get(): String +} + +@Remote("com.google.gson.JsonParser") +interface GraphJsonParserRemote { fun parseString(text: String): RemoteJsonValue } + +@Remote("io.github.mcppcommunity.mcppclion.settings.McppSettings", plugin = "io.github.mcpp-community.mcpp-clion") +interface GraphSettingsRemote { fun set(key: String, value: RemoteJsonValue?) } + +class GraphInteractionTest { + init { + di = DI { + extend(di) + bindSingleton(overrides = true) { + object : CIServer by NoCIServer { + override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?, kind: SyntheticTestKind, generifyTestName: Boolean) { + throw AssertionError("IDE failure: $testName: $message") + } + } + } + } + } + + @Test + fun graphRendersNavigatesAndRecoversInJcef() = runGraphScenario(false) + + @Test + fun graphExplainsUnavailableJcef() = runGraphScenario(true) + + private fun runGraphScenario(disableJcef: Boolean) { + val version = System.getProperty("mcpp.clion.ideVersion") ?: error("ideVersion is required") + val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() + .resolve(if (disableJcef) "graphs-no-jcef" else "graphs") + Files.createDirectories(output) + val observations = mutableListOf() + fun record(name: String) { + observations += name + Files.writeString(output.resolve("checks.json"), "{\"passed\":[" + observations.joinToString(",") { "\"$it\"" } + "]}\n") + } + val fixture = Files.createTempDirectory(output, "graph-project-") + Files.createDirectories(fixture.resolve("src")) + val manifest = "[package]\nname = \"clion-graph-control\"\nversion = \"0.1.0\"\n" + Files.writeString(fixture.resolve("mcpp.toml"), manifest) + Files.writeString(fixture.resolve("src/core.cppm"), "export module graphfixture.core;\nexport int answer() { return 42; }\n") + Files.writeString(fixture.resolve("src/main.cpp"), "import graphfixture.core;\nint main() { return answer() == 42 ? 0 : 1; }\n") + val localHome = System.getProperty("mcpp.clion.ideHome")?.let(Path::of) + var ide = IdeInfo(productCode = "CL", platformPrefix = "CLion", executableFileName = "clion", fullName = "CLion", version = version, buildType = "release") + if (localHome != null) { + ide = ide.copy(getInstaller = { + object : IdeInstaller { + override suspend fun install(ideInfo: IdeInfo) = + Files.readString(localHome.resolve("build.txt")).trim().removePrefix("CL-") to + di.direct.instance().installIDE(localHome, "clion") + } + }) + } + val context = Starter.newContext("mcpp-graphs-$version-${System.currentTimeMillis()}", TestCase(ide, LocalProjectInfo(fixture))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.mcpplsZip") ?: error("mcpplsZip is required"))) + PluginConfigurator(context).installPluginFromPath(Path.of(System.getProperty("mcpp.clion.pluginZip") ?: error("pluginZip is required"))) + context.applyVMOptionsPatch { + withXmx(2048) + addSystemProperty("idea.trust.all.projects", true) + addSystemProperty("ide.show.tips.on.startup.default.value", false) + // Keep the large generated VFS cache with this isolated test's output. + addSystemProperty("idea.system.path", output.resolve("ide-system").toString()) + if (disableJcef) addSystemProperty("ide.browser.jcef.enabled", false) + } + context.runIdeWithDriver().useDriverAndCloseIde { + waitForProjectOpen() + withContext { + assertEquals(version, utility().getInstance().getFullVersion()) + System.getProperty("mcpp.clion.backendPath")?.let { backend -> + // Optional local backend-only control. Canonical CI leaves + // this unset and exercises the packaged universal backend. + val json = "\"" + backend.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" + withContext(OnDispatcher.EDT) { + service().set("mcpp.ide.backendPath", utility().parseString(json)) + } + record("configured-backend-control") + } + val startup = service(singleProject()).start() + val startupDeadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + while (!startup.isDone() && System.nanoTime() < startupDeadline) Thread.sleep(100) + assertTrue(startup.isDone()) + assertEquals(2, startup.get().get("protocol").getAsInt()) + record("protocol2-startup") + if (disableJcef) { + invokeAction("mcpp.project.showModuleDependencyGraph") + val explanation = ui.x("//div[contains(@visible_text,'This IDE runs without JCEF') or contains(@text,'This IDE runs without JCEF')]") + val explained = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (!explanation.present() && System.nanoTime() < explained) Thread.sleep(100) + assertTrue(explanation.present(), "the graph explains why drawing is unavailable") + record("no-jcef-explanation-visible") + } else { + val runs = utility().getInstance(singleProject()) + val before = runs.getAllDescriptors().map { it.getExecutionId() }.toSet() + invokeAction("mcpp.build") + val deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(180) + var finished: RunDescriptorRemote? = null + while (System.nanoTime() < deadline) { + finished = runs.getAllDescriptors().firstOrNull { + it.getDisplayName().startsWith("mcpp build ") && it.getExecutionId() !in before && + it.getProcessHandler()?.isProcessTerminated() == true + } + if (finished != null) break + Thread.sleep(100) + } + assertNotNull(finished, "the graph fixture builds through the actual IDE action") + assertEquals(0, finished!!.getProcessHandler()!!.getExitCode()) + record("module-project-built") + val window = utility().getInstance(singleProject()).getToolWindow("mcpp")!! + fun graph(kind: String): GraphCefRemote { + invokeAction(if (kind == "modules") "mcpp.project.showModuleDependencyGraph" else "mcpp.project.showDependencyGraph") + val expected = if (kind == "modules") "Module Graph" else "Dependency Graph" + val ready = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (!window.isVisible() && System.nanoTime() < ready) Thread.sleep(100) + assertTrue(window.isVisible(), "the graph action opens the actual tool window") + fun selectedTitle() = withContext(OnDispatcher.EDT) { window.getContentManager().getSelectedContent()?.getDisplayName() } + while (selectedTitle() != expected && System.nanoTime() < ready) Thread.sleep(100) + assertEquals(expected, selectedTitle()) + val panel = withContext(OnDispatcher.EDT) { window.getContentManager().getSelectedContent()!!.getComponent() } + val browser = withContext(OnDispatcher.EDT) { + cast(panel, GraphComponentRemote::class).getClientProperty("JBCefBrowser.instance") + } + assertNotNull(browser, "the actual graph tab owns a JCEF browser") + while (!browser!!.isCefBrowserCreated() && System.nanoTime() < ready) Thread.sleep(100) + assertTrue(browser!!.isCefBrowserCreated()) + return browser.getCefBrowser() + } + fun evaluate(browser: GraphCefRemote, expression: String): Boolean { + val escaped = expression.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + val future = browser.getDevToolsClient().executeDevToolsMethod("Runtime.evaluate", "{\"expression\":\"$escaped\",\"returnByValue\":true}") + val ready = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (!future.isDone() && System.nanoTime() < ready) Thread.sleep(50) + assertTrue(future.isDone(), "the actual JCEF document answers evaluation") + val answer = future.get() + assertFalse(answer.contains("\"exceptionDetails\""), "the graph evaluation has no JavaScript exception") + return Regex("\\\"value\\\"\\s*:\\s*true").containsMatchIn(answer) + } + fun awaitGraph(browser: GraphCefRemote, expression: String, label: String) { + val ready = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + var passed = false + while (System.nanoTime() < ready) { + if (browser.hasDocument() && evaluate(browser, expression)) { passed = true; break } + Thread.sleep(100) + } + assertTrue(passed, label) + record(label) + } + val packages = graph("packages") + awaitGraph(packages, "Boolean(document.querySelector('#graph svg') && document.body.textContent.includes('clion-graph-control'))", "package-svg-visible") + val modules = graph("modules") + awaitGraph(modules, "Boolean(document.querySelector('#graph svg') && [...document.querySelectorAll('g.node')].some(n => n.textContent.includes('graphfixture.core')))", "module-svg-visible") + val editors = utility().getInstance(singleProject()) + assertFalse(editors.getSelectedTextEditor()?.getDocument()?.getText()?.contains("export module graphfixture.core;") == true, + "the module source is not already selected before graph navigation") + assertTrue(evaluate(modules, "(() => { const n = [...document.querySelectorAll('g.node')].find(n => n.textContent.includes('graphfixture.core')); if (!n) return false; n.dispatchEvent(new MouseEvent('click', {bubbles: true})); return true; })()")) + val opened = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(10) + while (editors.getSelectedTextEditor()?.getDocument()?.getText()?.contains("export module graphfixture.core;") != true && System.nanoTime() < opened) Thread.sleep(100) + assertTrue(editors.getSelectedTextEditor()?.getDocument()?.getText()?.contains("export module graphfixture.core;") == true, + "clicking the rendered module opens its real source in the editor") + record("module-source-navigation") + try { + Files.writeString(fixture.resolve("mcpp.toml"), "[package\n") + val broken = graph("modules") + awaitGraph(broken, "Boolean(document.getElementById('note')?.textContent.includes('mcpp:') && !document.querySelector('#graph svg'))", "graph-error-visible") + } finally { + Files.writeString(fixture.resolve("mcpp.toml"), manifest) + } + val recovered = graph("modules") + awaitGraph(recovered, "Boolean(document.querySelector('#graph svg') && document.body.textContent.includes('graphfixture.core') && !document.getElementById('note')?.textContent.includes('mcpp:'))", "graph-recovery-visible") + } + } + } + val plugin = Path.of(System.getProperty("mcpp.clion.pluginZip")) + val sha = java.security.MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(plugin)).joinToString("") { "%02x".format(it) } + val backendControl = System.getProperty("mcpp.clion.backendPath") != null + Files.writeString(output.resolve("result.json"), "{\"ideVersion\":\"$version\",\"pluginSha256\":\"$sha\",\"jcefDisabled\":$disableJcef,\"backendControl\":$backendControl,\"passed\":true}\n") + } +} diff --git a/src/server.cppm b/src/server.cppm index 850ce7e..890fbfb 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -492,6 +492,14 @@ std::expected Session::build_database(const std::filesystem::p } auto r = query(root, {"emit", "build-database", "--format", "json"}); if (!r) return std::unexpected(r.error()); + if (r->exitCode != 0) { + const auto diagnostics = mcpp::diagnostics_of(r->envelope); + auto message = t("mcpp {0} failed with exit code {1}", {"emit build-database", std::to_string(r->exitCode)}); + if (!r->narration.empty()) message += "\n" + r->narration; + else if (!diagnostics.empty()) message += "\n" + diagnostics.front().value("message", ""); + return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, std::move(message), + Json{{"exitCode", r->exitCode}, {"diagnostics", diagnostics}}}); + } if (r->envelope.is_null()) return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, t("mcpp emit build-database printed no document")}); std::lock_guard lock(mutex_); diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index d62c4c2..9096473 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -186,6 +186,25 @@ int main() { CHECK(pack["linkages"].empty() != helpNamesLinkage); result_of("mcpp-ide/graph/packages", rooted); result_of("mcpp-ide/graph/modules", rooted); + // A failed database is an error, not an empty graph or a cached answer. + // The mock flag changes without a manifest edit, so immediate recovery + // proves the failed response itself was never cached. + const auto badDatabase = root / "database-error"; + std::filesystem::create_directories(badDatabase); + std::ofstream(badDatabase / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + std::ofstream(badDatabase / ".mock-mcpp-fail") << "manifest\n"; + const Json badRoot{{"root", badDatabase.generic_string()}}; + for (const auto* method : {"mcpp-ide/graph/packages", "mcpp-ide/graph/modules"}) { + const auto failed = wire.call(method, badRoot); + CHECK(failed.contains("error")); + CHECK(failed["error"]["code"] == rpc::REQUEST_FAILED); + CHECK(failed["error"]["data"]["exitCode"] == 1); + CHECK(!failed["error"]["data"]["diagnostics"].empty()); + CHECK(failed["error"]["message"].get().find("emit build-database") != std::string::npos); + } + std::filesystem::remove(badDatabase / ".mock-mcpp-fail"); + CHECK(!result_of("mcpp-ide/graph/packages", badRoot)["nodes"].empty()); + CHECK(!result_of("mcpp-ide/graph/modules", badRoot)["nodes"].empty()); result_of("mcpp-ide/manifest/check", rooted); result_of("mcpp-ide/settings/registry", Json::object()); result_of("mcpp-ide/selfCheck", Json::object()); From c160dc93cb9509ae90b4dc7f1035cc340be7b29a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 12:58:29 +0900 Subject: [PATCH 09/18] fix: preserve the selected configuration in debugger launches --- ...026-11-06-debuggee-launch-configuration.md | 71 ++++++++++++++++ .github/workflows/clion-stable.yml | 30 +++++++ CHANGELOG.md | 19 +++++ docs/architecture.md | 8 ++ mcpp.toml | 2 +- mide-plugins/clion/README.md | 25 +++++- mide-plugins/clion/gradle.properties | 2 +- .../mcppclion/run/McppDebugRunner.kt | 21 +++-- .../mcppclion/run/McppRunConfiguration.kt | 4 +- .../mcppcommunity/mcppclion/run/McppStates.kt | 7 +- mide-plugins/nvim/lua/mcpp/session.lua | 2 +- mide-plugins/vscode/CHANGELOG.md | 7 ++ mide-plugins/vscode/package-lock.json | 4 +- mide-plugins/vscode/package.json | 2 +- protocol/mcpp-contract.schema.json | 10 ++- protocol/protocol.schema.json | 4 +- spec/i18n/runtime.zh-cn.json | 4 +- src/bin/mockmcpp.cpp | 13 ++- src/server.cppm | 44 ++++++++-- tests/test_protocol.cpp | 81 +++++++++++++++++++ 20 files changed, 327 insertions(+), 33 deletions(-) create mode 100644 .agents/docs/2026-11-06-debuggee-launch-configuration.md diff --git a/.agents/docs/2026-11-06-debuggee-launch-configuration.md b/.agents/docs/2026-11-06-debuggee-launch-configuration.md new file mode 100644 index 0000000..bf804eb --- /dev/null +++ b/.agents/docs/2026-11-06-debuggee-launch-configuration.md @@ -0,0 +1,71 @@ +# Debug launches from the selected build configuration + +Status, 2026-11-06: implemented in the current core and IDE worktrees. Local +core/backend controls and compilation against both stable CLion SDKs pass. +Final public-group CI, packaged IDE runtime and release qualification remain +separate requirements. + +## Responsibility and selection + +The core describes the program that `run` or `test --print-launch` actually +selected. The additive `configuration` object contains its triple, profile, +resolved platform and available named runners. The runner names remain +available when a project uses no active platform. This avoids querying a +default configuration separately or taking the first leg of a target-set +environment report, neither of which identifies the program selected to run. + +The IDE first asks for that ordinary launch. If it declares `debuggee` and the +request did not explicitly choose a runner, the IDE asks for that named runner +with the selected triple and profile. A different triple, profile or platform +in the second answer is refused. An explicit runner, including an explicit +empty/default runner, takes precedence. A device action named `debug` is not +interpreted as a host-debugger launch. Older cores that omit the additive +configuration retain their ordinary behavior. + +The core assigns no OpenKal meaning to `debuggee`. Platform 0.24 declares +`okrun --debugger` through the existing named-runner mechanism. `mcpp test` +accepts `--runner` through the same resolver as `run`, including launch +descriptions and archive execution. A missing name or a simultaneous +`--no-runner` is refused. The core feature version is 2026.11.6.1. + +## Editor behavior + +Custom profile names are passed with `--profile`; the CLion run-configuration +profile selector accepts typed values. CLion obtains the backend's debug +configuration for its selected native debugger. The adapter enables LLDB's +GDB JIT reader in that session before loading the target. It does not change +a user's global debugger initialization file. Other runners keep their own +semantics and do not implicitly promise source symbols. + +The backend describes both program and individual-test debug launches. The +existing CLion debug executor remains limited to program run configurations; +no CLion test-debug UI is claimed by the backend protocol tests. + +## Validation and current boundaries + +The actual protocol test drives the mock executable through the backend's +wire. It covers older-core fallback, ordinary board actions, target-set +selection, a custom profile, program and test paths, explicit runner +precedence, missing configuration and changed target/profile/platform refusal. +It also checks space-containing and empty arguments. + +A separate native integration starts the actual backend with the newly built +core and a real project. Eleven checks cover ordinary and debug launches for +both a program and a test: actual profile and runner declarations, unchanged +configuration, no execution while describing a launch, execution of the +returned argv, argument preservation and explicit default-runner precedence. +It uses no mock and does not stand in for CLion GUI or JIT qualification. + +The fresh core passes E2E 01, 333, 993 and 936. E2E 993 includes the actual +named runner, archive execution, missing-name and contradictory-option +controls. E2E 936 initially inherited `NO_COLOR=1`; removing that external +setting passes the unchanged color-precedence checks. The core build uses +the OS 0.3 candidate source path and is not final public dependency evidence. + +The changed Kotlin adapters compile against CLion 2026.1.5.2 and 2026.2.4.1. +Compilation does not establish packaged runtime behavior. Image source +debugging still requires retained debug relocations and discoverable JIT +registration symbols in the packaged runner. The source-runner GDB/LLDB +controls and the precompiled-runner packaging qualification are separate. +The independently reproduced CLion 2026.2 native frame-variable cancellation +error remains separate from these changes and is not suppressed. diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index 0064b49..66347e2 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -80,12 +80,42 @@ jobs: plugin="$(find stable-plugin -maxdepth 1 -name 'mcpp-clion-*.zip' -print)" language="$(find stable-plugin -maxdepth 1 -name 'mcppls-clion-*.zip' -print)" test -n "$plugin" && test -n "$language" + # Ubuntu's userns AppArmor restriction prevents JCEF's sandbox from + # creating its browser. Permit userns only for this hosted UI check; + # Chromium/JCEF sandbox flags and the tests remain unchanged. + original=$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns) + [[ "$original" =~ ^[01]$ ]] + printf '%s\n' "$original" > "$RUNNER_TEMP/clion-userns-policy" + restore_userns_policy() { + local status=$? + trap - EXIT + if ! sudo sysctl -w "kernel.apparmor_restrict_unprivileged_userns=$original" >/dev/null || + [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" != "$original" ]; then + echo '::error::Could not restore the original user namespace policy' + exit 1 + fi + exit "$status" + } + trap restore_userns_policy EXIT + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null + unshare -Ur true xvfb-run -a gradle --no-daemon --max-workers=4 \ -p mide-plugins/clion/integration-tests test \ -PpluginZip="$GITHUB_WORKSPACE/$plugin" \ -PmcpplsZip="$GITHUB_WORKSPACE/$language" \ -PideVersion="$IDE_VERSION" \ -Poutput="$RUNNER_TEMP/clion-stable-result" + - name: Restore the hosted user namespace policy + if: always() + run: | + set -euo pipefail + state="$RUNNER_TEMP/clion-userns-policy" + if [ -f "$state" ]; then + original=$(cat "$state") + [[ "$original" =~ ^[01]$ ]] + sudo sysctl -w "kernel.apparmor_restrict_unprivileged_userns=$original" >/dev/null + test "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "$original" + fi # The report contains public versions and assertions only. Private user # logs and arbitrary IDE process output are not uploaded as artifacts. - uses: actions/upload-artifact@v5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b464a..4fde285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.2.1 + +mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. + +- CLion locates the bundled backend from the plugin descriptor and checks + protocol compatibility before sending namespaced requests. Backend selection + and startup failures are recorded in `idea.log`. +- Native settings reject non-finite and out-of-range values without partially + saving the form. Failed graph queries remain errors and can be retried. +- Debug launches use the actual selected target and custom profile. With mcpp + 2026.11.6.1 or later, a declared `debuggee` runner is selected without guessing + from device actions; explicit runner choices take precedence. A changed + target, profile or platform between launch descriptions is refused. +- CLion requests the backend's debug launch and configures LLDB's JIT reader + for that session. The run-configuration profile field accepts custom names. +- CI covers stable CLion 2026.1.5.2 and 2026.2.4.1 with the same packaged ZIP, + static compatibility checks and actual editor interactions. The hosted JCEF + check restores its temporary user-namespace policy after the test. + ## 0.2.0 mcpp-vscode 1.1.0, mcpp-clion 0.2.0 and mcpp-nvim 0.2.0 (mcpp design 2026-10-29 §10). diff --git a/docs/architecture.md b/docs/architecture.md index 0f48cbb..ecbc3f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,14 @@ the complete startup chain in each IDE process. Functional checks cover build, r native debug launch, error recovery, manifest completion and view selection. The debugger adapter starts the native Cidr process from the session starter before returning it. +The debug request first obtains the program's actual launch. With mcpp +2026.11.6.1 or later, an available `debuggee` named runner is selected on that +launch's target and profile; an explicit runner parameter takes precedence. +The selected launch is checked again for the same target, profile and platform. +CLion requests this backend debug configuration and enables LLDB's JIT reader +within the session when starting a runner. Custom run-configuration profiles +are forwarded as `--profile`. + ## Generated declarations `spec/` is the source of everything the editors declare statically. `mcpp-ide-dev gen vscode` writes diff --git a/mcpp.toml b/mcpp.toml index 98ad6f4..159b7fe 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -9,7 +9,7 @@ # M2/M3), where the system it runs upon is answered when the image is loaded. [package] name = "mcpp-ide" -version = "0.2.0" +version = "0.2.1" description = "The IDE backend for mcpp: sessions, jobs, records and view models over mcpp's control contract" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index cb9a4c4..56f3738 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -5,8 +5,8 @@ mcpp's project features in CLion 2026.1 and later, over the mcpp-ide backend: - the **mcpp** run configuration type: run (the program in a PTY console), test (the IDE's test tree) or build, for a target and a profile; a ▶ beside `main` runs the project, or the test whose `main` it is; -- **debugging** under CLion's own GDB or LLDB (the default toolchain's); an ok image runs under - `okrun`, whose images reach GDB through the JIT interface; +- **debugging** under CLion's own GDB or LLDB (the default toolchain's), using the + program and arguments returned by the backend's debug launch; - **console links** to `file:line:column`; - the **mcpp tool window** in CLion's own components: the Project tab (each project's mcpp, targets, tests, libraries, last build, artifacts, platform, and what C++ Modules says), the @@ -28,6 +28,27 @@ settings unchanged. Entering a setting's default removes its stored override. The graph view reports a failed build-database query as an error. Correct the reported problem and reopen the graph to retry. An unavailable JCEF browser displays an explanation in the tool window. +## Debug launches + +The run configuration's Profile field accepts `dev`, `release` and custom +profile names. An empty field uses the project setting. + +With mcpp 2026.11.6.1 or later, the backend uses the configuration returned by +the actual program launch. If that configuration declares a named `debuggee` +runner, the backend selects it with the same target and profile. Other runner +names, including device actions named `debug`, are not selected automatically. +Older mcpp versions retain their ordinary launch behavior. + +OpenKal platform 0.24.0 declares `debuggee` as `okrun --debugger`. Image source +debugging requires retained debug relocation information and the runner's JIT +registration symbols. The adapter enables LLDB's GDB JIT reader for the debug +session; no change to a global LLDB initialization file is needed. External +debug sidecars are not supported by this path. + +The backend protocol also describes individual test debug launches through +`mcpp test --runner`. The CLion adapter currently offers native debugging for +program run configurations; this does not claim a CLion test-debug UI. + ## Build ```bash diff --git a/mide-plugins/clion/gradle.properties b/mide-plugins/clion/gradle.properties index ce4fe38..1255026 100644 --- a/mide-plugins/clion/gradle.properties +++ b/mide-plugins/clion/gradle.properties @@ -2,7 +2,7 @@ # where it installs (2026.1 and later); the verifier checks both ends. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.2.0 +pluginVersion = 0.2.1 # The mcppls CLion plugin this one depends on (io.github.sunrisepeak.mcppls): # this machine's package of the release, fetched for the sandbox, the tests and the verifier. mcpplsPluginVersion = 0.0.19 diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt index 890213b..5f9b736 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt @@ -13,7 +13,6 @@ import com.intellij.execution.filters.TextConsoleBuilderFactory import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.GenericProgramRunner import com.intellij.execution.ui.RunContentDescriptor -import com.intellij.notification.NotificationType import com.intellij.openapi.progress.ProgressManager import com.intellij.xdebugger.XDebugProcess import com.intellij.xdebugger.XDebugProcessStarter @@ -24,6 +23,9 @@ import com.jetbrains.cidr.cpp.toolchains.CPPToolchains import com.jetbrains.cidr.cpp.toolchains.createDriverConfiguration import com.jetbrains.cidr.execution.TrivialRunParameters import com.jetbrains.cidr.execution.debugger.CidrLocalDebugProcess +import com.jetbrains.cidr.execution.debugger.backend.DebuggerDriver +import com.jetbrains.cidr.execution.debugger.backend.lldb.LLDBDriver +import com.jetbrains.cidr.execution.debugger.backend.lldb.LLDBDriverConfiguration import io.github.mcppcommunity.mcppclion.McppSession class McppDebugRunner : GenericProgramRunner() { @@ -38,18 +40,23 @@ class McppDebugRunner : GenericProgramRunner() { val session = McppSession.get(project) val toolchain = CPPToolchains.getInstance().defaultToolchain ?: throw ExecutionException(session.t("No toolchain is set up in Settings | Build, Execution, Deployment | Toolchains; CLion's debugger comes from one.")) - val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously( - { launch(c).get() }, session.t("mcpp: building for the debugger…"), true, project) // The debugger the toolchain names (GDB, LLDB, or a debug adapter configured in CLion), as CLion // itself creates it. val driver = createDriverConfiguration(project, CPPEnvironment(toolchain)) - // Said for either debugger: the sentence names what each one needs. - if (!launch["via"].isJsonNull) - session.notify(session.t("This program runs through {0}. LLDB reads its symbols once `settings set plugin.jit-loader.gdb.enable on` is in ~/.lldbinit; GDB reads them as it is.", launch["via"].asString), NotificationType.INFORMATION) + val engine = if (driver is LLDBDriverConfiguration) "lldb" else "gdb" + val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously( + { launch(c, engine).get() }, session.t("mcpp: building for the debugger…"), true, project) + val throughRunner = launch["via"]?.isJsonNull == false val parameters = TrivialRunParameters(driver, commandLine(launch, pty = false)) return XDebuggerManager.getInstance(project).newSessionBuilder(object : XDebugProcessStarter() { override fun start(xs: XDebugSession): XDebugProcess { - val process = CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) + val process = object : CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) { + override fun doLoadTarget(driver: DebuggerDriver): DebuggerDriver.Inferior { + if (throughRunner && driver is LLDBDriver) + driver.executeInterpreterCommand("settings set plugin.jit-loader.gdb.enable on") + return super.doLoadTarget(driver) + } + } process.start() return process } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt index cbbd516..f40b647 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt @@ -83,7 +83,7 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name class Editor : SettingsEditor() { private val command = ComboBox(arrayOf("run", "test", "build")) private val target = JBTextField() - private val profile = ComboBox(arrayOf("", "dev", "release")) + private val profile = ComboBox(arrayOf("", "dev", "release")).apply { isEditable = true } private val args = JBTextField() private val filter = JBTextField() private val bin = JBTextField() @@ -104,7 +104,7 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name override fun applyEditorTo(c: McppRunConfiguration) { c.options.command = command.selectedItem as String c.options.target = target.text.trim() - c.options.profile = profile.selectedItem as String + c.options.profile = profile.editor.item.toString().trim() c.options.args = args.text c.options.filter = filter.text.trim() c.options.bin = bin.text.trim() diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt index 68c8f07..7990016 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt @@ -201,7 +201,7 @@ class McppTestState(private val c: McppRunConfiguration, env: ExecutionEnvironme } /** The backend's answer to "how does the program start" for a configuration (mcpp run --print-launch). */ -internal fun launch(c: McppRunConfiguration): CompletableFuture { +internal fun launch(c: McppRunConfiguration, debugEngine: String? = null): CompletableFuture { val session = McppSession.get(c.project) val params = JsonObject().apply { addProperty("root", c.root) @@ -209,8 +209,11 @@ internal fun launch(c: McppRunConfiguration): CompletableFuture { if (c.options.profile.isNotBlank()) addProperty("profile", c.options.profile) if (c.options.bin.isNotBlank()) addProperty("bin", c.options.bin) add("args", JsonArray().apply { c.programArgs.forEach { add(it) } }) + if (debugEngine != null) addProperty("engine", debugEngine) + } + return session.request(if (debugEngine == null) "run/launch" else "debug/configuration", params).thenApply { + if (debugEngine == null) it.asJsonObject else it.asJsonObject.getAsJsonObject("launch") } - return session.request("run/launch", params).thenApply { it.asJsonObject } } internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index 01dba6b..0212255 100644 --- a/mide-plugins/nvim/lua/mcpp/session.lua +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -13,7 +13,7 @@ local uv = vim.uv or vim.loop local is_windows = vim.fn.has('win32') == 1 -- mcpp-nvim's version: mcpp-ide's, whose release carries it. -M.version = '0.2.0' +M.version = '0.2.1' -- Neovim's version, as its release names it. function M.nvim_version() diff --git a/mide-plugins/vscode/CHANGELOG.md b/mide-plugins/vscode/CHANGELOG.md index 763df47..69cccfc 100644 --- a/mide-plugins/vscode/CHANGELOG.md +++ b/mide-plugins/vscode/CHANGELOG.md @@ -1,5 +1,12 @@ ## Unreleased +## 1.1.1 + +- The bundled backend preserves the target and custom profile of a debug + launch. With mcpp 2026.11.6.1 or later, a declared `debuggee` runner is selected + automatically unless the request explicitly chooses a runner. +- Failed build-database queries report graph errors and allow a retry. + ## 1.1.0 - The extension is mcpp-vscode 0.9.0 in everything a user sees and does: the same views (Project, diff --git a/mide-plugins/vscode/package-lock.json b/mide-plugins/vscode/package-lock.json index 8209f00..24a086d 100644 --- a/mide-plugins/vscode/package-lock.json +++ b/mide-plugins/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "1.1.0", + "version": "1.1.1", "license": "Apache-2.0", "devDependencies": { "@mermaid-js/tiny": "12.2.0", diff --git a/mide-plugins/vscode/package.json b/mide-plugins/vscode/package.json index 34fd4d0..ebeb948 100644 --- a/mide-plugins/vscode/package.json +++ b/mide-plugins/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "%displayName%", "description": "%description%", - "version": "1.1.0", + "version": "1.1.1", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", diff --git a/protocol/mcpp-contract.schema.json b/protocol/mcpp-contract.schema.json index 093e221..c327e47 100644 --- a/protocol/mcpp-contract.schema.json +++ b/protocol/mcpp-contract.schema.json @@ -82,7 +82,15 @@ "cwd": { "type": "string" }, "env": { "type": "object", "additionalProperties": { "type": "string" } }, "program": { "type": "string" }, - "via": { "type": ["string", "null"] } + "via": { "type": ["string", "null"] }, + "configuration": { + "type": "object", "required": ["triple", "profile", "platform", "runners"], + "properties": { + "triple": { "type": "string" }, "profile": { "type": "string" }, + "platform": { "type": ["object", "null"] }, + "runners": { "type": "array", "items": { "type": "string" } } + } + } } }, "artifact": { diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index c6581a1..1e7b5fe 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -203,7 +203,7 @@ "result": { "type": "object", "required": ["argv", "cwd", "env"], "properties": { "argv": { "type": "array", "items": { "type": "string" } }, "cwd": { "type": "string" }, "env": { "type": "object" }, "program": { "type": "string" }, "via": { "type": ["string", "null"] } } } }, "mcpp-ide/debug/configuration": { - "description": "Builds and returns a debug configuration for the adapter's debugger. A program that runs through another (an ok image under okrun) is debugged as that runner, its symbols read through the GDB JIT interface.", + "description": "Builds and returns a debug configuration for the adapter's debugger. Selects a declared debuggee runner from the actual launch configuration, preserving its target and profile; an explicit runner choice takes precedence. Source symbols depend on the runner's debugger support.", "params": { "allOf": [ { "$ref": "#/$defs/launchParams" }, @@ -512,7 +512,7 @@ "action": { "type": "object", "required": ["kind"], "properties": { "kind": { "enum": ["job", "run", "debug", "cache", "panel", "cancel", "selectTarget", "installToolchain", "selectDefaultToolchain", "newProject", "quickMenu", "selfCheck", "rpc", "languageService", "forward", "editor", "problems", "report"] } } } } }, - "launchParams": { "type": "object", "required": ["root"], "properties": { "root": { "type": "string" }, "target": { "type": "string" }, "profile": { "type": "string" }, "bin": { "type": "string" }, "args": { "type": "array", "items": { "type": "string" } } } }, + "launchParams": { "type": "object", "required": ["root"], "properties": { "root": { "type": "string" }, "target": { "type": "string" }, "profile": { "type": "string" }, "runner": { "type": "string", "description": "Explicit named runner; empty selects the default and disables automatic debuggee selection." }, "bin": { "type": "string" }, "test": { "type": "string" }, "framework": { "type": "string" }, "cases": { "type": "array", "items": { "type": "string" } }, "args": { "type": "array", "items": { "type": "string" } } } }, "graph": { "type": "object", "required": ["nodes", "edges"], "properties": { "nodes": { "type": "array" }, "edges": { "type": "array" } } }, "diagnostic": { "type": "object", diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index 92f2995..8ae491f 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -716,7 +716,9 @@ "this action was not confirmed": "此操作未经确认", "this machine": "本机", "this platform": "当前平台", - "this program runs through {0}; the debugger starts {0} and reads the program's symbols when it is loaded": "此程序经由 {0} 运行;调试器启动 {0},并在程序加载时读取其符号", + "mcpp's launch configuration omits its target or profile": "mcpp 的启动配置缺少目标或构建配置", + "mcpp's debug launch changed the selected target, profile or platform": "mcpp 的调试启动改变了已选择的目标、构建配置或平台", + "the debugger starts {0}; source symbols depend on the runner's debugger support": "调试器启动 {0};源码符号取决于运行器对调试器的支持", "under {0} day(s)": "少于 {0} 天", "unknown": "未知", "unknown error": "未知错误", diff --git a/src/bin/mockmcpp.cpp b/src/bin/mockmcpp.cpp index b4fd84f..c88e113 100644 --- a/src/bin/mockmcpp.cpp +++ b/src/bin/mockmcpp.cpp @@ -151,7 +151,9 @@ int build(bool message) { int main(int argc, char** argv) { std::vector args(argv + 1, argv + argc); - if (const auto log = env("MOCK_MCPP_LOG"); !log.empty()) { + auto log = env("MOCK_MCPP_LOG"); + if (log.empty() && std::filesystem::exists(".mock-mcpp-log")) log = ".mock-mcpp-log"; + if (!log.empty()) { std::ofstream out(log, std::ios::app); for (const auto& a : args) out << a << ' '; const char* pinned = std::getenv("XLINGS_PROJECT_DIR"); @@ -169,8 +171,15 @@ int main(int argc, char** argv) { if (first(0) == "--version") { out(fixture("version.txt")); return 0; } if (first(0) == "--protocol-version") { out(fixture("protocol-version.json")); return 0; } if (first(0) == "build") return build(has("--message-format")); + if ((first(0) == "run" || first(0) == "test") && has("--print-launch")) { + auto text = here_file(".mock-mcpp-launch"); + // A second launch can deliberately return a different configuration. + if (has("--runner")) + if (auto selected = here_file(".mock-mcpp-runner-launch"); !selected.empty()) text = selected; + out(text.empty() ? fixture("launch.json") : text); + return 0; + } if (first(0) == "test") { out(fixture(has("--list") ? "test-list.ndjson" : "test.ndjson")); return 0; } - if (first(0) == "run" && has("--print-launch")) { out(fixture("launch.json")); return 0; } if (first(0) == "search") { out(fixture("search.json")); return 0; } if (first(0) == "index" && first(1) == "list") { out(fixture("index-list.json")); return 0; } if (first(0) == "pack" && has("--help")) { out(fixture("pack-help.txt")); return 0; } diff --git a/src/server.cppm b/src/server.cppm index 890fbfb..4ca2276 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -33,7 +33,7 @@ export namespace mcppide::server { using Json = nlohmann::json; -inline constexpr std::string_view kVersion = "0.2.0"; +inline constexpr std::string_view kVersion = "0.2.1"; inline constexpr int kProtocol = 2; // The methods `install` registers, for the protocol schema's test: a method @@ -719,7 +719,15 @@ std::expected Session::run_launch(const Json& p, const rpc::Co std::vector args{test.empty() ? "run" : "test", "--print-launch"}; const auto profile = p.value("profile", setting("mcpp.build.profile").is_string() ? setting("mcpp.build.profile").get() : "dev"); if (profile == "release") args.push_back("--release"); + else if (!profile.empty() && profile != "dev") { + args.push_back("--profile"); + args.push_back(profile); + } for (const auto& a : target_args(p)) args.push_back(a); + if (auto runner = p.value("runner", ""); !runner.empty()) { + args.push_back("--runner"); + args.push_back(runner); + } if (!test.empty()) { args.push_back(test); if (const auto* f = testing::framework(p.value("framework", ""))) @@ -743,15 +751,35 @@ std::expected Session::run_launch(const Json& p, const rpc::Co return launch; } -// A debug session for what `run/launch` describes, in the adapter's debugger's own configuration. -// -// A PROGRAM THAT RUNS THROUGH ANOTHER (`via`: an ok image under `okrun`, a target's runner) is -// debugged as that runner, with the image's symbols arriving through the GDB JIT interface: okrun -// registers each image it loads (`__jit_debug_register_code`), which gdb reads by default and lldb -// reads once its JIT loader is on. Breakpoints in the image's sources then bind when it is loaded. +// A debug session starts the launch selected by mcpp. A named `debuggee` +// declares the host-debugger form; other runner names retain their own meaning. +// Configurations come from that actual build, including target-set selection +// and custom-profile build-program declarations. std::expected Session::debug_configuration(const Json& p, const rpc::Context& ctx) { auto launch = run_launch(p, ctx); if (!launch) return launch; + if (!p.contains("runner")) { + const auto configuration = launch->value("configuration", Json::object()); + const auto runners = configuration.is_object() + ? strings_of(configuration.value("runners", Json::array())) : std::vector{}; + if (std::ranges::find(runners, "debuggee") != runners.end()) { + const auto triple = configuration.value("triple", ""); + const auto profile = configuration.value("profile", ""); + if (triple.empty() || profile.empty()) + return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, t("mcpp's launch configuration omits its target or profile")}); + Json parameters = p; + parameters["target"] = triple; + parameters["profile"] = profile; + parameters["runner"] = "debuggee"; + launch = run_launch(parameters, ctx); + if (!launch) return launch; + const auto selected = launch->value("configuration", Json::object()); + if (!selected.is_object() || selected.value("triple", "") != triple + || selected.value("profile", "") != profile + || selected.value("platform", Json(nullptr)) != configuration.value("platform", Json(nullptr))) + return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, t("mcpp's debug launch changed the selected target, profile or platform")}); + } + } const auto& l = *launch; std::string engine = p.value("engine", setting("mcpp.debug.engine").is_string() ? setting("mcpp.debug.engine").get() : "auto"); const auto available = strings_of(p.value("available", Json::array())); @@ -795,7 +823,7 @@ std::expected Session::debug_configuration(const Json& p, cons return std::unexpected(invalid(std::format("unknown debug engine {}", engine))); } if (through) - cfg["mcppNote"] = t("this program runs through {0}; the debugger starts {0} and reads the program's symbols when it is loaded", + cfg["mcppNote"] = t("the debugger starts {0}; source symbols depend on the runner's debugger support", {l["via"].is_string() ? l["via"].get() : program}); return Json{{"engine", engine}, {"configuration", cfg}, {"launch", l}}; } diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 9096473..54c0abb 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -212,6 +212,87 @@ int main() { auto launch = result_of("mcpp-ide/run/launch", rooted); CHECK(!launch["argv"].empty()); result_of("mcpp-ide/debug/configuration", Json{{"root", root.generic_string()}, {"available", Json::array({"lldb"})}}); + // Debug selection follows the configuration of the actual launch, not a + // separately queried default profile or the first leg of a target set. + const auto launchFile = root / ".mock-mcpp-launch"; + const auto selectedFile = root / ".mock-mcpp-runner-launch"; + const auto commandLog = root / ".mock-mcpp-log"; + Json ordinary{{"kind", "mcpp.launch"}, {"data", { + {"program", "app.oke"}, {"argv", Json::array({"okrun", "app.oke"})}, + {"cwd", root.generic_string()}, {"env", Json::object()}, {"via", "runner"}}}}; + auto commands = [&] { + std::vector found; + std::istringstream input(testsupport::read(commandLog)); + for (std::string line; std::getline(input, line);) + if (line.starts_with("run --print-launch ") || line.starts_with("test --print-launch ")) found.push_back(line); + return found; + }; + auto reset_launch = [&](const Json& first, const Json& second) { + std::ofstream(launchFile) << first.dump(); + std::ofstream(selectedFile) << second.dump(); + std::ofstream(commandLog).close(); + }; + auto debugParams = rooted; + debugParams["engine"] = "lldb"; + debugParams["args"] = Json::array({"with spaces", ""}); + // Older cores omit configuration; board actions called `debug` are not + // host-debugger launch declarations and are never guessed as one. + for (bool withBoardRunner : {false, true}) { + auto first = ordinary; + if (withBoardRunner) first["data"]["configuration"] = { + {"triple", "x86_64-openkal"}, {"profile", "inspect"}, {"runners", Json::array({"debug"})}}; + reset_launch(first, first); + auto debug = result_of("mcpp-ide/debug/configuration", debugParams); + CHECK(commands().size() == 1); + CHECK(debug["launch"]["argv"] == Json::array({"okrun", "app.oke", "with spaces", ""})); + } + ordinary["data"]["configuration"] = {{"triple", "x86_64-openkal"}, {"profile", "inspect"}, + {"platform", nullptr}, {"runners", Json::array({"debug", "debuggee"})}}; + auto selected = ordinary; + selected["data"]["argv"] = Json::array({"okrun", "--debugger", "app.oke"}); + for (bool test : {false, true}) { + reset_launch(ordinary, selected); + auto params = debugParams; + params["target"] = "all"; + if (test) params["test"] = "unit/math"; + auto debug = result_of("mcpp-ide/debug/configuration", params); + auto calls = commands(); + CHECK(calls.size() == 2); + CHECK(calls[0].find("--target all ") != std::string::npos); + CHECK(calls[1].find("--target x86_64-openkal ") != std::string::npos); + CHECK(calls[1].find("--profile inspect ") != std::string::npos); + CHECK(calls[1].find("--runner debuggee ") != std::string::npos); + CHECK(calls[1].starts_with(test ? "test " : "run ")); + if (test) CHECK(calls[1].find("unit/math ") != std::string::npos); + CHECK(debug["launch"]["argv"] == Json::array({"okrun", "--debugger", "app.oke", "with spaces", ""})); + CHECK(debug["configuration"]["initCommands"] == Json::array({"settings set plugin.jit-loader.gdb.enable on"})); + } + // Explicit runner choices, including an explicit default, are preserved. + for (const auto* runner : {"", "monitor"}) { + reset_launch(ordinary, selected); + auto params = debugParams; + params["runner"] = runner; + result_of("mcpp-ide/debug/configuration", params); + CHECK(commands().size() == 1); + } + auto missing = ordinary; + missing["data"]["configuration"].erase("profile"); + reset_launch(missing, selected); + CHECK(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); + CHECK(commands().size() == 1); + for (const auto* field : {"triple", "profile", "platform"}) { + auto changed = selected; + changed["data"]["configuration"][field] = "different"; + reset_launch(ordinary, changed); + CHECK(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); + CHECK(commands().size() == 2); + } + reset_launch(ordinary, selected); + auto custom = rooted; + custom["profile"] = "inspect"; + result_of("mcpp-ide/run/launch", custom); + CHECK(commands().size() == 1 && commands()[0].find("--profile inspect ") != std::string::npos); + for (const auto& path : {launchFile, selectedFile, commandLog}) std::filesystem::remove(path); // What mcpp-vscode's own parsers read: one command's output as it printed it, and a project's // files with its source count. auto exec = result_of("mcpp-ide/mcpp/exec", Json{{"args", Json::array({"--version"})}, {"cwd", root.generic_string()}}); From eb3d10c4b77df7298f5193f928acd3adafbcdce5 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 13:14:23 +0900 Subject: [PATCH 10/18] build: align IDE payloads with the public OpenKal 0.24 group --- .github/versions.env | 10 +++------ .github/workflows/ci.yml | 2 +- mcpp.lock | 48 ++++++++++++++++++++-------------------- mcpp.toml | 16 ++++++-------- 4 files changed, 35 insertions(+), 41 deletions(-) diff --git a/.github/versions.env b/.github/versions.env index 88b0b54..b2cf440 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,16 +1,12 @@ # The versions CI builds and tests with, in one file; .github/actions/setup-mcpp exports them. -# The released mcpp CI installs and uses: it states its features (`features`, the project's mcpp of -# part 2 §4.3), test-launch among them (2026.10.31.2), and builds the backend's ok images on -# openkal-platform 0.23.3, which it compiles the std module for with the platform's words -# (2026.11.3.1). The mock replays the control contract the fixtures were captured from -# (protocol/fixtures/version.txt). -MCPP_VERSION=2026.11.3.1 +# The published bootstrap; the project explicitly selects platform 0.24. +MCPP_VERSION=2026.11.5.1 # A ref of mcpp-community/mcpp CI builds mcpp from and uses instead, for a contract not yet released. # Empty: the release above is used. MCPP_SOURCE_REF= # The LLVM the backend builds with on macOS and Windows (the targets mcpp reaches there with it). LLVM_VERSION=23.1.3 -# The xlings mcpp 2026.11.3.1 bundles, which reads the current index. +# The xlings mcpp 2026.11.5.1 bundles, which reads the current index. XLINGS_VERSION=v2026.10.26.1 # The oldest VS Code the extension supports (engines.vscode), which its tests run in. VSCODE_VERSION=1.91.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6531600..6ae6392 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - { os: ubuntu-24.04-arm, platform: linux-arm64, llvm: 'false' } - { os: macos-15, platform: darwin-arm64, llvm: 'true' } - { os: windows-2022, platform: win32-x64, llvm: 'true' } - - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', host-platform: 'openkal-platform@0.23.3' } + - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', host-platform: 'openkal-platform@0.24.0' } defaults: run: shell: bash diff --git a/mcpp.lock b/mcpp.lock index d54cdf3..b2d2dce 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -23,62 +23,62 @@ targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal"] namespace = "mcpplibs" -version = "0.23.0" -source = "index+mcpplibs@0.23.0" -hash = "fnv1a:3503e00759ddec3e" +version = "0.24.0" +source = "index+mcpplibs@0.24.0" +hash = "fnv1a:7264ed077cd83703" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.4.3" -source = "index+mcpplibs@0.4.3" -hash = "fnv1a:dda722727a966a97" +version = "0.5.0" +source = "index+mcpplibs@0.5.0" +hash = "fnv1a:e71cc672802c1551" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-linux"] namespace = "mcpplibs" -version = "0.24.1" -source = "index+mcpplibs@0.24.1" -hash = "fnv1a:2cce8833161f7421" +version = "0.25.0" +source = "index+mcpplibs@0.25.0" +hash = "fnv1a:34912a331a4386f5" [package."openkal-llvm-runtime"] namespace = "mcpplibs" -version = "0.18.4" -source = "index+mcpplibs@0.18.4" -hash = "fnv1a:4768f5337e73a58b" +version = "0.19.0" +source = "index+mcpplibs@0.19.0" +hash = "fnv1a:3e60b633793b7850" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-musl"] namespace = "mcpplibs" -version = "0.27.3" -source = "index+mcpplibs@0.27.3" -hash = "fnv1a:b2d032df6c1252c3" +version = "0.28.0" +source = "index+mcpplibs@0.28.0" +hash = "fnv1a:a96808df668801b3" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.23.3" -source = "index+mcpplibs@0.23.3" -hash = "fnv1a:6a1b522a679a9229" +version = "0.24.0" +source = "index+mcpplibs@0.24.0" +hash = "fnv1a:8527602a77391921" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.4.3" -source = "index+mcpplibs@0.4.3" -hash = "fnv1a:212ba973d31508bc" +version = "0.5.0" +source = "index+mcpplibs@0.5.0" +hash = "fnv1a:17b68973cd803e4e" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."mcpplibs.os"] namespace = "mcpplibs" -version = "0.2.9" -source = "index+mcpplibs@0.2.9" -hash = "fnv1a:d0be8fa50032f9e6" +version = "0.3.0" +source = "index+mcpplibs@0.3.0" +hash = "fnv1a:c8fc75a4fc0fce2a" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."nlohmann.json"] diff --git a/mcpp.toml b/mcpp.toml index 159b7fe..a305707 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -33,13 +33,11 @@ toolchain = "llvm@23.1.3" [target.'cfg(windows)'] toolchain = "llvm@23.1.3" -# The platform of the ok images the editor packages carry: 0.23.3's version group is openkal 0.23's, -# which mcpplibs.os 0.2.9 is written against; its okrun places an image at a base of the boot, keeps -# its data as a prepared copy and can start it from a completed snapshot of eligible initialisers, spending by the -# program's resource preference (openkal-image 0.4.3); every unit is compiled with frame pointers and -# hardening, the std module included from mcpp 2026.11.3.1 (mcpp design 2026-11-03 part 4). +# The editor payloads use the coherent OpenKal 0.24 source group: OS 0.3, +# platform 0.24, musl 0.28, runtime 0.19 and image/tools 0.5. The platform +# supplies the image toolchain, hardening, seal and runner. [target.'cfg(os = "openkal")'] -platform = "openkal-platform@0.23.3" +platform = "openkal-platform@0.24.0" # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] @@ -76,9 +74,9 @@ kind = "bin" main = "src/bin/dev.cpp" [dependencies.mcpplibs] -# os 0.2.9: openkal 0.23 (openkal-linux 0.24.1, openkal-macos 0.21.3, openkal-windows 0.19.0). Since 0.2.1 an ok image's C library, C++ run-time and loader are its platform's, -# so the package declares no openkal-tools of its own beside the platform's. -os = "0.2.9" +# OS 0.3 names OpenKal 0.24 and its matching per-system backend versions. +# The image C library, C++ runtime and loader remain the platform's. +os = "0.3.0" [dependencies.nlohmann] json = "3.12.0" From 22ca0b27171eb9a1d87def9c48641f56d1f2947f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 13:18:19 +0900 Subject: [PATCH 11/18] ci: align the project shim with the published bootstrap --- .github/actions/setup-mcpp/action.yml | 2 ++ .github/scripts/check-bootstrap-pin.py | 11 +++++++++++ .xlings.json | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/check-bootstrap-pin.py diff --git a/.github/actions/setup-mcpp/action.yml b/.github/actions/setup-mcpp/action.yml index a1c6aeb..f421fa3 100644 --- a/.github/actions/setup-mcpp/action.yml +++ b/.github/actions/setup-mcpp/action.yml @@ -11,6 +11,8 @@ runs: shell: bash run: | set -euo pipefail + python=python3; [ "$RUNNER_OS" = Windows ] && python=python + "$python" .github/scripts/check-bootstrap-pin.py while IFS='=' read -r name value; do case "$name" in ''|\#*) continue ;; esac if [ -z "${!name:-}" ]; then echo "$name=$value" >> "$GITHUB_ENV"; fi diff --git a/.github/scripts/check-bootstrap-pin.py b/.github/scripts/check-bootstrap-pin.py new file mode 100644 index 0000000..c25d647 --- /dev/null +++ b/.github/scripts/check-bootstrap-pin.py @@ -0,0 +1,11 @@ +"""Check the project's shim pin against the declared CI bootstrap before installation.""" +import json +from pathlib import Path + +values = dict(line.split('=', 1) for line in Path('.github/versions.env').read_text().splitlines() + if line and not line.startswith('#')) +declared = values['MCPP_VERSION'] +project = json.loads(Path('.xlings.json').read_text())['workspace']['mcpp'] +if project != declared: + raise SystemExit(f'bootstrap mismatch: .xlings.json selects {project}, versions.env installs {declared}') +print(f'project shim and CI bootstrap both select mcpp {declared}') diff --git a/.xlings.json b/.xlings.json index 116255d..f3a65ae 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.11.3.1" + "mcpp": "2026.11.5.1" } } From c213907ac3a315d3966c823afd49e923c0c3db51 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 14:11:09 +0900 Subject: [PATCH 12/18] ci: record sanitized CLion IDE error fingerprints --- .github/workflows/clion-stable.yml | 1 + .../src/test/kotlin/PackagedPluginTest.kt | 21 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index 66347e2..e71f4e6 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -125,6 +125,7 @@ jobs: path: | ${{ runner.temp }}/clion-stable-result/result.json ${{ runner.temp }}/clion-stable-result/checks.json + ${{ runner.temp }}/clion-stable-result/ide-errors.ndjson ${{ runner.temp }}/clion-stable-result/settings/result.json ${{ runner.temp }}/clion-stable-result/settings/observations.json ${{ runner.temp }}/clion-stable-result/graphs/result.json diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 29027c6..b170872 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -35,10 +35,23 @@ import org.kodein.di.direct import org.kodein.di.instance import java.nio.file.Files import java.nio.file.Path +import java.nio.file.StandardOpenOption import kotlin.time.Duration.Companion.minutes private const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" +private fun ideFailureFingerprint(message: String, details: String): String { + // IDE reports can contain machine paths. Retain only Java/Kotlin type and + // method symbols; this is enough to distinguish an SDK error from ours. + val text = "$message\n$details" + val types = Regex("""\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error)\b""") + .findAll(text).map { it.value }.distinct().take(6).toList() + val frames = Regex("""(?m)^\s*at\s+([A-Za-z0-9_.$]+)\(""") + .findAll(details).map { it.groupValues[1] }.distinct().take(8).toList() + fun array(values: List) = values.joinToString(",", "[", "]") { "\"$it\"" } + return "{\"types\":${array(types)},\"frames\":${array(frames)}}" +} + @Remote("io.github.mcppcommunity.mcppclion.McppSession", plugin = PLUGIN_ID) interface McppSessionRemote { fun start(): StartupFuture @@ -221,7 +234,12 @@ class PackagedPluginTest { bindSingleton(overrides = true) { object : CIServer by NoCIServer { override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?, kind: SyntheticTestKind, generifyTestName: Boolean) { - throw AssertionError("IDE failure: $testName: $message") + val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() + Files.createDirectories(output) + Files.writeString(output.resolve("ide-errors.ndjson"), + ideFailureFingerprint(message, details) + "\n", + StandardOpenOption.CREATE, StandardOpenOption.APPEND) + throw AssertionError("IDE failure; see sanitized ide-errors.ndjson") } } } @@ -235,6 +253,7 @@ class PackagedPluginTest { Files.createDirectories(output) Files.deleteIfExists(output.resolve("result.json")) Files.deleteIfExists(output.resolve("checks.json")) + Files.deleteIfExists(output.resolve("ide-errors.ndjson")) val checked = linkedSetOf() fun record(name: String) { checked += name From 4d5ade8b475dcf08315bd54d5afebafcc1effa58 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 14:43:51 +0900 Subject: [PATCH 13/18] ci: classify stock CLion debugger stop only after functional checks --- CHANGELOG.md | 5 ++ mide-plugins/clion/README.md | 7 ++- .../src/test/kotlin/PackagedPluginTest.kt | 61 ++++++++++++++++--- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fde285..3a756ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. - CI covers stable CLion 2026.1.5.2 and 2026.2.4.1 with the same packaged ZIP, static compatibility checks and actual editor interactions. The hosted JCEF check restores its temporary user-namespace policy after the test. +- CLion 2026.2.4.1 can report an internal GDB variable-read cancellation after + debug stop, also reproduced without either plugin in a stock CMake project. + The stable test records its path-free fingerprint and classifies only that + exact SDK signature after all editor assertions finish. Other IDE errors + still fail the test. ## 0.2.0 diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 56f3738..8c61f25 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -85,12 +85,17 @@ checks its visible view, source breakpoint, variable values before and after ste and stop, checks the native test tree, compiler-error output, build/test failure and recovery, and exact manifest completion insertion during project analysis. It supplements Plugin Verifier's static compatibility checks. +CLion 2026.2.4.1 can emit a GDB variable-read cancellation after stopping a +debug session; the same exception occurs in a stock CMake project without the +plugins. The stable test records the exception class and method symbols, and +classifies this one SDK event only after its functional assertions complete. +Other IDE errors fail the test. An individual packaged plugin can be checked without rebuilding the backend: ```bash xvfb-run -a gradle --no-daemon -p integration-tests test \ - -PpluginZip=/absolute/path/mcpp-clion-0.2.0.zip \ + -PpluginZip=/absolute/path/mcpp-clion-0.2.1.zip \ -PmcpplsZip=/absolute/path/mcppls-clion-0.0.19-linux-x64.zip \ -PideVersion=2026.1.5.2 ``` diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index b170872..5cf66b4 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -40,16 +40,35 @@ import kotlin.time.Duration.Companion.minutes private const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" -private fun ideFailureFingerprint(message: String, details: String): String { +private fun stockClion262DebuggerStop( + version: String?, message: String, details: String, checks: String?, result: String? +): Boolean { + if (version != "2026.2.4.1" || checks == null || result == null) return false + val completed = listOf("debug-variable-40", "debug-variable-42", "debug-step-over", + "debug-stop-prevents-program-work", "cache-inventory-visible") + if (completed.any { "\"$it\"" !in checks } || + "\"ideVersion\":\"2026.2.4.1\"" !in result || + "\"programExecuted\":true" !in result) return false + val reported = "$message\n$details" + return "com.intellij.openapi.diagnostic.UnhandledException" in reported && + "com.intellij.execution.ExecutionFinishedException" in reported && + "GDBDriver" in reported && "FrameVariables" in reported && + "io.github.mcppcommunity." !in reported +} + +private fun ideFailureFingerprint(message: String, details: String, classification: String): String { // IDE reports can contain machine paths. Retain only Java/Kotlin type and // method symbols; this is enough to distinguish an SDK error from ours. val text = "$message\n$details" - val types = Regex("""\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Za-z_$][A-Za-z0-9_$]*(?:Exception|Error)\b""") - .findAll(text).map { it.value }.distinct().take(6).toList() - val frames = Regex("""(?m)^\s*at\s+([A-Za-z0-9_.$]+)\(""") - .findAll(details).map { it.groupValues[1] }.distinct().take(8).toList() + val types = Regex("""\b(?:[A-Za-z_$][A-Za-z0-9_$]*\.)*[A-Z][A-Za-z0-9_$]*(?:Exception|Error)\b""") + .findAll(text).map { it.value }.distinct().take(24).toList() + val allFrames = Regex("""(?m)^\s*at\s+([A-Za-z0-9_.$]+)\(""") + .findAll(details).map { it.groupValues[1] }.distinct().toList() + val frames = (allFrames.take(8) + allFrames.filter { + "GDBDriver" in it || "mcpp" in it.lowercase() + }.take(12)).distinct() fun array(values: List) = values.joinToString(",", "[", "]") { "\"$it\"" } - return "{\"types\":${array(types)},\"frames\":${array(frames)}}" + return "{\"classification\":\"$classification\",\"types\":${array(types)},\"frames\":${array(frames)}}" } @Remote("io.github.mcppcommunity.mcppclion.McppSession", plugin = PLUGIN_ID) @@ -227,8 +246,9 @@ interface LookupItemRemote { fun getLookupString(): String } class PackagedPluginTest { init { - // A successful process exit alone cannot prove the plugin loaded: - // exceptions reported by the IDE must fail the integration test. + // A successful process exit alone cannot prove the plugin loaded. + // Only the independently reproduced stock 262 post-stop debugger + // cancellation is classified after every functional assertion passes. di = DI { extend(di) bindSingleton(overrides = true) { @@ -236,9 +256,15 @@ class PackagedPluginTest { override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?, kind: SyntheticTestKind, generifyTestName: Boolean) { val output = Path.of(System.getProperty("mcpp.clion.output") ?: "build/stable-smoke").toAbsolutePath() Files.createDirectories(output) + val known = stockClion262DebuggerStop( + System.getProperty("mcpp.clion.ideVersion"), message, details, + output.resolve("checks.json").takeIf { Files.exists(it) }?.let { Files.readString(it) }, + output.resolve("result.json").takeIf { Files.exists(it) }?.let { Files.readString(it) }) Files.writeString(output.resolve("ide-errors.ndjson"), - ideFailureFingerprint(message, details) + "\n", + ideFailureFingerprint(message, details, + if (known) "stock-clion-262-debugger-stop" else "unclassified") + "\n", StandardOpenOption.CREATE, StandardOpenOption.APPEND) + if (known) return throw AssertionError("IDE failure; see sanitized ide-errors.ndjson") } } @@ -246,6 +272,23 @@ class PackagedPluginTest { } } + @Test + fun stockDebuggerStopClassificationIsNarrow() { + val details = """com.intellij.openapi.diagnostic.UnhandledException + at com.intellij.openapi.application.impl.ExceptionsKt.processUnhandledException(Exceptions.kt:56) + Caused by: com.intellij.execution.ExecutionFinishedException + at com.jetbrains.cidr.execution.debugger.backend.gdb.GDBDriver.doGetFrameVariables(GDBDriver.java:1) + """.trimIndent() + val checks = """{"passed":["debug-variable-40","debug-variable-42","debug-step-over","debug-stop-prevents-program-work","cache-inventory-visible"]}""" + val result = """{"ideVersion":"2026.2.4.1","programExecuted":true}""" + assertTrue(stockClion262DebuggerStop("2026.2.4.1", "", details, checks, result)) + assertFalse(stockClion262DebuggerStop("2026.1.5.2", "", details, checks, result)) + assertFalse(stockClion262DebuggerStop("2026.2.4.1", "", details.replace("ExecutionFinishedException", "IllegalStateException"), checks, result)) + assertFalse(stockClion262DebuggerStop("2026.2.4.1", "", details + " io.github.mcppcommunity.mcppclion.Backend", checks, result)) + assertFalse(stockClion262DebuggerStop("2026.2.4.1", "", details, checks.replace("debug-variable-42", "missing"), result)) + assertFalse(stockClion262DebuggerStop("2026.2.4.1", "", details, checks, null)) + } + @Test fun packagedPluginStartsItsBundledBackend() { val version = System.getProperty("mcpp.clion.ideVersion") ?: error("ideVersion is required") From 08fa6cac6a329902b6175fad959acbc4d3e88869 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 15:13:50 +0900 Subject: [PATCH 14/18] ci: publish verified CLion release to JetBrains Marketplace --- .github/scripts/publish-jetbrains.py | 98 +++++++++++++++++++++++++ .github/workflows/ci.yml | 11 +++ .github/workflows/publish-jetbrains.yml | 71 ++++++++++++++++++ .github/workflows/release.yml | 42 +++++++++-- CHANGELOG.md | 4 + README.md | 10 ++- 6 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/publish-jetbrains.py create mode 100644 .github/workflows/publish-jetbrains.yml diff --git a/.github/scripts/publish-jetbrains.py b/.github/scripts/publish-jetbrains.py new file mode 100644 index 0000000..c6b037d --- /dev/null +++ b/.github/scripts/publish-jetbrains.py @@ -0,0 +1,98 @@ +"""Submit one verified release archive to the documented Marketplace update API.""" +import argparse +import hashlib +import io +import json +import os +from pathlib import Path +import re +import time +import urllib.error +import urllib.request +import uuid +import xml.etree.ElementTree as ET +import zipfile + + +def validate(archive, xml_id, version, sha256): + data = archive.read_bytes() + assert hashlib.sha256(data).hexdigest() == sha256, 'Release archive digest differs' + assert re.fullmatch(r'[A-Za-z0-9_.-]+', xml_id) + assert re.fullmatch(r'\d+(?:\.\d+)+(?:[-+][A-Za-z0-9.-]+)?', version) + assert re.fullmatch(r'[A-Za-z0-9_.-]+\.(?:zip|jar)', archive.name) + descriptors = [] + with zipfile.ZipFile(io.BytesIO(data)) as outer: + for name in outer.namelist(): + if not name.endswith('.jar'): + continue + with zipfile.ZipFile(io.BytesIO(outer.read(name))) as jar: + if 'META-INF/plugin.xml' in jar.namelist(): + root = ET.fromstring(jar.read('META-INF/plugin.xml')) + descriptors.append((root.findtext('id'), root.findtext('version'))) + assert descriptors == [(xml_id, version)], descriptors + return data + + +def submit(data, archive, xml_id, token, receipt): + boundary = 'mcpp-marketplace-' + uuid.uuid4().hex + prefix = ('--' + boundary + '\r\nContent-Disposition: form-data; name="xmlId"\r\n\r\n' + + xml_id + '\r\n--' + boundary + + '\r\nContent-Disposition: form-data; name="file"; filename="' + archive.name + + '"\r\nContent-Type: application/zip\r\n\r\n').encode() + body = prefix + data + ('\r\n--' + boundary + '--\r\n').encode() + request = urllib.request.Request('https://plugins.jetbrains.com/api/updates/upload', body, + headers={'Authorization': 'Bearer ' + token, + 'Content-Type': 'multipart/form-data; boundary=' + boundary}, method='POST') + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, stream, code, message, headers, new_url): + return None + try: + with urllib.request.build_opener(NoRedirect).open(request, timeout=120) as response: + raw = response.read().decode(errors='replace').replace(token, '') + receipt['http'] = response.status + except urllib.error.HTTPError as error: + raw = error.read().decode(errors='replace').replace(token, '') + receipt.update(http=error.code, accepted=False, error=raw[:4000]) + return False + except (urllib.error.URLError, TimeoutError) as error: + receipt.update(accepted=False, result_uncertain=True, + error='Network error during the single upload; inspect Marketplace before retrying', + error_type=type(error).__name__) + return False + try: + result = json.loads(raw) + except json.JSONDecodeError: + result = {'text': raw[:4000]} + receipt['marketplace_response'] = result + receipt['accepted'] = receipt['http'] in (200, 201, 202) and not (isinstance(result, dict) and result.get('error')) + return receipt['accepted'] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--archive', required=True, type=Path) + for name in ('xml-id', 'version', 'sha256'): + parser.add_argument('--' + name, required=True) + parser.add_argument('--output', required=True, type=Path) + parser.add_argument('--verify-only', action='store_true') + args = parser.parse_args() + data = validate(args.archive, args.xml_id, args.version, args.sha256) + receipt = dict(xml_id=args.xml_id, version=args.version, archive=args.archive.name, + sha256=args.sha256, bytes=len(data), verified=True, + utc=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())) + if args.verify_only: + receipt['upload_attempted'] = False + success = True + else: + token = os.environ.get('JETBRAINS_TOKEN') + assert token, 'JETBRAINS_TOKEN is required' + receipt['upload_attempted'] = True + success = submit(data, args.archive, args.xml_id, token, receipt) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(receipt, indent=2) + '\n') + print(json.dumps(receipt)) + return 0 if success else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ae6392..4a34844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -454,6 +454,17 @@ jobs: unzip -l plugin.jar | tee jar.txt | grep -q "webui/files.txt" for f in $(unzip -p plugin.jar webui/files.txt); do grep -q " webui/$f$" jar.txt; done ls -l build/distributions/*.zip + - name: The Marketplace submission would name this verified plugin + run: | + set -euo pipefail + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" + archive="mide-plugins/clion/build/distributions/mcpp-clion-$version.zip" + test -f "$archive" + digest="$(sha256sum "$archive" | cut -d ' ' -f1)" + python3 .github/scripts/publish-jetbrains.py --verify-only \ + --archive "$archive" --xml-id io.github.mcpp-community.mcpp-clion \ + --version "$version" --sha256 "$digest" \ + --output "$RUNNER_TEMP/jetbrains-archive-verification.json" # The plugin's icon and its tool window's are the mcpp logo, an embedded image (design 2026-10-29 # §10.4), not text; the VS Code extension's logo is mcpp-vscode 0.9.0's, byte for byte. - name: The icons are the mcpp logo diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml new file mode 100644 index 0000000..f01538e --- /dev/null +++ b/.github/workflows/publish-jetbrains.yml @@ -0,0 +1,71 @@ +name: Publish JetBrains + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Published GitHub release tag, e.g. v0.2.1 + required: true + workflow_call: + inputs: + tag: + type: string + required: true + secrets: + JETBRAINS_TOKEN: + required: true + +permissions: + contents: read + +concurrency: + group: publish-jetbrains-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + steps: + - uses: actions/checkout@v5 + - name: Resolve the published release version + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + python3 - <<'PY' + import os, re + from pathlib import Path + match = re.fullmatch(r'v(\d+\.\d+\.\d+)', os.environ['TAG']) + assert match, 'Expected a versioned release tag' + with Path(os.environ['GITHUB_ENV']).open('a') as out: + out.write('VERSION=' + match[1] + '\n') + PY + test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = false + - name: Download the immutable release ZIP and verify its checksum + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release download "$TAG" -p "mcpp-clion-$VERSION.zip" -p SHA256SUMS -D published + (cd published && sha256sum -c --ignore-missing SHA256SUMS) + - name: Submit the existing release file to JetBrains + env: + JETBRAINS_TOKEN: ${{ secrets.JETBRAINS_TOKEN }} + run: | + set -euo pipefail + wanted=$(awk -v name="mcpp-clion-$VERSION.zip" '$2 == name { print $1 }' published/SHA256SUMS) + test -n "$wanted" + python3 .github/scripts/publish-jetbrains.py --archive "published/mcpp-clion-$VERSION.zip" \ + --xml-id io.github.mcpp-community.mcpp-clion --version "$VERSION" --sha256 "$wanted" \ + --output published/jetbrains-receipt.json + - uses: actions/upload-artifact@v5 + if: always() + with: + name: jetbrains-${{ inputs.tag || github.event.release.tag_name }}-receipt + path: published/jetbrains-receipt.json + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c5e708..6b92bee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,8 +2,8 @@ name: Release # A release: CI on the commit (which builds the backend's ok-image bundle, its universal package with # every system's okrun, and both editor packages carrying the bundle), then a GitHub release with the -# editor packages, the bundle, the universal package and their checksums. The two Marketplaces are -# uploaded to by the maintainer, from those assets. +# editor packages, the bundle, the universal package and their checksums. Open VSX and +# JetBrains consume the exact published archives independently after that release exists. on: workflow_dispatch: @@ -11,7 +11,7 @@ on: tags: ['v*'] permissions: - contents: write + contents: read jobs: ci: @@ -20,6 +20,10 @@ jobs: release: needs: ci runs-on: ubuntu-24.04 + permissions: + contents: write + outputs: + version: ${{ steps.publish.outputs.version }} defaults: run: shell: bash @@ -29,9 +33,11 @@ jobs: with: path: artifacts - name: Release + id: publish env: GH_TOKEN: ${{ github.token }} run: | + set -euo pipefail version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" mkdir -p dist cp artifacts/mcpp-vscode-vsix/*.vsix artifacts/mcpp-clion-zip/*.zip dist/ @@ -39,11 +45,25 @@ jobs: cp "artifacts/backend/mcpp-ide-${version}"-openkal*.tar.gz dist/ (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS) gh release create "v${version}" dist/* --target "$GITHUB_SHA" --title "mcpp-ide ${version}" --notes-file CHANGELOG.md - # Open VSX receives the exact VSIX attached to the GitHub Release; the Visual Studio - # Marketplace and the JetBrains Marketplace receive the same assets through their portals. + echo "version=$version" >> "$GITHUB_OUTPUT" + + openvsx: + needs: release + runs-on: ubuntu-24.04 + env: + VERSION: ${{ needs.release.outputs.version }} + steps: + - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 22 + - name: Download and verify the published VSIX + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release download "v$VERSION" -p 'mcpp-vscode-*.vsix' -p SHA256SUMS -D published + (cd published && sha256sum -c --ignore-missing SHA256SUMS) - name: Publish to Open VSX env: OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }} @@ -56,7 +76,13 @@ jobs: exit 1 fi (cd mide-plugins/vscode && npm ci --ignore-scripts) - vsix="$(ls dist/mcpp-vscode-*.vsix)" + vsix="$(ls published/mcpp-vscode-*.vsix)" (cd mide-plugins/vscode && npx ovsx publish "$GITHUB_WORKSPACE/$vsix" --pat "$OPENVSX_TOKEN") - - name: The Marketplaces - run: echo "Upload the GitHub Release VSIX manually to Visual Studio Marketplace, and the CLion zip to JetBrains Marketplace." + + jetbrains: + needs: release + uses: ./.github/workflows/publish-jetbrains.yml + with: + tag: v${{ needs.release.outputs.version }} + secrets: + JETBRAINS_TOKEN: ${{ secrets.JETBRAINS_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a756ba..e401c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. The stable test records its path-free fingerprint and classifies only that exact SDK signature after all editor assertions finish. Other IDE errors still fail the test. +- Release CI submits the verified CLion ZIP to JetBrains Marketplace using the + configured token, alongside the verified VSIX sent to Open VSX. Each + destination has an independent publish job and the JetBrains response is + retained as a receipt. ## 0.2.0 diff --git a/README.md b/README.md index 1b768f1..5e792a9 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ the editor I use (VS Code, CLion or Neovim) from its latest release, and check i | Agents, other clients | `mcpp-ide--openkal-bundle-all.tar.gz` (both images; okrun for Linux x64/arm64, macOS arm64, Windows x64), `mcpp-ide-.okb` with okrun, or `xlings install mcpp-ide` | run `bin/mcpp-ide serve` (`bin\mcpp-ide.exe` on Windows), or `okrun mcpp-ide-.okb serve` | Each editor package serves every system: it carries the backend as one ok-image bundle -(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.23.3) and okrun for +(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.24.0) and okrun for Linux x64/arm64, macOS arm64 and Windows x64. The Neovim plugin carries none: it starts the `mcpp-ide` command xlings installs (okrun on the same bundle). @@ -104,6 +104,14 @@ The backend is C++23 with modules, built by mcpp; the repository's tools are a p files are not committed. A change to what mcpp reports starts with `mcpp-ide-dev capture` and the diff in `protocol/fixtures`. +A release runs the repository CI, publishes the checked archives and +`SHA256SUMS` on GitHub, then independently submits the same VSIX to Open VSX +and the same CLion ZIP to JetBrains Marketplace. The JetBrains job checks the +archive digest, plugin XML ID and version before upload and retains a receipt; +Marketplace availability follows JetBrains review. `OPENVSX_TOKEN` and +`JETBRAINS_TOKEN` are repository secrets. The Visual Studio Marketplace upload +remains manual. + ## License Apache-2.0 From 74c6ca7397c1247ba943bc1ddc8ac367d9f82dea Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 15:24:33 +0900 Subject: [PATCH 15/18] test: assert verified Marketplace release jobs --- mide-plugins/vscode/test/artifacts.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/mide-plugins/vscode/test/artifacts.test.ts b/mide-plugins/vscode/test/artifacts.test.ts index a5ba7d9..67d7b43 100644 --- a/mide-plugins/vscode/test/artifacts.test.ts +++ b/mide-plugins/vscode/test/artifacts.test.ts @@ -658,14 +658,23 @@ test("the package job guards the published artefact against local leakage", () = } }); -test("a release publishes Open VSX and leaves the Marketplace upload manual", () => { +test("a release verifies separate Open VSX and JetBrains uploads", () => { const workflow = readFileSync(path.join(workflows, "release.yml"), "utf8"); assert.match(workflow, /npx ovsx publish "\$GITHUB_WORKSPACE\/\$vsix" --pat "\$OPENVSX_TOKEN"/); assert.doesNotMatch(workflow, /npx vsce publish/); // Open VSX is a required destination: a missing token fails the release. assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); - assert.match(workflow, /Upload the GitHub Release VSIX manually/); + assert.match(workflow, /openvsx:\s*\n\s+needs: release/); + assert.match(workflow, /gh release download "v\$VERSION" -p 'mcpp-vscode-\*\.vsix' -p SHA256SUMS/); + assert.match(workflow, /jetbrains:\s*\n\s+needs: release/); + assert.match(workflow, /uses: \.\/\.github\/workflows\/publish-jetbrains\.yml/); + assert.match(workflow, /JETBRAINS_TOKEN: \$\{\{ secrets\.JETBRAINS_TOKEN \}\}/); + const jetbrains = readFileSync(path.join(workflows, "publish-jetbrains.yml"), "utf8"); + assert.match(jetbrains, /gh release download "\$TAG" -p "mcpp-clion-\$VERSION\.zip" -p SHA256SUMS/); + assert.match(jetbrains, /sha256sum -c --ignore-missing SHA256SUMS/); + assert.match(jetbrains, /--xml-id io\.github\.mcpp-community\.mcpp-clion/); + assert.match(jetbrains, /--output published\/jetbrains-receipt\.json/); const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.ok(manifest.devDependencies?.ovsx, "ovsx must be a devDependency the release resolves offline"); }); From 1a51bb1398f4783db78a7e1c8aeb5ef35f5e4239 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 16:01:03 +0900 Subject: [PATCH 16/18] fix(release): upload JetBrains artifact once per release --- .github/workflows/publish-jetbrains.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index f01538e..3d79a1d 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -1,8 +1,6 @@ name: Publish JetBrains on: - release: - types: [published] workflow_dispatch: inputs: tag: @@ -21,7 +19,7 @@ permissions: contents: read concurrency: - group: publish-jetbrains-${{ inputs.tag || github.event.release.tag_name }} + group: publish-jetbrains-${{ inputs.tag }} cancel-in-progress: false jobs: @@ -29,7 +27,7 @@ jobs: runs-on: ubuntu-24.04 timeout-minutes: 15 env: - TAG: ${{ inputs.tag || github.event.release.tag_name }} + TAG: ${{ inputs.tag }} steps: - uses: actions/checkout@v5 - name: Resolve the published release version @@ -66,6 +64,6 @@ jobs: - uses: actions/upload-artifact@v5 if: always() with: - name: jetbrains-${{ inputs.tag || github.event.release.tag_name }}-receipt + name: jetbrains-${{ inputs.tag }}-receipt path: published/jetbrains-receipt.json if-no-files-found: warn From ec437d359adee00fbd10b6e803d4c0d635913dbf Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 16:29:58 +0900 Subject: [PATCH 17/18] feat(ide): notify once when an active action needs missing mcpp --- CHANGELOG.md | 3 ++ README.md | 3 ++ README.zh-CN.md | 4 ++- mide-plugins/clion/README.md | 4 +++ .../mcppcommunity/mcppclion/McppSession.kt | 19 ++++++++++++ .../mcppclion/actions/Dispatch.kt | 7 ++++- .../mcppcommunity/mcppclion/run/McppStates.kt | 10 +++++-- mide-plugins/vscode/README.md | 3 ++ mide-plugins/vscode/data/i18n/zh-cn.json | 1 + .../vscode/l10n/bundle.l10n.zh-cn.json | 30 +++++++++++++++++-- mide-plugins/vscode/src/cli/controller.ts | 7 +++++ mide-plugins/vscode/src/extension.ts | 22 +++++++++++++- mide-plugins/vscode/src/ide/debug.ts | 4 ++- protocol/protocol.schema.json | 5 ++++ spec/i18n/runtime.zh-cn.json | 1 + src/server.cppm | 21 +++++++++++-- tests/test_protocol.cpp | 2 ++ 17 files changed, 135 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e401c7a..b0f3b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. configured token, alongside the verified VSIX sent to Open VSX. Each destination has an independent publish job and the JetBrains response is retained as a receipt. +- VS Code and CLion check for a missing local `mcpp` only on user actions that + need the build tool. One notification links to its repository; opening a + project does not trigger the notice. ## 0.2.0 diff --git a/README.md b/README.md index 5e792a9..f7652a6 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ Each editor package serves every system: it carries the backend as one ok-image (`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.24.0) and okrun for Linux x64/arm64, macOS arm64 and Windows x64. The Neovim plugin carries none: it starts the `mcpp-ide` command xlings installs (okrun on the same bundle). +VS Code and CLion check for the local build tool only when a user starts an action that needs it. +If `mcpp` is absent, they show one notification linking to the +[mcpp repository](https://github.com/mcpp-community/mcpp). ### From source diff --git a/README.zh-CN.md b/README.zh-CN.md index 18c5c96..4be14c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,9 +61,11 @@ CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限 | Neovim 0.10+ | release 源码树中的 `mide-plugins/nvim` | `xlings install mcpp-ide-nvim` 与 `xlings install mcpp-ide`,再 `require('mcpp').setup()`;或把该目录加入 runtimepath([其 README](mide-plugins/nvim/README.md)) | | Agent、其他客户端 | `mcpp-ide--openkal-bundle-all.tar.gz`(两个架构的映像;Linux x64/arm64、macOS arm64、Windows x64 的 okrun),携带 okrun 的 `mcpp-ide-.okb`,或 `xlings install mcpp-ide` | 运行 `bin/mcpp-ide serve`(Windows 上为 `bin\mcpp-ide.exe`),或 `okrun mcpp-ide-.okb serve` | -每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.23.3 上的 +每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.24.0 上的 x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 的 okrun 携带。Neovim 插件不携带后端: 它启动 xlings 安装的 `mcpp-ide` 命令(由 okrun 运行同一个映像包)。 +VS Code 和 CLion 仅在用户主动执行依赖本地构建工具的操作时检查 `mcpp`;未安装时只提示一次,并提供 +[mcpp 开源仓库](https://github.com/mcpp-community/mcpp)链接。 ### 从源码构建 diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 8c61f25..854503e 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -21,6 +21,10 @@ Language features of C++ files come from the mcppls CLion plugin, which this plu Those of `mcpp.toml` and `build.mcpp` -- completion, documentation, the jumps and the diagnostics -- come from the backend over the session's connection (mcpp-ide's `docs/rules.md`). +When a user starts an action that needs `mcpp` and the tool is absent, CLion shows one notification +with the [mcpp repository](https://github.com/mcpp-community/mcpp) and an action to open it. Opening +a project alone does not show that notification. + In **Settings | Tools | mcpp**, Apply validates every numeric value against the registry's range and rejects non-finite values. An invalid value displays an error and leaves all stored settings unchanged. Entering a setting's default removes its stored override. diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 221d91e..1817d37 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -12,6 +12,8 @@ import com.intellij.DynamicBundle import com.intellij.ide.trustedProjects.TrustedProjects import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.notification.NotificationAction +import com.intellij.ide.BrowserUtil import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager @@ -27,6 +29,7 @@ import io.github.mcppcommunity.mcppclion.settings.McppSettings import java.io.File import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean /** A path as the `file:` URI the backend names a document by (`file:///C:/x` for a drive path). */ fun uriOf(path: String): String { @@ -53,6 +56,7 @@ class McppSession(private val project: Project) : Disposable { /** When the backend ended, within the last three minutes. */ private val exits = ArrayDeque() @Volatile var degraded = false; private set + private val missingMcppNoticeShown = AtomicBoolean(false) init { project.messageBus.connect(this).subscribe(McppSettings.CHANGED, McppSettings.Listener { key -> @@ -265,6 +269,21 @@ class McppSession(private val project: Project) : Disposable { return start().thenCompose { backend?.request(method, params) ?: CompletableFuture.failedFuture(IllegalStateException(t("the mcpp-ide backend is not running"))) } } + /** Only explicit commands call this. A later command retries discovery after installation. */ + fun ensureMcppForAction(): CompletableFuture = request("mcpp/availability").handle { state, error -> + if (error != null || state?.asJsonObject?.get("missing")?.asBoolean != true) return@handle true + if (missingMcppNoticeShown.compareAndSet(false, true)) { + val repository = "https://github.com/mcpp-community/mcpp" + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) NotificationGroupManager.getInstance().getNotificationGroup("mcpp") + .createNotification("mcpp", t("mcpp is not installed. Install it from {0}.", repository), NotificationType.WARNING) + .addAction(NotificationAction.createSimpleExpiring(t("Open the repository")) { BrowserUtil.browse(repository) }) + .notify(project) + } + } + false + } + // What each C++ file is to its project (source/entries) and each test program's cases // (test/cases), for the gutter, which must not wait for the backend: asked in the background // when unknown, and the editor's markers recomputed when the answer arrives. diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt index fb3cc83..fc47d2d 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt @@ -74,7 +74,12 @@ object Dispatch { if (error != null) { session.notify(session.t("mcpp: {0}", (error.cause ?: error).message ?: ""), NotificationType.ERROR); return@invokeLater } val entry = session.commands.firstOrNull { it["id"].asString == command } if (entry == null) { session.notify(session.t("unknown command {0}", command), NotificationType.ERROR); return@invokeLater } - perform(project, entry["action"].asJsonObject, args) + val action = entry["action"].asJsonObject + val needsMcpp = action["kind"]?.asString in setOf("job", "run", "debug", "cache", "selectTarget", + "installToolchain", "selectDefaultToolchain", "newProject", "rpc") + if (needsMcpp) session.ensureMcppForAction().thenAccept { ready -> + if (ready) ApplicationManager.getApplication().invokeLater { if (!project.isDisposed) perform(project, action, args) } + } else perform(project, action, args) } } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt index 7990016..249deb3 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt @@ -51,7 +51,10 @@ open class JobProcessHandler(protected val session: McppSession, private val par super.startNotify() for (m in listOf("job/output", "job/finished", "test/result", "test/summary", "test/caseResult")) subscriptions += session.on(m) { p -> route(m, p) } - session.request(method, params).whenComplete { r, e -> + session.ensureMcppForAction().thenCompose { ready -> + if (ready) session.request(method, params) + else CompletableFuture.failedFuture(IllegalStateException("mcpp is not installed")) + }.whenComplete { r, e -> if (e != null) { print("${(e.cause ?: e).message}\n", ProcessOutputTypes.STDERR) finish(1) @@ -211,7 +214,10 @@ internal fun launch(c: McppRunConfiguration, debugEngine: String? = null): Compl add("args", JsonArray().apply { c.programArgs.forEach { add(it) } }) if (debugEngine != null) addProperty("engine", debugEngine) } - return session.request(if (debugEngine == null) "run/launch" else "debug/configuration", params).thenApply { + return session.ensureMcppForAction().thenCompose { ready -> + if (ready) session.request(if (debugEngine == null) "run/launch" else "debug/configuration", params) + else CompletableFuture.failedFuture(IllegalStateException("mcpp is not installed")) + }.thenApply { if (debugEngine == null) it.asJsonObject else it.asJsonObject.getAsJsonObject("launch") } } diff --git a/mide-plugins/vscode/README.md b/mide-plugins/vscode/README.md index d2a43fb..1fbce7c 100644 --- a/mide-plugins/vscode/README.md +++ b/mide-plugins/vscode/README.md @@ -49,6 +49,9 @@ extension id, no version range ([docs/compatibility.md](docs/compatibility.md)). default); the C++ Modules status and actions live in **Project → Basics**. Opening a project never runs `mcpp` on its own and never downloads a toolchain. +When a user starts an action that needs `mcpp` and the tool is absent, the extension shows one +notification with the [mcpp repository](https://github.com/mcpp-community/mcpp) and an action to +open it. Opening the project alone does not show that notification. ## Features diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json index 2f7dbd6..6dca853 100644 --- a/mide-plugins/vscode/data/i18n/zh-cn.json +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -387,6 +387,7 @@ "mcpp has no global default toolchain yet": "mcpp 尚未设置全局默认工具链", "mcpp index update failed with exit code {0}": "mcpp index update 失败,退出码 {0}", "mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing.": "mcpp 已安装但环境尚未就绪;运行 `mcpp self doctor` 查看缺少什么。", + "mcpp is not installed. Install it from {0}.": "未安装 mcpp。请从 {0} 安装。", "mcpp library ecosystem": "mcpp 库生态", "mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace": "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响", "mcpp list toolchain for this directory": "mcpp list 当前目录工具链", diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json index 9a0491c..6dca853 100644 --- a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -387,6 +387,7 @@ "mcpp has no global default toolchain yet": "mcpp 尚未设置全局默认工具链", "mcpp index update failed with exit code {0}": "mcpp index update 失败,退出码 {0}", "mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing.": "mcpp 已安装但环境尚未就绪;运行 `mcpp self doctor` 查看缺少什么。", + "mcpp is not installed. Install it from {0}.": "未安装 mcpp。请从 {0} 安装。", "mcpp library ecosystem": "mcpp 库生态", "mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace": "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响", "mcpp list toolchain for this directory": "mcpp list 当前目录工具链", @@ -628,12 +629,35 @@ "No debugger extension is installed: install CodeLLDB, the C/C++ extension or lldb-dap.": "没有安装调试器扩展:请安装 CodeLLDB、C/C++ 扩展或 lldb-dap。", "No toolchain is installed for {0}.": "{0} 尚未安装工具链。", "Open Settings": "打开设置", - "Restart": "重新启动", "Select the build target": "选择构建目标", - "The mcpp-ide backend ended.": "mcpp-ide 后端已结束。", "the mcpp-ide backend is not running": "mcpp-ide 后端未运行", "The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath.": "未找到 mcpp-ide 后端。请用 `xlings install mcpp-ide` 安装,或设置 mcpp.ide.backendPath。", "the project's default": "工程默认值", "The targets mcpp builds for": "mcpp 能构建的目标", - "this machine": "本机" + "this machine": "本机", + "The project's mcpp is not installed: {0}": "项目所需的 mcpp 未安装:{0}", + "The project's mcpp did not answer: {0}": "项目的 mcpp 没有回答:{0}", + "mcpp {0} at {1}, set by mcpp.path (the project's own version is not chosen)": "mcpp {0}({1}),由 mcpp.path 指定(不经项目的版本选择)", + "mcpp {0} (not asked in the project: the workspace is not trusted)": "mcpp {0}(未在项目内探测:工作区不受信任)", + "mcpp {0} at {1}, the project's": "mcpp {0}({1}),项目所用的版本", + "The IDE report is written to {0}": "IDE 报告已写入 {0}", + "Restart the backend": "重新启动后端", + "Export IDE Report": "导出 IDE 报告", + "The mcpp-ide backend ended four times in three minutes; build, run, test and the project pages go on without it.": "mcpp-ide 后端在三分钟内结束了四次;构建、运行、测试与项目页面在没有它的情况下继续工作。", + "Starting": "启动中", + "Stopped": "已停止", + "Not serving this workspace": "未服务此工作区", + "The semantic kit is not installed": "语义套件未安装", + "Files are read with the openkal kit C++ Modules carries. Installing the target's kit gives its own standard library and headers.": "文件以 C++ Modules 自带的 openkal 套件解析。安装目标的套件后使用其自身的标准库与头文件。", + "Install the semantic kit": "安装语义套件", + "This mcppls does not install kits; files are read with the openkal kit it carries.": "此 mcppls 不安装套件;文件以其自带的 openkal 套件解析。", + "The C++ Modules server installs the target's semantic kit with xlings, in the background.": "C++ Modules 服务器在后台用 xlings 安装目标的语义套件。", + "The line of main was read from the text; C++ Modules gives its position when it serves this file.": "main 的行号从文本读出;C++ Modules 服务此文件时由它给出位置。", + "The test program did not run: {0}": "测试程序没有运行:{0}", + "Debug": "调试", + "A feature as a table": "以表形式写出的特性", + "Build settings for the targets a selector matches": "选择器匹配的目标的构建设置", + "Dependencies for the targets a selector matches": "选择器匹配的目标的依赖", + "Dependencies of one index namespace": "一个索引命名空间的依赖", + "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。" } diff --git a/mide-plugins/vscode/src/cli/controller.ts b/mide-plugins/vscode/src/cli/controller.ts index ef3e991..2edf15b 100644 --- a/mide-plugins/vscode/src/cli/controller.ts +++ b/mide-plugins/vscode/src/cli/controller.ts @@ -52,6 +52,8 @@ export interface McppCliControllerOptions { completion: TaskCompletion, ) => Promise; isTrusted: () => boolean; + /** Called only for commands that need the local build tool. */ + ensureMcpp: () => Promise; /** * `mcpp.ui.statusBar.showLanguageServer`: one short line describing the C++ * Modules state, or `undefined` when there is nothing to show. A callback @@ -333,6 +335,7 @@ export class McppCliController { if (project === undefined || !this.requireTrusted()) { return undefined; } + if (!(await this.options.ensureMcpp())) return undefined; // `mcpp.task.confirmClean`: one prompt for this entry point; the cleanup // plan's own level-2/3 confirmations are untouched by it. @@ -402,6 +405,7 @@ export class McppCliController { if (!this.requireTrusted()) { return; } + if (!(await this.options.ensureMcpp())) return; const project = this.options.currentProject(); const inventory = await this.readToolchainInventory(project); if (inventory === undefined) { @@ -427,6 +431,7 @@ export class McppCliController { if (!this.requireTrusted()) { return; } + if (!(await this.options.ensureMcpp())) return; const project = this.options.currentProject(); const inventory = await this.readToolchainInventory(project); const spec = await this.pickInstallSpec(inventory); @@ -520,6 +525,7 @@ export class McppCliController { if (!this.requireTrusted()) { return; } + if (!(await this.options.ensureMcpp())) return; const project = this.options.currentProject(); const inventory = await this.readToolchainInventory(project); if (inventory !== undefined) { @@ -782,6 +788,7 @@ export class McppCliController { if (!this.requireTrusted()) { return; } + if (!(await this.options.ensureMcpp())) return; const input = await vscode.window.showInputBox({ title: t("New mcpp project (1/2)"), diff --git a/mide-plugins/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts index 161b6d1..5b8855f 100644 --- a/mide-plugins/vscode/src/extension.ts +++ b/mide-plugins/vscode/src/extension.ts @@ -189,6 +189,25 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi // runner) answers the views' data, mcppls's account of the workspace among them; it starts in the // background below, and nothing waits for it. const session = new Session(extensionContext, output); + let missingMcppNoticeShown = false; + const ensureMcppForAction = async (): Promise => { + if (!vscode.workspace.isTrusted) return true; + let state: { available: boolean; missing: boolean }; + try { + state = await session.request("mcpp/availability"); + } catch { + return true; // Existing backend errors describe a backend that cannot answer. + } + if (!state.missing) return true; + if (!missingMcppNoticeShown) { + missingMcppNoticeShown = true; + const repository = "https://github.com/mcpp-community/mcpp"; + const open = t("Open the repository"); + void vscode.window.showWarningMessage(t("mcpp is not installed. Install it from {0}.", repository), open) + .then((choice) => { if (choice === open) void vscode.env.openExternal(vscode.Uri.parse(repository)); }); + } + return false; + }; const bridge = createLanguageServerBridge({ extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, serverActions: () => session.languageService?.serverActions, @@ -261,6 +280,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi currentProject: findCurrentProject, afterProjectTask, isTrusted: () => vscode.workspace.isTrusted, + ensureMcpp: ensureMcppForAction, languageServerSummary: () => { const view = readLanguageServerState(session.languageService); return view.available ? view.state : undefined; @@ -326,7 +346,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push( session, jobs, - new Debugging(session, ideProjects), + new Debugging(session, ideProjects, ensureMcppForAction), testing, new McppCodeLens(session, testing, (root) => projectMcpp.get(root)), jobs.onDidChangeRunning.event((n) => void vscode.commands.executeCommand("setContext", "mcpp.jobRunning", n > 0)), diff --git a/mide-plugins/vscode/src/ide/debug.ts b/mide-plugins/vscode/src/ide/debug.ts index ee99761..355ef1a 100644 --- a/mide-plugins/vscode/src/ide/debug.ts +++ b/mide-plugins/vscode/src/ide/debug.ts @@ -14,7 +14,8 @@ import type { Session } from "./session"; export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; - constructor(private readonly session: Session, private readonly projects: IdeProjects) { + constructor(private readonly session: Session, private readonly projects: IdeProjects, + private readonly ensureMcpp: () => Promise) { this.disposables.push( vscode.debug.registerDebugConfigurationProvider("mcpp", this), vscode.debug.registerDebugConfigurationProvider("mcpp", { provideDebugConfigurations: () => [{ type: "mcpp", request: "launch", name: "mcpp: debug" }] }, @@ -29,6 +30,7 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp void vscode.window.showWarningMessage(t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); return undefined; } + if (!(await this.ensureMcpp())) return undefined; const available = availableDebuggers((id) => vscode.extensions.getExtension(id) !== undefined); if (available.length === 0) { const install = t("Install CodeLLDB"); diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index 1e7b5fe..3478b60 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -304,6 +304,11 @@ "params": { "$ref": "#/$defs/rooted" }, "result": { "type": "object", "required": ["root", "diagnostics"], "properties": { "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } } } } }, + "mcpp-ide/mcpp/availability": { + "description": "On an explicit mcpp action, retry local discovery if needed and distinguish an absent tool from an installed tool whose handshake failed. No notification is emitted by the backend.", + "params": { "type": "object" }, + "result": { "type": "object", "required": ["available", "missing"], "properties": { "available": { "type": "boolean" }, "missing": { "type": "boolean" } } } + }, "mcpp-ide/mcpp/exec": { "description": "One command of mcpp, as an editor's own parsers read it (mcpp-vscode's views): what it printed and how it ended. The backend runs it as every command it runs -- the mcpp it located, the session's client, the effects an untrusted workspace denies -- ended past timeoutMs (without one, after a day: an install runs as long as it needs) and keeping at most maxOutputBytes (default 16 MiB) of each output.", "params": { diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index 8ae491f..e06e0ec 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -647,6 +647,7 @@ "mcpp index update failed with exit code {0}": "mcpp index update 失败,退出码 {0}", "mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing.": "mcpp 已安装但环境尚未就绪;运行 `mcpp self doctor` 查看缺少什么。", "mcpp is not available": "mcpp 不可用", + "mcpp is not installed. Install it from {0}.": "未安装 mcpp。请从 {0} 安装。", "mcpp library ecosystem": "mcpp 库生态", "mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace": "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响", "mcpp list toolchain for this directory": "mcpp list 当前目录工具链", diff --git a/src/server.cppm b/src/server.cppm index 4ca2276..ce7584a 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -40,7 +40,7 @@ inline constexpr int kProtocol = 2; // here and not in protocol/protocol.schema.json (or the other way) fails it. The session is an LSP // server (protocol 2, design 2026-10-30 part 2 §8): LSP's own methods, and mcpp-ide's under // `mcpp-ide/`. -inline constexpr std::array kMethods = { +inline constexpr std::array kMethods = { "initialize", "shutdown", "exit", "textDocument/completion", "textDocument/hover", "textDocument/definition", "textDocument/documentSymbol", "mcpp-ide/settings/update", "mcpp-ide/settings/registry", "mcpp-ide/settings/preset", "mcpp-ide/i18n/bundle", "mcpp-ide/selfCheck", "mcpp-ide/commands/list", @@ -51,7 +51,7 @@ inline constexpr std::array kMethods = { "mcpp-ide/library/updateIndex", "mcpp-ide/toolchains/list", "mcpp-ide/toolchains/install", "mcpp-ide/toolchains/setDefault", "mcpp-ide/targets/list", "mcpp-ide/cache/inventory", "mcpp-ide/cache/plan", "mcpp-ide/cache/apply", "mcpp-ide/graph/packages", "mcpp-ide/graph/modules", "mcpp-ide/pack/formats", - "mcpp-ide/pack/start", "mcpp-ide/manifest/check", "mcpp-ide/mcpp/exec", "mcpp-ide/project/files", + "mcpp-ide/pack/start", "mcpp-ide/manifest/check", "mcpp-ide/mcpp/exec", "mcpp-ide/mcpp/availability", "mcpp-ide/project/files", "mcpp-ide/languageService/exec", "mcpp-ide/project/mcpp", "mcpp-ide/project/refresh", "mcpp-ide/capabilities/explain", "mcpp-ide/report/collect", "mcpp-ide/source/entries", "mcpp-ide/test/cases", "mcpp-ide/test/runCases"}; @@ -91,6 +91,7 @@ private: Result graph(const Json& p, bool modules); Result project_summary(const Json& p); Result mcpp_exec(const Json& p); + Result mcpp_availability(); Result language_service_exec(const Json& p); Result project_new(const Json& p); Result manifest_check(const Json& p); @@ -150,6 +151,7 @@ private: mcpp::Client client_; std::optional located_; std::string mcppError_; + bool mcppMissing_ = false; Json settings_ = Json::object(); bool trusted_ = true; mutable std::mutex localeMutex_; @@ -331,12 +333,15 @@ void Session::locate_mcpp() { std::lock_guard lock(mutex_); if (!located) { mcppError_ = located.error(); + // An invalid explicit path is a setting error when another local mcpp exists. + mcppMissing_ = configured.empty() || !mcpp::locate("").has_value(); located_.reset(); client_ = mcpp::Client{}; jobs_->set_client(client_); return; } auto protocol = mcpp::handshake(located->program); + mcppMissing_ = false; if (!protocol) { mcppError_ = protocol.error(); located_.reset(); @@ -507,6 +512,17 @@ std::expected Session::build_database(const std::filesystem::p return r->envelope; } +std::expected Session::mcpp_availability() { + bool retry; + { + std::lock_guard lock(mutex_); + retry = !client_.ready(); + } + if (retry) locate_mcpp(); + std::lock_guard lock(mutex_); + return Json{{"available", client_.ready()}, {"missing", mcppMissing_}}; +} + Json Session::self_check() { Json mcppPart = nullptr; std::string error; @@ -1696,6 +1712,7 @@ void Session::install() { }); s.on("mcpp-ide/project/summary", [this](const Json& p, const rpc::Context&) { return project_summary(p); }); s.on("mcpp-ide/mcpp/exec", [this](const Json& p, const rpc::Context&) { return mcpp_exec(p); }); + s.on("mcpp-ide/mcpp/availability", [this](const Json&, const rpc::Context&) { return mcpp_availability(); }); s.on("mcpp-ide/languageService/exec", [this](const Json& p, const rpc::Context&) { return language_service_exec(p); }); s.on("mcpp-ide/project/files", [](const Json& p, const rpc::Context&) -> Result { return projects::files(root_of(p)); }); s.on("mcpp-ide/project/new", [this](const Json& p, const rpc::Context&) { return project_new(p); }); diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 54c0abb..c0c8d7b 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -296,6 +296,8 @@ int main() { // What mcpp-vscode's own parsers read: one command's output as it printed it, and a project's // files with its source count. auto exec = result_of("mcpp-ide/mcpp/exec", Json{{"args", Json::array({"--version"})}, {"cwd", root.generic_string()}}); + auto availability = result_of("mcpp-ide/mcpp/availability", Json::object()); + CHECK(availability["available"] == true && availability["missing"] == false); CHECK(exec["exitCode"] == 0 && exec["stdout"].get().find("mcpp") != std::string::npos); CHECK(exec["timedOut"] == false && exec["truncated"] == false); auto bounded = result_of("mcpp-ide/mcpp/exec", Json{{"args", Json::array({"--version"})}, {"maxOutputBytes", 3}}); From d493f53bd25adbccaabbadd3b65488f76c5aca2c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Fri, 6 Nov 2026 16:45:37 +0900 Subject: [PATCH 18/18] fix(vscode): keep missing-tool notice out of autosave builds --- mide-plugins/vscode/src/cli/controller.ts | 6 ++++-- mide-plugins/vscode/src/extension.ts | 7 ++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/mide-plugins/vscode/src/cli/controller.ts b/mide-plugins/vscode/src/cli/controller.ts index 2edf15b..83f6b6f 100644 --- a/mide-plugins/vscode/src/cli/controller.ts +++ b/mide-plugins/vscode/src/cli/controller.ts @@ -53,7 +53,7 @@ export interface McppCliControllerOptions { ) => Promise; isTrusted: () => boolean; /** Called only for commands that need the local build tool. */ - ensureMcpp: () => Promise; + ensureMcpp: (notify?: boolean) => Promise; /** * `mcpp.ui.statusBar.showLanguageServer`: one short line describing the C++ * Modules state, or `undefined` when there is nothing to show. A callback @@ -82,6 +82,8 @@ const QUICK_MENU_ICON_DIRECTORY = "media/quick-menu"; export interface ProjectTaskRunOptions { /** Set to false when the caller performs its own post-task action. */ notify?: boolean; + /** Autosave builds check availability without showing an unsolicited notice. */ + promptMissingMcpp?: boolean; /** The arguments after the subcommand, for a pack the caller assembled. */ args?: readonly string[]; } @@ -335,7 +337,7 @@ export class McppCliController { if (project === undefined || !this.requireTrusted()) { return undefined; } - if (!(await this.options.ensureMcpp())) return undefined; + if (!(await this.options.ensureMcpp(options.promptMissingMcpp !== false))) return undefined; // `mcpp.task.confirmClean`: one prompt for this entry point; the cleanup // plan's own level-2/3 confirmations are untouched by it. diff --git a/mide-plugins/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts index 5b8855f..c207742 100644 --- a/mide-plugins/vscode/src/extension.ts +++ b/mide-plugins/vscode/src/extension.ts @@ -190,7 +190,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi // background below, and nothing waits for it. const session = new Session(extensionContext, output); let missingMcppNoticeShown = false; - const ensureMcppForAction = async (): Promise => { + const ensureMcppForAction = async (notify = true): Promise => { if (!vscode.workspace.isTrusted) return true; let state: { available: boolean; missing: boolean }; try { @@ -199,7 +199,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return true; // Existing backend errors describe a backend that cannot answer. } if (!state.missing) return true; - if (!missingMcppNoticeShown) { + if (notify && !missingMcppNoticeShown) { missingMcppNoticeShown = true; const repository = "https://github.com/mcpp-community/mcpp"; const open = t("Open the repository"); @@ -380,7 +380,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (!read("mcpp.build.onSave", document.uri) || cliController.isBusy()) return; if (!/\.(cppm|cpp|cc|cxx|ixx|mpp|h|hpp|toml|mcpp)$/.test(document.fileName)) return; const project = findCurrentProject(); - if (project && document.fileName.startsWith(project.root)) await cliController.runProjectTask("build", { notify: false }); + if (project && document.fileName.startsWith(project.root)) + await cliController.runProjectTask("build", { notify: false, promptMissingMcpp: false }); }), ); function updateJobRunning(): Thenable {