diff --git a/.github/components.tsv b/.github/components.tsv new file mode 100644 index 0000000..18d1e99 --- /dev/null +++ b/.github/components.tsv @@ -0,0 +1,29 @@ +# The components of this repository, by path (README "Versions"; .github/scripts/changes.sh). One +# `\t` line each; a pattern is a shell `case` glob over the path from the +# repository's root, in which `*` crosses `/`. The first line that matches a path names its component; +# a path no line matches, and the component `all`, run every job. +# +# `@core `: the component whose change reaches every other (each plugin is tested over it). +@core backend +# backend: the program every editor package carries, its protocol, the editors' declarations spec/ +# generates, the graph page, and their tests. +backend src/* +backend protocol/* +backend spec/* +backend webui/* +backend testsupport/* +backend tests/* +backend mcpp.toml +backend mcpp.lock +backend build.mcpp +# The editor plugins, each tested over the backend of the change it is made against. +vscode mide-plugins/vscode/* +clion mide-plugins/clion/* +nvim mide-plugins/nvim/* +# What only people read. +docs docs/* +docs agents/* +docs *.md +docs LICENSE +# The workflows, the scripts and the pins every job runs with. +all .github/* diff --git a/.github/scripts/changes.sh b/.github/scripts/changes.sh new file mode 100755 index 0000000..2deaa0b --- /dev/null +++ b/.github/scripts/changes.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# The components a change reaches, from .github/components.tsv (README "Versions"). +# +# changes.sh prints `=true|false`, `full=`, each flag, and `digest=` +# +# EVENT, BASE_REF and HEAD_REF (the workflow's event and a pull request's branches) decide a full run: +# anything but a pull request, and a pull request from or into a release branch, reaches every component. +# Otherwise each path reaches the component of the first line of the table that matches it; a path no +# line matches, or one of `all`, reaches every component; and a change of the `@core` component reaches +# every other, since each is tested over it. A `@flag` is a pull request's question: true when a changed +# path matches it, or when the pull request is from or into a release branch; false for any other event. +# +# `digest` names the core's inputs (the blobs of every path of the core and of `all`), so a run that built +# the core from the same inputs can lend what it built to a run that did not change them. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +table="$root/.github/components.tsv" +changed="${1:?the file of changed paths}" + +rows=(); flags=(); core=""; components=() +while IFS= read -r line; do + case "$line" in ''|'#'*) continue ;; esac + case "$line" in + @core$'\t'*) core="${line#*$'\t'}" ;; + @flag\ *) flags+=("${line#@flag }") ;; + *) rows+=("$line") + name="${line%%$'\t'*}" + [ "$name" = all ] || [[ " ${components[*]} " == *" $name "* ]] || components+=("$name") ;; + esac +done < "$table" + +declare -A reached=() flagged=() printed=() +full=false +release=false +case "${EVENT:-}" in pull_request) ;; *) full=true ;; esac +case "${BASE_REF:-}" in release/*) full=true; release=true ;; esac +case "${HEAD_REF:-}" in release/*) full=true; release=true ;; esac + +while IFS= read -r path; do + [ -n "$path" ] || continue + component="" + for row in "${rows[@]}"; do + name="${row%%$'\t'*}"; pattern="${row#*$'\t'}" + # shellcheck disable=SC2254 + case "$path" in $pattern) component="$name"; break ;; esac + done + if [ -z "$component" ] || [ "$component" = all ]; then + echo "every component: $path" >&2 + full=true + else + reached[$component]=true + fi + for flag in "${flags[@]}"; do + name="${flag%%$'\t'*}"; pattern="${flag#*$'\t'}" + # shellcheck disable=SC2254 + case "$path" in $pattern) flagged[$name]=true ;; esac + done +done < "$changed" + +if [ "$full" = true ] || { [ -n "$core" ] && [ "${reached[$core]:-}" = true ]; }; then + for c in "${components[@]}"; do reached[$c]=true; done +fi +for c in "${components[@]}"; do echo "$c=${reached[$c]:-false}"; done +echo "full=$full" +for flag in "${flags[@]}"; do + name="${flag%%$'\t'*}" + [ "$release" = true ] && flagged[$name]=true + [ "${EVENT:-}" = pull_request ] || flagged[$name]=false + [ -n "${printed[$name]:-}" ] && continue + printed[$name]=1 + echo "$name=${flagged[$name]:-false}" +done + +# The core's inputs: every tracked path the table gives to the core or to `all`. +mapfile -t inputs < <(for row in "${rows[@]}"; do + name="${row%%$'\t'*}"; pattern="${row#*$'\t'}" + if [ "$name" = "$core" ] || [ "$name" = all ]; then echo "${pattern%%\**}"; fi +done | sort -u) +digest=$(cd "$root" && git ls-files -s -- "${inputs[@]}" | sha256sum | cut -c1-24) +echo "digest=$digest" diff --git a/.github/scripts/release-plan.sh b/.github/scripts/release-plan.sh new file mode 100755 index 0000000..83318ce --- /dev/null +++ b/.github/scripts/release-plan.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# release-plan.sh [TAG] -- the components a release tag publishes, and its version (design 2026-11-07, D15). +# +# A version is X.Y.Z: X.Y, the series, is shared by the backend and every editor plugin; Z is each +# component's own patch. Two kinds of tag: +# +# vX.Y.0 a series release: every component, each at X.Y.0 +# -vX.Y.Z one component's patch: backend, vscode, clion or nvim, at X.Y.Z +# +# Prints `components=...`, `version=...` and `title=...` for $GITHUB_OUTPUT. Without a tag it only +# checks that every component states one series and the backend's two statements agree (CI). +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +declare -A version +version[backend]="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$root/mcpp.toml" | head -1)" +version[vscode]="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' "$root/mide-plugins/vscode/package.json" | head -1)" +version[clion]="$(sed -n 's/^pluginVersion *= *\(.*\)$/\1/p' "$root/mide-plugins/clion/gradle.properties" | head -1)" +version[nvim]="$(sed -n "s/^M.version = '\(.*\)'$/\1/p" "$root/mide-plugins/nvim/lua/mcpp/session.lua" | head -1)" +backend_source="$(sed -n 's/^inline constexpr std::string_view kVersion = "\(.*\)";$/\1/p' "$root/src/server.cppm" | head -1)" +series_source="$(sed -n 's/^inline constexpr std::string_view kSeries = "\(.*\)";$/\1/p' "$root/src/server.cppm" | head -1)" +fail() { echo "release-plan: $*" >&2; exit 1; } +series() { [[ "$1" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+$ ]] && echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"; } + +for c in backend vscode clion nvim; do + [ -n "${version[$c]}" ] && [ -n "$(series "${version[$c]}")" ] || fail "$c states no X.Y.Z version (${version[$c]:-none})" +done +[ "$backend_source" = "${version[backend]}" ] || fail "src/server.cppm says $backend_source and mcpp.toml ${version[backend]}" +the_series="$(series "${version[backend]}")" +[ "$series_source" = "$the_series" ] || fail "src/server.cppm's kSeries is $series_source, the backend's series $the_series" +for c in vscode clion nvim; do + [ "$(series "${version[$c]}")" = "$the_series" ] \ + || fail "$c is ${version[$c]}, of another series than the backend's ${version[backend]}" +done + +tag="${1:-}" +if [ -z "$tag" ]; then + echo "one series $the_series: backend ${version[backend]}, vscode ${version[vscode]}, clion ${version[clion]}, nvim ${version[nvim]}" + exit 0 +fi +if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.0)$ ]]; then + v="${BASH_REMATCH[1]}" + for c in backend vscode clion nvim; do + [ "${version[$c]}" = "$v" ] || fail "the series release $tag needs every component at $v; $c is ${version[$c]}" + done + echo "components=backend vscode clion nvim" + echo "version=$v" + echo "title=mcpp-ide $v" +elif [[ "$tag" =~ ^(backend|vscode|clion|nvim)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + c="${BASH_REMATCH[1]}"; v="${BASH_REMATCH[2]}" + [ "${version[$c]}" = "$v" ] || fail "the tag $tag names $c $v, and $c is ${version[$c]}" + declare -A names=([backend]=mcpp-ide [vscode]=mcpp-vscode [clion]=mcpp-clion [nvim]=mcpp-nvim) + echo "components=$c" + echo "version=$v" + echo "title=${names[$c]} $v" +else + fail "$tag is neither vX.Y.0 nor -vX.Y.Z" +fi diff --git a/.github/scripts/test_changes.sh b/.github/scripts/test_changes.sh new file mode 100755 index 0000000..6213e91 --- /dev/null +++ b/.github/scripts/test_changes.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# The classification of .github/scripts/changes.sh, on lists of paths. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +failures=0 +check() { # check -- + local what="$1" event="$2"; shift 2 + local want=() + while [ "$1" != -- ]; do want+=("$1"); shift; done + shift + local list; list=$(mktemp) + printf '%s\n' "$@" > "$list" + local base=main + [ "$event" = pull_request_release ] && { event=pull_request; base=release/1.2.0; } + local got; got=$(EVENT="$event" BASE_REF="$base" HEAD_REF=topic bash "$here/changes.sh" "$list" 2>/dev/null) + rm -f "$list" + for kv in "${want[@]}"; do + if ! grep -qx "$kv" <<<"$got"; then + echo "FAIL: $what: wanted $kv, got: $(tr '\n' ' ' <<<"$got")" + failures=$((failures + 1)) + fi + done +} +check "a plugin alone" pull_request backend=false clion=true vscode=false nvim=false full=false -- mide-plugins/clion/src/main/kotlin/X.kt +check "its README is the plugin's" pull_request vscode=true docs=false backend=false -- mide-plugins/vscode/README.md +check "documentation alone" pull_request docs=true backend=false vscode=false -- docs/x.md README.md +check "the backend reaches every plugin" pull_request backend=true vscode=true clion=true nvim=true -- src/main.cpp +check "spec/ is the backend's" pull_request backend=true vscode=true -- spec/test-frameworks.json +check "a workflow reaches everything" pull_request full=true backend=true -- .github/workflows/ci.yml +check "a path of no component reaches everything" pull_request full=true -- newdir/x +check "a push reaches everything" push full=true backend=true -- docs/x.md +check "a release branch reaches everything" pull_request_release full=true -- docs/x.md +list=$(mktemp); echo docs/x.md > "$list" +d=$(EVENT=pull_request bash "$here/changes.sh" "$list" 2>/dev/null | sed -n 's/^digest=//p'); rm -f "$list" +[ "${#d}" -eq 24 ] || { echo "FAIL: no digest"; failures=$((failures + 1)); } +[ "$failures" -eq 0 ] && echo "test_changes: every case held" +exit "$failures" diff --git a/.github/versions.env b/.github/versions.env index b2cf440..d956dab 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,12 +1,12 @@ # The versions CI builds and tests with, in one file; .github/actions/setup-mcpp exports them. # The published bootstrap; the project explicitly selects platform 0.24. -MCPP_VERSION=2026.11.5.1 +MCPP_VERSION=2026.11.7.2 # 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.5.1 bundles, which reads the current index. +# The xlings mcpp 2026.11.7.2 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 4a34844..c9b5a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,19 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + native: + description: "Also build and test the backend natively on every system (it is distributed only as an ok image)" + type: boolean + required: false + default: false workflow_call: + inputs: + native: + description: "Also build and test the backend natively on every system" + type: boolean + required: false + default: false concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -16,6 +28,81 @@ env: NODE_VERSION: 22 jobs: + # WHAT THE CHANGE REACHES (README "Versions"; .github/components.tsv). A pull request runs the jobs of the + # components it changes: the backend's change runs everything, a plugin's runs that plugin's jobs over the + # backend main built from the same inputs (`lend`, a run whose `backend-digest-` marker is kept), the + # documentation's its checks. A push, a dispatch, a release and a pull request from or into a release branch + # run everything. When no main run lends the backend, it is built as well. + changes: + name: what the change reaches + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + backend: ${{ steps.classify.outputs.backend }} + vscode: ${{ steps.classify.outputs.vscode }} + clion: ${{ steps.classify.outputs.clion }} + nvim: ${{ steps.classify.outputs.nvim }} + digest: ${{ steps.classify.outputs.digest }} + lend: ${{ steps.classify.outputs.lend }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 2 + - name: The classification's own cases + run: bash .github/scripts/test_changes.sh + - id: classify + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + changed="$RUNNER_TEMP/changed.txt" + if [ "$EVENT" = pull_request ]; then git diff --name-only HEAD^1 HEAD > "$changed"; else : > "$changed"; fi + bash .github/scripts/changes.sh "$changed" > "$RUNNER_TEMP/reached.txt" + cat "$RUNNER_TEMP/reached.txt" + get() { sed -n "s/^$1=//p" "$RUNNER_TEMP/reached.txt"; } + lend="" + if [ "$(get backend)" = false ] && { [ "$(get vscode)" = true ] || [ "$(get clion)" = true ] || [ "$(get nvim)" = true ]; }; then + lend=$(gh api "repos/${{ github.repository }}/actions/artifacts?name=backend-digest-$(get digest)&per_page=20" \ + --jq '[.artifacts[] | select(.expired == false and .workflow_run.head_branch == "main")][0].workflow_run.id // ""') + if [ -z "$lend" ]; then + echo "no main run lends the backend of these inputs: it is built as well" + sed -i 's/^\(backend\|vscode\|clion\|nvim\)=.*/\1=true/' "$RUNNER_TEMP/reached.txt" + else + echo "the backend of run $lend, built from the same inputs" + fi + fi + cat "$RUNNER_TEMP/reached.txt" >> "$GITHUB_OUTPUT" + echo "lend=$lend" >> "$GITHUB_OUTPUT" + + # The repository's source and translation checks when the backend is not built (the backend job runs them). + checks: + name: sources and translations + needs: changes + if: needs.changes.outputs.backend != 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + - run: | + set -euo pipefail + mcpp run mcpp-ide-dev -- check-i18n + mcpp run mcpp-ide-dev -- check-sources + + # Every component one series (design 2026-11-07, D15): the backend's two statements agree, and the + # VS Code, CLion and Neovim plugins share the backend's X.Y. + versions: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - run: bash .github/scripts/release-plan.sh + # The backend on each system the editors run on: build every program (the backend, the mock mcpp, # the dev tool), run the tests -- the protocol against its schema and the fixtures against mcpp's # contract among them -- and check the translations. The `pack` leg then packs the backend as the @@ -23,20 +110,36 @@ jobs: # universal self-contained package (both images; okrun for Linux, macOS and Windows, each built # over the platform's hosted arrangement) whose okrun the packages take. The `host-platform` row # builds and tests with the build programs (build.mcpp, the spec/ generators) as ok images run by - # okrun. + # okrun. The native rows of the other systems run only with `native`: what is distributed is the image. + # The native rows of the backend job: Linux x64, which packs the image and tests with the host graph's + # platform, and the other systems only in a run with `native` -- the backend is distributed as the ok + # image, which `backend-openkal` tests on every system (design 2026-11-07, D18). + matrix: + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ubuntu-24.04 + outputs: + backend: ${{ steps.pick.outputs.backend }} + steps: + - id: pick + env: + NATIVE: ${{ inputs.native && 'yes' || 'no' }} + run: | + rows='{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","pack":true},{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","host-platform":"openkal-platform@0.24.4"}' + if [ "$NATIVE" = yes ]; then + rows="$rows"',{"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"}' + fi + echo "backend=[$rows]" | tee -a "$GITHUB_OUTPUT" + backend: name: backend (${{ matrix.platform }}${{ matrix.host-platform && format(', host platform {0}', matrix.host-platform) || '' }}) + needs: matrix runs-on: ${{ matrix.os }} timeout-minutes: 75 strategy: fail-fast: false matrix: - include: - - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', pack: true } - - { 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.24.0' } + include: ${{ fromJSON(needs.matrix.outputs.backend) }} defaults: run: shell: bash @@ -54,6 +157,8 @@ jobs: run: mcpp test - name: Every string has its translation run: mcpp run mcpp-ide-dev -- check-i18n + - name: No header, no preprocessor branch, no compile-time platform question + run: mcpp run mcpp-ide-dev -- check-sources - name: The backend as the editor packages carry it if: matrix.pack run: | @@ -84,6 +189,36 @@ jobs: name: backend path: backend + # The backend as users run it, on each system the editors run on: its tests built as ok images of the + # system's architecture and run through the platform's runner. The mock mcpp, the mock mcppls and the dev + # tool they start are test tools, built natively first. + backend-openkal: + name: backend (${{ matrix.platform }}, openkal) + needs: changes + if: needs.changes.outputs.backend == 'true' + runs-on: ${{ matrix.os }} + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, platform: linux-x64, arch: x86_64, llvm: 'false' } + - { os: ubuntu-24.04-arm, platform: linux-arm64, arch: aarch64, llvm: 'false' } + - { os: macos-15, platform: darwin-arm64, arch: aarch64, llvm: 'true' } + - { os: windows-2022, platform: win32-x64, arch: x86_64, llvm: 'true' } + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + with: + llvm: ${{ matrix.llvm }} + - name: The test tools + run: mcpp build + - name: The tests, as ok images, through the runner + run: mcpp test --profile release --target ${{ matrix.arch }}-openkal + # The VS Code extension (mide-plugins/vscode), mcpp-vscode 0.9.0 over the backend. Its gates # (the registry, the translations, the unit suite) on the three desktop systems; the manifest # generated from spec/ with no drift, and the snapshots generated from mcpp's source; the parity @@ -92,6 +227,8 @@ jobs: # install that resolves the language service; the graph page in Chromium. vscode-gates: name: VS Code gates (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.vscode == 'true' strategy: fail-fast: false matrix: @@ -114,6 +251,8 @@ jobs: vscode-drift: name: VS Code manifest and settings documentation + needs: changes + if: needs.changes.outputs.vscode == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: @@ -141,7 +280,11 @@ jobs: vscode: name: VS Code extension - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} + permissions: + contents: read + actions: read runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: @@ -159,6 +302,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: Build the backend and its test programs run: | chmod +x backend/okrun/*/okrun* @@ -252,7 +397,11 @@ jobs: # exactly what differs between them. vscode-e2e: name: VS Code Extension Host (${{ matrix.os }}) - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} + permissions: + contents: read + actions: read strategy: fail-fast: false matrix: @@ -274,6 +423,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - run: npm ci - run: npm run compile - name: The backend the package carries @@ -361,6 +512,8 @@ jobs: vscode-graph-browser: name: VS Code graph page in Chromium + needs: changes + if: needs.changes.outputs.vscode == 'true' runs-on: ubuntu-latest timeout-minutes: 30 defaults: @@ -391,7 +544,11 @@ jobs: # CLion it claims (2026.1) and the one it is built on. clion: name: CLion plugin - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.clion == 'true' }} + permissions: + contents: read + actions: read runs-on: ubuntu-24.04 timeout-minutes: 90 defaults: @@ -418,6 +575,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: Build the backend and its test programs run: | chmod +x backend/okrun/*/okrun* @@ -496,6 +655,8 @@ jobs: # installed and selected with `xlings use`, as mcppls's CI provisions it. nvim: name: Neovim plugin + needs: changes + if: needs.changes.outputs.nvim == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 env: @@ -543,7 +704,11 @@ jobs: # universal package started by its launcher. packages: name: packages on ${{ matrix.os }} - needs: [vscode, clion] + needs: [changes, vscode, clion] + if: ${{ !cancelled() && !failure() && (needs.changes.outputs.vscode == 'true' || needs.changes.outputs.clion == 'true') }} + permissions: + contents: read + actions: read runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: @@ -564,9 +729,13 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: The VS Code extension + if: needs.changes.outputs.vscode == 'true' run: .github/scripts/check-package.sh packages/mcpp-vscode-*.vsix extension - name: The CLion plugin + if: needs.changes.outputs.clion == 'true' run: .github/scripts/check-package.sh packages/mcpp-clion-*.zip mcpp-clion - name: The backend's universal package run: | @@ -576,3 +745,38 @@ jobs: root="$(echo "$dir"/mcpp-ide-*-openkal*)" "$root/bin/mcpp-ide" --version | tee said.txt grep -q '^mcpp-ide ' said.txt + + # Main lends what it built: a marker named after the backend's inputs, which a pull request that changes + # only a plugin finds (`changes`), and whose run's backend it then tests over. + backend-digest: + name: lend the backend of these inputs + needs: [changes, backend] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.backend == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: echo '${{ needs.changes.outputs.digest }}' > "$RUNNER_TEMP/digest.txt" + - uses: actions/upload-artifact@v5 + with: + name: backend-digest-${{ needs.changes.outputs.digest }} + path: ${{ runner.temp }}/digest.txt + retention-days: 90 + + # THE ONE CHECK A BRANCH REQUIRES. The jobs a change does not reach are skipped, so none of them can be + # required; this one is, and it fails when any job that ran failed or was cancelled. + ci: + name: ci + if: always() + needs: [changes, checks, versions, matrix, backend, backend-openkal, vscode-gates, vscode-drift, vscode, vscode-e2e, + vscode-isolated-install, vscode-graph-browser, clion, clion-stable, nvim, packages] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + set -euo pipefail + echo "$RESULTS" + if echo "$RESULTS" | grep -Eq '"(failure|cancelled)"'; then + echo "::error::a job this change reaches failed or was cancelled"; exit 1 + fi diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index e71f4e6..8158610 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -132,4 +132,5 @@ jobs: ${{ 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 + ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/no-jcef-window.txt if-no-files-found: warn diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index 3d79a1d..ce5ed47 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag: - description: Published GitHub release tag, e.g. v0.2.1 + description: Published GitHub release tag, vX.Y.0 or clion-vX.Y.Z required: true workflow_call: inputs: @@ -38,8 +38,9 @@ jobs: 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' + # A series release (vX.Y.0) or the CLion plugin's patch (clion-vX.Y.Z), design 2026-11-07, D15. + match = re.fullmatch(r'(?:clion-)?v(\d+\.\d+\.\d+)', os.environ['TAG']) + assert match, 'Expected vX.Y.0 or clion-vX.Y.Z' with Path(os.environ['GITHUB_ENV']).open('a') as out: out.write('VERSION=' + match[1] + '\n') PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b92bee..1ef98a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,29 +1,56 @@ 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. Open VSX and -# JetBrains consume the exact published archives independently after that release exists. +# every system's okrun, and both editor packages carrying the bundle), then a GitHub release of what +# the tag publishes, with its checksums. Open VSX and JetBrains consume the exact published archives +# independently after that release exists. +# +# WHAT A TAG PUBLISHES (design 2026-11-07, D15; .github/scripts/release-plan.sh): a version is X.Y.Z, +# X.Y the series every component shares and Z each component's own patch. +# +# vX.Y.0 a series release: the backend, the VS Code extension, the CLion plugin and +# the Neovim plugin (a source tree, which xim takes from the tag's archive) +# backend-vX.Y.Z the backend's ok-image bundle and package (xim's mcpp-ide) +# vscode-vX.Y.Z the VSIX (Open VSX) +# clion-vX.Y.Z the CLion ZIP (JetBrains Marketplace) +# nvim-vX.Y.Z the Neovim plugin: the tag's source archive (xim's mcpp-ide-nvim) +# +# The tag's version must be the component's own, and every component one series. on: workflow_dispatch: push: - tags: ['v*'] + tags: ['v*', 'backend-v*', 'vscode-v*', 'clion-v*', 'nvim-v*'] permissions: contents: read jobs: + plan: + runs-on: ubuntu-24.04 + outputs: + components: ${{ steps.plan.outputs.components }} + version: ${{ steps.plan.outputs.version }} + title: ${{ steps.plan.outputs.title }} + steps: + - uses: actions/checkout@v5 + - id: plan + shell: bash # -eo pipefail: a refused tag fails the step through tee + run: bash .github/scripts/release-plan.sh "$GITHUB_REF_NAME" | tee -a "$GITHUB_OUTPUT" + ci: + needs: plan uses: ./.github/workflows/ci.yml release: - needs: ci + needs: [plan, ci] runs-on: ubuntu-24.04 permissions: contents: write - outputs: - version: ${{ steps.publish.outputs.version }} + env: + COMPONENTS: ${{ needs.plan.outputs.components }} + VERSION: ${{ needs.plan.outputs.version }} + TITLE: ${{ needs.plan.outputs.title }} defaults: run: shell: bash @@ -38,20 +65,33 @@ jobs: 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/ - cp artifacts/backend/mcpp-ide.okb "dist/mcpp-ide-${version}.okb" - 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 - echo "version=$version" >> "$GITHUB_OUTPUT" + backend="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" + for c in $COMPONENTS; do + case "$c" in + backend) + cp artifacts/backend/mcpp-ide.okb "dist/mcpp-ide-${backend}.okb" + cp "artifacts/backend/mcpp-ide-${backend}"-openkal*.tar.gz dist/ ;; + vscode) cp artifacts/mcpp-vscode-vsix/*.vsix dist/ ;; + clion) cp artifacts/mcpp-clion-zip/*.zip dist/ ;; + nvim) ;; # the tag's source archive is the plugin + esac + done + notes="$RUNNER_TEMP/notes.md" + printf '%s\n\n' "$TITLE" > "$notes"; cat CHANGELOG.md >> "$notes" + if [ -n "$(ls dist)" ]; then + (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS) + gh release create "$GITHUB_REF_NAME" dist/* --target "$GITHUB_SHA" --title "$TITLE" --notes-file "$notes" + else + gh release create "$GITHUB_REF_NAME" --target "$GITHUB_SHA" --title "$TITLE" --notes-file "$notes" + fi openvsx: - needs: release + needs: [plan, release] + if: contains(needs.plan.outputs.components, 'vscode') runs-on: ubuntu-24.04 env: - VERSION: ${{ needs.release.outputs.version }} + VERSION: ${{ needs.plan.outputs.version }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -62,7 +102,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh release download "v$VERSION" -p 'mcpp-vscode-*.vsix' -p SHA256SUMS -D published + gh release download "$GITHUB_REF_NAME" -p 'mcpp-vscode-*.vsix' -p SHA256SUMS -D published (cd published && sha256sum -c --ignore-missing SHA256SUMS) - name: Publish to Open VSX env: @@ -80,9 +120,10 @@ jobs: (cd mide-plugins/vscode && npx ovsx publish "$GITHUB_WORKSPACE/$vsix" --pat "$OPENVSX_TOKEN") jetbrains: - needs: release + needs: [plan, release] + if: contains(needs.plan.outputs.components, 'clion') uses: ./.github/workflows/publish-jetbrains.yml with: - tag: v${{ needs.release.outputs.version }} + tag: ${{ github.ref_name }} secrets: JETBRAINS_TOKEN: ${{ secrets.JETBRAINS_TOKEN }} diff --git a/.xlings.json b/.xlings.json index f3a65ae..4c1f3ff 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.11.5.1" + "mcpp": "2026.11.7.2" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f3b46..60a4f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # Changelog +The changelog is kept by series (`X.Y`); each section lists its components' releases. + +## 1.2 + +### 1.2.0 + +The first series of one version for every component: the backend, mcpp-vscode, mcpp-clion and +mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). + +- **One series.** A version is `X.Y.Z`; `X.Y` is shared by the backend and every plugin, `Z` is each + component's own patch. The backend states its series in the initialize answer, and each plugin + checks protocol 2 and its series and refuses another, naming both versions and the install that + matches. A series release is tagged `vX.Y.0`; `backend-`, `vscode-`, `clion-` and `nvim-vX.Y.Z` + publish one component's patch, and CI checks that every component states one series. +- **mcppls payloads by their specification.** The backend reads `payload.json` as mcppls's S6 states + it: version 5 (mcppls 0.0.20 and later) takes this system's entry under `platforms` and starts + ` `; version 4 serves the one `platform` it names. A payload that does not + serve this system is refused, naming the platforms it does serve. 0.2.1 ran a version 5 payload's + `bin/mcppls.okb` itself and failed. CLion makes every runner a payload names executable again. +- **Test results as mcpp states them.** Statuses follow mcpp's test records value for value: + `run_fail` is failed, or timed out when mcpp says so; `compile_fail` is failed and says it did not + compile; `not_run` is skipped with mcpp's reason; `built` is skipped as built only. 0.2.1 mapped + values mcpp never writes, so every failure was "errored" and a timeout was never seen. +- **Cases by the test protocol.** A test program that implements the mtest test protocol (mcpp + 2026.11.8.1, feature `test-protocol`) is listed by `mcpp test --cases` and its cases run by + `mcpp test --case`, whatever framework wrote it; each case's result, duration and first failure's + place come from mcpp's record. A file of mtest's cases shows its `"name"_test` literals, named + with their suites (`parsing/an empty line`), before anything is built. GoogleTest, Catch2 and + doctest without the protocol are listed and run as before (`spec/test-frameworks.json`). +- **Every test's own output.** Each test and each case carries its whole output, passed or not + (head and tail past 1 MiB), and a failure carries the place its output names. A case run divides + the program's output among its cases by GoogleTest's boundaries. VS Code says the output to the + test's own item and shows a failure at its place, else at the test's `main` or the case's macro; + CLion sends it to the test tree, with the place first in a failure's details; Neovim writes + `mcpp://tests`, one section per test and case, and puts the failures in the quickfix list. +- **Run entries with icons.** VS Code's lenses above `main` are `$(play) Run` and `$(debug-alt) + Debug`, and while the active editor is a target's entry the editor title's Run menu runs and + debugs it. Neovim's sign column shows ▶ on a program's and a test's `main` and on each case, and a + result turns it into ✓ or ✗. CLion logs why a file has no run entry. +- **The platform as one release.** The payloads' platform is `openkal-platform@0.24.4`, whose okrun + enters an image in its convention's floating-point state (a decimal number is read on Windows as on + the other systems), and every dependency is one release + named by its identity (`mcpplibs.os = "0.4.0"`); mcpp 2026.11.7.1 or later builds them. +- **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's + own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, + and the build's steps in the status bar (CLion, VS Code while it builds for the debugger, the + Neovim statusline), as for a Build. With mcpp 2026.11.7.2 an unchanged program's launch is + described on mcpp's fast path, 0.01 s where it was 0.1 to 0.3 s, so the program starts at once. + CLion records how long the build and the program's start took in `idea.log`. +- **Windows names reach the backend in one spelling.** The backend runs as an ok image, whose names + are `/`-separated on every system; a project root, a working directory, a parent directory or a + payload an editor names as `C:\Users\x\…` is read through mcpplibs.os into its generic spelling + before anything is built from it, so it is absolute and its parts are components. Found by the + backend's tests running as images on Windows. +- **No header in the backend's sources or tests.** The tests share a module (`mcppide.testsupport`, + in `testsupport/`) where they included `support.hpp`, and a failed check names its place through + `std::source_location`. `mcpp-ide-dev check-sources`, run by CI, reports a header, a preprocessor + branch and a compile-time platform question (`os::is_`, which an ok image answers false) + in the backend, its tests and the test support. +- **The mcpp tool window beside Project.** CLion's mcpp tool window is on the left, where VS Code + shows mcpp, and the first mcpp project opened puts its button on the stripe. +- **Tested as it is run.** The backend is distributed only as an ok image, and CI tests it as one on + every system: its tests are built as ok images of the system's architecture and run through the + platform's runner (`backend-openkal`). The native backend is built and tested on Linux x64, and on + every system in a manual CI run with `native`. + ## 0.2.1 mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. diff --git a/README.md b/README.md index f7652a6..1c759cc 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,20 @@ 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.24.0) and okrun for +(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.24) 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. + +**Versions.** A version is `X.Y.Z`. `X.Y`, the series, is shared by the backend and every plugin: a +plugin works with any backend of its series and refuses another, naming both versions and the +install that matches. `Z` is each component's own patch, so VS Code 1.2.3 and CLion 1.2.1 both work +with backend 1.2.0. A series release is tagged `vX.Y.0`; a component's patch is tagged +`backend-`, `vscode-`, `clion-` or `nvim-vX.Y.Z` and publishes that component alone. A pull request runs the jobs of +the components it changes ([`.github/components.tsv`](.github/components.tsv)): a change of the backend runs +everything, a change of one plugin runs that plugin's jobs over the backend main built from the same inputs, +a change of the documentation its checks; `.github/` and any path the table does not name run everything. +The one required check is `ci`. If `mcpp` is absent, they show one notification linking to the [mcpp repository](https://github.com/mcpp-community/mcpp). @@ -107,9 +117,10 @@ 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 +A release runs the repository CI, publishes the checked archives of what its +tag names (`.github/scripts/release-plan.sh`) and `SHA256SUMS` on GitHub, then +independently submits the same VSIX to Open VSX and the same CLion ZIP to +JetBrains Marketplace when the release carries them. 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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 4be14c7..23e4919 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,12 +61,19 @@ CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限 | Neovim 0.10+ | release 源码树中的 `mide-plugins/nvim` | `xlings install mcpp-ide-nvim` 与 `xlings install mcpp-ide`,再 `require('mcpp').setup()`;或把该目录加入 runtimepath([其 README](mide-plugins/nvim/README.md)) | | Agent、其他客户端 | `mcpp-ide--openkal-bundle-all.tar.gz`(两个架构的映像;Linux x64/arm64、macOS arm64、Windows x64 的 okrun),携带 okrun 的 `mcpp-ide-.okb`,或 `xlings install mcpp-ide` | 运行 `bin/mcpp-ide serve`(Windows 上为 `bin\mcpp-ide.exe`),或 `okrun mcpp-ide-.okb serve` | -每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.24.0 上的 +每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.24 上的 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)链接。 +**版本。** 版本为 `X.Y.Z`。`X.Y` 是系列,由后端与全部插件共享:插件与同系列的任何后端配合,遇到其他系列时拒绝, +并写出双方的版本与对应的安装方法。`Z` 是各组件自己的补丁,所以 VS Code 1.2.3 与 CLion 1.2.1 都与后端 1.2.0 +兼容。系列发布的标签为 `vX.Y.0`;组件补丁的标签为 `backend-`、`vscode-`、`clion-` 或 `nvim-vX.Y.Z`,只发布该组件。 +拉取请求只运行它所改动的组件的作业([`.github/components.tsv`](.github/components.tsv)):后端的改动运行全部作业; +一个插件的改动只运行该插件的作业,所用的后端是 main 以相同输入构建的那一份;文档的改动只运行文档的检查;`.github/` +与表中没有列出的路径运行全部作业。唯一必需的检查是 `ci`。 + ### 从源码构建 ```bash diff --git a/mcpp.lock b/mcpp.lock index b2d2dce..7cced65 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -30,9 +30,9 @@ targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.5.0" -source = "index+mcpplibs@0.5.0" -hash = "fnv1a:e71cc672802c1551" +version = "0.5.3" +source = "index+mcpplibs@0.5.3" +hash = "fnv1a:e71cc372802c1038" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] @@ -44,41 +44,41 @@ hash = "fnv1a:34912a331a4386f5" [package."openkal-llvm-runtime"] namespace = "mcpplibs" -version = "0.19.0" -source = "index+mcpplibs@0.19.0" -hash = "fnv1a:3e60b633793b7850" +version = "0.19.2" +source = "index+mcpplibs@0.19.2" +hash = "fnv1a:3e60b833793b7bb6" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-musl"] namespace = "mcpplibs" -version = "0.28.0" -source = "index+mcpplibs@0.28.0" -hash = "fnv1a:a96808df668801b3" +version = "0.28.1" +source = "index+mcpplibs@0.28.1" +hash = "fnv1a:a96807df66880000" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.24.0" -source = "index+mcpplibs@0.24.0" -hash = "fnv1a:8527602a77391921" +version = "0.24.4" +source = "index+mcpplibs@0.24.4" +hash = "fnv1a:8527642a77391fed" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.5.0" -source = "index+mcpplibs@0.5.0" -hash = "fnv1a:17b68973cd803e4e" +version = "0.5.3" +source = "index+mcpplibs@0.5.3" +hash = "fnv1a:17b68873cd803c9b" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."mcpplibs.os"] namespace = "mcpplibs" -version = "0.3.0" -source = "index+mcpplibs@0.3.0" -hash = "fnv1a:c8fc75a4fc0fce2a" +version = "0.4.0" +source = "index+mcpplibs@0.4.0" +hash = "fnv1a:c06e4aa4f73f1f27" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."nlohmann.json"] diff --git a/mcpp.toml b/mcpp.toml index a305707..f8075dc 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -9,12 +9,12 @@ # M2/M3), where the system it runs upon is answered when the image is loaded. [package] name = "mcpp-ide" -version = "0.2.1" +version = "1.2.0" description = "The IDE backend for mcpp: sessions, jobs, records and view models over mcpp's control contract" license = "Apache-2.0" authors = ["mcpp-community"] repo = "https://github.com/mcpp-community/mcpp-ide" -mcpp = ">=2026.11.3.1" +mcpp = ">=2026.11.7.1" [language] standard = "c++23" @@ -33,11 +33,12 @@ toolchain = "llvm@23.1.3" [target.'cfg(windows)'] toolchain = "llvm@23.1.3" -# 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. +# The platform of the editor payloads, one release (design 2026-11-07, D20): the backend each version +# ships is built over one version group. The platform supplies the image toolchain, hardening, seal +# and runner; 0.24.4 names OpenKal 0.24, musl 0.28.1, runtime 0.19.2 and image 0.5.3, whose okrun +# enters an image in its convention's floating-point state. [target.'cfg(os = "openkal")'] -platform = "openkal-platform@0.24.0" +platform = "openkal-platform@0.24.4" # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] @@ -73,10 +74,13 @@ main = "src/bin/mockmcppls.cpp" kind = "bin" main = "src/bin/dev.cpp" -[dependencies.mcpplibs] -# 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" +# Each dependency is one release, named by its identity (namespace, then name). +[dependencies] +# 0.3.1 reads a name in Windows' long spelling as the name it spells. The image C library, C++ +# runtime and loader remain the platform's. +mcpplibs.os = "0.4.0" +nlohmann.json = "3.12.0" -[dependencies.nlohmann] -json = "3.12.0" +# What every backend test shares (testsupport/): a module, as the backend's units are. +[dev-dependencies] +mcpp-ide-testsupport = { path = "testsupport" } diff --git a/mide-plugins/clion/gradle.properties b/mide-plugins/clion/gradle.properties index 1255026..fc9d16f 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.1 +pluginVersion = 1.2.0 # 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/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 0852360..74070ee 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -5,6 +5,7 @@ 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.waitForIndicators import com.intellij.driver.sdk.ui.ui import com.intellij.driver.sdk.ui.remote.Component import com.intellij.ide.starter.ci.CIServer @@ -124,6 +125,9 @@ class GraphInteractionTest { } context.runIdeWithDriver().useDriverAndCloseIde { waitForProjectOpen() + // The IDE settles after the project opens (its layout restored, its startup activities run): a tool + // window shown before then can be hidden again by them, on 2026.1 and 2026.2 alike. + waitForIndicators(kotlin.time.Duration.parse("3m")) withContext { assertEquals(version, utility().getInstance().getFullVersion()) System.getProperty("mcpp.clion.backendPath")?.let { backend -> @@ -142,10 +146,49 @@ class GraphInteractionTest { assertEquals(2, startup.get().get("protocol").getAsInt()) record("protocol2-startup") if (disableJcef) { + // The mcpp tool window shares the left side with Project, which the IDE opens as + // the project's layout is restored: an action before that finishes is shown and + // then covered. The person asks once the project is on screen; so does the test. + // CLion 2026.2 registers the Project window after the project reports open, so it is + // looked up again until it exists and is shown, not asked once. + val windows = utility().getInstance(singleProject()) + val restored = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + while (windows.getToolWindow("Project")?.isVisible() != true && System.nanoTime() < restored) Thread.sleep(100) + val before = "before the action: project=" + (windows.getToolWindow("Project")?.isVisible() ?: "absent") + + " after " + "%.1fs".format((System.nanoTime() - restored) / 1e9 + 60) + "\n" 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) + // The sentence wraps in the narrow tool window; its accessible name is whole. + val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") + // Which window is on screen, each time that changes, for the report: the mcpp window + // shares the left side with Project, and only one of the two is shown at a time. + val began = System.nanoTime() + val timeline = StringBuilder(before) + var last = "" + val explained = began + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (!explanation.present() && System.nanoTime() < explained) { + val now = withContext(OnDispatcher.EDT) { + val mcpp = windows.getToolWindow("mcpp") + "mcpp=" + (mcpp?.isVisible() ?: "absent") + " project=" + (windows.getToolWindow("Project")?.isVisible() ?: "absent") + } + if (now != last) { + timeline.append("%.1fs %s\n".format((System.nanoTime() - began) / 1e9, now)) + last = now + } + Thread.sleep(100) + } + if (!explanation.present()) { + // What the window shows instead, for the report. + val window = windows.getToolWindow("mcpp") + val state = withContext(OnDispatcher.EDT) { + if (window == null) "no mcpp tool window" + else "visible=" + window.isVisible() + " active=" + window.isActive() + + " type=" + runCatching { window.getType().name() }.getOrElse { "?" } + + " autoHide=" + window.isAutoHide() + " split=" + window.isSplitMode() + + " stripe=" + window.isShowStripeButton() + " selected=" + + window.getContentManager().getSelectedContent()?.getDisplayName() + } + Files.writeString(output.resolve("no-jcef-window.txt"), state + "\n" + timeline) + } assertTrue(explanation.present(), "the graph explains why drawing is unavailable") record("no-jcef-explanation-visible") } else { diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 5cf66b4..a9eff6b 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -12,6 +12,7 @@ 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.driver.sdk.waitForIndicators import com.intellij.ide.starter.ci.CIServer import com.intellij.ide.starter.ci.NoCIServer import com.intellij.ide.starter.di.di @@ -114,8 +115,15 @@ interface ToolWindowManagerRemote { fun getToolWindow(id: String): ToolWindowRem @Remote("com.intellij.openapi.wm.ToolWindow") interface ToolWindowRemote { fun isVisible(): Boolean + fun isActive(): Boolean + fun isAutoHide(): Boolean + fun isSplitMode(): Boolean + fun isShowStripeButton(): Boolean + fun getType(): ToolWindowTypeRemote fun getContentManager(): ContentManagerRemote } +@Remote("com.intellij.openapi.wm.ToolWindowType") +interface ToolWindowTypeRemote { fun name(): String } @Remote("com.intellij.ui.content.ContentManager") interface ContentManagerRemote { fun getContentCount(): Int @@ -343,6 +351,8 @@ class PackagedPluginTest { } context.runIdeWithDriver(runTimeout = 3.minutes).useDriverAndCloseIde { waitForProjectOpen() + // As GraphInteractionTest: windows shown before the IDE settles can be hidden again. + waitForIndicators(kotlin.time.Duration.parse("3m")) withContext { val application = utility().getInstance() assertEquals(version, application.getFullVersion(), "the requested stable IDE actually runs") @@ -434,8 +444,12 @@ class PackagedPluginTest { 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") + // The Debug window is shown as the session starts, after the process does. + val windows = utility().getInstance(singleProject()) + val shown = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (windows.getToolWindow("Debug")?.isVisible() != true && System.nanoTime() < shown) Thread.sleep(100) + assertTrue(windows.getToolWindow("Debug")?.isVisible() == true, "the native debugger view is visible") + val debugWindow = windows.getToolWindow("Debug") record("native-debugger-process-and-view") val debugger = utility().getInstance(singleProject()) val debugFile = requireNotNull(utility().getInstance().refreshAndFindFileByPath(source.toString())) 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 1817d37..0a94d9f 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 @@ -131,6 +131,14 @@ class McppSession(private val project: Project) : Disposable { com.intellij.ide.plugins.PluginManagerCore.isLoaded(id) }.getOrDefault(false) + /** + * This plugin's version, as its descriptor states it: the series the backend must share (D15). The + * platform loads every plugin class through the plugin's own class loader, which names the descriptor. + */ + private fun pluginVersion(): String = runCatching { + (McppSession::class.java.classLoader as? com.intellij.ide.plugins.cl.PluginAwareClassLoader)?.pluginDescriptor?.version + }.getOrNull() ?: "unknown" + /** The plugin's directory: the parent of the lib/ holding this class's jar. */ private fun pluginRoot(): File? = pluginDirectory() @@ -191,7 +199,7 @@ class McppSession(private val project: Project) : Disposable { var protocol = "unknown" b.request("initialize", params).thenCompose { init -> protocol = init.asJsonObject["protocol"]?.toString() ?: "missing" - requireProtocol(init.asJsonObject) + requireProtocol(init.asJsonObject, pluginVersion()) log.info("mcpp-ide: initialized (source=${located.source}, protocol=$protocol)") initialized = init.asJsonObject b.notify("initialized", JsonObject()) @@ -297,8 +305,17 @@ class McppSession(private val project: Project) : Disposable { } fun refreshEntries(path: String) { - request("source/entries", JsonObject().apply { addProperty("file", path) }).thenAccept { r -> - sources[path] = r.asJsonObject + request("source/entries", JsonObject().apply { addProperty("file", path) }).whenComplete { r, error -> + // Why the gutter has no ▶ for a file (D16): the backend could not say what the file is. + if (error != null) { + val cause = (error as? java.util.concurrent.CompletionException)?.cause ?: error + log.info("mcpp-ide: no run entries for $path: ${cause.message}") + return@whenComplete + } + val entries = r.asJsonObject + if (entries["kind"]?.asString !in setOf("program", "test")) + log.debug("mcpp-ide: $path is no target's entry (${entries["kind"]?.asString ?: "unknown"})") + sources[path] = entries restartMarkers() } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt index 5628955..283bce3 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt @@ -33,6 +33,7 @@ class McppStatusBarWidget(private val project: Project) : StatusBarWidget, Statu @Volatile private var version: String = "" @Volatile private var program: String = "" @Volatile private var running: String = "" + @Volatile private var kind: String = "" private val subscriptions = mutableListOf<() -> Unit>() override fun ID(): String = McppStatusBarFactory.ID @@ -41,7 +42,14 @@ class McppStatusBarWidget(private val project: Project) : StatusBarWidget, Statu override fun install(statusBar: StatusBar) { bar = statusBar val session = McppSession.get(project) - subscriptions += session.on("job/started") { p -> running = p.asJsonObject["kind"]?.asString ?: "job"; update() } + subscriptions += session.on("job/started") { p -> kind = p.asJsonObject["kind"]?.asString ?: "job"; running = kind; update() } + // The build's progress, as mcpp counts its steps (`job/progress`), beside the job that builds. + subscriptions += session.on("job/progress") { p -> + val o = p.asJsonObject + val done = o["finished"]?.takeIf { !it.isJsonNull }?.asInt + val total = o["total"]?.takeIf { !it.isJsonNull }?.asInt + if (done != null && total != null && running.isNotEmpty()) { running = "$kind $done/$total"; update() } + } subscriptions += session.on("job/finished") { running = ""; update() } subscriptions += session.on("project/mcppChanged") { ask() } ask() diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt index 8d9b5a0..267ca63 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt @@ -46,20 +46,23 @@ object Locate { } /** - * A payload of mcppls (`payload.json` beside `bin/`), with the program its `server` starts made - * executable again: the runner, else a program server. A plugin's install may not keep the bit; - * mcppls-clion puts it back when it starts its server, and the backend may run that program - * before, to read mcppls's version. Only a relative path with no `..` part is followed. + * A payload of mcppls (`payload.json` beside `bin/`), with the programs that start its server made + * executable again (mcppls specification S6): every platform's runner of a version 5 payload, the + * runner of a version 4 one, else a program server. A plugin's install may not keep the bit; + * mcppls-clion puts it back when it starts its server, and the backend, which chooses this system's + * entry, may run that program before, to read mcppls's version. Only a relative path with no `..` + * part is followed. */ fun runnablePayload(payload: File): File { - val server = runCatching { - JsonParser.parseString(File(payload, "payload.json").readText()).asJsonObject.getAsJsonObject("server") - }.getOrNull() - val named = listOf("runner", "path").firstNotNullOfOrNull { key -> - server?.get(key)?.takeIf { it.isJsonPrimitive }?.asString - } - named?.takeIf { !File(it).isAbsolute && it.split('/', '\\').none { part -> part == ".." } } - ?.let { File(payload, it) }?.takeIf { it.isFile }?.let(::runnable) + val manifest = runCatching { JsonParser.parseString(File(payload, "payload.json").readText()).asJsonObject }.getOrNull() + val server = manifest?.get("server")?.takeIf { it.isJsonObject }?.asJsonObject + fun named(obj: com.google.gson.JsonObject?, key: String): String? = + obj?.get(key)?.takeIf { it.isJsonPrimitive }?.asString + val runners = manifest?.get("platforms")?.takeIf { it.isJsonObject }?.asJsonObject?.entrySet() + ?.mapNotNull { (_, entry) -> entry.takeIf { it.isJsonObject }?.asJsonObject?.let { named(it, "runner") } } + ?: listOfNotNull(named(server, "runner") ?: named(server, "path")) + runners.filter { !File(it).isAbsolute && it.split('/', '\\').none { part -> part == ".." } } + .map { File(payload, it) }.filter { it.isFile }.forEach { runnable(it) } return payload } 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 index e26ec49..a175aca 100644 --- 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 @@ -3,8 +3,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) { +/** The series of a version `X.Y.Z`: `X.Y`, shared by the backend and every plugin (mcpp design 2026-11-07, D15). */ +fun seriesOf(version: String?): String? = + version?.let { Regex("""^(\d+)\.(\d+)(\.\d+)?""").find(it.trim()) }?.let { "${it.groupValues[1]}.${it.groupValues[2]}" } + +/** + * Check the handshake before issuing any namespaced extension requests: protocol 2, and a backend of + * this plugin's series. A backend before 1.2 states no series; its version's `X.Y` is taken, and it is + * another series. + */ +fun requireProtocol(initialized: JsonObject, pluginVersion: String) { val value = initialized["protocol"] val compatible = runCatching { value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber && @@ -13,4 +21,11 @@ fun requireProtocol(initialized: JsonObject) { 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." } + val backendVersion = runCatching { initialized.getAsJsonObject("serverInfo")["version"].asString }.getOrNull() ?: "unknown" + val backendSeries = runCatching { initialized["series"].asString }.getOrNull() ?: seriesOf(backendVersion) + val own = seriesOf(pluginVersion) + check(own != null && backendSeries == own) { + "The mcpp-ide backend $backendVersion is of series ${backendSeries ?: "unknown"}, and mcpp-clion $pluginVersion of series $own. " + + "Install the mcpp-ide backend $own (xlings install mcpp-ide@$own.0), or clear mcpp.ide.backendPath to use the bundled backend." + } } 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 5f9b736..4436f1f 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,6 +13,7 @@ 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.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressManager import com.intellij.xdebugger.XDebugProcess import com.intellij.xdebugger.XDebugProcessStarter @@ -27,6 +28,7 @@ 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 +import java.util.concurrent.TimeUnit class McppDebugRunner : GenericProgramRunner() { override fun getRunnerId(): String = "McppDebugRunner" @@ -44,8 +46,21 @@ class McppDebugRunner : GenericProgramRunner() { // itself creates it. val driver = createDriverConfiguration(project, CPPEnvironment(toolchain)) 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) + // The build first, as a launch job: mcpp's narration and its progress on the indicator; then + // the debugger's configuration of what it built, which mcpp describes on its fast path. + val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously({ + val indicator = ProgressManager.getInstance().progressIndicator + val params = launchParams(c).apply { addProperty("kind", "launch"); addProperty("color", false) } + val job = FollowedJob(session, params, + { text -> text.lines().lastOrNull { it.isNotBlank() }?.let { indicator?.text2 = it.trim() } }, + { done, total -> indicator?.isIndeterminate = false; indicator?.fraction = done.toDouble() / total.coerceAtLeast(1) }).start() + while (!job.finished.isDone) { + if (indicator?.isCanceled == true) { job.cancel(); throw ProcessCanceledException() } + runCatching { job.finished.get(100, TimeUnit.MILLISECONDS) } + } + launchOf(job.finished.get(), session) + debugLaunch(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() { 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 249deb3..d9395a4 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 @@ -120,10 +120,36 @@ class McppJobState(private val c: McppRunConfiguration, env: ExecutionEnvironmen /** * A test job whose results fill the IDE's test tree: each `test/result` becomes the TeamCity * service messages the test runner reads, located at the file that holds the test's `main`. + * + * EVERY TEST AND CASE HAS ITS OWN OUTPUT (design 2026-11-07, D16): `testStdOut` carries a program's + * whole output and a case's own (GoogleTest's console between `[ RUN ]` and its result), passed or + * not; a failure's `details` start with the place its output names (`file:line`, which the console + * filter links), then the output. */ class TestJobProcessHandler(session: McppSession, params: JsonObject, private val mains: Map, method: String = "job/start") : JobProcessHandler(session, params, method) { private fun escape(s: String) = s.replace("|", "||").replace("'", "|'").replace("\n", "|n").replace("\r", "|r").replace("[", "|[").replace("]", "|]") + + /** `path:line` of a result's `location` (its line 0-based), or "". */ + private fun place(p: JsonObject): String = runCatching { + val l = p.getAsJsonObject("location") + "${l["path"].asString}:${l["line"].asInt + 1}" + }.getOrDefault("") + + /** The output a result carries, as its test's standard output, with a note when it was cut short. */ + private fun stdout(out: StringBuilder, name: String, p: JsonObject) { + val output = p["output"]?.takeIf { it.isJsonPrimitive }?.asString ?: "" + if (output.isEmpty()) return + val note = if (p["outputTruncated"]?.asBoolean == true) "\n(the output was longer; its head and tail are shown)\n" else "" + out.append("##teamcity[testStdOut name='$name' out='${escape(output + note)}']\n") + } + + /** `testFailed` with the failure's place first in its details, then what the result says. */ + private fun failed(out: StringBuilder, name: String, message: String, p: JsonObject, details: String) { + val at = place(p) + val text = listOf(at, details).filter { it.isNotBlank() }.joinToString("\n") + out.append("##teamcity[testFailed name='$name' message='${escape(message)}' details='${escape(text)}']\n") + } /** The program whose cases are reported, once its first case is (test/runCases). */ private var suite: String? = null @@ -143,10 +169,14 @@ class TestJobProcessHandler(session: McppSession, params: JsonObject, private va val hint = caseUrl(session, p["root"]?.asString ?: "", program, p["case"].asString, mains[program]) ?.let { " locationHint='${escape(it)}'" } ?: "" out.append("##teamcity[testStarted name='$name'$hint]\n") + stdout(out, name, p) when (p["status"]?.asString) { "passed" -> {} "skipped" -> out.append("##teamcity[testIgnored name='$name']\n") - else -> out.append("##teamcity[testFailed name='$name' message='${escape(p["message"]?.asString ?: "")}']\n") + else -> { + val message = p["message"]?.asString ?: "" + failed(out, name, message.lineSequence().firstOrNull() ?: "", p, message) + } } out.append("##teamcity[testFinished name='$name' duration='${p["durationMs"]?.asLong ?: 0}']\n") notifyTextAvailable(out.toString(), ProcessOutputTypes.STDOUT) @@ -159,12 +189,19 @@ class TestJobProcessHandler(session: McppSession, params: JsonObject, private va val name = escape(p["test"].asString) val hint = mains[p["test"].asString]?.let { " locationHint='file://${escape(it)}'" } ?: "" val out = StringBuilder("##teamcity[testStarted name='$name'$hint]\n") - val output = p["output"]?.asString ?: "" - if (output.isNotEmpty()) out.append("##teamcity[testStdOut name='$name' out='${escape(output)}']\n") + stdout(out, name, p) + val reason = p["reason"]?.asString?.ifBlank { null } when (p["status"]?.asString) { "passed" -> {} - "skipped" -> out.append("##teamcity[testIgnored name='$name']\n") - else -> out.append("##teamcity[testFailed name='$name' message='${escape(p["reason"]?.asString?.ifBlank { null } ?: p["status"].asString)}']\n") + "skipped" -> out.append("##teamcity[testIgnored name='$name'${reason?.let { " message='${escape(it)}'" } ?: ""}]\n") + else -> { + val message = when { + p["timedOut"]?.asBoolean == true -> "The test timed out" + p["compileFailed"]?.asBoolean == true -> "The test did not compile" + else -> reason ?: "The test failed (${p["status"].asString})" + } + failed(out, name, message, p, "") + } } out.append("##teamcity[testFinished name='$name' duration='${p["durationMs"]?.asLong ?: 0}']\n") notifyTextAvailable(out.toString(), ProcessOutputTypes.STDOUT) @@ -203,25 +240,96 @@ 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, debugEngine: String? = null): CompletableFuture { - val session = McppSession.get(c.project) - val params = JsonObject().apply { - addProperty("root", c.root) - if (c.options.target.isNotBlank()) addProperty("target", c.options.target) - 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) +/** The parameters of the launch of a configuration: its program, its arguments, and the debugger's engine when debugging. */ +internal fun launchParams(c: McppRunConfiguration, debugEngine: String? = null): JsonObject = JsonObject().apply { + addProperty("root", c.root) + if (c.options.target.isNotBlank()) addProperty("target", c.options.target) + 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) +} + +/** + * A backend job followed to its end (design 2026-11-07 §4.9): mcpp's narration and its progress as + * they arrive, then the job's `job/finished`. A launch job (`kind: launch`) builds a program as + * `mcpp run` does and ends with the program's launch (`launch`), so a Run shows the build as the + * terminal shows it rather than a fixed line, and the program starts the moment it is built. + */ +internal class FollowedJob( + private val session: McppSession, + private val params: JsonObject, + private val onOutput: (String) -> Unit, + private val onProgress: (Int, Int) -> Unit = { _, _ -> }, +) { + val finished = CompletableFuture() + @Volatile var jobId: String? = null + private set + private val early = mutableListOf>() + private val subscriptions = mutableListOf<() -> Unit>() + + fun start(): FollowedJob { + for (m in listOf("job/output", "job/progress", "job/finished")) + subscriptions += session.on(m) { p -> route(m, p.asJsonObject) } + val requested = System.nanoTime() + session.ensureMcppForAction().thenCompose { ready -> + if (ready) session.request("job/start", params) + else CompletableFuture.failedFuture(IllegalStateException("mcpp is not installed")) + }.whenComplete { r, e -> + if (e != null) { close(); finished.completeExceptionally(e.cause ?: e); return@whenComplete } + val id = r.asJsonObject["jobId"].asString + LOG.info("mcpp ${params["kind"]?.asString} job $id accepted in ${(System.nanoTime() - requested) / 1_000_000} ms") + val replay: List> + synchronized(early) { jobId = id; replay = early.filter { it.second["jobId"]?.asString == id }; early.clear() } + replay.forEach { (m, p) -> handle(m, p) } + } + return this + } + + fun cancel() { + val id = jobId ?: run { close(); finished.cancel(false); return } + session.request("job/cancel", JsonObject().apply { addProperty("jobId", id) }) + } + + private fun route(method: String, p: JsonObject) { + synchronized(early) { if (jobId == null) { early += method to p; return } } + if (p["jobId"]?.asString == jobId) handle(method, p) + } + + private fun handle(method: String, p: JsonObject) { + when (method) { + "job/output" -> onOutput(p["text"].asString) + "job/progress" -> { + val done = p["finished"]?.takeIf { !it.isJsonNull }?.asInt + val total = p["total"]?.takeIf { !it.isJsonNull }?.asInt + if (done != null && total != null) onProgress(done, total) + } + "job/finished" -> { close(); finished.complete(p) } + } + } + + private fun close() { + subscriptions.forEach { it() } + subscriptions.clear() } - 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") + + companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(FollowedJob::class.java) } } +/** The launch of a finished launch job, or why there is none: the status and mcpp's diagnostics. */ +internal fun launchOf(finished: JsonObject, session: McppSession): JsonObject { + val launch = finished["launch"]?.takeIf { it.isJsonObject }?.asJsonObject + if (finished["status"]?.asString == "ok" && launch != null) return launch + val messages = finished.getAsJsonArray("diagnostics")?.mapNotNull { it.asJsonObject["message"]?.asString }.orEmpty() + throw IllegalStateException(messages.firstOrNull() ?: session.t("the program could not be built")) +} + +/** The debugger's configuration of the program a launch job built (debug/configuration; mcpp takes its fast path). */ +internal fun debugLaunch(c: McppRunConfiguration, engine: String): CompletableFuture = + McppSession.get(c.project).request("debug/configuration", launchParams(c, engine)).thenApply { it.asJsonObject.getAsJsonObject("launch") } + internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { val argv = l.getAsJsonArray("argv").map { it.asString } val cmd = if (pty) PtyCommandLine(argv) else GeneralCommandLine(argv) @@ -232,26 +340,30 @@ internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { } /** - * The program: built and described by the backend, then started in a PTY so it reads input and - * colours its output as in a terminal. The build happens before the program's own process exists, - * so this handler stands for both. + * The program: built by a launch job, whose narration is this console's output as it is written, + * then started in a PTY so it reads input and colours its output as in a terminal. The build happens + * before the program's own process exists, so this handler stands for both. */ -class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHandler() { +class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHandler(), AnsiEscapeDecoder.ColoredTextAcceptor { @Volatile private var inner: ProcessHandler? = null @Volatile private var cancelled = false + @Volatile private var job: FollowedJob? = null + private val decoder = AnsiEscapeDecoder() + + override fun coloredTextAvailable(text: String, attributes: Key<*>) = notifyTextAvailable(text, attributes) override fun startNotify() { super.startNotify() val session = McppSession.get(c.project) - notifyTextAvailable("${session.t("mcpp: building…")}\n", ProcessOutputTypes.SYSTEM) - launch(c).whenComplete { l, e -> - if (e != null) { - val cause = e.cause ?: e - val narration = (cause as? io.github.mcppcommunity.mcppclion.backend.BackendError)?.data?.takeIf { it.isJsonObject } - ?.asJsonObject?.get("narration")?.asString - if (narration != null) notifyTextAvailable(narration, ProcessOutputTypes.STDERR) - notifyTextAvailable("${cause.message}\n", ProcessOutputTypes.STDERR) - notifyProcessTerminated(1) + val started = System.nanoTime() + val params = launchParams(c).apply { addProperty("kind", "launch"); addProperty("color", true) } + val followed = FollowedJob(session, params, { text -> decoder.escapeText(text.replace("\r\n", "\n"), ProcessOutputTypes.SYSTEM, this) }) + job = followed + followed.start().finished.whenComplete { finished, e -> + val built = System.nanoTime() + val l = runCatching { if (e != null) throw (e.cause ?: e) else launchOf(finished, session) }.getOrElse { + notifyTextAvailable("${it.message}\n", ProcessOutputTypes.STDERR) + notifyProcessTerminated(finished?.get("exitCode")?.takeIf { x -> !x.isJsonNull }?.asInt?.takeIf { x -> x != 0 } ?: 1) return@whenComplete } if (cancelled) { notifyProcessTerminated(130); return@whenComplete } @@ -266,12 +378,13 @@ class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHand }) inner = handler handler.startNotify() + LOG.info("mcpp run: built in ${(built - started) / 1_000_000} ms, program started ${(System.nanoTime() - built) / 1_000_000} ms later") } } override fun destroyProcessImpl() { cancelled = true - inner?.destroyProcess() ?: notifyProcessTerminated(130) + inner?.destroyProcess() ?: run { job?.cancel(); notifyProcessTerminated(130) } } override fun detachProcessImpl() { @@ -281,6 +394,10 @@ class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHand override fun detachIsDefault(): Boolean = false override fun getProcessInput(): OutputStream? = inner?.processInput + + companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(LaunchingProcessHandler::class.java) + } } class McppProgramState(private val c: McppRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) { diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt index 1d82a18..6ebd527 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt @@ -14,18 +14,18 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea import com.intellij.ui.jcef.JBCefApp import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery +import com.intellij.util.ui.JBUI import io.github.mcppcommunity.mcppclion.McppSession import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import java.io.File import javax.swing.JComponent -import javax.swing.SwingConstants class GraphPage(private val project: Project, private val kind: String) : McppView, Disposable { override val id = "graph:$kind" @@ -34,7 +34,9 @@ class GraphPage(private val project: Project, private val kind: String) : McppVi private val query: JBCefJSQuery? = browser?.let { JBCefJSQuery.create(it as JBCefBrowserBase) } @Volatile private var loaded = false private var pending: String? = null - val component: JComponent = browser?.component ?: JBLabel(session.t("This IDE runs without JCEF, which draws the graph."), SwingConstants.CENTER) + // Without JCEF the page says why. The tool window sits on the left beside Project, narrower than + // the sentence, so the text wraps; its accessible name is the whole sentence. + val component: JComponent = browser?.component ?: unavailable(session.t("This IDE runs without JCEF, which draws the graph.")) init { if (browser != null && query != null) { @@ -105,3 +107,12 @@ class GraphPage(private val project: Project, private val kind: String) : McppVi override fun dispose() {} } + +private fun unavailable(text: String): JComponent = JBTextArea(text).apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + isOpaque = false + border = JBUI.Borders.empty(12) + accessibleContext.accessibleName = text +} 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 4aca84e..f02efe3 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 @@ -91,6 +91,21 @@ object McppPanels { class Factory : ToolWindowFactory, DumbAware { override suspend fun isApplicableAsync(project: Project): Boolean = project.basePath?.let { File(it, "mcpp.toml").isFile } ?: false + /** + * The button on the left stripe, beside Project, as VS Code's activity bar carries mcpp + * (design 2026-11-07 §4.9). The new UI keeps a plugin's tool window under "More Tool Windows" + * until it is placed; it is placed once, here, as the window is registered -- before anything + * can show it -- and what the user does with the button afterwards is kept. Placing it from a + * startup activity raced an early `activate`: the window was shown and then hidden again. + */ + override fun init(toolWindow: ToolWindow) { + val placed = "io.github.mcppcommunity.mcppclion.toolWindowPlaced" + val properties = com.intellij.ide.util.PropertiesComponent.getInstance() + if (properties.getBoolean(placed)) return + toolWindow.isShowStripeButton = true + properties.setValue(placed, true) + } + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val session = McppSession.get(project) fun add(view: McppView, component: javax.swing.JComponent, title: String): Content { diff --git a/mide-plugins/clion/src/main/resources/META-INF/plugin.xml b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml index 8a73fde..e82a0b2 100644 --- a/mide-plugins/clion/src/main/resources/META-INF/plugin.xml +++ b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml @@ -36,7 +36,7 @@ - diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt index c3ccd60..e8e718f 100644 --- a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt @@ -66,6 +66,14 @@ class BackendTest { Locate.runnablePayload(payload) assertTrue("outside the payload", !outside.canExecute()) outside.delete() + // Version 5 (mcppls 0.0.20 on): every platform's runner, whichever this system's entry is. + val linux = File(payload, "libexec/okrun/linux-x86_64/okrun").apply { parentFile.mkdirs(); writeText(""); setExecutable(false) } + val mac = File(payload, "libexec/okrun/macos-aarch64/okrun").apply { parentFile.mkdirs(); writeText(""); setExecutable(false) } + File(payload, "payload.json").writeText("""{"payload-version": 5, "server": {"path": "bin/mcppls.okb"}, "platforms": { + "linux-x64": {"runner": "libexec/okrun/linux-x86_64/okrun"}, "darwin-arm64": {"runner": "libexec/okrun/macos-aarch64/okrun"}}}""") + Locate.runnablePayload(payload) + assertTrue("linux's runner", linux.canExecute()) + assertTrue("macOS's runner", mac.canExecute()) payload.deleteRecursively() } 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 index 95d39c3..18a8951 100644 --- 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 @@ -1,22 +1,38 @@ package io.github.mcppcommunity.mcppclion import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.github.mcppcommunity.mcppclion.backend.requireProtocol +import io.github.mcppcommunity.mcppclion.backend.seriesOf +import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test class ProtocolTest { + private fun answer(protocol: String, version: String = "1.2.0", series: String? = "1.2") = + JsonParser.parseString("""{"protocol":$protocol,"serverInfo":{"name":"mcpp-ide","version":"$version"}${series?.let { ",\"series\":\"$it\"" } ?: ""}}""").asJsonObject + @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()) + assertThrows(IllegalStateException::class.java) { requireProtocol(answer(protocol), "1.2.0") } } - io.github.mcppcommunity.mcppclion.backend.requireProtocol(JsonObject().apply { addProperty("protocol", 2) }) + assertThrows(IllegalStateException::class.java) { requireProtocol(JsonObject(), "1.2.0") } + requireProtocol(answer("2"), "1.2.0") } + // D15: one series, any patch of either side. + @Test + fun theHandshakeAcceptsOneSeriesAndRefusesAnother() { + requireProtocol(answer("2", "1.2.0"), "1.2.3") + requireProtocol(answer("2", "1.2.4"), "1.2.0") + requireProtocol(answer("2", "1.2.1", series = null), "1.2.0") + val older = assertThrows(IllegalStateException::class.java) { requireProtocol(answer("2", "0.2.1", series = null), "1.2.0") } + assertTrue(older.message!!.contains("0.2.1") && older.message!!.contains("series 0.2") && older.message!!.contains("mcpp-ide@1.2.0")) + assertThrows(IllegalStateException::class.java) { requireProtocol(answer("2", "1.3.0", "1.3"), "1.2.0") } + assertEquals("1.2", seriesOf("1.2.0")) + assertEquals("1.2", seriesOf("1.2")) + assertEquals(null, seriesOf("unknown")) + } } diff --git a/mide-plugins/nvim/README.md b/mide-plugins/nvim/README.md index 6e4a44c..bbbd6c8 100644 --- a/mide-plugins/nvim/README.md +++ b/mide-plugins/nvim/README.md @@ -96,7 +96,7 @@ mapped onto Neovim: | Action | In Neovim | Subcommands | |---|---|---| | job, rpc | mcpp's narration streamed into `mcpp://output`, its end said there and, when it fails, as a notification | `build`, `test [filter]`, `autoConfigureModules`, `refreshCompilationDatabase`, `library.updateIndex` | -| run | `run/launch` builds; its `argv`, `cwd` and `env` start in a terminal buffer | `run [bin]` | +| run | a launch job builds, its narration in `mcpp://output` and its progress in the statusline; the launch's `argv`, `cwd` and `env` start in a terminal buffer | `run [bin]` | | debug | `debug/configuration` handed to nvim-dap; offered only when nvim-dap is loaded | `debug [bin]` | | cache | the backend's plan, confirmed at the level it asks for (`vim.ui.select`), applied as a job | `clean`, `cleanStaleArtifacts`, `cleanProjectArtifacts`, `gcGlobalCache`, `pruneGlobalCache`, `verifyGlobalCache`, `cleanLegacyCache` | | panel | the view model as text in a floating window, or as choices | `showTargets`, `showToolchains`, `showCachePanel`, `refreshCacheStats`, `project.showDependencyGraph`, `project.showModuleDependencyGraph`; `library.search [keyword]` (choose a package and a version: `mcpp add`); `pack` (format, mode, linkage: `mcpp pack`) | diff --git a/mide-plugins/nvim/lua/mcpp/commands.lua b/mide-plugins/nvim/lua/mcpp/commands.lua index 3f47dc0..bf143cd 100644 --- a/mide-plugins/nvim/lua/mcpp/commands.lua +++ b/mide-plugins/nvim/lua/mcpp/commands.lua @@ -6,6 +6,7 @@ local session = require('mcpp.session') local flow = require('mcpp.flow') local views = require('mcpp.views') local diagnostics = require('mcpp.diagnostics') +local tests = require('mcpp.tests') local M = {} local t = session.t @@ -57,26 +58,32 @@ local actions = {} function actions.job(row, args, root) local params = { kind = row.action.job, root = root } - if row.action.job == 'test' and args[1] then + local testing = row.action.job == 'test' + if testing and args[1] then params.filter = args[1] end - local done = views.job('job/start', params, row.action.job) + -- A test job: each result in mcpp://tests, its failures in the quickfix list, its signs (D16). + if testing then + tests.begin(root) + end + local done = views.job('job/start', params, row.action.job, testing and { test = tests.add } or nil) + if testing then + tests.finish() + tests.reveal() + end if done and row.action['then'] == 'languageService' then actions.languageService() end end +-- The program, built by a launch job (design 2026-11-07 §4.9): mcpp's narration in mcpp://output as +-- it is written and its progress in the statusline, then the program in a terminal of its own. function actions.run(_, args, root) - vim.api.nvim_echo({ { t('mcpp: building…') } }, false, {}) - local launch, err = session.call('run/launch', { root = root, args = session.value('mcpp.task.runArgs') or {}, bin = args[1] }) - if err then - if err.data and err.data.narration then - views.append(err.data.narration) - views.reveal() - end - return report(err) + local params = { kind = 'launch', root = root, args = session.value('mcpp.task.runArgs') or {}, bin = args[1] } + local done = views.job('job/start', params, 'run') + if done and done.status == 'ok' and type(done.launch) == 'table' then + views.terminal(done.launch) end - views.terminal(launch) end function actions.debug(_, args, root) diff --git a/mide-plugins/nvim/lua/mcpp/init.lua b/mide-plugins/nvim/lua/mcpp/init.lua index 23d9033..edea0e2 100644 --- a/mide-plugins/nvim/lua/mcpp/init.lua +++ b/mide-plugins/nvim/lua/mcpp/init.lua @@ -124,6 +124,33 @@ function M.setup(opts) end, }) vim.api.nvim_create_autocmd('User', { group = group, pattern = 'McppStatus', callback = on_started }) + -- The ▶ of a program's or a test's main and of each case, or its last result (D16). + vim.api.nvim_create_autocmd({ 'BufReadPost', 'BufWritePost' }, { + group = group, pattern = { '*.cpp', '*.cc', '*.cxx', '*.cppm', '*.ixx', '*.mpp' }, + callback = function(a) + require('mcpp.tests').signs(a.buf) + end, + }) + -- Once the backend is ready (McppStatus fires on every change of the statusline; the signs are + -- asked for when the session starts, and after a read or a write). + local signed = false + vim.api.nvim_create_autocmd('User', { + group = group, pattern = 'McppStatus', + callback = function() + if session.state.ready == signed then + return + end + signed = session.state.ready + if not signed then + return + end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + require('mcpp.tests').signs(buf) + end + end + end, + }) -- mcppls attached: it serves build.mcpp now, and the backend leaves it what it answers better. vim.api.nvim_create_autocmd('LspAttach', { group = group, @@ -163,6 +190,13 @@ function M.status() end local text = 'mcpp ' .. s.target if s.running > 0 then + -- A build reports its steps: the job and its count, as mcpp counts them. + for id, p in pairs(session.state.progress) do + local job = session.state.running[id] + if job then + return text .. ' · ' .. (job.kind or '') .. ' ' .. p[1] .. '/' .. p[2] + end + end return text .. ' · ' .. t('{0} job(s) running', s.running) end if s.last then diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index 0212255..ace26d3 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.1' +M.version = '1.2.0' -- Neovim's version, as its release names it. function M.nvim_version() @@ -39,6 +39,8 @@ local state = { claimed = {}, unclaimed = {}, running = {}, + -- The build's progress of each running job (`job/progress`): { finished, total }. + progress = {}, last = nil, plans = {}, stopping = false, @@ -208,6 +210,8 @@ local function deliver(owner, method, params) on.output(params.text or '') elseif method == 'job/started' and on.started then on.started(params) + elseif (method == 'test/result' or method == 'test/caseResult') and on.test then + on.test(method, params) elseif method == 'job/finished' then state.claimed[params.jobId] = nil owner.handle.done = params @@ -225,8 +229,14 @@ local function route(method, params) if method == 'job/started' then state.running[id] = params changed() + elseif method == 'job/progress' then + if params.finished and params.total then + state.progress[id] = { params.finished, params.total } + changed() + end elseif method == 'job/finished' then state.running[id] = nil + state.progress[id] = nil state.last = params changed() elseif method == 'job/plan' then @@ -251,11 +261,12 @@ end local JOB_EVENTS = { ['job/started'] = true, ['job/output'] = true, ['job/plan'] = true, ['job/progress'] = true, ['job/diagnostic'] = true, ['job/artifact'] = true, ['job/record'] = true, ['job/finished'] = true, - ['test/result'] = true, ['test/summary'] = true, + ['test/result'] = true, ['test/summary'] = true, ['test/caseResult'] = true, } -- Inside a flow: asks for a job (`job/start`, or another method that answers a job id) and follows --- it. `events`: output(text), started(params); the handle's wait() answers its end. Returns the +-- it. `events`: output(text), started(params), test(method, result) for each `test/result` and +-- `test/caseResult`; the handle's wait() answers its end. Returns the -- handle, or nil and the error. function M.job(method, params, events) local result, err = M.call(method, params) @@ -428,10 +439,37 @@ function M.attach(buf) vim.lsp.buf_attach_client(buf, client.id) end +-- `X.Y` of a version `X.Y` or `X.Y.Z`: the series, shared by the backend and every editor plugin +-- (mcpp design 2026-11-07, D15); nil for anything else. +function M.series_of(version) + if type(version) ~= 'string' then + return nil + end + local x, y = version:match('^%s*(%d+)%.(%d+)') + return x and (x .. '.' .. y) or nil +end + +-- Why the backend that answered `info` does not work with this plugin, or nil: protocol 2, and this +-- plugin's series (a backend before 1.2 states none; its version's `X.Y` is taken). +function M.incompatibility(info, own) + if info.protocol ~= 2 then + return 'the mcpp-ide backend does not speak protocol 2; update it, or point mcpp.ide.backendPath at one that does' + end + local backend = type(info.serverInfo) == 'table' and info.serverInfo.version or 'unknown' + local series = type(info.series) == 'string' and info.series or M.series_of(backend) or 'unknown' + local mine = M.series_of(own) or 'unknown' + if series == mine and mine ~= 'unknown' then + return nil + end + return string.format('the mcpp-ide backend %s is of series %s, and mcpp-nvim %s of series %s; install the mcpp-ide ' + .. 'backend %s (`xlings install mcpp-ide@%s.0`)', backend, series, own, mine, mine, mine) +end + -- Runs once the client is initialized: `info` is the backend's initialize answer. local function initialize(info) - if not info then - vim.notify('mcpp-ide: initialize failed', vim.log.levels.ERROR) + local refused = info and M.incompatibility(info, M.version) + if not info or refused then + vim.notify(refused and ('mcpp: ' .. refused) or 'mcpp-ide: initialize failed', vim.log.levels.ERROR) local client = state.client state.client = nil if client then diff --git a/mide-plugins/nvim/lua/mcpp/tests.lua b/mide-plugins/nvim/lua/mcpp/tests.lua new file mode 100644 index 0000000..09a0fdf --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/tests.lua @@ -0,0 +1,182 @@ +-- What `:Mcpp test` says about each test (design 2026-11-07, D16): a results buffer (`mcpp://tests`) +-- with one section per test and per case, its status and its whole output, passed or not; the +-- failures, at the place their output names, in the quickfix list; and in the sign column of a +-- test's source a ▶ on its `main` and each case, which a result turns into ✓ or ✗. A program's `main` +-- has its ▶ too. The places and the statuses are the backend's (`source/entries`, `test/cases`, +-- `test/result`, `test/caseResult`). +local session = require('mcpp.session') + +local M = {} +local t = session.t + +M.RESULTS = 'mcpp://tests' +local NS = vim.api.nvim_create_namespace('mcpp_tests') +local results_buf +-- The last result of each test and case, by `root\0test` and `root\0test\0case`. +local last = {} + +local SIGN = { entry = '▶', passed = '✓', failed = '✗', timedOut = '✗', errored = '✗', skipped = '–' } +local HL = { entry = 'DiagnosticInfo', passed = 'DiagnosticOk', failed = 'DiagnosticError', timedOut = 'DiagnosticError', + errored = 'DiagnosticError', skipped = 'DiagnosticHint' } + +local function results_buffer() + if results_buf and vim.api.nvim_buf_is_valid(results_buf) then + return results_buf + end + results_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(results_buf, M.RESULTS) + vim.bo[results_buf].bufhidden = 'hide' + vim.bo[results_buf].filetype = 'markdown' + vim.bo[results_buf].modifiable = false + return results_buf +end + +local function write(lines) + local buf = results_buffer() + vim.bo[buf].modifiable = true + local count = vim.api.nvim_buf_line_count(buf) + local empty = count == 1 and (vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or '') == '' + vim.api.nvim_buf_set_lines(buf, empty and 0 or count, -1, false, lines) + vim.bo[buf].modifiable = false +end + +local function output_lines(text) + local lines = vim.split((text or ''):gsub('\r\n', '\n'):gsub('\27%[[%d;]*[A-Za-z]', ''), '\n', { plain = true }) + if lines[#lines] == '' then + table.remove(lines) + end + return lines +end + +local function status_text(r) + if r.status == 'timedOut' then + return t('timed out') + elseif r.compileFailed then + return t('did not compile') + elseif r.builtOnly then + return t('built, not run') + end + return r.status +end + +-- The file a result's location names: as printed, else beneath the project. +local function located(root, location) + if type(location) ~= 'table' or type(location.path) ~= 'string' then + return nil + end + local path = location.path + if not (path:match('^/') or path:match('^%a:[/\\]')) then + path = (root or '') .. '/' .. path + end + return { filename = vim.fs.normalize(path), lnum = (location.line or 0) + 1 } +end + +-- A new run: the buffer and the quickfix list start over. +function M.begin(root) + local buf = results_buffer() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { '# ' .. t('mcpp test in {0}', root or '?') }) + vim.bo[buf].modifiable = false + M.failures = {} +end + +-- One result (`test/result` or `test/caseResult`) said in the buffer, kept for the signs, and its +-- failure put in the quickfix list. +function M.add(method, r) + local name = method == 'test/caseResult' and (r.test .. ' › ' .. r.case) or r.test + local key = (r.root or '') .. '\0' .. r.test .. (method == 'test/caseResult' and ('\0' .. r.case) or '') + last[key] = r.status + local lines = { '', string.format('## %s %s — %s (%d ms)', SIGN[r.status] or '?', name, status_text(r), r.durationMs or 0) } + if r.reason and r.reason ~= '' then + lines[#lines + 1] = r.reason + end + if method == 'test/caseResult' and r.message and r.message ~= '' and r.status == 'failed' then + for _, l in ipairs(output_lines(r.message)) do + lines[#lines + 1] = '> ' .. l + end + end + local out = output_lines(r.output) + if #out > 0 then + lines[#lines + 1] = '```' + vim.list_extend(lines, out) + lines[#lines + 1] = '```' + end + if r.outputTruncated then + lines[#lines + 1] = t('(the output was longer; its head and tail are shown)') + end + write(lines) + if r.status == 'failed' or r.status == 'timedOut' or r.status == 'errored' then + local item = located(r.root, r.location) + if item then + item.text = name .. ': ' .. status_text(r) + M.failures[#M.failures + 1] = item + end + end +end + +-- The run ended: the quickfix list holds its failures, and the signs show the results. +function M.finish() + vim.fn.setqflist({}, ' ', { title = 'mcpp test', items = M.failures or {} }) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + M.signs(buf) + end + end +end + +function M.reveal() + local buf = results_buffer() + if vim.fn.bufwinid(buf) == -1 then + local from = vim.api.nvim_get_current_win() + vim.cmd('botright 15split') + vim.api.nvim_win_set_buf(0, buf) + vim.api.nvim_set_current_win(from) + end +end + +local function place(buf, line, status, text) + pcall(vim.api.nvim_buf_set_extmark, buf, NS, line, 0, { + sign_text = SIGN[status] or SIGN.entry, sign_hl_group = HL[status] or HL.entry, priority = 20, + virt_text = text and { { text, 'Comment' } } or nil, virt_text_pos = text and 'eol' or nil, + }) +end + +-- The signs of a buffer: ▶ on a program's or a test's `main` and on each case, or the last result. +function M.signs(buf) + local name = vim.api.nvim_buf_get_name(buf) + if name == '' or vim.bo[buf].buftype ~= '' or not session.state.ready then + return + end + session.request('source/entries', { file = name }, function(err, entries) + if err or not entries or (entries.kind ~= 'program' and entries.kind ~= 'test') or not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1) + local key = (entries.root or '') .. '\0' .. (entries.test or '') + if entries.main and entries.main.line then + place(buf, entries.main.line, entries.kind == 'test' and last[key] or 'entry') + end + if entries.kind ~= 'test' then + return + end + session.request('test/cases', { root = entries.root, test = entries.test }, function(cerr, cases) + if cerr or not cases or not vim.api.nvim_buf_is_valid(buf) then + return + end + for _, c in ipairs(cases.cases or {}) do + if c.line then + place(buf, c.line, last[key .. '\0' .. c.name] or 'entry') + end + end + end) + end) +end + +-- What the sign of a key would show: the last result, or ▶ (the tests). +function M.sign_of(root, test, case) + local status = last[(root or '') .. '\0' .. test .. (case and ('\0' .. case) or '')] + return SIGN[status or 'entry'] +end + +M.namespace = NS +return M diff --git a/mide-plugins/nvim/lua/mcpp/views.lua b/mide-plugins/nvim/lua/mcpp/views.lua index da4f101..0c0d9b7 100644 --- a/mide-plugins/nvim/lua/mcpp/views.lua +++ b/mide-plugins/nvim/lua/mcpp/views.lua @@ -70,8 +70,9 @@ end -- Inside a flow: a job, its narration streamed into the output, its end said there and, when it -- did not succeed, as a notification. mcpp.task.clearTerminal and mcpp.task.revealTerminal apply --- as they do to mcpp-vscode's terminal. Returns `job/finished`, or nil when it did not start. -function M.job(method, params, what) +-- as they do to mcpp-vscode's terminal. `events.test(method, result)` is given each test result. +-- Returns `job/finished`, or nil when it did not start. +function M.job(method, params, what, events) if session.value('mcpp.task.clearTerminal') ~= false then M.clear() end @@ -85,6 +86,7 @@ function M.job(method, params, what) M.line('$ ' .. table.concat(p.argv or {}, ' ')) end, output = M.append, + test = events and events.test or nil, }) if not handle then M.line(t('mcpp: {0}', err.message)) @@ -106,7 +108,7 @@ function M.job(method, params, what) return done end --- The program `run/launch` describes, in a terminal buffer of its own (argv, its working directory, +-- The program a launch job built (its `launch`), in a terminal buffer of its own (argv, its working directory, -- the environment mcpp adds), where it reads its input. function M.terminal(launch) local from = vim.api.nvim_get_current_win() diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index 7fbf43e..919316b 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -66,7 +66,7 @@ if not mock then end write('mcpp.toml', manifest) write('src/main.cpp', 'int main() { return 0; }\n') -write('tests/test_smoke.cpp', 'int main() { return 0; }\n') +write('tests/test_smoke.cpp', '#include \nint main() { std::puts("test_smoke: ok"); return 0; }\n') -- The mock's builds take a moment, so one can be cancelled while it runs. if mock then vim.env.MOCK_MCPP_SLOW_MS = '1200' @@ -103,6 +103,17 @@ local function first(items) return items[1] end vim.opt.runtimepath:prepend(plugin) local mcppide = require('mcpp') local session = require('mcpp.session') + +-- ── the series (mcpp design 2026-11-07, D15) ─────────────────────────────────────────────────── +check('one series works together, whatever the patches', + session.incompatibility({ protocol = 2, series = '1.2', serverInfo = { version = '1.2.4' } }, '1.2.0') == nil + and session.incompatibility({ protocol = 2, serverInfo = { version = '1.2.1' } }, '1.2.3') == nil) +local older = session.incompatibility({ protocol = 2, serverInfo = { version = '0.2.1' } }, '1.2.0') +check('another series is refused, naming both versions and what installs this one', + older ~= nil and older:find('0.2.1', 1, true) ~= nil and older:find('series 0.2', 1, true) ~= nil + and older:find('mcpp-ide@1.2.0', 1, true) ~= nil, older) +check('another protocol is refused', session.incompatibility({ protocol = 1, series = '1.2' }, '1.2.0') ~= nil) +check('the plugin is of the backend series this repository builds', session.series_of(session.version) == '1.2') local diagnostics = require('mcpp.diagnostics') local views = require('mcpp.views') @@ -175,8 +186,10 @@ end local before = session.state.last vim.cmd('Mcpp build') -check('a build runs (the statusline says so)', vim.wait(10000, function() return mcppide.status():find('1 job', 1, true) ~= nil end, 20), - mcppide.status()) +check('a build runs (the statusline says so)', vim.wait(10000, function() + local said = mcppide.status() + return said:find('1 job', 1, true) ~= nil or said:find('build %d+/%d+') ~= nil +end, 20), mcppide.status()) local done = finished('build', before) check('the build finishes ok', done and done.status == 'ok', done and done.status) check('its narration is streamed into mcpp://output', output():find('$ ', 1, true) and output():find('build', 1, true) @@ -184,6 +197,44 @@ check('its narration is streamed into mcpp://output', output():find('$ ', 1, tru check('its end is said there', output():find('mcpp build: ok', 1, true) ~= nil) check('the statusline shows the last build', mcppide.status():find('build ok', 1, true) ~= nil, mcppide.status()) +-- ── a test run (D16) ───────────────────────────────────────────────────────────────────────── +before = session.state.last +vim.cmd('Mcpp test') +done = finished('test', before) +check('a test run finishes ok', done and done.status == 'ok', done and done.status) +local tests_buf = vim.fn.bufnr('mcpp://tests') +local said = tests_buf ~= -1 and table.concat(vim.api.nvim_buf_get_lines(tests_buf, 0, -1, false), '\n') or '' +check('mcpp://tests has a section for the test, with its status and its output, though it passed', + said:find('test_smoke', 1, true) ~= nil and said:find('passed', 1, true) ~= nil and said:find('test_smoke: ok', 1, true) ~= nil, + said:sub(1, 300)) +check('a run without failures leaves the quickfix list empty', #vim.fn.getqflist() == 0, #vim.fn.getqflist()) +local tests_mod = require('mcpp.tests') +check('the sign of a test that passed is ✓', tests_mod.sign_of(root, 'test_smoke') == '✓', tests_mod.sign_of(root, 'test_smoke')) +-- A failure lands in the quickfix list at the place its output names. +tests_mod.begin(root) +tests_mod.add('test/result', { root = root, test = 't', status = 'failed', durationMs = 1, output = 'tests/t.cpp:3: failed\n', + location = { path = 'tests/t.cpp', line = 2 } }) +tests_mod.finish() +local qf_items = vim.fn.getqflist() +check('a failure is put in the quickfix list at its place', #qf_items == 1 and qf_items[1].lnum == 3 + and vim.api.nvim_buf_get_name(qf_items[1].bufnr):find('tests/t.cpp', 1, true) ~= nil, vim.inspect(qf_items[1])) +check('and its sign is ✗', tests_mod.sign_of(root, 't') == '✗') + +-- ── a run (D19): a launch job builds as mcpp run does, its narration in mcpp://output ──────────── +before = session.state.last +vim.cmd('Mcpp run') +done = finished('launch', before) +check('a run is a launch job that ends with the launch', done and done.status == 'ok' and type(done.launch) == 'table' + and type(done.launch.argv) == 'table' and #done.launch.argv > 0, vim.inspect(done and done.launch)) +check("the launch job's narration is in mcpp://output", output():find('Finished', 1, true) ~= nil, output():sub(-300)) +-- The program's terminal is closed with its buffer, so the run below opens one of its own. +for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[buf].buftype == 'terminal' and vim.b[buf].mcpp_program then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end +end +pcall(vim.cmd, 'silent! only') + -- ── diagnostics ─────────────────────────────────────────────────────────────────────────────── write('src/main.cpp', 'int main() { return undeclared_name; }\n') write('.mock-mcpp-fail', 'build\n') diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json index 6dca853..413475b 100644 --- a/mide-plugins/vscode/data/i18n/zh-cn.json +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -659,5 +659,11 @@ "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})上运行;构建、运行、测试与项目页面照常可用。" + "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})上运行;构建、运行、测试与项目页面照常可用。", + "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", + "The test timed out.": "测试超时。", + "The test did not compile.": "测试没有编译通过。", + "the program could not be built": "程序未能构建" } diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json index 6dca853..413475b 100644 --- a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -659,5 +659,11 @@ "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})上运行;构建、运行、测试与项目页面照常可用。" + "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})上运行;构建、运行、测试与项目页面照常可用。", + "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", + "The test timed out.": "测试超时。", + "The test did not compile.": "测试没有编译通过。", + "the program could not be built": "程序未能构建" } diff --git a/mide-plugins/vscode/package-lock.json b/mide-plugins/vscode/package-lock.json index 24a086d..966eaee 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.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "1.1.1", + "version": "1.2.0", "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 ebeb948..75f1eb4 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.1", + "version": "1.2.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", @@ -89,6 +89,18 @@ "when": "mcpp.inProject && mcpp.editorTitleButtons" } ], + "editor/title/run": [ + { + "command": "mcpp.runThisFile", + "group": "mcpp@1", + "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" + }, + { + "command": "mcpp.debugThisFile", + "group": "mcpp@2", + "when": "mcpp.activeEntryDebug" + } + ], "view/title": [ { "command": "mcpp.refreshCacheStats", @@ -121,6 +133,14 @@ "command": "mcpp.debug", "when": "mcpp.inProject" }, + { + "command": "mcpp.runThisFile", + "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" + }, + { + "command": "mcpp.debugThisFile", + "when": "mcpp.activeEntryDebug" + }, { "command": "mcpp.pack", "when": "mcpp.inProject" @@ -159,6 +179,18 @@ "category": "%category%", "icon": "$(debug-alt)" }, + { + "command": "mcpp.runThisFile", + "title": "%command.mcpp.runThisFile.title%", + "category": "%category%", + "icon": "$(play)" + }, + { + "command": "mcpp.debugThisFile", + "title": "%command.mcpp.debugThisFile.title%", + "category": "%category%", + "icon": "$(debug-alt)" + }, { "command": "mcpp.test", "title": "%command.mcpp.test.title%", diff --git a/mide-plugins/vscode/package.nls.json b/mide-plugins/vscode/package.nls.json index 7503047..0e456e6 100644 --- a/mide-plugins/vscode/package.nls.json +++ b/mide-plugins/vscode/package.nls.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", "command.mcpp.run.title": "Run", + "command.mcpp.runThisFile.title": "Run This File's Entry", + "command.mcpp.debugThisFile.title": "Debug This File's Entry", "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", "command.mcpp.selfCheck.title": "Environment Self-check", "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", diff --git a/mide-plugins/vscode/package.nls.zh-cn.json b/mide-plugins/vscode/package.nls.zh-cn.json index 7ede0f2..bfb007f 100644 --- a/mide-plugins/vscode/package.nls.zh-cn.json +++ b/mide-plugins/vscode/package.nls.zh-cn.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "刷新缓存统计", "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", "command.mcpp.run.title": "运行", + "command.mcpp.runThisFile.title": "运行此文件的入口", + "command.mcpp.debugThisFile.title": "调试此文件的入口", "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", "command.mcpp.selfCheck.title": "环境自检", "command.mcpp.showCacheEntry.title": "查看缓存条目详情", diff --git a/mide-plugins/vscode/src/commands/ids.ts b/mide-plugins/vscode/src/commands/ids.ts index 523d9f8..46d6b9b 100644 --- a/mide-plugins/vscode/src/commands/ids.ts +++ b/mide-plugins/vscode/src/commands/ids.ts @@ -33,6 +33,9 @@ export const CLI_COMMANDS = { showCachePanel: "mcpp.showCachePanel", /** Build in the editor's title bar, with an icon (mcpp.build has none in 0.9.0's manifest). */ editorBuild: "mcpp.editor.build", + /** The editor title's Run menu: the active file's entry, run or debugged (1.2.0, D16). */ + runThisFile: "mcpp.runThisFile", + debugThisFile: "mcpp.debugThisFile", } as const; /** The language-service entries that existed in 0.4.x (kept as aliases, see below). */ diff --git a/mide-plugins/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts index c207742..d4a455b 100644 --- a/mide-plugins/vscode/src/extension.ts +++ b/mide-plugins/vscode/src/extension.ts @@ -346,7 +346,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push( session, jobs, - new Debugging(session, ideProjects, ensureMcppForAction), + new Debugging(session, ideProjects, ensureMcppForAction, jobs), testing, new McppCodeLens(session, testing, (root) => projectMcpp.get(root)), jobs.onDidChangeRunning.event((n) => void vscode.commands.executeCommand("setContext", "mcpp.jobRunning", n > 0)), diff --git a/mide-plugins/vscode/src/ide/codeLens.ts b/mide-plugins/vscode/src/ide/codeLens.ts index ecc303c..2867184 100644 --- a/mide-plugins/vscode/src/ide/codeLens.ts +++ b/mide-plugins/vscode/src/ide/codeLens.ts @@ -7,9 +7,16 @@ * * A program's Run and Debug are mcpp.run and mcpp.debug. A test's Run is its program in the Testing * view; its Debug is offered where the project's mcpp describes a test's launch (`test-launch`). + * + * THE ENTRY HAS AN ICON (design 2026-11-07, D16): the lenses are `$(play) Run` and `$(debug-alt) Debug`, + * and while the active editor is a target's entry the editor title's Run menu (`editor/title/run`) + * offers its Run and Debug (`mcpp.runThisFile`, `mcpp.debugThisFile`), by the context key + * `mcpp.activeEntry` ("program", "test" or "") and `mcpp.activeEntryDebug`. A test's cases have the + * Testing view's gutter icons, each with its last result. */ import * as vscode from "vscode"; +import { CLI_COMMANDS } from "../commands/ids"; import { read } from "../config/access"; import { t } from "../i18n/t"; import { mainLineOf } from "./mapping"; @@ -20,6 +27,8 @@ import type { Testing } from "./testing"; export const CODELENS_COMMANDS = { runTest: "mcpp.codeLens.runTest", debugTest: "mcpp.codeLens.debugTest", + runThisFile: CLI_COMMANDS.runThisFile, + debugThisFile: CLI_COMMANDS.debugThisFile, } as const; export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable { @@ -45,7 +54,42 @@ export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)); await vscode.debug.startDebugging(folder, { type: "mcpp", request: "launch", name: `mcpp: debug ${test}`, root, test }); }), + // The editor title's Run menu: the active editor's entry, run or debugged as its lens would. + vscode.commands.registerCommand(CODELENS_COMMANDS.runThisFile, async () => { + const entry = await this.activeEntry(); + if (entry?.kind === "program") await vscode.commands.executeCommand("mcpp.run"); + else if (entry?.kind === "test" && entry.root) await vscode.commands.executeCommand(CODELENS_COMMANDS.runTest, entry.root, entry.test ?? ""); + }), + vscode.commands.registerCommand(CODELENS_COMMANDS.debugThisFile, async () => { + const entry = await this.activeEntry(); + if (entry?.kind === "program") await vscode.commands.executeCommand("mcpp.debug"); + else if (entry?.kind === "test" && entry.root) await vscode.commands.executeCommand(CODELENS_COMMANDS.debugTest, entry.root, entry.test ?? ""); + }), + vscode.window.onDidChangeActiveTextEditor(() => void this.updateContext()), + vscode.workspace.onDidSaveTextDocument((doc) => { if (doc === vscode.window.activeTextEditor?.document) void this.updateContext(); }), + session.onDidStart.event(() => void this.updateContext()), ); + void this.updateContext(); + } + + /** What the active editor's file is to its project, or undefined. */ + private async activeEntry(): Promise { + const document = vscode.window.activeTextEditor?.document; + if (!document || document.languageId !== "cpp" || document.uri.scheme !== "file" || !this.session.running) return undefined; + try { + return await this.session.request("source/entries", { file: document.uri.fsPath, text: document.getText() }); + } catch { + return undefined; + } + } + + /** `mcpp.activeEntry` and `mcpp.activeEntryDebug`, for the editor title's Run menu. */ + private async updateContext(): Promise { + const entry = await this.activeEntry(); + const kind = entry && entry.kind !== "none" && entry.root !== undefined ? entry.kind : ""; + const debug = kind === "program" || (kind === "test" && !!entry?.root && !!this.projectMcpp(entry.root)?.features.includes("test-launch")); + await vscode.commands.executeCommand("setContext", "mcpp.activeEntry", kind); + await vscode.commands.executeCommand("setContext", "mcpp.activeEntryDebug", debug); } async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { @@ -70,15 +114,17 @@ export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable const range = new vscode.Range(line, 0, line, 0); const tooltip = approximate ? t("The line of main was read from the text; C++ Modules gives its position when it serves this file.") : undefined; const root = entries.root; + const run = `$(play) ${t("Run")}`; + const debug = `$(debug-alt) ${t("Debug")}`; if (entries.kind === "program") return [ - new vscode.CodeLens(range, { title: t("Run"), command: "mcpp.run", tooltip }), - new vscode.CodeLens(range, { title: t("Debug"), command: "mcpp.debug", tooltip }), + new vscode.CodeLens(range, { title: run, command: "mcpp.run", tooltip }), + new vscode.CodeLens(range, { title: debug, command: "mcpp.debug", tooltip }), ]; const test = entries.test ?? ""; - const lenses = [new vscode.CodeLens(range, { title: t("Run"), command: CODELENS_COMMANDS.runTest, arguments: [root, test], tooltip })]; + const lenses = [new vscode.CodeLens(range, { title: run, command: CODELENS_COMMANDS.runTest, arguments: [root, test], tooltip })]; if (this.projectMcpp(root)?.features.includes("test-launch")) - lenses.push(new vscode.CodeLens(range, { title: t("Debug"), command: CODELENS_COMMANDS.debugTest, arguments: [root, test], tooltip })); + lenses.push(new vscode.CodeLens(range, { title: debug, command: CODELENS_COMMANDS.debugTest, arguments: [root, test], tooltip })); return lenses; } diff --git a/mide-plugins/vscode/src/ide/compat.ts b/mide-plugins/vscode/src/ide/compat.ts new file mode 100644 index 0000000..167a023 --- /dev/null +++ b/mide-plugins/vscode/src/ide/compat.ts @@ -0,0 +1,31 @@ +// WHETHER THIS EXTENSION AND A BACKEND WORK TOGETHER (mcpp design 2026-11-07, D15). A version is +// `X.Y.Z`: `X.Y` is the series, shared by the backend and every editor plugin, and `Z` each +// component's own patch. The handshake asks protocol 2 and this extension's series; a backend before +// 1.2 states no series, and its version's `X.Y` is taken. + +export interface Handshake { + protocol?: unknown; + series?: unknown; + serverInfo?: { version?: unknown }; +} + +export type Incompatibility = + | { kind: "protocol"; protocol: unknown } + | { kind: "series"; backendVersion: string; backendSeries: string; ownVersion: string; ownSeries: string }; + +/** `X.Y` of a version `X.Y` or `X.Y.Z`; undefined for anything else. */ +export function seriesOf(version: unknown): string | undefined { + if (typeof version !== "string") return undefined; + const m = /^(\d+)\.(\d+)(\.\d+)?/.exec(version.trim()); + return m ? `${m[1]}.${m[2]}` : undefined; +} + +/** Why the backend that answered `init` does not work with this extension at `ownVersion`, or undefined. */ +export function incompatibility(init: Handshake, ownVersion: string): Incompatibility | undefined { + if (init.protocol !== 2) return { kind: "protocol", protocol: init.protocol }; + const backendVersion = typeof init.serverInfo?.version === "string" ? init.serverInfo.version : "unknown"; + const backendSeries = (typeof init.series === "string" ? init.series : undefined) ?? seriesOf(backendVersion) ?? "unknown"; + const ownSeries = seriesOf(ownVersion) ?? "unknown"; + if (backendSeries === ownSeries && ownSeries !== "unknown") return undefined; + return { kind: "series", backendVersion, backendSeries, ownVersion, ownSeries }; +} diff --git a/mide-plugins/vscode/src/ide/debug.ts b/mide-plugins/vscode/src/ide/debug.ts index 355ef1a..a23d8e1 100644 --- a/mide-plugins/vscode/src/ide/debug.ts +++ b/mide-plugins/vscode/src/ide/debug.ts @@ -9,13 +9,14 @@ import * as vscode from "vscode"; import { t } from "../i18n/t"; import { availableDebuggers } from "./mapping"; import type { IdeProjects } from "./projects"; +import type { Jobs } from "./jobs"; 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, - private readonly ensureMcpp: () => Promise) { + private readonly ensureMcpp: () => Promise, private readonly jobs?: Jobs) { this.disposables.push( vscode.debug.registerDebugConfigurationProvider("mcpp", this), vscode.debug.registerDebugConfigurationProvider("mcpp", { provideDebugConfigurations: () => [{ type: "mcpp", request: "launch", name: "mcpp: debug" }] }, @@ -43,7 +44,28 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp try { const answer = await vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: t("mcpp: building for the debugger…") }, - () => this.session.request<{ engine: string; configuration: vscode.DebugConfiguration & { mcppNote?: string } }>("debug/configuration", { + async (progress) => { + // The build first, as a launch job (design 2026-11-07 §4.9): mcpp's narration in the output and + // its steps on the progress; then the debugger's configuration, which mcpp describes on its fast path. + if (this.jobs && !config.test) { + let reported = 0; + const job = await this.jobs.start({ + kind: "launch", root: project.root, + ...(config.target !== undefined ? { target: config.target } : {}), + ...(config.profile ? { profile: config.profile } : {}), + args: Array.isArray(config.args) ? config.args : [], color: false, + }, { + output: (text) => this.session.output.append(text), + progress: ({ finished, total }) => { + const now = total > 0 ? Math.floor((finished / total) * 100) : 0; + progress.report({ message: `${finished}/${total}`, increment: Math.max(0, now - reported) }); + reported = Math.max(reported, now); + }, + }); + const built = await job.done; + if (built.status !== "ok") throw new Error(t("the program could not be built")); + } + return this.session.request<{ engine: string; configuration: vscode.DebugConfiguration & { mcppNote?: string } }>("debug/configuration", { root: project.root, // A test's program (mcpp test --print-launch), where the project's mcpp describes one. ...(config.test ? { test: config.test } : {}), @@ -54,7 +76,8 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp args: Array.isArray(config.args) ? config.args : [], available, platform: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux", - })); + }); + }); const { mcppNote, ...resolved } = answer.configuration; if (mcppNote) this.session.output.appendLine(`mcpp: ${mcppNote}`); return Object.assign({}, resolved, { name: config.name || resolved.name }); diff --git a/mide-plugins/vscode/src/ide/protocol.ts b/mide-plugins/vscode/src/ide/protocol.ts index c6ab7f9..26037e5 100644 --- a/mide-plugins/vscode/src/ide/protocol.ts +++ b/mide-plugins/vscode/src/ide/protocol.ts @@ -168,6 +168,8 @@ export interface JobFinished { envelope?: { kind?: string; data?: unknown }; diagnostics?: Diagnostic[]; artifacts?: { kind: string; name: string; path: string }[]; + /** A launch job's program, described (mcpp's `mcpp.launch` data). */ + launch?: { argv: string[]; cwd: string; env?: Record; program?: string }; } /** `source/entries` (design 2026-10-30 part 2 §6.3): what a C++ file is to its project. */ @@ -192,6 +194,12 @@ export interface TestCases { } /** `test/caseResult`: one case's outcome, read from the framework's report. */ +/** Where a failure is: the first `file:line` its output names, the line 0-based. */ +export interface FailureLocation { + path: string; + line: number; +} + export interface CaseResult { jobId: string; root: string; @@ -200,15 +208,26 @@ export interface CaseResult { status: "passed" | "failed" | "skipped"; durationMs: number; message: string; + /** The case's own output (GoogleTest's console between `[ RUN ]` and its result); empty without boundaries. */ + output?: string; + outputTruncated?: boolean; + location?: FailureLocation | null; } +/** One program's result, mcpp's status value for value (design 2026-11-07, D16). */ export interface TestResult { jobId: string; root: string; member: string; test: string; status: "passed" | "failed" | "timedOut" | "skipped" | "errored"; + timedOut?: boolean; + compileFailed?: boolean; + builtOnly?: boolean; durationMs: number; + /** The program's whole output, compile then run, passed or not. */ output: string; + outputTruncated?: boolean; + location?: FailureLocation | null; reason: string; } diff --git a/mide-plugins/vscode/src/ide/session.ts b/mide-plugins/vscode/src/ide/session.ts index bf142e5..0596f75 100644 --- a/mide-plugins/vscode/src/ide/session.ts +++ b/mide-plugins/vscode/src/ide/session.ts @@ -16,6 +16,12 @@ import { backendUnavailableHere, locateBackend, okrunDir, type Located } from ". import { mcpplsCandidates } from "./backend/mcppls"; import { MCPPLS_EXTENSION } from "./mapping"; import type { CommandEntry, InitializeResult, LanguageServiceStatus } from "./protocol"; +import { incompatibility, type Handshake } from "./compat"; + +/** This extension's version, as its manifest states it: the series the backend must share (D15). */ +function ownVersion(): string { + return (vscode.extensions.getExtension("mcpp-community.mcpp-vscode")?.packageJSON as { version?: string } | undefined)?.version ?? "unknown"; +} // The language client, bundled into dist/vendor (tools/bundle-languageclient.mjs): the VSIX carries // no node_modules. @@ -237,6 +243,18 @@ export class Session implements vscode.Disposable { try { await client.start(); this.initResult = client.initializeResult as unknown as InitializeResult; + const refused = incompatibility(this.initResult as unknown as Handshake, ownVersion()); + if (refused) { + const message = refused.kind === "protocol" + ? t("The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.") + : t("The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.", + refused.backendVersion, refused.backendSeries, refused.ownVersion, refused.ownSeries); + this.output.appendLine(`mcpp: ${message}`); + void vscode.window.showErrorMessage(`mcpp: ${message}`); + this.client = undefined; + await client.stop().catch(() => undefined); + return false; + } this.commandTable = (await this.request<{ commands: CommandEntry[] }>("commands/list", { editor: "vscode" })).commands; if (this.initResult.languageService) { this.languageServiceNow = this.initResult.languageService; diff --git a/mide-plugins/vscode/src/ide/testing.ts b/mide-plugins/vscode/src/ide/testing.ts index b0fdcc5..f298cc4 100644 --- a/mide-plugins/vscode/src/ide/testing.ts +++ b/mide-plugins/vscode/src/ide/testing.ts @@ -17,7 +17,32 @@ import type { Jobs } from "./jobs"; import { baseCaseName, caseId, parseCaseId, parseTestId, testId } from "./mapping"; import type { IdeProjects } from "./projects"; import type { Session } from "./session"; -import type { CaseResult, JobFinished, TestCases, TestResult } from "./protocol"; +import type { CaseResult, FailureLocation, JobFinished, TestCases, TestResult } from "./protocol"; + +/** Text for the test terminal, whose lines end in `\r\n`. */ +function terminal(text: string): string { + return text.replace(/\r?\n/g, "\r\n"); +} + +/** + * Where a failure is shown (D16): the place its output names, in the program's source when the + * name is relative to the project; else the item's own line (a test's `main`, a case's macro). + */ +function failureAt(root: string, location: FailureLocation | null | undefined, item: vscode.TestItem): vscode.Location | undefined { + if (location) { + const file = path.isAbsolute(location.path) ? location.path : path.join(root, location.path); + return new vscode.Location(vscode.Uri.file(file), new vscode.Position(location.line, 0)); + } + return item.uri && item.range ? new vscode.Location(item.uri, item.range) : undefined; +} + +/** The output a result carries, said to its item, with a note when the record cut it short. */ +function attribute(run: vscode.TestRun, item: vscode.TestItem, output: string | undefined, truncated: boolean | undefined, + at: vscode.Location | undefined): void { + if (!output) return; + run.appendOutput(terminal(output.endsWith("\n") ? output : `${output}\n`), at, item); + if (truncated) run.appendOutput(terminal(`${t("(the output was longer; its head and tail are shown)")}\n`), at, item); +} interface Program { root: string; @@ -149,14 +174,21 @@ export class Testing implements vscode.Disposable { const handle = await this.jobs.start( { root: program.root, test: program.test, framework: program.framework, cases: names, color: true }, { - output: (text) => run.appendOutput(text.replace(/\r?\n/g, "\r\n")), + output: (text) => run.appendOutput(terminal(text)), caseResult: (r: CaseResult) => { const item = byName.get(r.case) ?? byName.get(baseCaseName(program.framework, r.case)); if (!item) return; seen.add(item); + // Every case's own output reaches its item, passed or not (D16). + const at = r.status === "failed" ? failureAt(program.root, r.location, item) : undefined; + attribute(run, item, r.output, r.outputTruncated, at); if (r.status === "passed") run.passed(item, r.durationMs); else if (r.status === "skipped") run.skipped(item); - else run.failed(item, new vscode.TestMessage(r.message || r.status), r.durationMs); + else { + const message = new vscode.TestMessage(r.message || r.output || r.status); + message.location = at; + run.failed(item, message, r.durationMs); + } }, }, "test/runCases", @@ -177,15 +209,24 @@ export class Testing implements vscode.Disposable { const index = new Map(items.map((i) => [parseTestId(i.id)!.test, i])); const filter = items.length === 1 && request.include ? parseTestId(items[0].id)!.test : undefined; const handle = await this.jobs.start({ kind: "test", root, filter, color: true }, { - output: (text) => run.appendOutput(text.replace(/\r?\n/g, "\r\n")), + output: (text) => run.appendOutput(terminal(text)), testResult: (r: TestResult) => { const item = index.get(r.test); if (!item) return; const ms = r.durationMs; + const failing = r.status === "failed" || r.status === "timedOut" || r.status === "errored"; + const at = failing ? failureAt(root, r.location, item) : undefined; + // The program's output reaches its item whatever the result (D16). + attribute(run, item, r.output, r.outputTruncated, at); if (r.status === "passed") run.passed(item, ms); - else if (r.status === "skipped") run.skipped(item); - else { - const message = new vscode.TestMessage(r.output || r.reason || r.status); + else if (r.status === "skipped") { + if (r.reason) run.appendOutput(terminal(`${r.reason}\n`), undefined, item); + run.skipped(item); + } else { + const headline = r.status === "timedOut" ? t("The test timed out.") + : r.compileFailed ? t("The test did not compile.") : undefined; + const message = new vscode.TestMessage([headline, r.output || r.reason || r.status].filter(Boolean).join("\n")); + message.location = at; if (r.status === "errored") run.errored(item, message, ms); else run.failed(item, message, ms); } }, diff --git a/mide-plugins/vscode/test/artifacts.test.ts b/mide-plugins/vscode/test/artifacts.test.ts index 67d7b43..be8543f 100644 --- a/mide-plugins/vscode/test/artifacts.test.ts +++ b/mide-plugins/vscode/test/artifacts.test.ts @@ -369,6 +369,18 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.equal(commands.find((command) => command.command === "mcpp.test")?.icon, "$(beaker)"); assert.equal(commands.find((command) => command.command === "mcpp.editor.build")?.icon, "$(tools)"); assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); + // D16: while the active editor is a target's entry, the editor title's Run menu runs and debugs it, + // and the lenses above `main` carry the same icons. + const menus = manifest.contributes?.menus as Record> | undefined; + assert.deepEqual(menus?.["editor/title/run"], [ + { command: "mcpp.runThisFile", group: "mcpp@1", when: "mcpp.activeEntry == program || mcpp.activeEntry == test" }, + { command: "mcpp.debugThisFile", group: "mcpp@2", when: "mcpp.activeEntryDebug" }, + ]); + assert.equal(commands.find((command) => command.command === "mcpp.runThisFile")?.icon, "$(play)"); + assert.equal(commands.find((command) => command.command === "mcpp.debugThisFile")?.icon, "$(debug-alt)"); + const lens = readFileSync(path.join(root, "src", "ide", "codeLens.ts"), "utf8"); + assert.match(lens, /const run = `\$\(play\) \$\{t\("Run"\)\}`/); + assert.match(lens, /const debug = `\$\(debug-alt\) \$\{t\("Debug"\)\}`/); }); test("build.mcpp is a cpp document, not a language of its own", () => { @@ -634,7 +646,9 @@ const workflows = path.join(root, "..", "..", ".github", "workflows"); test("a release publishes the VSIX CI built, with its checksum", () => { const workflow = readFileSync(path.join(workflows, "release.yml"), "utf8"); - assert.match(workflow, /push:\s*\n\s+tags: \['v\*'\]/); + // A series release (vX.Y.0) and each component's patch tag (D15); vscode-vX.Y.Z publishes the VSIX alone. + assert.match(workflow, /push:\s*\n\s+tags: \['v\*', 'backend-v\*', 'vscode-v\*', 'clion-v\*', 'nvim-v\*'\]/); + assert.match(workflow, /release-plan\.sh "\$GITHUB_REF_NAME"/); assert.match(workflow, /contents: write/); // The release publishes what CI on the commit built and tested, not a second build. assert.match(workflow, /uses: \.\/\.github\/workflows\/ci\.yml/); @@ -665,9 +679,9 @@ test("a release verifies separate Open VSX and JetBrains uploads", () => { // 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, /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, /openvsx:\s*\n\s+needs: \[plan, release\]\s*\n\s+if: contains\(needs\.plan\.outputs\.components, 'vscode'\)/); + assert.match(workflow, /gh release download "\$GITHUB_REF_NAME" -p 'mcpp-vscode-\*\.vsix' -p SHA256SUMS/); + assert.match(workflow, /jetbrains:\s*\n\s+needs: \[plan, release\]\s*\n\s+if: contains\(needs\.plan\.outputs\.components, 'clion'\)/); 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"); diff --git a/mide-plugins/vscode/test/ide/compat.test.ts b/mide-plugins/vscode/test/ide/compat.test.ts new file mode 100644 index 0000000..29aada0 --- /dev/null +++ b/mide-plugins/vscode/test/ide/compat.test.ts @@ -0,0 +1,28 @@ +// D15: the extension works with a backend of its own series, whatever either patch is; it refuses +// another series, or another protocol, naming both versions. +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { incompatibility, seriesOf } from "../../src/ide/compat"; + +test("one series works together, whatever the patches", () => { + assert.equal(incompatibility({ protocol: 2, series: "1.2", serverInfo: { version: "1.2.0" } }, "1.2.3"), undefined); + assert.equal(incompatibility({ protocol: 2, series: "1.2", serverInfo: { version: "1.2.4" } }, "1.2.0"), undefined); + assert.equal(incompatibility({ protocol: 2, serverInfo: { version: "1.2.1" } }, "1.2.0"), undefined); +}); + +test("another series or protocol is refused, naming both versions", () => { + assert.deepEqual(incompatibility({ protocol: 2, serverInfo: { version: "0.2.1" } }, "1.2.0"), + { kind: "series", backendVersion: "0.2.1", backendSeries: "0.2", ownVersion: "1.2.0", ownSeries: "1.2" }); + assert.equal(incompatibility({ protocol: 2, series: "1.3", serverInfo: { version: "1.3.0" } }, "1.2.0")?.kind, "series"); + assert.equal(incompatibility({ protocol: 1, series: "1.2", serverInfo: { version: "1.2.0" } }, "1.2.0")?.kind, "protocol"); + assert.equal(incompatibility({}, "1.2.0")?.kind, "protocol"); + assert.equal(incompatibility({ protocol: 2, series: "1.2" }, "unknown")?.kind, "series"); +}); + +test("the series of a version", () => { + assert.equal(seriesOf("1.2.0"), "1.2"); + assert.equal(seriesOf("1.2"), "1.2"); + assert.equal(seriesOf("v1.2.0"), undefined); + assert.equal(seriesOf(undefined), undefined); +}); diff --git a/protocol/fixtures/test-cases.ndjson b/protocol/fixtures/test-cases.ndjson new file mode 100644 index 0000000..8823bfe --- /dev/null +++ b/protocol/fixtures/test-cases.ndjson @@ -0,0 +1,2 @@ +{"member":"","test":"unit/parse","status":"built","exit_code":0,"signal":null,"duration_ms":0,"timed_out":false,"compile_output":"","run_output":"","reason":"","build_ms":12,"protocol":1,"listed":[{"id":"parsing/an empty line","name":"an empty line","file":"tests/unit/parse.cpp","line":4},{"id":"parsing/a field","name":"a field","file":"tests/unit/parse.cpp","line":5}]} +{"summary":{"member":"","passed":0,"failed":0,"not_run":0,"not_run_reason":"","built":1,"elapsed_ms":20,"build_ms":12,"run_ms":0}} diff --git a/protocol/fixtures/test-protocol.ndjson b/protocol/fixtures/test-protocol.ndjson new file mode 100644 index 0000000..480c3aa --- /dev/null +++ b/protocol/fixtures/test-protocol.ndjson @@ -0,0 +1,2 @@ +{"member":"","test":"unit/parse","status":"run_fail","exit_code":1,"signal":null,"duration_ms":4,"timed_out":false,"compile_output":"","run_output":"","reason":"","build_ms":0,"protocol":1,"cases":[{"id":"parsing/an empty line","status":"pass","duration_ms":1},{"id":"parsing/a field","status":"fail","duration_ms":2,"failures":[{"file":"tests/unit/parse.cpp","line":7,"message":"1 == 2","expected":"2","actual":"1"}]}]} +{"summary":{"member":"","passed":0,"failed":1,"not_run":0,"not_run_reason":"","built":0,"elapsed_ms":9,"build_ms":0,"run_ms":4}} diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index 3478b60..a951cca 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -167,13 +167,16 @@ "result": { "type": ["object", "null"] } }, "mcpp-ide/job/start": { - "description": "Queues a job on its project's lane: one at a time per project, in order; a build or test asked for again replaces the waiting one, and `supersede` cancels the running one.", + "description": "Queues a job on its project's lane: one at a time per project, in order; a build or test asked for again replaces the waiting one, and `supersede` cancels the running one. A `launch` job builds a program as mcpp run does (`mcpp run --print-launch`, with mcpp's launch-stream its build events), its program arguments in `args`, and ends with the program's launch in job/finished.", "params": { "type": "object", "required": ["kind", "root"], "properties": { - "kind": { "enum": ["build", "test", "pack", "toolchain", "add", "command"] }, + "kind": { "enum": ["build", "test", "pack", "toolchain", "add", "launch", "command"] }, "root": { "type": "string" }, + "bin": { "type": "string", "description": "A launch job's program; absent: the project's one." }, + "test": { "type": "string", "description": "A launch job's test (mcpp test --print-launch)." }, + "runner": { "type": "string", "description": "A launch job's named runner." }, "target": { "type": "string", "description": "The target triple; absent: the mcpp.build.target setting; empty: this machine." }, "profile": { "enum": ["dev", "release"] }, "filter": { "type": "string" }, @@ -436,20 +439,27 @@ "kind": { "type": "string" }, "envelope": { "type": "object" }, "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, - "artifacts": { "type": "array" } + "artifacts": { "type": "array" }, + "launch": { "type": "object", "description": "A launch job's program, described: the data of mcpp's mcpp.launch." } } } }, "mcpp-ide/test/result": { + "description": "One test program's result, mcpp's status value for value: pass is passed; run_fail failed, or timedOut when mcpp says timed_out; compile_fail failed with compileFailed; not_run skipped with mcpp's reason; built skipped with builtOnly. Every result carries the program's whole output (compile, then run), passed or not, the head and the tail past 1 MiB with outputTruncated; a failure's location is the first file:line its output names, or null.", "params": { "type": "object", - "required": ["jobId", "test", "status"], + "required": ["jobId", "test", "status", "output", "outputTruncated"], "properties": { "test": { "type": "string" }, "member": { "type": "string" }, "status": { "enum": ["passed", "failed", "timedOut", "skipped", "errored"] }, + "timedOut": { "type": "boolean" }, + "compileFailed": { "type": "boolean" }, + "builtOnly": { "type": "boolean" }, "durationMs": { "type": "integer" }, "output": { "type": "string" }, + "outputTruncated": { "type": "boolean" }, + "location": { "$ref": "#/$defs/failureLocation" }, "reason": { "type": "string" } } } @@ -459,10 +469,11 @@ "mcpp-ide/manifest/changed": { "params": { "type": "object", "required": ["root"] } }, "mcpp-ide/manifest/diagnostics": { "params": { "type": "object", "required": ["root", "diagnostics"] } }, "mcpp-ide/project/mcppChanged": { "description": "A job's envelope named another mcpp than the project's was asked as: it is asked again on its next use.", "params": { "type": "object", "required": ["root", "version"] } }, - "mcpp-ide/test/caseResult": { "description": "One case's outcome, read from the framework's report when a test/runCases job ended.", "params": { "type": "object", "required": ["jobId", "root", "test", "case", "status"], "properties": { "status": { "enum": ["passed", "failed", "skipped"] }, "durationMs": { "type": "integer" }, "message": { "type": "string" } } } }, + "mcpp-ide/test/caseResult": { "description": "One case's outcome, read from the framework's report when a test/runCases job ended, with the case's own output (GoogleTest's console between [ RUN ] and its result line; empty for a framework whose console names no case) and a failure's location.", "params": { "type": "object", "required": ["jobId", "root", "test", "case", "status", "output", "outputTruncated"], "properties": { "status": { "enum": ["passed", "failed", "skipped"] }, "durationMs": { "type": "integer" }, "message": { "type": "string" }, "output": { "type": "string" }, "outputTruncated": { "type": "boolean" }, "location": { "$ref": "#/$defs/failureLocation" } } } }, "mcpp-ide/languageService/changed": { "description": "mcppls answered differently from the last time it was asked: another program or version, other actions, or another state of the workspace. An editor that lists the commands asks for them again.", "params": { "type": "object", "required": ["root", "state", "workspaceState"] } } }, "$defs": { + "failureLocation": { "description": "Where a failure is: the first file:line (or file(line):) its output names, line 0-based; null when it names none.", "oneOf": [{ "type": "null" }, { "type": "object", "required": ["path", "line"], "properties": { "path": { "type": "string" }, "line": { "type": "integer", "minimum": 0 } } }] }, "settingValues": { "type": "object", "additionalProperties": true }, "resolvedSettings": { "type": "object", "required": ["values", "rejected", "renames", "deprecated"] }, "rooted": { "type": "object", "required": ["root"], "properties": { "root": { "type": "string" } } }, diff --git a/spec/commands.json b/spec/commands.json index 5cf4994..e1d357c 100644 --- a/spec/commands.json +++ b/spec/commands.json @@ -26,6 +26,18 @@ "action": { "kind": "run" }, "quickMenu": true }, { "id": "mcpp.debug", "paletteWhen": "mcpp.inProject", "group": "project", "icon": { "vscode": "$(debug-alt)", "jetbrains": "AllIcons.Actions.StartDebugger" }, "when": "mcpp.inProject", "placements": { "title": 3, "context": 2, "gutter": 2 }, "action": { "kind": "debug" }, "quickMenu": true }, + { "id": "mcpp.runThisFile", "group": "project", "paletteWhen": "mcpp.activeEntry == program || mcpp.activeEntry == test", "editors": ["vscode"], + "omitted": { "jetbrains": "CLion runs a file's entry from its gutter and its context menu (mcpp.run).", + "nvim": "Neovim runs a file's entry from :Mcpp run and its signs." }, + "icon": { "vscode": "$(play)" }, "when": "mcpp.inProject", + "menus": [ { "menu": "editor/title/run", "group": "mcpp@1", "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" } ], + "action": { "kind": "run" } }, + { "id": "mcpp.debugThisFile", "group": "project", "paletteWhen": "mcpp.activeEntryDebug", "editors": ["vscode"], + "omitted": { "jetbrains": "CLion debugs a file's entry from its gutter and its context menu (mcpp.debug).", + "nvim": "Neovim debugs a file's entry from :Mcpp debug." }, + "icon": { "vscode": "$(debug-alt)" }, "when": "mcpp.inProject", + "menus": [ { "menu": "editor/title/run", "group": "mcpp@2", "when": "mcpp.activeEntryDebug" } ], + "action": { "kind": "debug" } }, { "id": "mcpp.test", "group": "project", "icon": { "vscode": "$(beaker)", "jetbrains": "AllIcons.RunConfigurations.TestState.Run" }, "when": "mcpp.inProject", "keybinding": { "key": "ctrl+alt+t", "mac": "cmd+alt+t" }, "menus": [ { "menu": "editor/title", "group": "navigation@2", "when": "mcpp.inProject && mcpp.editorTitleButtons" } ], diff --git a/spec/i18n/manifest.en.json b/spec/i18n/manifest.en.json index 7503047..0e456e6 100644 --- a/spec/i18n/manifest.en.json +++ b/spec/i18n/manifest.en.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", "command.mcpp.run.title": "Run", + "command.mcpp.runThisFile.title": "Run This File's Entry", + "command.mcpp.debugThisFile.title": "Debug This File's Entry", "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", "command.mcpp.selfCheck.title": "Environment Self-check", "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", diff --git a/spec/i18n/manifest.zh-cn.json b/spec/i18n/manifest.zh-cn.json index 7ede0f2..bfb007f 100644 --- a/spec/i18n/manifest.zh-cn.json +++ b/spec/i18n/manifest.zh-cn.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "刷新缓存统计", "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", "command.mcpp.run.title": "运行", + "command.mcpp.runThisFile.title": "运行此文件的入口", + "command.mcpp.debugThisFile.title": "调试此文件的入口", "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", "command.mcpp.selfCheck.title": "环境自检", "command.mcpp.showCacheEntry.title": "查看缓存条目详情", diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index e06e0ec..07d478b 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -808,5 +808,10 @@ "{0} days or more": "{0} 天及以上", "{0} in {1} entries": "{0},共 {1} 项", "{0} incomplete entries": "{0} 项不完整", - "{0} · {1} entries": "{0} · {1} 项" + "{0} · {1} entries": "{0} · {1} 项", + "timed out": "超时", + "did not compile": "没有编译通过", + "built, not run": "已构建,未运行", + "mcpp test in {0}": "{0} 中的 mcpp test", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)" } diff --git a/spec/test-frameworks.json b/spec/test-frameworks.json index 21f4b63..48bc055 100644 --- a/spec/test-frameworks.json +++ b/spec/test-frameworks.json @@ -1,6 +1,21 @@ { - "description": "The test frameworks whose cases the editors show (mcpp design 2026-10-30 part 2 §7.2). A test program belongs to a framework when its plan holds one of the framework's packages and its source calls one of the framework's macros. `name` says how a case's full name is made from the macro's arguments; `list` lists the cases (run only for a program whose source calls a macro); `filter` selects cases, `{}` being the cases joined by `join`, each with the characters of `escape` preceded by a backslash (a GoogleTest name is two identifiers, which hold none of its filter's special characters); `report` writes the results to the file `{}`, read as `reportFormat`. boost-ext.ut is not here: its runner takes no filter from the command line, so its tests run as programs.", + "description": "The test frameworks whose cases the editors show (mcpp design 2026-10-30 part 2 §7.2). A test program belongs to a framework when its plan holds one of the framework's packages and its source calls one of the framework's macros. `name` says how a case's full name is made from the macro's arguments; `list` lists the cases (run only for a program whose source calls a macro); `filter` selects cases, `{}` being the cases joined by `join`, each with the characters of `escape` preceded by a backslash (a GoogleTest name is two identifiers, which hold none of its filter's special characters); `report` writes the results to the file `{}`, read as `reportFormat`. A framework with `protocol` implements the mtest test protocol (mcpp 2026.11.8.1, feature `test-protocol`): its cases are its `\"name\"_test` literals, listed by `mcpp test --cases`, run by `mcpp test --case` and reported in mcpp's records; a program of any framework that implements the protocol (GoogleTest through mcpplibs.mtest.gtest) is listed and run so. boost-ext.ut is not here: its runner takes no filter from the command line, so its tests run as programs.", "frameworks": [ + { + "id": "mtest", + "title": "mtest", + "packages": ["mcpplibs.mtest"], + "protocol": 1, + "macros": [], + "name": "literal", + "list": [], + "listFormat": "lines", + "filter": [], + "join": ",", + "escape": "", + "report": [], + "reportFormat": "" + }, { "id": "gtest", "title": "GoogleTest", diff --git a/src/bin/dev.cpp b/src/bin/dev.cpp index de1fc37..eb42ac7 100644 --- a/src/bin/dev.cpp +++ b/src/bin/dev.cpp @@ -18,6 +18,11 @@ // Every runtime string the backend, the panels and the plugins pass to t(...) has a Chinese // entry in spec/i18n/runtime.zh-cn.json; every editor string a Chinese one in // manifest.zh-cn.json. +// mcpp-ide-dev check-sources +// No C++ source of the backend, its tests or its test support includes a header or branches on +// the platform in the preprocessor, and none asks `os::is_`: the system is +// mcpplibs.os's, and every platform branch of a program shipped as an ok image is a run-time +// one (`os::can_run_on` and `os::host()`; design 2026-11-08 S3, S4). // // The VS Code declarations are kept in the repository (mcpp-vscode packs its own directory, and its // gates read the manifest); CI checks they are spec/'s. The CLion declarations go where its build @@ -511,24 +516,63 @@ int check_i18n(std::span) { return missing.empty() && missingEditor.empty() ? 0 : 1; } +// ── check-sources ─────────────────────────────────────────────────────────── + +int check_sources(std::span) { + const auto root = repo(); + std::size_t files = 0; + std::vector problems; + std::error_code ec; + for (const char* dir : {"src", "tests", "testsupport"}) + for (fs::recursive_directory_iterator it(root / dir, ec), end; !ec && it != end; it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + const auto ext = it->path().extension().string(); + if (ext != ".cpp" && ext != ".cppm" && ext != ".hpp" && ext != ".h") continue; + ++files; + const auto rel = it->path().lexically_relative(root).generic_string(); + if (ext == ".hpp" || ext == ".h") { problems.push_back(std::format("{}: a header; a module states what it shares", rel)); continue; } + const auto text = slurp(it->path()); + std::size_t line = 0; + for (std::size_t at = 0; at <= text.size();) { + const auto nl = std::min(text.find('\n', at), text.size()); + const std::string_view row(text.data() + at, nl - at); + ++line; + const auto first = row.find_first_not_of(" \t"); + const auto code = first == std::string_view::npos ? std::string_view{} : row.substr(first); + if (code.starts_with("#include")) problems.push_back(std::format("{}:{}: includes a header", rel, line)); + else if (code.starts_with("#if") || code.starts_with("#elif")) problems.push_back(std::format("{}:{}: a preprocessor branch", rel, line)); + // This program's own rule names what it looks for. + else if (it->path().filename() != "dev.cpp") + for (std::string_view name : {"is_windows", "is_linux", "is_macos", "is_posix"}) + if (row.find(std::format("os::{}", name)) != std::string_view::npos) + problems.push_back(std::format("{}:{}: asks os::{}, which an ok image answers false; ask os::host(), or os::on

for a platform module", rel, line, name)); + at = nl + 1; + } + } + for (const auto& p : problems) std::println(std::cerr, "{}", p); + std::println("check-sources: {} files, {} problems", files, problems.size()); + return problems.empty() ? 0 : 1; +} + } // namespace int main(int argc, char** argv) { std::vector args(argv + 1, argv + argc); try { if (args.empty() || args[0] == "--help") { - std::println("mcpp-ide-dev capture | gen vscode | gen jetbrains --out DIR | check-i18n"); + std::println("mcpp-ide-dev capture | gen vscode | gen jetbrains --out DIR | check-i18n | check-sources"); return args.empty() ? 2 : 0; } const std::span rest(args.begin() + 1, args.end()); if (args[0] == "capture") return capture(rest); if (args[0] == "check-i18n") return check_i18n(rest); + if (args[0] == "check-sources") return check_sources(rest); if (args[0] == "gen" && !rest.empty()) { const std::span more(rest.begin() + 1, rest.end()); if (rest[0] == "vscode") return gen_vscode(more); if (rest[0] == "jetbrains") return gen_jetbrains(more); } - std::println(std::cerr, "mcpp-ide-dev: unknown command (capture, gen vscode|jetbrains, check-i18n)"); + std::println(std::cerr, "mcpp-ide-dev: unknown command (capture, gen vscode|jetbrains, check-i18n, check-sources)"); return 2; } catch (const std::exception& e) { std::println(std::cerr, "mcpp-ide-dev: {}", e.what()); diff --git a/src/bin/mockmcpp.cpp b/src/bin/mockmcpp.cpp index c88e113..2ba39ef 100644 --- a/src/bin/mockmcpp.cpp +++ b/src/bin/mockmcpp.cpp @@ -176,10 +176,26 @@ int main(int argc, char** argv) { // 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); + if (text.empty()) text = fixture("launch.json"); + // `--message-format json` (mcpp's launch-stream): the build's events, then the envelope as the last line. + if (has("--message-format")) { + std::string line; + for (char c : text) if (c != '\n' && c != '\r') line += c; + out("{\"schemaVersion\":1,\"kind\":\"mcpp.event\",\"event\":\"progress\",\"finished\":1,\"total\":2}\n" + "{\"schemaVersion\":1,\"kind\":\"mcpp.event\",\"event\":\"progress\",\"finished\":2,\"total\":2}\n" + line + "\n"); + } else { + out(text); + } + err(" Compiling app v0.1.0 (.)\n Finished dev [unoptimized + debuginfo] in 0.01s\n"); + return 0; + } + // A program that implements the test protocol: its listing (`--cases`) and a run of a selection + // (`--case`), each a record with the protocol's fields (mcpp docs/50 §8). + if (first(0) == "test") { + out(fixture(has("--list") ? "test-list.ndjson" : has("--cases") ? "test-cases.ndjson" + : has("--case") ? "test-protocol.ndjson" : "test.ndjson")); return 0; } - if (first(0) == "test") { out(fixture(has("--list") ? "test-list.ndjson" : "test.ndjson")); 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/jobs.cppm b/src/jobs.cppm index 69211fb..ad0aa46 100644 --- a/src/jobs.cppm +++ b/src/jobs.cppm @@ -23,7 +23,7 @@ export namespace mcppide::jobs { using Json = nlohmann::json; -enum class Kind { build, test, pack, clean, toolchain, add, command }; +enum class Kind { build, test, pack, clean, toolchain, add, launch, command }; // What standard output carries: narration for a person, NDJSON records (a // `--message-format json` stream), or one envelope printed at the end. @@ -109,13 +109,14 @@ std::string_view kind_name(Kind k) { case Kind::clean: return "clean"; case Kind::toolchain: return "toolchain"; case Kind::add: return "add"; + case Kind::launch: return "launch"; case Kind::command: return "command"; } return "command"; } std::optional kind_from(std::string_view s) { - for (auto k : {Kind::build, Kind::test, Kind::pack, Kind::clean, Kind::toolchain, Kind::add, Kind::command}) + for (auto k : {Kind::build, Kind::test, Kind::pack, Kind::clean, Kind::toolchain, Kind::add, Kind::launch, Kind::command}) if (kind_name(k) == s) return k; return std::nullopt; } diff --git a/src/languageservice.cppm b/src/languageservice.cppm index 633cbeb..ccdbe55 100644 --- a/src/languageservice.cppm +++ b/src/languageservice.cppm @@ -15,10 +15,13 @@ // those releases offered (the frozen table) and the workspace is not known. // // A SERVER IS A COMMAND. mcppls's payload (the VSIX's and the CLion package's `payload/`, the one Zed's -// install puts in the user data directory) carries the server as one okb of both architectures that the -// platform's runner starts: payload.json's `server` names `runner` and `path` (`bin/okrun bin/mcppls.okb`), -// or a program alone. A candidate is a payload directory or a program, and what is asked for its version -// is the command it stands for. +// install puts in the user data directory) carries the server as one okb of both architectures that a +// runner starts. payload.json says which, as mcppls's specification S6 states it: payload version 5 (mcppls +// 0.0.20 and later) names the runner of each platform under `platforms..runner`, and the command is +// that runner and `server.path`; version 4 (0.0.18, 0.0.19) serves the one `platform` it names, with +// `server.runner` and `server.path` (`bin/okrun bin/mcppls.okb`); earlier ones name a program alone. A payload +// that does not serve this system is refused, naming the platforms it does serve. A candidate is a payload +// directory or a program, and what is asked for its version is the command it stands for. export module mcppide.languageservice; import std; @@ -51,13 +54,26 @@ struct Status { struct Candidate { std::vector command; std::string source; + std::string problem; // a payload refused: why (S6 section 4); the command is then empty }; +// mcppls's name for this system (mcppls S6 section 1): the system and the architecture as VS Code names +// extension targets -- linux-x64, linux-arm64, darwin-arm64, darwin-x64, win32-x64, win32-arm64. The one +// place the editors' adapters take it from. +std::string host_platform(); + +// The server of a payload directory for `platform` (S6 section 4): the command, or why there is none. +struct PayloadServer { + std::vector command; + std::string problem; +}; +PayloadServer payload_server(const std::filesystem::path& payload, std::string_view platform = host_platform()); + // The version in `mcppls --version`'s output: the first word that starts with a digit. std::string version_in(std::string_view output); -// The command payload.json's `server` names in a payload directory: the runner and the image, or the -// program. Empty when the directory holds no payload or what it names is not there. +// The command a payload directory's payload.json names for this system: the runner and the image, or the +// program. Empty when the directory holds no payload, serves another system, or what it names is not there. std::vector payload_command(const std::filesystem::path& payload); // The command a path the adapter named stands for: a payload directory's server, or the program itself. @@ -136,7 +152,17 @@ std::filesystem::path within(const std::filesystem::path& payload, const std::st return payload / relative; } -std::vector payload_command(const std::filesystem::path& payload) { +std::string host_platform() { + const bool arm = mcpplibs::os::ARCH == mcpplibs::os::architecture::aarch64; + switch (mcpplibs::os::host()) { + case mcpplibs::os::platform::windows: return arm ? "win32-arm64" : "win32-x64"; + case mcpplibs::os::platform::macos: return arm ? "darwin-arm64" : "darwin-x64"; + default: break; + } + return arm ? "linux-arm64" : "linux-x64"; +} + +PayloadServer payload_server(const std::filesystem::path& payload, std::string_view platform) { std::error_code ec; std::ifstream in(payload / "payload.json", std::ios::binary); if (!in) return {}; @@ -144,12 +170,39 @@ std::vector payload_command(const std::filesystem::path& if (!manifest.is_object() || !manifest.contains("server") || !manifest["server"].is_object()) return {}; const Json& server = manifest["server"]; if (!server.contains("path") || !server["path"].is_string()) return {}; + const int version = manifest.contains("payload-version") && manifest["payload-version"].is_number_integer() + ? manifest["payload-version"].get() : 0; + const auto refused = [&](std::string why) { return PayloadServer{{}, std::format("the mcppls payload {}: {}", payload.string(), why)}; }; + std::optional runner; + if (version >= 6) return refused(std::format("payload-version {} is not one this mcpp-ide reads (4, 5)", version)); + if (version == 5) { + // One payload for every platform: this system's entry names its runner. + if (!manifest.contains("platforms") || !manifest["platforms"].is_object()) return refused("version 5 without platforms"); + const Json& platforms = manifest["platforms"]; + if (!platforms.contains(std::string(platform)) || !platforms[std::string(platform)].is_object()) { + std::string served; + for (const auto& item : platforms.items()) served += (served.empty() ? "" : ", ") + item.key(); + return refused(std::format("it serves {}, and this system is {}", served.empty() ? std::string("no platform") : served, platform)); + } + const Json& entry = platforms[std::string(platform)]; + if (entry.contains("runner") && entry["runner"].is_string()) runner = entry["runner"].get(); + } else { + // Version 4 serves the one platform it names; earlier ones name none. + if (version == 4 && manifest.contains("platform") && manifest["platform"].is_string() && + manifest["platform"].get() != platform) + return refused(std::format("it serves {}, and this system is {}", manifest["platform"].get(), platform)); + if (server.contains("runner") && server["runner"].is_string()) runner = server["runner"].get(); + } std::vector command; - if (server.contains("runner") && server["runner"].is_string()) command.push_back(within(payload, server["runner"].get())); + if (runner) command.push_back(within(payload, *runner)); command.push_back(within(payload, server["path"].get())); for (const auto& part : command) if (part.empty() || !std::filesystem::is_regular_file(part, ec)) return {}; - return command; + return {command, {}}; +} + +std::vector payload_command(const std::filesystem::path& payload) { + return payload_server(payload).command; } std::vector command_of(const std::filesystem::path& named) { @@ -159,9 +212,23 @@ std::vector command_of(const std::filesystem::path& named return {}; } +// A candidate the adapter or the user data directory names: a payload's server, or a program. +Candidate candidate_of(const std::filesystem::path& named, std::string source) { + std::error_code ec; + if (std::filesystem::is_directory(named, ec)) { + auto served = payload_server(named); + return {std::move(served.command), std::move(source), std::move(served.problem)}; + } + return {command_of(named), std::move(source), {}}; +} + std::vector candidates(const std::vector& fromEditor) { std::vector out; - for (const auto& p : fromEditor) if (!p.empty()) out.push_back({command_of(p), "editor"}); + // An editor names a payload in the host's spelling (`C:\Users\x\payload`); its generic one is the + // name every path built from it extends (mcpplibs.os). + for (const auto& p : fromEditor) + if (!p.empty()) + out.push_back(candidate_of(mcpplibs::os::fs::from_utf8(mcpplibs::os::fs::generic(mcpplibs::os::fs::from_utf8(p))), "editor")); if (auto found = mcpplibs::os::env::find_executable("mcppls")) out.push_back({{*found}, "PATH"}); const auto exe = std::string("mcppls") + std::string(mcpplibs::os::conventions().exe_suffix); if (auto xh = mcpplibs::os::env::get("XLINGS_HOME")) @@ -171,8 +238,9 @@ std::vector candidates(const std::vector& fromEditor) { out.push_back({command_of(*home / ".xlings" / "subos" / "default" / "bin" / exe), "xlings"}); } // The payload Zed's install puts at `/mcppls/payload`. - if (auto data = mcpplibs::os::dirs::data()) out.push_back({payload_command(*data / "mcppls" / "payload"), "payload"}); - std::erase_if(out, [](const Candidate& c) { return c.command.empty(); }); + if (auto data = mcpplibs::os::dirs::data()) out.push_back(candidate_of(*data / "mcppls" / "payload", "payload")); + // A refused payload stays in the list, so that a search that finds nothing else can say why. + std::erase_if(out, [](const Candidate& c) { return c.command.empty() && c.problem.empty(); }); return out; } @@ -192,9 +260,15 @@ Status classify(const std::filesystem::path& program, std::string source, std::s // The first candidate is the one asked: the one the adapter named, else the first found. Status probe(const std::vector& fromEditor) { - const auto found = candidates(fromEditor); - if (found.empty()) return Status{}; - const auto& [command, source] = found.front(); + const auto all = candidates(fromEditor); + std::vector found; + for (const auto& c : all) if (!c.command.empty()) found.push_back(c); + if (found.empty()) { + Status none; + for (const auto& c : all) if (!c.problem.empty()) { none.detail = c.problem; none.source = c.source; break; } + return none; + } + const auto& [command, source, problem] = found.front(); mcpplibs::os::process::spawn_options o; o.program = command.front(); for (std::size_t i = 1; i < command.size(); ++i) o.args.push_back(command[i].string()); diff --git a/src/scan.cppm b/src/scan.cppm index 69955d7..0d0cc41 100644 --- a/src/scan.cppm +++ b/src/scan.cppm @@ -25,9 +25,18 @@ struct Call { std::vector arguments; }; +// A case of the mtest library (`"name"_test`): its identifier -- the names of the suites it is +// declared in and its own, joined by `/` -- and where its name is (0-based). +struct Literal { + std::string id; + int line = 0; + int column = 0; +}; + struct Result { std::optional mainLine; // 0-based std::vector calls; + std::vector cases; // `"name"_test`, in `suite x{"name", ...}` scopes }; // Scans `text` for `main` and for calls of the macros named in `macros`. @@ -155,6 +164,17 @@ Result scan(std::string_view text, const std::vector& macros) { bool atDeclaration = true; // the next identifier starts a declaration bool lineHasCode = false; // a `#` is a directive only first on its line std::string previous; // the previous identifier, for `int main(` + // A suite of the mtest library: `suite {"", ...}`, whose `{` opens the scope its + // cases' identifiers are prefixed in. `suiteState` follows the declaration: 1 after `suite`, + // 2 after its variable's name, 3 after the `{` (the next literal is the suite's identifier). + struct Scope { std::string name; int depth; }; + std::vector suites; + int suiteState = 0, suiteDepth = 0; + auto prefix = [&] { + std::string out; + for (const auto& s : suites) if (!s.name.empty()) out += s.name + "/"; + return out; + }; while (!c.done()) { const char ch = c.peek(); if (ch == '\n') { lineHasCode = false; c.step(); continue; } @@ -164,7 +184,22 @@ Result scan(std::string_view text, const std::vector& macros) { if (ch == '#' && !lineHasCode) { c.skip_directive(); atDeclaration = true; previous.clear(); continue; } lineHasCode = true; if (c.on_raw()) { c.skip_raw(); atDeclaration = false; previous.clear(); continue; } - if (ch == '"' || ch == '\'') { c.skip_quoted(ch); atDeclaration = false; previous.clear(); continue; } + if (ch == '"') { + const auto from = c.at; + const int line = c.line, column = c.column(); + c.skip_quoted(ch); + const auto value = literal_value(text.substr(from, c.at - from)); + if (suiteState == 3) { + suites.push_back({value, suiteDepth}); + suiteState = 0; + } else if (text.substr(c.at, 5) == "_test" && !(c.at + 5 < text.size() && ident_char(text[c.at + 5]))) { + r.cases.push_back(Literal{prefix() + value, line, column}); + } + atDeclaration = false; + previous.clear(); + continue; + } + if (ch == '\'') { c.skip_quoted(ch); atDeclaration = false; previous.clear(); continue; } if (ident_start(ch)) { const auto from = c.at; const int line = c.line, column = c.column(); @@ -179,6 +214,10 @@ Result scan(std::string_view text, const std::vector& macros) { break; } const bool call = look.peek() == '('; + // `suite`, or `mtest::suite` (the identifier before `::` is kept in `previous`). + if (word == "suite" && (atDeclaration || previous == "mtest")) suiteState = 1; + else if (suiteState == 1) suiteState = 2; + else suiteState = 0; if (call && atDeclaration && std::ranges::find(macros, word) != macros.end()) { c = look; r.calls.push_back(Call{word, line, column, arguments(c)}); @@ -192,10 +231,18 @@ Result scan(std::string_view text, const std::vector& macros) { atDeclaration = false; continue; } - if (ch == '{') { ++depth; atDeclaration = true; } - else if (ch == '}') { if (depth > 0) --depth; atDeclaration = true; } - else if (ch == ';') atDeclaration = true; - else atDeclaration = false; + if (ch == '{') { + if (suiteState == 2) { suiteState = 3; suiteDepth = depth; } + else suiteState = 0; + ++depth; + atDeclaration = true; + } else if (ch == '}') { + if (depth > 0) --depth; + while (!suites.empty() && suites.back().depth >= depth) suites.pop_back(); + atDeclaration = true; + } + else if (ch == ';') { atDeclaration = true; suiteState = 0; } + else { atDeclaration = false; suiteState = 0; } // `std::main`, `x.main`: not the program's. if (ch != ':' ) previous.clear(); c.step(); diff --git a/src/server.cppm b/src/server.cppm index ce7584a..c0912c3 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -33,7 +33,12 @@ export namespace mcppide::server { using Json = nlohmann::json; -inline constexpr std::string_view kVersion = "0.2.1"; +inline constexpr std::string_view kVersion = "1.2.0"; +// THE SERIES (mcpp design 2026-11-07, D15): `X.Y` of `X.Y.Z`, shared by the backend and every editor +// plugin; components of one series work together, and `Z` is each component's own patch. A plugin +// refuses a backend of another series, naming both versions. +inline constexpr std::string_view kSeries = "1.2"; +static_assert(kVersion.starts_with(kSeries) && kVersion.size() > kSeries.size() && kVersion[kSeries.size()] == '.'); inline constexpr int kProtocol = 2; // The methods `install` registers, for the protocol schema's test: a method @@ -127,6 +132,7 @@ private: mcpp::ProjectMcpp probe_of(const std::filesystem::path& root); Json probe_json(const std::filesystem::path& root, const mcpp::ProjectMcpp& probe) const; bool has_feature(const std::filesystem::path& root, std::string_view feature); + std::vector launch_args(const Json& p, bool stream, bool programArgs); // A line of the backend's log, at a level (0 error, 1 warning, 2 info, 3 debug): written to // standard error, which every editor keeps in its log, when mcpp.backend.logLevel lets it // through, and kept for the IDE report either way (§3.2). @@ -180,6 +186,12 @@ private: std::map buildDatabases_; // The project and kind of each job, for the notifications its records become. std::map, std::less<>> jobOf_; + // The whole output of the last test record of each running test job, for a case run to divide + // among its cases when the job ends (test/runCases). + std::map> programOutput_; + // The last test record of each running test job, whole: a program that implements the test + // protocol reports its cases in it (test/runCases). + std::map> testRecord_; // The configuration (and platforms) the last plan of each project named. std::map plans_; // What `mcpp pack` accepts, read from its help once per mcpp (SPEC-014: `--pkg-format` names the @@ -203,8 +215,17 @@ namespace mcppide::server { namespace { rpc::Error invalid(std::string message) { return rpc::Error{rpc::INVALID_PARAMS, std::move(message)}; } +// A name an editor or the environment spells in the host's rules (`C:\Users\x` on Windows), in the +// generic spelling every name the backend builds, compares and reports uses: `/` between components +// (mcpplibs.os). The backend runs as an ok image, whose names are `/`-separated on every system; a +// `\` left in a name is read as part of a component there, so `C:\a\b` would be one relative name. +std::filesystem::path host_path(std::string_view text) { + if (text.empty()) return {}; + namespace fs = mcpplibs::os::fs; + return fs::from_utf8(fs::generic(fs::from_utf8(text))); +} std::filesystem::path root_of(const Json& p) { - return p.contains("root") && p["root"].is_string() ? std::filesystem::path(p["root"].get()) : std::filesystem::path{}; + return p.contains("root") && p["root"].is_string() ? host_path(p["root"].get()) : std::filesystem::path{}; } Json project_json(const projects::Project& p) { return Json{{"root", p.root.generic_string()}, {"workspaceRoot", p.workspaceRoot.generic_string()}, @@ -232,6 +253,9 @@ Session::Session(rpc::Server& server) : rpc_(server) { const auto& env = summary["envelope"]; if (env.is_object() && env.contains("data") && env["data"].is_object() && env["data"].contains("artifacts")) s["artifacts"] = env["data"]["artifacts"]; + // A launch job ends with the program it built, described (`mcpp.launch`). + if (env.is_object() && env.value("kind", "") == "mcpp.launch" && env.contains("data")) + s["launch"] = env["data"]; } std::filesystem::path root; { @@ -242,6 +266,8 @@ Session::Session(rpc::Server& server) : rpc_(server) { root = it->second.first; jobOf_.erase(it); } + if (auto it = programOutput_.find(id); it != programOutput_.end()) programOutput_.erase(it); + if (auto it = testRecord_.find(id); it != testRecord_.end()) testRecord_.erase(it); } rpc_.notify("mcpp-ide/job/finished", s); // Every envelope names the mcpp that wrote it: another version than the one probed (a @@ -399,6 +425,11 @@ void Session::on_record(std::string_view jobId, const Json& record) { } else if (type == "artifact") { rpc_.notify("mcpp-ide/job/artifact", n); } else if (type == "test") { + { + std::lock_guard lock(mutex_); + programOutput_[std::string(jobId)] = record.value("compile_output", "") + record.value("run_output", ""); + testRecord_[std::string(jobId)] = record; + } rpc_.notify("mcpp-ide/test/result", n); } else if (type == "summary") { rpc_.notify("mcpp-ide/test/summary", n); @@ -442,7 +473,7 @@ std::expected Session::mcpp_exec(const Json& p) { if (!a.is_string()) return std::unexpected(rpc::Error{rpc::INVALID_PARAMS, "args: an array of strings"}); o.args.push_back(a.get()); } - o.cwd = p.contains("cwd") && p["cwd"].is_string() ? std::filesystem::path(p["cwd"].get()) : std::filesystem::path{}; + o.cwd = p.contains("cwd") && p["cwd"].is_string() ? host_path(p["cwd"].get()) : std::filesystem::path{}; o.deny = deny(); o.client = client_label(); const auto timeoutMs = p.contains("timeoutMs") && p["timeoutMs"].is_number_integer() && p["timeoutMs"].get() > 0 @@ -615,6 +646,7 @@ std::expected Session::initialize(const Json& p) { return Json{{"capabilities", documents::server_capabilities()}, {"serverInfo", Json{{"name", "mcpp-ide"}, {"version", kVersion}}}, {"protocol", kProtocol}, + {"series", kSeries}, {"mcpp", check["mcpp"]}, {"languageService", check["languageService"]}, {"checks", check["checks"]}, @@ -714,11 +746,20 @@ std::expected Session::job_start(const Json& p, std::function< r.args = {"add", dep}; break; } + case jobs::Kind::launch: { + // A Run's or a Debug's build, reported as it goes: mcpp's narration, and with + // `launch-stream` its events, then the launch in `job/finished` (design 2026-11-07 §4.9). + const bool stream = p.value("test", "").empty() && has_feature(root, "launch-stream"); + r.args = launch_args(p, stream, true); + r.mode = stream ? jobs::Mode::ndjson : jobs::Mode::envelope; + break; + } case jobs::Kind::command: r.args = {}; break; } - for (const auto& a : extra) r.args.push_back(a); + if (*kind != jobs::Kind::launch) + for (const auto& a : extra) r.args.push_back(a); if (r.args.empty()) return std::unexpected(invalid("a command job names its arguments")); r.after = std::move(after); const auto id = jobs_->submit(std::move(r)); @@ -726,13 +767,17 @@ std::expected Session::job_start(const Json& p, std::function< return Json{{"jobId", id}}; } -std::expected Session::run_launch(const Json& p, const rpc::Context& ctx) { - const auto root = root_of(p); - if (root.empty()) return std::unexpected(invalid("run/launch names its project's root")); - // A test's program is described by `mcpp test --print-launch` (mcpp's `test-launch`), - // with its framework's filter after `--` when cases are asked for. +// The mcpp command that builds a program and describes its launch: `mcpp run --print-launch`, or +// `mcpp test --print-launch` for a test (mcpp's `test-launch`), with its framework's filter +// after `--` when cases are asked for. `stream` asks a run for the build's events before the +// envelope (mcpp's `launch-stream`), and `programArgs` are the program's own arguments, after `--`. +std::vector Session::launch_args(const Json& p, bool stream, bool programArgs) { const auto test = p.value("test", ""); std::vector args{test.empty() ? "run" : "test", "--print-launch"}; + if (stream && test.empty()) { + args.push_back("--message-format"); + args.push_back("json"); + } 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") { @@ -751,10 +796,20 @@ std::expected Session::run_launch(const Json& p, const rpc::Co args.push_back("--"); for (const auto& a : filter) args.push_back(a); } - } else if (auto bin = p.value("bin", ""); !bin.empty()) { - args.push_back(bin); + } else { + if (auto bin = p.value("bin", ""); !bin.empty()) args.push_back(bin); + if (const auto program = strings_of(p.value("args", Json::array())); programArgs && !program.empty()) { + args.push_back("--"); + args.insert(args.end(), program.begin(), program.end()); + } } - auto r = query(root, std::move(args)); + return args; +} + +std::expected Session::run_launch(const Json& p, const rpc::Context& ctx) { + const auto root = root_of(p); + if (root.empty()) return std::unexpected(invalid("run/launch names its project's root")); + auto r = query(root, launch_args(p, false, false)); if (!r) return std::unexpected(r.error()); if (ctx.cancelled()) return std::unexpected(rpc::Error{rpc::REQUEST_CANCELLED, "cancelled"}); if (r->exitCode != 0 || !r->envelope.is_object() || r->envelope.value("kind", "") != "mcpp.launch") @@ -966,7 +1021,7 @@ std::expected Session::project_summary(const Json& p) { } std::expected Session::project_new(const Json& p) { - const auto parent = std::filesystem::path(p.value("parent", "")); + const auto parent = host_path(p.value("parent", "")); const auto name = p.value("name", ""); if (parent.empty() || name.empty()) return std::unexpected(invalid("project/new names a parent directory and a name")); if (name.find_first_of("/\\") != std::string::npos || name == "." || name == "..") @@ -1473,10 +1528,55 @@ std::expected Session::test_cases(const Json& p) { trusted = trusted_; } const auto* f = testing::framework_of(plan_packages(root, trusted), found, extra); + std::vector cases; + if (f) { + out["framework"] = f->id; + cases = testing::static_cases(*f, found, extra); + } + // A PROGRAM THAT IMPLEMENTS THE TEST PROTOCOL LISTS ITS OWN CASES (mcpp's `test-protocol`): + // `mcpp test --cases`, whatever framework wrote it; its cases then run by `--case`. + if (p.value("list", false) && trusted && has_feature(root, "test-protocol")) { + mcpp::Client client; + { + std::lock_guard lock(mutex_); + client = client_; + } + if (client.ready()) { + mcpp::RunOptions o; + o.cwd = root; + o.args = {"test", "--message-format", "json"}; + for (const auto& a : target_args(p)) o.args.push_back(a); + o.args.push_back(test); + o.args.push_back("--cases"); + o.deny = deny(); + o.client = client_label(); + if (auto r = client.records(o)) + for (const auto& rec : r->records) { + if (!rec.is_object() || rec.value("test", "") != test || !rec.contains("listed")) continue; + const auto* protocol = testing::framework("mtest"); + if (!protocol || !rec["protocol"].is_number()) continue; + std::vector listed; + for (const auto& c : testing::protocol_listing(rec)) { + auto known = std::ranges::find_if(cases, [&](const testing::Case& s) { return s.name == c.id; }); + listed.push_back(testing::Case{c.id, known != cases.end() && known->line >= 0 ? known->line : c.line, "listed"}); + } + cases = std::move(listed); + out["framework"] = protocol->id; + out["listed"] = true; + f = protocol; + } + } + } if (!f) return out; - out["framework"] = f->id; - auto cases = testing::static_cases(*f, found, extra); - if (p.value("list", false) && trusted) { + if (out.value("listed", false)) { + for (const auto& c : cases) { + Json row{{"name", c.name}, {"source", c.source}}; + if (c.line >= 0) row["line"] = c.line; + out["cases"].push_back(row); + } + return out; + } + if (p.value("list", false) && trusted && !f->protocol) { mcpp::Client client; { std::lock_guard lock(mutex_); @@ -1517,14 +1617,20 @@ std::expected Session::test_run_cases(const Json& p) { const auto* f = testing::framework(p.value("framework", "")); if (root.empty() || test.empty() || !f) return std::unexpected(invalid("test/runCases names a project, a test and its framework")); const auto cases = strings_of(p.value("cases", Json::array())); - const auto dir = std::filesystem::temp_directory_path() / + const auto dir = host_path(mcpplibs::os::fs::utf8(mcpplibs::os::dirs::temp())) / std::format("mcpp-ide-cases-{}-{}", std::random_device{}(), std::chrono::steady_clock::now().time_since_epoch().count()); std::error_code ec; std::filesystem::create_directories(dir, ec); if (ec) return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, ec.message()}); const auto report = dir / "report"; - Json args = Json::array({"--"}); - for (const auto& a : testing::run_args(*f, cases, report)) args.push_back(a); + Json args = Json::array(); + if (f->protocol) { + // The protocol selects by identifier, and the record reports each case (mcpp docs/08). + for (const auto& c : cases) { args.push_back("--case"); args.push_back(c); } + } else { + args.push_back("--"); + for (const auto& a : testing::run_args(*f, cases, report)) args.push_back(a); + } Json job{{"kind", "test"}, {"root", root.generic_string()}, {"filter", test}, {"args", args}, {"color", p.value("color", true)}}; for (const auto* key : {"profile", "target"}) if (p.contains(key)) job[key] = p[key]; const std::string framework = f->id; @@ -1533,11 +1639,39 @@ std::expected Session::test_run_cases(const Json& p) { std::ifstream in(report, std::ios::binary); const std::string text(std::istreambuf_iterator(in), {}); in.close(); - if (fw) - for (const auto& o : testing::read_report(*fw, text)) + std::string programOutput; + { + std::lock_guard lock(mutex_); + if (auto it = programOutput_.find(jobId); it != programOutput_.end()) programOutput = it->second; + } + if (fw && fw->protocol) { + Json record; + { + std::lock_guard lock(mutex_); + if (auto it = testRecord_.find(jobId); it != testRecord_.end()) record = it->second; + } + bool truncated = false; + for (const auto& o : testing::protocol_outcomes(record)) { + auto output = views::bounded_output(o.outcome.status == "failed" ? std::string_view(o.outcome.message) : std::string_view{}, truncated); + rpc_.notify("mcpp-ide/test/caseResult", Json{{"jobId", jobId}, {"root", root.generic_string()}, {"test", test}, + {"case", o.outcome.name}, {"status", o.outcome.status}, {"durationMs", o.outcome.durationMs}, + {"message", o.outcome.message}, {"output", std::move(output)}, + {"outputTruncated", truncated}, {"location", o.location}}); + } + } else if (fw) { + const auto outputs = testing::case_outputs(*fw, programOutput); + for (const auto& o : testing::read_report(*fw, text)) { + // Each case with its own output (passed or not) and a failure's place (D16). + const auto own = outputs.find(o.name); + bool truncated = false; + auto output = views::bounded_output(own != outputs.end() ? own->second : std::string_view{}, truncated); + const Json location = o.status == "failed" ? views::failure_location(o.message.empty() ? output : o.message) : Json(nullptr); rpc_.notify("mcpp-ide/test/caseResult", Json{{"jobId", jobId}, {"root", root.generic_string()}, {"test", test}, {"case", o.name}, {"status", o.status}, {"durationMs", o.durationMs}, - {"message", o.message}}); + {"message", o.message}, {"output", std::move(output)}, + {"outputTruncated", truncated}, {"location", location}}); + } + } std::error_code ignored; std::filesystem::remove_all(dir, ignored); }); diff --git a/src/testing.cppm b/src/testing.cppm index 302c369..b563e23 100644 --- a/src/testing.cppm +++ b/src/testing.cppm @@ -30,6 +30,10 @@ struct Framework { std::string listFormat; // "gtest-list" | "catch2-json" | "lines" std::vector filter, report; std::string join, escape, reportFormat; + // The version of the mtest test protocol the framework's programs implement (0: none). Their + // cases are their `"name"_test` literals in the source, listed by `mcpp test --cases`, run by + // `mcpp test --case` and reported in mcpp's records (mcpp's feature `test-protocol`). + int protocol = 0; }; // spec/test-frameworks.json, in its order. @@ -77,9 +81,32 @@ struct Outcome { std::string message; }; +// Each case's own output in its program's output, by the framework's boundaries (design 2026-11-07, +// D16): GoogleTest prints `[ RUN ] Suite.Name` before a case and `[ OK ]`, `[ FAILED ]` or +// `[ SKIPPED ]` with the name after it, and the case's output is those lines and what is between +// them. Empty for a framework whose console names no case boundary (Catch2 and doctest report a case +// in their report; a failure's expression and place are its message). +std::map case_outputs(const Framework& f, std::string_view output); + // A report the framework wrote. std::vector read_report(const Framework& f, std::string_view text); +// The cases of a test program that implements the test protocol, as a record of `mcpp test +// --message-format json` states them (mcpp docs/50 §8): `listed` (from `--cases`), each with its +// place (0-based line); and each case's outcome from `cases`, with its first failure's message and +// place. +struct ProtocolCase { + std::string id; + std::string file; + int line = -1; +}; +std::vector protocol_listing(const Json& record); +struct ProtocolOutcome { + Outcome outcome; + Json location; // {path, line} or null +}; +std::vector protocol_outcomes(const Json& record); + } // namespace mcppide::testing namespace mcppide::testing { @@ -262,6 +289,7 @@ const std::vector& frameworks() { v.join = f.value("join", ","); v.escape = f.value("escape", ""); v.reportFormat = f.value("reportFormat", ""); + v.protocol = f.value("protocol", 0); out.push_back(std::move(v)); } return out; @@ -288,6 +316,7 @@ const Framework* framework_of(const std::vector& packages, const sc return std::ranges::any_of(packages, [&](const std::string& in) { return in == p || in.starts_with(p + ":"); }); }); if (!linked) continue; + if (f.protocol && !found.cases.empty()) return &f; const auto names = macros_of(f, extra); if (std::ranges::any_of(found.calls, [&](const scan::Call& c) { return std::ranges::find(names, c.name) != names.end(); })) return &f; @@ -297,6 +326,10 @@ const Framework* framework_of(const std::vector& packages, const sc std::vector static_cases(const Framework& f, const scan::Result& found, const Json& extra) { std::vector out; + if (f.protocol) { + for (const auto& c : found.cases) out.push_back(Case{c.id, c.line, "static"}); + return out; + } const auto names = macros_of(f, extra); for (const auto& call : found.calls) { if (std::ranges::find(names, call.name) == names.end()) continue; @@ -428,6 +461,80 @@ std::vector filter_args(const Framework& f, const std::vector case_outputs(const Framework& f, std::string_view output) { + std::map out; + if (f.id != "gtest") return out; + std::string current, text; + std::size_t at = 0; + while (at < output.size()) { + auto end = output.find('\n', at); + const auto stop = end == std::string_view::npos ? output.size() : end + 1; + const auto line = output.substr(at, stop - at); + at = stop; + // `[ RUN ] Suite.Name`; the result lines name it again, then ` (N ms)`. + const auto tagged = [&](std::string_view tag) -> std::optional { + if (!line.starts_with(tag)) return std::nullopt; + auto name = line.substr(tag.size()); + while (!name.empty() && (name.back() == '\n' || name.back() == '\r')) name.remove_suffix(1); + if (const auto paren = name.find(" ("); paren != std::string_view::npos) name = name.substr(0, paren); + return std::string(name); + }; + if (auto started = tagged("[ RUN ] ")) { + current = *started; + text = std::string(line); + continue; + } + if (current.empty()) continue; + text += line; + for (std::string_view tag : {"[ OK ] ", "[ FAILED ] ", "[ SKIPPED ] "}) + if (auto ended = tagged(tag); ended && *ended == current) { + out[current] = std::move(text); + current.clear(); + text.clear(); + break; + } + } + // A case the program ended in (a crash): what it printed until then. + if (!current.empty()) out[current] = std::move(text); + return out; +} + +std::vector protocol_listing(const Json& record) { + std::vector out; + for (const auto& c : record.value("listed", Json::array())) { + if (!c.is_object()) continue; + const int line = c.value("line", 0); + out.push_back({c.value("id", ""), c.value("file", ""), line > 0 ? line - 1 : -1}); + } + return out; +} + +std::vector protocol_outcomes(const Json& record) { + std::vector out; + for (const auto& c : record.value("cases", Json::array())) { + if (!c.is_object()) continue; + ProtocolOutcome o; + o.outcome.name = c.value("id", ""); + const auto status = c.value("status", ""); + o.outcome.status = status == "pass" || status == "xfail" ? "passed" : status == "skip" ? "skipped" : "failed"; + o.outcome.durationMs = static_cast(c.value("duration_ms", 0.0)); + o.location = nullptr; + const auto failures = c.value("failures", Json::array()); + if (!failures.empty() && failures[0].is_object()) { + const auto& f = failures[0]; + o.outcome.message = std::format("{}:{}: {}", f.value("file", ""), f.value("line", 0), f.value("message", "")); + if (f.contains("expected")) + o.outcome.message += std::format(" (expected {}, actual {})", f.value("expected", ""), f.value("actual", "")); + const int line = f.value("line", 0); + o.location = Json{{"path", f.value("file", "")}, {"line", line > 0 ? line - 1 : 0}}; + } else { + o.outcome.message = c.value("reason", ""); + } + out.push_back(std::move(o)); + } + return out; +} + std::vector read_report(const Framework& f, std::string_view text) { if (f.reportFormat == "junit") return junit_report(text); const Json doc = Json::parse(text, nullptr, false); diff --git a/src/views.cppm b/src/views.cppm index 057c0c5..02f7acd 100644 --- a/src/views.cppm +++ b/src/views.cppm @@ -59,8 +59,23 @@ std::string platform_text(const Json& platform); // One record of a `--message-format json` stream (a build's events, a test's records), as the // adapters consume it: {"type": "plan" | "progress" | "diagnostic" | "artifact" | "test" | "summary" | // "other", ...}. +// +// A TEST'S STATUS IS mcpp's, VALUE FOR VALUE (mcpp docs/50, the test record; design 2026-11-07, D16): +// `pass` is passed; `run_fail` failed, or timedOut when `timed_out`; `compile_fail` failed, with +// `compileFailed`; `not_run` skipped, with mcpp's reason; `built` skipped, with `builtOnly` (`--no-run`). +// Any other value is errored and logged once. Every record carries the test's whole output +// (`compile_output` then `run_output`), passed or not, up to `kOutputLimit` bytes: past it the head +// and the tail are kept and `outputTruncated` is set. A failure's `location` is the first `file:line:` +// the output names, or null. Json stream_record(const Json& record); +// The most of a test's or a case's output one record carries. +inline constexpr std::size_t kOutputLimit = 1u << 20; +// `text` within `limit` bytes: the head and the tail, with a line saying what was left out between. +std::string bounded_output(std::string_view text, bool& truncated, std::size_t limit = kOutputLimit); +// The first `file:line:` (or `file(line):`) at the start of a line of `output`: {"path", "line"} (0-based), or null. +Json failure_location(std::string_view output); + // "1.5 GiB" -- binary units, one decimal. std::string format_bytes(std::int64_t bytes); @@ -476,6 +491,45 @@ Json targets(const Json& envelope, std::string_view selected, bool sets) { return result; } +std::string bounded_output(std::string_view text, bool& truncated, std::size_t limit) { + truncated = text.size() > limit; + if (!truncated) return std::string(text); + const std::size_t half = limit / 2; + // Whole lines at either end: the head up to its last newline, the tail from its first. + std::size_t head = half; + if (const auto nl = text.substr(0, half).rfind('\n'); nl != std::string_view::npos) head = nl + 1; + std::size_t tail = text.size() - half; + if (const auto nl = text.find('\n', tail); nl != std::string_view::npos && nl + 1 < text.size()) tail = nl + 1; + return std::format("{}[... {} bytes of output left out ...]\n{}", text.substr(0, head), tail - head, text.substr(tail)); +} + +Json failure_location(std::string_view output) { + std::size_t at = 0; + while (at < output.size()) { + auto end = output.find('\n', at); + if (end == std::string_view::npos) end = output.size(); + auto line = output.substr(at, end - at); + at = end + 1; + while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) line.remove_prefix(1); + // `path:LINE:` (GCC, Clang, GoogleTest, Catch2) or `path(LINE):` (MSVC). The path holds a + // dot (a file name), and a drive's `C:` is not its line. + for (std::size_t i = line.size() > 2 && line[1] == ':' ? 2 : 0; i < line.size(); ++i) { + const char c = line[i]; + if (c != ':' && c != '(') continue; + std::size_t j = i + 1, digits = 0; + while (j < line.size() && std::isdigit(static_cast(line[j]))) { ++j; ++digits; } + if (digits == 0) continue; + const bool closes = c == '(' ? (j < line.size() && line[j] == ')' && j + 1 < line.size() && line[j + 1] == ':') + : (j == line.size() || line[j] == ':'); + const auto path = line.substr(0, i); + if (!closes || path.find('.') == std::string_view::npos || path.find(' ') != std::string_view::npos) break; + const int number = std::stoi(std::string(line.substr(i + 1, digits))); + return Json{{"path", std::string(path)}, {"line", number > 0 ? number - 1 : 0}}; + } + } + return nullptr; +} + Json stream_record(const Json& r) { if (!r.is_object()) return Json{{"type", "other"}, {"record", r}}; // `mcpp test --message-format json`: one record per test, then the summary. @@ -487,14 +541,33 @@ Json stream_record(const Json& r) { } if (r.contains("test") && r["test"].is_string()) { const auto raw = r.value("status", ""); - const std::string status = raw == "pass" ? "passed" - : raw == "fail" ? (r.value("timed_out", false) ? "timedOut" : "failed") - : raw == "skip" || raw == "not_run" ? "skipped" : "errored"; + const bool timedOut = r.value("timed_out", false); + std::string status; + std::string reason = r.value("reason", ""); + Json out{{"type", "test"}, {"member", r.value("member", "")}, {"test", r["test"]}}; + if (raw == "pass") status = "passed"; + else if (raw == "run_fail") status = timedOut ? "timedOut" : "failed"; + else if (raw == "compile_fail") { status = "failed"; out["compileFailed"] = true; } + else if (raw == "not_run") status = "skipped"; + else if (raw == "built") { status = "skipped"; out["builtOnly"] = true; if (reason.empty()) reason = "built, not run (--no-run)"; } + else { + status = "errored"; + static std::once_flag said; + std::call_once(said, [&] { std::println(std::cerr, "mcpp-ide: a test record's status `{}` is not mcpp's contract", raw); }); + } std::string output = r.value("compile_output", ""); output += r.value("run_output", ""); - return Json{{"type", "test"}, {"member", r.value("member", "")}, {"test", r["test"]}, {"status", status}, - {"durationMs", r.value("duration_ms", 0)}, {"exitCode", r.contains("exit_code") ? r["exit_code"] : Json(nullptr)}, - {"reason", r.value("reason", "")}, {"output", output}}; + bool truncated = false; + output = bounded_output(output, truncated); + out["status"] = status; + out["timedOut"] = timedOut; + out["durationMs"] = r.value("duration_ms", 0); + out["exitCode"] = r.contains("exit_code") ? r["exit_code"] : Json(nullptr); + out["reason"] = reason; + out["location"] = status == "failed" || status == "timedOut" ? failure_location(output) : Json(nullptr); + out["output"] = std::move(output); + out["outputTruncated"] = truncated; + return out; } const auto event = r.value("event", ""); if (r.value("kind", "") == "mcpp.event") { diff --git a/tests/payloads/mcppls-0.0.19-linux-x64.json b/tests/payloads/mcppls-0.0.19-linux-x64.json new file mode 100644 index 0000000..9134a16 --- /dev/null +++ b/tests/payloads/mcppls-0.0.19-linux-x64.json @@ -0,0 +1,71 @@ +{ + "build": { + "builtAt": "2026-10-29T07:11:42Z", + "commit": "ad6e09d4f3b70f63987ee728319a1f622dab36da", + "dirty": true, + "source": "from-source" + }, + "clangd": { + "path": "clangd/bin/clangd", + "version": "23.1.0-mcppls.5" + }, + "engines": { + "clangd": { + "identity": { + "build-binary-sha256": "43ba5a35228861eaa92bfff12bf4d7ae216e990cec6506eb2a87c57157e0dba2", + "cmake-cache-sha256": "f4eb50aa3f158501180c92eba3c286b6369a3d1f0e7e42c76f40025f6d0837ee", + "engine-version": "23.1.0-mcppls.5", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions" + ], + "fork-commit": "67228c81b69ff3d60472c60346b13e9227c4e498", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "c7dfd88e157b622412bfa2315f9a135f71329029", + "patch-series-sha256": "6cfe7b1ecd8f52bc65c70fd83e9f705ba8d446b1ebd63dcf9bfbed990b135ae9", + "platform": "linux-x64", + "sha256": "42f5534b9c252b3f4efccaedae526bb9683a692ccf9e8578a37e0224b3be40b4" + }, + "kit": { + "name": "mcppls-kit-libcxx-23.1.0-x86_64-unknown-linux-gnu", + "path": "kit" + }, + "path": "clangd/bin/clangd", + "version": "23.1.0-mcppls.5" + } + }, + "files": { + "clangd/bin/clangd": { + "sha256": "42f5534b9c252b3f4efccaedae526bb9683a692ccf9e8578a37e0224b3be40b4", + "size": 55306936 + }, + "kit/kit.json": { + "sha256": "74c2de97fd10e7927f81c0cc324751ce6b67e36938d72bfcb543b15ade2677b4", + "size": 575 + } + }, + "kit": { + "name": "mcppls-kit-libcxx-23.1.0-x86_64-unknown-linux-gnu", + "path": "kit" + }, + "payload-version": 4, + "platform": "linux-x64", + "server": { + "path": "bin/mcppls.okb", + "runner": "bin/okrun", + "version": "0.0.19" + } +} diff --git a/tests/payloads/mcppls-0.0.20.json b/tests/payloads/mcppls-0.0.20.json new file mode 100644 index 0000000..62fe189 --- /dev/null +++ b/tests/payloads/mcppls-0.0.20.json @@ -0,0 +1,197 @@ +{ + "build": { + "builtAt": "2026-11-06T13:20:33Z", + "commit": "8923698cde330586bca99534dedea012d161dcf7", + "dirty": true, + "source": "from-source" + }, + "files": { + "kit/kit.json": { + "sha256": "39e6c240ad25179152f7c922f8077acf7e22b46707d4230668ef198624d1b479", + "size": 3885 + } + }, + "kit": { + "name": "mcppls-kit-openkal-0.24.0", + "path": "kit" + }, + "payload-version": 5, + "platforms": { + "darwin-arm64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/macos-aarch64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/macos-aarch64/okrun" + }, + "linux-arm64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/linux-aarch64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/linux-aarch64/okrun" + }, + "linux-x64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/linux-x86_64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/linux-x86_64/okrun" + }, + "win32-x64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/windows-x86_64/okrun.exe", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/windows-x86_64/okrun.exe" + } + }, + "server": { + "path": "bin/mcppls.okb", + "version": "0.0.20" + } +} diff --git a/tests/support.hpp b/tests/support.hpp deleted file mode 100644 index 39b72ad..0000000 --- a/tests/support.hpp +++ /dev/null @@ -1,46 +0,0 @@ -// What every backend test shares: a check that says where it failed, and the -// paths of the fixtures and the mock mcpp. -#pragma once -#include -#include - -#define CHECK(cond) \ - do { \ - if (!(cond)) { \ - std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ - std::exit(1); \ - } \ - } while (0) - -#include -#include -#include - -namespace testsupport { -// The repository, from this file's own path. -inline std::filesystem::path repo() { return std::filesystem::path(__FILE__).parent_path().parent_path(); } -// A path as the `file:` URI an LSP client names it by. -inline std::string file_uri(const std::filesystem::path& p) { - const auto g = p.generic_string(); - return "file://" + std::string(g.starts_with("/") ? "" : "/") + g; -} -inline std::filesystem::path fixtures() { return repo() / "protocol" / "fixtures"; } -inline std::string read(const std::filesystem::path& p) { - std::ifstream in(p, std::ios::binary); - return std::string(std::istreambuf_iterator(in), {}); -} -// A program the build produced (the backend, the mock mcpp): newest first. -inline std::filesystem::path built(const std::string& name) { - std::filesystem::path best; - std::filesystem::file_time_type bestTime{}; - std::error_code ec; - for (std::filesystem::recursive_directory_iterator it(repo() / "target", ec), end; !ec && it != end; it.increment(ec)) { - const auto file = it->path().filename().string(); - if (file != name && file != name + ".exe") continue; - if (it->path().parent_path().filename() != "bin") continue; - const auto t = std::filesystem::last_write_time(it->path(), ec); - if (best.empty() || t > bestTime) { best = it->path(); bestTime = t; } - } - return best; -} -} // namespace testsupport diff --git a/tests/test_jobs.cpp b/tests/test_jobs.cpp index 6238642..eec4e03 100644 --- a/tests/test_jobs.cpp +++ b/tests/test_jobs.cpp @@ -1,12 +1,14 @@ // mcppide.jobs over the mock mcpp: a build streams its records and ends `ok`, a failed build ends // `failed` with its diagnostics, and a cancelled one ends `cancelled` with mcpp's record saying so. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcpplibs.os; import mcppide.mcpp; import mcppide.jobs; +using testsupport::check; + namespace jobs = mcppide::jobs; using Json = nlohmann::json; @@ -21,13 +23,13 @@ struct Seen { int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); + check(!mock.empty()); // The mock reads its fixtures and its behaviour from the environment of each run. const mcppide::mcpp::Environment fixtures{{"MCPPIDE_FIXTURES", testsupport::fixtures().generic_string()}}; auto protocol = mcppide::mcpp::handshake(mock, fixtures); - CHECK(protocol.has_value() && protocol->contract); + check(protocol.has_value() && protocol->contract); - const auto root = std::filesystem::temp_directory_path() / std::format("mcppide-jobs-{}", mcpplibs::os::process::self()->id); + const auto root = testsupport::temp() / std::format("mcppide-jobs-{}", mcpplibs::os::process::self()->id); std::filesystem::create_directories(root); Seen seen; jobs::Sink sink; @@ -41,14 +43,14 @@ int main() { runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished.contains(ok)); - CHECK(seen.finished[ok]["status"] == "ok"); - CHECK(!seen.records.empty()); + check(seen.finished.contains(ok)); + check(seen.finished[ok]["status"] == "ok"); + check(!seen.records.empty()); if (!seen.finished[ok].contains("envelope")) { std::println(std::cerr, "no envelope; the summary: {}", seen.finished[ok].dump()); for (const auto& r : seen.records) std::println(std::cerr, "record: {}", r.dump().substr(0, 200)); } - CHECK(seen.finished[ok].contains("envelope")); + check(seen.finished[ok].contains("envelope")); } auto failing = build; @@ -57,7 +59,7 @@ int main() { runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished[failed]["status"] == "failed"); + check(seen.finished[failed]["status"] == "failed"); } // A build that takes its time, cancelled while it runs. @@ -65,18 +67,18 @@ int main() { slowly.env.push_back({"MOCK_MCPP_SLOW_MS", "20000"}); const auto slow = runner.submit(slowly); std::this_thread::sleep_for(std::chrono::milliseconds(500)); - CHECK(runner.cancel(slow)); + check(runner.cancel(slow)); runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished[slow]["status"] == "cancelled"); + check(seen.finished[slow]["status"] == "cancelled"); } // mcpp leaves its record `cancelled` when the system delivers the request to end (SIGTERM). On // Windows the program is ended outright (openkal-windows: TerminateProcess), so the record is // the last one mcpp wrote, never `ok`. const auto record = testsupport::read(root / "target" / ".last-result.json"); - if (mcpplibs::os::host() == mcpplibs::os::platform::windows) CHECK(record.find("\"ok\"") == std::string::npos); - else CHECK(record.find("\"cancelled\"") != std::string::npos); + if (mcpplibs::os::host() == mcpplibs::os::platform::windows) check(record.find("\"ok\"") == std::string::npos); + else check(record.find("\"cancelled\"") != std::string::npos); std::error_code ec; std::filesystem::remove_all(root, ec); diff --git a/tests/test_languageservice.cpp b/tests/test_languageservice.cpp index 85d88da..e83426a 100644 --- a/tests/test_languageservice.cpp +++ b/tests/test_languageservice.cpp @@ -2,58 +2,117 @@ // read the result record, an older one is `outdated`, and output with no version is `unreadable`. A // payload directory stands for the command its payload.json's `server` names: the runner and the okb // (mcppls 0.0.18 and later), or a program. -#include "support.hpp" import std; +import mcppide.testsupport; import mcppide.languageservice; +using testsupport::check; + using namespace mcppide; namespace ls = mcppide::languageservice; int main() { - CHECK(ls::version_in("0.0.16\n") == "0.0.16"); - CHECK(ls::version_in("mcppls 0.0.18\n") == "0.0.18"); - CHECK(ls::version_in("mcppls 0.0.19,\n") == "0.0.19"); - CHECK(ls::version_in("mcppls\n").empty()); - CHECK(ls::classify("/x/mcppls", "PATH", "0.0.16\n").state == ls::State::outdated); - CHECK(ls::classify("/x/mcppls", "PATH", "mcppls 0.0.18\n").state == ls::State::ok); - CHECK(ls::classify("/x/mcppls", "PATH", "0.1.0").state == ls::State::ok); - CHECK(ls::classify("/x/mcppls", "PATH", "usage: mcppls").state == ls::State::unreadable); + check(ls::version_in("0.0.16\n") == "0.0.16"); + check(ls::version_in("mcppls 0.0.18\n") == "0.0.18"); + check(ls::version_in("mcppls 0.0.19,\n") == "0.0.19"); + check(ls::version_in("mcppls\n").empty()); + check(ls::classify("/x/mcppls", "PATH", "0.0.16\n").state == ls::State::outdated); + check(ls::classify("/x/mcppls", "PATH", "mcppls 0.0.18\n").state == ls::State::ok); + check(ls::classify("/x/mcppls", "PATH", "0.1.0").state == ls::State::ok); + check(ls::classify("/x/mcppls", "PATH", "usage: mcppls").state == ls::State::unreadable); // A candidate that does not exist is passed over; with nothing found the state is `missing` or // whatever the machine's own mcppls answers -- never an error. const auto s = ls::probe({"/no/such/mcppls"}); - CHECK(s.program != std::filesystem::path("/no/such/mcppls")); - CHECK(ls::state_name(ls::State::outdated) == "outdated"); + check(s.program != std::filesystem::path("/no/such/mcppls")); + check(ls::state_name(ls::State::outdated) == "outdated"); - const auto payload = std::filesystem::temp_directory_path() / std::format("mcppide-payload-{}", std::random_device{}()); + const auto payload = testsupport::temp() / std::format("mcppide-payload-{}", std::random_device{}()); std::filesystem::create_directories(payload / "bin"); auto write = [&](const std::filesystem::path& file, std::string_view text) { std::ofstream(file, std::ios::binary) << text; }; write(payload / "bin" / "okrun", "runner"); write(payload / "bin" / "mcppls.okb", "okb"); write(payload / "payload.json", R"({"platform": "linux-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); const std::vector imaged{payload / "bin" / "okrun", payload / "bin" / "mcppls.okb"}; - CHECK(ls::payload_command(payload) == imaged); - CHECK(ls::command_of(payload) == imaged); - CHECK(ls::command_of(payload / "bin" / "okrun") == std::vector{payload / "bin" / "okrun"}); + check(ls::payload_command(payload) == imaged); + check(ls::command_of(payload) == imaged); + check(ls::command_of(payload / "bin" / "okrun") == std::vector{payload / "bin" / "okrun"}); std::filesystem::remove(payload / "bin" / "mcppls.okb"); - CHECK(ls::payload_command(payload).empty()); // what it names is not there + check(ls::payload_command(payload).empty()); // what it names is not there write(payload / "bin" / "mcppls", "program"); write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); - CHECK(ls::payload_command(payload) == std::vector{payload / "bin" / "mcppls"}); + check(ls::payload_command(payload) == std::vector{payload / "bin" / "mcppls"}); // What payload.json names stays inside the payload. const std::filesystem::path outside = payload.string() + "-outside"; write(outside, "program"); write(payload / "payload.json", R"({"server": {"path": "bin/mcppls", "runner": "../)" + outside.filename().string() + R"("}})"); - CHECK(ls::payload_command(payload).empty()); + check(ls::payload_command(payload).empty()); write(payload / "payload.json", R"({"server": {"path": ")" + (payload / "bin" / "mcppls").generic_string() + R"("}})"); - CHECK(ls::payload_command(payload).empty()); + check(ls::payload_command(payload).empty()); std::filesystem::remove(outside); write(payload / "payload.json", "not json"); - CHECK(ls::payload_command(payload).empty()); - CHECK(ls::command_of(payload / "missing").empty()); + check(ls::payload_command(payload).empty()); + check(ls::command_of(payload / "missing").empty()); + // mcppls S6: version 5 names each platform's runner; version 4 serves the one platform it names. A + // payload that does not serve this system is refused, naming the platforms it does serve. + const auto host = ls::host_platform(); + check(host == "linux-x64" || host == "linux-arm64" || host == "darwin-arm64" || host == "darwin-x64" || + host == "win32-x64" || host == "win32-arm64"); + std::filesystem::create_directories(payload / "libexec" / "okrun" / "this"); + write(payload / "libexec" / "okrun" / "this" / "okrun", "runner"); + write(payload / "bin" / "mcppls.okb", "okb"); + write(payload / "payload.json", std::format(R"({{"payload-version": 5, "server": {{"path": "bin/mcppls.okb", "version": "0.0.20"}}, + "kit": {{"name": "k", "path": "kit"}}, "files": {{}}, + "platforms": {{"{}": {{"runner": "libexec/okrun/this/okrun", "engines": {{}}, "files": {{}}}}, + "haiku-x64": {{"runner": "libexec/okrun/haiku/okrun", "engines": {{}}, "files": {{}}}}}}}})", host)); + const std::vector fromEntry{payload / "libexec" / "okrun" / "this" / "okrun", payload / "bin" / "mcppls.okb"}; + check(ls::payload_command(payload) == fromEntry); + const auto other = ls::payload_server(payload, "plan9-x64"); + check(other.command.empty() && other.problem.contains("serves") && other.problem.contains("haiku-x64") && other.problem.contains("plan9-x64")); + write(payload / "payload.json", R"({"payload-version": 4, "platform": "plan9-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); + write(payload / "bin" / "okrun", "runner"); + check(ls::payload_command(payload).empty()); + check(ls::payload_server(payload).problem.contains("plan9-x64")); + write(payload / "payload.json", std::format(R"({{"payload-version": 4, "platform": "{}", "server": {{"path": "bin/mcppls.okb", "runner": "bin/okrun"}}}})", host)); + check(ls::payload_command(payload) == imaged); + write(payload / "payload.json", R"({"payload-version": 6, "server": {"path": "bin/mcppls.okb"}})"); + check(ls::payload_server(payload).problem.contains("payload-version 6")); + // The manifests of the published payloads (tests/payloads: mcppls 0.0.19's for linux-x64, version 4, + // and 0.0.20's, version 5, as mcppls's S6 examples hold them, their file lists cut short). + { + const auto published = [&](std::string_view name) { + std::ifstream in(testsupport::repo() / "tests" / "payloads" / std::string(name), std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); + }; + const auto served = testsupport::temp() / std::format("mcppide-published-{}", std::random_device{}()); + std::filesystem::create_directories(served / "bin"); + for (const auto* system : {"linux-x86_64", "linux-aarch64", "macos-aarch64", "windows-x86_64"}) { + std::filesystem::create_directories(served / "libexec" / "okrun" / system); + write(served / "libexec" / "okrun" / system / (std::string(system).starts_with("windows") ? "okrun.exe" : "okrun"), "runner"); + } + write(served / "bin" / "mcppls.okb", "okb"); + write(served / "bin" / "okrun", "runner"); + write(served / "payload.json", published("mcppls-0.0.20.json")); + const std::pair runners[] = { + {"linux-x64", "linux-x86_64/okrun"}, {"linux-arm64", "linux-aarch64/okrun"}, + {"darwin-arm64", "macos-aarch64/okrun"}, {"win32-x64", "windows-x86_64/okrun.exe"}}; + for (const auto& [platform, runner] : runners) { + const auto s = ls::payload_server(served, platform); + check(s.command == std::vector({served / "libexec" / "okrun" / std::filesystem::path(runner), served / "bin" / "mcppls.okb"})); + } + check(ls::payload_server(served, "win32-arm64").problem.contains("darwin-arm64, linux-arm64, linux-x64, win32-x64")); + write(served / "payload.json", published("mcppls-0.0.19-linux-x64.json")); + check(ls::payload_server(served, "linux-x64").command == std::vector({served / "bin" / "okrun", served / "bin" / "mcppls.okb"})); + check(ls::payload_server(served, "darwin-arm64").problem.contains("serves linux-x64")); + std::filesystem::remove_all(served); + } + // A refused payload named by the adapter is said, when nothing else is found. + write(payload / "payload.json", R"({"payload-version": 5, "server": {"path": "bin/mcppls.okb"}, "platforms": {"plan9-x64": {"runner": "bin/okrun"}}})"); + const auto refused = ls::candidates({payload.string()}); + check(!refused.empty() && refused.front().command.empty() && refused.front().problem.contains("plan9-x64")); // The adapter's payload is the candidate asked first, ahead of PATH and xlings. write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); const auto found = ls::candidates({payload.string()}); - CHECK(!found.empty() && found.front().source == "editor" && found.front().command.back() == payload / "bin" / "mcppls"); + check(!found.empty() && found.front().source == "editor" && found.front().command.back() == payload / "bin" / "mcppls"); std::filesystem::remove_all(payload); std::println("test_languageservice: ok"); } diff --git a/tests/test_mcppls_status.cpp b/tests/test_mcppls_status.cpp index f56590b..ec6cb35 100644 --- a/tests/test_mcppls_status.cpp +++ b/tests/test_mcppls_status.cpp @@ -2,12 +2,15 @@ // the frozen table for 0.0.18 and 0.0.19; mcppls's actions offered in each editor under the id // mcppls gives that editor, and none where it gives none; a change of state told once. The mock // mcppls sits in a payload made here, as an editor's own payload would. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.languageservice; +import mcpplibs.os; + +using testsupport::check; using namespace mcppide; using Json = nlohmann::json; @@ -57,22 +60,27 @@ int main() { { const auto r = ls::parse_status(R"({"schema": 1, "capabilities": ["status", 7], "workspace": {"state": "ready"}, "actions": [{"id": "restart", "vscode": "mcppls.restartServer"}, {"title": "no id"}]})"); - CHECK(r.source == "status"); - CHECK(r.capabilities == Json::array({"status"})); - CHECK(r.actions.size() == 1 && r.workspace["state"] == "ready"); + check(r.source == "status"); + check(r.capabilities == Json::array({"status"})); + check(r.actions.size() == 1 && r.workspace["state"] == "ready"); const auto broken = ls::parse_status("mcppls: unknown command status"); - CHECK(broken.source == "frozen" && !broken.detail.empty()); - CHECK(broken.actions == ls::frozen_actions()); + check(broken.source == "frozen" && !broken.detail.empty()); + check(broken.actions == ls::frozen_actions()); } const auto mock = testsupport::built("mcpp-ide-mock-mcppls"); - CHECK(!mock.empty()); - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-mcppls-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + check(!mock.empty()); + const auto base = testsupport::temp() / std::format("mcppide-mcppls-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto payload = base / "payload"; const auto root = base / "project"; std::filesystem::create_directories(payload / "bin"); std::filesystem::create_directories(root); - std::filesystem::copy_file(mock, payload / "bin" / mock.filename()); + // The mock as the payload's server. An ok image creates no executable file (openkal states no + // permission bits), so where a program needs its executable bit the payload names it by a link. + if (mcpplibs::os::host() == mcpplibs::os::platform::windows) + std::filesystem::copy_file(mock, payload / "bin" / mock.filename()); + else + std::filesystem::create_symlink(mock, payload / "bin" / mock.filename()); std::ofstream(payload / "payload.json") << std::format(R"({{"server": {{"path": "bin/{}"}}}})", mock.filename().string()); std::ofstream(root / "mcpp.toml") << "[package]\nname = \"p\"\nversion = \"0.1.0\"\n"; @@ -82,7 +90,7 @@ int main() { auto result = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); return answer["result"]; }; const Json candidates = Json::array({payload.generic_string()}); @@ -93,38 +101,38 @@ int main() { // 0.0.19: no `status`; the actions are the frozen table's, and the workspace is not known. std::ofstream(payload / "mock-version") << "0.0.19\n"; auto old = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(old["state"] == "ok" && old["version"] == "0.0.19"); - CHECK(old["statusSource"] == "frozen" && old["workspace"].is_null()); - CHECK(old["serverActions"] == ls::frozen_actions()); + check(old["state"] == "ok" && old["version"] == "0.0.19"); + check(old["statusSource"] == "frozen" && old["workspace"].is_null()); + check(old["serverActions"] == ls::frozen_actions()); // 0.0.20: the status document. std::ofstream(payload / "mock-version") << "0.0.20\n"; std::ofstream(root / ".mock-mcppls-state") << "starting\n"; auto now = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(now["statusSource"] == "status"); - CHECK(now["workspace"]["state"] == "starting"); - CHECK(now["capabilities"] == Json::array({"status"})); - CHECK(now["serverActions"].size() == 3); + check(now["statusSource"] == "status"); + check(now["workspace"]["state"] == "starting"); + check(now["capabilities"] == Json::array({"status"})); + check(now["serverActions"].size() == 3); // The commands each editor is offered: mcppls's action under that editor's id, or not at all. auto jetbrains = result("mcpp-ide/commands/list", Json{{"editor", "jetbrains"}}); - CHECK(command(jetbrains, "mcpp.languageServer.restart")["action"]["command"] == "community.mcpp.mcppls.RestartServer"); - CHECK(command(jetbrains, "mcpp.languageServer.restartEngine").is_null()); + check(command(jetbrains, "mcpp.languageServer.restart")["action"]["command"] == "community.mcpp.mcppls.RestartServer"); + check(command(jetbrains, "mcpp.languageServer.restartEngine").is_null()); auto nvim = result("mcpp-ide/commands/list", Json{{"editor", "nvim"}}); - CHECK(command(nvim, "mcpp.languageServer.restart")["action"]["command"] == "McpplsRestart"); + check(command(nvim, "mcpp.languageServer.restart")["action"]["command"] == "McpplsRestart"); auto vscode = result("mcpp-ide/commands/list", Json{{"editor", "vscode"}}); - CHECK(command(vscode, "mcpp.languageServer.openLogFolder")["action"]["arguments"] == Json::array({"logs"})); - CHECK(command(vscode, "mcpp.languageServer.restartEngine")["action"]["command"] == "mcppls.restartClangd"); + check(command(vscode, "mcpp.languageServer.openLogFolder")["action"]["arguments"] == Json::array({"logs"})); + check(command(vscode, "mcpp.languageServer.restartEngine")["action"]["command"] == "mcppls.restartClangd"); auto cli = result("mcpp-ide/commands/list", Json{{"editor", "cli"}}); - CHECK(std::ranges::none_of(cli["commands"], [](const Json& c) { return c["action"].value("kind", "") == "forward"; })); + check(std::ranges::none_of(cli["commands"], [](const Json& c) { return c["action"].value("kind", "") == "forward"; })); // A change of state is told once; asking again with nothing changed tells nothing. const int before = wire.count("mcpp-ide/languageService/changed"); std::ofstream(root / ".mock-mcppls-state") << "ready\n"; auto ready = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(ready["workspace"]["state"] == "ready"); + check(ready["workspace"]["state"] == "ready"); (void)result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(wire.count("mcpp-ide/languageService/changed") == before + 1); + check(wire.count("mcpp-ide/languageService/changed") == before + 1); std::error_code ignored; std::filesystem::remove_all(base, ignored); diff --git a/tests/test_policy.cpp b/tests/test_policy.cpp index c10bc02..78aace4 100644 --- a/tests/test_policy.cpp +++ b/tests/test_policy.cpp @@ -1,27 +1,29 @@ // The cleanup table: every action's argv and its level, and that an action // needing a preview or an acknowledgement is not confirmed without it. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.policy; +using testsupport::check; + using namespace mcppide; int main() { policy::CleanOptions o; - for (auto a : policy::kActions) CHECK(policy::plan(a, o).has_value()); - CHECK(!policy::plan("everything", o)); + for (auto a : policy::kActions) check(policy::plan(a, o).has_value()); + check(!policy::plan("everything", o)); auto project = *policy::plan("project", o); - CHECK(project.level == 1 && project.argv == std::vector{"clean"} && project.requiresTrust); + check(project.level == 1 && project.argv == std::vector{"clean"} && project.requiresTrust); auto stale = *policy::plan("stale", o); - CHECK(stale.level == 2 && stale.previewSource == "mcpp" && stale.previewArgv.back() == "--dry-run"); + check(stale.level == 2 && stale.previewSource == "mcpp" && stale.previewArgv.back() == "--dry-run"); auto all = *policy::plan("cacheAll", o); - CHECK(all.level == 3 && all.acknowledge && all.previewSource == "inventory"); + check(all.level == 3 && all.acknowledge && all.previewSource == "inventory"); o.budgetGiB = 2; auto gc = *policy::plan("cacheGc", o); - CHECK((gc.argv == std::vector{"cache", "gc", "--max-size", "2GiB"})); - CHECK(!policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}})); - CHECK(policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}, {"acknowledged", true}})); - CHECK(policy::confirmed(*policy::plan("cacheVerify", o), nullptr)); + check((gc.argv == std::vector{"cache", "gc", "--max-size", "2GiB"})); + check(!policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}})); + check(policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}, {"acknowledged", true}})); + check(policy::confirmed(*policy::plan("cacheVerify", o), nullptr)); std::println("test_policy: ok"); } diff --git a/tests/test_probe.cpp b/tests/test_probe.cpp index 9dc84a5..6e862a1 100644 --- a/tests/test_probe.cpp +++ b/tests/test_probe.cpp @@ -3,13 +3,15 @@ // XLINGS_PROJECT_DIR removed; features as mcpp states them, else from the frozen table; a pinned // version that is not installed; an untrusted workspace; and a job whose envelope names another // version, after which the project is asked again. The mock mcpp plays the shim per directory. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.mcpp; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -59,8 +61,8 @@ std::filesystem::path project(const std::filesystem::path& base, std::string_vie int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + check(!mock.empty()); + const auto base = testsupport::temp() / std::format("mcppide-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto old = project(base, "old", {{".mock-mcpp-version", "2026.10.29.1"}}); const auto sets = project(base, "sets", {{".mock-mcpp-version", "2026.10.30.1"}}); const auto stated = project(base, "stated", {{".mock-mcpp-version", "2026.10.31.1"}, @@ -73,7 +75,7 @@ int main() { auto result = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); return answer["result"]; }; result("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, @@ -82,32 +84,32 @@ int main() { // Before features: the frozen table, by the version the project's mcpp answers. auto a = result("mcpp-ide/project/mcpp", Json{{"root", old.generic_string()}}); - CHECK(a["state"] == "ok" && a["version"] == "2026.10.29.1"); - CHECK(a["features"].empty() && a["featuresFrozen"] == true); - CHECK(a["fromSetting"] == true); + check(a["state"] == "ok" && a["version"] == "2026.10.29.1"); + check(a["features"].empty() && a["featuresFrozen"] == true); + check(a["fromSetting"] == true); auto b = result("mcpp-ide/project/mcpp", Json{{"root", sets.generic_string()}}); - CHECK(b["version"] == "2026.10.30.1" && b["featuresFrozen"] == true); - CHECK(b["features"] == Json::array({"target-sets", "pkg-format-list"})); + check(b["version"] == "2026.10.30.1" && b["featuresFrozen"] == true); + check(b["features"] == Json::array({"target-sets", "pkg-format-list"})); // Stated: taken at mcpp's word, a name the table does not hold included. auto c = result("mcpp-ide/project/mcpp", Json{{"root", stated.generic_string()}}); - CHECK(c["version"] == "2026.10.31.1" && c["featuresFrozen"] == false); - CHECK(c["features"] == Json::array({"target-sets", "pkg-format-list", "polyglot"})); + check(c["version"] == "2026.10.31.1" && c["featuresFrozen"] == false); + check(c["features"] == Json::array({"target-sets", "pkg-format-list", "polyglot"})); // A pinned version that is not installed: the shim's words and its install command. auto d = result("mcpp-ide/project/mcpp", Json{{"root", missing.generic_string()}}); - CHECK(d["state"] == "pinned-missing"); - CHECK(d["install"] == "xlings install mcpp@2026.8.17.1"); - CHECK(d["message"].get().find("not found") != std::string::npos); + check(d["state"] == "pinned-missing"); + check(d["install"] == "xlings install mcpp@2026.8.17.1"); + check(d["message"].get().find("not found") != std::string::npos); // Why a feature is not offered: the party, the capability and the version that has it. auto why = result("mcpp-ide/capabilities/explain", Json{{"root", old.generic_string()}}); bool explained = false; for (const auto& f : why["features"]) if (f["id"] == "targets.sets") { - CHECK(f["available"] == false); - CHECK(f["reason"]["source"] == "mcpp" && f["reason"]["need"] == "2026.10.30.1"); + check(f["available"] == false); + check(f["reason"]["source"] == "mcpp" && f["reason"]["need"] == "2026.10.30.1"); explained = true; } - CHECK(explained); + check(explained); // No run carries another project's pin: every run of mcpp removes XLINGS_PROJECT_DIR, which an // editor started from another project's terminal inherits (mcpp docs/50). The removal is the @@ -116,21 +118,21 @@ int main() { { mcpp::Client client(mock, mcpp::Protocol{}); const auto o = client.spawn_options(mcpp::RunOptions{}); - CHECK(std::ranges::any_of(o.env, [](const auto& kv) { return kv.first == "XLINGS_PROJECT_DIR" && !kv.second; })); + check(std::ranges::any_of(o.env, [](const auto& kv) { return kv.first == "XLINGS_PROJECT_DIR" && !kv.second; })); } // A job's envelope names another version: the project is asked again, and the editor told. std::ofstream(old / ".mock-mcpp-version") << "2026.10.31.1\n"; auto job = result("mcpp-ide/job/start", Json{{"kind", "build"}, {"root", old.generic_string()}}); for (int i = 0; i < 2000 && !wire.saw("mcpp-ide/project/mcppChanged"); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(wire.saw("mcpp-ide/project/mcppChanged")); + check(wire.saw("mcpp-ide/project/mcppChanged")); auto again = result("mcpp-ide/project/mcpp", Json{{"root", old.generic_string()}}); - CHECK(again["version"] == "2026.10.31.1"); + check(again["version"] == "2026.10.31.1"); (void)job; // The report names every project asked, with no secret in it. auto report = result("mcpp-ide/report/collect", Json::object()); - CHECK(report["kind"] == "mcpp-ide.report" && report["projects"].size() >= 4); + check(report["kind"] == "mcpp-ide.report" && report["projects"].size() >= 4); // An untrusted workspace: the pinned program is not run. Wire untrusted; @@ -139,9 +141,9 @@ int main() { auto answer = untrusted.call("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"trusted", false}, {"settings", {{"mcpp.path", mock.generic_string()}}}}}}); - CHECK(answer.contains("result")); + check(answer.contains("result")); auto e = untrusted.call("mcpp-ide/project/mcpp", Json{{"root", missing.generic_string()}}); - CHECK(e.contains("result") && e["result"]["state"] == "not-probed"); + check(e.contains("result") && e["result"]["state"] == "not-probed"); std::error_code ignored; std::filesystem::remove_all(base, ignored); diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index c0c8d7b..1d54c72 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -5,13 +5,15 @@ // protocol/protocol.schema.json, both ways; // - a session over the mock mcpp: each answer against its method's `result`, and // every notification a build and a test send against its `params`. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.schema; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -66,7 +68,7 @@ int main() { for (const auto& f : index["fixtures"]) { const auto path = testsupport::fixtures() / f["file"].get(); if (!std::filesystem::exists(path)) { - CHECK(f.value("optional", false)); + check(f.value("optional", false)); continue; } const auto text = testsupport::read(path); @@ -76,12 +78,12 @@ int main() { int n = 0; for (std::string line; std::getline(lines, line);) { if (line.empty()) continue; - CHECK(conforms(Json::parse(line), def, contract, std::format("{}:{}", f["file"].get(), ++n))); + check(conforms(Json::parse(line), def, contract, std::format("{}:{}", f["file"].get(), ++n))); } - CHECK(n > 0); + check(n > 0); } else { const Json def{{"$ref", "#/$defs/" + f["schema"].get()}}; - CHECK(conforms(Json::parse(text), def, contract, f["file"].get())); + check(conforms(Json::parse(text), def, contract, f["file"].get())); } } @@ -93,13 +95,13 @@ int main() { std::set sent(server::kNotifications.begin(), server::kNotifications.end()); for (const auto& m : methods) if (!registered.contains(m)) std::println(std::cerr, "schema method not registered: {}", m); for (const auto& m : registered) if (!methods.contains(m)) std::println(std::cerr, "registered method not in the schema: {}", m); - CHECK(methods == registered); - CHECK(notifications == sent); + check(methods == registered); + check(notifications == sent); // A session over the mock mcpp. const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); - const auto root = std::filesystem::temp_directory_path() / std::format("mcppide-protocol-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + check(!mock.empty()); + const auto root = testsupport::temp() / std::format("mcppide-protocol-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(root / "src"); std::ofstream(root / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; Wire wire; @@ -108,16 +110,16 @@ int main() { auto result_of = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); const auto& def = protocol["methods"][std::string(method)]["result"]; - CHECK(conforms(answer["result"], def, protocol, method)); + check(conforms(answer["result"], def, protocol, method)); return answer["result"]; }; auto init = result_of("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(root)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"settings", {{"mcpp.path", mock.generic_string()}}}}}}); - CHECK(init["projects"].size() == 1); - CHECK(init["mcpp"].is_object() && init["mcpp"]["contract"] == true); - CHECK(init["capabilities"]["textDocumentSync"]["change"] == 1 && init["capabilities"]["hoverProvider"] == true); + check(init["projects"].size() == 1); + check(init["mcpp"].is_object() && init["mcpp"]["contract"] == true); + check(init["capabilities"]["textDocumentSync"]["change"] == 1 && init["capabilities"]["hoverProvider"] == true); // The rules layer as LSP: a manifest opened has its diagnostics published (0-based, UTF-16), and // completion, hover and the outline answer for it. @@ -126,64 +128,64 @@ int main() { wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didOpen"}, {"params", {{"textDocument", {{"uri", tomlUri}, {"languageId", "mcpp-toml"}, {"version", 1}, {"text", toml}}}}}}.dump()); auto published = wire.notifications("textDocument/publishDiagnostics"); - CHECK(!published.empty() && published.back()["uri"] == tomlUri && published.back()["diagnostics"].size() == 1); - CHECK(published.back()["diagnostics"][0]["range"]["start"] == Json({{"line", 2}, {"character", 0}})); - CHECK(published.back()["diagnostics"][0]["code"] == "mcpp.manifest.unknownKey"); + check(!published.empty() && published.back()["uri"] == tomlUri && published.back()["diagnostics"].size() == 1); + check(published.back()["diagnostics"][0]["range"]["start"] == Json({{"line", 2}, {"character", 0}})); + check(published.back()["diagnostics"][0]["code"] == "mcpp.manifest.unknownKey"); auto at = [&](int line, int character) { return Json{{"textDocument", {{"uri", tomlUri}}}, {"position", {{"line", line}, {"character", character}}}}; }; auto list = result_of("textDocument/completion", at(4, 16)); std::set labels; for (const auto& i : list["items"]) labels.insert(i["label"].get()); - CHECK(labels.contains("auto") && labels.contains("on") && labels.contains("off")); + check(labels.contains("auto") && labels.contains("on") && labels.contains("off")); auto hover = result_of("textDocument/hover", at(4, 2)); - CHECK(hover["contents"]["value"].get().find("bmi_schedule") != std::string::npos); + check(hover["contents"]["value"].get().find("bmi_schedule") != std::string::npos); auto outline = result_of("textDocument/documentSymbol", Json{{"textDocument", {{"uri", tomlUri}}}}); - CHECK(outline.size() == 2 && outline[1]["name"] == "[build]" && outline[1]["children"].size() == 1); + check(outline.size() == 2 && outline[1]["name"] == "[build]" && outline[1]["children"].size() == 1); // A change is the whole text; closing clears the document's diagnostics. wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didChange"}, {"params", {{"textDocument", {{"uri", tomlUri}, {"version", 2}}}, {"contentChanges", Json::array({{{"text", "[package]\n"}}})}}}}.dump()); - CHECK(wire.notifications("textDocument/publishDiagnostics").back()["diagnostics"].empty()); + check(wire.notifications("textDocument/publishDiagnostics").back()["diagnostics"].empty()); wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didClose"}, {"params", {{"textDocument", {{"uri", tomlUri}}}}}}.dump()); - CHECK(result_of("textDocument/hover", at(0, 2)).is_null()); + check(result_of("textDocument/hover", at(0, 2)).is_null()); const Json rooted{{"root", root.generic_string()}}; result_of("mcpp-ide/project/summary", rooted); auto tests = result_of("mcpp-ide/test/list", rooted); - CHECK(tests["tests"].size() == 1 && tests["tests"][0]["test"] == "test_smoke"); + check(tests["tests"].size() == 1 && tests["tests"][0]["test"] == "test_smoke"); auto targets = result_of("mcpp-ide/targets/list", rooted); - CHECK(!targets["rows"].empty()); + check(!targets["rows"].empty()); auto cmds = result_of("mcpp-ide/commands/list", Json{{"editor", "jetbrains"}}); - for (const auto& c : cmds["commands"]) CHECK(c["action"]["kind"] != "forward"); + for (const auto& c : cmds["commands"]) check(c["action"]["kind"] != "forward"); // Neovim's table: no forward to mcppls's commands (mcppls-nvim has its own), no panel it has no // text for, its own quickfix command, and each row's subcommand (`:Mcpp `). const Json nvimInit{{"clientInfo", {{"name", "Neovim"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(root)}}})}, {"initializationOptions", Json{{"editor", "nvim"}, {"locale", "en_US.UTF-8"}, {"trusted", true}, {"settings", Json::object()}}}}; - CHECK(conforms(nvimInit, protocol["methods"]["initialize"]["params"], protocol, "initialize params (nvim)")); + check(conforms(nvimInit, protocol["methods"]["initialize"]["params"], protocol, "initialize params (nvim)")); std::map nvim; for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "nvim"}})["commands"]) { const auto kind = c["action"]["kind"].get(); - CHECK(kind != "forward" && kind != "editor"); - CHECK(!(kind == "panel" && c["action"]["panel"] == "settings")); + check(kind != "forward" && kind != "editor"); + check(!(kind == "panel" && c["action"]["panel"] == "settings")); nvim[c["subcommand"].get()] = c["id"].get(); } - CHECK(nvim["cancel"] == "mcpp.cancelJob" && nvim["build"] == "mcpp.build" && nvim["problems"] == "mcpp.problems"); - CHECK(nvim.contains("showMenu") && nvim.contains("library.search") && !nvim.contains("openSettings")); - for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "vscode"}})["commands"]) CHECK(c["id"] != "mcpp.problems"); + check(nvim["cancel"] == "mcpp.cancelJob" && nvim["build"] == "mcpp.build" && nvim["problems"] == "mcpp.problems"); + check(nvim.contains("showMenu") && nvim.contains("library.search") && !nvim.contains("openSettings")); + for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "vscode"}})["commands"]) check(c["id"] != "mcpp.problems"); // A row that leaves Neovim out says why. for (const auto& c : load(testsupport::repo() / "spec" / "commands.json")["commands"]) { if (!c.contains("editors") || std::ranges::find(c["editors"], Json("nvim")) != c["editors"].end()) continue; if (c["action"]["kind"] == "editor") continue; const auto reason = c.contains("omitted") ? c["omitted"].value("nvim", "") : std::string{}; if (reason.empty()) std::println(std::cerr, "{} leaves nvim out without saying why", c["id"].get()); - CHECK(!reason.empty()); + check(!reason.empty()); } result_of("mcpp-ide/menu/quick", rooted); result_of("mcpp-ide/languageService/status", Json{{"candidates", Json::array()}}); // The pack panel's choices: the closures SPEC-014 names, and linkages only where mcpp takes them. auto pack = result_of("mcpp-ide/pack/formats", rooted); - CHECK(pack["modes"].size() == 4); + check(pack["modes"].size() == 4); const bool helpNamesLinkage = testsupport::read(testsupport::fixtures() / "pack-help.txt").find("--linkage") != std::string::npos; - CHECK(pack["linkages"].empty() != helpNamesLinkage); + 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. @@ -196,21 +198,21 @@ int main() { 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); + 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()); + 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()); result_of("mcpp-ide/cache/inventory", Json::object()); auto launch = result_of("mcpp-ide/run/launch", rooted); - CHECK(!launch["argv"].empty()); + 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. @@ -243,8 +245,8 @@ int main() { {"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", ""})); + 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"})}}; @@ -257,15 +259,15 @@ int main() { 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"})); + 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"}) { @@ -273,65 +275,126 @@ int main() { auto params = debugParams; params["runner"] = runner; result_of("mcpp-ide/debug/configuration", params); - CHECK(commands().size() == 1); + 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); + 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); + 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); + 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); + 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}}); - CHECK(bounded["truncated"] == true && bounded["stdout"].get().size() <= 3); - CHECK(wire.call("mcpp-ide/mcpp/exec", Json{{"args", "--version"}}).contains("error")); + check(bounded["truncated"] == true && bounded["stdout"].get().size() <= 3); + check(wire.call("mcpp-ide/mcpp/exec", Json{{"args", "--version"}}).contains("error")); // mcppls's own commands run as the command the status found (here a program that answers --version). result_of("mcpp-ide/languageService/status", Json{{"candidates", Json::array({mock.generic_string()})}}); auto ls = result_of("mcpp-ide/languageService/exec", Json{{"args", Json::array({"--version"})}}); - CHECK(ls["exitCode"] == 0 && !ls["stdout"].get().empty()); + check(ls["exitCode"] == 0 && !ls["stdout"].get().empty()); std::ofstream(root / "src" / "main.cpp") << "int main() {}\n"; std::ofstream(root / "src" / "notes.txt") << "not a source\n"; std::filesystem::create_directories(root / "target" / "obj"); std::ofstream(root / "target" / "obj" / "gen.cpp") << "// build output\n"; auto files = result_of("mcpp-ide/project/files", rooted); - CHECK(files["manifest"].get().starts_with("[package]") && files["lock"].is_null()); - CHECK(files["sourceFiles"] == 1); + check(files["manifest"].get().starts_with("[package]") && files["lock"].is_null()); + check(files["sourceFiles"] == 1); result_of("mcpp-ide/job/start", Json{{"kind", "build"}, {"root", root.generic_string()}}); result_of("mcpp-ide/job/start", Json{{"kind", "test"}, {"root", root.generic_string()}}); for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/job/finished").size() < 2; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(wire.notifications("mcpp-ide/job/finished").size() == 2); + check(wire.notifications("mcpp-ide/job/finished").size() == 2); for (const auto& name : server::kNotifications) { const auto& def = protocol["notifications"][std::string(name)]["params"]; - for (const auto& n : wire.notifications(name)) CHECK(conforms(n, def, protocol, name)); + for (const auto& n : wire.notifications(name)) check(conforms(n, def, protocol, name)); } - CHECK(!wire.notifications("mcpp-ide/job/plan").empty()); - CHECK(!wire.notifications("mcpp-ide/job/artifact").empty()); + check(!wire.notifications("mcpp-ide/job/plan").empty()); + check(!wire.notifications("mcpp-ide/job/artifact").empty()); const auto results = wire.notifications("mcpp-ide/test/result"); - CHECK(results.size() == 1 && results[0]["status"] == "passed"); - CHECK(wire.notifications("mcpp-ide/test/summary").size() == 1); + check(results.size() == 1 && results[0]["status"] == "passed"); + check(wire.notifications("mcpp-ide/test/summary").size() == 1); // The plan a build sent is the project's configuration from then on. auto after = result_of("mcpp-ide/project/summary", rooted); - CHECK(after.contains("configuration")); + check(after.contains("configuration")); + + // A launch job (design 2026-11-07 §4.9): mcpp's narration while it builds, and the launch in + // job/finished, the program's arguments after `--`. With mcpp's launch-stream, the build's progress too. + const auto streamed = root.parent_path() / (root.filename().string() + "-stream"); + std::filesystem::create_directories(streamed); + std::ofstream(streamed / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + std::ofstream(streamed / ".mock-mcpp-features") << "launch-stream"; + const auto finishedBefore = wire.notifications("mcpp-ide/job/finished").size(); + const auto progressBefore = wire.notifications("mcpp-ide/job/progress").size(); + const auto plain = result_of("mcpp-ide/job/start", Json{{"kind", "launch"}, {"root", root.generic_string()}, {"args", Json::array({"one"})}}); + const auto live = result_of("mcpp-ide/job/start", Json{{"kind", "launch"}, {"root", streamed.generic_string()}, {"args", Json::array({"two"})}}); + for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/job/finished").size() < finishedBefore + 2; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto ends = wire.notifications("mcpp-ide/job/finished"); + check(ends.size() == finishedBefore + 2); + for (const auto& id : {plain["jobId"], live["jobId"]}) { + const auto end = std::ranges::find_if(ends, [&](const Json& n) { return n["jobId"] == id; }); + check(end != ends.end() && (*end)["status"] == "ok" && (*end)["kind"] == "launch"); + if (end != ends.end()) check((*end).contains("launch") && !(*end)["launch"]["argv"].empty()); + const auto outputs = wire.notifications("mcpp-ide/job/output"); + check(std::ranges::any_of(outputs, [&](const Json& n) { + return n["jobId"] == id && n["text"].get().find("Finished") != std::string::npos; })); + } + const auto progress = wire.notifications("mcpp-ide/job/progress"); + check(progress.size() > progressBefore); + check(std::ranges::all_of(progress.begin() + static_cast(progressBefore), progress.end(), + [&](const Json& n) { return n["jobId"] == live["jobId"]; })); + for (const auto& name : {"mcpp-ide/job/finished", "mcpp-ide/job/progress", "mcpp-ide/job/output"}) { + const auto& def = protocol["notifications"][name]["params"]; + for (const auto& n : wire.notifications(name)) check(conforms(n, def, protocol, name)); + } + // A program that implements the test protocol (mcpp's `test-protocol`): its cases listed by + // `mcpp test --cases`, a selection run by `--case`, and each case's result from the record. + const auto protocolRoot = root.parent_path() / (root.filename().string() + "-cases"); + std::filesystem::create_directories(protocolRoot / "tests" / "unit"); + std::ofstream(protocolRoot / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + std::ofstream(protocolRoot / ".mock-mcpp-features") << "test-protocol"; + std::ofstream(protocolRoot / "tests" / "unit" / "parse.cpp") + << "import mcpplibs.mtest;\nusing namespace mcpplibs::mtest;\nsuite parsing{\"parsing\", [] {\n" + " \"an empty line\"_test = [] {};\n \"a field\"_test = [] {};\n}};\n"; + const auto listing = result_of("mcpp-ide/test/cases", Json{{"root", protocolRoot.generic_string()}, {"test", "unit/parse"}, {"list", true}}); + check(listing["framework"] == "mtest" && listing.value("listed", false)); + check(listing["cases"].size() == 2 && listing["cases"][0]["name"] == "parsing/an empty line" && listing["cases"][0]["line"] == 3); + const auto caseResultsBefore = wire.notifications("mcpp-ide/test/caseResult").size(); + const auto run = result_of("mcpp-ide/test/runCases", Json{{"root", protocolRoot.generic_string()}, {"test", "unit/parse"}, {"framework", "mtest"}, + {"cases", Json::array({"parsing/an empty line", "parsing/a field"})}}); + check(run.contains("jobId")); + for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/test/caseResult").size() < caseResultsBefore + 2; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto caseResults = wire.notifications("mcpp-ide/test/caseResult"); + check(caseResults.size() == caseResultsBefore + 2); + if (caseResults.size() == caseResultsBefore + 2) { + check(caseResults[caseResultsBefore]["case"] == "parsing/an empty line" && caseResults[caseResultsBefore]["status"] == "passed"); + const auto& failed = caseResults[caseResultsBefore + 1]; + check(failed["status"] == "failed" && failed["location"]["line"] == 6 + && failed["message"].get().contains("expected 2, actual 1")); + for (const auto& n : caseResults) + check(conforms(n, protocol["notifications"]["mcpp-ide/test/caseResult"]["params"], protocol, "mcpp-ide/test/caseResult")); + } + std::filesystem::remove_all(protocolRoot); + std::filesystem::remove_all(streamed); std::filesystem::remove_all(root); std::println("test_protocol: ok"); } diff --git a/tests/test_rpc.cpp b/tests/test_rpc.cpp index 112ac33..b24b58b 100644 --- a/tests/test_rpc.cpp +++ b/tests/test_rpc.cpp @@ -1,11 +1,13 @@ // The wire: frames split anywhere reassemble, a header without a length is // skipped, a request before initialize is refused, an unknown method answers // METHOD_NOT_FOUND, a cancelled request answers REQUEST_CANCELLED. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -17,10 +19,10 @@ int main() { const auto all = "Bogus: 1\r\n\r\n" + a + b; for (char c : all) r.feed(std::string_view(&c, 1)); auto first = r.next(); - CHECK(first && Json::parse(*first)["id"] == 1); + check(first && Json::parse(*first)["id"] == 1); auto second = r.next(); - CHECK(second && Json::parse(*second)["id"] == 2); - CHECK(!r.next()); + check(second && Json::parse(*second)["id"] == 2); + check(!r.next()); } { std::mutex m; @@ -42,20 +44,20 @@ int main() { return Json{{"done", true}}; }); s.dispatch(R"({"jsonrpc":"2.0","id":1,"method":"slow"})"); - CHECK(out.back()["error"]["code"] == rpc::NOT_INITIALIZED); + check(out.back()["error"]["code"] == rpc::NOT_INITIALIZED); s.dispatch(R"({"jsonrpc":"2.0","id":2,"method":"initialize"})"); - CHECK(out.back()["result"]["ok"] == true); + check(out.back()["result"]["ok"] == true); s.dispatch(R"({"jsonrpc":"2.0","id":3,"method":"nope"})"); - CHECK(out.back()["error"]["code"] == rpc::METHOD_NOT_FOUND); + check(out.back()["error"]["code"] == rpc::METHOD_NOT_FOUND); s.dispatch(R"({"jsonrpc":"2.0","id":4,"method":"slow"})"); s.dispatch(R"({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":4}})"); s.drain(); bool cancelled = false; for (const auto& msg : out) cancelled = cancelled || (msg.value("id", 0) == 4 && msg.contains("error") && msg["error"]["code"] == rpc::REQUEST_CANCELLED); - CHECK(!cancelled); // the handler answered (it saw the flag) with a result, which is kept + check(!cancelled); // the handler answered (it saw the flag) with a result, which is kept s.dispatch("not json"); - CHECK(out.back()["error"]["code"] == rpc::PARSE_ERROR); + check(out.back()["error"]["code"] == rpc::PARSE_ERROR); } std::println("test_rpc: ok"); } diff --git a/tests/test_rules.cpp b/tests/test_rules.cpp index b768353..9e8fd28 100644 --- a/tests/test_rules.cpp +++ b/tests/test_rules.cpp @@ -3,14 +3,16 @@ // scanner's structure and cursor contexts, the completions, hovers, jumps, version contexts and // diagnostics equal tests/rules/golden, which that implementation wrote (with the documents of its // parser tests, golden/snippets.json). A difference is printed with its file and position. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.text; import mcppide.manifest; import mcppide.buildscript; import mcppide.toml; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -279,7 +281,7 @@ int main() { std::vector files; for (const auto& e : std::filesystem::directory_iterator(root / "corpus")) files.push_back(e.path()); std::ranges::sort(files); - CHECK(!files.empty()); + check(!files.empty()); for (const auto& f : files) { const auto golden = Json::parse(testsupport::read(root / "golden" / (f.filename().string() + ".json"))); if (f.extension() == ".toml") check_manifest(f, golden); @@ -297,19 +299,19 @@ int main() { Json{{"name", "zlibx"}, {"namespace", "x"}, {"versions", {"9.0.0"}}}, })}}}}; const auto versions = manifest::catalogue_versions(envelope, "zlib"); - CHECK(versions.size() == 3 && versions[0].version == "1.3.1" && versions[2].version == "1.2.13"); - CHECK(versions[0].summary && *versions[0].summary == "A compression library"); + check(versions.size() == 3 && versions[0].version == "1.3.1" && versions[2].version == "1.2.13"); + check(versions[0].summary && *versions[0].summary == "A compression library"); const auto lines = text::lines_of("[dependencies]\nzlib = \"1.\n"); const auto at = manifest::dependency_version_context(lines, 1, 9); - CHECK(at && at->name == "zlib" && at->insideString); + check(at && at->name == "zlib" && at->insideString); const auto suggestions = manifest::version_suggestions(*at, versions); - CHECK(suggestions.size() == 3 && suggestions[0].insertSnippet == "1.3.1" && suggestions[0].kind == "version"); + check(suggestions.size() == 3 && suggestions[0].insertSnippet == "1.3.1" && suggestions[0].kind == "version"); // A suggestion's detail in the user's language. const auto zh = manifest::completions(text::lines_of("[dependencies]\n"), 1, 0, "zh-cn"); - CHECK(!zh.empty() && zh[0].detail != "SemVer version dependency"); + check(!zh.empty() && zh[0].detail != "SemVer version dependency"); if (failures) std::println(std::cerr, "{} mismatch(es)", failures); - CHECK(failures == 0); + check(failures == 0); std::println("rules: the goldens hold"); return 0; } diff --git a/tests/test_settings.cpp b/tests/test_settings.cpp index 8bfe7b9..0b15db3 100644 --- a/tests/test_settings.cpp +++ b/tests/test_settings.cpp @@ -1,28 +1,30 @@ // The registry: defaults, rejections, renames, presets, and the runtime strings. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.settings; import mcppide.i18n; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; int main() { auto r = settings::resolve(Json{{"mcpp.build.profile", "fast"}, {"mcpp.cache.topN", 3}, {"mcpp.task.buildArgs", Json::array({1})}}); - CHECK(r["values"]["mcpp.build.profile"] == "dev"); - CHECK(r["values"]["mcpp.cache.topN"] == 3 || r["values"].contains("mcpp.views.cache.topN")); + check(r["values"]["mcpp.build.profile"] == "dev"); + check(r["values"]["mcpp.cache.topN"] == 3 || r["values"].contains("mcpp.views.cache.topN")); bool enumRejected = false, itemsRejected = false; for (const auto& x : r["rejected"]) { enumRejected = enumRejected || (x["key"] == "mcpp.build.profile" && x["reason"] == "enum"); itemsRejected = itemsRejected || (x["key"] == "mcpp.task.buildArgs" && x["reason"] == "items"); } - CHECK(enumRejected && itemsRejected); + check(enumRejected && itemsRejected); auto reg = settings::registry(); - CHECK(reg["settings"].size() > 60 && reg["presets"].size() >= 3); - CHECK(settings::preset("quiet").has_value() && !settings::preset("nope")); - CHECK(i18n::normalize_locale("zh_CN") == "zh-cn" && i18n::normalize_locale("de") == "en"); - CHECK(i18n::translate("{0} and {1}", "en", {"a", "b"}) == "a and b"); - CHECK(i18n::translate("Actions", "zh-cn") == "操作"); + check(reg["settings"].size() > 60 && reg["presets"].size() >= 3); + check(settings::preset("quiet").has_value() && !settings::preset("nope")); + check(i18n::normalize_locale("zh_CN") == "zh-cn" && i18n::normalize_locale("de") == "en"); + check(i18n::translate("{0} and {1}", "en", {"a", "b"}) == "a and b"); + check(i18n::translate("Actions", "zh-cn") == "操作"); std::println("test_settings: ok"); } diff --git a/tests/test_testing.cpp b/tests/test_testing.cpp index 96cdd80..e23ae4d 100644 --- a/tests/test_testing.cpp +++ b/tests/test_testing.cpp @@ -3,14 +3,16 @@ // and its macros; cases named as each framework names them, listed names attached to them, the // filter escaped by the framework's rules, and the three report formats read back. And the // backend's source/entries and test/cases over a project on disk. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.scan; import mcppide.testing; import mcppide.rpc; import mcppide.server; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -18,7 +20,7 @@ namespace { const testing::Framework& fw(std::string_view id) { const auto* f = testing::framework(id); - CHECK(f != nullptr); + check(f != nullptr); return *f; } @@ -37,9 +39,9 @@ namespace n { int main(); } int main(int argc, char** argv) { return 0; } )", {}); - CHECK(r.mainLine && *r.mainLine == 8); - CHECK(!scan::scan("void f() { obj.main(); std::main(); }", {}).mainLine); - CHECK(scan::scan("export auto main() -> int { return 0; }", {}).mainLine == 0); + check(r.mainLine && *r.mainLine == 8); + check(!scan::scan("void f() { obj.main(); std::main(); }", {}).mainLine); + check(scan::scan("export auto main() -> int { return 0; }", {}).mainLine == 0); } // Macro calls at the start of a declaration, with their arguments. const auto macros = testing::macros(Json::object()); @@ -52,62 +54,62 @@ void helper() { auto x = TEST(1, 2); } TEST(Math, Spans) {} )", macros); - CHECK(r.calls.size() == 3); - CHECK(r.calls[0].name == "TEST" && r.calls[0].line == 1 && r.calls[0].arguments == std::vector({"Math", "Adds"})); - CHECK(r.calls[1].name == "TEST_P" && r.calls[1].line == 3); - CHECK(r.calls[2].line == 5 && r.calls[2].arguments[1] == "Spans"); - CHECK(scan::literal_value(R"("a \"b\"" " c")") == "a \"b\" c"); + check(r.calls.size() == 3); + check(r.calls[0].name == "TEST" && r.calls[0].line == 1 && r.calls[0].arguments == std::vector({"Math", "Adds"})); + check(r.calls[1].name == "TEST_P" && r.calls[1].line == 3); + check(r.calls[2].line == 5 && r.calls[2].arguments[1] == "Spans"); + check(scan::literal_value(R"("a \"b\"" " c")") == "a \"b\" c"); } // A program's framework: its package in the plan and its macros in its source. { const auto gtestSource = scan::scan("TEST(A, B) {}\nTEST_F(F, C) {}\n", macros); - CHECK(testing::framework_of({"app", "compat.gtest"}, gtestSource, Json::object()) == &fw("gtest")); - CHECK(testing::framework_of({"app"}, gtestSource, Json::object()) == nullptr); + check(testing::framework_of({"app", "compat.gtest"}, gtestSource, Json::object()) == &fw("gtest")); + check(testing::framework_of({"app"}, gtestSource, Json::object()) == nullptr); const auto ownMain = scan::scan("int main() { return 0; }\n", macros); - CHECK(testing::framework_of({"compat.gtest"}, ownMain, Json::object()) == nullptr); + check(testing::framework_of({"compat.gtest"}, ownMain, Json::object()) == nullptr); // A macro of the project's own, named by mcpp.test.macros. const Json extra{{"gtest", Json::array({"MY_TEST"})}}; const auto own = scan::scan("MY_TEST(S, N) {}\n", testing::macros(extra)); - CHECK(testing::framework_of({"compat.gtest"}, own, extra) == &fw("gtest")); + check(testing::framework_of({"compat.gtest"}, own, extra) == &fw("gtest")); const auto cases = testing::static_cases(fw("gtest"), own, extra); - CHECK(cases.size() == 1 && cases[0].name == "S.N" && cases[0].line == 0); + check(cases.size() == 1 && cases[0].name == "S.N" && cases[0].line == 0); // Catch2 and doctest share TEST_CASE; the package decides. const auto both = scan::scan("TEST_CASE(\"adds\", \"[math]\") {}\nSCENARIO(\"grows\") {}\n", macros); - CHECK(testing::framework_of({"compat.catch2"}, both, Json::object()) == &fw("catch2")); - CHECK(testing::framework_of({"compat.doctest"}, both, Json::object()) == &fw("doctest")); + check(testing::framework_of({"compat.catch2"}, both, Json::object()) == &fw("catch2")); + check(testing::framework_of({"compat.doctest"}, both, Json::object()) == &fw("doctest")); const auto catchCases = testing::static_cases(fw("catch2"), both, Json::object()); - CHECK(catchCases.size() == 2 && catchCases[0].name == "adds" && catchCases[1].name == "Scenario: grows"); + check(catchCases.size() == 2 && catchCases[0].name == "adds" && catchCases[1].name == "Scenario: grows"); const auto doctestCases = testing::static_cases(fw("doctest"), both, Json::object()); - CHECK(doctestCases[1].name == " Scenario: grows"); + check(doctestCases[1].name == " Scenario: grows"); } // Listings, base names and the merge. { const auto names = testing::listed(fw("gtest"), "Math.\n Adds\n Spans\nInst/Param.\n Works/0 # GetParam() = 1\n Works/1 # GetParam() = 2\nTyped/0. # TypeParam = int\n Holds\n"); - CHECK(names == std::vector({"Math.Adds", "Math.Spans", "Inst/Param.Works/0", "Inst/Param.Works/1", "Typed/0.Holds"})); - CHECK(testing::base_name(fw("gtest"), "Inst/Param.Works/1") == "Param.Works"); - CHECK(testing::base_name(fw("gtest"), "Typed/0.Holds") == "Typed.Holds"); + check(names == std::vector({"Math.Adds", "Math.Spans", "Inst/Param.Works/0", "Inst/Param.Works/1", "Typed/0.Holds"})); + check(testing::base_name(fw("gtest"), "Inst/Param.Works/1") == "Param.Works"); + check(testing::base_name(fw("gtest"), "Typed/0.Holds") == "Typed.Holds"); const std::vector statics{{"Math.Adds", 1, "static"}, {"Param.Works", 3, "static"}, {"Math.Spans", 5, "static"}}; const auto merged = testing::merge(statics, names, fw("gtest")); - CHECK(merged[0].name == "Math.Adds" && merged[0].source == "listed"); - CHECK(merged[1].name == "Inst/Param.Works/0" && merged[1].line == 3); - CHECK(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Inst/Param.Works/1" && c.line == 3; })); - CHECK(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Typed/0.Holds" && c.line == -1; })); + check(merged[0].name == "Math.Adds" && merged[0].source == "listed"); + check(merged[1].name == "Inst/Param.Works/0" && merged[1].line == 3); + check(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Inst/Param.Works/1" && c.line == 3; })); + check(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Typed/0.Holds" && c.line == -1; })); const auto listedCatch = testing::listed(fw("catch2"), R"({"version": 1, "listings": {"tests": [{"name": "adds"}, {"name": "a, b"}]}})"); - CHECK(listedCatch == std::vector({"adds", "a, b"})); + check(listedCatch == std::vector({"adds", "a, b"})); const auto listedDoctest = testing::listed(fw("doctest"), "[doctest] doctest version is \"2.4.12\"\n===\nadds\n Scenario: grows\n===\n[doctest] unskipped test cases passing the current filters: 2\n"); - CHECK(listedDoctest == std::vector({"adds", " Scenario: grows"})); + check(listedDoctest == std::vector({"adds", " Scenario: grows"})); } // The arguments after `--`: the filter escaped by the framework's rules, and the report. { const auto g = testing::run_args(fw("gtest"), {"Math.Adds", "Inst/Param.Works/0"}, "/r/out"); - CHECK(g == std::vector({"--gtest_filter=Math.Adds:*/Math.Adds/*:Math/*.Adds:Inst/Param.Works/0", "--gtest_output=json:/r/out"})); + check(g == std::vector({"--gtest_filter=Math.Adds:*/Math.Adds/*:Math/*.Adds:Inst/Param.Works/0", "--gtest_output=json:/r/out"})); const auto c = testing::run_args(fw("catch2"), {"a, b", "x[1]"}, "/r/out"); - CHECK(c == std::vector({"a\\, b,x\\[1\\]", "--reporter", "json::out=/r/out"})); + check(c == std::vector({"a\\, b,x\\[1\\]", "--reporter", "json::out=/r/out"})); const auto d = testing::run_args(fw("doctest"), {}, "/r/out"); - CHECK(d == std::vector({"--reporters=junit", "--out=/r/out"})); + check(d == std::vector({"--reporters=junit", "--out=/r/out"})); } // Reports. @@ -116,29 +118,84 @@ TEST(Math, {"name": "Adds", "status": "RUN", "result": "COMPLETED", "time": "0.002s"}, {"name": "Spans", "status": "RUN", "result": "COMPLETED", "time": "0s", "failures": [{"failure": "a.cpp:3\nExpected 2"}]}, {"name": "Later", "status": "RUN", "result": "SKIPPED", "time": "0s"}]}]})"); - CHECK(g.size() == 3 && g[0].name == "Math.Adds" && g[0].status == "passed" && g[0].durationMs == 2); - CHECK(g[1].status == "failed" && g[1].message.find("Expected 2") != std::string::npos); - CHECK(g[2].status == "skipped"); + check(g.size() == 3 && g[0].name == "Math.Adds" && g[0].status == "passed" && g[0].durationMs == 2); + check(g[1].status == "failed" && g[1].message.find("Expected 2") != std::string::npos); + check(g[2].status == "skipped"); const auto c = testing::read_report(fw("catch2"), R"({"version": 1, "test-run": {"test-cases": [ {"test-info": {"name": "adds"}, "runs": [], "totals": {"assertions": {"passed": 1, "failed": 0}}}, {"test-info": {"name": "grows"}, "runs": [{"path": [{"kind": "assertion", "status": false, "source-location": {"filename": "g.cpp", "line": 7}}]}], "totals": {"assertions": {"passed": 0, "failed": 1}}}]}})"); - CHECK(c.size() == 2 && c[0].status == "passed" && c[1].status == "failed" && c[1].message.starts_with("g.cpp:7")); + check(c.size() == 2 && c[0].status == "passed" && c[1].status == "failed" && c[1].message.starts_with("g.cpp:7")); const auto d = testing::read_report(fw("doctest"), R"( - + )"); - CHECK(d.size() == 3 && d[0].status == "passed" && d[0].durationMs == 1); - CHECK(d[1].name == "a & b" && d[1].status == "failed" && d[1].message.find("NOT correct") != std::string::npos); - CHECK(d[2].status == "skipped"); + check(d.size() == 3 && d[0].status == "passed" && d[0].durationMs == 1); + check(d[1].name == "a & b" && d[1].status == "failed" && d[1].message.find("NOT correct") != std::string::npos); + check(d[2].status == "skipped"); + + // Each case's own output, by GoogleTest's boundaries (D16); none for a framework without them. + const auto outputs = testing::case_outputs(fw("gtest"), "Running main() from gtest_main.cc\n" + "[==========] Running 3 tests from 1 test suite.\n" + "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n" + "[ RUN ] Math.Spans\na.cpp:3: Failure\nExpected 2\n[ FAILED ] Math.Spans (0 ms)\n" + "[ RUN ] Math.Crashes\nhalf way\n"); + check(outputs.size() == 3); + check(outputs.at("Math.Adds") == "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n"); + check(outputs.at("Math.Spans").contains("a.cpp:3: Failure") && outputs.at("Math.Spans").ends_with("[ FAILED ] Math.Spans (0 ms)\n")); + check(outputs.at("Math.Crashes") == "[ RUN ] Math.Crashes\nhalf way\n"); + check(testing::case_outputs(fw("catch2"), "[ RUN ] x\n").empty()); + } + + // mtest's cases: `"name"_test` literals, prefixed by the suites they are declared in; never a + // literal in a comment or one not followed by `_test`. The framework is claimed by its package + // and its cases, and a record of `mcpp test` lists and reports them (the test protocol). + { + const auto r = scan::scan(R"(import mcpplibs.mtest; +using namespace mcpplibs::mtest; +// "commented"_test +suite parsing{"parsing", [] { + "an empty line"_test = [] { expect(true); }; + suite fields{"fields", [] { + "one field"_test[tag{"x"}] = [] {}; + }}; + const char* s = "not a case"; +}}; +mcpplibs::mtest::suite other{"other", [] { "after"_test = [] {}; }}; +int main() { "top"_test = [] {}; return report(); } +)", {}); + check(r.cases.size() == 4); + check(r.cases.size() == 4 && r.cases[0].id == "parsing/an empty line" && r.cases[0].line == 4); + check(r.cases.size() == 4 && r.cases[1].id == "parsing/fields/one field"); + check(r.cases.size() == 4 && r.cases[2].id == "other/after" && r.cases[3].id == "top"); + check(testing::framework_of({"app", "mcpplibs.mtest"}, r, Json::object()) == &fw("mtest")); + check(testing::framework_of({"app"}, r, Json::object()) == nullptr); + const auto cases = testing::static_cases(fw("mtest"), r, Json::object()); + check(cases.size() == 4 && cases[0].name == "parsing/an empty line" && cases[0].line == 4); + + const auto record = Json::parse(R"({"test":"parse","protocol":1, + "listed":[{"id":"parsing/an empty line","file":"tests/parse.cpp","line":5}], + "cases":[{"id":"a","status":"pass","duration_ms":1.5}, + {"id":"b","status":"fail","duration_ms":2,"failures":[{"file":"tests/parse.cpp","line":9,"message":"1 == 2","expected":"2","actual":"1"}]}, + {"id":"c","status":"skip","duration_ms":0,"reason":"later"}, + {"id":"d","status":"xfail","duration_ms":0}]})"); + const auto listed = testing::protocol_listing(record); + check(listed.size() == 1 && listed[0].id == "parsing/an empty line" && listed[0].line == 4); + const auto outcomes = testing::protocol_outcomes(record); + check(outcomes.size() == 4); + check(outcomes.size() == 4 && outcomes[0].outcome.status == "passed" && outcomes[0].outcome.durationMs == 1); + check(outcomes.size() == 4 && outcomes[1].outcome.status == "failed" && outcomes[1].location["line"] == 8 + && outcomes[1].outcome.message.contains("expected 2, actual 1")); + check(outcomes.size() == 4 && outcomes[2].outcome.status == "skipped" && outcomes[2].outcome.message == "later"); + check(outcomes.size() == 4 && outcomes[3].outcome.status == "passed"); } // source/entries and test/cases over a project on disk (no mcpp: the plan's packages are not // known, so no framework is claimed, and the static entries still answer). { - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-testing-{}", std::random_device{}()); + const auto base = testsupport::temp() / std::format("mcppide-testing-{}", std::random_device{}()); std::filesystem::create_directories(base / "src"); std::filesystem::create_directories(base / "tests" / "unit"); std::ofstream(base / "mcpp.toml") << "[package]\nname = \"p\"\nversion = \"0.1.0\"\n"; @@ -161,19 +218,19 @@ TEST(Math, answers.clear(); wire.dispatch(Json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, {"params", std::move(params)}}.dump()); for (int i = 0; i < 2000 && answers.empty(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(!answers.empty() && answers.back().contains("result")); + check(!answers.empty() && answers.back().contains("result")); return answers.back()["result"]; }; (void)call("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"trusted", false}, {"settings", {{"mcpp.path", "/no/such/mcpp"}}}}}}); auto program = call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "main.cpp").generic_string()}}); - CHECK(program["kind"] == "program" && program["main"]["line"] == 2 && program["main"]["approximate"] == true); - CHECK(call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "lib.cpp").generic_string()}})["kind"] == "none"); + check(program["kind"] == "program" && program["main"]["line"] == 2 && program["main"]["approximate"] == true); + check(call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "lib.cpp").generic_string()}})["kind"] == "none"); auto test = call("mcpp-ide/source/entries", Json{{"file", (base / "tests" / "unit" / "math.cpp").generic_string()}}); - CHECK(test["kind"] == "test" && test["test"] == "unit/math" && !test.contains("framework")); + check(test["kind"] == "test" && test["test"] == "unit/math" && !test.contains("framework")); auto cases = call("mcpp-ide/test/cases", Json{{"root", base.generic_string()}, {"test", "unit/math"}}); - CHECK(cases["framework"].is_null() && cases["cases"].empty()); + check(cases["framework"].is_null() && cases["cases"].empty()); std::error_code ignored; std::filesystem::remove_all(base, ignored); } diff --git a/tests/test_views.cpp b/tests/test_views.cpp index 61b98be..73e2399 100644 --- a/tests/test_views.cpp +++ b/tests/test_views.cpp @@ -1,10 +1,12 @@ // The view models, from the golden fixtures: what each panel and tree shows. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.views; import mcppide.policy; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -15,116 +17,145 @@ int main() { views::CacheOptions o; o.topN = 3; auto inv = views::cache_inventory(cache, o); - CHECK(inv["totalEntries"].get() == static_cast(cache["data"]["entries"].size())); - CHECK(inv["topLabels"].size() <= 3); + check(inv["totalEntries"].get() == static_cast(cache["data"]["entries"].size())); + check(inv["topLabels"].size() <= 3); std::int64_t sum = 0; for (const auto& b : inv["ageBuckets"]) sum += b["entries"].get(); - CHECK(sum <= inv["totalEntries"].get()); - CHECK(inv["ageBuckets"].size() == 4); - for (const auto& k : inv["byKind"]) CHECK(k["bytesText"].is_string()); - for (const auto& b : inv["ageBuckets"]) CHECK(b["bytesText"].is_string()); + check(sum <= inv["totalEntries"].get()); + check(inv["ageBuckets"].size() == 4); + for (const auto& k : inv["byKind"]) check(k["bytesText"].is_string()); + for (const auto& b : inv["ageBuckets"]) check(b["bytesText"].is_string()); policy::CleanOptions co; auto all = views::cache_preview(cache, *policy::plan("cacheAll", co), co); - CHECK(all["count"].get() == cache["data"]["entries"].size()); + check(all["count"].get() == cache["data"]["entries"].size()); auto pkgs = views::cache_preview(cache, *policy::plan("cacheDeps", co), co); - for (const auto& e : pkgs["entries"]) CHECK(e["kind"] == "pkg"); + for (const auto& e : pkgs["entries"]) check(e["kind"] == "pkg"); co.budgetGiB = 0.000001; auto gc = views::cache_preview(cache, *policy::plan("cacheGc", co), co); - CHECK(gc["count"].get() >= 0); + check(gc["count"].get() >= 0); auto tc = views::toolchains(fixture("toolchain-list.json")); - CHECK(!tc["rows"].empty() && tc["defaults"].size() >= 1); + check(!tc["rows"].empty() && tc["defaults"].size() >= 1); for (const auto& r : tc["rows"]) { const auto spec = r["spec"].get(); - if (r["toolchain"].get().find(' ') == std::string::npos) CHECK(spec.empty()); - else CHECK(spec.find('@') != std::string::npos); + if (r["toolchain"].get().find(' ') == std::string::npos) check(spec.empty()); + else check(spec.find('@') != std::string::npos); } auto search = fixture("search.json"); auto lib = views::library(search, views::LibraryQuery{}); - CHECK(lib["total"].get() == search["data"]["packages"].size()); + check(lib["total"].get() == search["data"]["packages"].size()); const auto firstId = lib["packages"][0]["id"].get(); - CHECK(!views::library_detail(search, firstId).is_null()); + check(!views::library_detail(search, firstId).is_null()); auto filtered = views::library(search, views::LibraryQuery{"zzzz-no-such-package"}); - CHECK(filtered["total"] == 0); + check(filtered["total"] == 0); auto db = fixture("build-database.json"); auto pg = views::package_graph(db); - CHECK(pg["nodes"].size() >= 2 && !pg["edges"].empty()); + check(pg["nodes"].size() >= 2 && !pg["edges"].empty()); auto mg = views::module_graph(db); bool std_node = false; for (const auto& n : mg["nodes"]) std_node = std_node || n["id"] == "std"; - CHECK(std_node); + check(std_node); auto summary = views::project_summary(db, Json{{"record", fixture("result-ok.json")["data"]}}); - CHECK(summary["targets"].size() == 1 && summary["tests"].size() == 1); - CHECK(summary["lastBuild"]["status"] == "ok" && !summary["artifacts"].empty()); - CHECK(views::format_bytes(1536) == "1.5 KiB"); + check(summary["targets"].size() == 1 && summary["tests"].size() == 1); + check(summary["lastBuild"]["status"] == "ok" && !summary["artifacts"].empty()); + check(views::format_bytes(1536) == "1.5 KiB"); // Targets: one row per target, the selected one marked, and the platform a row names (the shape // of mcpp's platform object, design 2026-10-28 §9) carried and rendered. auto list = fixture("toolchain-list.json"); auto t = views::targets(list, ""); - CHECK(!t["rows"].empty()); + check(!t["rows"].empty()); std::set seen; int selected = 0; for (const auto& r : t["rows"]) { - CHECK(seen.insert(r["target"].get()).second); - if (r["selected"].get()) { ++selected; CHECK(r["target"] == t["host"]); } - CHECK(r["platform"].is_null() == r["platformText"].get().empty()); + check(seen.insert(r["target"].get()).second); + if (r["selected"].get()) { ++selected; check(r["target"] == t["host"]); } + check(r["platform"].is_null() == r["platformText"].get().empty()); } - CHECK(selected == 1); + check(selected == 1); // A row names its platform as the target table does, a package spec (mcpp SPEC-013); the view // renders it as a plan's platform object. bool found = false; for (const auto& r : t["rows"]) if (r["target"] == "x86_64-openkal") { found = true; - CHECK(r["platform"]["name"] == "mcpplibs.openkal-platform"); - CHECK(r["platformText"].get().starts_with("mcpplibs.openkal-platform 0.17")); - CHECK(r["platformText"].get().ends_with("(target table)")); + check(r["platform"]["name"] == "mcpplibs.openkal-platform"); + check(r["platformText"].get().starts_with("mcpplibs.openkal-platform 0.17")); + check(r["platformText"].get().ends_with("(target table)")); } - CHECK(found); + check(found); list["data"]["hostPlatform"] = Json{{"name", ""}, {"version", nullptr}, {"source", "built-in"}}; auto withPlatform = views::targets(list, "x86_64-openkal"); for (const auto& r : withPlatform["rows"]) - if (r["target"] == "x86_64-openkal") CHECK(r["selected"] == true); - CHECK(withPlatform["hostPlatformText"] == "host conventions (built-in)"); + if (r["target"] == "x86_64-openkal") check(r["selected"] == true); + check(withPlatform["hostPlatformText"] == "host conventions (built-in)"); // Target sets (mcpp SPEC-015): offered only when the installed mcpp names them; one row per // group of targets of one platform that differ only in the architecture, before its members. - for (const auto& r : t["rows"]) CHECK(!r.contains("set")); + for (const auto& r : t["rows"]) check(!r.contains("set")); auto withSets = views::targets(list, "openkal", true); std::size_t setAt = 0, memberAt = 0, i = 0; bool setSelected = false; for (const auto& r : withSets["rows"]) { if (r["target"] == "openkal") { setAt = i; - CHECK(r["set"] == true); - CHECK(r["members"].size() >= 2); + check(r["set"] == true); + check(r["members"].size() >= 2); // The members in the target table's order. - CHECK(r["label"] == "openkal (aarch64, x86_64)"); + check(r["label"] == "openkal (aarch64, x86_64)"); setSelected = r["selected"].get(); } if (r["target"] == "x86_64-openkal" && memberAt == 0) memberAt = i; ++i; } - CHECK(setSelected && setAt < memberAt); + check(setSelected && setAt < memberAt); // A planned row is no member: linux-musl is x86_64 and aarch64, not the planned riscv64. for (const auto& r : withSets["rows"]) - if (r["target"] == "linux-musl") CHECK(r["members"].size() == 2); + if (r["target"] == "linux-musl") check(r["members"].size() == 2); // Stream records: each line of a build and of a test, normalised. std::istringstream build(testsupport::read(testsupport::fixtures() / "build-failed.ndjson")); std::set types; for (std::string line; std::getline(build, line);) if (!line.empty()) types.insert(views::stream_record(Json::parse(line))["type"].get()); - CHECK(types.contains("plan") && types.contains("progress") && types.contains("diagnostic")); + check(types.contains("plan") && types.contains("progress") && types.contains("diagnostic")); std::istringstream test(testsupport::read(testsupport::fixtures() / "test.ndjson")); std::string first; std::getline(test, first); auto r = views::stream_record(Json::parse(first)); - CHECK(r["type"] == "test" && r["status"] == "passed" && r["test"] == "test_smoke"); - auto failed = views::stream_record(Json{{"test", "t"}, {"status", "fail"}, {"timed_out", true}}); - CHECK(failed["status"] == "timedOut"); + check(r["type"] == "test" && r["status"] == "passed" && r["test"] == "test_smoke"); + // mcpp's statuses, value for value (D16): pass, run_fail (timed_out), compile_fail, not_run, built. + auto timedOut = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, {"timed_out", true}}); + check(timedOut["status"] == "timedOut" && timedOut["timedOut"] == true); + auto failed = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, + {"run_output", "running\ntests/t.cpp:12: assertion failed\n"}}); + check(failed["status"] == "failed" && failed["location"]["path"] == "tests/t.cpp" && failed["location"]["line"] == 11); + auto compileFailed = views::stream_record(Json{{"test", "t"}, {"status", "compile_fail"}, {"compile_output", "tests/t.cpp:3:5: error: x\n"}}); + check(compileFailed["status"] == "failed" && compileFailed["compileFailed"] == true && compileFailed["location"]["line"] == 2); + auto notRun = views::stream_record(Json{{"test", "t"}, {"status", "not_run"}, {"reason", "no runner for riscv64"}}); + check(notRun["status"] == "skipped" && notRun["reason"] == "no runner for riscv64" && notRun["location"].is_null()); + auto built = views::stream_record(Json{{"test", "t"}, {"status", "built"}}); + check(built["status"] == "skipped" && built["builtOnly"] == true && !built["reason"].get().empty()); + check(views::stream_record(Json{{"test", "t"}, {"status", "fail"}})["status"] == "errored"); + // A passed test keeps its output. + auto passed = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", "hello\n"}}); + check(passed["status"] == "passed" && passed["output"] == "hello\n" && passed["outputTruncated"] == false); + // Past the limit, the head and the tail are kept, and the record says so. + std::string big = "first line\n"; + while (big.size() < 3 * views::kOutputLimit) big += "a line of output\n"; + big += "last line\n"; + auto long_ = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", big}}); + const auto kept = long_["output"].get(); + check(long_["outputTruncated"] == true && kept.size() <= views::kOutputLimit + 100); + check(kept.starts_with("first line\n") && kept.ends_with("last line\n") && kept.contains("bytes of output left out")); + // Where a failure is: GCC/Clang, MSVC and a drive; nothing that only looks like it. + check(views::failure_location("src/a.cpp:3:5: error: x")["line"] == 2); + check(views::failure_location("C:\\p\\a.cpp(12): error C2065")["path"] == "C:\\p\\a.cpp"); + check(views::failure_location("C:\\p\\a.cpp(12): error C2065")["line"] == 11); + check(views::failure_location("C:/p/b.cpp:7: Failure")["path"] == "C:/p/b.cpp"); + check(views::failure_location("started at 12:30: ok").is_null()); + check(views::failure_location("no location here").is_null()); std::println("test_views: ok"); } diff --git a/testsupport/mcpp.toml b/testsupport/mcpp.toml new file mode 100644 index 0000000..12c6593 --- /dev/null +++ b/testsupport/mcpp.toml @@ -0,0 +1,13 @@ +[package] +name = "mcpp-ide-testsupport" +version = "0.1.0" +description = "What every backend test of mcpp-ide shares: a check that says where it failed, and the paths of the fixtures and the built programs" +license = "Apache-2.0" + +[language] +standard = "c++23" +modules = true +import_std = true + +[targets.mcpp-ide-testsupport] +kind = "lib" diff --git a/testsupport/src/testsupport.cppm b/testsupport/src/testsupport.cppm new file mode 100644 index 0000000..1d5decd --- /dev/null +++ b/testsupport/src/testsupport.cppm @@ -0,0 +1,55 @@ +// mcppide.testsupport: what every backend test shares -- a check that says where it failed, and the +// paths of the fixtures and the built programs. A module, as the backend's own units are: no test +// includes a header (mcpp-ide-dev check-sources). +export module mcppide.testsupport; + +import std; + +export namespace testsupport { + +// A failed check names its place and ends the test. +inline void check(bool cond, std::source_location at = std::source_location::current()) { + if (cond) return; + std::println(std::cerr, "{}:{}: check failed in {}", at.file_name(), at.line(), at.function_name()); + std::exit(1); +} + +// A name in the generic spelling: `/` between components. The tests run as ok images too, whose +// names are `/`-separated on every system, while a Windows host spells this file's own path and the +// temporary directory with `\`. +inline std::filesystem::path generic(std::string text) { + for (auto& c : text) if (c == '\\') c = '/'; + return std::filesystem::path(text); +} +// The repository, from this file's own path (testsupport/src/testsupport.cppm). +inline std::filesystem::path repo() { + return generic(std::source_location::current().file_name()).parent_path().parent_path().parent_path(); +} +// The temporary directory, in the generic spelling. +inline std::filesystem::path temp() { return generic(std::filesystem::temp_directory_path().string()); } +// A path as the `file:` URI an LSP client names it by. +inline std::string file_uri(const std::filesystem::path& p) { + const auto g = p.generic_string(); + return "file://" + std::string(g.starts_with("/") ? "" : "/") + g; +} +inline std::filesystem::path fixtures() { return repo() / "protocol" / "fixtures"; } +inline std::string read(const std::filesystem::path& p) { + std::ifstream in(p, std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); +} +// A program the build produced (the backend, the mock mcpp): newest first. +inline std::filesystem::path built(const std::string& name) { + std::filesystem::path best; + std::filesystem::file_time_type bestTime{}; + std::error_code ec; + for (std::filesystem::recursive_directory_iterator it(repo() / "target", ec), end; !ec && it != end; it.increment(ec)) { + const auto file = it->path().filename().string(); + if (file != name && file != name + ".exe") continue; + if (it->path().parent_path().filename() != "bin") continue; + const auto t = std::filesystem::last_write_time(it->path(), ec); + if (best.empty() || t > bestTime) { best = it->path(); bestTime = t; } + } + return best; +} + +} // namespace testsupport