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..d8bd0bf --- /dev/null +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -0,0 +1,141 @@ +# CLion backend location and stable runtime validation + +Status: fixes and the initial stable runtime suite pass canonical CI; expanded debugger CI 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 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 + +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 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 +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. +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. 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. +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 +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 | 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 | +| 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 | +| 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 | 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 | + +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/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..304f5c7 --- /dev/null +++ b/.github/workflows/clion-stable.yml @@ -0,0 +1,98 @@ +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 + +env: + XLINGS_NON_INTERACTIVE: '1' + +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 + - 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 + 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 + ${{ runner.temp }}/clion-stable-result/checks.json + if-no-files-found: warn diff --git a/.gitignore b/.gitignore index 708cb65..b6c05ef 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..0f48cbb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,15 @@ 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. 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 `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..129bbf6 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -42,3 +42,27 @@ 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 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, +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: + +```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..8287cb5 --- /dev/null +++ b/mide-plugins/clion/integration-tests/build.gradle.kts @@ -0,0 +1,39 @@ +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("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") + 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..d3742d3 --- /dev/null +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -0,0 +1,459 @@ +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.XDebuggerUtil +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 +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 +import kotlin.time.Duration.Companion.minutes + +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 + 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 } +@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? + fun getExecutionConsole(): ExecutionConsoleRemote +} +@Remote("com.intellij.execution.process.ProcessHandler") +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.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") +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: + // 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) + Files.deleteIfExists(output.resolve("result.json")) + 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 + 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) + 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(runTimeout = 3.minutes).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()) + 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() + 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") + } + } + record("offered-actions-and-forward-targets") + 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") + record("visible-three-view-tool-window") + val runs = utility().getInstance(singleProject()) + val actionResults = mutableMapOf() + 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) { + completed = runs.getAllDescriptors().firstOrNull { + it.getDisplayName().startsWith("mcpp $command ") && + 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") + 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(finished.getExecutionConsole(), TestConsoleRemote::class).getResultsViewer().getTestsRootNode() + assertFalse(testRoot.getChildren().isEmpty(), "the native test tree contains discovered tests") + 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 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") + 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") + 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") + 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) + 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") + } + 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()) + 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,"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 e820d75..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 @@ -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,10 @@ 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 + (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/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/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/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 } } 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() 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) }) + } + +}