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..d15a97f --- /dev/null +++ b/.agents/docs/2026-11-05-clion-backend-location-and-stable-runtime.md @@ -0,0 +1,235 @@ +# CLion backend location and stable runtime validation + +Status: review branch; canonical and diagnostic evidence are distinguished below. + +## 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 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. 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 +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. 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 +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 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 +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 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 | +| 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. + +## 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. + +## 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/.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/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/.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/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 12a4706..4a34844 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 @@ -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 @@ -471,6 +482,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..e71f4e6 --- /dev/null +++ b/.github/workflows/clion-stable.yml @@ -0,0 +1,135 @@ +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" + # 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 + if: always() + with: + name: clion-stable-${{ matrix.version }} + 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 + ${{ 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/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml new file mode 100644 index 0000000..3d79a1d --- /dev/null +++ b/.github/workflows/publish-jetbrains.yml @@ -0,0 +1,69 @@ +name: Publish JetBrains + +on: + 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 }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + TAG: ${{ inputs.tag }} + 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 }}-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/.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/.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" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b464a..b0f3b46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # 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. +- 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. +- 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. +- 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 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/README.md b/README.md index 4fff171..f7652a6 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,13 @@ 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. + +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 @@ -62,9 +69,12 @@ 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). +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 @@ -97,6 +107,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 diff --git a/README.zh-CN.md b/README.zh-CN.md index aa67629..4be14c7 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,6 +31,11 @@ quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。C++ 文 | 目标(含目标集合)、打包 | 快速选择 | 弹出列表 | `vim.ui.select` | | 语言功能 | mcppls(随扩展一并安装) | mcppls CLion 插件(依赖) | mcppls-nvim(`xlings install mcppls-nvim`) | +CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限、是否处于允许范围内。 +无效输入会显示错误,并保持所有已存储的值不变。输入某项设置的默认值会移除其存储覆盖。 + +图查询失败时显示构建数据库错误。修正所报告的问题后,重新打开图即可重试。 + - **处处同一答案**——构建运行什么、测试如何报告、清理的代价:都由后端决定 - **只读 mcpp 的契约**——不自行解析清单,不自行解析依赖 - **任务有序**——每个工程同一时间一个任务,新构建替换等待中的构建,取消会正常结束 @@ -56,9 +61,11 @@ quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。C++ 文 | 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/docs/architecture.md b/docs/architecture.md index d74ae17..ecbc3f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,23 @@ 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. + +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.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 98ad6f4..a305707 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"] @@ -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" diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index f44542b..854503e 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 @@ -21,6 +21,38 @@ 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. + +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 @@ -42,3 +74,38 @@ 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 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 +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, 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. +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.1.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/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/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/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..be3797e --- /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", "backendPath")) { + 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/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/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..5cf66b4 --- /dev/null +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -0,0 +1,568 @@ +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 java.nio.file.StandardOpenOption +import kotlin.time.Duration.Companion.minutes + +private const val PLUGIN_ID = "io.github.mcpp-community.mcpp-clion" + +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-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 "{\"classification\":\"$classification\",\"types\":${array(types)},\"frames\":${array(frames)}}" +} + +@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("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 +@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. + // Only the independently reproduced stock 262 post-stop debugger + // cancellation is classified after every functional assertion passes. + 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) { + 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, + 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") + } + } + } + } + } + + @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") + 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")) + Files.deleteIfExists(output.resolve("ide-errors.ndjson")) + 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") + } + 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") + 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() + 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) + 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!!) } + 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") + 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/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/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index e820d75..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 @@ -20,10 +22,14 @@ 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 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 { @@ -50,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 -> @@ -146,12 +153,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 +188,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 +200,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() } } @@ -245,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. @@ -330,9 +369,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/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/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..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,26 @@ 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 = - CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) + override fun start(xs: XDebugSession): XDebugProcess { + 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 + } }).environment(env).startSession().runContentDescriptor } } 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..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) @@ -201,7 +204,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 +212,14 @@ 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.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") } - return session.request("run/launch", params).thenApply { it.asJsonObject } } internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { 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/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) }) + } + +} 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/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/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/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/mide-plugins/vscode/src/cli/controller.ts b/mide-plugins/vscode/src/cli/controller.ts index ef3e991..83f6b6f 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: (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 @@ -80,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[]; } @@ -333,6 +337,7 @@ export class McppCliController { if (project === undefined || !this.requireTrusted()) { 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. @@ -402,6 +407,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 +433,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 +527,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 +790,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..c207742 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 (notify = true): 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 (notify && !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)), @@ -360,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 { 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/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"); }); 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..3478b60 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" }, @@ -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": { @@ -512,7 +517,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 d824f34..e06e0ec 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": "包", @@ -645,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 当前目录工具链", @@ -714,7 +717,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 850ce7e..ce7584a 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -33,14 +33,14 @@ 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 // 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(); @@ -492,6 +497,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_); @@ -499,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; @@ -711,7 +735,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", ""))) @@ -735,15 +767,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())); @@ -787,7 +839,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}}; } @@ -1660,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 d62c4c2..c0c8d7b 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()); @@ -193,9 +212,92 @@ 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()}}); + 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}});