diff --git a/.gitattributes b/.gitattributes index 8909cc8..045dd15 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,4 @@ * text=auto eol=lf protocol/fixtures/** -text *.png binary +tests/rules/corpus/** -text diff --git a/.github/versions.env b/.github/versions.env index 614779f..88b0b54 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,14 +1,16 @@ # The versions CI builds and tests with, in one file; .github/actions/setup-mcpp exports them. -# The released mcpp CI installs and uses: the control contract the fixtures were captured from -# (protocol/fixtures/version.txt) and the platforms (openkal-platform 0.17.3) the backend's ok images -# are built on. -MCPP_VERSION=2026.10.27.1 +# The released mcpp CI installs and uses: it states its features (`features`, the project's mcpp of +# part 2 §4.3), test-launch among them (2026.10.31.2), and builds the backend's ok images on +# openkal-platform 0.23.3, which it compiles the std module for with the platform's words +# (2026.11.3.1). The mock replays the control contract the fixtures were captured from +# (protocol/fixtures/version.txt). +MCPP_VERSION=2026.11.3.1 # 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.10.27.1 bundles, which reads the current index. +# The xlings mcpp 2026.11.3.1 bundles, which reads the current index. XLINGS_VERSION=v2026.10.26.1 # The oldest VS Code the extension supports (engines.vscode), which its tests run in. VSCODE_VERSION=1.91.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e72d05c..12a4706 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ concurrency: env: XLINGS_NON_INTERACTIVE: '1' + NODE_VERSION: 22 jobs: # The backend on each system the editors run on: build every program (the backend, the mock mcpp, @@ -33,9 +34,9 @@ jobs: include: - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', pack: true } - { os: ubuntu-24.04-arm, platform: linux-arm64, llvm: 'false' } - - { os: macos-14, platform: darwin-arm64, llvm: 'true' } + - { 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.17.3' } + - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', host-platform: 'openkal-platform@0.23.3' } defaults: run: shell: bash @@ -58,10 +59,10 @@ jobs: run: | set -euo pipefail version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" - package="mcpp-ide-$version-openkal" - mcpp pack mcpp-ide --target x86_64-openkal --mode self-contained --systems linux,macos,windows \ - --pkg-format tar -o "$package.tar.gz" - mcpp pack mcpp-ide --target x86_64-openkal --pkg-format okb + mcpp pack mcpp-ide --target openkal --mode self-contained --systems all --pkg-format tar,okb -o target/dist/ + # The tree's name carries the mode (`mcpp-ide--openkal-bundle-all`, mcpp SPEC-014). + tarball="$(ls target/dist/mcpp-ide-"$version"-openkal*.tar.gz)" + package="$(basename "$tarball" .tar.gz)" mkdir -p backend unpacked cp "target/dist/$package.tar.gz" backend/ cp "target/dist/mcpp-ide-$version.okb" backend/mcpp-ide.okb @@ -70,9 +71,9 @@ jobs: python3 - "unpacked/$package/mcpp-package.json" <<'PY' import json, sys d = json.load(open(sys.argv[1])) - print("legs", d["legs"], "systems", d["systems"]) + print("legs", d["legs"], "runs_on", d["runs_on"]) want = {"linux-x86_64", "linux-aarch64", "macos-aarch64", "windows-x86_64"} - assert want <= set(d["systems"]), d["systems"] + assert want <= set(d["runs_on"]["self"]), d["runs_on"] PY "unpacked/$package/bin/mcpp-ide" --version backend/okrun/linux-x86_64/okrun backend/mcpp-ide.okb --version @@ -83,9 +84,61 @@ jobs: name: backend path: backend - # The VS Code extension (mide-plugins/vscode): unit tests (the wire, the mappings) and the shared panels in a DOM, both - # against the backend built here; the extension in a real VS Code (the oldest it supports) over the - # mock mcpp, with that backend and with the bundle it carries; then the package, and its file list. + # 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 + # gates against 0.9.0; the package with the bundle and every system's okrun; the Extension Host + # suite against every mcppls variant on the three systems, over the bundled backend; an isolated + # install that resolves the language service; the graph page in Chromium. + vscode-gates: + name: VS Code gates (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + working-directory: mide-plugins/vscode + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - run: npm ci + - name: Registry, translation and unit gates + run: npm test + + vscode-drift: + name: VS Code manifest and settings documentation + runs-on: ubuntu-24.04 + timeout-minutes: 45 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - name: The manifest, its strings and the settings registry are spec/'s + run: | + mcpp run mcpp-ide-dev -- gen vscode + git diff --exit-code -- mide-plugins/vscode spec + - run: npm ci + working-directory: mide-plugins/vscode + - name: The settings documentation matches the registry + working-directory: mide-plugins/vscode + run: | + npm run gen:docs + git diff --exit-code docs/settings.md + vscode: name: VS Code extension needs: backend @@ -99,7 +152,7 @@ jobs: - uses: ./.github/actions/setup-mcpp - uses: actions/setup-node@v5 with: - node-version: 22 + node-version: ${{ env.NODE_VERSION }} cache: npm cache-dependency-path: mide-plugins/vscode/package-lock.json - uses: actions/download-artifact@v5 @@ -114,36 +167,228 @@ jobs: echo "MCPP_IDE_OKB=$GITHUB_WORKSPACE/backend/mcpp-ide.okb" echo "MCPP_IDE_OKRUN=$GITHUB_WORKSPACE/backend/okrun" } >> "$GITHUB_ENV" - - name: Install + - run: npm ci working-directory: mide-plugins/vscode - run: npm ci - - name: Unit and panel tests + - name: Unit tests, with the backend built here working-directory: mide-plugins/vscode run: npm test - - name: The panels type-check + - name: Parity with 0.9.0 (the manifest and strings, every page's document) working-directory: mide-plugins/vscode - run: npm run check:webui - - name: The extension in VS Code ${{ env.VSCODE_VERSION }} + run: npm run test:parity + - name: Package, with the bundle and every system's okrun working-directory: mide-plugins/vscode - run: xvfb-run -a npm run test:e2e - - name: Package + run: npm run package + - name: Verify the VSIX archive + working-directory: mide-plugins/vscode + run: unzip -t mcpp-vscode-*.vsix + - name: The manifest keeps its promises working-directory: mide-plugins/vscode run: | - npm run package - (cd dist/extension && npx vsce ls --no-dependencies) | tee files.txt - grep -qx "backend/mcpp-ide.okb" files.txt - for p in linux-x86_64/okrun linux-aarch64/okrun macos-aarch64/okrun windows-x86_64/okrun.exe; do grep -qx "backend/okrun/$p" files.txt; done - [ "$(grep -c '\.okb$' files.txt)" = 1 ] - ls -l dist/*.vsix + node -e ' + const p = require("./package.json"); + const fail = (message) => { console.error(message); process.exit(1); }; + if (p.extensionDependencies?.[0] !== "sunrisepeak.mcpp-language-server") fail("the dependency moved"); + if (p.extensionDependencies.some((id) => id.includes("clangd"))) fail("clangd is back in the dependency list"); + // The language client of the backend is bundled into dist/vendor; nothing is a runtime dependency. + if (Object.keys(p.dependencies ?? {}).length) fail("a runtime dependency crept in: the VSIX carries no node_modules"); + if ((p.activationEvents ?? []).some((event) => event.startsWith("onCommand:"))) fail("onCommand activations are derived since 1.74"); + if (!p.contributes?.viewsContainers?.activitybar?.length) fail("the view container disappeared"); + console.log("manifest ok"); + ' + - name: The VSIX carries the runtime artefacts and the backend + working-directory: mide-plugins/vscode + run: | + set -euo pipefail + VSIX=$(ls mcpp-vscode-*.vsix) + for entry in \ + extension/l10n/bundle.l10n.zh-cn.json \ + extension/package.nls.json \ + extension/package.nls.zh-cn.json \ + extension/dist/data/config-registry.json \ + extension/media/settings.css \ + extension/backend/mcpp-ide.okb \ + extension/backend/okrun/linux-x86_64/okrun \ + extension/backend/okrun/linux-aarch64/okrun \ + extension/backend/okrun/macos-aarch64/okrun \ + extension/backend/okrun/windows-x86_64/okrun.exe; do + unzip -l "$VSIX" | grep -q "$entry" || { echo "missing from the VSIX: $entry"; exit 1; } + done + echo "VSIX contents ok" + - name: The VSIX stays small and free of local state + working-directory: mide-plugins/vscode + run: | + set -euo pipefail + VSIX=$(ls mcpp-vscode-*.vsix) + SIZE=$(stat -c %s "$VSIX") + COUNT=$(unzip -l "$VSIX" | tail -1 | awk '{print $2}') + echo "VSIX: $COUNT files, $((SIZE / 1024)) KiB" + if [ "$COUNT" -gt 220 ]; then echo "the VSIX has $COUNT files; something local leaked in"; exit 1; fi + # Offline Mermaid, the pinned official tests, the backend bundle (two images) and four okruns. + if [ "$SIZE" -gt 12582912 ]; then echo "the VSIX is over 12 MiB ($SIZE bytes)"; exit 1; fi + for required in dist/vendor/mermaid.js dist/vendor/MERMAID-LICENSE dist/data/library-examples.json; do + unzip -p "$VSIX" "extension/$required" > /dev/null + done + for forbidden in .dev-profile/ .agents/ .agnets/ test/ tools/ src/ node_modules/; do + if unzip -l "$VSIX" | grep -q "extension/$forbidden"; then + echo "leaked into the VSIX: $forbidden" + exit 1 + fi + done + - name: One language client, the backend's, bundled + working-directory: mide-plugins/vscode + run: | + test ! -e dist/src/languageClient.js + ! grep -rqsE "require\(['\"]vscode-languageclient['\"]\)|from ['\"]vscode-languageclient['\"]" dist/src package.json + test "$(grep -rlE "vendor/languageclient" dist/src)" = "dist/src/ide/session.js" + test -s dist/vendor/languageclient.js - uses: actions/upload-artifact@v5 with: name: mcpp-vscode-vsix - path: mide-plugins/vscode/dist/*.vsix + path: mide-plugins/vscode/mcpp-vscode-*.vsix + if-no-files-found: error + + # One Extension Host run per mcppls variant, on the three desktop systems, over the backend the + # package carries (staged into the extension): the spawn and path handling the suite exercises is + # exactly what differs between them. + vscode-e2e: + name: VS Code Extension Host (${{ matrix.os }}) + needs: backend + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + defaults: + run: + shell: bash + working-directory: mide-plugins/vscode + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - uses: actions/download-artifact@v5 + with: + name: backend + path: backend + - run: npm ci + - run: npm run compile + - name: The backend the package carries + run: MCPP_IDE_OKB="$GITHUB_WORKSPACE/backend/mcpp-ide.okb" MCPP_IDE_OKRUN="$GITHUB_WORKSPACE/backend/okrun" npm run stage:backend + - name: Cache the pinned VS Code download + uses: actions/cache@v5 + with: + path: mide-plugins/vscode/.vscode-test + key: vscode-1.91.0-${{ runner.os }}-${{ runner.arch }} + - name: Extension Host smoke tests against every mcppls variant + run: | + if [ "$(uname)" = "Linux" ]; then + xvfb-run -a npm run test:e2e:one + else + npm run test:e2e:one + fi + # The declared degraded mode (part 2 §8, F15): the bundle without a runner for this system, + # as on a platform okrun does not cover. + - name: Extension Host without its backend + env: + MCPP_E2E_NO_BACKEND: "1" + run: | + if [ "$(uname)" = "Linux" ]; then + xvfb-run -a npm run test:e2e:one + else + npm run test:e2e:one + fi + + # The only check that proves the dependency resolves end to end: install the packed VSIX into a + # private profile and let VS Code pull the platform build of the language service. + vscode-isolated-install: + name: VS Code isolated install (${{ matrix.os }}) + needs: vscode + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + working-directory: mide-plugins/vscode + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - run: npm ci + - uses: actions/download-artifact@v5 + with: + name: mcpp-vscode-vsix + path: mide-plugins/vscode + - name: Cache the pinned VS Code download + uses: actions/cache@v5 + with: + path: mide-plugins/vscode/.vscode-test + key: vscode-1.91.0-${{ runner.os }}-${{ runner.arch }} + - name: Download the VS Code CLI + run: | + set -euo pipefail + CODE=$(node tools/ci-vscode-cli.mjs | tr '\\' '/') + "$CODE" --version + echo "CODE=$CODE" >> "$GITHUB_ENV" + - name: Install into a private profile + run: | + set -euo pipefail + "$CODE" --extensions-dir "$PWD/.ci-extensions" --user-data-dir "$PWD/.ci-user" \ + --install-extension mcpp-vscode-*.vsix --force + "$CODE" --extensions-dir "$PWD/.ci-extensions" --user-data-dir "$PWD/.ci-user" \ + --install-extension sunrisepeak.mcpp-language-server + - name: The dependency resolved, with a version + run: | + set -euo pipefail + LIST=$("$CODE" --extensions-dir "$PWD/.ci-extensions" --user-data-dir "$PWD/.ci-user" \ + --list-extensions --show-versions | tr -d '\r') + echo "$LIST" + echo "$LIST" | grep -q "^mcpp-community.mcpp-vscode@" || { echo "the extension is missing"; exit 1; } + echo "$LIST" | grep -q "^sunrisepeak.mcpp-language-server@" || { echo "the dependency did not resolve"; exit 1; } + VERSION=$(printf '%s\n' "$LIST" | sed -n 's/^sunrisepeak\.mcpp-language-server@//p') + echo "resolved mcppls $VERSION" + echo "resolved mcppls \`$VERSION\`" >> "$GITHUB_STEP_SUMMARY" + + vscode-graph-browser: + name: VS Code graph page in Chromium + runs-on: ubuntu-latest + timeout-minutes: 30 + defaults: + run: + working-directory: mide-plugins/vscode + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - name: Render and interact with the shipped graph in Chromium + run: npm run test:graphs-ui + - uses: actions/upload-artifact@v5 + if: always() + with: + name: graph-browser-evidence + path: mide-plugins/vscode/.dev-profile/graph-ui-artifacts + if-no-files-found: warn # The CLion plugin (mide-plugins/clion; the protocol's editor `jetbrains`, the IntelliJ platform): - # its tests (the wire and a session over the mock mcpp), the archive carrying the bundle and every - # system's okrun, and the Plugin Verifier against the oldest CLion it claims (2026.1) and the one it - # is built on. + # its tests (the wire and a session over the mock mcpp), the workbench it shows (mcpp-vscode's pages, + # bundled by the extension's tools) in Chromium over the backend built here, the archive carrying + # the workbench, the bundle and every system's okrun, and the Plugin Verifier against the oldest + # CLion it claims (2026.1) and the one it is built on. clion: name: CLion plugin needs: backend @@ -155,6 +400,14 @@ jobs: steps: - uses: actions/checkout@v5 - uses: ./.github/actions/setup-mcpp + - uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: mide-plugins/vscode/package-lock.json + - name: The extension's dependencies (the workbench's bundler) + working-directory: mide-plugins/vscode + run: npm ci - name: Gradle (xim:gradle brings its JDK) run: xlings install gradle -y -g - uses: actions/cache@v5 @@ -178,6 +431,11 @@ jobs: echo "MCPP_IDE_OKB=$GITHUB_WORKSPACE/backend/mcpp-ide.okb" echo "MCPP_IDE_OKRUN=$GITHUB_WORKSPACE/backend/okrun" } >> "$GITHUB_ENV" + - name: The graph page in Chromium, over the backend + working-directory: mide-plugins/vscode + run: | + npx playwright install --with-deps chromium + npm run test:webui - name: Test and package working-directory: mide-plugins/clion run: gradle --no-daemon test buildPlugin @@ -191,7 +449,23 @@ jobs: grep -q "mcpp-clion/backend/mcpp-ide.okb" files.txt for p in linux-x86_64/okrun linux-aarch64/okrun macos-aarch64/okrun windows-x86_64/okrun.exe; do grep -q "mcpp-clion/backend/okrun/$p" files.txt; done [ "$(grep -c '\.okb$' files.txt)" = 1 ] + jar="$(grep -o 'mcpp-clion/lib/mcpp-clion-[^ ]*\.jar' files.txt | head -1)" + unzip -p build/distributions/mcpp-clion-*.zip "$jar" > plugin.jar + unzip -l plugin.jar | tee jar.txt | grep -q "webui/files.txt" + for f in $(unzip -p plugin.jar webui/files.txt); do grep -q " webui/$f$" jar.txt; done ls -l build/distributions/*.zip + # The plugin's icon and its tool window's are the mcpp logo, an embedded image (design 2026-10-29 + # §10.4), not text; the VS Code extension's logo is mcpp-vscode 0.9.0's, byte for byte. + - name: The icons are the mcpp logo + run: | + [ "$(md5sum < mide-plugins/vscode/media/logo.png | cut -d' ' -f1)" = f94579030616a45ca26694120daba050 ] \ + || { echo "::error::media/logo.png is not the mcpp logo"; exit 1; } + cd mide-plugins/clion + for f in META-INF/pluginIcon.svg icons/mcpp.svg; do + svg="$(unzip -p plugin.jar "$f")" + grep -q 'data:image/png;base64,' <<< "$svg" || { echo "::error::$f embeds no image of the logo"; exit 1; } + ! grep -q ' SHA256SUMS && cat SHA256SUMS) gh release create "v${version}" dist/* --target "$GITHUB_SHA" --title "mcpp-ide ${version}" --notes-file CHANGELOG.md + # Open VSX receives the exact VSIX attached to the GitHub Release; the Visual Studio + # Marketplace and the JetBrains Marketplace receive the same assets through their portals. + - uses: actions/setup-node@v5 + with: + node-version: 22 + - name: Publish to Open VSX + env: + OPENVSX_TOKEN: ${{ secrets.OPENVSX_TOKEN }} + run: | + set -euo pipefail + if [ -z "${OPENVSX_TOKEN:-}" ]; then + echo "OPENVSX_TOKEN is not set: Open VSX is a required destination of a release." + echo "Create a token with the publish scope at open-vsx.org (Settings, Access Tokens) and add it" + echo "as the OPENVSX_TOKEN secret of this repository (Settings, Secrets and variables, Actions)." + exit 1 + fi + (cd mide-plugins/vscode && npm ci --ignore-scripts) + vsix="$(ls dist/mcpp-vscode-*.vsix)" + (cd mide-plugins/vscode && npx ovsx publish "$GITHUB_WORKSPACE/$vsix" --pat "$OPENVSX_TOKEN") + - name: The Marketplaces + run: echo "Upload the GitHub Release VSIX manually to Visual Studio Marketplace, and the CLion zip to JetBrains Marketplace." diff --git a/.xlings.json b/.xlings.json index 8eff4f4..116255d 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.10.27.1" + "mcpp": "2026.11.3.1" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3553332..38e1a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,126 @@ # Changelog +## 0.2.0 + +mcpp-vscode 1.1.0, mcpp-clion 0.2.0 and mcpp-nvim 0.2.0 (mcpp design 2026-10-29 §10). + +- **The project's mcpp (mcpp design 2026-10-30 part 2 §4).** Each project is asked in its own + directory, `mcpp self env --format json` through the program a version manager's shim stands for + (or `mcpp.path`), with `XLINGS_PROJECT_DIR` removed from every run of mcpp, so a project pinned in + `.xlings.json` is described by the mcpp that builds it. A feature is turned on by what the project's + mcpp states (`features`, mcpp 2026.10.31.1) or, for an earlier mcpp, by a table frozen at that + release, never by comparing versions. A pinned version that is not installed is `pinned-missing`, + with the shim's words and its install command; an untrusted workspace's project is not asked. A + job whose envelope names another version, the editor regaining focus and a changed manifest each + make the project be asked again; never more than two at once. Methods `project/mcpp`, + `project/refresh`, `capabilities/explain` (why a feature is not offered: which party, which + capability, from which version) and `report/collect`; notification `project/mcppChanged`. +- **The backend's lifecycle and what it says (§3).** VS Code and CLion start an ended backend again + after 1, 4 and 16 seconds; a fourth end within three minutes leaves them in the degraded mode with + two actions, restarting the backend and exporting the IDE report. **mcpp: Export IDE Report** + (every editor; Neovim `:Mcpp exportReport [path]`) writes the backend, every project's mcpp, + mcppls's status, the settings in effect, recent jobs and the backend's recent log to a file the + person names. `mcpp.backend.logLevel` decides what the backend writes to the editor's log. VS + Code's status item names the project's mcpp version, and its tooltip the program and whether + `mcpp.path` chose it. +- **What mcppls says about the workspace (§5).** The backend's `languageService/status` runs + `mcppls status --root --format json` (mcppls 0.0.20 and later, bounded at five seconds) and + returns its capabilities, the workspace's state, engine and kit, the servers serving it, and + mcppls's actions under each editor's own id; for mcppls 0.0.18 and 0.0.19 the actions come from a + table frozen at those releases. A different answer is sent once (`languageService/changed`). The + command table's forwards name mcppls's action ids, and `commands/list` gives each editor the id + mcppls states for it: CLion runs the mcppls plugin's action (and hides a row it does not have), + Neovim mcppls-nvim's command. VS Code reads the state and the commands from this answer instead of + the mcppls extension's API object and keeps only the confirmation each action needs; the state is + asked again after a job, on focus, when the project view becomes visible and, while it is visible, + at most every 30 seconds. A missing semantic kit is a row with **C++ Modules: Install the Semantic + Kit**. After a build nothing is forwarded by default (`auto`): mcppls reads mcpp's result record. +- **Entries (§6).** The backend's `source/entries` says what a C++ file is to its project -- a + program's `main`, a test's (`tests/**`, named as mcpp names it), or neither -- with the line of + `main` read from the text, outside comments and literals. VS Code shows Run and Debug above `main` + (`mcpp.codeLens.enable`), at the position C++ Modules gives and otherwise at the line read, said to + be approximate; Build and Debug join Run and Test in the editor's title bar, and Run, Debug, Build + and Test are in the editor's context menu. The command table states these places (`placements`), + and the generator writes VS Code's menus and CLion's editor popup from it. CLion's ▶ stands on the + PSI element `main` on the line the backend read, the IDE's Build (the hammer) of an mcpp project is + `mcpp build`, a status bar item shows the project's mcpp and the running job with the quick menu, + and two keymaps (`mcpp`, `mcpp (macOS)`) carry VS Code's chords. +- **Test cases (§7).** `spec/test-frameworks.json` describes GoogleTest, Catch2 and doctest: a test + program belongs to one when its plan holds the framework's package and its source calls the + framework's macros (`mcpp.test.macros` adds a project's own). `test/cases` reads the cases from the + source and, after a build in a trusted workspace, from the program's own listing; `test/runCases` + runs some of them with the framework's filter and reads its report, each outcome sent as + `test/caseResult`. VS Code's Testing view has the cases under their program, located at their + macros, and the program at its `main`; CLion has a ▶ on each case and runs cases in its test tree. + Debugging a test's program is offered where the project's mcpp describes its launch (`test-launch`, + mcpp 2026.10.31.2). +- **The rules layer of mcpp.toml and build.mcpp, in the backend (§8; docs/rules.md).** The session + is an LSP server, protocol 2: `initialize` in LSP's shape with mcpp-ide's parameters in + `initializationOptions`, mcpp.toml's and build.mcpp's completion, hover, jumps and diagnostics as + `textDocument/*` (full synchronisation, UTF-16 columns), and mcpp-ide's own methods and + notifications under `mcpp-ide/`. The tolerant scanner, the seven manifest rules and the SPEC-007 + checks are ported from mcpp-vscode 0.9's TypeScript, whose answers over a corpus at every position + are the tests' goldens (`tests/rules`). A table is read through the schema (a workspace table as the + table it mirrors, a platform's table under a selector as the platform's); a selector-only table is + offered with its selector; the schema's own arrays of tables are allowed; a dependency's version is + completed from mcpp's catalogue (`mcpp search --format json`) when `mcpp.toml.indexCompletion` is + on. VS Code connects with its language client (bundled into `dist/vendor`); CLion over the + session's connection, through a completion contributor, an annotator, a documentation provider + and a go-to-declaration handler; Neovim with its LSP client, attached to mcpp.toml and build.mcpp + buffers. While C++ Modules serves build.mcpp (`mcpplsServes`) it is left the unknownSymbol rule + and the module names after `import`. One snapshot of each: `spec/toml-schema.json`, + `spec/buildscript-api.json` (mcpp 2026.10.29.1's directives). +- **CLion in its own components (§9).** The "mcpp" tool window's Project, Libraries and Cache tabs + are native trees and lists over the backend's view models (each project's mcpp, targets, tests, + libraries, last build, artifacts and platform, and what C++ Modules says; the indexes' packages, a + package's page and adding it to the project; what mcpp keeps and the command table's cleanups with + their confirmations). A graph of the packages or the modules is the one page in JCEF + (`webui/graph.html`, mermaid over `graph/packages` and `graph/modules`; a module opens its source), + tested in Chromium over a real backend. Settings | Tools | mcpp is a form generated from + `settings/registry`, with Apply, Reset and a search. The workbench that ran mcpp-vscode's pages in + JCEF, its `vscode` shim and its bridge are removed; the views' models and the settings form are + tested headless. +- **The declared degraded mode (§8, F15).** Where the package's okrun does not run (macOS x86_64, + Windows arm64, Linux armhf) VS Code says the backend does not run on this platform and goes on + with build, run, test and the project pages, running mcpp's queries itself; an Extension Host run + in CI holds it. +- **mcpp-vscode 1.1.0 is mcpp-vscode 0.9.0 over the backend.** 0.9.0's extension is imported with + its history (`mide-plugins/vscode`); every view, page, command, menu, setting and string a user + sees is 0.9.0's. What 1.0 added stays, in 0.9.0's style: debugging (`mcpp.debug`, the `mcpp` + launch type), the test tree, the target picker with target-set rows, packing, build on save, + cancellable jobs. mcpp is run through the backend's `mcpp/exec`, mcppls through its + `languageService/exec`; the extension starts no other process for mcpp or mcppls. +- **Parity gates against 0.9.0.** The manifest's contributions and strings contain 0.9.0's exactly + (`spec/` states them; `mcpp-ide-dev gen vscode` writes the manifest); every page's document, + rendered by the extension's generators, is byte-identical to the snapshot rendered by 0.9.0's; + the Extension Host suite runs over the bundled backend. +- **One page library for VS Code and CLion.** The workbench (`webui/`) runs mcpp-vscode's page + modules unchanged in a browser host: the `vscode` module and Node's built-ins are answered by + the host's bridge, views are tabs, webviews are iframes with the IDE's `--vscode-*` theme. + mcpp-clion's tool window and its settings page are this workbench in JCEF; the earlier panels + (`webui/js`) are removed. A Chromium test runs the workbench over a real backend. +- **Backend methods.** `mcpp/exec` (a bounded mcpp run: timeout, output limit, truncation reported), + `languageService/exec` (the located mcppls with arguments), `project/files` (manifest, lock and + the source count 0.9.0 shows); `targets` rows for target sets (`set`, `members`, `label`) when mcpp + is 2026.10.30.1 or later. +- **The mcpp logo in CLion.** mcpp-clion's plugin icon and its tool window's icon are the mcpp + logo, as mcpp-vscode's and mcppls's are; the text icons (`m++`, `m+`) are removed. CI checks that + both embed the logo and that the VS Code logo is 0.9.0's. +- **mcpp-clion on 2026.1 through 2026.3.** Trust through `TrustedProjects.isProjectTrusted`, debug + sessions through `XDebuggerManager.newSessionBuilder`, the debugger driver through + `createDriverConfiguration(project, CPPEnvironment(toolchain))`; packing is a job over the + backend's `pack/formats`. +- **mcpp 2026.11.3.1.** One pack of the target set `openkal` writes the editor packages' tree and + okb (`--target openkal --mode self-contained --systems all --pkg-format tar,okb`); CI reads + `runs_on`; the Windows launcher is `bin/mcpp-ide.exe`. The backend is on mcpplibs.os 0.2.8 and + openkal-platform 0.23.2, ok images included (openkal 0.23: okrun places an image at a base of the + boot, keeps its data as a prepared copy and starts it from a snapshot of its initialisers; every + unit, the std module included, is compiled with frame pointers and hardening; mcpp design + 2026-11-03 part 4). +- **CI and release.** 0.9.0's gates run in this repository (registry, translations, unit suite on + three systems, manifest drift, parity, Extension Host on three systems, isolated install, the + graph page in Chromium); the release publishes the VSIX to Open VSX beside the GitHub release. + ## 0.1.1 mcpp-vscode 1.0.1, mcpp-clion 0.1.1 and the first mcpp-nvim (0.1.1), with mcppls 0.0.19. diff --git a/README.md b/README.md index c2b8294..4fff171 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,12 @@ different tool. mcpp-ide makes them once. Its backend, `mcpp-ide serve`, is a C++ program that reads only mcpp's machine contract (event streams, result records, launch descriptors, JSON queries) and answers the -editors over JSON-RPC. The VS Code extension and the CLion plugin map those answers onto their own -UI and share one set of web panels; the Neovim plugin maps them onto buffers, floating windows and -the quickfix list. The editor plugins are `mide-plugins/` of this repository. Language features of -C++ files come from [mcppls](https://github.com/Sunrisepeak/mcpp-language-server); mcpp-ide -registers none. +editors as an LSP server (JSON-RPC, protocol 2). The VS Code extension's views and pages are +mcpp-vscode 0.9.0's; the CLion plugin shows the same information in CLion's own trees, lists and +settings form (a graph in JCEF), and the Neovim plugin maps the answers onto buffers, floating +windows and the quickfix list. The editor plugins are `mide-plugins/` of this repository. Language +features of C++ files come from [mcppls](https://github.com/Sunrisepeak/mcpp-language-server); +those of mcpp.toml and build.mcpp come from the backend ([docs/rules.md](docs/rules.md)). ## Features @@ -29,8 +30,9 @@ registers none. | Test tree, re-run one test | Testing view | IDE test runner | `:Mcpp test ` | | Debug | CodeLLDB, C/C++ (gdb, lldb), lldb-dap | CLion's GDB or LLDB | nvim-dap (codelldb, lldb-dap, cppdbg, gdb) | | Diagnostics | Problems, from mcpp's located diagnostics | Console links to the reported location | `vim.diagnostic`, `:Mcpp problems` in the quickfix list | -| mcpp.toml | Problems from mcpp; completion, hover, outline from mcpp's key registry | Problems from mcpp | Problems from mcpp | -| Targets and platforms, libraries, graphs, cache, pack, settings | Shared web panels | The same panels (JCEF) | Text in floating windows, `vim.ui.select`; settings in `setup()` | +| mcpp.toml, build.mcpp | Completion, hover, jumps and the rules' diagnostics from the backend (language client); Problems from mcpp | The same, through CLion's completion, documentation, annotations and go-to | The same, through `vim.lsp`; mcpp's problems in `vim.diagnostic` | +| Libraries, a package's page, graphs, cache, settings | mcpp-vscode's views and pages | Native trees and lists, a settings form from the registry, the graphs in JCEF | Text in floating windows, `vim.ui.select`; settings in `setup()` | +| Targets (target sets among them), pack | Quick picks | Popups | `vim.ui.select` | | Language features | mcppls (installed with the extension) | mcppls CLion plugin (a dependency) | mcppls-nvim (`xlings install mcppls-nvim`) | - **One answer everywhere** -- what a build runs, how a test is reported, what a cleanup costs: the backend's @@ -57,10 +59,10 @@ the editor I use (VS Code, CLion or Neovim) from its latest release, and check i | VS Code-compatible editors | Open VSX, or the `.vsix` | install the `.vsix` from the extensions view | | CLion 2026.1+ | JetBrains Marketplace (`mcpp-clion`), or `mcpp-clion-.zip` | Settings → Plugins → Install Plugin from Disk; the mcppls CLion plugin (its package for this system carries the server) first | | Neovim 0.10+ | `mide-plugins/nvim` of the release's source tree | `xlings install mcpp-ide-nvim` (the backend with it, set up at start); or the directory on the runtimepath and `require('mcpp').setup()` ([its README](mide-plugins/nvim/README.md)) | -| Agents, other clients | `mcpp-ide--openkal.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.cmd` on Windows), or `okrun mcpp-ide-.okb serve` | +| 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.17.3) and okrun for +(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.23.3) 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). @@ -69,7 +71,8 @@ Linux x64/arm64, macOS arm64 and Windows x64. The Neovim plugin carries none: it ```bash mcpp build # the backend, the mock mcpp, mcpp-ide-dev mcpp test # the backend's tests -cd mide-plugins/vscode && npm ci && npm test # the extension's and the panels' tests +cd mide-plugins/vscode && npm ci && npm test # the extension's tests, the parity gates against 0.9.0 among them +npm run test:webui # CLion's graph page in Chromium, over the backend npm run package # dist/mcpp-vscode-.vsix cd ../clion && gradle buildPlugin verifyPlugin # build/distributions/mcpp-clion-.zip cd ../.. && nvim --headless --clean -l mide-plugins/nvim/tests/smoke.lua # the Neovim plugin's tests diff --git a/README.zh-CN.md b/README.zh-CN.md index 58218bc..aa67629 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,9 +12,11 @@ 每个编辑器各写一遍,这些决定就会分歧;从一个编辑器换到另一个,用户遇到的就是另一个工具。 mcpp-ide 只做一次。它的后端 `mcpp-ide serve` 是一个 C++ 程序,只读取 mcpp 的机器契约(事件流、结果记录、启动描述、 -JSON 查询),通过 JSON-RPC 回答编辑器。VS Code 扩展与 CLion 插件把这些回答映射到各自的界面,并共用一套网页面板; -Neovim 插件把它们映射到缓冲区、浮动窗口与 quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。 -C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-language-server);mcpp-ide 不注册任何语言服务。 +JSON 查询),以 LSP 服务器(JSON-RPC,协议 2)回答编辑器。VS Code 扩展的视图与页面即 mcpp-vscode 0.9.0 的视图与页面; +CLion 插件以 CLion 自己的树、列表与设置表单显示同样的信息(图在 JCEF 中);Neovim 插件把这些回答映射到缓冲区、浮动窗口与 +quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。C++ 文件的语言功能来自 +[mcppls](https://github.com/Sunrisepeak/mcpp-language-server);mcpp.toml 与 build.mcpp 的语言功能来自后端 +([docs/rules.md](docs/rules.md))。 ## 功能 @@ -24,8 +26,9 @@ C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-lan | 测试树、单个测试重跑 | 测试视图 | IDE 测试运行器 | `:Mcpp test ` | | 调试 | CodeLLDB、C/C++(gdb、lldb)、lldb-dap | CLion 的 GDB 或 LLDB | nvim-dap(codelldb、lldb-dap、cppdbg、gdb) | | 诊断 | 问题面板,来自 mcpp 带位置的诊断 | 控制台链接到报告的位置 | `vim.diagnostic`,`:Mcpp problems` 填入 quickfix 列表 | -| mcpp.toml | 问题来自 mcpp;补全、悬停、大纲来自 mcpp 的键注册表 | 问题来自 mcpp | 问题来自 mcpp | -| 目标与平台、库、依赖图、缓存、打包、设置 | 共享网页面板 | 同一套面板(JCEF) | 浮动窗口中的文本、`vim.ui.select`;设置在 `setup()` 中 | +| mcpp.toml、build.mcpp | 补全、悬停、跳转与规则诊断来自后端(语言客户端);问题来自 mcpp | 同上,经 CLion 的补全、文档、标注与跳转 | 同上,经 `vim.lsp`;mcpp 的问题在 `vim.diagnostic` | +| 库、包页面、依赖图、缓存、设置 | mcpp-vscode 的视图与页面 | 原生的树与列表、由设置注册表生成的设置表单、JCEF 中的图 | 浮动窗口中的文本、`vim.ui.select`;设置在 `setup()` 中 | +| 目标(含目标集合)、打包 | 快速选择 | 弹出列表 | `vim.ui.select` | | 语言功能 | mcppls(随扩展一并安装) | mcppls CLion 插件(依赖) | mcppls-nvim(`xlings install mcppls-nvim`) | - **处处同一答案**——构建运行什么、测试如何报告、清理的代价:都由后端决定 @@ -51,9 +54,9 @@ C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-lan | 兼容 VS Code 的编辑器 | Open VSX,或 `.vsix` | 在扩展视图中安装 `.vsix` | | CLion 2026.1+ | JetBrains Marketplace(`mcpp-clion`),或 `mcpp-clion-.zip` | Settings → Plugins → Install Plugin from Disk;先安装 mcppls CLion 插件(本系统的包自带服务端) | | 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.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.cmd`),或 `okrun mcpp-ide-.okb serve` | +| 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.17.3 上的 +每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.23.3 上的 x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 的 okrun 携带。Neovim 插件不携带后端: 它启动 xlings 安装的 `mcpp-ide` 命令(由 okrun 运行同一个映像包)。 @@ -62,7 +65,8 @@ x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 ```bash mcpp build # 后端、mock mcpp、mcpp-ide-dev mcpp test # 后端测试 -cd mide-plugins/vscode && npm ci && npm test # 扩展与面板的测试 +cd mide-plugins/vscode && npm ci && npm test # 扩展的测试,含对 0.9.0 的一致性门 +npm run test:webui # CLion 的图页面在 Chromium 中、经后端运行 npm run package # dist/mcpp-vscode-.vsix cd ../clion && gradle buildPlugin verifyPlugin # build/distributions/mcpp-clion-.zip cd ../.. && nvim --headless --clean -l mide-plugins/nvim/tests/smoke.lua # Neovim 插件的测试 diff --git a/build.mcpp b/build.mcpp index 8c6cebd..37a1522 100644 --- a/build.mcpp +++ b/build.mcpp @@ -45,7 +45,7 @@ int main() { const std::filesystem::path root = env_or_empty("MCPP_MANIFEST_DIR"); const std::filesystem::path out = env_or_empty("MCPP_OUT_DIR"); const std::filesystem::path spec = root / "spec"; - const std::array, 8> files{{ + const std::array, 9> files{{ {"kSettingsJson", "settings.json"}, {"kPresetsJson", "presets.json"}, {"kCommandsJson", "commands.json"}, @@ -54,6 +54,7 @@ int main() { {"kManifestZhCnJson", "i18n/manifest.zh-cn.json"}, {"kTomlSchemaJson", "toml-schema.json"}, {"kBuildScriptApiJson", "buildscript-api.json"}, + {"kTestFrameworksJson", "test-frameworks.json"}, }}; std::string module = "// Generated by mcpp-ide's build.mcpp from spec/ (not part of the repository).\n" diff --git a/docs/README.md b/docs/README.md index 25d3983..a692e64 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # mcpp-ide documentation - [architecture.md](architecture.md) -- the three layers, what each holds, and how a request travels +- [rules.md](rules.md) -- mcpp.toml and build.mcpp: completion, hover, jumps and diagnostics over LSP - [../protocol/protocol.schema.json](../protocol/protocol.schema.json) -- the backend's protocol - [../agents/README.md](../agents/README.md) -- driving the backend from an agent - [../mide-plugins/vscode/README.md](../mide-plugins/vscode/README.md) -- the VS Code extension diff --git a/docs/architecture.md b/docs/architecture.md index e901a75..d74ae17 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -5,19 +5,35 @@ | Layer | Holds | Does not hold | |---|---|---| | mcpp | the machine contract: event streams, result records, launch descriptors, JSON queries, `--deny` (mcpp docs/50) | any IDE concept | -| mcpp-ide backend (`src/`) | sessions and projects (which mcpp.toml a file belongs to), watching (result records, manifests), jobs (one at a time per project, cancellation, a newer job replacing a waiting one), view models (project, targets and platforms, cache, libraries, graphs), cleanup plans and their danger levels, settings validation and migration, the command table, runtime strings, the self-check, the handshakes with mcpp (`--protocol-version`) and mcppls (`--version`) | parsing mcpp.toml, resolving dependencies | -| adapters (`mide-plugins/`) | declarations generated from `spec/`, the protocol mapped onto native UI, the panels' host, the backend process, finding the backend, workspace trust | any decision | - -The panels (`webui/`) are one set of classic scripts that the VS Code extension and the CLion -plugin load: a VS Code webview and a JCEF page. A panel speaks the backend's protocol through its -host and asks the host only for what the editor does (open a file, confirm, store a setting). +| mcpp-ide backend (`src/`) | sessions and projects (which mcpp.toml a file belongs to), watching (result records, manifests), jobs (one at a time per project, cancellation, a newer job replacing a waiting one), view models (project, targets and platforms, cache, libraries, graphs), cleanup plans and their danger levels, settings validation and migration, the command table, runtime strings, the self-check, the handshakes with mcpp (`--protocol-version`) and mcppls (`--version`), the rules layer of mcpp.toml and build.mcpp as an LSP server ([rules.md](rules.md)) | a manifest's meaning, resolving dependencies | +| adapters (`mide-plugins/`) | declarations generated from `spec/`, the protocol mapped onto native UI, the pages' host, the backend process, finding the backend, workspace trust | any decision | + +## The pages + +The views and pages of VS Code -- the project tree, the libraries and a package's page, the cache, +the settings page, the dependency and module graphs -- are mcpp-vscode's (`mide-plugins/vscode/src`), +as mcpp-vscode 0.9.0 drew them; `registerPages` registers them. They read projects and the index +from disk and run mcpp through the backend's `mcpp-ide/mcpp/exec` and mcppls through its +`mcpp-ide/languageService/exec`; the extension starts no other process for either. The parity gates +hold them to 0.9.0: the manifest's contributions and strings contain 0.9.0's exactly, and every +page's document, rendered by the extension's generators, is byte-identical to the snapshot 0.9.0's +generators render (`test/parity`). + +CLion has the same information in its own components (design 2026-10-30 part 2 §9): the "mcpp" +tool window's Project, Libraries and Cache tabs are native trees and lists over the backend's view +models (`projects/list`, `project/summary`, `project/mcpp`, `library/search`, `cache/inventory`), +a package's page is HTML in the Libraries tab, the cleanups are the command table's with their +confirmations, and Settings | Tools | mcpp is a form generated from `settings/registry`. A graph is +the one page in JCEF: `webui/graph.html` draws the backend's `graph/packages` or `graph/modules` +with mermaid and asks the IDE to open a module's source; a Chromium test runs it over a real +backend (`webui/tests`). The strings are the backend's (`i18n/bundle`), the ones VS Code shows. ## The adapters | Directory | Plugin | `editor` | Declares | Holds | |---|---|---|---|---| -| `mide-plugins/vscode` | mcpp-vscode | `vscode` | package.json's contributes, from `mcpp-ide-dev gen vscode` | tasks, the Testing view, debug configurations, Problems, the panels' webviews | -| `mide-plugins/clion` | mcpp-clion | `jetbrains` (the IntelliJ platform) | actions and message bundles, from `mcpp-ide-dev gen jetbrains` | run configurations, the test tree, CLion's debugger, the panels in JCEF | +| `mide-plugins/vscode` | mcpp-vscode | `vscode` | package.json's contributes, from `mcpp-ide-dev gen vscode` | the pages, tasks, the Testing view, debug configurations, Problems | +| `mide-plugins/clion` | mcpp-clion | `jetbrains` (the IntelliJ platform) | actions and message bundles, from `mcpp-ide-dev gen jetbrains` | run configurations, the test tree, CLion's debugger, the tool window's native views, the settings form, the graph page in JCEF | | `mide-plugins/nvim` | mcpp-nvim | `nvim` | nothing: `:Mcpp` reads the command table from the backend | jobs in a buffer, a terminal for the program, `vim.diagnostic` and the quickfix list, the view models as text, nvim-dap | `spec/commands.json` says which editors offer a command (`editors`, every editor when absent; a @@ -25,22 +41,28 @@ forward to mcppls's commands only VS Code) and, where an editor is left out, why backend answers each editor's table (`commands/list`) with the rows it offers, their titles in the session's language and their subcommands, the names Neovim types. -The Neovim plugin is Lua over Neovim's own JSON-RPC client (`vim.lsp.rpc`, the LSP base protocol's -framing). It carries no backend and no web view: a panel's view model is text in a floating window +The Neovim plugin is Lua over Neovim's own LSP client (`vim.lsp`), attached to mcpp.toml and +build.mcpp buffers. It carries no backend and no web view: a panel's view model is text in a floating window or a choice in `vim.ui.select`, and a panel with no textual equivalent (the settings, edited in `setup()`) is left out of its table. ## Language features -mcppls provides every language feature of C++ files; mcpp-ide registers no language service. The -VS Code extension installs mcppls as an extension pack (it can be removed); the CLion plugin depends -on the mcppls CLion plugin; in Neovim mcppls has its own plugin (`xlings install mcppls-nvim`), whose -client's server the Neovim plugin names to the backend. The backend reads `mcppls --version`: 0.0.18 and later refresh after any -build through mcpp's result record, and an older or missing mcppls is reported with what to do. +mcppls provides every language feature of C++ files; mcpp-ide registers no C++ language service. +The VS Code extension installs mcppls as an extension pack (it can be removed); the CLion plugin +depends on the mcppls CLion plugin; in Neovim mcppls has its own plugin (`xlings install +mcppls-nvim`), whose client's server the Neovim plugin names to the backend. The backend reads +`mcppls --version`: 0.0.18 and later refresh after any build through mcpp's result record, and an +older or missing mcppls is reported with what to do. + +mcpp.toml and build.mcpp are the backend's: the session is an LSP server (protocol 2), and its rules +layer answers their completion, hover, jumps and diagnostics in every editor +([rules.md](rules.md)). In build.mcpp, which mcppls compiles as a build program, the backend leaves +mcppls what a compiler answers better. ## A build, end to end -1. The user runs Build. The adapter sends `job/start {kind: "build", root}`. +1. The user runs Build. The adapter sends `mcpp-ide/job/start {kind: "build", root}`. 2. The backend queues it on the project's lane and runs `mcpp build --message-format json` with `MCPP_CLIENT`, colour for a terminal, and `MCPP_DENY_EFFECTS` in an untrusted workspace. 3. mcpp's narration becomes `job/output`; each event becomes `job/plan`, `job/progress`, @@ -55,28 +77,28 @@ build through mcpp's result record, and an older or missing mcppls is reported w The backend is one program for every editor, and one file for every system: an ok image per architecture (`mcpp build --target -openkal`, openkal-platform's toolchain and C library), -packed as one ok-image bundle (`mcpp pack mcpp-ide --target x86_64-openkal --pkg-format okb`). Each +packed as one ok-image bundle (`--pkg-format okb`). Each editor package carries that bundle and the runner that starts it on each system: | Layout in the package | Holds | Started as | |---|---|---| | `backend/mcpp-ide.okb` | the x86_64 and aarch64 images | | -| `backend/okrun/-/okrun[.exe]` | okrun for `linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `windows-x86_64`, from the universal self-contained package (`mcpp pack --mode self-contained --systems linux,macos,windows`: each okrun built over the platform's hosted arrangement) | `okrun mcpp-ide.okb serve`: the okrun carried for this system, else okrun on PATH | +| `backend/okrun/-/okrun[.exe]` | okrun for `linux-x86_64`, `linux-aarch64`, `macos-aarch64`, `windows-x86_64`, from the universal self-contained package (`--mode self-contained --systems all`: each okrun built over the platform's hosted arrangement); one command packs both, `mcpp pack mcpp-ide --target openkal --mode self-contained --systems all --pkg-format tar,okb`, and the package's `runs_on.self` names the systems it runs on | `okrun mcpp-ide.okb serve`: the okrun carried for this system, else okrun on PATH | Before it, a configured `mcpp.ide.backendPath` (a program or an `.okb`) is used; after it, an installed `mcpp-ide` (xlings, whose command runs okrun on its bundle) or one on PATH. The Neovim plugin carries no package of the backend: it starts setup()'s `backend`, else `mcpp-ide` on PATH, and `xlings install mcpp-ide-nvim` installs it from the release's source tree, with the backend's package as its dependency. CI packs the -bundle and the universal package (`mcpp-ide--openkal.tar.gz`, a release asset) once, on -Linux, takes okrun from that package, opens both editor packages and the universal package on every +bundle and the target set's tree (`mcpp-ide--openkal-bundle-all.tar.gz`, a release asset) in +one pack on Linux, takes okrun from that tree, opens both editor packages and the tree on every system and starts what they carry for it (`.github/scripts/check-package.sh`). ## Generated declarations `spec/` is the source of everything the editors declare statically. `mcpp-ide-dev gen vscode` writes -the extension's package.json (commands, keybindings, menus, configuration) and its message bundles -into `mide-plugins/vscode/dist`; `mcpp-ide-dev gen jetbrains` writes the CLion plugin's actions and +the extension's package.json (commands, keybindings, menus, configuration), its message bundles and +its settings registry in place, in `mide-plugins/vscode` (CI regenerates them and expects no change); `mcpp-ide-dev gen jetbrains` writes the CLion plugin's actions and message bundles into `mide-plugins/clion/build`. The backend embeds the same files (build.mcpp), so the commands an editor registers at run time are the ones it declared; which rows an editor offers is one rule (`spec::offered_in`) for both. The Neovim plugin declares nothing and offers what the diff --git a/docs/rules.md b/docs/rules.md new file mode 100644 index 0000000..8620059 --- /dev/null +++ b/docs/rules.md @@ -0,0 +1,97 @@ +# The rules layer: mcpp.toml and build.mcpp + +The backend reads the two files mcpp defines beside C++ sources, `mcpp.toml` and `build.mcpp`, and +answers completion, hover, the jumps of `mcpp.toml` and the diagnostics of both (mcpp design +2026-10-30 part 2 §8). The answers are text analysis: nothing is run, so they hold in an untrusted +workspace too. Whether a manifest is valid is mcpp's to say (`mcpp-ide/manifest/check`, located +diagnostics); the rules layer says what the text in the editor already shows. + +## The connection + +`mcpp-ide serve` is an LSP server (protocol 2). `initialize` has LSP's shape, with mcpp-ide's +parameters in `initializationOptions`; the rules layer answers LSP's `textDocument/*` methods, and +mcpp-ide's own methods and notifications are under `mcpp-ide/`. Every editor connects to the one +backend process and so to one project state: + +| Editor | Client | The rules layer reaches the editor as | +|---|---|---| +| VS Code | VS Code's language client (bundled into `dist/vendor`) | the client's completion, hover, definition and the `mcpp` diagnostic collection | +| CLion | the session's connection (the platform's LSP client starts a server of its own) | a completion contributor, an external annotator, a documentation provider, a go-to-declaration handler | +| Neovim | Neovim's LSP client (`vim.lsp`) | the client attached to `mcpp.toml` and `build.mcpp` buffers | + +The documents are synchronised whole (`didOpen`, then `didChange` with each new text). A position is +a 0-based line and a UTF-16 column. Completion, hover, definition and the outline are answered in +the order the messages arrive, so an answer sees the document the notifications before it left. + +## mcpp.toml + +| Cursor | Suggestions | +|---|---| +| On a table header, or before the first one | every table of the snapshot (a table written only under a selector, `[target.'cfg(...)'.abi]`, with its selector to fill in), and the forms that name something (`[targets.]`, `[dependencies.]`, `[features.]`, `[target.'cfg(...)'.build]`, ...) | +| Inside `[[` | nothing: a suggestion would turn an array of tables into a table | +| On a key of a table with a closed vocabulary | its keys, less those already written in the table's group; a key's detail names its type, values and default | +| On a key of a dependency table | the five dependency forms | +| On a key of `[features]`, `[generated_files]`, `[capabilities]`, `[xlings.workspace]`, `[tools.overrides]` | that table's forms | +| On an enum key's value | its values (inside a string only the content is replaced) | +| On a dependency's version, with `mcpp.toml.indexCompletion` on and the workspace trusted | the versions mcpp's catalogue (`mcpp search --format json`) lists for that package, newest first; the catalogue is read once in the background (the answer is incomplete until then) and kept for ten minutes | + +A table is read as the snapshot's section its name denotes: the whole name when the snapshot has +it; `[workspace.]` as the table it mirrors; a platform's table under a target selector +(`[target..link]`) as the platform's (`[platform.link]`); `[target..
]` as +`
`; a named entry (`[targets.app]`, `[platform.build.'cfg(...)']`) as the longest leading +run that names a section. + +Hover describes a table (its plane, its keys), a key (type, values, default, the release that added +it, its note) and an enum value. Definition jumps from `workspace = true` to the entry of +`[workspace.dependencies]`, from an element of `features = [...]` to `[features.]`, and from +`path = "../x"` to that manifest's `[package]`. + +| Code | Default severity | Setting | What it reports | +|---|---|---|---| +| `mcpp.manifest.syntax` | error | `mcpp.toml.diagnostics.syntax` | a line that is neither a table header nor `key = value`; an invalid key; a key without a value | +| `mcpp.manifest.unknownSection` | warning | `mcpp.toml.diagnostics.unknownSection` | a table no section of the snapshot names, nor a prefix of it | +| `mcpp.manifest.unknownKey` | warning | `mcpp.toml.diagnostics.unknownKey` | a key a table with a closed vocabulary does not have | +| `mcpp.manifest.planeSeparation` | warning | `mcpp.toml.diagnostics.planeSeparation` | a tool payload (`xim:`, `xpm:`, `xlings:`, `xvm:`) as a dependency; an mcpp package with a version under `[xlings]` | +| `mcpp.manifest.legacyKey` | info | `mcpp.toml.diagnostics.legacyKeys` | a deprecated table or key, with its replacement | +| `mcpp.manifest.mcppFloor` | error | -- | `[package] mcpp` that is not a release floor (`">=2026.9.28.3"`) | +| `mcpp.manifest.arrayTable` | error | -- | an array of tables mcpp does not read (it reads `[[build.flags]]`, `[[runtime.requirements]]`, the snapshot's own) | + +The scanner errs towards silence: the lines of an unclosed array, inline table or multi-line string +are not checked. `mcpp.toml.completion`, `mcpp.toml.hover`, `mcpp.toml.navigation` and +`mcpp.toml.diagnostics.enabled` turn each feature off. + +## build.mcpp + +| Code | What it reports (SPEC-007) | +|---|---| +| `mcpp.buildscript.unknownSymbol` | a `mcpp::` that is no directive, action role or typed-API name of the snapshot, nor a namespace the file imports | +| `mcpp.buildscript.roleLiteral` | `role = ""` where `mcpp::roles::` exists (R3.6) | +| `mcpp.buildscript.actionNeedsRole` | an action without a role, or with a role the engine does not know (R3.2, R3.6) | +| `mcpp.buildscript.prepareNeedsOutputDir` | a prepare action without `output_dir` (R3.3) | +| `mcpp.buildscript.rpathInLinkFlag` | a run path in `link_flag` (R4.4) | +| `mcpp.buildscript.rawPathFlag` | `-I`/`-L` in a raw flag (R2.1) | +| `mcpp.buildscript.shellSyntaxInAction` | `VAR=value` or `cd x &&` in an action's command (R3.8) | + +The analysis is line-oriented: comments and string literals (raw strings too) are masked, and an +action block runs from `mcpp::action` to `.submit(` within 40 lines. Completion offers the +directives' typed names and the API's snippets after `mcpp::`, and the modules a build program may +import (`std`, `std.compat`, `mcpp`, `mcpp.core`, `mcpp.plugins.*`, ...) after `import`; hover +describes a directive and a known module. The layer recognises module names and never reports one +as missing. + +While C++ Modules (mcppls) serves the file -- the editor says so with `mcpplsServes` -- the backend +leaves it what a compiler answers better: the `unknownSymbol` rule, the module names after `import` +and their hover. `mcpp.buildScript.intelligence`, `.diagnostics` (with `.diagnostics.severity`), +`.snippets` and `.imports.knownModules` turn each part off. + +## The snapshots and the tests + +The rules read two snapshots of mcpp: `spec/toml-schema.json` (every table and key, each with the +release that added it) and `spec/buildscript-api.json` (the directives, roles and protocol), each +naming the mcpp release and commit it was taken from. + +The layer is a port of mcpp-vscode 0.9's TypeScript implementation. `tests/rules/golden` holds that +implementation's answers over `tests/rules/corpus` -- the scanner's structure and the cursor's context +at every position of every line, completion, hover, the jumps, the version contexts and the +diagnostics under two sets of severities -- and over the documents of its parser tests +(`golden/snippets.json`); `test_rules` compares the backend's answers with them. diff --git a/mcpp.lock b/mcpp.lock index 2c6d32f..23551d6 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -5,6 +5,14 @@ # file must not change depending on which command ran last. version = 2 +[package."compat.mimalloc"] +namespace = "compat" +version = "3.6.1" +source = "index+compat@3.6.1" +hash = "fnv1a:44a30e7e4403e477" +origin = "platform" +targets = ["aarch64-openkal", "x86_64-openkal"] + [package."mcpp.plugins"] namespace = "mcpp" version = "0.20.0" @@ -15,62 +23,62 @@ targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal"] namespace = "mcpplibs" -version = "0.17.1" -source = "index+mcpplibs@0.17.1" -hash = "fnv1a:d9a6fd1043f0f322" +version = "0.21.0" +source = "index+mcpplibs@0.21.0" +hash = "fnv1a:4707ae0764444448" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.1.3" -source = "index+mcpplibs@0.1.3" -hash = "fnv1a:c45ba7726c74fc44" +version = "0.3.1" +source = "index+mcpplibs@0.3.1" +hash = "fnv1a:d518f77275c5bb94" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-linux"] namespace = "mcpplibs" -version = "0.18.2" -source = "index+mcpplibs@0.18.2" -hash = "fnv1a:2947b54ddbcf8813" +version = "0.22.0" +source = "index+mcpplibs@0.22.0" +hash = "fnv1a:5fe0713332cb49ec" [package."openkal-llvm-runtime"] namespace = "mcpplibs" -version = "0.16.6" -source = "index+mcpplibs@0.16.6" -hash = "fnv1a:1310f13360b2df3f" +version = "0.17.1" +source = "index+mcpplibs@0.17.1" +hash = "fnv1a:0a754d335bd6d845" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-musl"] namespace = "mcpplibs" -version = "0.22.6" -source = "index+mcpplibs@0.22.6" -hash = "fnv1a:de2d14df84a5a777" +version = "0.26.1" +source = "index+mcpplibs@0.26.1" +hash = "fnv1a:bb6bf5df70ee8e6a" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.17.3" -source = "index+mcpplibs@0.17.3" -hash = "fnv1a:d470e612205b7986" +version = "0.21.1" +source = "index+mcpplibs@0.21.1" +hash = "fnv1a:59ca822a5ea5cb39" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.1.3" -source = "index+mcpplibs@0.1.3" -hash = "fnv1a:3a0aa473e0da7eaf" +version = "0.3.1" +source = "index+mcpplibs@0.3.1" +hash = "fnv1a:4c7af473eb9cd27f" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."mcpplibs.os"] namespace = "mcpplibs" -version = "0.2.1" -source = "index+mcpplibs@0.2.1" -hash = "fnv1a:d0be97a50033077e" +version = "0.2.7" +source = "index+mcpplibs@0.2.7" +hash = "fnv1a:d0be99a500330ae4" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."nlohmann.json"] diff --git a/mcpp.toml b/mcpp.toml index 02c90d2..98ad6f4 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.1.1" +version = "0.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.10.27.1" +mcpp = ">=2026.11.3.1" [language] standard = "c++23" @@ -33,6 +33,14 @@ toolchain = "llvm@23.1.3" [target.'cfg(windows)'] toolchain = "llvm@23.1.3" +# The platform of the ok images the editor packages carry: 0.23.3's version group is openkal 0.23's, +# which mcpplibs.os 0.2.9 is written against; its okrun places an image at a base of the boot, keeps +# its data as a prepared copy and can start it from a completed snapshot of eligible initialisers, spending by the +# program's resource preference (openkal-image 0.4.3); every unit is compiled with frame pointers and +# hardening, the std module included from mcpp 2026.11.3.1 (mcpp design 2026-11-03 part 4). +[target.'cfg(os = "openkal")'] +platform = "openkal-platform@0.23.3" + # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] toolchain = "llvm@23.1.3" @@ -57,15 +65,20 @@ main = "src/main.cpp" kind = "bin" main = "src/bin/mockmcpp.cpp" +# Stands in for mcppls in the tests: its version and `mcppls status`. +[targets.mcpp-ide-mock-mcppls] +kind = "bin" +main = "src/bin/mockmcppls.cpp" + # The repository's tools: fixture capture, the editors' declarations from spec/, the i18n check. [targets.mcpp-ide-dev] kind = "bin" main = "src/bin/dev.cpp" [dependencies.mcpplibs] -# os 0.2.1: an ok image's C library, C++ run-time and loader are its platform's, so the package -# declares no openkal-tools of its own beside the platform's. -os = "0.2.1" +# os 0.2.9: openkal 0.23 (openkal-linux 0.24.1, openkal-macos 0.21.3, openkal-windows 0.19.0). Since 0.2.1 an ok image's C library, C++ run-time and loader are its platform's, +# so the package declares no openkal-tools of its own beside the platform's. +os = "0.2.9" [dependencies.nlohmann] json = "3.12.0" diff --git a/mide-plugins/clion/README.md b/mide-plugins/clion/README.md index 45c6371..f44542b 100644 --- a/mide-plugins/clion/README.md +++ b/mide-plugins/clion/README.md @@ -8,18 +8,25 @@ mcpp's project features in CLion 2026.1 and later, over the mcpp-ide backend: - **debugging** under CLion's own GDB or LLDB (the default toolchain's); an ok image runs under `okrun`, whose images reach GDB through the JIT interface; - **console links** to `file:line:column`; -- the **mcpp tool window**: project, libraries, cache, targets and platforms, and the graphs, pack - and settings panels (Settings | Tools | mcpp); +- the **mcpp tool window** in CLion's own components: the Project tab (each project's mcpp, + targets, tests, libraries, last build, artifacts, platform, and what C++ Modules says), the + Libraries tab (the indexes' packages, a package's page, adding it to the project), the Cache tab + (what mcpp keeps, and the cleanups with their confirmations), and a graph of the packages or the + modules as a page in JCEF; **Settings | Tools | mcpp** is a form generated from the backend's + settings registry, with a search; +- **Select the Build Target** (target sets among the rows) and **Pack** as popups, packing as a job; - the **Tools | mcpp** menu, generated from `spec/commands.json`. -Language features of C++ files come from the mcppls CLion plugin, which this plugin depends on; it -registers no language service. +Language features of C++ files come from the mcppls CLion plugin, which this plugin depends on. +Those of `mcpp.toml` and `build.mcpp` -- completion, documentation, the jumps and the diagnostics -- +come from the backend over the session's connection (mcpp-ide's `docs/rules.md`). ## Build ```bash xlings install gradle # Gradle and its JDK mcpp build # in the repository root: the backend and mcpp-ide-dev +(cd ../vscode && npm ci) # the extension's dependencies: the build bundles the graph page with them gradle buildPlugin # build/distributions/mcpp-clion-.zip gradle verifyPlugin # CLion 2026.1 and the version in gradle.properties ``` @@ -31,5 +38,7 @@ docs/architecture.md, Distribution. Without them the plugin finds the backend th ## Tests -`gradle test` runs the wire's tests, and a session over the mock mcpp when `MCPP_IDE_BACKEND` and -`MCPP_IDE_MOCK_MCPP` name the programs `mcpp build` produced. +`gradle test` runs the wire's tests, a session over the mock mcpp when `MCPP_IDE_BACKEND` and +`MCPP_IDE_MOCK_MCPP` name the programs `mcpp build` produced, and the views' models and the +settings form headless. `npm run test:webui` in `../vscode` runs the graph page in Chromium over the +backend. diff --git a/mide-plugins/clion/build.gradle.kts b/mide-plugins/clion/build.gradle.kts index 2e58ddf..cb4046c 100644 --- a/mide-plugins/clion/build.gradle.kts +++ b/mide-plugins/clion/build.gradle.kts @@ -1,8 +1,8 @@ // mcpp-clion: mcpp's project features in CLion -- run configurations, the test tree, debugging, the -// console's links, and the shared panels (../../webui) -- over the mcpp-ide backend (`mcpp-ide serve`). +// console's links, native views of the project, the libraries and the cache, the graphs in JCEF (../../webui), +// and mcpp.toml's and build.mcpp's rules -- over the mcpp-ide backend (`mcpp-ide serve`). // -// Language features are mcppls's: this plugin depends on the mcppls CLion plugin and registers no -// language service. The declarations generated from ../../spec (actions, message bundles) are written +// C++ language features are mcppls's: this plugin depends on the mcppls CLion plugin. The declarations generated from ../../spec (actions, message bundles) are written // by the backend's `mcpp-ide-dev gen jetbrains` before the resources are processed; nothing // generated is in the repository. // @@ -89,10 +89,22 @@ val generateDeclarations = tasks.register("generateDeclarations") { if (dev != null) commandLine(dev, "gen", "jetbrains", "--out", out.absolutePath) else commandLine("mcpp", "run", "mcpp-ide-dev", "--", "gen", "jetbrains", "--out", out.absolutePath) } -// ../../webui, as it is: the panels JCEF loads. -val copyWebui = tasks.register("copyWebui") { - from(repositoryRoot.resolve("webui")) { exclude("tests/**", "tsconfig.json", "js/types.d.ts") } - into(layout.buildDirectory.dir("generated/webui/webui")) +// The graph page JCEF loads (../../webui, mcpp design 2026-10-30 part 2 §9): webui/src/graph.ts and +// mermaid, bundled by the extension's tools/bundle-webui.mjs (Node; `npm ci` in ../vscode first), +// with webui/files.txt naming its files. +val webuiOut = layout.buildDirectory.dir("generated/webui/webui") +val bundleWebui = tasks.register("bundleWebui") { + val extension = repositoryRoot.resolve("mide-plugins/vscode") + inputs.dir(repositoryRoot.resolve("webui/src")) + inputs.file(repositoryRoot.resolve("webui/graph.html")) + inputs.file(extension.resolve("tools/bundle-webui.mjs")) + outputs.dir(webuiOut) + workingDir = extension + doFirst { + if (!extension.resolve("node_modules/esbuild").isDirectory) + throw GradleException("the graph page is bundled with the extension's dependencies: run `npm ci` in ${extension}") + } + commandLine("node", "tools/bundle-webui.mjs", "--out", webuiOut.get().asFile.absolutePath) } sourceSets { main { @@ -100,7 +112,7 @@ sourceSets { resources.srcDir(layout.buildDirectory.dir("generated/webui")) } } -tasks.processResources { dependsOn(generateDeclarations, copyWebui) } +tasks.processResources { dependsOn(generateDeclarations, bundleWebui) } // What the plugin carries of the backend (mcpp design 2026-10-28 part 2 §15), as the VS Code // extension carries it: MCPP_IDE_OKB, the ok-image bundle (backend/mcpp-ide.okb, one for every diff --git a/mide-plugins/clion/gradle.properties b/mide-plugins/clion/gradle.properties index f36b18e..ce4fe38 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.1.1 +pluginVersion = 0.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/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index fce8163..e820d75 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 @@ -1,13 +1,15 @@ // The session with the backend for one project: started when an mcpp project opens, given the // IDE's settings, trust, language and the project's directory, kept current as they change, and -// restarted once when it ends. Everything else in the plugin reaches the backend through it. +// started again after 1, 4 and 16 seconds when it ends; a fourth end within three minutes leaves the +// plugin in its degraded mode (design 2026-10-30 part 2 §3.1). Everything else in the plugin +// reaches the backend through it. package io.github.mcppcommunity.mcppclion import com.google.gson.JsonArray import com.google.gson.JsonElement import com.google.gson.JsonObject import com.intellij.DynamicBundle -import com.intellij.ide.impl.isTrusted +import com.intellij.ide.trustedProjects.TrustedProjects import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable @@ -23,6 +25,19 @@ import java.io.File import java.util.concurrent.CompletableFuture import java.util.concurrent.CopyOnWriteArrayList +/** A path as the `file:` URI the backend names a document by (`file:///C:/x` for a drive path). */ +fun uriOf(path: String): String { + val p = path.replace('\\', '/') + val out = StringBuilder("file://") + if (!p.startsWith("/")) out.append('/') + for (b in p.toByteArray(Charsets.UTF_8)) { + val c = b.toInt() and 0xff + val ch = c.toChar() + if (ch.isLetterOrDigit() && c < 0x80 || ch in "/-_.~:") out.append(ch) else out.append("%%%02X".format(c)) + } + return out.toString() +} + @Service(Service.Level.PROJECT) class McppSession(private val project: Project) : Disposable { private val log = logger() @@ -32,13 +47,45 @@ class McppSession(private val project: Project) : Disposable { @Volatile private var bundle: Map = emptyMap() @Volatile var commands: List = emptyList(); private set private val listeners = CopyOnWriteArrayList Unit>>() - private var restarts = 0 + /** When the backend ended, within the last three minutes. */ + private val exits = ArrayDeque() + @Volatile var degraded = false; private set init { project.messageBus.connect(this).subscribe(McppSettings.CHANGED, McppSettings.Listener { key -> if (key == "mcpp.ide.backendPath") restart() else pushSettings() }) - on("manifest/changed") { p -> p.asJsonObject["root"]?.asString?.let { refreshTests(it) } } + on("manifest/changed") { forgetSources() } + // A build ended: a test program can list the cases its macros generate (§7.3). + on("job/finished") { p -> + val o = p.asJsonObject + if (o["status"]?.asString == "ok" && o["kind"]?.asString in setOf("build", "test")) { + cases.keys.toList().forEach { key -> key.split('\u0000').takeIf { it.size == 2 }?.let { refreshCases(it[0], it[1], list = true) } } + } + } + // A C++ source saved: what was read from it (main, a framework's cases) is read again. + project.messageBus.connect(this).subscribe(com.intellij.openapi.vfs.VirtualFileManager.VFS_CHANGES, + object : com.intellij.openapi.vfs.newvfs.BulkFileListener { + override fun after(events: MutableList) { + for (e in events) { + val path = e.path + if (path.endsWith(".cpp") || path.endsWith(".cc") || path.endsWith(".cxx") || path.endsWith(".cppm")) forgetSource(path) + } + } + }) + // mcppls answered differently (another version, other actions): the commands are asked again, + // since mcppls's actions are offered under the ids it states (§5.3). + on("languageService/changed") { refreshCommands() } + // The IDE regained focus: a `xlings use` or a changed pin happened outside it (§4.2). + com.intellij.openapi.application.ApplicationManager.getApplication().messageBus.connect(this).subscribe( + com.intellij.openapi.application.ApplicationActivationListener.TOPIC, + object : com.intellij.openapi.application.ApplicationActivationListener { + override fun applicationActivated(ideFrame: com.intellij.openapi.wm.IdeFrame) { + if (backend?.running != true) return + backend?.request("project/refresh", JsonObject()) + languageServiceStatus() + } + }) } val root: String? get() = projects().firstOrNull()?.get("root")?.asString ?: project.basePath?.takeIf { File(it, "mcpp.toml").isFile } @@ -54,12 +101,29 @@ class McppSession(private val project: Project) : Disposable { val strings: Map get() = bundle + private val startedListeners = CopyOnWriteArrayList<() -> Unit>() + + /** Called each time a backend has started and answered `initialize`. */ + fun whenStarted(listener: () -> Unit) { + startedListeners += listener + } + fun on(method: String, listener: (JsonElement) -> Unit): () -> Unit { val entry = method to listener listeners += entry return { listeners -= entry } } + /** + * Whether C++ Modules serves build.mcpp here: the mcppls CLion plugin is installed and enabled, + * and the backend leaves it what it answers better (the unknownSymbol rule, the module names + * after `import`). + */ + fun mcpplsServes(): Boolean = runCatching { + val id = com.intellij.openapi.extensions.PluginId.getId(MCPPLS_PLUGIN_ID) + com.intellij.ide.plugins.PluginManagerCore.isLoaded(id) + }.getOrDefault(false) + /** The plugin's directory: the parent of the lib/ holding this class's jar. */ private fun pluginRoot(): File? = pluginDirectory() @@ -79,7 +143,7 @@ class McppSession(private val project: Project) : Disposable { started?.let { if (backend?.running == true || !it.isDone) return it } val future = CompletableFuture() started = future - val configured = if (project.isTrusted()) McppSettings.get().string("mcpp.ide.backendPath") else "" + val configured = if (TrustedProjects.isProjectTrusted(project)) McppSettings.get().string("mcpp.ide.backendPath") else "" val located = Locate.backend(configured, pluginRoot()) if (located == null) { notify(t("The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath."), NotificationType.ERROR) @@ -93,17 +157,26 @@ class McppSession(private val project: Project) : Disposable { runCatching { fn(if (m == "*") JsonObject().apply { addProperty("method", method); add("params", params) } else params) } } } b.exited.thenAccept { code -> if (backend === b) onExit(code) } + // LSP's `initialize`: the client and the folder in LSP's fields, mcpp-ide's own in initializationOptions. val params = JsonObject().apply { - add("client", JsonObject().apply { addProperty("name", ApplicationInfo.getInstance().versionName); addProperty("version", ApplicationInfo.getInstance().fullVersion) }) - addProperty("editor", "jetbrains") - addProperty("locale", DynamicBundle.getLocale().toLanguageTag()) - addProperty("trusted", project.isTrusted()) - add("settings", McppSettings.get().values()) - add("workspaceFolders", JsonArray().apply { project.basePath?.let { add(it) } }) - add("languageService", JsonObject().apply { add("candidates", mcpplsCandidates()) }) + addProperty("processId", ProcessHandle.current().pid()) + add("clientInfo", JsonObject().apply { addProperty("name", ApplicationInfo.getInstance().versionName); addProperty("version", ApplicationInfo.getInstance().fullVersion) }) + add("workspaceFolders", JsonArray().apply { + project.basePath?.let { base -> add(JsonObject().apply { addProperty("uri", uriOf(base)); addProperty("name", project.name) }) } + }) + add("capabilities", JsonObject()) + add("initializationOptions", JsonObject().apply { + addProperty("editor", "jetbrains") + addProperty("locale", DynamicBundle.getLocale().toLanguageTag()) + addProperty("trusted", TrustedProjects.isProjectTrusted(project)) + add("settings", McppSettings.get().values()) + add("languageService", JsonObject().apply { add("candidates", mcpplsCandidates()) }) + addProperty("mcpplsServes", mcpplsServes()) + }) } b.request("initialize", params).thenCompose { init -> initialized = init.asJsonObject + b.notify("initialized", JsonObject()) b.request("i18n/bundle") }.thenCompose { strings -> bundle = strings.asJsonObject.entrySet().associate { it.key to it.value.asString } @@ -112,6 +185,7 @@ class McppSession(private val project: Project) : Disposable { if (error != null) { future.completeExceptionally(error); return@whenComplete } commands = table.asJsonObject.getAsJsonArray("commands").map { it.asJsonObject } future.complete(initialized!!) + startedListeners.forEach { runCatching { it() } } } return future } @@ -119,50 +193,132 @@ class McppSession(private val project: Project) : Disposable { private fun onExit(code: Int) { log.info("mcpp-ide: the backend ended ($code)") backend = null - if (restarts++ < 1) { start(); return } - notify(t("The mcpp-ide backend ended."), NotificationType.ERROR) + val now = System.currentTimeMillis() + synchronized(exits) { + exits.addLast(now) + while (exits.isNotEmpty() && now - exits.first() >= 3 * 60_000) exits.removeFirst() + } + val delays = longArrayOf(1000, 4000, 16000) + val count = synchronized(exits) { exits.size } + if (count <= delays.size) { + log.info("mcpp-ide: starting it again in ${delays[count - 1] / 1000} s") + com.intellij.util.concurrency.AppExecutorUtil.getAppScheduledExecutorService() + .schedule({ started = null; start() }, delays[count - 1], java.util.concurrent.TimeUnit.MILLISECONDS) + return + } + degraded = true + val n = NotificationGroupManager.getInstance().getNotificationGroup("mcpp").createNotification("mcpp", + t("The mcpp-ide backend ended four times in three minutes; build, run, test and the project pages go on without it."), + NotificationType.ERROR) + n.addAction(com.intellij.notification.NotificationAction.createSimpleExpiring(t("Restart the backend")) { restart() }) + n.addAction(com.intellij.notification.NotificationAction.createSimpleExpiring(t("Export IDE Report")) { + io.github.mcppcommunity.mcppclion.actions.Dispatch.exportReport(project) + }) + n.notify(project) + } + + /** What mcppls says about the project (§5.2); the backend tells `languageService/changed` when it differs. */ + fun languageServiceStatus(): CompletableFuture = + request("languageService/status", JsonObject().apply { + add("candidates", mcpplsCandidates()) + root?.let { addProperty("root", it) } + }) + + private fun refreshCommands() { + backend?.request("commands/list", JsonObject().apply { addProperty("editor", "jetbrains") }) + ?.thenAccept { table -> commands = table.asJsonObject.getAsJsonArray("commands").map { it.asJsonObject } } } fun restart() { val old = backend backend = null started = null - restarts = 0 + synchronized(exits) { exits.clear() } + degraded = false old?.close() start() } - fun request(method: String, params: JsonElement = JsonObject()): CompletableFuture = - start().thenCompose { backend?.request(method, params) ?: CompletableFuture.failedFuture(IllegalStateException(t("the mcpp-ide backend is not running"))) } + fun request(method: String, params: JsonElement = JsonObject()): CompletableFuture { + if (degraded && backend?.running != true) + return CompletableFuture.failedFuture(IllegalStateException(t("the mcpp-ide backend is not running"))) + return start().thenCompose { backend?.request(method, params) ?: CompletableFuture.failedFuture(IllegalStateException(t("the mcpp-ide backend is not running"))) } + } - // The tests of each project by the file holding their `main` (mcpp test --list), for the gutter - // and the run configurations from context, which must not wait for mcpp. - private val testMains = java.util.concurrent.ConcurrentHashMap>() + // What each C++ file is to its project (source/entries) and each test program's cases + // (test/cases), for the gutter, which must not wait for the backend: asked in the background + // when unknown, and the editor's markers recomputed when the answer arrives. + private val sources = java.util.concurrent.ConcurrentHashMap() + private val cases = java.util.concurrent.ConcurrentHashMap() - /** The test whose `main` is `path`, from what is known; asks again in the background when nothing is. */ - fun testForFile(root: String, path: String): String? { - val known = testMains[root] - if (known == null) refreshTests(root) - return known?.get(File(path).canonicalPath) + fun entriesFor(path: String): JsonObject? { + val known = sources[path] + if (known == null) refreshEntries(path) + return known } - fun refreshTests(root: String) { - request("test/list", JsonObject().apply { addProperty("root", root) }).thenAccept { r -> - testMains[root] = r.asJsonObject.getAsJsonArray("tests").map { it.asJsonObject } - .filter { it["main"]?.asString?.isNotEmpty() == true } - .associate { File(it["main"].asString).canonicalPath to it["test"].asString } + fun refreshEntries(path: String) { + request("source/entries", JsonObject().apply { addProperty("file", path) }).thenAccept { r -> + sources[path] = r.asJsonObject + restartMarkers() } } + fun casesFor(root: String, test: String): JsonObject? { + val known = cases["$root\u0000$test"] + if (known == null) refreshCases(root, test, list = false) + return known + } + + fun refreshCases(root: String, test: String, list: Boolean) { + request("test/cases", JsonObject().apply { + addProperty("root", root) + addProperty("test", test) + addProperty("list", list && TrustedProjects.isProjectTrusted(project)) + }).thenAccept { r -> + cases["$root\u0000$test"] = r.asJsonObject + restartMarkers() + } + } + + /** A source changed: what was read from it is read again on its next use. */ + fun forgetSource(path: String) { + sources.remove(path) + cases.keys.removeIf { it.split('\u0000').getOrNull(1)?.let { test -> path.replace('\\', '/').endsWith("/tests/$test.cpp") } == true } + } + + private fun forgetSources() { + sources.clear() + cases.clear() + } + + private fun restartMarkers() = com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + if (!project.isDisposed) com.intellij.codeInsight.daemon.DaemonCodeAnalyzer.getInstance(project).restart() + } + fun pushSettings() { val b = backend ?: return b.request("settings/update", JsonObject().apply { add("settings", McppSettings.get().values()) - addProperty("trusted", project.isTrusted()) + addProperty("trusted", TrustedProjects.isProjectTrusted(project)) addProperty("locale", DynamicBundle.getLocale().toLanguageTag()) + addProperty("mcpplsServes", mcpplsServes()) }) } + /** A notification to the backend (LSP's document notifications), when it is running. */ + fun notifyBackend(method: String, params: JsonObject) { + backend?.notify(method, params) + } + + /** Whether the backend is running now (nothing is started to answer this). */ + val running: Boolean get() = backend?.running == true + + /** What the pages write to their output channel: the IDE's log. */ + fun log(text: String) { + text.trimEnd().lines().forEach { log.info("[mcpp] $it") } + } + fun notify(message: String, type: NotificationType = NotificationType.INFORMATION) { NotificationGroupManager.getInstance().getNotificationGroup("mcpp").createNotification("mcpp", message, type).notify(project) } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt index a6b9802..97654fe 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt @@ -16,6 +16,8 @@ class McppStartup : ProjectActivity { val base = project.basePath ?: return if (!File(base, "mcpp.toml").isFile) return val session = McppSession.get(project) + // mcpp.toml and build.mcpp reach the backend's rules layer as they are opened and edited. + io.github.mcppcommunity.mcppclion.editor.McppDocuments.get(project).install() session.start().thenAccept { init -> init.get("languageService")?.takeIf { it.isJsonObject }?.asJsonObject?.let { ls -> if (ls["state"].asString == "ok") return@let @@ -26,7 +28,6 @@ class McppStartup : ProjectActivity { } n.notify(project) } - session.root?.let { session.refreshTests(it) } } val stale = File(base, "compile_commands.json") if (stale.isFile) { 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 new file mode 100644 index 0000000..5628955 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt @@ -0,0 +1,95 @@ +// The mcpp item of the status bar (mcpp design 2026-10-30 part 2 §6.1): what VS Code's status item +// shows -- the project's mcpp version, and the job that runs -- with the same quick menu on a click. +package io.github.mcppcommunity.mcppclion + +import com.google.gson.JsonObject +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.openapi.wm.StatusBar +import com.intellij.openapi.wm.StatusBarWidget +import com.intellij.openapi.wm.StatusBarWidgetFactory +import com.intellij.util.Consumer +import io.github.mcppcommunity.mcppclion.actions.Dispatch +import java.awt.Component +import java.awt.event.MouseEvent +import java.io.File + +class McppStatusBarFactory : StatusBarWidgetFactory { + override fun getId(): String = ID + override fun getDisplayName(): String = "mcpp" + override fun isAvailable(project: Project): Boolean = project.basePath?.let { File(it, "mcpp.toml").isFile } == true + override fun createWidget(project: Project): StatusBarWidget = McppStatusBarWidget(project) + + companion object { + const val ID = "io.github.mcppcommunity.mcppclion.status" + } +} + +class McppStatusBarWidget(private val project: Project) : StatusBarWidget, StatusBarWidget.TextPresentation { + private var bar: StatusBar? = null + @Volatile private var version: String = "" + @Volatile private var program: String = "" + @Volatile private var running: String = "" + private val subscriptions = mutableListOf<() -> Unit>() + + override fun ID(): String = McppStatusBarFactory.ID + override fun getPresentation(): StatusBarWidget.WidgetPresentation = this + + 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/finished") { running = ""; update() } + subscriptions += session.on("project/mcppChanged") { ask() } + ask() + } + + /** The project's mcpp, as the project asks for it (project/mcpp). */ + private fun ask() { + val session = McppSession.get(project) + val root = session.root ?: return + session.request("project/mcpp", JsonObject().apply { addProperty("root", root) }).thenAccept { r -> + val o = r.asJsonObject + version = if (o["state"]?.asString == "ok") o["version"]?.asString ?: "" else o["state"]?.asString ?: "" + program = o["program"]?.asString ?: "" + update() + } + } + + private fun update() = ApplicationManager.getApplication().invokeLater { bar?.updateWidget(ID()) } + + override fun getText(): String = buildString { + append("mcpp") + if (version.isNotEmpty()) append(" ").append(version) + if (running.isNotEmpty()) append(" · ").append(running).append("…") + } + + override fun getTooltipText(): String = McppSession.get(project).t("mcpp {0} at {1}", version, program) + + override fun getAlignment(): Float = Component.LEFT_ALIGNMENT + + override fun getClickConsumer(): Consumer = Consumer { event -> + val session = McppSession.get(project) + session.request("menu/quick", JsonObject().apply { session.root?.let { addProperty("root", it) } }).thenAccept { r -> + val rows = r.asJsonObject.getAsJsonArray("items")?.map { it.asJsonObject }?.filter { it["enabled"]?.asBoolean != false } ?: emptyList() + ApplicationManager.getApplication().invokeLater { + JBPopupFactory.getInstance().createListPopup(object : BaseListPopupStep("mcpp", rows) { + override fun getTextFor(row: JsonObject): String = row["title"]?.asString ?: row["command"].asString + override fun onChosen(row: JsonObject, finalChoice: Boolean): PopupStep<*>? { + Dispatch.run(project, row["command"].asString, JsonObject()) + return FINAL_CHOICE + } + }).showUnderneathOf(event.component) + } + } + } + + override fun dispose() { + subscriptions.forEach { it() } + subscriptions.clear() + bar = null + } +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt index 8bc8e52..fb3cc83 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt @@ -3,7 +3,9 @@ // does what the row's `action` says, in the IDE's own terms (run configurations, dialogs, panels). package io.github.mcppcommunity.mcppclion.actions +import com.google.gson.JsonElement import com.google.gson.JsonObject +import java.util.concurrent.CompletableFuture import com.intellij.execution.ProgramRunnerUtil import com.intellij.execution.RunManager import com.intellij.execution.executors.DefaultDebugExecutor @@ -12,6 +14,8 @@ import com.intellij.ide.impl.ProjectUtil import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.application.ApplicationManager @@ -28,7 +32,7 @@ import io.github.mcppcommunity.mcppclion.McppSession import io.github.mcppcommunity.mcppclion.run.McppRunConfiguration import io.github.mcppcommunity.mcppclion.run.McppRunConfigurationType import io.github.mcppcommunity.mcppclion.settings.McppSettings -import io.github.mcppcommunity.mcppclion.webui.McppPanels +import io.github.mcppcommunity.mcppclion.ui.McppPanels import java.io.File import java.nio.file.Paths @@ -40,6 +44,17 @@ class McppCommandAction : DumbAwareAction() { e.presentation.isEnabledAndVisible = project != null if (project == null) return val id = e.actionManager.getId(this) ?: return + // mcppls's actions are offered where mcppls states a JetBrains id for them and its plugin has + // the action (§5.3); the table is the backend's, asked when the session started. + val session = McppSession.get(project) + val row = session.commands.firstOrNull { it["id"].asString == id } + if (row != null && row["action"].asJsonObject["kind"]?.asString == "forward") { + val target = row["action"].asJsonObject["command"]?.asString + e.presentation.isEnabledAndVisible = target != null && e.actionManager.getAction(target) != null + return + } + // A command the backend's table does not hold for this IDE is not offered. + if (row == null && session.commands.isNotEmpty()) { e.presentation.isEnabledAndVisible = false; return } val needsProject = id in setOf("mcpp.build", "mcpp.run", "mcpp.debug", "mcpp.test", "mcpp.clean", "mcpp.pack", "mcpp.cleanProjectArtifacts") if (needsProject) e.presentation.isEnabled = McppSession.get(project).root != null } @@ -111,13 +126,37 @@ object Dispatch { ApplicationManager.getApplication().invokeLater { Messages.showInfoMessage(project, text, t("Environment Self-check")) } } "languageService" -> languageService(project) + "forward" -> forward(project, action) + "report" -> exportReport(project) "rpc" -> job(project, action["method"].asString, JsonObject().apply { session.root?.let { addProperty("root", it) } }) else -> session.notify(t("This command is not available in this IDE."), NotificationType.INFORMATION) } } + /** + * THE IDE REPORT (design 2026-10-30 part 2 §3.2): what a bug report needs, written where the + * person chooses. Without a backend (the degraded mode) it says so, and still writes. + */ + fun exportReport(project: Project) { + val session = McppSession.get(project) + val collected = if (session.degraded) CompletableFuture.completedFuture(JsonObject().apply { + addProperty("kind", "mcpp-ide.report"); addProperty("version", 1); addProperty("degraded", true) + }) else session.request("report/collect", JsonObject().apply { session.root?.let { addProperty("root", it) } }) + collected.whenComplete { report, error -> + val document = report ?: JsonObject().apply { addProperty("kind", "mcpp-ide.report"); addProperty("version", 1); addProperty("error", error?.message ?: "") } + ApplicationManager.getApplication().invokeLater { + val stamp = java.time.Instant.now().toString().replace(':', '-').replace('.', '-') + val dialog = com.intellij.openapi.fileChooser.FileChooserFactory.getInstance().createSaveFileDialog( + com.intellij.openapi.fileChooser.FileSaverDescriptor(session.t("Export IDE Report"), "", "json"), project) + val target = dialog.save(project.basePath?.let { java.nio.file.Path.of(it) }, "mcpp-ide-report-$stamp.json") ?: return@invokeLater + target.file.writeText(com.google.gson.GsonBuilder().setPrettyPrinting().create().toJson(document) + "\n") + session.notify(session.t("The IDE report is written to {0}", target.file.path)) + } + } + } + /** A job whose narration is not a console's: its end is a notification. */ - private fun job(project: Project, method: String, params: JsonObject) { + fun job(project: Project, method: String, params: JsonObject) { val session = McppSession.get(project) session.request(method, params).thenAccept { r -> val id = r.asJsonObject["jobId"]?.asString ?: return@thenAccept @@ -164,7 +203,50 @@ object Dispatch { } } - private fun selectTarget(project: Project) { + /** The pack's three choices (format, mode, linkage), as VS Code asks them, then the job in a console. */ + fun pack(project: Project) { + val session = McppSession.get(project) + val root = session.root ?: return + session.request("pack/formats", JsonObject().apply { addProperty("root", root) }).thenAccept { r -> + val o = r.asJsonObject + val dflt = session.t("the project's default") + val formats = listOf("") + (o.getAsJsonArray("formats")?.map { it.asJsonObject["name"].asString } ?: emptyList()) + val modes = listOf("") + (o.getAsJsonArray("modes")?.map { it.asString } ?: emptyList()) + val linkages = o.getAsJsonArray("linkages")?.map { it.asString } ?: emptyList() + fun choose(title: String, values: List, next: (String) -> Unit) { + val step = object : BaseListPopupStep(title, values) { + override fun getTextFor(value: String): String = value.ifEmpty { dflt } + override fun onChosen(value: String, finalChoice: Boolean): PopupStep<*>? { + ApplicationManager.getApplication().invokeLater { next(value) } + return FINAL_CHOICE + } + } + JBPopupFactory.getInstance().createListPopup(step).showCenteredInCurrentWindow(project) + } + ApplicationManager.getApplication().invokeLater { + choose(session.t("mcpp: Pack (1/3): the package format"), formats) { format -> + choose(session.t("mcpp: Pack (2/3): what the machine that runs it provides"), modes) { mode -> + val start = { linkage: String -> + val params = JsonObject().apply { + addProperty("kind", "pack"); addProperty("root", root); addProperty("color", true) + if (format.isNotEmpty()) addProperty("pkgFormat", format) + if (mode.isNotEmpty()) addProperty("mode", mode) + if (linkage.isNotEmpty()) addProperty("linkage", linkage) + McppSettings.get().string("mcpp.build.target").takeIf { it.isNotBlank() }?.let { addProperty("target", it) } + } + val handler = io.github.mcppcommunity.mcppclion.run.JobProcessHandler(session, params) + com.intellij.execution.RunContentExecutor(project, handler).withTitle(session.t("mcpp: Pack")).withActivateToolWindow(true).run() + } + if (linkages.isEmpty()) start("") + else choose(session.t("mcpp: Pack (3/3): the libraries inside the program or beside it"), listOf("") + linkages, start) + } + } + } + } + } + + /** The targets mcpp knows, and the target sets it names (`openkal (aarch64, x86_64)`). */ + fun selectTarget(project: Project) { val session = McppSession.get(project) session.request("targets/list", JsonObject().apply { session.root?.let { addProperty("root", it) } }).thenAccept { r -> val rows = r.asJsonObject.getAsJsonArray("rows").map { it.asJsonObject } @@ -172,7 +254,8 @@ object Dispatch { val step = object : BaseListPopupStep(session.t("Select the build target"), rows) { override fun getTextFor(row: JsonObject): String { val platform = row["platformText"]?.asString?.takeIf { it.isNotEmpty() }?.let { " · $it" } ?: "" - return (if (row["selected"].asBoolean) "✓ " else "") + row["target"].asString + platform + val name = if (row["set"]?.asBoolean == true) row["label"]?.asString ?: row["target"].asString else row["target"].asString + return (if (row["selected"].asBoolean) "✓ " else "") + name + platform } override fun onChosen(row: JsonObject, finalChoice: Boolean): PopupStep<*>? { McppSettings.get().set("mcpp.build.target", com.google.gson.JsonPrimitive(if (row["host"].asBoolean) "" else row["target"].asString)) @@ -220,6 +303,14 @@ object Dispatch { } } + /** One of mcppls's actions, by the id of its CLion plugin's action that mcppls states. */ + private fun forward(project: Project, action: JsonObject) { + val session = McppSession.get(project) + val target = action["command"]?.asString?.let { ActionManager.getInstance().getAction(it) } + if (target == null) { session.notify(session.t("This command is not available in this IDE."), NotificationType.INFORMATION); return } + ActionManager.getInstance().tryToExecute(target, null, null, ActionPlaces.UNKNOWN, true) + } + fun languageServiceAction(project: Project, id: String) { // The mcppls CLion plugin is installed and updated where every plugin is: Settings | Plugins, // whose Marketplace tab gives this system's package, the server inside it. diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt index 5288b05..3cf7492 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt @@ -1,6 +1,8 @@ -// The backend's wire, as the plugin speaks it: `mcpp-ide serve` as a child process, JSON-RPC 2.0 -// framed as the LSP base protocol on its standard streams. Requests answer futures; notifications -// go to the listeners of their method ("*" receives every one). +// The backend's wire, as the plugin speaks it: `mcpp-ide serve` as a child process, an LSP server +// (protocol 2, mcpp design 2026-10-30 part 2 §8) on its standard streams. LSP's methods go as they +// are named; mcpp-ide's own are under `mcpp-ide/`, which this class adds to a request and takes off +// a notification. Requests answer futures; notifications go to the listeners of their method ("*" +// receives every one). package io.github.mcppcommunity.mcppclion.backend import com.google.gson.JsonElement @@ -61,6 +63,16 @@ fun encode(message: JsonObject): ByteArray { return "Content-Length: ${body.size}\r\n\r\n".toByteArray(StandardCharsets.US_ASCII) + body } +/** mcpp-ide's methods and notifications are under this namespace; LSP's are not. */ +const val NAMESPACE = "mcpp-ide/" + +/** The name a method has on the wire: LSP's own as it is, mcpp-ide's under its namespace. */ +fun wireName(method: String): String = + if (method in setOf("initialize", "initialized", "shutdown", "exit") || method.startsWith("$/") || + method.startsWith("textDocument/") || method.startsWith("workspace/") || method.startsWith("window/") || + method.startsWith(NAMESPACE)) method + else NAMESPACE + method + class Backend(command: List, private val log: (String) -> Unit = {}) : AutoCloseable { private val process: Process = ProcessBuilder(command).redirectErrorStream(false).start() private val input: OutputStream = process.outputStream @@ -109,7 +121,7 @@ class Backend(command: List, private val log: (String) -> Unit = {}) : A } else future.complete(message.get("result") ?: JsonNull.INSTANCE) return } - val method = message.get("method")?.asString ?: return + val method = message.get("method")?.asString?.removePrefix(NAMESPACE) ?: return val params = message.get("params") ?: JsonObject() listeners[method]?.forEach { runCatching { it(method, params) }.onFailure { e -> log("$method: ${e.message}") } } listeners["*"]?.forEach { runCatching { it(method, params) } } @@ -123,12 +135,12 @@ class Backend(command: List, private val log: (String) -> Unit = {}) : A } val id = nextId.getAndIncrement() pending[id] = method to future - send(JsonObject().apply { addProperty("jsonrpc", "2.0"); addProperty("id", id); addProperty("method", method); add("params", params) }) + send(JsonObject().apply { addProperty("jsonrpc", "2.0"); addProperty("id", id); addProperty("method", wireName(method)); add("params", params) }) return future } fun notify(method: String, params: JsonElement = JsonObject()) { - if (process.isAlive) send(JsonObject().apply { addProperty("jsonrpc", "2.0"); addProperty("method", method); add("params", params) }) + if (process.isAlive) send(JsonObject().apply { addProperty("jsonrpc", "2.0"); addProperty("method", wireName(method)); add("params", params) }) } private fun send(message: JsonObject) { diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt new file mode 100644 index 0000000..0207c90 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/editor/McppEditor.kt @@ -0,0 +1,378 @@ +// mcpp.toml and build.mcpp in CLion: the backend's rules layer (mcpp design 2026-10-30 part 2 §8) -- +// completion, documentation, the jumps of mcpp.toml and the diagnostics of both -- over the +// session's own connection to `mcpp-ide serve`, which is an LSP server. The platform's LSP client +// starts the server it talks to, and a second backend process is what §8 rules out, so the requests +// are LSP's, sent by the session, and their answers reach CLion through its own extension points. +// +// The editor's text reaches the backend whole (`didOpen`, then `didChange` with each new version); +// positions are a 0-based line and a UTF-16 column, which a Document's offsets already count. +package io.github.mcppcommunity.mcppclion.editor + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.intellij.codeInsight.completion.CompletionContributor +import com.intellij.codeInsight.completion.CompletionParameters +import com.intellij.codeInsight.completion.CompletionResultSet +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.codeInsight.navigation.actions.GotoDeclarationHandler +import com.intellij.lang.annotation.AnnotationHolder +import com.intellij.lang.annotation.ExternalAnnotator +import com.intellij.lang.annotation.HighlightSeverity +import com.intellij.lang.documentation.AbstractDocumentationProvider +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.FileEditorManagerListener +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.util.TextRange +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.PsiDocumentManager +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.PsiManager +import io.github.mcppcommunity.mcppclion.McppSession +import io.github.mcppcommunity.mcppclion.uriOf +import java.net.URLDecoder +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** The files the rules layer reads. */ +internal fun served(file: VirtualFile?): Boolean = + file != null && file.isInLocalFileSystem && (file.name == "mcpp.toml" || file.name == "build.mcpp") + +/** A `file:` URI's path, as the backend wrote it (`file:///C:/x` is `C:/x`). */ +internal fun pathOf(uri: String): String { + var p = URLDecoder.decode(uri.removePrefix("file://").replace("+", "%2B"), Charsets.UTF_8) + if (p.length >= 3 && p[0] == '/' && p[1].isLetter() && p[2] == ':') p = p.substring(1) + return p +} + +private fun position(document: Document, offset: Int): JsonObject { + val line = document.getLineNumber(offset) + return JsonObject().apply { + addProperty("line", line) + addProperty("character", offset - document.getLineStartOffset(line)) + } +} + +private fun offsetOf(document: Document, position: JsonObject): Int { + val line = position["line"].asInt + if (line >= document.lineCount) return document.textLength + val start = document.getLineStartOffset(line) + return (start + position["character"].asInt).coerceIn(start, document.getLineEndOffset(line)) +} + +/** The documents the backend holds, and what it published about each. */ +@Service(Service.Level.PROJECT) +class McppDocuments(private val project: Project) : Disposable { + private val versions = ConcurrentHashMap() + private val sent = ConcurrentHashMap() + private val published = ConcurrentHashMap() + private val waiting = ConcurrentHashMap>>>() + @Volatile private var installed = false + + fun install() { + if (installed) return + installed = true + val session = McppSession.get(project) + session.on("textDocument/publishDiagnostics") { p -> onPublished(p.asJsonObject) } + // A backend that started again knows no document: each open one is sent again. + session.whenStarted { reopen() } + project.messageBus.connect(this).subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + if (served(file)) FileDocumentManager.getInstance().getDocument(file)?.let { sync(file, it) } + } + + override fun fileClosed(source: FileEditorManager, file: VirtualFile) { + if (served(file)) close(file) + } + }) + EditorFactory.getInstance().eventMulticaster.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + val file = FileDocumentManager.getInstance().getFile(event.document) ?: return + if (served(file) && sent.containsKey(uriOf(file.path))) sync(file, event.document) + } + }, this) + reopen() + } + + private fun reopen() { + sent.clear() + for (file in FileEditorManager.getInstance(project).openFiles) + if (served(file)) FileDocumentManager.getInstance().getDocument(file)?.let { sync(file, it) } + } + + /** Sends the document's text when it differs from what the backend holds: its uri and version. */ + fun sync(file: VirtualFile, document: Document): Pair? { + val session = McppSession.get(project) + if (!session.running) return null + val uri = uriOf(file.path) + val text = document.immutableCharSequence.toString() + synchronized(this) { + val last = sent[uri] + if (last == text) return uri to (versions[uri] ?: 0) + val version = (versions[uri] ?: 0) + 1 + versions[uri] = version + if (last == null) { + session.notifyBackend("textDocument/didOpen", JsonObject().apply { + add("textDocument", JsonObject().apply { + addProperty("uri", uri) + addProperty("languageId", if (file.name == "mcpp.toml") "mcpp-toml" else "cpp") + addProperty("version", version) + addProperty("text", text) + }) + }) + } else { + session.notifyBackend("textDocument/didChange", JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri); addProperty("version", version) }) + add("contentChanges", JsonArray().apply { add(JsonObject().apply { addProperty("text", text) }) }) + }) + } + sent[uri] = text + return uri to version + } + } + + fun close(file: VirtualFile) { + val uri = uriOf(file.path) + if (sent.remove(uri) != null) + McppSession.get(project).notifyBackend("textDocument/didClose", JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri) }) + }) + published.remove(uri) + } + + private fun onPublished(p: JsonObject) { + val uri = p["uri"]?.asString ?: return + published[uri] = p + val version = p["version"]?.takeIf { !it.isJsonNull }?.asInt ?: Int.MAX_VALUE + waiting[uri]?.let { list -> + synchronized(list) { + list.removeIf { (wanted, future) -> (version >= wanted).also { if (it) future.complete(p) } } + } + } + } + + /** The diagnostics the backend published for `version` of the document or a later one. */ + fun diagnostics(uri: String, version: Int, timeoutMs: Long): JsonArray? { + published[uri]?.let { p -> if ((p["version"]?.takeIf { !it.isJsonNull }?.asInt ?: 0) >= version) return p.getAsJsonArray("diagnostics") } + val future = CompletableFuture() + val list = waiting.computeIfAbsent(uri) { mutableListOf() } + synchronized(list) { list += version to future } + // Published while this waiter was being registered. + published[uri]?.let { onPublished(it) } + return runCatching { future.get(timeoutMs, TimeUnit.MILLISECONDS).getAsJsonArray("diagnostics") }.getOrNull() + } + + override fun dispose() {} + + companion object { + fun get(project: Project): McppDocuments = project.getService(McppDocuments::class.java) + } +} + +/** The rules' diagnostics as CLion's annotations. */ +class McppAnnotator : ExternalAnnotator() { + class Input(val project: Project, val file: VirtualFile, val document: Document) + + override fun collectInformation(file: PsiFile, editor: Editor, hasErrors: Boolean): Input? { + val vf = file.virtualFile ?: return null + if (!served(vf)) return null + return Input(file.project, vf, editor.document) + } + + override fun doAnnotate(input: Input): JsonArray? { + val documents = McppDocuments.get(input.project) + documents.install() + val (uri, version) = documents.sync(input.file, input.document) ?: return null + return documents.diagnostics(uri, version, 2000) + } + + override fun apply(file: PsiFile, result: JsonArray?, holder: AnnotationHolder) { + val document = PsiDocumentManager.getInstance(file.project).getDocument(file) ?: return + for (element in result ?: return) { + val d = element.asJsonObject + val range = d.getAsJsonObject("range") + val start = offsetOf(document, range.getAsJsonObject("start")) + var end = offsetOf(document, range.getAsJsonObject("end")) + if (end <= start) end = minOf(start + 1, document.textLength) + if (end <= start) continue + val severity = when (d["severity"]?.asInt) { + 1 -> HighlightSeverity.ERROR + 2 -> HighlightSeverity.WARNING + else -> HighlightSeverity.WEAK_WARNING + } + val code = d["code"]?.takeIf { it.isJsonPrimitive }?.asString + holder.newAnnotation(severity, d["message"].asString).range(TextRange(start, end)) + .apply { if (code != null) tooltip("${d["message"].asString} ($code)") }.create() + } + } +} + +/** A snippet (`${1:name}`, `$1`, `${2|a,b|}`) as plain text and where the caret goes: its first stop. */ +internal fun expandSnippet(snippet: String): Pair { + val out = StringBuilder() + var caret: Int? = null + var firstStop = Int.MAX_VALUE + var i = 0 + while (i < snippet.length) { + val c = snippet[i] + if (c == '\\' && i + 1 < snippet.length) { out.append(snippet[i + 1]); i += 2; continue } + if (c == '$' && i + 1 < snippet.length) { + val brace = snippet[i + 1] == '{' + var j = if (brace) i + 2 else i + 1 + val digits = StringBuilder() + while (j < snippet.length && snippet[j].isDigit()) digits.append(snippet[j++]) + if (digits.isNotEmpty()) { + val stop = digits.toString().toInt() + var text = "" + if (brace) { + var depth = 1 + val body = StringBuilder() + while (j < snippet.length && depth > 0) { + val d = snippet[j] + if (d == '{') depth++ + if (d == '}') depth-- + if (depth > 0) body.append(d) + j++ + } + text = when { + body.startsWith(":") -> expandSnippet(body.substring(1)).first + body.startsWith("|") -> body.removePrefix("|").removeSuffix("|").split(',').first() + else -> "" + } + } + if (stop in 1 until firstStop) { firstStop = stop; caret = out.length } + out.append(text) + i = j + continue + } + } + out.append(c) + i++ + } + return out.toString() to caret +} + +/** The backend's completion in mcpp.toml and build.mcpp. */ +class McppCompletion : CompletionContributor() { + override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) { + val vf = parameters.originalFile.virtualFile ?: return + if (!served(vf)) return + val project = parameters.originalFile.project + val document = parameters.editor.document + val documents = McppDocuments.get(project) + documents.install() + val (uri, _) = documents.sync(vf, document) ?: return + val offset = parameters.offset + val params = JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri) }) + add("position", position(document, offset)) + } + val answer = runCatching { McppSession.get(project).request("textDocument/completion", params).get(2, TimeUnit.SECONDS) } + .getOrNull()?.takeIf { it.isJsonObject }?.asJsonObject ?: return + val lineStart = document.getLineStartOffset(document.getLineNumber(offset)) + for (element in answer.getAsJsonArray("items") ?: return) { + val item = element.asJsonObject + val label = item["label"].asString + val edit = item["textEdit"]?.takeIf { it.isJsonObject }?.asJsonObject + val start = edit?.getAsJsonObject("range")?.getAsJsonObject("start")?.let { lineStart + it["character"].asInt } + val end = edit?.getAsJsonObject("range")?.getAsJsonObject("end")?.let { lineStart + it["character"].asInt } + val text = edit?.get("newText")?.asString ?: item["insertText"]?.asString ?: label + val (plain, caret) = if (item["insertTextFormat"]?.asInt == 2) expandSnippet(text) else text to null + val lookup = item["filterText"]?.asString ?: label + val built = LookupElementBuilder.create(lookup) + .withPresentableText(label) + .withTypeText(item["detail"]?.asString, true) + .withInsertHandler { context, _ -> + // What the lookup inserted is replaced by the item's text, and what the edit + // covered past the cursor goes too. + val past = if (end != null) (end - offset).coerceAtLeast(0) else 0 + val tail = minOf(context.tailOffset + past, context.document.textLength) + context.document.replaceString(context.startOffset, tail, plain) + context.editor.caretModel.moveToOffset(context.startOffset + (caret ?: plain.length)) + } + val prefix = if (start != null && start <= offset) document.getText(TextRange(start, offset)) else result.prefixMatcher.prefix + result.withPrefixMatcher(prefix).addElement(built) + } + } +} + +/** The backend's hover as CLion's documentation, in mcpp.toml and build.mcpp. */ +class McppDocumentation : AbstractDocumentationProvider() { + override fun getCustomDocumentationElement(editor: Editor, file: PsiFile, contextElement: PsiElement?, targetOffset: Int): PsiElement? { + if (!served(file.virtualFile)) return null + file.putUserData(OFFSET, targetOffset) + return contextElement ?: file + } + + override fun generateDoc(element: PsiElement, originalElement: PsiElement?): String? { + val file = element.containingFile ?: return null + val vf = file.virtualFile + if (!served(vf)) return null + val project = file.project + val document = PsiDocumentManager.getInstance(project).getDocument(file) ?: return null + val documents = McppDocuments.get(project) + documents.install() + val (uri, _) = documents.sync(vf, document) ?: return null + val offset = file.getUserData(OFFSET) ?: (originalElement ?: element).textOffset + val params = JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri) }) + add("position", position(document, offset.coerceIn(0, document.textLength))) + } + val answer = runCatching { McppSession.get(project).request("textDocument/hover", params).get(2, TimeUnit.SECONDS) }.getOrNull() + val value = answer?.takeIf { it.isJsonObject }?.asJsonObject?.getAsJsonObject("contents")?.get("value")?.asString ?: return null + return markdownToHtml(value) + } + + companion object { + private val OFFSET = Key.create("mcpp.documentation.offset") + } +} + +/** The little markdown the rules write: paragraphs, **bold**, `code`, [text](link). */ +internal fun markdownToHtml(markdown: String): String { + fun inline(text: String): String { + var s = text.replace("&", "&").replace("<", "<").replace(">", ">") + s = Regex("\\[([^\\]]+)]\\(([^)]+)\\)").replace(s) { "${it.groupValues[1]}" } + s = Regex("\\*\\*([^*]+)\\*\\*").replace(s) { "${it.groupValues[1]}" } + s = Regex("`([^`]+)`").replace(s) { "${it.groupValues[1]}" } + return s + } + return markdown.split(Regex("\\n\\s*\\n")).joinToString("") { "

${inline(it.trim()).replace("\n", "
")}

" } +} + +/** mcpp.toml's jumps: a workspace dependency, a feature, a path dependency's manifest. */ +class McppGotoDeclaration : GotoDeclarationHandler { + override fun getGotoDeclarationTargets(sourceElement: PsiElement?, offset: Int, editor: Editor): Array? { + val file = sourceElement?.containingFile ?: return null + val vf = file.virtualFile + if (!served(vf) || vf.name != "mcpp.toml") return null + val project = file.project + val documents = McppDocuments.get(project) + documents.install() + val (uri, _) = documents.sync(vf, editor.document) ?: return null + val params = JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri) }) + add("position", position(editor.document, offset)) + } + val answer: JsonElement = runCatching { McppSession.get(project).request("textDocument/definition", params).get(2, TimeUnit.SECONDS) } + .getOrNull() ?: return null + val location = answer.takeIf { it.isJsonObject }?.asJsonObject ?: return null + val target = LocalFileSystem.getInstance().findFileByPath(pathOf(location["uri"].asString)) ?: return null + val psi = PsiManager.getInstance(project).findFile(target) ?: return null + val document = FileDocumentManager.getInstance().getDocument(target) ?: return null + val at = offsetOf(document, location.getAsJsonObject("range").getAsJsonObject("start")) + return arrayOf(psi.findElementAt(at) ?: psi) + } +} 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 6ea6daf..f82dcf3 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 @@ -19,9 +19,9 @@ import com.intellij.xdebugger.XDebugProcess import com.intellij.xdebugger.XDebugProcessStarter import com.intellij.xdebugger.XDebugSession import com.intellij.xdebugger.XDebuggerManager -import com.jetbrains.cidr.cpp.execution.debugger.backend.CLionGDBDriverConfiguration -import com.jetbrains.cidr.cpp.execution.debugger.backend.CLionLLDBDriverConfiguration +import com.jetbrains.cidr.cpp.toolchains.CPPEnvironment import com.jetbrains.cidr.cpp.toolchains.CPPToolchains +import com.jetbrains.cidr.cpp.toolchains.createDriverConfiguration import com.jetbrains.cidr.execution.TrivialRunParameters import com.jetbrains.cidr.execution.debugger.CidrLocalDebugProcess import io.github.mcppcommunity.mcppclion.McppSession @@ -40,14 +40,16 @@ class McppDebugRunner : GenericProgramRunner() { ?: throw ExecutionException(session.t("No toolchain is set up in Settings | Build, Execution, Deployment | Toolchains; CLion's debugger comes from one.")) val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously( { launch(c).get() }, session.t("mcpp: building for the debugger…"), true, project) - val lldb = toolchain.debuggerKind.isLLDB() - if (!launch["via"].isJsonNull && lldb) + // The debugger the toolchain names (GDB, LLDB, or a debug adapter configured in CLion), as CLion + // itself creates it. + val driver = createDriverConfiguration(project, CPPEnvironment(toolchain)) + // Said for either debugger: the sentence names what each one needs. + if (!launch["via"].isJsonNull) session.notify(session.t("This program runs through {0}. LLDB reads its symbols once `settings set plugin.jit-loader.gdb.enable on` is in ~/.lldbinit; GDB reads them as it is.", launch["via"].asString), NotificationType.INFORMATION) - val driver = if (lldb) CLionLLDBDriverConfiguration(project, toolchain) else CLionGDBDriverConfiguration(project, toolchain) val parameters = TrivialRunParameters(driver, commandLine(launch, pty = false)) - return XDebuggerManager.getInstance(project).startSession(env, object : XDebugProcessStarter() { + return XDebuggerManager.getInstance(project).newSessionBuilder(object : XDebugProcessStarter() { override fun start(xs: XDebugSession): XDebugProcess = CidrLocalDebugProcess(parameters, xs, TextConsoleBuilderFactory.getInstance().createBuilder(project)) - }).runContentDescriptor + }).environment(env).startSession().runContentDescriptor } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt index 25e3f30..aab7ff7 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt @@ -1,8 +1,12 @@ -// Run configurations from context, and the ▶ beside `main`: a C++ file of an mcpp project whose -// `main` is a test's (mcpp test --list names the file) runs that test; any other `main` runs the -// project's program. Both come from the same producer, so Run and Debug are offered alike. +// Run configurations from context, and the ▶ in the gutter (mcpp design 2026-10-30 part 2 §6.3, +// §7.4). What a file is -- a program's `main`, a test's, or neither -- is the backend's answer +// (source/entries); where `main` is, the language service's: the ▶ stands on the PSI element `main` +// on the line the backend read. A test program of a framework has a ▶ on each case's macro +// (test/cases), which runs that case. Every ▶ comes from the same producer, so Run and Debug are +// offered alike. package io.github.mcppcommunity.mcppclion.run +import com.google.gson.JsonObject import com.intellij.execution.actions.ConfigurationContext import com.intellij.execution.actions.LazyRunConfigurationProducer import com.intellij.execution.configurations.ConfigurationFactory @@ -16,8 +20,6 @@ import com.intellij.psi.PsiElement import io.github.mcppcommunity.mcppclion.McppSession import java.io.File -private val MAIN = Regex("""^\s*(?:export\s+)?(?:int|auto)\s+main\s*\(""") - /** The mcpp project a file belongs to: the nearest directory above it with an mcpp.toml. */ internal fun projectOf(file: VirtualFile?): String? { var dir = file?.parent @@ -28,8 +30,25 @@ internal fun projectOf(file: VirtualFile?): String? { return null } -/** The test a file is the `main` of (mcpp test --list), as far as the session knows; null otherwise. */ -internal fun testOf(session: McppSession, root: String, file: VirtualFile): String? = session.testForFile(root, file.path) +/** The 0-based line of an element, from its document. */ +private fun lineOf(element: PsiElement): Int? { + val document = element.containingFile?.viewProvider?.document ?: return null + return document.getLineNumber(element.textOffset) +} + +/** What the backend says the file is, as far as the session knows (asked in the background otherwise). */ +internal fun entriesOf(session: McppSession, file: VirtualFile): JsonObject? = + session.entriesFor(file.path)?.takeIf { it["kind"]?.asString in setOf("program", "test") } + +/** The case written at `line` of a test's source, by its full name, with its framework. */ +internal fun caseAt(session: McppSession, entries: JsonObject, line: Int): Pair? { + val root = entries["root"]?.asString ?: return null + val test = entries["test"]?.asString ?: return null + val cases = session.casesFor(root, test) ?: return null + val framework = cases["framework"]?.takeIf { !it.isJsonNull }?.asString ?: return null + val hit = cases.getAsJsonArray("cases")?.firstOrNull { it.asJsonObject["line"]?.asInt == line } ?: return null + return hit.asJsonObject["name"].asString to framework +} class McppConfigurationProducer : LazyRunConfigurationProducer() { override fun getConfigurationFactory(): ConfigurationFactory = @@ -37,15 +56,23 @@ class McppConfigurationProducer : LazyRunConfigurationProducer): Boolean { val file = context.location?.virtualFile ?: return false - val root = projectOf(file) ?: return false - val text = context.psiLocation?.containingFile?.text ?: return false - if (!text.lineSequence().any { MAIN.containsMatchIn(it) }) return false + val session = McppSession.get(c.project) + val entries = entriesOf(session, file) ?: return false + val root = entries["root"]?.asString ?: projectOf(file) ?: return false c.options.root = root - val test = testOf(McppSession.get(c.project), root, file) - if (test != null) { + if (entries["kind"].asString == "test") { + val test = entries["test"].asString c.options.command = "test" c.options.filter = test - c.name = "mcpp test $test" + val line = context.psiLocation?.let { lineOf(it) } + val case = line?.let { caseAt(session, entries, it) } + if (case != null) { + c.options.framework = case.second + c.options.cases = case.first + c.name = "mcpp test $test · ${case.first}" + } else { + c.name = "mcpp test $test" + } } else { c.options.command = "run" c.name = "mcpp run ${File(root).name}" @@ -55,23 +82,35 @@ class McppConfigurationProducer : LazyRunConfigurationProducer { + val promise = AsyncPromise() + val session = McppSession.get(project) + val root = session.root ?: project.basePath + if (root == null) { + promise.setResult(TaskRunnerResults.FAILURE) + return promise + } + // The job's end can arrive before the reply that names its id: ends are kept until it is known. + val lock = Any() + var jobId: String? = null + val early = mutableListOf() + var unsubscribe: (() -> Unit)? = null + fun finish(o: JsonObject) { + unsubscribe?.invoke() + val status = o["status"]?.asString + if (status != "ok") session.notify(session.t("mcpp {0}: {1}", "build", status ?: "failed"), NotificationType.ERROR) + promise.setResult(when (status) { + "ok" -> TaskRunnerResults.SUCCESS + "cancelled", "superseded" -> TaskRunnerResults.ABORTED + else -> TaskRunnerResults.FAILURE + }) + } + unsubscribe = session.on("job/finished") { p -> + val o = p.asJsonObject + val mine = synchronized(lock) { + if (jobId == null) { early += o; false } else o["jobId"]?.asString == jobId + } + if (mine) finish(o) + } + session.request("job/start", JsonObject().apply { + addProperty("kind", "build") + addProperty("root", root) + addProperty("color", false) + }).whenComplete { r, e -> + if (e != null) { + unsubscribe?.invoke() + session.notify(session.t("mcpp: {0}", (e.cause ?: e).message ?: ""), NotificationType.ERROR) + promise.setResult(TaskRunnerResults.FAILURE) + return@whenComplete + } + val id = r.asJsonObject["jobId"].asString + val ended = synchronized(lock) { jobId = id; early.firstOrNull { it["jobId"]?.asString == id }.also { early.clear() } } + if (ended != null) finish(ended) + } + return promise + } +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt index b7f4947..cbbd516 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt @@ -45,6 +45,8 @@ class McppRunOptions : RunConfigurationOptions() { private val filterProperty = string("").provideDelegate(this, "filter") private val binProperty = string("").provideDelegate(this, "bin") private val rootProperty = string("").provideDelegate(this, "root") + private val frameworkProperty = string("").provideDelegate(this, "framework") + private val casesProperty = string("").provideDelegate(this, "cases") var command: String get() = commandProperty.getValue(this) ?: "run"; set(v) = commandProperty.setValue(this, v) var target: String get() = targetProperty.getValue(this) ?: ""; set(v) = targetProperty.setValue(this, v) @@ -53,6 +55,10 @@ class McppRunOptions : RunConfigurationOptions() { var filter: String get() = filterProperty.getValue(this) ?: ""; set(v) = filterProperty.setValue(this, v) var bin: String get() = binProperty.getValue(this) ?: ""; set(v) = binProperty.setValue(this, v) var root: String get() = rootProperty.getValue(this) ?: ""; set(v) = rootProperty.setValue(this, v) + /** The test framework whose cases `cases` names (design 2026-10-30 part 2 §7); empty: whole programs. */ + var framework: String get() = frameworkProperty.getValue(this) ?: ""; set(v) = frameworkProperty.setValue(this, v) + /** The cases to run, one per line, by the framework's full names; empty: every case. */ + var cases: String get() = casesProperty.getValue(this) ?: ""; set(v) = casesProperty.setValue(this, v) } class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name: String) : @@ -81,6 +87,8 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name private val args = JBTextField() private val filter = JBTextField() private val bin = JBTextField() + private val framework = JBTextField() + private val cases = com.intellij.ui.components.JBTextArea(3, 40) override fun resetEditorFrom(c: McppRunConfiguration) { command.selectedItem = c.options.command @@ -89,6 +97,8 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name args.text = c.options.args filter.text = c.options.filter bin.text = c.options.bin + framework.text = c.options.framework + cases.text = c.options.cases } override fun applyEditorTo(c: McppRunConfiguration) { @@ -98,6 +108,8 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name c.options.args = args.text c.options.filter = filter.text.trim() c.options.bin = bin.text.trim() + c.options.framework = framework.text.trim() + c.options.cases = cases.text.lines().map { it.trimEnd() }.filter { it.isNotEmpty() }.joinToString("\n") } override fun createEditor(): JComponent = FormBuilder.createFormBuilder() @@ -106,6 +118,8 @@ class McppRunConfiguration(project: Project, factory: ConfigurationFactory, name .addLabeledComponent("Profile (empty: the mcpp.build.profile setting):", profile) .addLabeledComponent("Executable (run):", bin) .addLabeledComponent("Test filter (test):", filter) + .addLabeledComponent("Test framework (test cases):", framework) + .addLabeledComponent("Cases, one per line (test cases):", cases) .addLabeledComponent("Arguments:", args) .panel } 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 b896a02..68c8f07 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 @@ -49,7 +49,7 @@ open class JobProcessHandler(protected val session: McppSession, private val par override fun startNotify() { super.startNotify() - for (m in listOf("job/output", "job/finished", "test/result", "test/summary")) + for (m in listOf("job/output", "job/finished", "test/result", "test/summary", "test/caseResult")) subscriptions += session.on(m) { p -> route(m, p) } session.request(method, params).whenComplete { r, e -> if (e != null) { @@ -118,11 +118,40 @@ 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`. */ -class TestJobProcessHandler(session: McppSession, params: JsonObject, private val mains: Map) : JobProcessHandler(session, params) { +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("]", "|]") + /** The program whose cases are reported, once its first case is (test/runCases). */ + private var suite: String? = null override fun handle(method: String, p: JsonObject) { when (method) { + // A case of a framework (§7.4): the program is a suite, each case a test in it. + "test/caseResult" -> { + val program = p["test"].asString + val out = StringBuilder() + if (suite == null) { + suite = program + val hint = mains[program]?.let { " locationHint='file://${escape(it)}'" } ?: "" + out.append("##teamcity[testSuiteStarted name='${escape(program)}'$hint]\n") + } + val name = escape(p["case"].asString) + // The case's place in its source, for the editor's ▶ to show the last outcome (TestStateStorage). + 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") + 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") + } + out.append("##teamcity[testFinished name='$name' duration='${p["durationMs"]?.asLong ?: 0}']\n") + notifyTextAvailable(out.toString(), ProcessOutputTypes.STDOUT) + } + "job/finished" -> { + suite?.let { notifyTextAvailable("##teamcity[testSuiteFinished name='${escape(it)}']\n", ProcessOutputTypes.STDOUT) } + super.handle(method, p) + } "test/result" -> { val name = escape(p["test"].asString) val hint = mains[p["test"].asString]?.let { " locationHint='file://${escape(it)}'" } ?: "" @@ -149,6 +178,14 @@ class McppTestState(private val c: McppRunConfiguration, env: ExecutionEnvironme session.request("test/list", JsonObject().apply { addProperty("root", c.root) }).get() .asJsonObject.getAsJsonArray("tests").associate { it.asJsonObject["test"].asString to it.asJsonObject["main"].asString } }.getOrDefault(emptyMap()) + // A framework's cases (test/runCases): the program receives the framework's filter. + if (c.options.framework.isNotBlank() && c.options.filter.isNotBlank()) + return TestJobProcessHandler(session, jobParams(c, "test").apply { + remove("kind") + addProperty("test", c.options.filter) + addProperty("framework", c.options.framework) + add("cases", JsonArray().apply { c.options.cases.lines().filter { it.isNotBlank() }.forEach { add(it) } }) + }, mains, "test/runCases") return TestJobProcessHandler(session, jobParams(c, "test").apply { if (c.options.filter.isNotBlank()) addProperty("filter", c.options.filter) if (c.programArgs.isNotEmpty()) add("args", JsonArray().apply { add("--"); c.programArgs.forEach { add(it) } }) @@ -240,3 +277,13 @@ class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHand class McppProgramState(private val c: McppRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) { override fun startProcess(): ProcessHandler = LaunchingProcessHandler(c) } + +/** The URL of a case: its program's source and the line of its macro (`file://:`, 1-based). */ +internal fun caseUrl(session: McppSession, root: String, test: String, case: String, main: String?): String? { + val file = main?.takeIf { it.isNotEmpty() } ?: return null + val cases = session.casesFor(root, test) ?: return null + val line = cases.getAsJsonArray("cases")?.firstOrNull { it.asJsonObject["name"]?.asString == case } + ?.asJsonObject?.get("line")?.asInt ?: return null + return "file://$file:${line + 1}" +} + diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt index eaa2b73..72f1a84 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt @@ -1,31 +1,203 @@ -// Settings | Tools | mcpp: the shared settings panel, the same one VS Code shows. +// Settings | Tools | mcpp, in CLion's own form (mcpp design 2026-10-30 part 2 §9): one field per +// setting of the backend's `settings/registry` -- its title, its description and the values it may +// hold, in the session's language -- under the registry's groups, the advanced ones last. Apply +// stores what changed (a value equal to the default is removed) and the session tells the backend; +// Reset shows what is stored. The search field narrows the form by key, title and description. package io.github.mcppcommunity.mcppclion.settings +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonNull import com.google.gson.JsonObject -import com.intellij.openapi.Disposable -import com.intellij.openapi.options.Configurable +import com.google.gson.JsonPrimitive +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.options.SearchableConfigurable import com.intellij.openapi.project.Project -import com.intellij.openapi.util.Disposer -import io.github.mcppcommunity.mcppclion.webui.WebuiPanel +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.SearchTextField +import com.intellij.ui.TitledSeparator +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.components.JBTextArea +import com.intellij.ui.components.JBTextField +import com.intellij.util.ui.FormBuilder +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import io.github.mcppcommunity.mcppclion.McppSession +import java.awt.BorderLayout +import javax.swing.DefaultComboBoxModel import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.event.DocumentEvent -class McppConfigurable(private val project: Project) : Configurable { - private var disposable: Disposable? = null +/** One setting's field: what it shows, how it reads and writes a JSON value. */ +class SettingField(val entry: JsonObject) { + val key: String = entry["key"].asString + val type: String = entry["type"]?.asString ?: "string" + val default: JsonElement = entry["default"] ?: JsonNull.INSTANCE + private val enumValues: List = entry["enum"]?.takeIf { it.isJsonArray }?.asJsonArray?.toList() ?: emptyList() + val title: String = entry["title"]?.asString ?: key + val description: String = entry["description"]?.asString ?: "" + val component: JComponent = when { + type == "boolean" -> JBCheckBox(title) + enumValues.isNotEmpty() -> ComboBox(DefaultComboBoxModel(enumValues.map { label(it) }.toTypedArray())) + type == "array" -> JBTextArea(2, 40).apply { lineWrap = false } + else -> JBTextField(30) + } + val label: JBLabel? = if (type == "boolean") null else JBLabel("$title:") + val comment: JBLabel = JBLabel("${escape(description)}
$key").apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + } + + private fun label(v: JsonElement) = if (v.isJsonPrimitive && v.asJsonPrimitive.isString) v.asString else v.toString() + + /** Shows a stored value, else the default. */ + fun show(value: JsonElement?) { + val v = value?.takeIf { !it.isJsonNull } ?: default + when (val c = component) { + is JBCheckBox -> c.isSelected = v.isJsonPrimitive && v.asBoolean + is ComboBox<*> -> c.selectedItem = label(v) + is JBTextArea -> c.text = if (v.isJsonArray) v.asJsonArray.joinToString("\n") { label(it) } else "" + is JBTextField -> c.text = if (v.isJsonNull) "" else label(v) + } + } + + /** The value the field holds, as the registry types it; null when it is not one. */ + fun value(): JsonElement? = when (val c = component) { + is JBCheckBox -> JsonPrimitive(c.isSelected) + is ComboBox<*> -> enumValues.firstOrNull { label(it) == c.selectedItem } ?: default + is JBTextArea -> JsonArray().apply { c.text.lines().map { it.trim() }.filter { it.isNotEmpty() }.forEach { add(it) } } + is JBTextField -> if (type == "number") c.text.trim().toDoubleOrNull()?.let { d -> + if (d == Math.floor(d) && !d.isInfinite()) JsonPrimitive(d.toLong()) else JsonPrimitive(d) + } else JsonPrimitive(c.text) + else -> null + } + + fun matches(filter: String): Boolean = filter.isEmpty() || + listOf(key, title, description).any { it.contains(filter, ignoreCase = true) } + + fun setVisible(visible: Boolean) { + component.isVisible = visible + label?.isVisible = visible + comment.isVisible = visible + } + + private fun escape(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">") +} + +/** The form from a registry. Pure but for Swing: the tests build it from the backend's answer. */ +class SettingsForm(registry: JsonObject, t: (String) -> String, private val stored: (String) -> JsonElement?) { + val fields: List + val component: JComponent + private val search = SearchTextField(false) + private val separators = mutableListOf>>() + + init { + val entries = registry.getAsJsonArray("settings")?.map { it.asJsonObject }?.filter { it["deprecated"]?.asBoolean != true } ?: emptyList() + val groups = registry.getAsJsonArray("groups")?.map { it.asJsonObject }?.sortedBy { it["order"]?.asInt ?: 0 } ?: emptyList() + val builder = FormBuilder.createFormBuilder() + val all = mutableListOf() + // The public settings by group, then the advanced ones by group. + for (advanced in listOf(false, true)) { + for (g in groups) { + val id = g["id"].asString + val inGroup = entries.filter { it["group"]?.asString == id && (it["tier"]?.asString == "advanced") == advanced } + if (inGroup.isEmpty()) continue + val title = g["title"]?.asString ?: id + val separator = TitledSeparator(if (advanced) "$title · ${t("Advanced")}" else title) + builder.addComponent(separator) + val fields = inGroup.map { SettingField(it) } + for (f in fields) { + if (f.label != null) builder.addLabeledComponent(f.label, f.component) else builder.addComponent(f.component) + builder.addComponentToRightColumn(f.comment) + } + separators += separator to fields + all += fields + } + } + fields = all + val form = builder.addComponentFillVertically(JPanel(), 0).panel + search.addDocumentListener(object : com.intellij.ui.DocumentAdapter() { + override fun textChanged(e: DocumentEvent) = filter(search.text.trim()) + }) + component = JPanel(BorderLayout(0, JBUI.scale(6))).apply { + add(search, BorderLayout.NORTH) + add(JBScrollPane(form).apply { border = JBUI.Borders.empty() }, BorderLayout.CENTER) + } + reset() + } + + fun filter(text: String) { + for ((separator, fields) in separators) { + var any = false + for (f in fields) { + val shown = f.matches(text) + f.setVisible(shown) + any = any || shown + } + separator.isVisible = any + } + } + + /** The stored value a field stands for: stored, else its default. */ + private fun current(f: SettingField): JsonElement = stored(f.key)?.takeIf { !it.isJsonNull } ?: f.default + + fun isModified(): Boolean = fields.any { f -> f.value()?.let { it != current(f) } ?: true } + + /** What changed, each value to store (null: back to the default). */ + fun changes(): Map = fields.mapNotNull { f -> + val v = f.value() ?: return@mapNotNull null + if (v == current(f)) null else f.key to (if (v == f.default) null else v) + }.toMap() + + fun reset() = fields.forEach { it.show(stored(it.key)) } +} + +class McppConfigurable(private val project: Project) : SearchableConfigurable { + private var form: SettingsForm? = null + private val holder = JPanel(BorderLayout()) + + override fun getId(): String = "io.github.mcppcommunity.mcppclion.settings" override fun getDisplayName(): String = "mcpp" override fun createComponent(): JComponent { - val d = Disposer.newDisposable("mcpp settings") - disposable = d - return WebuiPanel(project, "settings", JsonObject(), d).component + val session = McppSession.get(project) + holder.removeAll() + holder.add(JBLabel(session.t("Reading the settings…")), BorderLayout.NORTH) + session.start().thenCompose { session.request("settings/registry") }.whenComplete { registry, error -> + ApplicationManager.getApplication().invokeLater { + holder.removeAll() + if (error != null || registry == null || !registry.isJsonObject) { + holder.add(JBLabel(session.t("The settings come from the mcpp-ide backend, which is not running: {0}", (error?.cause ?: error)?.message ?: "")), BorderLayout.NORTH) + } else { + val f = SettingsForm(registry.asJsonObject, { key -> session.t(key) }) { key -> McppSettings.get().get(key) } + form = f + holder.add(f.component, BorderLayout.CENTER) + } + holder.revalidate() + holder.repaint() + } + } + return holder } - // Every change is stored as it is made (the panel writes through the host). - override fun isModified(): Boolean = false - override fun apply() {} + override fun isModified(): Boolean = form?.isModified() ?: false + + override fun apply() { + val f = form ?: return + val settings = McppSettings.get() + for ((key, value) in f.changes()) settings.set(key, value) + } + + override fun reset() { + form?.reset() + } override fun disposeUIResources() { - disposable?.let { Disposer.dispose(it) } - disposable = null + form = null + holder.removeAll() } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/CacheView.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/CacheView.kt new file mode 100644 index 0000000..72cdd48 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/CacheView.kt @@ -0,0 +1,114 @@ +// The cache: what mcpp keeps (`cache/inventory` over `mcpp cache list`) -- the total, each kind and +// its size, the largest labels, the ages -- and the cleanups of the command table, each planned, +// confirmed at its danger level and applied as a job (Dispatch.cleanup). +package io.github.mcppcommunity.mcppclion.ui + +import com.google.gson.JsonObject +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.util.Disposer +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.tree.TreeUtil +import io.github.mcppcommunity.mcppclion.McppSession +import io.github.mcppcommunity.mcppclion.actions.Dispatch +import javax.swing.JComponent +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel + +/** The cache's rows, from the inventory. Pure: the tests build it. */ +object CacheModel { + fun rows(inventory: JsonObject, t: (String, Array) -> String): List { + fun tr(key: String, vararg a: Any?) = t(key, a) + val out = mutableListOf() + out += RowNode(Row(tr("{0} in {1} entries", inventory["totalText"]?.asString ?: "", inventory["totalEntries"]?.asString ?: "0"), + inventory["root"]?.asString ?: "", AllIcons.Nodes.Folder)) + val kinds = inventory["byKind"].objects() + if (kinds.isNotEmpty()) + out += RowNode(Row(tr("By kind"), icon = AllIcons.Nodes.Folder), kinds.map { k -> + RowNode(Row(k["kind"].asString, tr("{0} · {1} entries", k["bytesText"]?.asString ?: "", k["entries"]?.asString ?: "0"), AllIcons.Nodes.DataTables)) + }) + val top = inventory["topLabels"].objects() + if (top.isNotEmpty()) + out += RowNode(Row(tr("Largest"), icon = AllIcons.Nodes.Folder), top.map { l -> + RowNode(Row(l["label"].asString.ifEmpty { "—" }, tr("{0} · {1} entries", l["bytesText"]?.asString ?: "", l["entries"]?.asString ?: "0"), AllIcons.Nodes.Package)) + }) + val ages = inventory["ageBuckets"].objects().filter { (it["entries"]?.asLong ?: 0) > 0 } + if (ages.isNotEmpty()) + out += RowNode(Row(tr("By last use"), icon = AllIcons.Nodes.Folder), ages.map { b -> + val from = b["fromDays"]?.asInt ?: 0 + val span = b["toDays"]?.asInt?.let { tr("{0}–{1} days", from, it) } ?: tr("{0} days or more", from) + RowNode(Row(span, tr("{0} · {1} entries", b["bytesText"]?.asString ?: "", b["entries"]?.asString ?: "0"), AllIcons.Vcs.History)) + }) + val incomplete = inventory["incomplete"].objects() + if (incomplete.isNotEmpty()) + out += RowNode(Row(tr("{0} incomplete entries", incomplete.size), icon = AllIcons.General.Warning, error = true)) + return out + } +} + +class CacheView(private val project: Project, parent: Disposable) : McppView, Disposable { + override val id = "cache" + private val session = McppSession.get(project) + private val tree = Tree(DefaultTreeModel(DefaultMutableTreeNode())) + private val panel = SimpleToolWindowPanel(true, true) + private val cleanups = DefaultActionGroup() + val component: JComponent get() = panel + private var unsubscribe: () -> Unit = {} + + init { + Disposer.register(parent, this) + tree.isRootVisible = false + tree.showsRootHandles = true + tree.cellRenderer = RowRenderer() + val group = DefaultActionGroup().apply { + add(object : DumbAwareAction(session.t("Refresh"), null, AllIcons.Actions.Refresh) { + override fun actionPerformed(e: AnActionEvent) = refresh() + }) + addSeparator() + add(cleanups) + } + val toolbar = ActionManager.getInstance().createActionToolbar("McppCacheView", group, true) + toolbar.targetComponent = panel + panel.toolbar = toolbar.component + panel.setContent(ScrollPaneFactory.createScrollPane(tree)) + unsubscribe = session.on("job/finished") { p -> if (p.asJsonObject["kind"]?.asString in setOf("cache", "clean", "command")) refresh() } + } + + /** The cleanups the command table offers here, under their titles. */ + private fun fillCleanups() { + cleanups.removeAll() + for (c in session.commands) { + val action = c["action"]?.asJsonObject ?: continue + if (action["kind"]?.asString != "cache") continue + val plan = action["plan"]?.asString ?: continue + cleanups.add(object : DumbAwareAction(c["title"]?.asString ?: plan, null, AllIcons.Actions.GC) { + override fun actionPerformed(e: AnActionEvent) = Dispatch.cleanup(project, plan) + }) + } + } + + override fun refresh() { + if (!session.running) return + session.request("cache/inventory", JsonObject().apply { session.root?.let { addProperty("root", it) } }).whenComplete { r, error -> + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + fillCleanups() + val root = DefaultMutableTreeNode() + if (error != null) root.add(RowNode(Row(session.t("mcpp: {0}", (error.cause ?: error).message ?: ""), icon = AllIcons.General.Error, error = true))) + else CacheModel.rows(r.asJsonObject) { key, args -> session.t(key, *args) }.forEach { root.add(it) } + tree.model = DefaultTreeModel(root) + TreeUtil.expand(tree, 1) + } + } + } + + override fun dispose() = unsubscribe() +} 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 new file mode 100644 index 0000000..1d82a18 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt @@ -0,0 +1,107 @@ +// A graph of the project -- its packages or its modules -- as the page webui/graph.html draws it in +// JCEF (mcpp design 2026-10-30 part 2 §9). The page receives the backend's `graph/packages` or +// `graph/modules` and the strings it shows, and asks the IDE to open a module's source; it reaches +// nothing else. Without JCEF the tab says so. +package io.github.mcppcommunity.mcppclion.ui + +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +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.jcef.JBCefApp +import com.intellij.ui.jcef.JBCefBrowser +import com.intellij.ui.jcef.JBCefBrowserBase +import com.intellij.ui.jcef.JBCefJSQuery +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" + private val session = McppSession.get(project) + private val browser: JBCefBrowser? = if (JBCefApp.isSupported()) JBCefBrowser() else null + 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) + + init { + if (browser != null && query != null) { + Disposer.register(this, browser) + Disposer.register(this, query) + query.addHandler { text -> open(text); null } + browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { + override fun onLoadEnd(b: CefBrowser, frame: CefFrame, status: Int) { + if (!frame.isMain) return + b.executeJavaScript("window.mcppHostSend = function (text) { ${query.inject("text")} };", b.url, 0) + ApplicationManager.getApplication().invokeLater { + loaded = true + pending?.let { render(it) } + pending = null + } + } + }, browser.cefBrowser) + browser.loadURL(page().toURI().toString()) + } + } + + /** The page's files, written once per build of the plugin where the browser can load them. */ + private fun page(): File { + val jar = runCatching { File(GraphPage::class.java.protectionDomain.codeSource.location.toURI()) }.getOrNull() + val version = jar?.takeIf { it.isFile }?.let { "${it.nameWithoutExtension}-${it.lastModified()}" } ?: "dev" + val dir = File(PathManager.getSystemPath(), "mcpp-clion/webui-$version") + val index = javaClass.getResource("/webui/files.txt")?.readText() ?: error("the graph page is not in the plugin (webui/files.txt)") + for (f in index.lines().filter { it.isNotBlank() }) { + val target = File(dir, f) + if (target.isFile && version != "dev") continue + target.parentFile.mkdirs() + javaClass.getResourceAsStream("/webui/$f")?.use { input -> target.outputStream().use { input.copyTo(it) } } + } + return File(dir, "graph.html") + } + + override fun refresh() { + val root = session.root ?: return + session.request("graph/$kind", JsonObject().apply { addProperty("root", root) }).whenComplete { graph, error -> + val page = JsonObject().apply { + addProperty("kind", kind) + add("graph", graph?.takeIf { it.isJsonObject } ?: JsonObject().apply { add("nodes", com.google.gson.JsonArray()); add("edges", com.google.gson.JsonArray()) }) + addProperty("title", if (kind == "modules") session.t("Module Graph") else session.t("Dependency Graph")) + addProperty("empty", error?.let { session.t("mcpp: {0}", (it.cause ?: it).message ?: "") } ?: session.t("Nothing to draw yet: build the project first.")) + addProperty("truncated", session.t("Only the first {0} nodes are drawn.")) + addProperty("dark", !JBColor.isBright()) + } + ApplicationManager.getApplication().invokeLater { + if (loaded) render(page.toString()) else pending = page.toString() + } + } + } + + private fun render(page: String) { + val b = browser ?: return + b.cefBrowser.executeJavaScript("window.mcppRender && window.mcppRender($page);", b.cefBrowser.url, 0) + } + + /** `{"open": ""}`: the module's source in an editor. */ + private fun open(text: String) { + val source = runCatching { JsonParser.parseString(text).asJsonObject["open"]?.asString }.getOrNull() ?: return + val root = session.root ?: return + val path = File(source).let { if (it.isAbsolute) it else File(root, source) }.path + ApplicationManager.getApplication().invokeLater { + LocalFileSystem.getInstance().refreshAndFindFileByPath(path)?.let { OpenFileDescriptor(project, it).navigate(true) } + } + } + + override fun dispose() {} +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/LibraryView.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/LibraryView.kt new file mode 100644 index 0000000..d8d8722 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/LibraryView.kt @@ -0,0 +1,190 @@ +// The libraries: the packages of mcpp's indexes (`library/search`, a keyword and an index), and the +// page of the one selected -- its description, versions, licences and repository -- with the action +// that adds it to the project (`library/add`, `mcpp add` as a job). +package io.github.mcppcommunity.mcppclion.ui + +import com.google.gson.JsonObject +import com.intellij.icons.AllIcons +import com.intellij.ide.BrowserUtil +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.ComboBox +import com.intellij.openapi.ui.popup.JBPopupFactory +import com.intellij.openapi.ui.popup.PopupStep +import com.intellij.openapi.ui.popup.util.BaseListPopupStep +import com.intellij.openapi.util.Disposer +import com.intellij.ui.ColoredListCellRenderer +import com.intellij.ui.JBSplitter +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SearchTextField +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.components.JBList +import com.intellij.util.Alarm +import com.intellij.util.ui.HTMLEditorKitBuilder +import com.intellij.util.ui.JBUI +import io.github.mcppcommunity.mcppclion.McppSession +import java.awt.BorderLayout +import java.awt.FlowLayout +import javax.swing.DefaultComboBoxModel +import javax.swing.DefaultListModel +import javax.swing.JButton +import javax.swing.JComponent +import javax.swing.JEditorPane +import javax.swing.JList +import javax.swing.JPanel +import javax.swing.event.DocumentEvent +import javax.swing.event.HyperlinkEvent + +/** A package's page as HTML. Pure: the tests read it. */ +object LibraryPage { + private fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">") + + fun html(p: JsonObject, t: (String) -> String): String { + val versions = p.getAsJsonArray("versions")?.map { it.asString } ?: emptyList() + val licenses = p.getAsJsonArray("licenses")?.joinToString(", ") { it.asString } ?: "" + val repo = p["repo"]?.takeIf { it.isJsonPrimitive }?.asString ?: "" + return buildString { + append("") + append("

${esc(p["id"]?.asString ?: "")}

") + p["description"]?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.takeIf { it.isNotEmpty() }?.let { append("

${esc(it)}

") } + append("
") + fun row(k: String, v: String) { if (v.isNotEmpty()) append("") } + row("Index", esc(p["index"]?.asString ?: "")) + row("In mcpp.toml", "${esc(p["dependencyName"]?.asString ?: "")}") + row("Latest", esc(p["latest"]?.takeIf { it.isJsonPrimitive }?.asString ?: "")) + row("Versions", esc(versions.joinToString(", "))) + row("License", esc(licenses)) + if (repo.isNotEmpty()) row("Repository", "${esc(repo)}") + append("
${esc(t(k))}$v
") + } + } +} + +class LibraryView(private val project: Project, parent: Disposable) : McppView, Disposable { + override val id = "libraries" + private val session = McppSession.get(project) + private val search = SearchTextField(true) + private val index = ComboBox() + private val packages = DefaultListModel() + private val list = JBList(packages) + private val page = JEditorPane().apply { + isEditable = false + editorKit = HTMLEditorKitBuilder.simple() + addHyperlinkListener { e -> if (e.eventType == HyperlinkEvent.EventType.ACTIVATED) e.url?.let { BrowserUtil.browse(it) } } + } + private val add = JButton(session.t("Add to the project"), AllIcons.General.Add) + private val status = com.intellij.ui.components.JBLabel() + private val alarm = Alarm(Alarm.ThreadToUse.SWING_THREAD, this) + private var wanted: String? = null + private val panel = JPanel(BorderLayout()) + val component: JComponent get() = panel + + init { + Disposer.register(parent, this) + index.model = DefaultComboBoxModel(arrayOf("")) + index.renderer = com.intellij.ui.SimpleListCellRenderer.create { label, value, _ -> + label.text = (value ?: "").ifEmpty { session.t("All indexes") } + } + list.cellRenderer = object : ColoredListCellRenderer() { + override fun customizeCellRenderer(list: JList, value: JsonObject?, index: Int, selected: Boolean, hasFocus: Boolean) { + value ?: return + icon = AllIcons.Nodes.PpLib + append(value["id"]?.asString ?: "") + value["latest"]?.takeIf { it.isJsonPrimitive }?.let { append(" ${it.asString}", SimpleTextAttributes.GRAYED_ATTRIBUTES) } + value["description"]?.takeIf { it.isJsonPrimitive }?.asString?.trim()?.takeIf { it.isNotEmpty() }?.let { + append(" — " + it.take(100), SimpleTextAttributes.GRAYED_SMALL_ATTRIBUTES) + } + } + } + list.addListSelectionListener { if (!it.valueIsAdjusting) show(list.selectedValue) } + search.addDocumentListener(object : com.intellij.ui.DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + alarm.cancelAllRequests() + alarm.addRequest({ refresh() }, 300) + } + }) + index.addActionListener { refresh() } + add.isEnabled = false + add.addActionListener { list.selectedValue?.let { addToProject(it) } } + val top = JPanel(BorderLayout(JBUI.scale(4), 0)).apply { + border = JBUI.Borders.empty(4) + add(search, BorderLayout.CENTER) + add(index, BorderLayout.EAST) + } + val detail = JPanel(BorderLayout()).apply { + add(ScrollPaneFactory.createScrollPane(page), BorderLayout.CENTER) + add(JPanel(FlowLayout(FlowLayout.LEFT)).apply { add(add) }, BorderLayout.SOUTH) + } + val splitter = JBSplitter(true, 0.55f).apply { + firstComponent = ScrollPaneFactory.createScrollPane(list) + secondComponent = detail + } + panel.add(top, BorderLayout.NORTH) + panel.add(splitter, BorderLayout.CENTER) + panel.add(status.apply { border = JBUI.Borders.empty(2, 6) }, BorderLayout.SOUTH) + } + + override fun focus(params: JsonObject) { + params["id"]?.asString?.let { id -> + wanted = id + search.text = id.substringAfter(':') + } + refresh() + } + + override fun refresh() { + if (!session.running) return + val params = JsonObject().apply { + addProperty("keyword", search.text.trim()) + (index.selectedItem as? String)?.takeIf { it.isNotEmpty() }?.let { addProperty("index", it) } + addProperty("limit", 500) + session.root?.let { addProperty("root", it) } + } + status.text = session.t("Searching…") + session.request("library/search", params).whenComplete { r, error -> + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + if (error != null) { status.text = session.t("mcpp: {0}", (error.cause ?: error).message ?: ""); return@invokeLater } + val o = r.asJsonObject + val selected = wanted ?: list.selectedValue?.get("id")?.asString + packages.clear() + o.getAsJsonArray("packages").forEach { packages.addElement(it.asJsonObject) } + val known = (index.model as DefaultComboBoxModel) + if (known.size <= 1) o.getAsJsonArray("indexes")?.forEach { known.addElement(it.asString) } + status.text = session.t("{0} packages", o["total"]?.asString ?: packages.size().toString()) + val at = (0 until packages.size()).firstOrNull { packages[it]["id"]?.asString == selected } + if (at != null) { list.selectedIndex = at; list.ensureIndexIsVisible(at) } else show(null) + wanted = null + } + } + } + + private fun show(p: JsonObject?) { + add.isEnabled = p != null + page.text = p?.let { LibraryPage.html(it) { key -> session.t(key) } } ?: "" + page.caretPosition = 0 + } + + /** `mcpp add [@version]`: the version chosen among the package's, the latest first. */ + private fun addToProject(p: JsonObject) { + val root = session.root ?: return + val name = p["dependencyName"]?.asString ?: return + val versions = listOf("") + (p.getAsJsonArray("versions")?.map { it.asString } ?: emptyList()) + val step = object : BaseListPopupStep(session.t("Add {0}", name), versions) { + override fun getTextFor(value: String): String = value.ifEmpty { session.t("The latest version") } + override fun onChosen(value: String, finalChoice: Boolean): PopupStep<*>? { + // A job: its end is said, and the project view reads the manifest again. + io.github.mcppcommunity.mcppclion.actions.Dispatch.job(project, "library/add", JsonObject().apply { + addProperty("root", root) + addProperty("dependency", name) + if (value.isNotEmpty()) addProperty("version", value) + }) + return FINAL_CHOICE + } + } + JBPopupFactory.getInstance().createListPopup(step).showUnderneathOf(add) + } + + override fun dispose() {} +} 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 new file mode 100644 index 0000000..afc1cad --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt @@ -0,0 +1,109 @@ +// The "mcpp" tool window in CLion's own components (mcpp design 2026-10-30 part 2 §9): the project, +// the libraries and the cache as native trees and lists over the backend's view models, and a graph +// (a project's packages or modules) as a page of its own in JCEF. VS Code's information structure, +// CLion's components; the strings are the backend's (`i18n/bundle`), the ones VS Code shows. +package io.github.mcppcommunity.mcppclion.ui + +import com.google.gson.JsonObject +import com.intellij.openapi.options.ShowSettingsUtil +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory +import com.intellij.openapi.wm.ToolWindowManager +import com.intellij.ui.content.Content +import com.intellij.ui.content.ContentFactory +import io.github.mcppcommunity.mcppclion.McppSession +import io.github.mcppcommunity.mcppclion.actions.Dispatch +import io.github.mcppcommunity.mcppclion.settings.McppConfigurable +import java.io.File + +/** A view of the tool window: what it is called, and how it is asked to show something. */ +interface McppView { + val id: String + fun refresh() + /** Shows what `params` names (a package of the libraries); the view itself when nothing. */ + fun focus(params: JsonObject) {} +} + +object McppPanels { + const val TOOL_WINDOW = "mcpp" + private val VIEW = Key.create("mcpp.view") + + /** The command table's `panel` actions: a view brought on screen, a graph opened, settings shown. */ + fun show(project: Project, panel: String, params: JsonObject = JsonObject()) { + when (panel) { + "targets" -> { Dispatch.selectTarget(project); return } + "pack" -> { Dispatch.pack(project); return } + "settings" -> { ShowSettingsUtil.getInstance().showSettingsDialog(project, McppConfigurable::class.java); return } + } + val window = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW) ?: return + window.activate { + when (panel) { + "graph" -> openGraph(project, window, params["graph"]?.asString ?: "packages") + "project" -> select(window, "project", params) + "cache" -> select(window, "cache", params) + "libraries", "library" -> select(window, "libraries", params) + } + } + } + + private fun select(window: ToolWindow, id: String, params: JsonObject) { + val content = window.contentManager.contents.firstOrNull { it.getUserData(VIEW)?.id == id } ?: return + window.contentManager.setSelectedContent(content, true) + content.getUserData(VIEW)?.focus(params) + } + + /** A graph is a tab of its own, closeable; one per kind, refreshed when it is asked again. */ + private fun openGraph(project: Project, window: ToolWindow, kind: String) { + val id = "graph:$kind" + window.contentManager.contents.firstOrNull { it.getUserData(VIEW)?.id == id }?.let { + window.contentManager.setSelectedContent(it, true) + it.getUserData(VIEW)?.refresh() + return + } + val session = McppSession.get(project) + val page = GraphPage(project, kind) + val title = if (kind == "modules") session.t("Module Graph") else session.t("Dependency Graph") + val content = ContentFactory.getInstance().createContent(page.component, title, false) + content.isCloseable = true + content.putUserData(VIEW, page) + Disposer.register(content, page) + window.contentManager.addContent(content) + window.contentManager.setSelectedContent(content, true) + page.refresh() + } + + class Factory : ToolWindowFactory, DumbAware { + override suspend fun isApplicableAsync(project: Project): Boolean = project.basePath?.let { File(it, "mcpp.toml").isFile } ?: false + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + val session = McppSession.get(project) + fun add(view: McppView, component: javax.swing.JComponent, title: String): Content { + val content = ContentFactory.getInstance().createContent(component, title, false) + content.isCloseable = false + content.putUserData(VIEW, view) + toolWindow.contentManager.addContent(content) + return content + } + val projectView = ProjectView(project, toolWindow.disposable) + val libraryView = LibraryView(project, toolWindow.disposable) + val cacheView = CacheView(project, toolWindow.disposable) + add(projectView, projectView.component, session.t("Project")) + add(libraryView, libraryView.component, session.t("Libraries")) + add(cacheView, cacheView.component, session.t("Cache")) + // The strings and the data arrive with the backend. + session.whenStarted { + com.intellij.openapi.application.ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + val titles = mapOf("project" to "Project", "libraries" to "Libraries", "cache" to "Cache") + for (c in toolWindow.contentManager.contents) c.getUserData(VIEW)?.id?.let { id -> titles[id]?.let { c.displayName = session.t(it) } } + projectView.refresh() + } + } + projectView.refresh() + } + } +} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/ProjectView.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/ProjectView.kt new file mode 100644 index 0000000..f4f3fa0 --- /dev/null +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/ProjectView.kt @@ -0,0 +1,201 @@ +// The project view: each mcpp project of the window, its mcpp, its targets, tests and libraries, its +// last build and what it produced, and what C++ Modules says about it -- a tree over the backend's +// `projects/list`, `project/summary`, `project/mcpp` and `languageService/status`. +package io.github.mcppcommunity.mcppclion.ui + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.intellij.icons.AllIcons +import com.intellij.openapi.Disposable +import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.DefaultActionGroup +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.project.DumbAwareAction +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.SimpleToolWindowPanel +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.LocalFileSystem +import com.intellij.ui.ColoredTreeCellRenderer +import com.intellij.ui.DoubleClickListener +import com.intellij.ui.ScrollPaneFactory +import com.intellij.ui.SimpleTextAttributes +import com.intellij.ui.treeStructure.Tree +import com.intellij.util.ui.tree.TreeUtil +import io.github.mcppcommunity.mcppclion.McppSession +import io.github.mcppcommunity.mcppclion.actions.Dispatch +import java.awt.event.MouseEvent +import java.util.concurrent.CompletableFuture +import javax.swing.Icon +import javax.swing.JComponent +import javax.swing.JTree +import javax.swing.tree.DefaultMutableTreeNode +import javax.swing.tree.DefaultTreeModel + +/** A row of a tree: its text, a quieter detail, an icon, and a file it opens or a command it runs. */ +data class Row(val text: String, val detail: String = "", val icon: Icon? = null, val file: String? = null, + val command: String? = null, val root: String? = null, val error: Boolean = false) + +/** A row with its children. */ +class RowNode(row: Row, children: List = emptyList()) : DefaultMutableTreeNode(row) { + init { children.forEach { add(it) } } + val row: Row get() = userObject as Row +} + +/** What the project view shows for one project, from the backend's answers. Pure: the tests build it. */ +object ProjectModel { + fun project(p: JsonObject, summary: JsonObject?, mcpp: JsonObject?, t: (String, Array) -> String): RowNode { + fun tr(key: String, vararg a: Any?) = t(key, a) + val root = p["root"].asString + val children = mutableListOf() + mcpp?.let { m -> + val state = m["state"]?.asString ?: "not-probed" + val text = if (state == "ok") "mcpp ${m["version"]?.asString ?: ""}" else "mcpp: ${m["message"]?.asString ?: state}" + children += RowNode(Row(text, m["program"]?.asString ?: "", AllIcons.Nodes.Plugin, error = state != "ok")) + } + summary?.let { s -> + s["error"]?.takeIf { it.isJsonPrimitive }?.let { children += RowNode(Row(tr("The build database could not be read: {0}", it.asString), icon = AllIcons.General.Warning, error = true)) } + fun group(key: String, title: String, icon: Icon, command: String?) { + val rows = s[key]?.takeIf { it.isJsonArray }?.asJsonArray ?: return + if (rows.isEmpty) return + children += RowNode(Row(title, rows.size().toString(), AllIcons.Nodes.Folder), rows.map { r -> + val o = r.asJsonObject + RowNode(Row(o["name"].asString, o["configuration"]?.asString ?: "", icon, command = command, root = root)) + }) + } + group("targets", tr("Targets"), AllIcons.RunConfigurations.Application, "mcpp.run") + group("tests", tr("Tests"), AllIcons.RunConfigurations.Junit, "mcpp.test") + group("libraries", tr("Libraries"), AllIcons.Nodes.PpLib, null) + s["lastBuild"]?.takeIf { it.isJsonObject }?.asJsonObject?.let { b -> + val status = b["status"]?.asString ?: "" + children += RowNode(Row(tr("Last build: {0}", status), b["command"]?.asString ?: "", + if (status == "ok") AllIcons.RunConfigurations.TestPassed else AllIcons.RunConfigurations.TestFailed, error = status != "ok")) + } + s["artifacts"]?.takeIf { it.isJsonArray }?.asJsonArray?.takeIf { !it.isEmpty }?.let { artifacts -> + children += RowNode(Row(tr("Artifacts"), artifacts.size().toString(), AllIcons.Nodes.Artifact), artifacts.mapNotNull { a -> + val path = when { + a.isJsonPrimitive -> a.asString + a.isJsonObject -> a.asJsonObject["path"]?.asString + else -> null + } ?: return@mapNotNull null + RowNode(Row(path.substringAfterLast('/'), path, AllIcons.FileTypes.Any_type, file = path)) + }) + } + s["platformText"]?.takeIf { it.isJsonPrimitive && it.asString.isNotEmpty() }?.let { + children += RowNode(Row(tr("Platform: {0}", it.asString), icon = AllIcons.Nodes.Module)) + } + } + return RowNode(Row(p["name"]?.asString ?: root.substringAfterLast('/'), root, AllIcons.Nodes.Module, file = "$root/mcpp.toml"), children) + } + + fun languageService(ls: JsonObject?, t: (String, Array) -> String): RowNode? { + ls ?: return null + val ok = ls["state"]?.asString == "ok" + val workspace = ls["workspace"]?.takeIf { it.isJsonObject }?.asJsonObject + val state = workspace?.get("state")?.asString + val text = t("C++ Modules", emptyArray()) + (state?.let { ": $it" } ?: "") + return RowNode(Row(text, ls["message"]?.asString ?: "", AllIcons.Nodes.Plugin, error = !ok)) + } +} + +class ProjectView(private val project: Project, parent: Disposable) : McppView, Disposable { + override val id = "project" + private val session = McppSession.get(project) + private val tree = Tree(DefaultTreeModel(DefaultMutableTreeNode())) + private val panel = SimpleToolWindowPanel(true, true) + val component: JComponent get() = panel + private var unsubscribe: List<() -> Unit> = emptyList() + + init { + Disposer.register(parent, this) + tree.isRootVisible = false + tree.showsRootHandles = true + tree.cellRenderer = RowRenderer() + object : DoubleClickListener() { + override fun onDoubleClick(event: MouseEvent): Boolean { + val row = (tree.lastSelectedPathComponent as? RowNode)?.row ?: return false + open(row) + return true + } + }.installOn(tree) + val group = DefaultActionGroup().apply { + add(action("Refresh", AllIcons.Actions.Refresh) { refresh() }) + add(action("Build", AllIcons.Actions.Compile) { Dispatch.run(project, "mcpp.build", JsonObject()) }) + add(action("Test", AllIcons.RunConfigurations.Junit) { Dispatch.run(project, "mcpp.test", JsonObject()) }) + addSeparator() + add(action("Dependency Graph", AllIcons.Graph.Layout) { McppPanels.show(project, "graph", JsonObject().apply { addProperty("graph", "packages") }) }) + add(action("Module Graph", AllIcons.Nodes.Module) { McppPanels.show(project, "graph", JsonObject().apply { addProperty("graph", "modules") }) }) + } + val toolbar = ActionManager.getInstance().createActionToolbar("McppProjectView", group, true) + toolbar.targetComponent = panel + panel.toolbar = toolbar.component + panel.setContent(ScrollPaneFactory.createScrollPane(tree)) + // What changes the tree: a build ended, a manifest or a result record changed, mcppls answered. + unsubscribe = listOf("job/finished", "result/changed", "manifest/changed", "languageService/changed", "project/mcppChanged") + .map { m -> session.on(m) { refresh() } } + } + + private fun action(text: String, icon: Icon, run: () -> Unit) = object : DumbAwareAction(session.t(text), null, icon) { + override fun actionPerformed(e: AnActionEvent) = run() + } + + private fun open(row: Row) { + row.file?.let { path -> + LocalFileSystem.getInstance().refreshAndFindFileByPath(path)?.let { OpenFileDescriptor(project, it).navigate(true) } + return + } + val command = row.command ?: return + // A target runs as its program (`bin`), a test as the one the filter names. + Dispatch.run(project, command, JsonObject().apply { + row.root?.let { addProperty("root", it) } + addProperty(if (command == "mcpp.run") "bin" else "filter", row.text) + }) + } + + override fun refresh() { + if (!session.running) return + session.request("projects/list").thenCompose { list -> + val projects = list.takeIf { it.isJsonArray }?.asJsonArray?.map { it.asJsonObject } ?: emptyList() + val asked = projects.map { p -> + val root = JsonObject().apply { addProperty("root", p["root"].asString) } + val summary = session.request("project/summary", root).handle { v, _ -> v } + val mcpp = session.request("project/mcpp", root).handle { v, _ -> v } + summary.thenCombine(mcpp) { s, m -> Triple(p, s?.takeIf { it.isJsonObject }?.asJsonObject, m?.takeIf { it.isJsonObject }?.asJsonObject) } + } + CompletableFuture.allOf(*asked.toTypedArray()).thenCombine(session.languageServiceStatus().handle { v, _ -> v }) { _, ls -> + asked.map { it.join() } to ls?.takeIf { it.isJsonObject }?.asJsonObject + } + }.thenAccept { (rows, ls) -> + val t: (String, Array) -> String = { key, args -> session.t(key, *args) } + val root = DefaultMutableTreeNode() + for ((p, s, m) in rows) root.add(ProjectModel.project(p, s, m, t)) + ProjectModel.languageService(ls, t)?.let { root.add(it) } + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + val state = TreeUtil.collectExpandedPaths(tree) + tree.model = DefaultTreeModel(root) + if (state.isEmpty()) TreeUtil.expand(tree, 2) else TreeUtil.restoreExpandedPaths(tree, state) + } + } + } + + override fun dispose() { + unsubscribe.forEach { it() } + } +} + +/** A row's text, then its detail in grey; an error in the error colour. */ +class RowRenderer : ColoredTreeCellRenderer() { + override fun customizeCellRenderer(tree: JTree, value: Any?, selected: Boolean, expanded: Boolean, leaf: Boolean, row: Int, hasFocus: Boolean) { + val r = (value as? RowNode)?.row ?: return + icon = r.icon + append(r.text, if (r.error) SimpleTextAttributes.ERROR_ATTRIBUTES else SimpleTextAttributes.REGULAR_ATTRIBUTES) + if (r.detail.isNotEmpty()) append(" " + r.detail, SimpleTextAttributes.GRAYED_ATTRIBUTES) + } +} + +/** The values of a JSON array as objects. */ +internal fun JsonElement?.objects(): List = + (this as? JsonArray)?.mapNotNull { it.takeIf { e -> e.isJsonObject }?.asJsonObject } ?: emptyList() diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt deleted file mode 100644 index 22eee42..0000000 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt +++ /dev/null @@ -1,64 +0,0 @@ -// The panels' places in the IDE: the "mcpp" tool window holds the project, libraries, cache and -// targets panels; any other panel (a graph, pack, settings, a package's page) opens there as a tab -// of its own, which a second request for the same panel selects again. -package io.github.mcppcommunity.mcppclion.webui - -import com.google.gson.JsonObject -import com.intellij.openapi.project.DumbAware -import com.intellij.openapi.project.Project -import com.intellij.openapi.wm.ToolWindow -import com.intellij.openapi.wm.ToolWindowFactory -import com.intellij.openapi.wm.ToolWindowManager -import com.intellij.ui.content.ContentFactory -import io.github.mcppcommunity.mcppclion.McppSession -import java.io.File - -object McppPanels { - const val TOOL_WINDOW = "mcpp" - private val defaults = listOf("project", "libraries", "cache", "targets") - - fun title(session: McppSession, panel: String, params: JsonObject): String = when (panel) { - "project" -> session.t("Project") - "libraries", "library" -> params["id"]?.asString ?: session.t("Libraries") - "cache" -> session.t("Cache") - "targets" -> session.t("Targets and Platforms") - "settings" -> session.t("Settings") - "pack" -> session.t("Pack") - "graph" -> if (params["graph"]?.asString == "modules") session.t("Module graph") else session.t("Dependency graph") - else -> panel - } - - private fun key(panel: String, params: JsonObject) = "$panel:${params["graph"]?.asString ?: ""}:${params["id"]?.asString ?: ""}" - - fun add(project: Project, window: ToolWindow, panel: String, params: JsonObject, closeable: Boolean) { - val session = McppSession.get(project) - val content = ContentFactory.getInstance().createContent(null, title(session, panel, params), false) - val view = WebuiPanel(project, if (panel == "library") "libraries" else panel, params, content) - content.component = view.component - content.isCloseable = closeable - content.putUserData(KEY, key(panel, params)) - window.contentManager.addContent(content) - } - - fun show(project: Project, panel: String, params: JsonObject = JsonObject()) { - val window = ToolWindowManager.getInstance(project).getToolWindow(TOOL_WINDOW) ?: return - if (!params.has("root")) McppSession.get(project).root?.let { params.addProperty("root", it) } - val wanted = key(panel, params) - val existing = window.contentManager.contents.firstOrNull { it.getUserData(KEY) == wanted } - if (existing == null) add(project, window, panel, params, closeable = panel !in defaults) - val content = window.contentManager.contents.first { it.getUserData(KEY) == wanted } - window.contentManager.setSelectedContent(content) - window.activate(null) - } - - private val KEY = com.intellij.openapi.util.Key.create("mcpp.panel") - - class Factory : ToolWindowFactory, DumbAware { - override suspend fun isApplicableAsync(project: Project): Boolean = project.basePath?.let { File(it, "mcpp.toml").isFile } ?: false - - override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { - val params = JsonObject().apply { McppSession.get(project).root?.let { addProperty("root", it) } } - for (panel in defaults) add(project, toolWindow, panel, params.deepCopy(), closeable = false) - } - } -} diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt deleted file mode 100644 index b2b7061..0000000 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt +++ /dev/null @@ -1,182 +0,0 @@ -// The shared panels (webui/) in a JCEF browser. The page speaks the backend's protocol through -// this host -- `window.mcppHostSend` (a JBCefJSQuery) out, `window.mcppHostReceive` in -- and asks -// it for what only the IDE does: open a file, confirm, store a setting, run a command. -package io.github.mcppcommunity.mcppclion.webui - -import com.google.gson.Gson -import com.google.gson.JsonElement -import com.google.gson.JsonNull -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.intellij.ide.BrowserUtil -import com.intellij.ide.actions.RevealFileAction -import com.intellij.notification.NotificationType -import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.PathManager -import com.intellij.openapi.fileEditor.OpenFileDescriptor -import com.intellij.openapi.ide.CopyPasteManager -import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.util.Disposer -import com.intellij.openapi.vfs.LocalFileSystem -import com.intellij.ui.JBColor -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.UIUtil -import io.github.mcppcommunity.mcppclion.McppSession -import io.github.mcppcommunity.mcppclion.actions.Dispatch -import io.github.mcppcommunity.mcppclion.settings.McppSettings -import org.cef.browser.CefBrowser -import org.cef.browser.CefFrame -import org.cef.handler.CefLoadHandlerAdapter -import java.awt.Color -import java.awt.datatransfer.StringSelection -import java.io.File -import javax.swing.JComponent -import javax.swing.JLabel -import javax.swing.SwingConstants - -class WebuiPanel(private val project: Project, private val panel: String, private val params: JsonObject, parent: Disposable) : Disposable { - private val session = McppSession.get(project) - private val browser: JBCefBrowser? = if (JBCefApp.isSupported()) JBCefBrowser() else null - private val query: JBCefJSQuery? = browser?.let { JBCefJSQuery.create(it as JBCefBrowserBase) } - private val unsubscribe: () -> Unit - - val component: JComponent = browser?.component ?: JLabel(session.t("This IDE runs without JCEF; the mcpp panels need it."), SwingConstants.CENTER) - - init { - Disposer.register(parent, this) - unsubscribe = session.on("*") { message -> post(JsonObject().apply { addProperty("type", "notify"); add("method", message.asJsonObject["method"]); add("params", message.asJsonObject["params"]) }) } - if (browser != null && query != null) { - query.addHandler { text -> receive(text); null } - browser.jbCefClient.addLoadHandler(object : CefLoadHandlerAdapter() { - override fun onLoadEnd(b: CefBrowser, frame: CefFrame, status: Int) { - if (!frame.isMain) return - b.executeJavaScript("window.mcppHostSend = function (text) { ${query.inject("text")} }; window.mcppHostAttached && window.mcppHostAttached();", b.url, 0) - } - }, browser.cefBrowser) - browser.loadURL(page().toURI().toString()) - } - } - - // The page and its scripts, written once per plugin version where the browser can load them. - private fun page(): File { - // One directory per build of the plugin: its jar's name and time identify it. - val jar = runCatching { File(WebuiPanel::class.java.protectionDomain.codeSource.location.toURI()) }.getOrNull() - val version = jar?.takeIf { it.isFile }?.let { "${it.nameWithoutExtension}-${it.lastModified()}" } ?: "dev" - val dir = File(PathManager.getSystemPath(), "mcpp-clion/webui-$version") - val index = javaClass.getResource("/webui/index.html")?.readText() ?: error("webui/index.html is not in the plugin") - val files = Regex("\\{\\{base}}([\\w./-]+)").findAll(index).map { it.groupValues[1] }.toSet() - for (f in files) { - val target = File(dir, f) - if (target.isFile && version != "dev") continue - target.parentFile.mkdirs() - javaClass.getResourceAsStream("/webui/$f")?.use { input -> target.outputStream().use { input.copyTo(it) } } - } - val html = File(dir, "panel.html") - html.writeText(index.replace("{{base}}", "").replace("{{csp}}", "default-src 'self' 'unsafe-inline' file:") - .replace("{{nonce}}", "").replace("{{lang}}", com.intellij.DynamicBundle.getLocale().language)) - return html - } - - private fun css(c: Color) = "rgb(${c.red}, ${c.green}, ${c.blue})" - - private fun theme(): JsonObject = JsonObject().apply { - addProperty("--mcpp-fg", css(UIUtil.getLabelForeground())) - addProperty("--mcpp-bg", css(UIUtil.getPanelBackground())) - addProperty("--mcpp-muted", css(UIUtil.getContextHelpForeground())) - addProperty("--mcpp-border", css(JBColor.border())) - addProperty("--mcpp-card", css(UIUtil.getListBackground())) - addProperty("--mcpp-input", css(UIUtil.getTextFieldBackground())) - addProperty("--mcpp-accent", css(JBColor.namedColor("Button.default.startBackground", JBColor(0x3574F0, 0x3574F0)))) - addProperty("--mcpp-secondary", css(JBColor.namedColor("Button.startBackground", JBColor(0xEBECF0, 0x3C3F41)))) - addProperty("--mcpp-secondary-fg", css(UIUtil.getLabelForeground())) - addProperty("--mcpp-font", "\"${UIUtil.getLabelFont().family}\", system-ui, sans-serif") - addProperty("--mcpp-font-size", "${UIUtil.getLabelFont().size}px") - } - - private fun post(message: JsonObject) { - val b = browser ?: return - val js = "window.mcppHostReceive && window.mcppHostReceive(${message});" - ApplicationManager.getApplication().invokeLater { b.cefBrowser.executeJavaScript(js, b.cefBrowser.url, 0) } - } - - private fun receive(text: String) { - val m = runCatching { JsonParser.parseString(text).asJsonObject }.getOrNull() ?: return - when (m["type"]?.asString) { - "ready" -> post(JsonObject().apply { - addProperty("type", "init"); addProperty("panel", panel); add("params", params) - add("bundle", Gson().toJsonTree(session.strings)); add("theme", theme()); addProperty("editor", "jetbrains") - }) - "rpc" -> session.request(m["method"].asString, m["params"] ?: JsonObject()).whenComplete { result, error -> - if (error != null) post(JsonObject().apply { - addProperty("type", "rpcError"); add("id", m["id"]) - add("error", JsonObject().apply { addProperty("message", (error.cause ?: error).message ?: "failed") }) - }) - else post(JsonObject().apply { addProperty("type", "rpcResult"); add("id", m["id"]); add("result", result) }) - } - "host" -> ApplicationManager.getApplication().invokeLater { - val result = runCatching { host(m["action"].asString, m["params"]?.asJsonObject ?: JsonObject()) } - post(JsonObject().apply { - add("id", m["id"]) - result.onSuccess { addProperty("type", "hostResult"); add("result", it) } - result.onFailure { e -> addProperty("type", "hostError"); add("error", JsonObject().apply { addProperty("message", e.message ?: "failed") }) } - }) - } - } - } - - private fun host(action: String, p: JsonObject): JsonElement { - val settings = McppSettings.get() - return when (action) { - "settings" -> settings.values() - "setSetting" -> { settings.set(p["key"].asString, p["value"]); JsonParser.parseString("true") } - "resetSettings" -> { p.getAsJsonArray("keys").forEach { settings.set(it.asString, null) }; JsonParser.parseString("true") } - "migrateSettings" -> { - p.getAsJsonArray("renames").forEach { r -> - val o = r.asJsonObject - settings.get(o["from"].asString)?.let { v -> settings.set(o["to"].asString, v) } - } - JsonParser.parseString("true") - } - "openFile" -> { - val file = LocalFileSystem.getInstance().refreshAndFindFileByPath(p["path"].asString) - if (file != null) OpenFileDescriptor(project, file, maxOf(0, (p["line"]?.asInt ?: 1) - 1), 0).navigate(true) - JsonParser.parseString((file != null).toString()) - } - "openExternal" -> { BrowserUtil.browse(p["url"].asString); JsonNull.INSTANCE } - "reveal" -> { RevealFileAction.openFile(File(p["path"].asString)); JsonNull.INSTANCE } - "copy" -> { CopyPasteManager.getInstance().setContents(StringSelection(p["text"].asString)); JsonNull.INSTANCE } - "notify" -> { - val type = when (p["level"]?.asString) { "error" -> NotificationType.ERROR; "warning" -> NotificationType.WARNING; else -> NotificationType.INFORMATION } - session.notify(p["message"].asString, type); JsonNull.INSTANCE - } - "openPanel" -> { - val rest = p.deepCopy().apply { remove("panel") } - McppPanels.show(project, p["panel"].asString, rest); JsonNull.INSTANCE - } - "runCommand" -> { Dispatch.run(project, p["command"].asString, p); JsonNull.INSTANCE } - "confirm" -> { - val detail = listOfNotNull(p["detail"]?.asString, p["preview"]?.asString?.takeIf { it.isNotBlank() }).joinToString("\n\n") - val yes = Messages.showOkCancelDialog(project, detail, p["title"].asString, session.t("Continue"), Messages.getCancelButton(), Messages.getWarningIcon()) == Messages.OK - var acknowledged = false - if (yes && p["acknowledge"]?.asBoolean == true) { - val ack = session.t("I understand this affects every mcpp project on this machine") - acknowledged = Messages.showOkCancelDialog(project, ack, p["title"].asString, ack, Messages.getCancelButton(), Messages.getWarningIcon()) == Messages.OK - } - JsonObject().apply { addProperty("confirmed", yes && (acknowledged || p["acknowledge"]?.asBoolean != true)); addProperty("acknowledged", acknowledged) } - } - "languageService" -> { Dispatch.languageServiceAction(project, p["action"]?.asString ?: "install"); JsonNull.INSTANCE } - else -> error("unknown host action $action") - } - } - - override fun dispose() { - unsubscribe() - query?.let { Disposer.dispose(it) } - browser?.let { Disposer.dispose(it) } - } -} 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 a5ae59b..8a73fde 100644 --- a/mide-plugins/clion/src/main/resources/META-INF/plugin.xml +++ b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml @@ -21,8 +21,9 @@ com.intellij.modules.clion com.intellij.modules.cidr.debugger io.github.sunrisepeak.mcppls - + com.intellij.modules.jcef intellij.testRunner.plugin @@ -36,7 +37,7 @@ + factoryClass="io.github.mcppcommunity.mcppclion.ui.McppPanels$Factory"/> @@ -45,5 +46,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg b/mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg index 132191f..7f95b88 100644 --- a/mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg +++ b/mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg @@ -1 +1 @@ -m++ + diff --git a/mide-plugins/clion/src/main/resources/icons/mcpp.svg b/mide-plugins/clion/src/main/resources/icons/mcpp.svg index 5366045..df879d9 100644 --- a/mide-plugins/clion/src/main/resources/icons/mcpp.svg +++ b/mide-plugins/clion/src/main/resources/icons/mcpp.svg @@ -1 +1 @@ -m+ + diff --git a/mide-plugins/clion/src/main/resources/icons/mcpp_dark.svg b/mide-plugins/clion/src/main/resources/icons/mcpp_dark.svg deleted file mode 100644 index 5366045..0000000 --- a/mide-plugins/clion/src/main/resources/icons/mcpp_dark.svg +++ /dev/null @@ -1 +0,0 @@ -m+ 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 416acdf..c3ccd60 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 @@ -1,6 +1,7 @@ // The wire and the backend, without an IDE: frames reassemble whatever the chunking; a session over -// the mock mcpp finds the project, runs a build to `ok`, and lists the commands this editor offers -// (no forward to mcppls's VS Code commands among them). The session test runs when the build passes +// the mock mcpp (protocol 2, an LSP server) finds the project, publishes mcpp.toml's diagnostics and +// completes it, runs a build to `ok`, and lists the commands this editor offers +// (a forward to one of mcppls's actions only under the JetBrains id mcppls states). The session test runs when the build passes // the backend and the mock mcpp (MCPP_IDE_BACKEND, MCPP_IDE_MOCK_MCPP). package io.github.mcppcommunity.mcppclion @@ -78,11 +79,32 @@ class BackendTest { File(root, "mcpp.toml").writeText("[package]\nname = \"app\"\nversion = \"0.1.0\"\n") Backend(listOf(backend!!, "serve")).use { b -> val init = b.request("initialize", JsonObject().apply { - addProperty("editor", "jetbrains") - add("settings", JsonObject().apply { addProperty("mcpp.path", mock) }) - add("workspaceFolders", JsonArray().apply { add(root.path) }) + add("workspaceFolders", JsonArray().apply { add(JsonObject().apply { addProperty("uri", uriOf(root.path)) }) }) + add("initializationOptions", JsonObject().apply { + addProperty("editor", "jetbrains") + add("settings", JsonObject().apply { addProperty("mcpp.path", mock) }) + }) }).get(30, TimeUnit.SECONDS).asJsonObject assertEquals(1, init.getAsJsonArray("projects").size()) + assertEquals(2, init["protocol"].asInt) + // mcpp.toml through LSP: opened, its diagnostics published (0-based, UTF-16), completion answers. + val uri = uriOf(File(root, "mcpp.toml").path) + val published = CompletableFuture() + b.on("textDocument/publishDiagnostics") { _, p -> published.complete(p) } + b.notify("textDocument/didOpen", JsonObject().apply { + add("textDocument", JsonObject().apply { + addProperty("uri", uri); addProperty("languageId", "mcpp-toml"); addProperty("version", 1) + addProperty("text", "[package]\nname = \"名字\"\ncolour = 1\n") + }) + }) + val diagnostic = published.get(30, TimeUnit.SECONDS).asJsonObject.getAsJsonArray("diagnostics")[0].asJsonObject + assertEquals("mcpp.manifest.unknownKey", diagnostic["code"].asString) + assertEquals(2, diagnostic.getAsJsonObject("range").getAsJsonObject("start")["line"].asInt) + val list = b.request("textDocument/completion", JsonObject().apply { + add("textDocument", JsonObject().apply { addProperty("uri", uri) }) + add("position", JsonObject().apply { addProperty("line", 0); addProperty("character", 1) }) + }).get(30, TimeUnit.SECONDS).asJsonObject + assertTrue(list.getAsJsonArray("items").any { it.asJsonObject["label"].asString == "[build]" }) val finished = CompletableFuture() b.on("job/finished") { _, p -> finished.complete(p) } b.request("job/start", JsonObject().apply { addProperty("kind", "build"); addProperty("root", root.path) }).get(30, TimeUnit.SECONDS) @@ -90,18 +112,39 @@ class BackendTest { val commands = b.request("commands/list", JsonObject().apply { addProperty("editor", "jetbrains") }).get(30, TimeUnit.SECONDS) .asJsonObject.getAsJsonArray("commands").map { it.asJsonObject } assertTrue(commands.any { it["id"].asString == "mcpp.build" }) - assertTrue(commands.none { it["action"].asJsonObject["kind"].asString == "forward" }) + assertTrue(commands.filter { it["action"].asJsonObject["kind"].asString == "forward" } + .all { it["action"].asJsonObject["command"]?.asString?.startsWith("community.mcpp.mcppls.") == true }) } root.deleteRecursively() } + @Test + fun snippetsAndMarkdownAsCLionShowsThem() { + assertEquals("name = \"1.0.0\"" to 0, io.github.mcppcommunity.mcppclion.editor.expandSnippet("\${1:name} = \"\${2:1.0.0}\"")) + assertEquals("role = source;" to 7, io.github.mcppcommunity.mcppclion.editor.expandSnippet("role = \${3|source,check|};")) + assertEquals("x()" to 2, io.github.mcppcommunity.mcppclion.editor.expandSnippet("x($1)")) + assertEquals("

[package]

plane: identity r

", + io.github.mcppcommunity.mcppclion.editor.markdownToHtml("**[package]**\n\nplane: `identity` [r](u)")) + assertEquals("file:///C:/a%20b/mcpp.toml", uriOf("C:\\a b\\mcpp.toml")) + assertEquals("C:/a b/mcpp.toml", io.github.mcppcommunity.mcppclion.editor.pathOf("file:///C:/a%20b/mcpp.toml")) + } + @Test fun theGeneratedActionsAreTheTables() { val xml = javaClass.getResource("/META-INF/mcpp-actions.xml")?.readText() assumeTrue("the generated resources are on the test classpath", xml != null) assertTrue(xml!!.contains("""id="mcpp.build"""")) - assertTrue(!xml.contains("mcpp.languageServer.restart")) + // mcppls's actions are declared; each is shown where mcppls states a JetBrains id for it. + assertTrue(xml.contains("mcpp.languageServer.restart")) + // Build, Run, Debug and Test in the editor's context menu (part 2 §6.2). + assertTrue(xml.contains("""""")) + assertTrue(xml.contains("""""")) val bundle = javaClass.getResource("/messages/McppClionBundle.properties")!!.readText() assertTrue(bundle.contains("action.mcpp.build.text=Build")) + // VS Code's chords as keymaps of their own. + val keymap = javaClass.getResource("/keymaps/mcpp.xml")!!.readText() + assertTrue(keymap.contains("""""")) + assertTrue(keymap.contains("""first-keystroke="ctrl alt B"""")) + assertTrue(javaClass.getResource("/keymaps/mcpp-macos.xml")!!.readText().contains("""first-keystroke="meta alt B"""")) } } diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt new file mode 100644 index 0000000..a43d5c9 --- /dev/null +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/UiTest.kt @@ -0,0 +1,119 @@ +// CLion's own views over the backend's view models (mcpp design 2026-10-30 part 2 §9), headless: the +// project tree and the cache's rows from the answers the backend gives, a package's page, and the +// settings form from a registry -- its fields, its search, Apply's changes and Reset. They need the +// application (icons, Swing) and no project: a light project lives on temp://, where a bundled +// plugin's startup activity (CLion's STM32) fails. +package io.github.mcppcommunity.mcppclion + +import com.google.gson.JsonElement +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive +import com.intellij.testFramework.ApplicationRule +import com.intellij.testFramework.EdtRule +import com.intellij.testFramework.RunsInEdt +import com.intellij.ui.components.JBCheckBox +import com.intellij.ui.components.JBTextField +import io.github.mcppcommunity.mcppclion.settings.SettingsForm +import io.github.mcppcommunity.mcppclion.ui.CacheModel +import io.github.mcppcommunity.mcppclion.ui.LibraryPage +import io.github.mcppcommunity.mcppclion.ui.ProjectModel +import io.github.mcppcommunity.mcppclion.ui.RowNode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.ClassRule +import org.junit.Rule +import org.junit.Test + +@RunsInEdt +class UiTest { + companion object { + @ClassRule @JvmField val application = ApplicationRule() + } + + @get:Rule val edt = EdtRule() + + private val t: (String, Array) -> String = { key, args -> + var text = key + args.forEachIndexed { i, a -> text = text.replace("{$i}", a.toString()) } + text + } + + private fun json(text: String) = JsonParser.parseString(text).asJsonObject + + private fun texts(node: RowNode): List = (0 until node.childCount).map { (node.getChildAt(it) as RowNode).row.text } + + @Test + fun theProjectTreeFromTheBackendsAnswers() { + val project = json("""{"root": "/w/app", "name": "app"}""") + val summary = json("""{"targets": [{"name": "app", "kind": "executable", "configuration": "dev"}], + "tests": [{"name": "test_smoke", "kind": "test"}], "libraries": [], + "lastBuild": {"status": "ok", "command": "mcpp build"}, "artifacts": [{"path": "/w/app/target/bin/app"}], + "platformText": "openkal-platform 0.19.0 (built-in)"}""") + val mcpp = json("""{"state": "ok", "version": "2026.10.31.2", "program": "/x/mcpp"}""") + val node = ProjectModel.project(project, summary, mcpp, t) + assertEquals("app", node.row.text) + assertEquals("/w/app/mcpp.toml", node.row.file) + assertEquals(listOf("mcpp 2026.10.31.2", "Targets", "Tests", "Last build: ok", "Artifacts", "Platform: openkal-platform 0.19.0 (built-in)"), texts(node)) + val targets = node.getChildAt(1) as RowNode + assertEquals("mcpp.run", (targets.getChildAt(0) as RowNode).row.command) + val artifacts = node.getChildAt(4) as RowNode + assertEquals("/w/app/target/bin/app", (artifacts.getChildAt(0) as RowNode).row.file) + // A project whose mcpp is pinned and missing says so, as an error. + val missing = ProjectModel.project(project, null, json("""{"state": "pinned-missing", "message": "mcpp 2026.9.1.1 is pinned"}"""), t) + assertTrue((missing.getChildAt(0) as RowNode).row.error) + } + + @Test + fun theCacheRowsFromTheInventory() { + val inventory = json("""{"root": "/home/u/.mcpp", "totalBytes": 3, "totalEntries": 2, "totalText": "1.5 GiB", + "byKind": [{"kind": "bmi", "entries": 2, "bytes": 3, "bytesText": "1.5 GiB"}], + "topLabels": [{"label": "std", "entries": 1, "bytes": 2, "bytesText": "1.0 GiB"}], + "ageBuckets": [{"fromDays": 0, "toDays": 7, "entries": 2, "bytes": 3, "bytesText": "1.5 GiB"}, {"fromDays": 7, "entries": 0, "bytes": 0, "bytesText": "0 B"}], + "incomplete": []}""") + val rows = CacheModel.rows(inventory, t) + assertEquals(listOf("1.5 GiB in 2 entries", "By kind", "Largest", "By last use"), rows.map { it.row.text }) + assertEquals("0–7 days", (rows[3].getChildAt(0) as RowNode).row.text) + assertEquals(1, rows[3].childCount) // an empty bucket is not a row + } + + @Test + fun aPackagesPage() { + val html = LibraryPage.html(json("""{"id": "mcpplibs:os", "description": "the OS ", "index": "mcpplibs", + "dependencyName": "mcpplibs.os", "latest": "0.2.4", "versions": ["0.2.4", "0.2.3"], "licenses": ["Apache-2.0"], + "repo": "https://github.com/mcpplibs/os"}""")) { it } + assertTrue(html.contains("mcpplibs:os")) + assertTrue(html.contains("the OS <API>")) + assertTrue(html.contains("mcpplibs.os")) + assertTrue(html.contains("href=\"https://github.com/mcpplibs/os\"")) + } + + @Test + fun theSettingsFormFromARegistry() { + val registry = json("""{"groups": [{"id": "project", "order": 10, "title": "Project"}, {"id": "build", "order": 20, "title": "Build"}], + "settings": [ + {"key": "mcpp.path", "type": "string", "default": "", "group": "project", "tier": "public", "title": "mcpp path", "description": "the mcpp to run"}, + {"key": "mcpp.build.onSave", "type": "boolean", "default": false, "group": "build", "tier": "public", "title": "Build on save", "description": "a save builds"}, + {"key": "mcpp.build.jobs", "type": "number", "default": 0, "group": "build", "tier": "advanced", "title": "Jobs", "description": "parallel jobs"}, + {"key": "mcpp.build.profile", "type": "string", "enum": ["dev", "release"], "default": "dev", "group": "build", "tier": "public", "title": "Profile", "description": ""}, + {"key": "mcpp.old", "type": "string", "default": "", "group": "build", "tier": "public", "deprecated": true, "title": "old", "description": ""}]}""") + val stored = mutableMapOf("mcpp.path" to JsonPrimitive("/opt/mcpp")) + val form = SettingsForm(registry, { it }) { stored[it] } + assertEquals(listOf("mcpp.path", "mcpp.build.onSave", "mcpp.build.profile", "mcpp.build.jobs"), form.fields.map { it.key }) + assertFalse(form.isModified()) + assertEquals("/opt/mcpp", (form.fields[0].component as JBTextField).text) + // A change is what Apply stores; a value back at its default is removed. + (form.fields[1].component as JBCheckBox).isSelected = true + (form.fields[0].component as JBTextField).text = "" + (form.fields[3].component as JBTextField).text = "8" + assertTrue(form.isModified()) + assertEquals(mapOf("mcpp.path" to null, "mcpp.build.onSave" to JsonPrimitive(true), "mcpp.build.jobs" to JsonPrimitive(8L)), form.changes()) + form.reset() + assertFalse(form.isModified()) + // The search narrows the form. + form.filter("save") + assertEquals(listOf(false, true, false, false), form.fields.map { it.component.isVisible }) + form.filter("") + assertTrue(form.fields.all { it.component.isVisible }) + } +} diff --git a/mide-plugins/nvim/README.md b/mide-plugins/nvim/README.md index 426b977..6e4a44c 100644 --- a/mide-plugins/nvim/README.md +++ b/mide-plugins/nvim/README.md @@ -8,8 +8,9 @@ graphs, and a statusline component. It is the Neovim adapter of build runs, what a cleanup costs and which commands exist are the backend's answers, the same ones those editors receive. -The plugin registers no language service. Completion, navigation and diagnostics of C++ files come -from mcppls through its own Neovim plugin (below). +Completion, navigation and diagnostics of C++ files come from mcppls through its own Neovim plugin +(below). Those of `mcpp.toml` and `build.mcpp` come from the backend: the session's LSP client is +attached to their buffers (completion, `K`, `gd`, `vim.diagnostic`; see mcpp-ide's `docs/rules.md`). | | | |---|---| @@ -68,8 +69,8 @@ require('mcpp').setup({ `xlings install mcpp-ide`, and starts nothing; `:Mcpp` looks again. **The session.** A buffer whose file lies under an `mcpp.toml` (the nearest one upward, else the -working directory's) starts one backend for the Neovim instance, speaking the protocol through -Neovim's own JSON-RPC client (`vim.lsp.rpc`). It is initialized as the editor `nvim` with Neovim's +working directory's) starts one backend for the Neovim instance, an LSP server that Neovim's own LSP +client (`vim.lsp`) starts and speaks to. It is initialized as the editor `nvim` with Neovim's language (`v:lang`, or `mcpp.ui.language` when that names one), the trust below, that project as its folder and the settings above; the projects of buffers entered later are added to it. The runtime strings and the command table are then read from it. The backend is ended with `shutdown` diff --git a/mide-plugins/nvim/lua/mcpp/commands.lua b/mide-plugins/nvim/lua/mcpp/commands.lua index ce51cff..3f47dc0 100644 --- a/mide-plugins/nvim/lua/mcpp/commands.lua +++ b/mide-plugins/nvim/lua/mcpp/commands.lua @@ -347,6 +347,27 @@ function actions.selfCheck() vim.notify(table.concat(lines, '\n'), level) end +-- The IDE report (design 2026-10-30 part 2 §3.2): what a bug report needs, written to a file the +-- person names (default: beside the project). +function actions.report(_, args, root) + local r, err = session.call('report/collect', root and { root = root } or {}) + if err then + r = { kind = 'mcpp-ide.report', version = 1, error = err.message or tostring(err) } + end + local default = (root or vim.fn.getcwd()) .. '/mcpp-ide-report-' .. os.date('!%Y%m%dT%H%M%SZ') .. '.json' + local path = (args and args[1]) or flow.input({ prompt = t('Export IDE Report') .. ': ', default = default }) + if not path or path == '' then + return + end + local file = io.open(path, 'w') + if not file then + return vim.notify('mcpp: ' .. t('cannot write {0}', path), vim.log.levels.ERROR) + end + file:write(vim.json.encode(r), '\n') + file:close() + vim.notify('mcpp: ' .. t('The IDE report is written to {0}', path), vim.log.levels.INFO) +end + -- Which mcppls answers C++ files (languageService/status), said: in Neovim a missing or older mcppls -- comes with its own plugin, mcppls-nvim. function M.language_service(s) @@ -357,13 +378,22 @@ function M.language_service(s) end function actions.languageService() - local s, err = session.call('languageService/status', { candidates = session.mcppls_candidates() }) + local s, err = session.call('languageService/status', { candidates = session.mcppls_candidates(), root = M.root() }) if err then return report(err) end M.language_service(s) end +-- One of mcppls's actions, by the user command of mcppls-nvim that mcppls states (§5.3). +function actions.forward(row) + local command = row.action.command + if not command or vim.fn.exists(':' .. command) ~= 2 then + return vim.notify(t('This command is not available in this IDE.'), vim.log.levels.INFO) + end + vim.cmd(command) +end + function actions.rpc(row, _, root) views.job(row.action.method, { root = root }, row.subcommand) end @@ -374,13 +404,15 @@ end -- ── the table ───────────────────────────────────────────────────────────────────────────────── --- The rows Neovim offers now: not deprecated, not hidden from a palette, and the debugger only when --- nvim-dap is there. +-- The rows Neovim offers now: not deprecated, not hidden from a palette, the debugger only when +-- nvim-dap is there, and mcppls's actions only where mcppls-nvim defines the command. function M.offered() local out = {} for _, row in ipairs(session.state.commands) do local debug = row.action and row.action.kind == 'debug' - if not row.deprecated and row.palette ~= false and (not debug or dap()) then + local forward = row.action and row.action.kind == 'forward' + if not row.deprecated and row.palette ~= false and (not debug or dap()) + and (not forward or vim.fn.exists(':' .. (row.action.command or '')) == 2) then out[#out + 1] = row end end diff --git a/mide-plugins/nvim/lua/mcpp/init.lua b/mide-plugins/nvim/lua/mcpp/init.lua index aecc703..23d9033 100644 --- a/mide-plugins/nvim/lua/mcpp/init.lua +++ b/mide-plugins/nvim/lua/mcpp/init.lua @@ -3,7 +3,8 @@ -- in a terminal, mcpp's diagnostics, targets, toolchains, libraries, the cache and the graphs, a -- statusline component. Every decision is the backend's, the one mcpp-vscode and mcpp-clion ask. -- --- It registers no language service: C++ files are mcppls's, through its own plugin (mcppls-nvim). +-- C++ files are mcppls's, through its own plugin (mcppls-nvim). mcpp.toml and build.mcpp are the +-- backend's: its LSP client serves their completion, hover, jumps and diagnostics. -- -- require('mcpp').setup({ backend = ..., settings = { ['mcpp.build.target'] = ... }, trusted = true }) -- :Mcpp [subcommand] the backend's command table; no subcommand: the quick menu @@ -62,7 +63,8 @@ end -- A buffer of an mcpp project: the backend starts for its project, or learns of it. A backend that -- could not start is tried again by `:Mcpp`, not by every buffer entered. -local function enter() +local function enter(a) + session.attach(a and a.buf) local root = commands.root() if not root or known[root] then return @@ -122,7 +124,27 @@ function M.setup(opts) end, }) vim.api.nvim_create_autocmd('User', { group = group, pattern = 'McppStatus', callback = on_started }) + -- mcppls attached: it serves build.mcpp now, and the backend leaves it what it answers better. + vim.api.nvim_create_autocmd('LspAttach', { + group = group, + callback = function(a) + local client = vim.lsp.get_client_by_id(a.data and a.data.client_id or -1) + if client and client.name == 'mcppls' then + session.push_settings() + end + end, + }) vim.api.nvim_create_autocmd('VimLeavePre', { group = group, callback = session.stop }) + -- The editor regained focus: a `xlings use` or a changed pin happened outside it (§4.2). + vim.api.nvim_create_autocmd('FocusGained', { + group = group, + callback = function() + if session.state.ready then + session.request('project/refresh', {}, function() end) + session.request('languageService/status', { candidates = session.mcppls_candidates(), root = commands.root() }, function() end) + end + end, + }) if session.state.ready then session.push_settings() elseif vim.v.vim_did_enter == 1 then diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index d8da8b2..01dba6b 100644 --- a/mide-plugins/nvim/lua/mcpp/session.lua +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -1,8 +1,10 @@ --- The session with the backend: one `mcpp-ide serve` per Neovim, spoken to through Neovim's own --- JSON-RPC client (vim.lsp.rpc, which frames each message with Content-Length as the protocol --- does). Started for the nearest directory upward that holds an mcpp.toml, initialized as the --- editor `nvim` with the locale, trust, folders and setup()'s settings; then the runtime strings and --- the command table are read from it. Everything else reaches the backend through this module. +-- The session with the backend: one `mcpp-ide serve` per Neovim, an LSP server (protocol 2, mcpp +-- design 2026-10-30 part 2 §8) that Neovim's own LSP client starts and speaks to. Started for the +-- nearest directory upward that holds an mcpp.toml, initialized as the editor `nvim` with the +-- locale, trust, folders and setup()'s settings; then the runtime strings and the command table are +-- read from it. The client serves mcpp.toml and build.mcpp buffers (completion, hover, the jumps, +-- the diagnostics); mcpp-ide's own methods and notifications are under `mcpp-ide/`. Everything else +-- reaches the backend through this module. local flow = require('mcpp.flow') local M = {} @@ -11,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.1.1' +M.version = '0.2.0' -- Neovim's version, as its release names it. function M.nvim_version() @@ -276,8 +278,49 @@ local function not_running() return { code = -32099, message = t('the mcpp-ide backend is not running') } end +-- mcpp-ide's own methods and notifications, beside LSP's. +local NAMESPACE = 'mcpp-ide/' + +-- The notifications the backend sends under its namespace (protocol/protocol.schema.json). +local NOTIFICATIONS = { + 'job/started', 'job/output', 'job/plan', 'job/progress', 'job/diagnostic', 'job/artifact', 'job/record', + 'job/finished', 'test/result', 'test/summary', 'result/changed', 'manifest/changed', 'manifest/diagnostics', + 'project/mcppChanged', 'languageService/changed', 'test/caseResult', +} + +-- A method of Neovim's LSP client: `client:name(...)` since 0.11, `client.name(...)` before. +local methods_take_self = vim.fn.has('nvim-0.11') == 1 +local function invoke(client, name, ...) + if methods_take_self then + return client[name](client, ...) + end + return client[name](...) +end + +-- The session's client: Neovim's LSP client of the backend, as the rest of the plugin asks it. +local function wrap(lsp) + return { + id = lsp.id, + request = function(method, params, handler) + return invoke(lsp, 'request', method, params, handler) + end, + notify = function(method, params) + return invoke(lsp, 'notify', method, params) + end, + is_closing = function() + return invoke(lsp, 'is_stopped') + end, + terminate = function() + invoke(lsp, 'stop', true) + end, + stop = function() + invoke(lsp, 'stop') + end, + } +end + -- A request; `callback(err, result)` on the main loop. A request still unanswered when the backend --- ends is answered with that error (vim.lsp.rpc drops it). +-- ends is answered with that error. function M.request(method, params, callback) local client = state.client if not client or client.is_closing() then @@ -286,7 +329,7 @@ function M.request(method, params, callback) end local entry = { callback = callback } state.pending[entry] = true - local ok = client.request(method, object(params), function(err, result) + local ok = client.request(NAMESPACE .. method, object(params), function(err, result) if state.pending[entry] then state.pending[entry] = nil callback(denull(err), denull(result)) @@ -316,8 +359,15 @@ function M.value(key) end -- settings/update's parameters: setup()'s settings, the trust, the language. +-- Whether mcppls (mcppls-nvim's client) runs: it then serves build.mcpp, and the backend leaves it +-- what it answers better (the unknownSymbol rule, the module names after `import`). +local function mcppls_serves() + local get = vim.lsp.get_clients or vim.lsp.get_active_clients + return #get({ name = 'mcppls' }) > 0 +end + local function settings_params() - return { settings = object(state.options.settings), trusted = trusted(), locale = locale() } + return { settings = object(state.options.settings), trusted = trusted(), locale = locale(), mcpplsServes = mcppls_serves() } end -- The settings changed (setup again, a target chosen): the backend validates and resolves them. @@ -361,18 +411,27 @@ local function report_rejected(resolved) end end -local function initialize() - local info, err = M.call('initialize', { - client = { name = 'Neovim', version = M.nvim_version() }, - editor = 'nvim', - locale = locale(), - trusted = trusted(), - settings = object(state.options.settings), - workspaceFolders = state.folder and { state.folder } or {}, - languageService = { candidates = M.mcppls_candidates() }, - }) +-- The rules layer's buffers: mcpp.toml and build.mcpp. +local function served(buf) + local name = vim.fs.basename(vim.api.nvim_buf_get_name(buf)) + return name == 'mcpp.toml' or name == 'build.mcpp' +end + +-- A buffer of mcpp.toml or build.mcpp joins the session's client (completion, hover, the jumps and +-- the diagnostics of the rules layer). +function M.attach(buf) + buf = (buf == nil or buf == 0) and vim.api.nvim_get_current_buf() or buf + local client = state.client + if not state.ready or not client or client.is_closing() or not vim.api.nvim_buf_is_loaded(buf) or not served(buf) then + return + end + vim.lsp.buf_attach_client(buf, client.id) +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: ' .. tostring(err and err.message), vim.log.levels.ERROR) + vim.notify('mcpp-ide: initialize failed', vim.log.levels.ERROR) local client = state.client state.client = nil if client then @@ -384,6 +443,17 @@ local function initialize() state.strings = M.call('i18n/bundle', {}) or {} local listed = M.call('commands/list', { editor = 'nvim' }) state.commands = listed and listed.commands or {} + -- mcppls answered differently (another version, other actions): the commands are asked again, + -- since mcppls's actions are offered under the ids it states (§5.3). + if not state.listing then + state.listing = M.on('languageService/changed', function() + M.request('commands/list', { editor = 'nvim' }, function(err, again) + if not err and again and again.commands then + state.commands = again.commands + end + end) + end) + end local resolved = M.call('settings/update', settings_params()) if resolved then state.values = resolved.values or {} @@ -397,6 +467,9 @@ local function initialize() end -- A backend that ended while it was asked these has not started. finish_start(state.client ~= nil) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + M.attach(buf) + end end local function on_exit(client, code, signal) @@ -445,27 +518,56 @@ function M.start(folder, done) end state.starting, state.stopping, state.folder = true, false, folder cmd[#cmd + 1] = 'serve' + local handlers = {} + for _, name in ipairs(NOTIFICATIONS) do + handlers[NAMESPACE .. name] = function(_, params) + params = denull(params) or {} + if JOB_EVENTS[name] then + route(name, params) + end + emit(name, params) + end + end local client - local ok, err = pcall(function() - client = vim.lsp.rpc.start(cmd, { - notification = function(method, params) - params = denull(params) or {} - if JOB_EVENTS[method] then - route(method, params) - end - emit(method, params) - end, - on_exit = function(code, signal) - on_exit(client, code, signal) - end, - }, folder and { cwd = folder } or nil) + local config = { + name = 'mcpp-ide', + cmd = cmd, + cmd_cwd = folder, + root_dir = folder, + workspace_folders = folder and { { uri = vim.uri_from_fname(folder), name = vim.fs.basename(folder) } } or nil, + init_options = { + editor = 'nvim', + locale = locale(), + trusted = trusted(), + settings = object(state.options.settings), + languageService = { candidates = M.mcppls_candidates() }, + mcpplsServes = mcppls_serves(), + }, + handlers = handlers, + on_init = function(_, result) + vim.schedule(function() + flow.run(function() + initialize(denull(result)) + end) + end) + end, + on_exit = function(code, signal) + on_exit(client, code, signal) + end, + } + local ok, id = pcall(function() + if vim.fn.has('nvim-0.11') == 1 then + return vim.lsp.start(config, { attach = false, reuse_client = function() return false end }) + end + return vim.lsp.start_client(config) end) - if not ok then - vim.notify(t('The mcpp-ide backend could not be started: {0}', tostring(err)), vim.log.levels.ERROR) + local lsp = ok and id and vim.lsp.get_client_by_id(id) or nil + if not lsp then + vim.notify(t('The mcpp-ide backend could not be started: {0}', tostring(ok and 'no client' or id)), vim.log.levels.ERROR) return finish_start(false) end + client = wrap(lsp) state.client = client - flow.run(initialize) end -- Inside a flow: the session, started for the current project when it is not running; nil when it @@ -477,22 +579,15 @@ function M.ensure(folder) return ok and M or nil end --- Ends the backend as the protocol asks: shutdown, then exit. Neovim waits briefly for the answer. +-- Ends the backend as the protocol asks: the client's shutdown, then exit. Neovim waits briefly. function M.stop() local client = state.client if not client or client.is_closing() then return end state.stopping = true - local answered = false - client.request('shutdown', vim.empty_dict(), function() - answered = true - end) - vim.wait(1000, function() - return answered - end, 10) - client.notify('exit', vim.empty_dict()) - vim.wait(500, function() + client.stop() + vim.wait(1500, function() return client.is_closing() end, 10) if not client.is_closing() then diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index c457c0a..7fbf43e 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -134,7 +134,7 @@ vim.cmd.edit(vim.fn.fnameescape(root .. '/src/main.cpp')) check('a buffer of the project starts the backend', vim.wait(30000, function() return session.state.ready end, 20), session.state.failed and 'failed' or nil) local info = session.state.info or {} -check('initialize answers as mcpp-ide, protocol 1', (info.server or {}).name == 'mcpp-ide' and info.protocol == 1, vim.inspect(info.server)) +check('initialize answers as mcpp-ide, protocol 2', (info.serverInfo or {}).name == 'mcpp-ide' and info.protocol == 2, vim.inspect(info.serverInfo)) check('the project is the nearest mcpp.toml upward', info.projects and info.projects[1] and vim.fs.normalize(info.projects[1].root) == root, info.projects and info.projects[1] and info.projects[1].root) check('mcpp is the one setup() names', info.mcpp and vim.fs.normalize(info.mcpp.path) == vim.fs.normalize(mcpp), info.mcpp and info.mcpp.path) @@ -145,8 +145,14 @@ for _, row in ipairs(session.state.commands) do end check('the command table arrives, each row with its subcommand', #session.state.commands > 20 and by.build ~= nil, #session.state.commands) check('cancel and problems are Neovim\'s, settings is not', by.cancel and by.cancel.id == 'mcpp.cancelJob' and by.problems ~= nil and by.openSettings == nil) -local forwards = vim.tbl_filter(function(r) return r.action.kind == 'forward' or r.action.kind == 'editor' end, session.state.commands) -check('no forward to mcppls\'s commands (mcppls-nvim has its own)', #forwards == 0, #forwards) +local editors = vim.tbl_filter(function(r) return r.action.kind == 'editor' end, session.state.commands) +check('no command of another editor', #editors == 0, #editors) +-- mcppls's actions only under the mcppls-nvim command mcppls states; offered only where it is defined. +local forwards = vim.tbl_filter(function(r) return r.action.kind == 'forward' end, session.state.commands) +check('a forward names mcppls-nvim\'s command', vim.tbl_isempty(vim.tbl_filter(function(r) + return not (r.action.command or ''):match('^Mcppls') end, forwards)), vim.inspect(forwards)) +check('a forward whose command is not defined is not offered', vim.tbl_isempty(vim.tbl_filter(function(r) + return r.action.kind == 'forward' and vim.fn.exists(':' .. r.action.command) ~= 2 end, require('mcpp.commands').offered()))) -- ── completion ──────────────────────────────────────────────────────────────────────────────── local all = vim.fn.getcompletion('Mcpp ', 'cmdline') @@ -210,6 +216,32 @@ check('a broken mcpp.toml gets mcpp\'s problem in the `mcpp.toml` namespace', vim.api.nvim_buf_set_lines(0, 0, 1, false, { '[package]' }) vim.cmd('silent write') os.remove(root .. '/.mock-mcpp-fail') +-- The rules layer through the backend's LSP client (protocol 2): the buffer joined it, a rule is +-- published as the text changes, and hover answers from the snapshot of mcpp's tables. +local function lsp_attached() + local get = vim.lsp.get_clients or vim.lsp.get_active_clients + return #get({ name = 'mcpp-ide', bufnr = toml }) > 0 +end +check('mcpp.toml joins the backend\'s LSP client', vim.wait(5000, lsp_attached, 20)) +vim.api.nvim_buf_set_lines(toml, 1, 1, false, { 'colour = 1' }) +local function rule_seen() + for _, d in ipairs(vim.diagnostic.get(toml)) do + if d.code == 'mcpp.manifest.unknownKey' and d.lnum == 1 and d.col == 0 then + return true + end + end + return false +end +check('a key [package] does not have is published as the text changes', vim.wait(5000, rule_seen, 20), + vim.inspect(vim.diagnostic.get(toml))) +vim.api.nvim_buf_set_lines(toml, 1, 2, false, {}) +local replies = vim.lsp.buf_request_sync(toml, 'textDocument/hover', + { textDocument = { uri = vim.uri_from_bufnr(toml) }, position = { line = 0, character = 2 } }, 5000) or {} +local _, answer = next(replies) +local hovered = answer and answer.result and answer.result.contents and answer.result.contents.value or '' +check('hover on [package] answers from the rules layer', hovered:find('plane', 1, true) ~= nil, hovered) +-- The buffer is the file again; nothing is written (a write would build on save). +vim.bo[toml].modified = false vim.cmd.edit(vim.fn.fnameescape(root .. '/src/main.cpp')) -- ── the backend's views as text ─────────────────────────────────────────────────────────────── @@ -296,6 +328,15 @@ local report = heard('Self-check') or '' check(':Mcpp selfCheck reports the backend, mcpp and the checks', report:find('mcpp-nvim ', 1, true) and report:find('mcpp-ide ', 1, true) and report:find(vim.fs.basename(mcpp), 1, true) ~= nil, report:sub(1, 120)) +-- The IDE report (design 2026-10-30 part 2 §3.2), to a file named on the command line. +said = {} +local report_path = vim.fn.tempname() .. '-mcpp-ide-report.json' +vim.cmd('Mcpp exportReport ' .. vim.fn.fnameescape(report_path)) +vim.wait(20000, function() return heard('IDE report') ~= nil end, 20) +local written = vim.fn.filereadable(report_path) == 1 and vim.json.decode(table.concat(vim.fn.readfile(report_path), '\n')) or {} +check(':Mcpp exportReport writes the report the backend collects', written.kind == 'mcpp-ide.report' and written.backend ~= nil, + vim.inspect(written):sub(1, 120)) + -- ── what only the mock plays ────────────────────────────────────────────────────────────────── if mock then before = session.state.last diff --git a/mide-plugins/vscode/.gitignore b/mide-plugins/vscode/.gitignore new file mode 100644 index 0000000..e89c910 --- /dev/null +++ b/mide-plugins/vscode/.gitignore @@ -0,0 +1,18 @@ +node_modules/ +dist/ +*.vsix +.DS_Store +.vscode-test/ + +# 分析报告与评审记录:本地产物,只保留 .agents/docs +.agents/reviews/ + +# 本地验收 profile(tools/dev-profile.mjs 生成) +.dev-profile/ + +# 界面原型(tools/ 之外的设计评审产物,体积大,不入库) +.agents/prototypes/ + +# The backend the package carries (tools/stage-backend.mjs). +/backend/ +.webui-test/ diff --git a/mide-plugins/vscode/.vscodeignore b/mide-plugins/vscode/.vscodeignore index 40fbe32..14e9820 100644 --- a/mide-plugins/vscode/.vscodeignore +++ b/mide-plugins/vscode/.vscodeignore @@ -1,2 +1,20 @@ -**/*.map -out/test/** +.vscode/** +.github/** +.gitignore +.agents/** +.agnets/** +# 本地验收 profile(tools/dev-profile.mjs 生成)与 e2e 的 VS Code 缓存都不属于产物 +.dev-profile/** +.vscode-test/** +.webui-test/** +docs/** +src/** +test/** +tools/** +data/** +tsconfig.json +package-lock.json +*.tsbuildinfo +dist/**/*.map +dist/test/** +node_modules/** diff --git a/mide-plugins/vscode/CHANGELOG.md b/mide-plugins/vscode/CHANGELOG.md index 0ad0958..763df47 100644 --- a/mide-plugins/vscode/CHANGELOG.md +++ b/mide-plugins/vscode/CHANGELOG.md @@ -1,18 +1,450 @@ -# Changelog - -## 1.0.0 - -- The extension is the VS Code adapter of mcpp-ide: its decisions are the mcpp-ide backend's - (`mcpp-ide serve`, shipped inside the extension), which reads mcpp's control contract. The same - features exist in CLion through mcpp-clion. -- Builds and tests run as tasks in a pseudoterminal that shows mcpp's own narration; closing the - terminal cancels the build. Problems come from mcpp's located diagnostics, not from a pattern. -- The Testing view lists every test of every project (`mcpp test --list`) and runs one or all. -- Debugging through a launch configuration of type `mcpp`, for CodeLLDB, the C/C++ extension, - lldb-dap or Native Debug; an ok image is debugged through `okrun`. -- Targets and platforms: the targets mcpp knows, their toolchains and the platform that configures - each; `mcpp.build.target` chooses the target build, run, test, debug and pack use. -- mcpp.toml's problems come from mcpp; the extension's own TOML checks are removed and their - settings deprecated. -- mcppls is installed with the extension as an extension pack rather than required; the project - view reports whether the installed mcppls reads mcpp's result record (0.0.18 and later). +## Unreleased + +## 1.1.0 + +- The extension is mcpp-vscode 0.9.0 in everything a user sees and does: the same views (Project, + Libraries, Cache), pages (a package's page, the settings page, the dependency and module graphs), + commands, menus, keybindings, settings and strings. 1.0's simplified panels are gone. +- mcpp runs through the mcpp-ide backend (`mcpp/exec`) and mcppls through it + (`languageService/exec`); the backend is bundled (`backend/`, an ok image with okrun for Linux, + macOS and Windows) and located as 1.0 located it (`mcpp.backendPath`, the bundle, PATH). +- Kept from 1.0, in 0.9.0's style: **Debug** (`mcpp.debug`, the `mcpp` launch type through CodeLLDB, + the C/C++ extension or lldb-dap), the **Testing** tree, **Select Target** with rows for target + sets (mcpp 2026.10.30.1 and later), **Pack**, build on save (`mcpp.build.onSave`), the build's + profile and target (`mcpp.build.profile`, `mcpp.build.target`), and cancelling a running job. +- A launch without a debugger extension is refused at once; the message offers to install CodeLLDB. +- mcpp.toml's and build.mcpp's completion, hover, jumps and diagnostics are the mcpp-ide backend's, + connected through VS Code's language client (`docs/rules.md` of mcpp-ide): every table and key of + mcpp 2026.10.27.1 and the build-script API of 2026.10.29.1, dependency versions from mcpp's + catalogue (`mcpp.toml.indexCompletion`), and diagnostics at the columns VS Code counts (0.9.0 placed + manifest diagnostics one column late). +- Where the backend's runner does not run (macOS x86_64, Windows arm64, Linux armhf) the extension + says so and goes on with build, run, test and the project pages. +- Releases are published to Open VSX beside the GitHub release. + +## 0.9.0 - 2026-10-20 + +- Node operations use a keyboard-accessible right-click menu. Refresh/Export use theme colors, aligned chevrons and matching control heights; depth selectors align in View options. +- Visible project metadata checks recover Dependencies updates when native file events are dropped, without repeated CLI reads for unchanged files. + +- Group module entries into Main, Library, Tests and Other, with paged test entries and a connected default view for large projects. +- Separate selection, focus and camera navigation: click to select, double-click to open source, locate either selection or focus, and explore two importer levels alongside three dependency levels. Local expansion advances repeatedly and preserves shared branches. +- Coalesce graph refresh events, cancel obsolete queries, reuse unchanged layouts and pause hidden graph work. Preserve dependency rows through transient manifest failures and show a status line when no declarations are available. +- Keep entry/search controls visible and group export formats, view settings and node actions into compact menus. +- Export the visible graph as offline SVG or bounded PNG without expanding or querying the full project. Add Chromium coverage for graph interaction, layout reuse and exports. +- Release VSIX remains available on GitHub and Open VSX; Visual Studio Marketplace upload is manual. + +## 0.8.0 - 2026-10-19 + +- Package details use the available editor width on fullscreen displays, with bounded side gutters and a compact metadata column. +- Add C++ Modules Deps backed by mcppls JSON, with entry/source cards, one-hop external boundaries and read-only source navigation. Share the package graph canvas with depth/branch folding, hidden-node search, focused views and 150-node/300-edge layout budgets. Move direct Dependencies after Basics as a collapsed sibling. + +- Example code uses a uniform background and compact heading actions; local and bundled example sources both open read-only. Direct dependency rows now resolve namespaces correctly, show package coordinates and sources, and focus the selected node in the simplified colored graph. +- Usage pages restore real index test examples even when the installed index omits tests, using a pinned offline snapshot with full read-only source access. Focused search shows a single clear button. +- Project dependencies offer a Mermaid graph of the nearest three levels, based on manifests and local index metadata, with refresh, zoom and Mermaid copy controls. +- Libraries now defaults to the official `mcpp-index` in `/indices/mcpplibs`, with support for the old location. The xlings tool index is no longer substituted for the library catalog. +- Package details put usage, standard and license tags beside the name, and project selection in the header. A split action chooses project, development or build dependencies. The CLI handles project/development additions; build dependencies and group migrations provide a TOML preview and a manifest editing entry point. +- Search and detail updates preserve the document, selection and scroll state. Cross-registry hits have explicit limited previews. Stale searches and results from another package/project cannot update the current page. +- Dependency state is refreshed independently from cached descriptors, including external file changes. Local rereads and index updates are separate actions; failures retain usable results. + +## 0.7.0 - 2026-10-14 + +`build.mcpp` is C++ again, compiled by C++ Modules as the build program it is, and the project +view says when another C++ language service is answering the same files. + +### Changed + +- **`build.mcpp` is a `cpp` document.** 0.6.0 gave it a language of its own, `mcpp-build`. + VS Code prefers a filename match over an extension match, so that hid the file from C++ + Modules, which selects `cpp`: the build program got a line-by-line text check instead of a + compiler. The `mcpp-build` language, its grammar and its language configuration are gone; + this extension's build-script help now registers on `cpp` documents named `build.mcpp`, and + the C++ module grammar is injected into `source.cpp` only. +- **While C++ Modules serves `build.mcpp`, it answers what it answers better**: the + `unknownSymbol` check and the module names after `import` (completion and hover) are left to + it. The SPEC-007 rule checks, the `mcpp::…` directive completion and hover, and the snippets + stay. "Serves" means the extension is installed and enabled and `mcppls.enable` is not + `false` here; the open files are re-checked when that changes. +- **`unknownSymbol` reads the file's imports** when it does run: `import mcpp.rules.qt;` makes + `mcpp::rules::…` a name the snapshot cannot judge, so it is no longer reported + (`unknown mcpp::rules` on every Qt build program). +- The diagnostics' owner is `mcpp-build-script` (was `mcpp-build`). +- The build-script snapshot and the `mcpp.toml` schema are regenerated from mcpp 2026.10.11.1. + The schema generator reads the `[build]` keys from mcpp's key registry, where 2026.10.11.1 + moved them. + +### Added + +- **Project → Basics → Language service → "Other C++ language services".** While C/C++ + IntelliSense, clangd or ccls is active next to C++ Modules, the row shows a warning and their + names, and one click runs C++ Modules' own **Turn Off Other C++ Language Services** (it asks + whether for this workspace or everywhere, and writes the other extensions' own settings). + With none, the row is a dimmed check mark, "Only C++ Modules is serving C++". When C++ + Modules remembers what it turned off, the row's context menu offers **Restore**. The list is + C++ Modules' own answer (0.0.16+); with an older one, a read-only look at the same three + extensions. +- `mcpp.languageServer.restoreConflicts` ("C++ Modules: Restore Other C++ Language Services"). + `mcpp.languageServer.manageConflicts` is now titled "Turn Off Other C++ Language Services", + which is what it always ran. + +### Migrating + +- A setting written for the old language id, `"[mcpp-build]": { … }` in `settings.json`, no + longer applies. Move it under `"[cpp]"`. +- Turning other C++ services off or restoring them no longer asks a second time in this + extension: C++ Modules' command asks where to act, and closing that question does nothing. + +## 0.6.0 - 2026-10-02 + +The sidebar was reorganised around one rule: **the body shows state, the actions sit +next to it, and the two are visually separate.** + +### Changed + +- **Three views instead of four**: 工程 / mcpp 库生态 / 缓存. The C++ Modules view is + gone as a view; its state is now one row inside 工程 → 基本信息, collapsed by + default. **The collapsed row always carries a status icon**, so a degraded language + service is visible without expanding it. +- **The project view** is two labelled sections: 基本信息 (identity, target, toolchain, + dependencies, the language service) — collapsed by default — and 常用命令, which opens + with the nine things you can do, creation first (`new project`, then build/run/test/ + clean/toolchain/search-and-add/self-check/settings). Rows carry a keybinding hint where + one exists, and their icons are coloured by what they act on (blue builds or adds, green + runs or verifies, purple tests, red deletes, yellow manages the toolchain). +- **Dependencies are shown as two levels**: what `mcpp.toml` declares, and the version + `mcpp.lock` resolved for it. No connector lines — `mcpp.lock` has no parent/child + edges, and drawing them would be inventing a tree. +- **The cache view is a sidebar webview**, not an editor tab, and it starts **collapsed** + so the library list gets the height. 项目缓存 is always visible inside it; 全局缓存 is + collapsed and shows its summary on one line. The composition and age bars are drawn once, + with a single-line legend, and the age bar is a four-stop ramp (blue → green → yellow → + red) so four buckets do not read as one block. +- **The mcpp status bar menu** groups its commands under one heading per area (project / + library / toolchain / cache / C++ Modules / settings) with a codicon per command, and it + carries the C++ Modules actions that used to be reachable only from the removed view — + including **capture the logs** (`mcppls.exportDiagnosticBundle`) and the two places the + logs and the last bundle live. +- **`mcpp.views.enabled` hides the mcpp views' contents.** It does *not* remove the + activity bar icon: VS Code only hides a container that a built-in registered with + `hideIfEmpty`, and an extension cannot mark its own container that way. Right-click the + icon to remove it from the activity bar. +- `package.json`'s marketplace icon is still mcpp's official logo — the same file the C++ + Modules extension uses. The **activity bar** icon is a derived monochrome stencil of that + logo's wordmark, because VS Code paints a container icon as an alpha mask in the theme's + foreground colour (the badge would have been a solid block). + +### Added + +- **mcpp 库生态** — browse the package index already on this machine, offline: search + (a row's haystack carries its namespace, so typing `compat` filters to that namespace), + the `已添加` state on the row itself, and a per-package detail page in the editor with the + real **example code** the index's CI builds and runs (172 example projects), the version + matrix per platform, licence and repository. The page opens with what it is for: one row + of buttons — **Add to mcpp.toml** / **Switch to <version>** / Open the repository / + Open on the index site — and a clickable version matrix that aims that command. For a + package the workspace already depends on it reads `mcpp.toml` (then `mcpp.lock`), marks + the version in hand, and offers the switch instead of a blind add. `mcpp add` is still the + only thing that writes `mcpp.toml`. +- Searching the *other* registries is a separate switch, `mcpp.library.networkSearch`, + **off by default**: that tier runs `mcpp search`, which may use the network and can + only be read from human output on a best-effort basis. +- Labels use the official index site's own vocabulary — `import` / `#include` / `tool` + / 上游 mcpp.toml — read from `mcpp xpkg parse --json`, so the editor and the site say + the same thing. + +### Fixed during acceptance testing + +Every one of these was found by running the extension in a real profile, and each was +traced to its cause rather than to its symptom. + +- **The library view reloaded itself forever** (flicker, rows that could not be clicked, a + pegged CPU). The document announced its own load with a `ready` message and the host + answered it with a repaint; assigning `webview.html` reloads the document, and every + render minted a fresh CSP nonce, so no two documents ever matched. The handshake is gone, + the nonce is per view, and an identical document is never pushed. The same dead `ready` + shape was removed from the detail page, and both the reload rule and the missing + `registerWebviewViewProvider` now have tests. +- **Every cache bar was solid black.** The stylesheet selected `rect[data-kind=…]`, but the + renderer stamps those attributes on the `` that wraps the rectangle, so no rule matched + and every segment took the SVG default fill. `fill` is now set on the group (which the + rectangle inherits), and a test compares the document against the stylesheet. +- **The activity bar icon was a white block.** VS Code draws a container icon as an alpha + mask, and the official logo is an opaque badge: as a stencil it is a filled square. The + icon is now a derived, transparent-background wordmark, regenerated from `logo.png` by + `tools/generate-activitybar-icon.mjs`, with `npm run check:icon` as the drift gate. +- **Quick menu rows had no icons, then no colour.** Icons needed a table and a rendering + path; colour cannot come from a `ThemeIcon` at all (VS Code 1.132 drops its colour in a + quick pick), so the rows ship generated coloured SVG assets — one per icon and palette + word, light and dark — built from `@vscode/codicons` by + `tools/generate-quick-menu-icons.mjs`. The project tree's `charts.orange` turned out to be + 33%-alpha, and was replaced by `charts.red` for destructive rows. +- **`mcpp.languageServer.exportDiagnosticBundle` was nearly registered twice** (once through + the table, once by hand after a change), which makes `activate()` throw and the whole + extension fail to start. A source gate now rejects a command id registered twice, and the + gate was verified by reintroducing the bug. +- **A detail-page link click said nothing.** `void vscode.env.openExternal(…)` dropped the + boolean that says whether the browser opened, so a failure was indistinguishable from a + dead button. The click now writes a `pending` line immediately, and the host answers with + either the result, or the URL on the clipboard plus a note in the output channel. + +### Security & platform (found in external review) + +- **The workspace-trust boundary is closed again.** Three mcpp calls the new library + views had added (`xpkg parse` on the detail page, `mcpp search` behind the + cross-registry switch, the self-check's protocol probe) ran without checking trust, + and `mcpp.path` is a `resource`-scoped setting — so a repository could name any + program and have it run in a workspace the user had explicitly marked untrusted. + `mcpp.path` and `mcpp.clangd.path` are now `restrictedConfigurations`, every + non-controller call goes through a trust-gated seam (`runMcpp`), and an + architecture test keeps new call sites from bypassing it. Untrusted workspaces + degrade in place: the detail page falls back to the descriptor's own text, the + search keeps its local results, the self-check says it did not probe. +- **Windows: arguments are quoted on the `.cmd`/`.bat` path.** Node joins shell + arguments without quoting, so an index path containing a space split in two and a + search term like `foo & calc` became two commands. Arguments now follow the + `CommandLineToArgvW` quoting rules. +- **One render seam for all four webviews** (`WebviewDocument`): a per-view CSP nonce + and an assign-only-on-change rule, with gates holding every host to it. The cache + view was the real winner: a refresh that changes nothing no longer reloads the + page, so the budget input and the scroll position survive. +- **The release pipeline publishes to two markets.** A version tag tests, packages one + VSIX, publishes it to GitHub Releases and Open VSX (required), and to the + Visual Studio Marketplace when its token is configured (skipped with a notice + otherwise) — always the same artifact the Release carries. + +> From 0.5.0 on, entries are written in English. Earlier entries remain as they +> were written. + +## 0.5.0 - 2026-10-02 + +A groundwork release: the extension now says what it is doing, in the user's +language, and every claim about mcpp or the C++ Modules extension is checked by a +test or a generated snapshot rather than by a comment. + +### Added + +- **Three views** under a new `mcpp` Activity Bar container: **Project** (identity, + toolchain, targets and the build/run/test buttons), **Cache** (project artifacts + and the shared build cache, with its size, age distribution, largest packages + and incomplete entries) and **C++ Modules** (the language service's state, + issues and every action, forwarded to `sunrisepeak.mcpp-language-server`). +- **A settings panel** (`mcpp: Open Settings Panel`) built from a single registry: + search, "only modified", per-section collapse, four presets, each row showing + where its value comes from, when it takes effect, and a link to the native + Settings editor. +- **64 settings**, 29 of them public and the rest behind "show advanced". A new + `data/config-registry.json` is the single source of truth; `package.json` and + `docs/settings.md` are held to it by CI. +- **Cache cleanup as a table of plans**, with five levels of confirmation: reading + is free, `mcpp clean` asks once, `--stale`/`gc`/`prune` show a preview first, + `mcpp cache clean --all` additionally requires an explicit acknowledgement naming + every project on the machine, and emptying the shared cache during a project + clean is a separate, unticked choice. +- **`mcpp: Environment Self-check`**: one copyable snapshot of every version, the + mcpp protocol and advertised kinds, each C++ Modules capability and its fate, + the cache figures and every setting changed from its default. +- **`build.mcpp` intelligence** without a language server: completion and hover for + the 31 build directives and the five action roles, snippets, document symbols and + seven static SPEC-007 diagnostics, generated from mcpp's own directive table. + `import std;` and `import mcpp;` are recognised and never reported as missing — + measured behaviour, see `docs/build-script.md`. +- **`mcpp.toml` editing**: key completion, enum values, hover with the type, default, + plane and legacy advice, seven manifest diagnostics and navigation for + `workspace = true`, `path = …` and `features = […]`. +- **English and Chinese**: every command title, setting and runtime message follows + the editor's language. `mcpp.ui.language` can override the runtime messages and + our panels; the Command Palette and the Settings UI always follow VS Code. + +### Changed + +- User-visible strings in `package.json` are now `%nls%` references; the text lives + in `package.nls.json` and `package.nls.zh-cn.json`. +- Activation is narrower: the `onCommand:*` events are gone (VS Code derives them + from `contributes.commands` since 1.74), as is `onLanguage:cpp`. +- `mcpp template`-free: `mcpp: Clean` is now `mcpp: Clean Project Artifacts`, and + the language-service commands moved under the `mcpp.languageServer.*` namespace. + +### Fixed + +- The toolchain inventory is read from `mcpp toolchain list --format json` when mcpp + speaks the machine-output protocol, instead of scraping the human table — the + human table no longer prints the line the old parser depended on. +- A failing C++ Modules call can no longer be mistaken for a failing mcpp build, and + a command that a newer or older mcppls does not offer is remembered and hidden + rather than retried on every click. + +### Removed + +- `src/configureOnly.ts` and `src/ideWorkflow.ts`: dead since 0.4.0 moved the build + database to the language service, and at odds with the documented boundary. + +### Migration + +Nothing has to be changed by hand: every 0.4.x command id and setting key below still +works, a keybinding that names an old id keeps firing, and the old setting key is read +as an alias. A one-time prompt offers to move renamed setting values to their new +names (dismiss it and it comes back next session; the old key is never deleted without +your say-so). + +| 0.4.x | Now | +| --- | --- | +| `mcpp.tomlCompletion` (setting) | `mcpp.toml.completion` | +| `mcpp.configureLanguageServer` | `mcpp.languageServer.selectContext` | +| `mcpp.configureClangd` | `mcpp.languageServer.selectContext` | +| `mcpp.refreshCompilationDatabase` | runs `mcpp build` (which refreshes the database) | +| `mcpp.checkModuleSupport` | `mcpp.languageServer.restartServer` | +| `mcpp.showModuleGraph` | `mcpp.languageServer.showModuleGraph` | +| `mcpp.showLanguageServerLogs` | `mcpp.languageServer.showLogs` | +| `mcpp.clean` | alias of `mcpp.cleanProjectArtifacts` | + +Removed outright in 0.6.0: `mcpp.showCachePanel` (the cache view is now in the sidebar +— `mcpp.cache.focus` brings it up) and the standalone C++ Modules view (its state and +actions live in 工程 → 基本信息 and the status bar menu). + +## 0.3.1 - 2026-08-11 + +- 修复缺少 CDB 时 IDE 命令看似无响应的问题:配置 clangd、刷新编译数据库、检查模块支持和一键配置现在会立即显示进度并打开 `mcpp` 输出频道;clangd 重启与 `mcpp build --configure-only` 增加超时,避免异步操作永久占用 IDE 队列。 +- 增加删除 `compile_commands.json` 后刷新 CDB、配置 clangd、检查模块支持和一键配置的 Extension Host 回归测试。 + +## 0.3.0 - 2026-08-11 + +- 将编译数据库刷新迁移到 `mcpp build --configure-only`:不解析 stdout 人类文本,以退出码 + 和可解析 CDB 作为成功条件,失败时保留 last-known-good CDB。 +- 删除旧 `mcpp ide configure --format ndjson` 解析层和重复的 `mcpp.configureIde` 命令; + configure-only 与 build/run/test 共用项目操作锁,manifest 与 `mcpp.path` 变化按工程协调。 +- CDB watcher 只重读已发布数据库,不反向触发 configure-only;多根工作区按事件 URI 路由, + 显式刷新会分别报告 CDB 生成与 clangd 协调结果。 +- 对齐 mcpp #387 的最终 workspace 契约:virtual workspace 根不作为单一 clangd 工程, + 扩展消费当前活动 member 根的 CDB;rooted workspace 仍按根 package 处理。 +## 0.4.0 - 2026-09-26 + +### Changed + +- 将 C++ 模块语言服务依赖从官方 `llvm-vs-code-extensions.vscode-clangd` 切换为 + `sunrisepeak.mcpp-language-server`(C++ Modules Language Server / mcppls);VS Code 最低版本 + 提升到 1.91。 +- 删除本扩展中的 clangd 解析、匹配、配置、PCM 检查、CDB 监听和自动重启逻辑。mcpp-vscode + 不再启动第二个 LSP 客户端,也不再打包语言服务 payload。 +- mcpp build 完成后只通过 mcppls 公开命令刷新语言服务;新增模块图、语言服务日志和分析 + 上下文转发命令。 +- 一键模块配置改为一次确认后执行 `mcpp build` 并刷新 mcppls,不再安装 llvm-tools、切换 + LLVM 全局默认或修改 clangd 配置。 + +### Fixed + +- 移除 mcpp 已删除的 `[xlings.envs]` manifest 补全,并将 `[xlings.workspace]` 示例版本更新为 + 当前索引可用的 Node 条目。 + +## 0.2.7 + +- 修复「一键配置模块代码提示」在标准 mcpp 安装(install.sh / AUR)下无法发现 mcpp 内置 + xlings 的问题:xlings 发现以 `mcpp self env` 为权威来源(项目级契约),路径探测仅作回退; + 为 `mcpp self env` 调用增加超时保护,并补齐测试(PR #11)。 + +## 0.2.6 + +- 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd + 配置 → 模块检查」链恢复 IDE,支持取消与失败降级;仅在受信任工作区执行(issues #6/#7, + PR #10)。 +- 新增 `mcpp.toml` 结构补全:段头 snippet(26 段)与依赖/feature 等写法模板,所有建议 + 携带显式替换范围并经真实 mcpp 契约测试;由 `mcpp.tomlCompletion` 设置控制(默认开启), + 未受信任工作区仅纯文本分析(PR #9)。 + +## 0.2.5 + +- 编辑器标题栏的 mcpp 运行/测试操作按当前活动文件所属的 mcpp member 作用域执行;工作区外 + 打开的文件不会显示这些按钮。 +- 将状态栏快捷菜单名称明确为 `$(tools) mcpp: 快捷菜单`,与模块可用性状态按钮区分。 + +## 0.2.4 + +- 修复 hermetic mcpp 编译数据库被插件追加 `--query-driver` 后导致的 clangd 标准库和 + 模块误诊断;仅在编译命令未自带 `--no-default-config`、`-nostdinc++` 和显式 libc++ + 路径时补充 query driver。 +- 模块检查优先选择项目源码,避免误选 `.mcpp` 依赖缓存或 `target` 生成源码,改善补全、 + 跳转和诊断稳定性。 +- 将 `build.mcpp` 作为独立的语法高亮语言处理,不再让 clangd 把 mcpp 构建脚本当作普通 + C++ 翻译单元。 + +## 0.2.3 + +- 为精确文件名 `build.mcpp` 增加 C++ 语言关联,复用 VS Code 内置 C++ 高亮和现有模块 + 语法注入;不扩大到所有 `*.mcpp` 文件。 +- 为精确文件名 `mcpp.toml` 内置独立 TOML 语法高亮,不依赖额外扩展,也不接管普通 + `*.toml` 文件。 + +## 0.2.2 + +- 新增 **mcpp: 一键配置模块代码提示**:自动检测匹配的 clangd,缺失时通过 xlings 下载 + 对应版本的 llvm-tools,配置 clangd 并刷新模块状态,无需重启 VS Code。 +- 检测 PCM 版本不匹配(`ast_file_version_too_new`、`ast_file_version_too_old`、 + `ast_file_different_branch`),并在诊断信息中引导一键配置。 +- 非 LLVM 工具链(GCC/MSVC)项目提供清晰的引导说明而非催促安装:状态栏点击弹出 + QuickPick,一键配置向导显示原因并提供工具链安装/选择入口。 +- 优化编辑器标签切换性能:相同工程内切换文件不再重复协调 clangd。 +- 序列执行器使用 AsyncLocalStorage 范围标记替代全局深度计数,修复不相关并发调用 + 绕过队列的问题。 +- 完善 xlings 检测:检查 PATH 中是否实际存在 xlings 而非无条件返回路径名,确保 + 未安装时的引导分支可达。 + +## 0.2.1 + +- 修复项目任务已经结束后,clangd/CDB 重协调仍占用项目锁,导致下一条 CLI 命令误报 + “已有 mcpp 操作正在运行”的问题。 +- 增加 GitHub tag Release 工作流:校验 tag 与扩展版本,运行测试和打包,并发布 VSIX + 及其 SHA-256 校验文件。 + +## 0.2.0 + +- 新增 `$(tools) mcpp` 状态栏快捷菜单和命令面板命令:构建、运行、测试、清理 target、 + 查看工具链、安装工具链、选择全局默认工具链。 +- 项目任务使用 VS Code 的无 shell `ProcessExecution`,显示实时任务终端;同一工程任务 + 互斥,工具链安装/默认值操作共享全局锁,取消不会误报成功。 +- 项目任务结束后复用 CDB/clangd 自动协调;`mcpp.refreshCompilationDatabase` 与构建 + 命令共用同一任务路径,避免并发写入工程构建目录。 +- 按 mcpp 官方工具链契约解析 `Toolchains:`、`System:`、`Targets:` 和可安装列表: + toolchain 与 target 分轴处理,首版 UI 不提供结构化 `--target` 选择器。 +- 全局默认选择过滤普通 target-only payload,保留 mcpp 在 Windows 上将 host GCC 映射到 + MinGW payload 的官方规则,并明确设置 host 默认会清空 `default_target`; + 安装入口接受省略版本和 namespace,把兼容 spec 安全规范化为单个参数后交给 mcpp 最终解析; + MSVC 走系统检测,已知 target 别名走对应 payload,泛化 triple 由 mcpp 校验。 +- 工具链安装和全局默认修改均需要用户确认;Restricted Mode 不执行 mcpp 或其他外部工具。 + +## 0.1.5 + +- 打开已有编译数据库的 LLVM mcpp 工程时自动执行 `clangd --check`,状态栏无需点击即可显示“模块可用”或“模块不可用”。 +- 编译数据库创建或变化后自动重新检查模块支持;工程内合并重复事件,共享 clangd 操作全局串行,只允许当前活动工程接管窗口级配置,并且只接受最新检查结果。 +- 修改 `mcpp.clangd.path` 或模块支持模式后自动复查,并为直接 clangd 检查增加 60 秒超时。 +- 收紧 Restricted Mode:未受信任工作区不执行 CDB 编译器、mcpp 或 clangd,也不修改 clangd 配置;授予信任后自动协调。 +- 补充独立安装 mcpp 的配置说明;`mcpp.path`、CDB 中的编译器与 `mcpp.clangd.path` 相互独立,`clang-tidy` 和 `clang-format` 不是当前扩展的运行依赖。 + +## 0.1.4 + +- 缺少编译数据库时,状态栏改为直接执行“刷新编译数据库”;CDB 创建或变化后会自动重新配置并重启 clangd,不再要求重载窗口。 +- 增加 `mcpp.path` 设置,允许 macOS GUI 环境显式指定 mcpp 可执行文件。 +- `mcpp build` 失败后仍会检查现有 CDB;IDE 配置成功与构建失败分别提示,不再误报 clangd 已刷新。 +- Restricted Mode 下不会执行 `mcpp build`、依赖安装或 `build.mcpp`。 +- 在 README 中记录无需正式构建即可获得模块语义能力的长期核心接口、CDB 合同和插件演进计划。 + +## 0.1.3 + +- 修复 C++ 模块语法未实际注入 `source.cpp` 的问题,`import`、模块名和模块分区现在会使用扩展提供的 TextMate scope。 +- 增加扩展清单回归测试,防止语法注入注册字段再次写错。 + +## 0.1.2 + +- 在扩展激活和刷新编译数据库时自动配置匹配的 clangd,避免官方 clangd 扩展启动到 xlings shim。 +- 模块语法高亮支持未输入分号的编辑中间态,并补充 `.mpp`、`.ccm` 文件关联。 +- 避免重载窗口期间异步检查向已关闭的输出频道写入。 + +## 0.1.1 + +- 修复 `clangd.arguments` 中 `${workspaceFolder}` 未解析导致 clangd 忽略编译数据库的问题。 +- 从 mcpp 的 `.mcpp` 工具链路径自动查找用户目录下 `.xlings` 中匹配版本的 clangd。 + +## 0.1.0 + +- 为 LLVM 后端的 mcpp 工程配置官方 clangd 扩展。 +- 通过直接执行 `clangd --check` 诊断 PCM 和工具链不匹配。 +- 将 GCC、MSVC 模块工程降级为语法高亮,并显示明确的能力提示。 +- 增加覆盖声明、导入、模块名和分区的 C++ 模块注入语法。 diff --git a/mide-plugins/vscode/LICENSE b/mide-plugins/vscode/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/mide-plugins/vscode/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/mide-plugins/vscode/README.md b/mide-plugins/vscode/README.md index 18c8212..d2a43fb 100644 --- a/mide-plugins/vscode/README.md +++ b/mide-plugins/vscode/README.md @@ -1,59 +1,92 @@ -# mcpp for Visual Studio Code +

+ mcpp logo +

-**English** | [简体中文](https://github.com/mcpp-community/mcpp-ide/blob/main/README.zh-CN.md) +# mcpp for VS Code -Build, run, test, debug and pack [mcpp](https://github.com/mcpp-community/mcpp) C++23 module -projects, browse their libraries, dependency and module graphs, targets and platforms, and manage -mcpp's shared build cache. +English | [简体中文](README.zh-CN.md) -This extension is the VS Code adapter of [mcpp-ide](https://github.com/mcpp-community/mcpp-ide). -Its decisions are made by the mcpp-ide backend (`mcpp-ide serve`), which reads only mcpp's -machine contract; JetBrains CLion has the same features through mcpp-clion, and Neovim through -mcpp-nvim. +The VS Code front end for the [mcpp](https://github.com/mcpp-community/mcpp) C++23 build tool: +project discovery, `mcpp build` / `run` / `test` / `clean` as VS Code tasks, toolchain management, +`mcpp.toml` and `build.mcpp` editing help, the package library view, and cache views with graded +cleanup. C++ module semantics — diagnostics, completion, hover, definitions, references, the module +graph — come from `sunrisepeak.mcpp-language-server`; this extension never starts an LSP client of +its own and only **forwards** to it ([docs/architecture.md](docs/architecture.md)). -## Language features come from mcppls +## Related projects -Completion, navigation, highlighting and diagnostics of C++ files come from the -[C++ Modules language server](https://marketplace.visualstudio.com/items?itemName=sunrisepeak.mcpp-language-server) -(mcppls), which this extension installs with it (an extension pack: it can be uninstalled -separately). This extension registers no language service. mcppls 0.0.18 and later refresh after -every build, whoever runs it; the project view says when the installed mcppls is older or missing. +| Project | What it is | Where | +| --- | --- | --- | +| [mcpp](https://github.com/mcpp-community/mcpp) | The C++23 build tool this extension drives | — | +| [mcpp-language-server](https://github.com/Sunrisepeak/mcpp-language-server) | The C++ Modules language service; a hard dependency of this extension | [Marketplace](https://marketplace.visualstudio.com/items?itemName=sunrisepeak.mcpp-language-server) · [Open VSX](https://open-vsx.org/extension/sunrisepeak/mcpp-language-server) | +| mcpp-vscode (this extension) | The VS Code front end | [Marketplace](https://marketplace.visualstudio.com/items?itemName=mcpp-community.mcpp-vscode) · [Open VSX](https://open-vsx.org/extension/mcpp-community/mcpp-vscode) · [Releases](https://github.com/mcpp-community/mcpp-ide/releases) | + +See [dependency graph navigation and exports](docs/dependency-graphs.md) for entry groups, importer depth, refresh behavior and SVG/PNG export. + +## Install + +Any of the three channels installs the same extension: + +- **Marketplace**: the link above, or search "mcpp" inside VS Code. +- **Open VSX**: the link above (VSCodium, Gitpod and friends; listed from 0.6.0 on). +- **GitHub Releases**: download the VSIX, then **Extensions: Install from VSIX...**, or + `code --install-extension mcpp-vscode-.vsix`. + +`package.json` declares `extensionDependencies: ["sunrisepeak.mcpp-language-server"]`, so VS Code +installs that extension automatically where a platform package exists — the field only accepts an +extension id, no version range ([docs/compatibility.md](docs/compatibility.md)). + +| Platform | mcppls package | This extension | +| --- | --- | --- | +| `linux-x64`, `linux-arm64`, `darwin-arm64`, `win32-x64` | yes | yes | +| `darwin-x64`, `win32-arm64` | no package | not activated | + +## Quick start + +1. Open a folder containing an `mcpp.toml` (or run **mcpp: New Project**). +2. Run **mcpp: Build** (`mcpp.build`); it runs in a dedicated task terminal. +3. Open the **mcpp** activity-bar container: **Project**, **Libraries** and **Cache** (collapsed by + default); the C++ Modules status and actions live in **Project → Basics**. + +Opening a project never runs `mcpp` on its own and never downloads a toolchain. ## Features -| Feature | Where | -|---|---| -| Build and test | Tasks of type `mcpp` in a terminal that shows mcpp's own output, in colour; closing it cancels the build | -| Run | The program in a terminal of its own, started the way `mcpp run` starts it | -| Debug | A launch configuration of type `mcpp`; the debugger is CodeLLDB, the C/C++ extension, lldb-dap or Native Debug. An ok image (`--target -openkal`) is debugged through `okrun` | -| Tests | The Testing view: every test of every project, run one or all | -| Problems | mcpp's diagnostics, located where mcpp reports them; mcpp.toml's problems from mcpp itself | -| Targets and platforms | The targets mcpp knows, their toolchains and the platform that configures each; the build target is chosen in the status bar | -| Libraries | The packages of mcpp's indexes; `mcpp add` from a package's page | -| Graphs | The package dependency graph and the module graph of a project | -| Cache | The shared build cache by kind, package and age; cleanups with a preview and a confirmation at their level of danger | -| Pack | The formats a project can be packed into, and the result | -| mcpp.toml | Completion, hover and outline from mcpp's own key registry | +- **mcpp CLI and tasks** — build, run, test, clean, the quick menu, toolchains: [docs/commands.md](docs/commands.md). +- **`mcpp.toml` editing** — completion of tables, keys, values and dependency versions, hovers, go-to-definition, seven structural diagnostics, no formatting, from the mcpp-ide backend's rules layer: [docs/rules.md](../../docs/rules.md). +- **`build.mcpp` help** — `build.mcpp` is a `cpp` file that C++ Modules compiles as the build program; the backend's rules layer adds the `mcpp::…` directive completion, hovers and snippets and the SPEC-007 rule checks no compiler makes: [docs/rules.md](../../docs/rules.md). +- **Libraries** — browse the official mcpp-index offline, choose a target project and dependency type, and view available versions and verified example code. Other registry hits have separate previews. See [Libraries](docs/libraries.md). +- **Cache views and cleanup** — project artifacts and the shared build cache, previewed before anything is deleted: [docs/cache.md](docs/cache.md). +- **C++ Modules** — the other extension's state and actions, in the project view and the status-bar menu, forwarded not reimplemented: [docs/commands.md](docs/commands.md). +- **Debug, tests, targets, packing** — **mcpp: Debug** and the `mcpp` launch type (CodeLLDB, the C/C++ extension or lldb-dap), the Testing tree, **mcpp: Select the Build Target** (target sets among its rows with mcpp 2026.10.30.1 and later), **mcpp: Pack**, and build on save (`mcpp.build.onSave`). +- **Settings and diagnostics** — a registry-backed settings panel (`mcpp.openSettings`) and a copyable environment self-check (`mcpp.selfCheck`): [docs/settings.md](docs/settings.md). `mcpp.ui.language` overrides our messages and panels only; palette titles always follow VS Code ([docs/architecture.md](docs/architecture.md#language)). + +## The backend -## Requirements +The extension carries the [mcpp-ide](https://github.com/mcpp-community/mcpp-ide) backend +(`backend/`: one ok-image bundle and okrun for Linux x64/arm64, macOS arm64 and Windows x64) and runs +mcpp and mcppls through it; `mcpp.backendPath` names another. The CLion plugin shows the same pages +over the same backend. -- mcpp 2026.10.26.1 or later (`xlings install mcpp`). -- The mcpp-ide backend: shipped inside the extension for every system (one ok-image bundle run by - the okrun the extension carries for Linux x64/arm64, macOS arm64 and Windows x64, or by okrun on - PATH elsewhere); otherwise `xlings install mcpp-ide`, or set `mcpp.ide.backendPath` (a program or - an `.okb`). +## Troubleshooting -## Upgrading from 0.9 +Look first at the **mcpp** output channel, then run **mcpp: Environment Self-check** +(`mcpp.selfCheck`). The common cases — the dependency is missing, the language service stays on +"no status yet", `mcpp.path` versus `PATH`, a build that finished without refreshing the language +service — are in [docs/troubleshooting.md](docs/troubleshooting.md). -The extension ID is unchanged (`mcpp-community.mcpp-vscode`), and so are its command IDs and -setting keys. Settings that described the removed TOML checks are kept and marked deprecated; the -problems of mcpp.toml now come from mcpp. +## Development -## Untrusted workspaces +`npm ci`, `npm run compile`, `npm test` (the gates first, then the unit tests, the parity gates +against 0.9.0 among them), `npm run test:e2e`, `npm run test:webui` (the CLion workbench in +Chromium), `npm run gen` (the declarations from the repository's `spec/`), `npm run package`, and `node tools/dev-profile.mjs` for a throwaway profile under `.dev-profile/` +that never touches your real one. -In an untrusted workspace mcpp runs with the effects listed in `mcpp.trust.denyEffects` denied -(`mcpp --deny`): a command that needs one of them stops before it and says so. +Release: the extension is released with mcpp-ide (a `v` tag of that repository). The +workflow runs the tests, packages one VSIX with the backend, and publishes that same artifact to +GitHub Releases and Open VSX. Upload that VSIX manually through the Visual Studio Marketplace +publisher portal. ## License -Apache-2.0 +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/mide-plugins/vscode/README.zh-CN.md b/mide-plugins/vscode/README.zh-CN.md new file mode 100644 index 0000000..bc97ffb --- /dev/null +++ b/mide-plugins/vscode/README.zh-CN.md @@ -0,0 +1,88 @@ +

+ mcpp 标志 +

+ +# mcpp for VS Code + +[English](README.md) | 简体中文 + +[mcpp](https://github.com/mcpp-community/mcpp)(C++23 构建工具)的 VS Code 前端:发现 mcpp +工程,把 `mcpp build` / `run` / `test` / `clean` 作为 VS Code 任务运行,管理工具链,为 +`mcpp.toml` 与 `build.mcpp` 提供编辑帮助,浏览包库生态,并展示构建缓存的占用与分级清理。 +C++ 模块语义——诊断、补全、悬停、定义、引用、模块图——来自 +`sunrisepeak.mcpp-language-server`;本扩展不会自行启动 LSP 客户端,对它只**转发**不重写 +([docs/architecture.md](docs/architecture.md))。 + +## 相关项目 + +| 项目 | 是什么 | 入口 | +| --- | --- | --- | +| [mcpp](https://github.com/mcpp-community/mcpp) | 本扩展驱动的 C++23 构建工具 | — | +| [mcpp-language-server](https://github.com/Sunrisepeak/mcpp-language-server) | C++ Modules 语言服务;本扩展的硬依赖 | [Marketplace](https://marketplace.visualstudio.com/items?itemName=sunrisepeak.mcpp-language-server) · [Open VSX](https://open-vsx.org/extension/sunrisepeak/mcpp-language-server) | +| mcpp-vscode(本扩展) | VS Code 前端 | [Marketplace](https://marketplace.visualstudio.com/items?itemName=mcpp-community.mcpp-vscode) · [Open VSX](https://open-vsx.org/extension/mcpp-community/mcpp-vscode) · [Releases](https://github.com/mcpp-community/mcpp-ide/releases) | + +模块入口分组、父节点深度、局部展开、刷新和 SVG/PNG 导出见 [依赖图使用说明](docs/dependency-graphs.md)。 + +## 安装 + +三个渠道装到的是同一个扩展: + +- **Marketplace**:点上面的链接,或在 VS Code 里搜索 "mcpp"。 +- **Open VSX**:点上面的链接(VSCodium、Gitpod 等;自 0.6.0 起上架)。 +- **GitHub Releases**:下载 VSIX 后用 **Extensions: Install from VSIX...**,或执行 + `code --install-extension mcpp-vscode-.vsix`。 + +`package.json` 声明了 `extensionDependencies: ["sunrisepeak.mcpp-language-server"]`,因此在有 +对应平台包的机器上 VS Code 会自动安装它——该字段只接受扩展 ID,无法固定版本范围 +([docs/compatibility.md](docs/compatibility.md))。 + +| 平台 | mcppls 包 | 本扩展 | +| --- | --- | --- | +| `linux-x64`、`linux-arm64`、`darwin-arm64`、`win32-x64` | 有 | 可用 | +| `darwin-x64`、`win32-arm64` | 无包 | 不会被激活 | + +## 快速开始 + +1. 打开包含 `mcpp.toml` 的文件夹(或运行 **mcpp: 新建工程**)。 +2. 运行 **mcpp: 构建**(`mcpp.build`);它会在专用任务终端里执行。 +3. 打开活动栏的 **mcpp** 容器:**工程**、**库生态** 与 **缓存**(默认折叠);C++ Modules 的 + 状态与操作在 **工程 → 基本信息** 里。 + +打开工程本身不会执行 `mcpp`,也不会下载或切换工具链。 + +## 功能 + +- **mcpp CLI 与任务** —— 构建、运行、测试、清理、快捷菜单、工具链:[docs/commands.md](docs/commands.md)。 +- **`mcpp.toml` 编辑** —— 结构补全、悬停、跳转与七条结构诊断,不做格式化:[docs/mcpp-toml.md](docs/mcpp-toml.md)。 +- **`build.mcpp` 辅助** —— `build.mcpp` 是普通的 `cpp` 文件,由 C++ Modules 当作构建程序编译;本扩展补上 `mcpp::…` 指令的补全、悬停、代码片段,以及编译器不会检查的 SPEC-007 规则:[docs/build-script.md](docs/build-script.md)。 +- **库生态** —— 离线浏览本机已刷新的包索引;详情页展示真实示例代码、版本矩阵,以及工程是否已依赖它。 +- **缓存视图与清理** —— 工程产物与共享缓存,删除前一律先给出预览:[docs/cache.md](docs/cache.md)。 +- **C++ Modules** —— 对方扩展的状态与操作,集中在工程视图与状态栏快捷菜单,只转发不重写:[docs/commands.md](docs/commands.md)。 +- **调试、测试、目标、打包** —— **mcpp: 调试**与 `mcpp` 启动类型(CodeLLDB、C/C++ 扩展或 lldb-dap)、测试树、**mcpp: 选择构建目标**(mcpp 2026.10.30.1 起含目标集合行)、**mcpp: 打包**,以及保存时构建(`mcpp.build.onSave`)。 +- **设置与诊断** —— 注册表驱动的设置面板(`mcpp.openSettings`)与可复制的环境自检(`mcpp.selfCheck`):[docs/settings.md](docs/settings.md)。`mcpp.ui.language` 只覆盖本扩展的运行时提示与面板;命令面板标题始终跟随 VS Code([docs/architecture.md](docs/architecture.md#language))。 + +## 后端 + +扩展携带 [mcpp-ide](https://github.com/mcpp-community/mcpp-ide) 后端(`backend/`:一个 ok 映像包,以及 +Linux x64/arm64、macOS arm64、Windows x64 的 okrun),mcpp 与 mcppls 都经后端运行;`mcpp.backendPath` +可指定另一个后端。CLion 插件在同一后端上显示同一套页面。 + +## 故障排查 + +先看 **mcpp** 输出频道,再运行 **mcpp: 环境自检**(`mcpp.selfCheck`)。常见情形——依赖缺失、 +语言服务停在「暂无状态」、`mcpp.path` 与 `PATH`、构建结束后语言服务没刷新——见 +[docs/troubleshooting.md](docs/troubleshooting.md)。 + +## 开发 + +`npm ci`、`npm run compile`、`npm test`(先门禁后单测,含对 0.9.0 的一致性门)、`npm run test:e2e`、 +`npm run test:webui`(CLion 工作台在 Chromium 中运行)、`npm run gen`(由仓库的 `spec/` 生成声明)、 +`npm run package`,以及 `node tools/dev-profile.mjs`(在 `.dev-profile/` 下建一次性 profile, +不会碰你自己的)。 + +发布:扩展随 mcpp-ide 发布(该仓库的 `v` tag)。工作流运行测试、打包携带后端的同一只 VSIX, +并发布到 GitHub Releases 与 Open VSX。官方 Marketplace 使用 Release 中的同一 VSIX 手动上传。 + +## 许可证 + +Apache-2.0,见 [LICENSE](LICENSE)。 diff --git a/mide-plugins/vscode/data/config-registry.json b/mide-plugins/vscode/data/config-registry.json new file mode 100644 index 0000000..cd46686 --- /dev/null +++ b/mide-plugins/vscode/data/config-registry.json @@ -0,0 +1,1226 @@ +{ + "version": 1, + "groups": [ + { + "id": "project", + "order": 10, + "title": "Project" + }, + { + "id": "tasks", + "order": 20, + "title": "Tasks" + }, + { + "id": "languageService", + "order": 30, + "title": "C++ Modules language service" + }, + { + "id": "toml", + "order": 40, + "title": "mcpp.toml editing" + }, + { + "id": "buildScript", + "order": 50, + "title": "build.mcpp editing" + }, + { + "id": "cache", + "order": 60, + "title": "Cache" + }, + { + "id": "views", + "order": 70, + "title": "Views" + }, + { + "id": "ui", + "order": 80, + "title": "Interface" + }, + { + "id": "diagnostics", + "order": 90, + "title": "Diagnostics and logging" + }, + { + "id": "advanced", + "order": 100, + "title": "Advanced" + } + ], + "settings": [ + { + "key": "mcpp.path", + "type": "string", + "default": "", + "scope": "resource", + "group": "project", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.1.0", + "title": "Set the mcpp executable path", + "description": "Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process." + }, + { + "key": "mcpp.project.discoveryBoundary", + "type": "string", + "default": "workspaceFolder", + "enum": [ + "workspaceFolder", + "filesystem" + ], + "scope": "resource", + "group": "project", + "order": 20, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Limit project discovery scope", + "description": "How far the extension walks to find mcpp.toml. \"filesystem\" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees." + }, + { + "key": "mcpp.task.clearTerminal", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Clear the task terminal", + "description": "Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's.", + "order": 1 + }, + { + "key": "mcpp.task.buildArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 10, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp build", + "description": "Extra arguments appended to the mcpp build task. Applied on the next build.", + "items": { + "type": "string" + } + }, + { + "key": "mcpp.task.runArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 20, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp run", + "description": "Extra arguments appended to the mcpp run task. Applied on the next run.", + "items": { + "type": "string" + } + }, + { + "key": "mcpp.task.testArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 30, + "tier": "public", + "applies": "next-build", + "since": "0.5.0", + "title": "Add arguments to mcpp test", + "description": "Extra arguments appended to the mcpp test task. Applied on the next test run.", + "items": { + "type": "string" + } + }, + { + "key": "mcpp.task.cleanArgs", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 40, + "tier": "public", + "applies": "next-clean", + "since": "0.5.0", + "title": "Add arguments to mcpp clean", + "description": "Extra arguments appended to the mcpp clean task. Applied on the next clean.", + "items": { + "type": "string" + } + }, + { + "key": "mcpp.task.confirmClean", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 50, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm before cleaning", + "description": "Ask for confirmation before a clean task deletes the target directory of the project." + }, + { + "key": "mcpp.task.revealTerminal", + "type": "string", + "default": "always", + "enum": [ + "always", + "onFailure", + "never" + ], + "scope": "resource", + "group": "tasks", + "order": 60, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Reveal the task terminal", + "description": "When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never." + }, + { + "key": "mcpp.task.focusTerminal", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "tasks", + "order": 70, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Focus the task terminal", + "description": "Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away." + }, + { + "key": "mcpp.task.problemMatcher", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 80, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Parse task problems", + "description": "Parse compiler output of the mcpp build and test tasks into the Problems panel." + }, + { + "key": "mcpp.task.editorTitleButtons", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 90, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show editor title buttons", + "description": "Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project." + }, + { + "key": "mcpp.build.onSave", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "tasks", + "order": 100, + "tier": "public", + "applies": "immediate", + "since": "1.0.0", + "title": "Build when a file is saved", + "description": "Start a build of the file's project when a source or mcpp.toml is saved. A save while a build waits replaces it; a running build is cancelled and started again." + }, + { + "key": "mcpp.build.profile", + "type": "string", + "default": "dev", + "enum": [ + "dev", + "release" + ], + "scope": "resource", + "group": "tasks", + "order": 110, + "tier": "public", + "applies": "immediate", + "since": "1.0.0", + "title": "Choose the build profile", + "description": "The profile build, run, test and debug use: dev (unoptimised, debug information) or release." + }, + { + "key": "mcpp.build.target", + "type": "string", + "default": "", + "scope": "resource", + "group": "tasks", + "order": 120, + "tier": "public", + "applies": "immediate", + "since": "1.0.0", + "title": "Choose the build target", + "description": "The target build, run, test, debug and pack use, as mcpp names it (x86_64-linux-musl, x86_64-openkal). Empty: this machine. The Targets panel lists the targets and the platform that configures each." + }, + { + "key": "mcpp.debug.engine", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "cppdbg", + "lldb", + "lldb-dap", + "gdb" + ], + "scope": "resource", + "group": "tasks", + "order": 130, + "tier": "public", + "applies": "immediate", + "since": "1.0.0", + "title": "Choose the debugger", + "description": "The debug adapter a debug session uses. auto: CodeLLDB when it is installed, then the C/C++ extension's gdb or lldb, then lldb-dap." + }, + { + "key": "mcpp.codeLens.enable", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "tasks", + "order": 140, + "tier": "public", + "applies": "immediate", + "since": "1.1.0", + "title": "Show Run and Debug above main", + "description": "A CodeLens above the main of a program or a test: Run and Debug. Where C++ Modules serves the file its position is the language service's; otherwise the line is read from the text." + }, + { + "key": "mcpp.test.macros", + "type": "array", + "default": [], + "scope": "resource", + "group": "tasks", + "order": 150, + "tier": "advanced", + "applies": "immediate", + "since": "1.1.0", + "title": "Add a test framework's macros", + "description": "Macros that write a test framework's cases, each as framework:MACRO (gtest:MY_TEST). A test program's cases are listed only when its source calls one of its framework's macros." + }, + { + "key": "mcpp.languageService.menuItems", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show language-service entries in the menu", + "description": "Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands.", + "order": 1 + }, + { + "key": "mcpp.languageService.confirmResetCache", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm resetting the workspace cache", + "description": "Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens.", + "order": 2 + }, + { + "key": "mcpp.languageService.refreshAfterBuild", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "reload", + "restart", + "off" + ], + "scope": "resource", + "group": "languageService", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Refresh language service after build", + "description": "How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing." + }, + { + "key": "mcpp.languageService.notifyOnDegraded", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "order": 20, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Notify when the language service degrades", + "description": "Show a notification when the C++ Modules language service reports a degraded state." + }, + { + "key": "mcpp.languageService.readState", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "languageService", + "order": 30, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Read language service state", + "description": "Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself." + }, + { + "key": "mcpp.languageService.stateRefreshSeconds", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "languageService", + "order": 40, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the state refresh interval", + "description": "Re-read the language service state every this many seconds. 0 disables the timer." + }, + { + "key": "mcpp.toml.completion", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "aliases": [ + "mcpp.tomlCompletion" + ], + "title": "Complete mcpp.toml keys", + "description": "Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias." + }, + { + "key": "mcpp.toml.hover", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 20, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show mcpp.toml hovers", + "description": "Show documentation for sections and keys when hovering in mcpp.toml." + }, + { + "key": "mcpp.toml.navigation", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 30, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Navigate inside mcpp.toml", + "description": "Enable go-to-definition for workspace, path and feature references in mcpp.toml." + }, + { + "key": "mcpp.toml.diagnostics.enabled", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "toml", + "order": 40, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable mcpp.toml diagnostics", + "description": "Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting." + }, + { + "key": "mcpp.toml.diagnostics.syntax", + "type": "string", + "default": "error", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 50, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set syntax diagnostic severity", + "description": "Severity used for mcpp.toml syntax errors.", + "deprecationMessage": "mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect." + }, + { + "key": "mcpp.toml.diagnostics.unknownSection", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 60, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set unknown section severity", + "description": "Severity used for sections that mcpp does not recognise.", + "deprecationMessage": "mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect." + }, + { + "key": "mcpp.toml.diagnostics.unknownKey", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 70, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set unknown key severity", + "description": "Severity used for keys that mcpp does not recognise.", + "deprecationMessage": "mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect." + }, + { + "key": "mcpp.toml.diagnostics.planeSeparation", + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 80, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set plane separation severity", + "description": "Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane.", + "deprecationMessage": "mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect." + }, + { + "key": "mcpp.toml.diagnostics.legacyKeys", + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "toml", + "order": 90, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set legacy key severity", + "description": "Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated.", + "deprecationMessage": "mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect." + }, + { + "key": "mcpp.toml.indexCompletion", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "toml", + "order": 100, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Complete dependency versions", + "description": "Query the package index to complete dependency versions. Off by default because it needs network access." + }, + { + "key": "mcpp.toml.indexCompletionTimeoutSeconds", + "type": "number", + "default": 20, + "minimum": 1, + "maximum": 600, + "scope": "resource", + "group": "toml", + "order": 110, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the index query timeout", + "description": "How long one package index query may run before it is abandoned and completion falls back to local data, in seconds." + }, + { + "key": "mcpp.buildScript.intelligence", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable build.mcpp intelligence", + "description": "Turn completion and hover support for build.mcpp on or off." + }, + { + "key": "mcpp.buildScript.diagnostics", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 20, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Enable build.mcpp diagnostics", + "description": "Validate build.mcpp against the build API mcpp actually ships." + }, + { + "key": "mcpp.buildScript.diagnostics.severity", + "type": "string", + "default": "warning", + "enum": [ + "warning", + "info", + "off" + ], + "scope": "resource", + "group": "buildScript", + "order": 30, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set build.mcpp diagnostic severity", + "description": "Severity used for build.mcpp diagnostics." + }, + { + "key": "mcpp.buildScript.imports.knownModules", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 40, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Check imported modules", + "description": "Offer mcpp's own modules (std, std.compat, mcpp.*) in import completion. Hover still recognises them, and no import is ever reported missing, whichever way this is set." + }, + { + "key": "mcpp.buildScript.snippets", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "buildScript", + "order": 50, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Offer build.mcpp snippets", + "description": "Offer snippet completions for the mcpp build script API." + }, + { + "key": "mcpp.cache.statusBar", + "type": "boolean", + "default": false, + "scope": "resource", + "group": "cache", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Show cache size in status bar", + "description": "Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet." + }, + { + "key": "mcpp.cache.warnAboveGiB", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 20, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Warn above a cache size", + "description": "Warn when the mcpp global cache grows past this many GiB. 0 disables the warning." + }, + { + "key": "mcpp.cache.staleDays", + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", + "group": "cache", + "order": 30, + "tier": "public", + "applies": "next-clean", + "since": "0.5.0", + "title": "Mark stale cache entries", + "description": "Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule." + }, + { + "key": "mcpp.cache.autoRefreshSeconds", + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "group": "cache", + "order": 40, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Refresh the cache view automatically", + "description": "Re-read the cache statistics every this many seconds. 0 disables automatic refreshing." + }, + { + "key": "mcpp.cache.estimateProjectBytes", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "cache", + "order": 50, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Estimate project artifact size", + "description": "Measure the target directory of the current project so the cache view can show its size." + }, + { + "key": "mcpp.cache.showLegacy", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "cache", + "order": 60, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show legacy cache entries", + "description": "Include cache entries written by older mcpp versions in the cache view and in cleaning." + }, + { + "key": "mcpp.cache.gc.defaultBudgetGiB", + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 70, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the default cleanup budget", + "description": "Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run." + }, + { + "key": "mcpp.cache.gc.confirmAboveGiB", + "type": "number", + "default": 1, + "minimum": 0, + "scope": "resource", + "group": "cache", + "order": 80, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm large cache cleanup", + "description": "Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup." + }, + { + "key": "mcpp.cache.pruneAgeDays", + "type": "number", + "default": 30, + "minimum": 1, + "scope": "resource", + "group": "cache", + "order": 90, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the pruning age", + "description": "Default age in days used when pruning cache entries older than a threshold." + }, + { + "key": "mcpp.views.project.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 10, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the project view", + "description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads." + }, + { + "key": "mcpp.views.cache.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 20, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the cache view", + "description": "Show the mcpp cache view in the activity bar. Takes effect after the view container reloads." + }, + { + "key": "mcpp.views.enabled", + "group": "views", + "tier": "public", + "type": "boolean", + "default": true, + "scope": "window", + "applies": "immediate", + "since": "0.6.0", + "title": "Hide the mcpp view contents", + "description": "Turns every mcpp view off at once. The view contents disappear, so the container shows nothing; whether VS Code also removes the mcpp icon from the activity bar is up to VS Code itself, because an extension cannot mark its container as hidden-when-empty. Right-click the icon to hide it there.", + "order": 30 + }, + { + "key": "mcpp.views.library.show", + "group": "views", + "tier": "public", + "type": "boolean", + "default": true, + "scope": "window", + "applies": "view-reload", + "since": "0.6.0", + "title": "Show the mcpp library ecosystem view", + "description": "Show the view that browses the package index mcpp has already refreshed locally.", + "order": 40 + }, + { + "key": "mcpp.library.indexPath", + "group": "views", + "tier": "advanced", + "type": "string", + "default": "", + "scope": "window", + "applies": "immediate", + "since": "0.6.0", + "title": "Path to a mcpp package index checkout", + "description": "Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source.", + "order": 50 + }, + { + "key": "mcpp.library.networkSearch", + "group": "views", + "tier": "public", + "type": "boolean", + "default": false, + "scope": "window", + "applies": "immediate", + "since": "0.6.0", + "title": "Search every configured registry over the network", + "description": "Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly.", + "order": 60 + }, + { + "key": "mcpp.views.languageServer.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "views", + "order": 70, + "tier": "public", + "applies": "view-reload", + "since": "0.5.0", + "title": "Show the language-service block in the project view", + "description": "The C++ Modules state is folded into the project view's basic information. The collapsed row always shows a status icon, so a problem is visible without expanding it." + }, + { + "key": "mcpp.views.cache.topN", + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 50, + "scope": "window", + "group": "views", + "order": 80, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set cache list length", + "description": "How many entries the cache view lists per group." + }, + { + "key": "mcpp.views.cache.ageBuckets", + "type": "array", + "default": [ + "1d", + "7d", + "30d" + ], + "scope": "window", + "group": "views", + "order": 90, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set cache age buckets", + "description": "Age buckets used to group cache entries in the cache view, written as \"1d\", \"7d\" and so on." + }, + { + "key": "mcpp.ui.confirmDestructiveOnly", + "type": "boolean", + "default": true, + "scope": "window", + "group": "ui", + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Confirm only destructive actions", + "description": "Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less.", + "order": 1 + }, + { + "key": "mcpp.ui.language", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "en", + "zh-cn" + ], + "scope": "window", + "group": "ui", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the extension language", + "description": "Language for extension messages and panels. \"auto\" follows VS Code; command titles and settings labels always follow the VS Code display language." + }, + { + "key": "mcpp.ui.statusBar.show", + "type": "boolean", + "default": true, + "scope": "window", + "group": "ui", + "order": 20, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show the mcpp status bar item", + "description": "Show the main mcpp status bar item." + }, + { + "key": "mcpp.ui.statusBar.showLanguageServer", + "type": "boolean", + "default": false, + "scope": "window", + "group": "ui", + "order": 30, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Show language service status", + "description": "Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item." + }, + { + "key": "mcpp.ui.statusBar.background", + "type": "string", + "default": "none", + "enum": [ + "none", + "warning", + "error" + ], + "scope": "window", + "group": "ui", + "order": 40, + "tier": "advanced", + "applies": "immediate", + "since": "0.6.0", + "title": "Highlight the mcpp status bar item", + "description": "VS Code accepts only two background colours for a status bar entry, \"statusBarItem.warningBackground\" and \"statusBarItem.errorBackground\", and it overrides the foreground so the text stays readable; any other colour is dropped. The mcpp item is plain by default; \"warning\" gives it the theme's amber chip and \"error\" its red one." + }, + { + "key": "mcpp.ui.notifications.success", + "type": "string", + "default": "statusBar", + "enum": [ + "silent", + "statusBar", + "toast" + ], + "scope": "window", + "group": "ui", + "order": 50, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Report successful operations", + "description": "How successful mcpp operations are reported: in the status bar only, as a toast, or not at all." + }, + { + "key": "mcpp.ui.notifications.dedupeMinutes", + "type": "number", + "default": 5, + "minimum": 0, + "maximum": 1440, + "scope": "window", + "group": "ui", + "order": 60, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Deduplicate notifications", + "description": "Suppress a repeated notification for this many minutes. 0 shows every notification." + }, + { + "key": "mcpp.ui.numberFormat", + "type": "string", + "default": "binary", + "enum": [ + "binary", + "decimal" + ], + "scope": "window", + "group": "ui", + "order": 70, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Choose number formatting", + "description": "Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB)." + }, + { + "key": "mcpp.log.level", + "type": "string", + "default": "info", + "enum": [ + "error", + "warn", + "info", + "debug" + ], + "scope": "window", + "group": "diagnostics", + "order": 10, + "tier": "public", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the log level", + "description": "Amount of detail written to the mcpp output channel. Debug output is verbose." + }, + { + "key": "mcpp.diagnostics.selfCheckOnStartup", + "type": "boolean", + "default": false, + "scope": "window", + "group": "diagnostics", + "order": 20, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Run a self check at startup", + "description": "Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp." + }, + { + "key": "mcpp.runtime.timeoutSeconds", + "type": "number", + "default": 30, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "group": "advanced", + "order": 10, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Set the command timeout", + "description": "How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout." + }, + { + "key": "mcpp.runtime.maxOutputMiB", + "type": "number", + "default": 16, + "minimum": 1, + "maximum": 1024, + "scope": "resource", + "group": "advanced", + "order": 20, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Cap captured command output", + "description": "Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept." + }, + { + "key": "mcpp.runtime.concurrency", + "type": "string", + "default": "perProject", + "enum": [ + "perProject", + "global" + ], + "scope": "resource", + "group": "advanced", + "order": 30, + "tier": "advanced", + "applies": "immediate", + "since": "0.5.0", + "title": "Limit concurrent mcpp commands", + "description": "Whether the one-command-at-a-time rule applies to each project separately or to the whole window." + }, + { + "key": "mcpp.clangd.path", + "type": "string", + "default": "", + "scope": "resource", + "group": "advanced", + "order": 40, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated clangd path", + "description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.modulesSupport", + "type": "string", + "default": "auto", + "enum": [ + "auto", + "on", + "off" + ], + "scope": "resource", + "group": "advanced", + "order": 50, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated module support mode", + "description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.configureCppTools", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "advanced", + "order": 60, + "tier": "advanced", + "applies": "immediate", + "since": "0.4.0", + "deprecated": true, + "deprecationMessage": "Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "title": "Deprecated C++ tool configuration", + "description": "Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting." + }, + { + "key": "mcpp.tomlCompletion", + "type": "boolean", + "default": true, + "scope": "resource", + "group": "advanced", + "order": 70, + "tier": "advanced", + "applies": "immediate", + "since": "0.2.6", + "deprecated": true, + "deprecationMessage": "mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "title": "Deprecated mcpp.toml completion", + "description": "Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead." + }, + { + "key": "mcpp.ide.backendPath", + "type": "string", + "default": "", + "scope": "machine", + "group": "advanced", + "order": 80, + "tier": "advanced", + "applies": "restart", + "since": "1.0.0", + "title": "Set the mcpp-ide backend path", + "description": "The mcpp-ide program the extension starts. Empty: the backend shipped with the extension, then the one xlings installed, then PATH." + }, + { + "key": "mcpp.trust.denyEffects", + "type": "array", + "default": [ + "exec-build-script", + "network" + ], + "scope": "window", + "group": "advanced", + "order": 90, + "tier": "advanced", + "applies": "immediate", + "since": "1.0.0", + "title": "Effects denied in an untrusted workspace", + "description": "What mcpp may not do while the workspace is not trusted (mcpp --deny): a run that needs one of them stops before it and says so. Trusting the workspace lifts the list." + }, + { + "key": "mcpp.backend.logLevel", + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "debug" + ], + "scope": "window", + "group": "advanced", + "order": 100, + "tier": "advanced", + "applies": "immediate", + "since": "1.1.0", + "title": "Set the backend's log level", + "description": "What the mcpp-ide backend writes to the editor's log: errors only, warnings, what it does (the default), or every run of mcpp and mcppls with its arguments." + } + ] +} diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json new file mode 100644 index 0000000..2f7dbd6 --- /dev/null +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -0,0 +1,662 @@ +{ + "$(cloud-download) Install a toolchain…": "$(cloud-download) 安装工具链…", + "$(edit) Enter another compatible toolchain spec…": "$(edit) 输入其他兼容工具链 spec…", + "(size unknown)": "(大小未知)", + "About {0} would be freed ({1} entries).": "预计释放约 {0}({1} 个条目)。", + "Accepts family, family@version, namespace, partial versions and mcpp's compatible legacy spellings": "支持 family、family@version、namespace、部分版本和 mcpp 兼容旧拼写", + "Actions": "操作", + "Add to mcpp.toml": "添加到 mcpp.toml", + "Added": "已添加", + "Added {0} to mcpp.toml.": "已把 {0} 加入 mcpp.toml。", + "Adding {0}…": "正在添加 {0}…", + "Age distribution": "按最近使用时间分布", + "All": "全部", + "Already added": "已添加", + "Already within {0} GiB.": "当前已在 {0} GiB 以内。", + "Also empty the shared build cache": "同时清空共享构建缓存", + "An mcpp operation is already running.": "已有 mcpp 操作正在运行。", + "An mcpp operation is already running; not starting {0} now.": "已有 mcpp 操作正在运行,暂不启动 {0}。", + "An mcpp operation is already running; wait for it to finish and try again.": "已有 mcpp 操作正在运行,请等待完成后再试。", + "Array shorthand: implies the feature only": "数组简写:仅隐含 feature", + "Back to the toolchain installation flow": "回到工具链安装流程", + "Basics": "基本信息", + "Budget simulator": "预算模拟器", + "Build": "构建", + "Build and refresh the language service": "构建并刷新语言服务", + "Build cache": "构建缓存", + "Build configuration": "构建配置", + "Build environment (supplied by xlings)": "构建环境(xlings 供给)", + "Build profiles": "构建档案", + "Build shape": "构建形态", + "Build targets": "构建目标", + "Build the current mcpp project and refresh the C++ Modules language service?": "是否构建当前 mcpp 工程并刷新 C++ 模块语言服务?", + "Build-time dependencies (pulled at build time only, invisible at run time)": "构建期依赖(仅构建期拉取,运行时不可见)", + "Bytes and entries by how long ago they were last used.": "按最近使用时间统计的字节数与条目数。", + "C++ Modules (provided by the mcpp language server extension): {0}": "C++ Modules(由 mcpp 语言服务扩展提供):{0}", + "C++ Modules command failed: {0}": "C++ Modules 命令失败:{0}", + "C++ Modules is not installed": "未安装 C++ Modules", + "C++ Modules language service": "C++ Modules 语言服务", + "C++ Modules: capture the logs (report + bundle)": "C++ Modules:抓取日志(报告 + 压缩包)", + "C++ Modules: open the log": "C++ Modules:打开日志", + "C++ Modules: open the log folder": "C++ Modules:打开日志目录", + "C++ Modules: reset this workspace's cache": "C++ Modules:重置本工作区缓存", + "C++ Modules: restart the language server": "C++ Modules:重启语言服务器", + "C++ Modules: select the analysis context": "C++ Modules:选择分析上下文", + "C++ Modules: show the diagnostic report": "C++ Modules:查看诊断报告", + "C++ Modules: show the last captured bundle": "C++ Modules:定位最近抓取的压缩包", + "C++ Modules: show the module graph": "C++ Modules:查看模块图", + "C++ Modules: {0}": "C++ Modules:{0}", + "Cache actions": "缓存操作", + "Cache entry {0}": "缓存条目 {0}", + "Cache statistics": "缓存统计", + "Cache verification": "缓存校验", + "Cache warning": "缓存告警", + "Cancelled": "已取消", + "Capability binding (equivalent to --cap)": "capability 绑定(等价于 --cap)", + "Capability bindings (provider selection)": "capability 绑定(provider 选择)", + "Capture logs now": "现在抓取", + "Caret constraint (^) by default; ~, = and range combinations such as \">=1.0, <2.0\" are also supported.": "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", + "Changed from the default": "已偏离默认值", + "China mirror": "国内镜像", + "Choose a global default": "选择全局默认", + "Choose a project, toolchain, cache or C++ Modules action": "选择工程、工具链、缓存或 C++ Modules 操作", + "Choose the mcpp global default toolchain": "选择 mcpp 全局默认工具链", + "Choose the project location (2/2)": "选择项目位置(2/2)", + "Choosing here only changes the mcpp global configuration; it does not build the project": "选择后只修改 mcpp 全局配置,不会自动构建工程", + "Clean": "清理", + "Clean and build": "清理并构建", + "Clean old artifacts": "清理旧产物", + "Clean project artifacts": "清理项目产物", + "Clean stale artifacts": "清理过期产物", + "Clean target": "清理 target", + "Click a version to aim the command above at it.": "点一个版本,上面的命令就会指向它。", + "Collect a diagnostic report": "收集诊断报告", + "Collect the global cache to a budget": "将全局缓存收敛到预算内", + "Collect to this budget": "收敛到此预算", + "Command": "命令", + "Common commands": "常用命令", + "Compiler toolchain shorthand": "编译器工具链简写", + "Completing dependency versions runs `mcpp search`, which may use the network.": "补全依赖版本会调用 `mcpp search`,可能使用网络。", + "Composition by kind": "按类型的构成", + "Configuration per target triple": "按目标三元组的配置", + "Configuring the C++ Modules language service failed.": "C++ 模块语言服务配置失败。", + "Confirm a cleanup this large": "确认如此规模的清理", + "Confirm once more": "请再次确认", + "Confirm one-click setup": "确认一键配置", + "Create and open": "创建并打开", + "Create the project here": "在此创建项目", + "Current effective toolchain": "当前有效工具链", + "Current mcpp project": "当前 mcpp 工程", + "Currently effective item": "当前有效项", + "Data source": "数据来源", + "Declare dependency versions centrally; members inherit them with workspace = true": "集中声明依赖版本,成员用 workspace = true 继承", + "Declared in mcpp.toml; the resolved version comes from mcpp.lock.": "声明来自 mcpp.toml,解析值来自 mcpp.lock。", + "Default": "默认", + "Degraded": "降级", + "Delete the target/ directory of this project: {0}/target": "将删除当前工程的 target 目录:{0}/target", + "Dependencies": "依赖", + "Dependencies ({0})": "依赖 ({0})", + "Deprecated": "已弃用", + "Descriptor not readable": "描述符无法读取", + "Details": "详情", + "Detect": "检测", + "Development/test dependencies": "开发/测试依赖", + "Direction: disable.": "方向:停用。", + "Direction: enable.": "方向:启用。", + "Drop entries unused for a while": "按时间丢弃长期未用的条目", + "Effective target: host (mcpp shows no explicit target)": "有效 target:host(mcpp 未显示显式 target)", + "Effective target: {0}": "有效 target:{0}", + "Effective toolchain of this project": "当前工程有效工具链", + "Enter a family, family@version, family version, namespace or an mcpp-compatible spec": "请输入 family、family@version、family version、namespace 或 mcpp 兼容 spec", + "Enter a project name; a folder of that name is created at the location you pick": "输入项目名,将在所选位置创建同名项目文件夹", + "Enter a toolchain spec": "输入工具链 spec", + "Enter a whole number of GiB": "请输入整数 GiB", + "Entries": "条目", + "Environment self-check": "环境自检", + "Estimated size": "估算大小", + "Every entry matches its manifest.": "每个条目都与它的清单一致。", + "Every figure here is read from mcpp cache list, mcpp cache dir and a bounded walk of target/. Sizes are estimates; cleanup runs only after confirmation and never automatically.": "这里的数字来自 mcpp cache list、mcpp cache dir 以及对 target/ 的有界遍历。体积是估算值;清理只在确认后执行,绝不自动进行。", + "Example code": "示例代码", + "Example project: {0} — built and run by the index's CI, not written for this page.": "示例工程:{0} —— 由索引的 CI 实际构建并运行过,不是为这个页面写的。", + "Exit code {0}": "退出码 {0}", + "Explain this code with: {0}": "解释此退出码:{0}", + "Export a diagnostic bundle": "导出诊断包", + "Failed with exit code {0}": "失败,退出码 {0}", + "Feature definitions": "feature 定义", + "Filters": "筛选", + "Fine-tuning of the vendored filtering policy": "vendored 过滤策略微调", + "For example gcc, llvm@20.1.7, xim:gcc@16, msvc, mingw; mcpp normalises compatible spellings": "例如 gcc、llvm@20.1.7、xim:gcc@16、msvc、mingw;兼容写法由 mcpp 规范化", + "From the global default": "来自全局默认", + "From this project's mcpp.toml, overriding the global default": "来自当前项目 mcpp.toml,覆盖全局默认", + "Generated file (relative path → content, part of the fingerprint)": "生成文件(相对路径 → 内容,进指纹)", + "Generated files (path → content)": "生成文件(路径 → 内容)", + "GiB": "GiB", + "Git dependency (one of tag / branch / rev)": "Git 依赖(tag / branch / rev 三选一)", + "Global build cache": "全局构建缓存", + "Global default: ": "全局默认:", + "Global default: {0}": "全局默认:{0}", + "Host runtime capabilities": "主机运行时能力", + "Host tool binary overrides": "host 工具二进制覆盖", + "Host tools produced by the dependency (must be a bin target of that package)": "依赖产出的 host 工具(须为该包的 bin target)", + "I understand this affects every mcpp project on this machine": "我明白这会影响本机所有 mcpp 工程", + "I understand this removes entries other mcpp projects may use": "我明白这会删除其它 mcpp 工程可能正在使用的条目", + "Include directories": "包含目录", + "Incomplete entries": "不完整条目", + "Index versions mcpp aggregates by family; no host payload is promised": "mcpp 按 family 聚合的索引版本;未承诺 host payload", + "Inspect the toolchain": "查看工具链", + "Install": "安装", + "Install C++ Modules": "安装 C++ Modules", + "Install an mcpp toolchain": "安装 mcpp 工具链", + "Install extension": "安装扩展", + "Install toolchain": "安装工具链", + "Installable: {0}": "可安装:{0}", + "Installed": "已安装", + "Invalid": "无效", + "Keep the shared build cache under": "将共享构建缓存保留在", + "Keep the shared build cache under how many GiB?": "共享构建缓存保留在多少 GiB 以内?", + "Label": "包", + "Language service": "语言服务", + "Largest packages": "占用最多的包", + "Largest packages (top {0})": "占用最多的包(前 {0})", + "Last use": "最近使用", + "Legacy compatibility field; new projects should use [package].standard": "旧版兼容字段;新项目请用 [package].standard", + "Library": "库", + "Library package {0}": "库包 {0}", + "Library root-module convention": "库根模块约定", + "License": "许可", + "Long dep spec: request a feature of that dependency": "长式 dep spec:请求该依赖的 feature", + "MSVC detection finished. Choose a global default from the latest list?": "MSVC 检测完成。是否从最新列表中选择全局默认?", + "Manifest reference": "清单参考", + "Metadata and assets compiled into the product (PE targets only)": "编译进产物的元数据与资产(仅 PE 目标)", + "Module graph": "模块图", + "More ({0})…": "其它 {0} 项…", + "Move them to my user settings": "移动到我的用户设置", + "Move them to this workspace": "移动到当前工作区", + "New mcpp project (1/2)": "新建 mcpp 工程(1/2)", + "New mcpp project…": "新建 mcpp 工程…", + "New project": "新建工程", + "Newest use: {0}": "最近使用:{0}", + "No cache entries were found.": "没有找到缓存条目。", + "No cache label was read.": "没有读到缓存标签。", + "No descriptor was read.": "没有读到任何描述符。", + "No diagnostic bundle has been written yet. Capturing the logs writes one zip with the report, the environment and the recent logs.": "还没有写过诊断压缩包。抓取日志会把报告、环境和最近的日志写成一个 zip。", + "No entry has a recorded last use.": "没有条目记录最近使用时间。", + "No further packages were found in the other registries.": "其它 registry 中没有找到更多包。", + "No installed toolchain is usable for the host target, so a global default cannot be set here. For target-specific toolchains use the mcpp CLI; on Windows install Visual Studio first.": "没有可用于 host target 的已安装工具链,不能在此处设置全局默认。target 专用工具链请使用 mcpp CLI;Windows MSVC 请先安装 Visual Studio。", + "No local index was read.": "没有读取本地索引。", + "No mcpp index was found. Run mcpp: Refresh the mcpp Package Index once, or set mcpp.library.indexPath to an index checkout.": "没有找到 mcpp 索引。先执行一次 mcpp: 刷新 mcpp 包索引,或用 mcpp.library.indexPath 指向一个索引检出。", + "No mcpp project in this workspace": "当前工作区没有 mcpp 工程", + "No mcpp.toml was found in this workspace. Run this command inside an mcpp project.": "当前工作区没有找到 mcpp.toml。请在 mcpp 工程中执行此命令。", + "No package matches this search.": "没有匹配的包。", + "No settings match the search": "没有匹配的设置", + "No target is selected; for a target-specific install use the mcpp CLI": "不选择 target;target 专用安装请使用 mcpp CLI", + "No target/ directory": "没有 target/ 目录", + "No workspace folder is open": "未打开工作区文件夹", + "Not measured yet": "尚未测量", + "Off by default: this tier runs mcpp search, which may use the network and is read from human output on a best-effort basis. Failures fall back to the local index.": "默认关闭:这一档会调用 mcpp search,可能联网,且只能从人类输出尽力解析。失败时退回本地索引。", + "Oldest use": "最早使用", + "Oldest use: {0}": "最早使用:{0}", + "One value per line": "每行一个值", + "Only modified": "仅显示已修改", + "Open in the Settings editor": "在设置编辑器中打开", + "Open logs": "打开日志", + "Open on the index site": "在索引站打开", + "Open the C++ Modules settings": "打开 C++ Modules 设置", + "Open the detail page for {0}": "打开 {0} 的详情页", + "Open the mcpp project and toolchain quick menu": "打开 mcpp 项目和工具链快捷菜单", + "Open the repository": "打开仓库", + "Opened {0} in your browser.": "已在浏览器中打开 {0}。", + "Opening {0}…": "正在打开 {0}…", + "Optional dependencies pulled in by a feature": "由 feature 拉取的可选依赖", + "Override a host tool with an existing binary (skips the build)": "用已有二进制覆盖 host 工具(跳过构建)", + "POSIX environment": "POSIX 环境", + "Package metadata": "包元数据", + "Path dependency (local development)": "路径依赖(本地开发)", + "Path: {0}": "路径:{0}", + "Pin the xlings tool version": "固定 xlings 工具版本", + "Pin tool versions": "固定工具版本", + "Pre-v1 cache": "pre-v1 遗留缓存", + "Presets": "预设", + "Project artifacts": "工程产物", + "Project artifacts are not available.": "工程产物不可用。", + "Project artifacts could not be measured.": "无法测量工程产物。", + "Project cache": "项目缓存", + "Project-level index redirection": "项目级索引重定向", + "Provided by": "提供方", + "Read offline from {0} in the {1} index ({2} index folder(s) found).": "已从 {1} 索引离线读取 {0}(共找到 {2} 个索引目录)。", + "Read-only view of what mcpp currently resolves": "只读查看 mcpp 当前解析结果", + "Reading the index…": "正在读取索引…", + "Reading {0}…": "正在读取 {0}…", + "Ready": "就绪", + "Refresh": "刷新", + "Refresh cache statistics": "刷新缓存统计", + "Refresh the mcpp Package Index": "刷新 mcpp 包索引", + "Refresh this view": "刷新此视图", + "Refreshing the mcpp Package Index…": "正在刷新 mcpp 包索引…", + "Registry": "Registry", + "Reload the window to see this": "重载窗口后生效", + "Remove the pre-v1 cache": "清理 pre-v1 遗留缓存", + "Repository": "仓库", + "Reset": "重置", + "Reset the invalid value to the default": "将无效值重置为默认值", + "Reset this workspace's cache": "重置本工作区缓存", + "Resolved {0}": "已解析 {0}", + "Resolved —": "已解析 —", + "Restart language service": "重启语言服务", + "Restart the semantic engine": "重启语义引擎", + "Result: {0}": "结果:{0}", + "Root: {0}": "根目录:{0}", + "Run": "运行", + "Run the build tool in a terminal": "在终端运行构建工具", + "Run “mcpp new {0}” in {1}, create the project folder {2} and open it.": "将在 {1} 执行 “mcpp new {0}”,创建项目文件夹 {2} 并打开它。", + "Runtime dependencies": "运行时依赖", + "Save settings to": "保存设置到", + "Search all registries": "在全部 registry 中搜索", + "Search and add a dependency": "搜索并添加依赖", + "Search and add a dependency…": "搜索并添加依赖…", + "Search packages": "搜索包", + "Search settings": "搜索设置", + "Searching all registries failed (exit {0}); showing the local index only.": "搜索全部 registry 失败(退出码 {0});只显示本地索引。", + "Searching all registries…": "正在搜索全部 registry…", + "Select analysis context": "重选分析上下文", + "Select the global default toolchain": "选择全局默认工具链", + "SemVer version dependency": "SemVer 版本依赖", + "Set as global default": "设为全局默认", + "Set the global default toolchain": "设置全局默认工具链", + "Setting the global default toolchain failed (exit code {0}). See the mcpp output channel.": "设置全局默认工具链失败(退出码 {0})。请查看 mcpp 输出频道。", + "Settings": "设置", + "Settings and diagnostics": "设置与诊断", + "Share of the total cache size, by entry kind.": "按条目类型占缓存总大小的比例。", + "Shared build cache": "共享构建缓存", + "Show advanced settings": "显示高级设置", + "Show cache entry details for {0}": "查看缓存条目 {0} 的详情", + "Show running tasks": "显示正在运行的任务", + "Show toolchains": "查看工具链", + "Simulates mcpp cache gc --max-size: the estimate is a local projection, so mcpp's own policy decides in the end.": "模拟 mcpp cache gc --max-size:这里的估算只是本地推算,最终仍以 mcpp 自身的策略为准。", + "Size": "大小", + "Stale artifacts: about {0}": "其中过期产物约 {0}", + "Standard": "标准", + "Switch to {0}": "切换到 {0}", + "System toolchain (mcpp only detects it)": "系统工具链(mcpp 只检测)", + "System: system toolchain, detection only": "System:系统工具链,仅检测", + "Table form: contributes the package's own macros when activated": "表形式:激活时贡献包自有宏", + "Table form: feature-gated source globs": "表形式:feature 门控的源 glob", + "Table form: requires a capability": "表形式:需要 capability", + "Takes effect on the next build": "下次构建时生效", + "Takes effect on the next clean": "下次清理时生效", + "Target": "目标", + "Targets": "目标", + "Task arguments: {0}": "任务参数:{0}", + "Test": "测试", + "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", + "The build was cancelled; the C++ Modules language service was not refreshed.": "构建已取消;C++ 模块语言服务未刷新。", + "The compatible spec {0} carrying target semantics is handed to mcpp to install.": "将把携带 target 语义的兼容 spec {0} 交给 mcpp 安装。", + "The compatible spec {0}, which may carry target semantics, is handed to mcpp to install, and mcpp validates it in the end.": "将把可能携带 target 语义的兼容 spec {0} 交给 mcpp 安装,最终由 mcpp 校验。", + "The current mcpp toolchain list output is not recognised; the raw output is kept in the mcpp output channel, so check the mcpp version.": "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", + "The destination already exists: {0}. Choose another project name or location.": "目标路径已存在:{0}。请更换项目名或位置。", + "The global default pair is set to {0} + host target. The mcpp.toml/target configuration of the current project can still override it.": "将把全局默认对设为 {0} + host target。当前项目的 mcpp.toml/target 配置仍可能覆盖它。", + "The index folders contain no descriptors.": "索引目录里没有描述符。", + "The installation may download a large toolchain package and modifies the mcpp global cache.": "安装可能下载较大的工具链包,并修改 mcpp 全局缓存。", + "The mcpp build failed (exit code {0}); the C++ Modules language service re-read the existing build description.": "mcpp 构建失败(退出码 {0});C++ 模块语言服务已重新读取现有构建描述。", + "The mcpp build finished; the C++ Modules language service has been refreshed.": "mcpp 构建完成,C++ 模块语言服务已刷新。", + "The mcpp global default is now {0} + host target. Cleaning the old toolchain artifacts and rebuilding is recommended.": "mcpp 全局默认已更新为 {0} + host target。建议清理旧工具链产物后重新构建。", + "The program did not build; see the task terminal for the compiler output.": "程序未构建成功;编译器输出见任务终端。", + "The project currently overrides the global default; this only changes the current mcpp global configuration": "项目当前覆盖全局默认;这里只修改 mcpp 当前全局配置", + "The project name must not be . or ..": "项目名不能是 . 或 ..", + "The project name must not be a Windows reserved device name.": "项目名不能是 Windows 保留设备名", + "The project name must not be empty.": "项目名不能为空", + "The project name must not contain PROJECT: it triggers a defect in the current mcpp template substitution.": "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷", + "The project name must not contain a path separator.": "项目名不能包含路径分隔符", + "The project name must not contain control characters.": "项目名不能包含控制字符", + "The project name must not end with . (unsupported on Windows).": "项目名不能以 . 结尾(Windows 不支持)", + "The project name must not start with -, or mcpp would read it as a command-line option.": "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项", + "The project root sits below the VS Code folder; the real build resolution is whatever mcpp reports": "当前工程根位于 VS Code 文件夹子目录;实际构建解析以 mcpp 为准", + "The same figures are listed as text next to each bar.": "每个条形图旁边以文字列出了同样的数字。", + "The setting could not be written; the toggle was not saved.": "设置写入失败;开关没有保存。", + "The shared build cache could not be read.": "无法读取共享构建缓存。", + "The shared build cache is not available.": "共享构建缓存不可用。", + "The shared build cache is {0}, at or above the {1} warning threshold.": "共享构建缓存为 {0},已达到或超过 {1} 的告警阈值。", + "The shared build cache is {0}. Raise or clear mcpp.cache.warnAboveGiB to change when this appears.": "共享构建缓存为 {0}。调高或清空 mcpp.cache.warnAboveGiB 可改变告警时机。", + "The target axis is shown read-only; to select a target use the mcpp CLI": "target 轴只读展示;选择 target 请使用 mcpp CLI", + "The version is required: mcpp accepts an exact version only.": "必须指定版本:mcpp 只接受精确版本。", + "The {0} largest cache labels, by bytes.": "按字节数排名的前 {0} 个缓存标签。", + "There is no pre-v1 cache to remove.": "没有可清理的 pre-v1 缓存。", + "This descriptor declares no dependencies.": "此描述符没有声明依赖。", + "This does not clean the global BMI cache.": "此操作不会清理全局 BMI 缓存。", + "This index publishes no version for any platform.": "此索引没有为任何平台发布版本。", + "This index publishes no version for this platform, so there is nothing to add.": "此索引没有为当前平台发布版本,因此无法添加。", + "This is the current-directory view; overrides from the project or a parent configuration are decided by the actual build": "当前目录视图;项目或父级配置的覆盖以实际构建为准", + "This is the current-directory view; the value mcpp build resolves is authoritative": "当前目录视图;实际生效值以 mcpp build 解析为准", + "This mcpp build does not recognise that command; update mcpp or check the spelling.": "此 mcpp 版本不认识该命令;请升级 mcpp 或检查拼写。", + "This package has no test project in the index, so there is no example to show.": "此包在索引里没有示例工程,因此没有示例可显示。", + "This package is not in the local index any more.": "此包已不在本地索引中。", + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.": "本面板只修改 mcpp-vscode 的设置;C++ Modules 扩展({0})自行管理其 mcppls.* 设置,本面板不会写入它们。", + "This project may not be using this value": "当前项目可能没有使用此值", + "This runs mcpp build and has the C++ Modules extension re-read the build description; it does not change the toolchain, mcpp.toml or any language service setting.": "将执行 mcpp build,并由 C++ Modules 扩展重新读取构建描述;不会修改工具链、mcpp.toml 或任何语言服务设置。", + "This workspace has no mcpp.toml.": "当前工作区没有找到 mcpp.toml。", + "This workspace is not trusted, so mcpp will not run and the C++ Modules language service will not refresh. Trust the workspace first.": "当前工作区未受信任,不会执行 mcpp 或刷新 C++ 模块语言服务。请先信任工作区。", + "This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.": "当前工作区未受信任。mcpp 命令可能执行工作区设置指定的外部程序,请先信任工作区。", + "This workspace is not trusted. mcpp commands that write are disabled until you trust it.": "当前工作区未受信任。会写盘的 mcpp 命令在信任工作区之前不会执行。", + "This {0} GiB budget frees entries that every mcpp project on this machine shares. The cleanup can take a while, and any dropped entry is rebuilt on next use.": "{0} GiB 的预算会释放本机所有 mcpp 工程共享的条目。清理可能耗时,被丢弃的条目会在下次使用时重建。", + "Toggle this section": "折叠或展开本节", + "Toolchain": "工具链", + "Toolchain managed by mcpp": "mcpp 管理的工具链", + "Toolchain {0} installed. Choose a global default from the latest list?": "工具链 {0} 安装完成。是否从最新列表中选择全局默认?", + "Unavailable": "暂无元数据", + "Unexpected error: {0}": "发生未预期错误:{0}", + "Unknown": "未知", + "Use": "用法", + "User": "用户", + "User settings": "用户设置", + "VS Code could not open {0}. The link is on your clipboard.": "VS Code 无法打开 {0},链接已复制到剪贴板。", + "Values shown for {0}": "以下值适用于 {0}", + "Verify the cache": "校验缓存", + "Verify the shared cache": "校验共享缓存", + "Versions": "版本", + "Versions ({0})": "版本({0})", + "Versions mcpp aggregates by family; this operation installs for the host target": "mcpp 按 family 聚合的可用版本;本操作按 host target 安装", + "What the descriptor declares. The resolved version lives in a project's mcpp.lock, not here.": "这里显示描述符声明的依赖。解析后的版本在工程的 mcpp.lock 里,不在这里。", + "Working directory: {0}": "工作目录:{0}", + "Workspace": "工作区", + "Workspace folder": "工作区文件夹", + "Workspace member declarations": "工作空间成员声明", + "Workspace settings": "工作区设置", + "added": "已添加", + "convention {0}": "约定 {0}", + "dev dependency": "dev 依赖", + "git · {0}": "git · {0}", + "host default": "host 默认", + "latest {0}": "最新 {0}", + "mcpp CLI operation failed: {0}": "mcpp CLI 操作失败:{0}", + "mcpp Package Index refreshed: {0} package(s) from {1} index folder(s).": "mcpp 包索引已刷新:{0} 个包,来自 {1} 个索引目录。", + "mcpp add failed with exit code {0}.": "mcpp add 失败,退出码 {0}。", + "mcpp add failed with exit code {0}: {1}": "mcpp add 失败,退出码 {0}:{1}", + "mcpp add needs an exact version: mcpp add {0}@.": "mcpp add 需要精确版本:mcpp add {0}@。", + "mcpp add {0}@{1}": "mcpp add {0}@{1}", + "mcpp add {0}@{1} --dev": "mcpp add {0}@{1} --dev", + "mcpp also clears the global default_target; the configuration file location is decided by the current mcpp installation and MCPP_HOME.": "mcpp 会同时清空全局 default_target;配置文件位置由当前 mcpp 安装及 MCPP_HOME 决定。", + "mcpp build was not started.": "未启动 mcpp 构建。", + "mcpp cache list did not return the documented document": "mcpp cache list 未返回约定的文档", + "mcpp cache list failed (exit {0})": "mcpp cache list 失败(退出码 {0})", + "mcpp cache verify failed with exit code {0}": "mcpp cache verify 失败,退出码 {0}", + "mcpp clean --dry-run": "mcpp clean --dry-run", + "mcpp decides whether the compiler prefix is a valid triple; when it is, it may download a larger toolchain package for that target.": "mcpp 会判断编译器前缀是否为有效 triple;有效时可能下载对应 target 的较大工具链包。", + "mcpp does not download or install MSVC; it detects Visual Studio and points to the official installation guide when it is missing.": "mcpp 不会下载或安装 MSVC;它会检测 Visual Studio,并在缺失时给出官方安装指引。", + "mcpp finished {0}. This first release does not change the target default; to set one, use the mcpp CLI with --target.": "mcpp 已完成 {0}。首版插件不修改 target 默认;如需设为默认,请使用带 --target 的 mcpp CLI。", + "mcpp global configuration": "mcpp 全局配置", + "mcpp has no global default toolchain yet": "mcpp 尚未设置全局默认工具链", + "mcpp index update failed with exit code {0}": "mcpp index update 失败,退出码 {0}", + "mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing.": "mcpp 已安装但环境尚未就绪;运行 `mcpp self doctor` 查看缺少什么。", + "mcpp library ecosystem": "mcpp 库生态", + "mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace": "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响", + "mcpp list toolchain for this directory": "mcpp list 当前目录工具链", + "mcpp listed no usable toolchain; see the raw result in the mcpp output channel.": "mcpp 没有列出可用工具链;请查看 mcpp 输出频道中的原始结果。", + "mcpp new {0} failed (exit code {1}). See the mcpp output channel.": "mcpp new {0} 失败(退出码 {1})。请查看 mcpp 输出频道。", + "mcpp normalises the compatible spelling and may download a larger toolchain package for that target.": "mcpp 会规范化兼容写法,并可能下载对应 target 的较大工具链包。", + "mcpp pack packaging configuration": "mcpp pack 打包配置", + "mcpp rejected the command line; check the arguments and run it from the project root.": "mcpp 拒绝了命令行;请检查参数,并在工程根目录运行。", + "mcpp reported an internal error; re-run with the mcpp output channel open and report it.": "mcpp 报告了内部错误;请打开 mcpp 输出频道重跑并反馈。", + "mcpp settings": "mcpp 设置", + "mcpp toolchain list failed (exit code {0}). See the mcpp output channel.": "mcpp toolchain list 失败(退出码 {0})。请查看 mcpp 输出频道。", + "mcpp toolchain management": "mcpp 工具链管理", + "mcpp toolchain {0} is installed (no target specified, the host target is used).": "将安装 mcpp 工具链 {0}(不指定 target,使用 host target)。", + "mcpp toolchains and targets": "mcpp 工具链与 target", + "mcpp will detect the system MSVC ({0}).": "将调用 mcpp 检测系统 MSVC({0})。", + "mcpp xpkg parse could not read this descriptor; the versions below come from its text, which is less authoritative.": "mcpp xpkg parse 无法读取此描述符;下面的版本来自文本解析,权威性较低。", + "mcpp {0} failed with exit code {1}": "mcpp {0} 失败,退出码 {1}", + "mcpp.toml could not be read": "无法读取 mcpp.toml", + "mcpp: Build": "mcpp: 构建", + "mcpp: Clean": "mcpp: 清理", + "mcpp: Run": "mcpp: 运行", + "mcpp: Test": "mcpp: 测试", + "mcpp: detect system MSVC ({0})": "mcpp: 检测系统 MSVC({0})", + "mcpp: install toolchain {0}": "mcpp: 安装工具链 {0}", + "mcpp: moved {0} setting(s).": "mcpp:已移动 {0} 个设置。", + "mcpp: quick menu": "mcpp: 快捷菜单", + "mcpp: {0}": "mcpp:{0}", + "mcppls {0}": "mcppls {0}", + "more than {0} day(s)": "超过 {0} 天", + "more than {0} day(s) ago": "超过 {0} 天前", + "not read yet": "尚未读取", + "openkal-compat": "openkal-compat", + "openkal-ecosystem": "openkal-ecosystem", + "other registry": "其它 registry", + "path · {0}": "路径 · {0}", + "resolved {0}": "已解析 {0}", + "the workspace is not trusted": "工作区未受信任", + "this platform": "当前平台", + "under {0} day(s)": "少于 {0} 天", + "unknown": "未知", + "unknown error": "未知错误", + "upstream mcpp.toml": "上游 mcpp.toml", + "uses platform interfaces": "使用平台接口", + "{0}": "{0}", + "{0} cache entries are incomplete; run mcpp cache verify to see which ones.": "有 {0} 个缓存条目不完整;运行 mcpp cache verify 可查看具体是哪些。", + "{0} descriptor(s) from {1} local index folder(s): {2}. Read offline.": "从 {1} 个本地索引目录读到 {0} 个描述符:{2}。离线读取。", + "{0} dev": "{0} 个 dev", + "{0} entries": "{0} 个条目", + "{0} entries have no recorded last use and are not counted in this bar.": "{0} 个条目没有记录最近使用时间,未计入此条形图。", + "{0} failed (exit code {1}). See the task terminal.": "{0}失败(退出码 {1})。请查看任务终端。", + "{0} failed: {1}": "{0} 失败:{1}", + "{0} file(s) · {1} group(s)": "{0} 个文件 · {1} 个分组", + "{0} finished.": "{0} 已完成。", + "{0} further package(s) came from the other registries.": "另有 {0} 个包来自其它 registry。", + "{0} in the shared cache": "共享缓存中 {0}", + "{0} of {1} packages": "{0} / {1} 个包", + "{0} problem(s)": "{0} 个问题", + "{0} source file(s)": "{0} 个源文件", + "{0} was cancelled.": "{0}已取消。", + "{0} {1}": "{0} {1}", + "{0} {1} is older than the verified range ({2}); the capability probe will hide what it cannot do.": "{0} {1} 低于已验证范围({2});能力探测会隐藏它做不到的部分。", + "{0} · at or above {1}": "{0} · 已达到或超过 {1}", + "{0} · line {1}": "{0} · 第 {1} 行", + "{0} · version {1}": "{0} · 版本 {1}", + "{0} · {1}": "{0} · {1}", + "{0} · {1} file(s)": "{0} · {1} 个文件", + "{0} · {1} · {2}": "{0} · {1} · {2}", + "{0}: done.": "{0}:完成。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", + "{0}{1}": "{0}{1}", + "{0}–{1} day(s)": "{0}–{1} 天", + "{0}–{1} day(s) ago": "{0}–{1} 天前", + "✓ Has examples": "✓ 有示例", + "Bring it into your code": "在代码里引入", + "Copy": "复制", + "Copied to the clipboard.": "已复制到剪贴板。", + "Copying…": "复制中…", + "Could not write the clipboard.": "无法写入剪贴板。", + "The workspace is not trusted; mcpp itself was not probed.": "工作区未受信任;未探测 mcpp 本身。", + "This workspace is not trusted, so mcpp is not run; the versions below come from the descriptor's text.": "工作区未受信任,因此不运行 mcpp;下面的版本来自描述符文本。", + "The workspace is not trusted; the other registries were not searched.": "工作区未受信任;未搜索其他 registry。", + "Only C++ Modules is serving C++": "仅 C++ Modules 在提供 C++ 语言服务", + "No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice.": "没有其他 C++ 语言服务(C/C++、clangd、ccls)在工作,结果不会重复出现。", + "Other C++ language services": "其他 C++ 语言服务", + "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。", + "Libraries": "库", + "Source details": "来源详情", + "Other registries": "其它 registry", + "Set index path": "设置索引路径", + "Update mcpp index": "更新 mcpp 索引", + "Show output": "查看输出", + "Clear search": "清空搜索", + "Search other registries": "搜索其它 registry", + "Declared": "已声明", + "Reread locally": "重新读取本地索引", + "Could not read the index: {0}": "无法读取索引:{0}", + "Searching other registries…": "正在搜索其它 registry…", + "Other registry": "其它 registry", + "Official index": "官方索引", + "Custom index": "自定义索引", + "Browse libraries; select an mcpp project in the detail page to add dependencies.": "可以浏览库;请在详情页选择 mcpp 项目后添加依赖。", + "No local mcpp-index found": "未找到本地 mcpp-index", + "{0} · {1} packages · local": "{0} · {1} 个包 · 本地", + "Target project": "目标项目", + "Choose project": "选择项目", + "Environment platform": "当前环境平台", + "Version": "版本", + "Other platforms (read only)": "其它平台(只读)", + "Resolved in lockfile": "锁定解析", + "Source dependency": "源码依赖", + "No verified import or header path is available locally. See the repository or index site for usage.": "本地没有可验证的模块名或头文件路径,请查看仓库或索引站的用法说明。", + "From the index's example source": "来自索引示例源码", + "Source and diagnostics": "来源与诊断", + "Reading package details…": "正在读取包详情…", + "Adding dependency…": "正在添加依赖…", + "Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration.": "此处暂不支持迁移依赖分组,请编辑 mcpp.toml 移动声明。", + "Retry reading details": "重新读取详情", + "Open mcpp.toml": "打开 mcpp.toml", + "Open source file": "打开源文件", + "Search preview: only the fields returned by mcpp search are available.": "搜索预览:仅展示 mcpp search 返回的字段。", + "Copied": "已复制", + "Copy failed": "复制失败", + "Opened in your browser.": "已在浏览器中打开。", + "Could not open the browser. Link copied to clipboard.": "无法打开浏览器,链接已复制到剪贴板。", + "Could not open the browser or copy the link: {0}": "无法打开浏览器或复制链接:{0}", + "Could not add the dependency. See output for details.": "无法添加依赖,请查看输出了解详情。", + "Could not read package details. See output for details.": "无法读取包详情,请查看输出了解详情。", + "This is a search preview. Set an index path containing this package to read its complete details.": "这是搜索预览。将索引路径设置为包含此包的目录后,可读取完整详情。", + "Search previews cannot be added. Open a complete local descriptor first.": "搜索预览无法直接添加,请先打开完整的本地包描述。", + "The package descriptor is missing. Reread the local index.": "包描述不存在,请重新读取本地索引。", + "Trust this workspace to run mcpp and add dependencies.": "信任此工作区后,才能运行 mcpp 和添加依赖。", + "Select an mcpp project before adding this library.": "请先选择 mcpp 项目,再添加此库。", + "This dependency name is ambiguous. Use a qualified package name in mcpp.toml.": "此依赖名称有歧义,请在 mcpp.toml 中使用完整包名。", + "This project uses a source dependency. Edit its manifest to change the source.": "项目已声明源码依赖,请编辑清单修改来源。", + "Version availability could not be confirmed. Retry reading this descriptor.": "暂时无法确认版本可用性,请重新读取包描述。", + "No version is published for the current environment platform.": "当前环境平台没有可用的发布版本。", + "Already declared": "已声明", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.": "这里展示包描述中的依赖,所选版本的依赖可能不同;解析版本来自当前项目的锁文件。", + "mcpp add failed (exit {0}). See output for details.": "mcpp add 失败(退出码 {0}),请查看输出了解详情。", + "Choose the target mcpp project": "选择目标 mcpp 项目", + "Dependencies will be added to this project's mcpp.toml": "依赖将添加到此项目的 mcpp.toml", + "Updating the global mcpp index; a configured custom checkout is only reread.": "正在更新全局 mcpp 索引;已配置的自定义检出目录仅会被重新读取。", + "The current environment platform is not recognized by this index.": "索引无法识别当前环境平台。", + "Versions are from descriptor text; mcpp will confirm availability when adding.": "版本来自描述符文本,添加时将由 mcpp 确认可用性。", + "Platforms and versions": "平台与版本", + "Add dependency": "添加依赖", + "Usage": "用法", + "Dependency type": "依赖类型", + "Project dependency": "项目依赖", + "Development dependency": "开发依赖", + "Build dependency": "构建依赖", + "Add development dependency": "添加开发依赖", + "Build dependencies are used by the build script. Copy this entry into mcpp.toml; mcpp add does not support this group yet.": "构建依赖供构建脚本使用;当前 mcpp add 尚不支持此分组,请复制此项并编辑 mcpp.toml。", + "Changing this declaration requires a manifest edit. Copy the entry and update its dependency group in mcpp.toml.": "请复制声明并在 mcpp.toml 中调整依赖分组;已有声明不会自动迁移。", + "The dependency declaration changed. Review mcpp.toml before adding it.": "依赖声明已变化,请先检查 mcpp.toml 再添加。", + "Updating mcpp index…": "正在更新 mcpp 索引…", + "Index update failed; showing the last available local snapshot.": "索引更新失败,继续显示上次可用的本地快照。", + "Index updated; {0} local packages.": "索引已更新,本地共 {0} 个包。", + "Bundled official index tests · commit {0}": "插件内置官方索引测试 · 提交 {0}", + "Project dependency graph": "项目依赖图", + "Copy Mermaid": "复制 Mermaid", + "Up to 3 dependency levels · {0} packages · {1} edges": "最多 3 层依赖 · {0} 个包 · {1} 条关系", + "Edges come from manifests and local index descriptors; lockfile versions are shown when available. This is a declaration graph, not a resolved build graph. Dashed nodes have no local dependency metadata; conditional edges may not apply to this environment.": "关系来自项目清单和本地索引描述符;有锁定版本时显示锁定版本。这是声明关系图,不代表实际构建解析结果。虚线节点缺少本地依赖元数据,条件关系可能不适用于当前环境。", + "Large graph truncated at 120 nodes or 240 edges.": "大型依赖图已截断(最多 120 个节点或 240 条关系)。", + "Fit": "适应窗口", + "Mermaid source": "Mermaid 源码", + "Could not render the graph. Copy Mermaid source or refresh to retry.": "依赖图渲染失败,可复制 Mermaid 源码或刷新重试。", + "Could not read project dependencies: {0}": "无法读取项目依赖:{0}", + "Open an mcpp project first.": "请先打开一个 mcpp 项目。", + "The project manifest is missing or unreadable.": "项目清单不存在或无法读取。", + "Reading project dependencies…": "正在读取项目依赖…", + "Source": "源码", + "Open read-only source": "打开只读源码", + "Dependency graph": "依赖图", + "Maximum dependency depth: 3": "最多展示 3 层依赖", + "Zoom out": "缩小", + "Zoom in": "放大", + "Legend and source": "图例与源码", + "Project": "项目", + "Declaration graph · dashed edges are conditional · lockfile supplies versions": "声明关系图 · 虚线为条件依赖 · 锁文件提供版本", + "index": "索引", + "build": "构建", + "conditional": "条件", + "Scroll to zoom · drag to pan": "滚轮缩放 · 按住拖动画布", + "C++ Modules Deps": "C++ Modules 依赖图", + "No module relationships were found for this project.": "此项目尚无模块关系。", + "View limit reached. Focus a node to explore its dependencies.": "已达到当前视图上限,可聚焦节点继续查看。", + "The source graph is incomplete. Refresh after indexing finishes.": "数据尚不完整,可在索引完成后刷新。", + "Expand next level": "展开下一层", + "Selected node": "已选节点", + "Graph view": "图视图", + "Packages": "包依赖", + "Depth": "深度", + "Find a node…": "查找节点…", + "Back to project": "返回项目", + "Select a node to explore its relationships.": "选择节点查看关联。", + "Collapse": "收起", + "Focus": "聚焦", + "Packages show declarations; modules show mcppls imports. External modules stop after one hop.": "包图显示声明关系;模块图来自 mcppls,外部模块只显示一层。", + "Source is unavailable for this module.": "此模块的源码不可用。", + "Choose a module source file": "选择模块源码文件", + "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。", + "Could not export this graph. Try SVG or a smaller view.": "无法导出此图,请尝试 SVG 或更小的视图。", + "Entry group": "入口分组", + "Selected": "选中", + "Export SVG": "导出 SVG", + "Export PNG": "导出 PNG", + "Dependencies depth": "依赖深度", + "Importers depth": "父节点深度", + "Previous entries": "上一批入口", + "Next entries": "下一批入口", + "Back": "后退", + "Locate focus": "定位焦点", + "Locate selected": "定位选中", + "Open source": "打开源码", + "Expand importers": "展开父节点", + "Collapse importers": "折叠父节点", + "Focus this result": "以此结果为焦点", + "Dependencies unavailable — click to retry": "暂时无法读取依赖,点击重试", + "Reading dependencies…": "正在读取依赖…", + "This project declares no dependencies": "此项目未声明依赖", + "Refresh pending": "等待刷新", + "Main": "主程序", + "Tests": "测试", + "Other": "其他", + "Export": "导出", + "View options": "视图", + "Node actions": "节点操作", + "build, run, test and debug use it": "构建、运行、测试与调试都使用它", + "every architecture of the platform": "平台的全部架构", + "Install CodeLLDB": "安装 CodeLLDB", + "mcpp: building for the debugger…": "mcpp:正在为调试器构建…", + "mcpp: Pack": "mcpp:打包", + "mcpp: Pack (1/3): the package format": "mcpp:打包(1/3):包格式", + "mcpp: Pack (2/3): what the machine that runs it provides": "mcpp:打包(2/3):运行它的机器提供什么", + "mcpp: Pack (3/3): the libraries inside the program or beside it": "mcpp:打包(3/3):库在程序里还是在程序旁边", + "mcpp: reading the package formats…": "mcpp:正在读取包格式…", + "mcpp: reading the targets…": "mcpp:正在读取目标…", + "No debugger extension is installed: install CodeLLDB, the C/C++ extension or lldb-dap.": "没有安装调试器扩展:请安装 CodeLLDB、C/C++ 扩展或 lldb-dap。", + "No toolchain is installed for {0}.": "{0} 尚未安装工具链。", + "Open Settings": "打开设置", + "Select the build target": "选择构建目标", + "the mcpp-ide backend is not running": "mcpp-ide 后端未运行", + "The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath.": "未找到 mcpp-ide 后端。请用 `xlings install mcpp-ide` 安装,或设置 mcpp.ide.backendPath。", + "the project's default": "工程默认值", + "The targets mcpp builds for": "mcpp 能构建的目标", + "this machine": "本机", + "The project's mcpp is not installed: {0}": "项目所需的 mcpp 未安装:{0}", + "The project's mcpp did not answer: {0}": "项目的 mcpp 没有回答:{0}", + "mcpp {0} at {1}, set by mcpp.path (the project's own version is not chosen)": "mcpp {0}({1}),由 mcpp.path 指定(不经项目的版本选择)", + "mcpp {0} (not asked in the project: the workspace is not trusted)": "mcpp {0}(未在项目内探测:工作区不受信任)", + "mcpp {0} at {1}, the project's": "mcpp {0}({1}),项目所用的版本", + "The IDE report is written to {0}": "IDE 报告已写入 {0}", + "Restart the backend": "重新启动后端", + "Export IDE Report": "导出 IDE 报告", + "The mcpp-ide backend ended four times in three minutes; build, run, test and the project pages go on without it.": "mcpp-ide 后端在三分钟内结束了四次;构建、运行、测试与项目页面在没有它的情况下继续工作。", + "Starting": "启动中", + "Stopped": "已停止", + "Not serving this workspace": "未服务此工作区", + "The semantic kit is not installed": "语义套件未安装", + "Files are read with the openkal kit C++ Modules carries. Installing the target's kit gives its own standard library and headers.": "文件以 C++ Modules 自带的 openkal 套件解析。安装目标的套件后使用其自身的标准库与头文件。", + "Install the semantic kit": "安装语义套件", + "This mcppls does not install kits; files are read with the openkal kit it carries.": "此 mcppls 不安装套件;文件以其自带的 openkal 套件解析。", + "The C++ Modules server installs the target's semantic kit with xlings, in the background.": "C++ Modules 服务器在后台用 xlings 安装目标的语义套件。", + "The line of main was read from the text; C++ Modules gives its position when it serves this file.": "main 的行号从文本读出;C++ Modules 服务此文件时由它给出位置。", + "The test program did not run: {0}": "测试程序没有运行:{0}", + "Debug": "调试", + "A feature as a table": "以表形式写出的特性", + "Build settings for the targets a selector matches": "选择器匹配的目标的构建设置", + "Dependencies for the targets a selector matches": "选择器匹配的目标的依赖", + "Dependencies of one index namespace": "一个索引命名空间的依赖", + "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。" +} diff --git a/mide-plugins/vscode/data/library-examples.json b/mide-plugins/vscode/data/library-examples.json new file mode 100644 index 0000000..4752397 --- /dev/null +++ b/mide-plugins/vscode/data/library-examples.json @@ -0,0 +1,1880 @@ +{ + "repository": "https://github.com/mcpp-community/mcpp-index", + "commit": "5fc151e0abed68788d9fa7b47e6e3ccb50607706", + "projects": [ + { + "project": "abseil", + "text": "# abseil test project: consumes compat.abseil and asserts behavior under\n# `mcpp test`. Part of the mcpp-index self-referential workspace — the\n# workspace-root `[indices]` redirect points at this repo, so the dependency\n# resolves to the checked-in recipe (pkgs/c/compat.abseil.lua) rather than the\n# published remote index.\n#\n# The test deliberately exercises symbols that live in COMPILED TUs\n# (absl::StrCat, absl::Status, absl::Mutex, absl::Cord, absl::FormatTime),\n# not just header inlines: a green build that linked zero Abseil objects would\n# otherwise look identical to a working one.\n[package]\nname = \"abseil-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nabseil = \"20250512.1\"\n", + "files": [ + { + "path": "tests/examples/abseil/tests/basics.cpp", + "text": "// Behavioral test for compat.abseil.\n//\n// Every assertion below crosses into a COMPILED Abseil translation unit, on\n// purpose — header-only usage would link zero objects from the package and\n// still go green, which is exactly the failure this suite has to catch:\n//\n// absl::StrCat / StrSplit / StrJoin -> absl/strings/*.cc\n// absl::Status / StatusOr -> absl/status/status.cc, statusor.cc\n// absl::Mutex / Notification -> absl/synchronization/mutex.cc\n// absl::Cord -> absl/strings/cord.cc + cord_rep_*.cc\n// absl::FormatTime / ParseTime -> absl/time/*.cc + internal/cctz/src/*.cc\n// absl::flat_hash_map -> absl/container/internal/raw_hash_set.cc\n// absl::StrFormat -> absl/strings/internal/str_format/*.cc\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n#include \n\n#include \"absl/container/flat_hash_map.h\"\n#include \"absl/status/status.h\"\n#include \"absl/status/statusor.h\"\n#include \"absl/strings/cord.h\"\n#include \"absl/strings/str_cat.h\"\n#include \"absl/strings/str_format.h\"\n#include \"absl/strings/str_join.h\"\n#include \"absl/strings/str_split.h\"\n#include \"absl/synchronization/mutex.h\"\n#include \"absl/synchronization/notification.h\"\n#include \"absl/time/civil_time.h\"\n#include \"absl/time/time.h\"\n\nnamespace {\n\nbool strings_ok() {\n if (absl::StrCat(\"answer=\", 42, \"/\", 1.5) != \"answer=42/1.5\") return false;\n if (absl::StrFormat(\"%s:%04d:%#x\", \"id\", 7, 255) != \"id:0007:0xff\") return false;\n\n const std::vector parts = absl::StrSplit(\"a,b,,c\", ',');\n if (parts.size() != 4 || parts[2] != \"\") return false;\n if (absl::StrJoin(parts, \"|\") != \"a|b||c\") return false;\n return true;\n}\n\nbool cord_ok() {\n // Build a Cord large enough to leave the inline representation and go\n // through the btree reps in absl/strings/internal/cord_rep_btree*.cc.\n absl::Cord cord;\n for (int i = 0; i < 512; ++i) cord.Append(absl::StrCat(\"chunk\", i, \";\"));\n const std::string flat(cord);\n return cord.size() == flat.size() && flat.rfind(\"chunk511;\") != std::string::npos;\n}\n\nbool status_ok() {\n const absl::Status ok = absl::OkStatus();\n const absl::Status err = absl::InvalidArgumentError(\"bad input\");\n if (!ok.ok() || err.ok()) return false;\n if (err.code() != absl::StatusCode::kInvalidArgument) return false;\n if (err.message() != \"bad input\") return false;\n // ToString() formats through absl/status/status.cc.\n if (err.ToString().find(\"INVALID_ARGUMENT\") == std::string::npos) return false;\n\n const absl::StatusOr good = 11;\n const absl::StatusOr bad = absl::NotFoundError(\"missing\");\n return good.ok() && *good == 11 && !bad.ok();\n}\n\nbool time_ok() {\n // A fixed instant, formatted in UTC: exercises the vendored cctz sources.\n const absl::TimeZone utc = absl::UTCTimeZone();\n const absl::Time t = absl::FromCivil(absl::CivilSecond(2026, 8, 4, 12, 34, 56), utc);\n if (absl::FormatTime(\"%Y-%m-%dT%H:%M:%S\", t, utc) != \"2026-08-04T12:34:56\") return false;\n\n absl::Time parsed;\n std::string err;\n if (!absl::ParseTime(\"%Y-%m-%d\", \"2026-08-04\", utc, &parsed, &err)) return false;\n return absl::ToUnixSeconds(t) - absl::ToUnixSeconds(parsed) == 12 * 3600 + 34 * 60 + 56;\n}\n\nbool concurrency_ok() {\n // absl::Mutex + Notification pull in the real synchronization objects\n // (mutex.cc, waiter backends, thread identity).\n absl::Mutex mu;\n int counter = 0;\n absl::Notification started;\n\n std::thread worker([&] {\n started.WaitForNotification();\n for (int i = 0; i < 1000; ++i) {\n absl::MutexLock lock(&mu);\n ++counter;\n }\n });\n\n started.Notify();\n for (int i = 0; i < 1000; ++i) {\n absl::MutexLock lock(&mu);\n ++counter;\n }\n worker.join();\n\n absl::MutexLock lock(&mu);\n return counter == 2000;\n}\n\nbool container_ok() {\n absl::flat_hash_map m;\n for (int i = 0; i < 256; ++i) m.emplace(absl::StrCat(\"k\", i), i);\n if (m.size() != 256) return false;\n const auto it = m.find(\"k255\");\n return it != m.end() && it->second == 255;\n}\n\n} // namespace\n\nint main() {\n const bool ok = strings_ok() && cord_ok() && status_ok() && time_ok()\n && concurrency_ok() && container_ok();\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "archive", + "text": "# Archive + compression codecs: libarchive, zlib, bzip2, lz4, xz (lzma), zstd.\n# Headless: version probes + compress→decompress round-trips. Cross-platform.\n[package]\nname = \"archive-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nlibarchive = \"3.8.7\"\nzlib = \"1.3.2\"\nbzip2 = \"1.0.8\"\nlz4 = \"1.10.0\"\nxz = \"5.8.3\"\nzstd = \"1.5.7\"\n", + "files": [ + { + "path": "tests/examples/archive/tests/compression.cpp", + "text": "// Round-trip compress→decompress per codec, asserting byte-for-byte recovery.\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nstatic bool same_bytes(const void* a, size_t an, const void* b, size_t bn) {\n return an == bn && std::memcmp(a, b, bn) == 0;\n}\n\nint main() {\n const uint8_t input[] = \"mcpp compat compression smoke\";\n const size_t input_size = sizeof(input) - 1;\n\n uint8_t zc[256] = {}; uLongf zcs = sizeof(zc);\n if (compress2(zc, &zcs, input, (uLong)input_size, Z_BEST_SPEED) != Z_OK) return 1;\n uint8_t zo[sizeof(input)] = {}; uLongf zos = input_size;\n if (uncompress(zo, &zos, zc, zcs) != Z_OK || !same_bytes(zo, zos, input, input_size)) return 2;\n\n char bc[256] = {}; unsigned int bcs = sizeof(bc);\n if (BZ2_bzBuffToBuffCompress(bc, &bcs, const_cast(reinterpret_cast(input)),\n (unsigned)input_size, 1, 0, 30) != BZ_OK) return 3;\n char bo[sizeof(input)] = {}; unsigned int bos = input_size;\n if (BZ2_bzBuffToBuffDecompress(bo, &bos, bc, bcs, 0, 0) != BZ_OK ||\n !same_bytes(bo, bos, input, input_size)) return 4;\n\n char lc[256] = {};\n const int lcs = LZ4_compress_default(reinterpret_cast(input), lc, (int)input_size, sizeof(lc));\n if (lcs <= 0) return 5;\n char lo[sizeof(input)] = {};\n const int los = LZ4_decompress_safe(lc, lo, lcs, sizeof(lo));\n if (los < 0 || !same_bytes(lo, (size_t)los, input, input_size)) return 6;\n\n std::vector sc(ZSTD_compressBound(input_size));\n const size_t scs = ZSTD_compress(sc.data(), sc.size(), input, input_size, 1);\n if (ZSTD_isError(scs)) return 7;\n std::vector so(input_size);\n const size_t sos = ZSTD_decompress(so.data(), so.size(), sc.data(), scs);\n if (ZSTD_isError(sos) || !same_bytes(so.data(), sos, input, input_size)) return 8;\n\n std::vector xc(lzma_stream_buffer_bound(input_size)); size_t xcp = 0;\n if (lzma_easy_buffer_encode(0, LZMA_CHECK_CRC64, nullptr, input, input_size,\n xc.data(), &xcp, xc.size()) != LZMA_OK) return 9;\n uint64_t xm = (std::numeric_limits::max)(); size_t xip = 0, xop = 0;\n std::vector xo(input_size);\n if (lzma_stream_buffer_decode(&xm, 0, nullptr, xc.data(), &xip, xcp, xo.data(), &xop, xo.size()) != LZMA_OK ||\n !same_bytes(xo.data(), xop, input, input_size)) return 10;\n return 0;\n}\n" + }, + { + "path": "tests/examples/archive/tests/versions.cpp", + "text": "// libarchive object lifecycle + version probes across all codecs.\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nint main() {\n archive* writer = archive_write_new();\n if (!writer) return 1;\n archive_write_free(writer);\n archive_entry* entry = archive_entry_new();\n if (!entry) return 2;\n archive_entry_free(entry);\n if (!archive_version_string()) return 3;\n if (!zlibVersion()) return 4;\n if (!BZ2_bzlibVersion()) return 5;\n if (LZ4_versionNumber() <= 0) return 6;\n if (ZSTD_versionNumber() == 0) return 7;\n if (lzma_version_number() == 0) return 8;\n return 0;\n}\n" + } + ] + }, + { + "project": "argparse", + "text": "# compat.argparse test project. Header-only; `compat` redirect inherited from\n# the workspace root.\n[package]\nname = \"argparse-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nargparse = \"3.2\"\n", + "files": [ + { + "path": "tests/examples/argparse/tests/parse.cpp", + "text": "// Behavioral test — compat.argparse parses a command line, it does not merely\n// include.\n//\n// argv is written by hand rather than taken from the runner, so the assertions\n// are about argparse's behaviour and not about how `mcpp test` invokes a\n// binary. No `import std;`: argparse.hpp is a textual header over the standard\n// library.\n#include \n\n#include \n#include \n#include \n\nint main() {\n argparse::ArgumentParser program(\"renderer\", \"0.1.0\");\n program.add_argument(\"model\").help(\"path to the glTF model\");\n program.add_argument(\"--width\").default_value(1280).scan<'i', int>();\n program.add_argument(\"--validate\").default_value(false).implicit_value(true);\n\n const std::vector argv{\"renderer\", \"scene.gltf\", \"--width\", \"1920\", \"--validate\"};\n try {\n program.parse_args(argv);\n } catch (const std::exception& e) {\n std::cerr << \"parse_args threw: \" << e.what() << \"\\n\";\n return 1;\n }\n\n if (program.get(\"model\") != \"scene.gltf\") { std::cerr << \"positional wrong\\n\"; return 2; }\n if (program.get(\"--width\") != 1920) { std::cerr << \"typed scan wrong\\n\"; return 3; }\n if (!program.get(\"--validate\")) { std::cerr << \"implicit flag wrong\\n\"; return 4; }\n\n // The default has to survive an argv that never mentions the option.\n argparse::ArgumentParser defaults(\"renderer\");\n defaults.add_argument(\"--width\").default_value(1280).scan<'i', int>();\n defaults.parse_args(std::vector{\"renderer\"});\n if (defaults.get(\"--width\") != 1280) { std::cerr << \"default_value wrong\\n\"; return 5; }\n\n // A missing required positional must be an error, not a silent empty\n // string — that is the half of a parser a \"compiles fine\" test misses.\n argparse::ArgumentParser required(\"renderer\");\n required.add_argument(\"model\");\n bool threw = false;\n try {\n required.parse_args(std::vector{\"renderer\"});\n } catch (const std::exception&) {\n threw = true;\n }\n if (!threw) { std::cerr << \"missing positional did not throw\\n\"; return 6; }\n\n std::cout << \"compat.argparse: ok (positional, typed scan, implicit flag, default, error path)\\n\";\n return 0;\n}\n" + } + ] + }, + { + "project": "asio-module", + "text": "# Asio C++23-module consumer test project: `import std; import asio;`.\n# The public package is `chriskohlhoff.asio@1.38.1` (module-only).\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `chriskohlhoff`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nchriskohlhoff = { path = \"../../..\" }\n\n[package]\nname = \"asio-module-tests\"\nversion = \"0.1.0\"\n\n[dependencies.chriskohlhoff]\nasio = \"1.38.1\"\n\n[dependencies.fmtlib]\nfmt = \"12.2.0\"\n", + "files": [ + { + "path": "tests/examples/asio-module/tests/core.cpp", + "text": "// Core executor/timer behavior over the module surface: strand FIFO order,\n// work-guard-driven run loop, thread_pool, and timer cancellation mapping to\n// asio::error::operation_aborted. Module consumers pair `import asio;` with\n// `import std;` (no text #include mixing).\nimport std;\nimport asio;\n\nint main() {\n using namespace std::chrono_literals;\n\n asio::io_context io;\n auto guard = asio::make_work_guard(io);\n auto serial = asio::make_strand(io);\n asio::steady_timer timer(io, 2ms);\n\n std::atomic posted{0};\n std::mutex order_mutex;\n std::vector order;\n bool timer_called = false;\n\n asio::post(serial, [&] {\n std::lock_guard lock(order_mutex);\n order.push_back(1);\n ++posted;\n });\n asio::post(serial, [&] {\n std::lock_guard lock(order_mutex);\n order.push_back(2);\n ++posted;\n });\n timer.async_wait([&](const std::error_code& ec) {\n timer_called = !ec;\n guard.reset();\n });\n\n std::thread worker([&] { io.run(); });\n worker.join();\n if (posted != 2 || !timer_called || order != std::vector{1, 2}) return 1;\n\n asio::thread_pool pool(2);\n std::atomic pooled{0};\n asio::post(pool, [&] { ++pooled; });\n asio::post(pool, [&] { ++pooled; });\n pool.join();\n if (pooled != 2) return 2;\n\n asio::io_context cancel_io;\n asio::steady_timer cancelled(cancel_io, 1h);\n asio::cancellation_signal cancellation;\n std::error_code cancelled_ec;\n bool cancelled_called = false;\n cancelled.async_wait(asio::bind_cancellation_slot(\n cancellation.slot(),\n [&](const std::error_code& ec) {\n cancelled_ec = ec;\n cancelled_called = true;\n }));\n cancellation.emit(asio::cancellation_type::all);\n cancel_io.run();\n\n return cancelled_called && cancelled_ec == asio::error::operation_aborted ? 0 : 3;\n}\n" + }, + { + "path": "tests/examples/asio-module/tests/coroutine.cpp", + "text": "// Coroutine surface: awaitable/co_spawn/use_awaitable/this_coro over the\n// module boundary, including a real timer suspension point.\nimport std;\nimport asio;\n\nasio::awaitable answer() {\n auto ex = co_await asio::this_coro::executor;\n asio::steady_timer t(ex, std::chrono::milliseconds(1));\n co_await t.async_wait(asio::use_awaitable);\n co_return 42;\n}\n\nint main() {\n asio::io_context io;\n int result = 0;\n asio::co_spawn(io, answer(), [&](std::exception_ptr, int v) { result = v; });\n io.run();\n return result == 42 ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/asio-module/tests/errors.cpp", + "text": "// The error surface: the four Asio error enums and their enumerators reach a\n// module consumer, and each one compares equal to the std::error_code that\n// asio::error::make_error_code builds from it.\n//\n// Before this was exported the module offered exactly one condition,\n// operation_aborted, which is enough to notice a cancellation and nothing else:\n// telling a refused connection from a DNS failure had no name to compare\n// against on the consumer's side.\nimport std;\nimport asio;\n\nint main() {\n // --- the enum types themselves ---\n static_assert(std::is_enum_v);\n static_assert(std::is_enum_v);\n static_assert(std::is_enum_v);\n static_assert(std::is_enum_v);\n\n // --- enumerators, one per enum, round-tripped through make_error_code ---\n const std::error_code aborted = asio::error::make_error_code(asio::error::operation_aborted);\n const std::error_code refused = asio::error::make_error_code(asio::error::connection_refused);\n const std::error_code timedout = asio::error::make_error_code(asio::error::timed_out);\n const std::error_code unreach = asio::error::make_error_code(asio::error::host_unreachable);\n const std::error_code netdown = asio::error::make_error_code(asio::error::network_unreachable);\n const std::error_code nohost = asio::error::make_error_code(asio::error::host_not_found);\n const std::error_code retryhost = asio::error::make_error_code(asio::error::host_not_found_try_again);\n const std::error_code nosvc = asio::error::make_error_code(asio::error::service_not_found);\n const std::error_code eof = asio::error::make_error_code(asio::error::eof);\n\n // An error_code built from an enumerator compares equal to that enumerator:\n // this is the comparison real code writes (`if (ec == asio::error::eof)`),\n // and it only works if the enumerator NAME crossed the module boundary.\n if (aborted != asio::error::operation_aborted) return 1;\n if (refused != asio::error::connection_refused) return 2;\n if (timedout != asio::error::timed_out) return 3;\n if (unreach != asio::error::host_unreachable) return 4;\n if (netdown != asio::error::network_unreachable) return 5;\n if (nohost != asio::error::host_not_found) return 6;\n if (retryhost != asio::error::host_not_found_try_again) return 7;\n if (nosvc != asio::error::service_not_found) return 8;\n if (eof != asio::error::eof) return 9;\n\n // Distinct conditions must not collapse into each other.\n if (refused == timedout || nohost == retryhost) return 10;\n\n // Every one of them is a real, non-empty condition.\n for (const auto& ec : {aborted, refused, timedout, unreach, netdown,\n nohost, retryhost, nosvc, eof}) {\n if (!ec) return 11;\n if (ec.message().empty()) return 12;\n }\n\n // --- ip::make_address parses, and reports failure through error_code ---\n std::error_code parse;\n const auto v4 = asio::ip::make_address(\"192.0.2.7\", parse);\n if (parse || !v4.is_v4() || v4.to_string() != \"192.0.2.7\") return 13;\n\n const auto v6 = asio::ip::make_address(\"::1\", parse);\n if (parse || !v6.is_v6() || !v6.is_loopback()) return 14;\n\n (void)asio::ip::make_address(\"not-an-address\", parse);\n if (!parse) return 15;\n\n if (asio::ip::make_address_v4(\"10.0.0.1\", parse).to_string() != \"10.0.0.1\" || parse) return 16;\n if (!asio::ip::make_address_v6(\"::1\", parse).is_loopback() || parse) return 17;\n\n return 0;\n}\n" + }, + { + "path": "tests/examples/asio-module/tests/experimental.cpp", + "text": "// Experimental channel/concurrent_channel/use_promise over the module surface.\nimport std;\nimport asio;\n\nint main() {\n asio::io_context io;\n\n asio::experimental::channel ch(io, 1);\n if (!ch.try_send(std::error_code{}, \"channel\")) return 1;\n std::string channel_value;\n std::error_code channel_error;\n ch.async_receive([&](std::error_code ec, std::string value) {\n channel_error = ec;\n channel_value = std::move(value);\n });\n\n asio::experimental::concurrent_channel concurrent(io, 1);\n if (!concurrent.try_send(std::error_code{}, 42)) return 2;\n int concurrent_value = 0;\n std::error_code concurrent_error;\n concurrent.async_receive([&](std::error_code ec, int value) {\n concurrent_error = ec;\n concurrent_value = value;\n });\n\n auto promised = asio::post(io, asio::experimental::use_promise);\n bool promise_completed = false;\n promised([&] { promise_completed = true; });\n\n io.run();\n\n return !channel_error && channel_value == \"channel\"\n && !concurrent_error && concurrent_value == 42\n && promise_completed ? 0 : 3;\n}\n" + }, + { + "path": "tests/examples/asio-module/tests/file.cpp", + "text": "// Asio's file I/O over the module surface: asio::stream_file writes and reads\n// back through a coroutine, asio::random_access_file reads at an offset, and\n// asio::file_base's open flags compose.\n//\n// These names are exported only where Asio has a file backend. For THIS\n// package's configuration the condition is exactly Windows: the backend is IOCP\n// (ASIO_HAS_WINDOWS_RANDOM_ACCESS_HANDLE), and the only other one Asio offers is\n// io_uring, which this package does not enable. `ASIO_HAS_FILE` itself is\n// unusable as the discriminator here — a macro does not cross a module boundary\n// and a module consumer includes no Asio header, so it is always false in this\n// TU no matter what the package was built with.\nimport std;\nimport asio;\n\n#if defined(_WIN32)\n\nint main() {\n namespace fs = std::filesystem;\n\n const auto path = (fs::temp_directory_path() / \"mcpp_asio_module_file.bin\").string();\n std::error_code rm;\n fs::remove(path, rm);\n\n constexpr std::string_view payload = \"mcpp-asio-file\";\n\n asio::io_context io;\n int failure = 0;\n\n asio::co_spawn(io, [&]() -> asio::awaitable {\n // --- write through stream_file, composing three file_base flags ---\n {\n asio::stream_file out(co_await asio::this_coro::executor, path,\n asio::file_base::write_only\n | asio::file_base::create\n | asio::file_base::truncate);\n const auto written =\n co_await asio::async_write(out, asio::buffer(payload), asio::use_awaitable);\n if (written != payload.size()) { failure = 1; co_return; }\n out.close();\n }\n\n // --- read it back through stream_file ---\n {\n asio::stream_file in(co_await asio::this_coro::executor, path,\n asio::file_base::read_only);\n std::string got(payload.size(), '\\0');\n const auto read =\n co_await asio::async_read(in, asio::buffer(got), asio::use_awaitable);\n if (read != payload.size() || got != payload) { failure = 2; co_return; }\n }\n\n // --- random_access_file reads at an offset, leaving no file position ---\n {\n asio::random_access_file ra(co_await asio::this_coro::executor, path,\n asio::file_base::read_only);\n std::string tail(5, '\\0'); // \"-file\", offset 9\n const auto read = co_await ra.async_read_some_at(9, asio::buffer(tail),\n asio::use_awaitable);\n if (read != 5 || tail != \"-file\") { failure = 3; co_return; }\n }\n }, asio::detached);\n\n io.run();\n fs::remove(path, rm);\n\n // The aliases are the basic_ templates with the default executor.\n static_assert(std::is_same_v>);\n static_assert(std::is_same_v>);\n static_assert(std::is_base_of_v);\n\n return failure;\n}\n\n#else\n\nint main() {\n // No file backend in this configuration: asserting the names are absent is\n // not something a TU can do, so this leg only records that the test ran.\n std::println(\"asio module: file I/O not available in this configuration\");\n return 0;\n}\n\n#endif\n" + }, + { + "path": "tests/examples/asio-module/tests/network.cpp", + "text": "// TCP (acceptor/socket, async_read/async_write), the free asio::async_connect\n// over a sequence of candidate endpoints, and UDP (datagram send/receive) over\n// the imported module surface.\nimport std;\nimport asio;\n\nint main() {\n using namespace std::chrono_literals;\n\n // --- TCP echo ---\n asio::io_context io;\n asio::ip::tcp::acceptor acceptor(io, {asio::ip::address_v4::loopback(), 0});\n asio::ip::tcp::socket server(io);\n asio::ip::tcp::socket client(io);\n asio::steady_timer deadline(io, 5s);\n\n const std::string ping = \"ping\";\n const std::string pong = \"pong\";\n std::array server_data{};\n std::array client_data{};\n bool accepted = false;\n bool connected = false;\n bool tcp_done = false;\n bool timed_out = false;\n int failure = 0;\n\n auto fail = [&](int code) {\n if (failure == 0) failure = code;\n std::error_code ignored;\n acceptor.close(ignored);\n server.close(ignored);\n client.close(ignored);\n deadline.cancel();\n };\n\n deadline.async_wait([&](const std::error_code& ec) {\n if (!ec) {\n timed_out = true;\n fail(90);\n }\n });\n\n acceptor.async_accept(server, [&](const std::error_code& ec) {\n if (ec) return fail(1);\n accepted = true;\n asio::async_read(server, asio::buffer(server_data),\n [&](const std::error_code& read_ec, std::size_t n) {\n if (read_ec || n != ping.size()\n || std::string(server_data.data(), n) != ping) return fail(2);\n asio::async_write(server, asio::buffer(pong),\n [&](const std::error_code& write_ec, std::size_t written) {\n if (write_ec || written != pong.size()) fail(3);\n });\n });\n });\n\n client.async_connect(\n {asio::ip::address_v4::loopback(), acceptor.local_endpoint().port()},\n [&](const std::error_code& ec) {\n if (ec) return fail(4);\n connected = true;\n asio::async_write(client, asio::buffer(ping),\n [&](const std::error_code& write_ec, std::size_t written) {\n if (write_ec || written != ping.size()) return fail(5);\n asio::async_read(client, asio::buffer(client_data),\n [&](const std::error_code& read_ec, std::size_t n) {\n if (read_ec || n != pong.size()\n || std::string(client_data.data(), n) != pong) return fail(6);\n tcp_done = true;\n deadline.cancel();\n });\n });\n });\n\n io.run();\n if (failure || timed_out || !accepted || !connected || !tcp_done) return failure ? failure : 7;\n\n // --- UDP datagram ---\n asio::io_context udp_io;\n asio::ip::udp::socket receiver(udp_io, {asio::ip::address_v4::loopback(), 0});\n asio::ip::udp::socket sender(udp_io, {asio::ip::address_v4::loopback(), 0});\n const std::string datagram = \"asio-udp\";\n std::array received{};\n asio::ip::udp::endpoint remote;\n bool receive_done = false;\n bool send_done = false;\n std::error_code udp_failure;\n\n receiver.async_receive_from(asio::buffer(received), remote,\n [&](const std::error_code& ec, std::size_t n) {\n udp_failure = ec;\n receive_done = !ec && n == datagram.size()\n && std::string(received.data(), n) == datagram;\n });\n sender.async_send_to(asio::buffer(datagram), receiver.local_endpoint(),\n [&](const std::error_code& ec, std::size_t n) {\n if (ec) udp_failure = ec;\n send_done = !ec && n == datagram.size();\n });\n\n udp_io.run();\n if (udp_failure || !receive_done || !send_done) return 8;\n\n // --- free asio::async_connect over a candidate sequence ---\n //\n // The member socket.async_connect above takes ONE endpoint; the free\n // function takes a range and tries each in turn, which is what a consumer\n // hands a resolver's results_type to. Assert that semantics directly:\n // an unreachable candidate first, the live acceptor second. Kept hermetic\n // (no DNS) so it cannot fail for reasons that have nothing to do with the\n // module surface.\n asio::io_context connect_io;\n asio::ip::tcp::acceptor live(connect_io, {asio::ip::address_v4::loopback(), 0});\n asio::ip::tcp::socket accepted_side(connect_io);\n asio::ip::tcp::socket connecting(connect_io);\n live.async_accept(accepted_side, [](const std::error_code&) {});\n\n const std::vector candidates{\n // Port 1 on loopback: nothing listens there, so this candidate is\n // refused and async_connect must move on rather than give up.\n {asio::ip::address_v4::loopback(), 1},\n live.local_endpoint(),\n };\n\n std::error_code connect_ec{std::make_error_code(std::errc::not_supported)};\n asio::ip::tcp::endpoint chosen;\n bool connect_done = false;\n\n asio::async_connect(connecting, candidates,\n [&](const std::error_code& ec, const asio::ip::tcp::endpoint& ep) {\n connect_ec = ec;\n chosen = ep;\n connect_done = true;\n });\n\n connect_io.run();\n if (!connect_done || connect_ec) return 9;\n if (chosen != live.local_endpoint()) return 10; // it must have skipped port 1\n if (!connecting.is_open()) return 11;\n\n return 0;\n}\n" + }, + { + "path": "tests/examples/asio-module/tests/surface.cpp", + "text": "// Surface/smoke coverage: exported types, completion tokens, and vocabulary\n// types are usable across the module boundary.\nimport std;\nimport fmt;\nimport asio;\n\nint main() {\n // 回归:导入 Asio 后,libc++ 的 println 仍应保持可用。\n std::println(\"asio module println\");\n std::println(\"asio module println {}\", 42);\n\n // --- buffer / address (existing) ---\n std::array src{'m', 'c', 'p', 'p'};\n asio::const_buffer cb = asio::buffer(src);\n if (cb.size() != 4) return 1;\n\n std::string dst(4, '\\0');\n asio::mutable_buffer mb = asio::buffer(dst);\n std::memcpy(mb.data(), cb.data(), cb.size());\n if (dst != \"mcpp\") return 2;\n\n asio::ip::address_v4 loopback = asio::ip::address_v4::loopback();\n if (loopback.to_string() != \"127.0.0.1\") return 3;\n\n asio::ip::tcp::endpoint ep(loopback, 8080);\n if (ep.port() != 8080) return 4;\n\n // --- execution context hierarchy ---\n if (!std::is_base_of_v) return 5;\n if (!std::is_base_of_v) return 6;\n if (!std::is_base_of_v) return 7;\n\n // --- executors ---\n static_assert(std::is_class_v);\n static_assert(std::is_class_v);\n\n // --- error_code typedef ---\n static_assert(std::is_same_v);\n\n // --- cancellation_type (scoped enum) ---\n static_assert(std::is_enum_v);\n if (static_cast(asio::cancellation_type::all) == 0) return 8;\n if (static_cast(asio::cancellation_type::terminal) == 0) return 9;\n\n // --- signal / timer types ---\n static_assert(std::is_class_v);\n static_assert(std::is_class_v);\n\n // --- completion token variables ---\n asio::io_context surface_io;\n // detached — compile test for the constexpr variable and its usage\n asio::steady_timer t(surface_io, std::chrono::milliseconds(0));\n t.async_wait(asio::detached);\n static_assert(std::is_same_v);\n\n // use_future — accessible as a named variable\n auto uf = asio::use_future;\n (void)uf;\n\n // deferred\n static_assert(std::is_same_v);\n\n // --- redirect_error ---\n std::error_code redirect_ec;\n auto redirected = asio::redirect_error(redirect_ec);\n (void)redirected;\n\n // --- bind_executor ---\n auto bound = asio::bind_executor(asio::system_executor(), []{});\n (void)bound;\n\n // --- associated traits ---\n static_assert(std::is_class_v>);\n static_assert(std::is_class_v>);\n static_assert(std::is_class_v>);\n\n // --- error namespace ---\n if (asio::error::operation_aborted == std::error_code{}) return 10;\n\n return 0;\n}\n" + } + ] + }, + { + "project": "asio-ssl", + "text": "# chriskohlhoff.asio `ssl` FEATURE test — the end-to-end assertion for the\n# opt-in TLS surface: activating the feature compiles asio_ssl.cpp into the\n# package, pulls compat.openssl (3.5.1, built from source), and links\n# libssl.a + libcrypto.a. tests/ssl.cpp then runs a real TLS handshake over\n# loopback.\n#\n# Why this is its own member rather than a flag on tests/examples/asio-module:\n# features are resolved per consuming project, so one project cannot have asio\n# both with and without `ssl`; and the base member must keep asserting the\n# no-ssl build, which is what nearly every consumer takes.\n#\n# It needs BOTH `chriskohlhoff` and `compat`, and a project can only carry one\n# index repo — a second one pointing at the same tree makes every lookup\n# ambiguous (xlings#374). So the split is deliberate: this member declares NO\n# member-level [indices] and inherits the root's `compat`, which is the\n# namespace of the descriptor under test; asio itself is already published and\n# resolves from the remote index. The local asio descriptor, including this\n# feature's own wiring, is covered by tests/examples/asio-module.\n#\n# That inversion is the point — a feature dep resolves through whatever index\n# is configured for ITS namespace, so an unmerged compat descriptor can be\n# exercised through a published consumer that activates it.\n#\n# Linux, macOS, and Windows all resolve compat.openssl and execute the real\n# TLS handshake below. A selected member must always execute the real test.\n[package]\nname = \"asio-ssl-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.chriskohlhoff]\nasio = { version = \"1.38.1\", features = [\"ssl\"] }\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_ASIO_SSL=1\"]\n\n[target.'cfg(macos)'.dependencies.chriskohlhoff]\nasio = { version = \"1.38.1\", features = [\"ssl\"] }\n\n[target.'cfg(macos)'.build]\ncxxflags = [\"-DHAVE_ASIO_SSL=1\"]\n\n# Windows 也必须执行真实 TLS 测试;OpenSSL 的 Windows 构建由包描述负责。\n[target.'cfg(windows)'.dependencies.chriskohlhoff]\nasio = { version = \"1.38.1\", features = [\"ssl\"] }\n\n[target.'cfg(windows)'.build]\ncxxflags = [\"-DHAVE_ASIO_SSL=1\"]\n", + "files": [ + { + "path": "tests/examples/asio-ssl/tests/ssl.cpp", + "text": "// TLS echo test — ssl::stream wrapping a TCP socket, single-threaded\n// async handshake → write/read round-trip. PEM cert+key embedded as\n// string literals (use_certificate / use_private_key from const_buffer).\n//\n// This is the real end-to-end assertion for chriskohlhoff.asio's `ssl`\n// feature: the member activates it, which pulls in compat.openssl, compiles\n// asio_ssl.cpp, and links libssl.a/libcrypto.a.\n//\n// HAVE_ASIO_SSL is set by THIS project's own [target.'cfg(...)'.build]\n// cxxflags, not by the package's feature. A feature's `defines` apply to the\n// package's own translation units; a consumer that keys its source off one has\n// to declare it itself (same shape as the openblas member's HAVE_OPENBLAS).\n// HAVE_ASIO_SSL 由本测试成员自己的构建配置定义;所有已声明平台都必须运行真实 TLS 测试。\n#ifdef HAVE_ASIO_SSL\nimport std;\nimport asio;\n\nnamespace {\nconstexpr std::string_view cert_pem = R\"(-----BEGIN CERTIFICATE-----\nMIIDCzCCAfOgAwIBAgIUQzyqmufGtwwB//IMUo6WewD6UqgwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcyNjA4NTczNloYDzIxMjYw\nNzAyMDg1NzM2WjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB\nAQUAA4IBDwAwggEKAoIBAQCVXzA1Yve8b24Fjue5/A+ZPKFADt/4/945Vnm5hfEt\n2XOYGNaY9/M3lB/MSRRHB4lP8bxI0cj5AuHnlZkC7wiQQ3t/pdsWMQjmpfrLO9Aq\nWwgTA2Y94HNB8JtjUWLX/Kf+qLU8MQu374zrcnTyEYcrih0/mGMgvzmT5YXkB/La\n7CDyfV7l/vBfzAwtt3Q0YQpuS47ahraO+mnv0Y9wotnm7L8D5/uPUepbTtDNR9K2\nZ22SF5/WxElb/r6vRmmLOJbX29K8Z7hEtKswcJx9U40Rnt3j8mFvelEWLGTdFkj+\nn6KFX1eD95/0RzPWf9XrtkJ7zzJOPDj7Njp5zIfUjvuPAgMBAAGjUzBRMB0GA1Ud\nDgQWBBQkq8tq7Tnld/JWpfUJbFlBnRsFuDAfBgNVHSMEGDAWgBQkq8tq7Tnld/JW\npfUJbFlBnRsFuDAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBu\n7Q+mh+Mf45QyuGfRMxu6q3RU4sjJAwRjrm6UwEmSBqUpKvOKnam4+jSFOLhz2uaz\nsk2WLs3zJ0SQb3uRzp92QNJmQmMf8TDl1sa32R41nCFs7CwLihyg3NwI1mWjN8Rm\nT+nfgea1o5K3BijDOoSCVzKUWv+K4OqZ679LxX4k02sMSKUd9kw1pMhZE2alxHoH\nMQW9IDkaCzjwvuYkzeFJdSNoxktf0ebWkNoZjAlTy83sDDiuwJ09OybnRql7Cifz\nSB+GQNhPRSdMH7cCv7uwokDiccDTK4VKrOsGC1G5y6Jd+C9L0PCngYi9vu1I8U90\nB+1vU0skeNeszdW++phe\n-----END CERTIFICATE-----\n)\";\n\nconstexpr std::string_view key_pem = R\"(-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCVXzA1Yve8b24F\njue5/A+ZPKFADt/4/945Vnm5hfEt2XOYGNaY9/M3lB/MSRRHB4lP8bxI0cj5AuHn\nlZkC7wiQQ3t/pdsWMQjmpfrLO9AqWwgTA2Y94HNB8JtjUWLX/Kf+qLU8MQu374zr\ncnTyEYcrih0/mGMgvzmT5YXkB/La7CDyfV7l/vBfzAwtt3Q0YQpuS47ahraO+mnv\n0Y9wotnm7L8D5/uPUepbTtDNR9K2Z22SF5/WxElb/r6vRmmLOJbX29K8Z7hEtKsw\ncJx9U40Rnt3j8mFvelEWLGTdFkj+n6KFX1eD95/0RzPWf9XrtkJ7zzJOPDj7Njp5\nzIfUjvuPAgMBAAECggEAL3nHutAv6WaJU57uLADffFb28YNI0L2ShirkGYFm/Kmm\nwerzGj+EwF+GE8oOdd0BWbV9oK987xhpcM/tiC8tS50HPbUbg1wmdhi/M6VZLn0s\nfc6Qyo3yVD0DRnfxsLCPPLOmlvEHxniPE66XWPEVQ1NspG/s4dWlmUpUWfvkxovW\nQ2nagMCwMjt/miadEQm64KznpN57yCYE8+ZAeWnWUoP/EtrTk9BVAC5s50vVJQKm\ngXW8t9g7mNE+6ywOOHajA2AbKFbk7Bi0vh/y4MkIiQATZW4KC98MU0e/JK8Y6cNb\nG94Xm5fA6MnYQdkpJ9AijD3sLwF5KRqiD4ro2+licQKBgQDGTnTyPEopnNK+/Ol0\n7i1kuqI9+WFkN/gwF01ZdzUzgqut5LHbSxUpf9RDYj7tMo7lsQHuGP1Om4fXLDN8\nJUTK0Gb0xlC43IgrNsXpziA4VnUhQ3FXiWTIUeKvAOpckww/wBfirTekUeReH8rN\nE8E7C+0Vz2Xp8BaIoJ4iDrVa4wKBgQDA1Ciy7fMyobJEfmBUxHvQo1GDModDwZb4\nXaaxH7hTuZd+tqAC1iUY8gzWvlO8rWm6+sSNwu1noMVEtsc0OzKBv5h6EB6NhBOd\nXNacKP7DSo4K6420KkFh5t76p2uKlN3ge2CIzTiESsYf+TP6QXaC/uh455OSe9NJ\nNZ1MGe9gZQKBgHUx6sU5wi6DgrziZOn41JTiA34Swm7i8Oci7lCANc3CXMmBDWdn\nIROMexpzlnLB3Vd7W4Ol+xWYrxgIBElLETO3JBFmnlAR7Nt1HFPHwJzq44AMBpDQ\nHuKQGiKIrPiW4rdORA9vhSG0T/0cVtMJ6LmHm8626ijt/bMzESFZhe43AoGAX3ls\ngVOBw8L94h30kmQKrf3/QQeGo8y5dFXiT/bVrFbLJMlFpsHi8lv+cWEhUt1F6Xd6\nVHp8U3/tzJz3OuxIkKeN1noetpD7qUGrXPyLT6Sdedixe9AkOVY3d0Hn5GDbDufn\nnzSFVDM1r+USkElTZX7TGfIHRlMbBTePn3uD42UCgYAHiAr2RB/JewvjpQkZCkWT\nwtEKliMPx8aOH24D5nUvtMIQlrCNmiy3SqqIaWyG2oMk4EsDmUQiCUj7Ov0+R5nC\nvEbmTFZL2J/YTXm5JR0jWCCz9oIjxYCp6z1CkW9IxBgyCVPnh4fe+/pgMvkskhRz\nVGJlxAgzCXYPd1NGSakFTg==\n-----END PRIVATE KEY-----\n)\";\n}\n\nint main() {\n using namespace std::chrono_literals;\n\n asio::io_context io;\n\n // --- SSL contexts (cert+key from memory) ---\n asio::ssl::context ssl_ctx(asio::ssl::context::tls_server);\n ssl_ctx.use_certificate(\n asio::buffer(cert_pem.data(), cert_pem.size()),\n asio::ssl::context_base::pem);\n ssl_ctx.use_private_key(\n asio::buffer(key_pem.data(), key_pem.size()),\n asio::ssl::context_base::pem);\n\n asio::ssl::context client_ctx(asio::ssl::context::tls_client);\n\n // --- TCP acceptor ---\n asio::ip::tcp::acceptor acceptor(io, {asio::ip::address_v4::loopback(), 0});\n asio::ip::tcp::socket server_sock(io);\n asio::ssl::stream server_stream(server_sock, ssl_ctx);\n\n // --- deadline ---\n asio::steady_timer deadline(io, 5s);\n int failure = 0;\n\n auto fail = [&](int code) {\n if (failure == 0) failure = code;\n std::error_code ignored;\n acceptor.close(ignored);\n deadline.cancel();\n };\n\n deadline.async_wait([&](const std::error_code& ec) { if (!ec) fail(90); });\n\n const std::string ping = \"tls-ping\";\n const std::string pong = \"tls-pong\";\n\n // --- server: accept → handshake → read → write ---\n acceptor.async_accept(server_sock, [&](const std::error_code& ec) {\n if (ec) return fail(1);\n server_stream.async_handshake(asio::ssl::stream_base::server,\n [&](const std::error_code& hsec) {\n if (hsec) return fail(2);\n static std::array data{};\n asio::async_read(server_stream, asio::buffer(data),\n [&](const std::error_code& rec, std::size_t n) {\n if (rec || n != ping.size()\n || std::string(data.data(), n) != ping) return fail(3);\n asio::async_write(server_stream, asio::buffer(pong),\n [&](const std::error_code& wec, std::size_t) {\n if (wec) fail(4);\n });\n });\n });\n });\n\n // --- client: connect → handshake → write → read ---\n bool client_done = false;\n asio::ssl::stream client_stream(io, client_ctx);\n client_stream.lowest_layer().connect(\n {asio::ip::address_v4::loopback(), acceptor.local_endpoint().port()});\n\n client_stream.async_handshake(asio::ssl::stream_base::client,\n [&](const std::error_code& ec) {\n if (ec) return fail(5);\n asio::async_write(client_stream, asio::buffer(ping),\n [&](const std::error_code& wec, std::size_t written) {\n if (wec || written != ping.size()) return fail(6);\n static std::array data{};\n asio::async_read(client_stream, asio::buffer(data),\n [&](const std::error_code& rec, std::size_t n) {\n if (rec || n != pong.size()\n || std::string(data.data(), n) != pong) return fail(7);\n client_done = true;\n deadline.cancel();\n });\n });\n });\n\n io.run();\n return failure ? failure : (client_done ? 0 : 8);\n}\n#else\n#error \"HAVE_ASIO_SSL must be enabled for every declared asio-ssl test platform\"\n#endif\n" + } + ] + }, + { + "project": "boost-any", + "text": "# compat.boost-any test project.\n[package]\nname = \"boost-any-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-any = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-any/tests/any.cpp", + "text": "#include \n\n#include \n\nint main() {\n boost::any value = 42;\n if (value.empty()) return 1;\n if (boost::any_cast(value) != 42) return 1;\n\n value = std::string(\"muduo\");\n const auto* text = boost::any_cast(&value);\n if (text == nullptr || *text != \"muduo\") return 1;\n\n value.clear();\n return value.empty() ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-asio", + "text": "# compat.boost-asio test project: the direct-consumer path for the boostorg\n# asio tree — feature-define propagation (BOOST_ASIO_DISABLE_BOOST_* reach\n# this TU), a real steady_timer round through io_context, and the coroutine\n# surface (co_spawn + awaitable). Stackful spawn/deadline_timer stay absent\n# by design; the descriptor's disable defines are what keep boost.context and\n# boost.date_time out of the closure.\n[package]\nname = \"boost-asio-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-asio = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-asio/tests/asio.cpp", + "text": "// Behavioral test for compat.boost-asio: a real steady_timer fires through a\n// running io_context, post/defech queue in order, and the C++20 coroutine\n// surface (co_spawn + awaitable) adds numbers. All offline, no sockets.\n#include \n\nint main() {\n namespace asio = boost::asio;\n using asio::ip::tcp;\n bool ok = true;\n\n // 1. A steady_timer with a 1ms expiry actually wakes the io_context.\n {\n asio::io_context ctx;\n bool fired = false;\n asio::steady_timer timer(ctx, std::chrono::milliseconds(1));\n timer.async_wait([&](const boost::system::error_code& ec) {\n fired = !ec.failed();\n });\n ctx.run();\n ok = ok && fired;\n }\n\n // 2. post and defer both run; a strand-wrapped counter is consistent.\n {\n asio::io_context ctx;\n int counter = 0;\n asio::post(ctx, [&] { ++counter; });\n asio::defer(ctx, [&] { ++counter; });\n ctx.run();\n ok = ok && counter == 2;\n }\n\n // 3. co_spawn + awaitable: the coroutine surface works end to end.\n {\n asio::io_context ctx;\n auto adder = [](int a, int b) -> asio::awaitable { co_return a + b; };\n int result = 0;\n asio::co_spawn(\n ctx,\n [&]() -> asio::awaitable {\n result = co_await adder(20, 22);\n },\n asio::detached);\n ctx.run();\n ok = ok && result == 42;\n }\n\n // 4. The tcp endpoint/address plumbing compiles and resolves offline.\n {\n tcp::endpoint ep(asio::ip::make_address(\"127.0.0.1\"), 8080);\n ok = ok && ep.address().is_loopback() && ep.port() == 8080;\n }\n\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-assert", + "text": "# compat.boost-assert test project: asserts the assert/current_function/\n# source_location surface compiles and behaves at runtime.\n[package]\nname = \"boost-assert-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-assert = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-assert/tests/assert.cpp", + "text": "// Behavioral test: BOOST_VERIFY's expression is evaluated in ALL build modes\n// (unlike BOOST_ASSERT, which compiles away under NDEBUG), and the\n// source_location / current_function utilities return live data.\n#include \n#include \n#include \n\nstatic int answer() { return 42; }\n\nint main() {\n bool ran = false;\n BOOST_VERIFY(answer() == 42 && (ran = true)); // must run even under NDEBUG\n\n const char* fn = BOOST_CURRENT_FUNCTION;\n bool fn_ok = fn != nullptr && *fn != '\\0';\n\n constexpr boost::source_location loc = BOOST_CURRENT_LOCATION;\n bool loc_ok = loc.file_name() != nullptr && loc.line() > 0;\n\n return (ran && fn_ok && loc_ok) ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-beast", + "text": "# compat.boost-beast test project: offline HTTP parse/serialize round-trip,\n# zlib round-trip and SSL-detection — the assertions exercise the whole\n# 25-package modular-boost closure transitively (asio, system, core, mp11,\n# intrusive, ...) through one `#include `.\n[package]\nname = \"boost-beast-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-beast = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-beast/tests/beast.cpp", + "text": "// Behavioral test for compat.boost-beast, entirely offline: buffer types,\n// HTTP request parsing, response serialize + parse round-trip, the error\n// path on malformed input, the bundled zlib streams, and beast::static_string.\n// Including the boost/beast.hpp aggregate forces one TU through core + http +\n// websocket + zlib, which is what validates the whole modular-boost closure\n// wiring (asio, system, mp11, intrusive, ...) against the 1.92.0 train.\n#include \n\n#include \n\nint main() {\n namespace beast = boost::beast;\n namespace asio = boost::asio;\n namespace http = beast::http;\n namespace zlib = beast::zlib;\n bool ok = true;\n\n // beast's parser.put() returns the number of octets it parsed and the\n // caller must consume exactly that many from the dynamic buffer before\n // feeding the rest — the same contract http::read() implements on top.\n auto parse_all = [](auto& parser, beast::flat_buffer& buf) {\n beast::error_code ec;\n while (!parser.is_done()) {\n auto const consumed = parser.put(buf.data(), ec);\n if (ec.failed() || consumed == 0) break;\n buf.consume(consumed);\n }\n return ec;\n };\n\n // 1. multi_buffer (an intrusive list of extents) holds appended chunks;\n // flat_buffer linearizes them byte-for-byte.\n {\n beast::multi_buffer mb;\n asio::const_buffer chunk(\"0123456789\", 10);\n mb.commit(asio::buffer_copy(mb.prepare(10), chunk));\n mb.commit(asio::buffer_copy(mb.prepare(10), chunk));\n ok = ok && mb.size() == 20;\n\n beast::flat_buffer fb;\n fb.commit(asio::buffer_copy(fb.prepare(mb.size()), mb.data()));\n ok = ok && beast::buffers_to_string(fb.data()) ==\n \"01234567890123456789\";\n }\n\n // 2\\. Parse a request carrying headers and a body; assert the fields.\n {\n std::string const wire =\n \"PUT /things/42 HTTP/1.1\\r\\n\"\n \"Host: example.com\\r\\n\"\n \"Content-Type: text/plain\\r\\n\"\n \"Content-Length: 5\\r\\n\"\n \"\\r\\n\"\n \"hello\";\n beast::flat_buffer buf;\n buf.commit(asio::buffer_copy(buf.prepare(wire.size()),\n asio::buffer(wire)));\n\n http::request_parser parser;\n beast::error_code ec = parse_all(parser, buf);\n ok = ok && !ec.failed() && parser.is_done();\n\n auto const& req = parser.get();\n ok = ok && req.method() == http::verb::put;\n ok = ok && req.target() == \"/things/42\";\n ok = ok && req.version() == 11;\n ok = ok && req[\"Host\"] == \"example.com\";\n ok = ok && req[\"Content-Type\"] == \"text/plain\";\n ok = ok && req.body() == \"hello\";\n }\n\n // 3\\. Serialize a response, parse the wire bytes back; values survive.\n {\n http::response res{http::status::created, 11};\n res.set(http::field::content_type, \"application/json\");\n res.body() = \"{\\\"ok\\\":true}\";\n res.prepare_payload();\n\n http::response_serializer sr(res);\n std::string out;\n beast::error_code sec;\n while (!sr.is_done()) {\n sr.next(sec, [&](beast::error_code const&,\n auto const& bytes) {\n out += beast::buffers_to_string(bytes);\n sr.consume(beast::buffer_bytes(bytes));\n });\n }\n ok = ok && !sec.failed();\n ok = ok && out.find(\"HTTP/1.1 201 Created\\r\\n\") == 0;\n ok = ok &&\n out.find(\"Content-Type: application/json\") != std::string::npos;\n ok = ok && out.find(\"{\\\"ok\\\":true}\") != std::string::npos;\n\n beast::flat_buffer buf;\n buf.commit(asio::buffer_copy(buf.prepare(out.size()),\n asio::buffer(out)));\n http::response_parser parser;\n beast::error_code ec = parse_all(parser, buf);\n ok = ok && !ec.failed() && parser.is_done();\n\n auto const& back = parser.get();\n ok = ok && back.result() == http::status::created;\n ok = ok && back.version() == 11;\n ok = ok && back[http::field::content_type] == \"application/json\";\n ok = ok && back.body() == \"{\\\"ok\\\":true}\";\n }\n\n // 4\\. Malformed input must fail the parser through beast's error type.\n {\n std::string const bad = \"NONSENSE\\r\\n\\r\\n\";\n beast::flat_buffer buf;\n buf.commit(asio::buffer_copy(buf.prepare(bad.size()),\n asio::buffer(bad)));\n http::request_parser parser;\n beast::error_code ec = parse_all(parser, buf);\n ok = ok && ec.failed();\n }\n\n // 5\\. The bundled zlib implementation (no external libz) round-trips data\n // that compresses well.\n {\n std::string const text(8192, 'a');\n beast::error_code ec;\n\n zlib::deflate_stream def;\n def.reset(6, 15, 8, zlib::Strategy::normal);\n std::string comp(1024, '\\0');\n zlib::z_params zd{};\n zd.next_in = text.data();\n zd.avail_in = text.size();\n zd.next_out = comp.data();\n zd.avail_out = comp.size();\n def.write(zd, zlib::Flush::full, ec);\n ok = ok && !ec.failed();\n comp.resize(comp.size() - zd.avail_out);\n ok = ok && !comp.empty() && comp.size() < text.size();\n\n zlib::inflate_stream inf;\n inf.reset(15);\n std::string back(text.size() + 64, '\\0');\n zlib::z_params zi{};\n zi.next_in = comp.data();\n zi.avail_in = comp.size();\n zi.next_out = back.data();\n zi.avail_out = back.size();\n inf.write(zi, zlib::Flush::none, ec);\n ok = ok && !ec.failed();\n back.resize(back.size() - zi.avail_out);\n ok = ok && back == text;\n }\n\n // 6\\. beast::static_string (boost/static_string) stays within capacity and\n // compares like a string.\n {\n beast::static_string<16> s{\"hello\"};\n s.append(\", world\");\n ok = ok && s.size() == 12;\n ok = ok && s == \"hello, world\";\n }\n\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-circular-buffer", + "text": "# compat.boost-circular-buffer test project.\n[package]\nname = \"boost-circular-buffer-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-circular-buffer = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-circular-buffer/tests/circular_buffer.cpp", + "text": "#include \n\nint main() {\n boost::circular_buffer values(3);\n values.push_back(1);\n values.push_back(2);\n values.push_back(3);\n values.push_back(4);\n return values.size() == 3 && values.front() == 2 && values.back() == 4\n ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-concept-check", + "text": "# compat.boost-concept-check test project.\n[package]\nname = \"boost-concept-check-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-concept-check = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-concept-check/tests/concepts.cpp", + "text": "#include \n\nBOOST_CONCEPT_ASSERT((boost::CopyConstructible));\nBOOST_CONCEPT_ASSERT((boost::DefaultConstructible));\n\nint main() {\n int value = 42;\n return value == 42 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-config", + "text": "# compat.boost-config test project: the root of the modular-boost header\n# family this index carries. Asserts the pinned 1.92.0 train and positive\n# platform detection under `mcpp test`. Resolves via the inherited\n# workspace-root `[indices]` compat redirect.\n[package]\nname = \"boost-config-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-config = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-config/tests/config.cpp", + "text": "// Behavioral test: the compat.boost-config headers are present and identify\n// themselves as exactly the pinned 1.92.0 train. If the descriptor ever\n// drifts to another release without this member following, this fails loudly.\n#include \n#include \n#include \n\nint main() {\n // BOOST_VERSION encodes major*100000 + minor*100 + patch -> 1.92.0.\n bool ok = BOOST_VERSION == 109200;\n ok = ok && std::string_view(BOOST_LIB_VERSION) == \"1_92\";\n\n // Platform detection must have positively selected THIS platform's header:\n // each platform/*.hpp stamps BOOST_PLATFORM with its own string. A config\n // that silently defaulted everywhere would fail on every branch.\n#if defined(_WIN32)\n ok = ok && std::string_view(BOOST_PLATFORM) == \"Win32\";\n#elif defined(__APPLE__) && defined(__MACH__)\n ok = ok && std::string_view(BOOST_PLATFORM) == \"Mac OS\";\n#elif defined(__linux__)\n ok = ok && std::string_view(BOOST_PLATFORM) == \"linux\";\n#else\n ok = false; // not a platform this index claims\n#endif\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-ext.ut", + "text": "# boost-ext.ut test project: consumes the C++23 module `boost.ut` and\n# asserts two test styles (UDL + function syntax) plus an expect failure\n# under `mcpp test`.\n# Overrides the workspace-root redirect (`compat`): this member needs the\n# `boost-ext` namespace. A member-level [indices] REPLACES the inherited\n# table rather than merging with it — keeps the project index repo to ONE.\n[indices]\nboost-ext = { path = \"../../..\" }\n\n[package]\nname = \"boost-ext-ut-tests\"\nversion = \"0.1.0\"\n\n[dependencies.boost-ext]\nut = \"2.3.1\"\n", + "files": [ + { + "path": "tests/examples/boost-ext.ut/tests/ut.cpp", + "text": "// Behavioral test: build two tests under [Boost::ext].UT via `import boost.ut;`,\n// then exercise the UDL (\"...\"_test) and function-call (test(\"...\")) syntaxes.\n// Counts test registration by reading ut's `tests_runner` directly is awkward,\n// so the test instead just RUNS a passing assertion in each style; if the\n// module fails to import or any expect() fails, [Boost::ext].UT reports the\n// failure through its test runner and `mcpp test` returns non-zero.\nimport std;\nimport boost.ut;\n\nusing namespace boost::ut;\n\nint main() {\n \"UDL syntax\"_test = [] {\n expect(42_i == 42);\n };\n\n test(\"function syntax\") = [] {\n expect(0_i == 0);\n expect(1u == 1_u);\n };\n\n return 0;\n}\n" + } + ] + }, + { + "project": "boost-family", + "text": "# compat boost 家族\"大测试\"成员:为每个没有专属成员的小依赖包提供行为断言。\n# 消费全部 23 个小包(boost-beast / boost-asio / 既有 uuid/config/assert/\n# throw-exception/type-traits 各有专属成员,不在其中),按主题分成 6 个\n# tests/*.cpp,每个组件都有可失败的有效断言。\n[package]\nname = \"boost-family-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-align = \"1.92.0\"\nboost-bind = \"1.92.0\"\nboost-compat = \"1.92.0\"\nboost-container = \"1.92.0\"\nboost-container-hash = \"1.92.0\"\nboost-core = \"1.92.0\"\nboost-describe = \"1.92.0\"\nboost-endian = \"1.92.0\"\nboost-intrusive = \"1.92.0\"\nboost-io = \"1.92.0\"\nboost-logic = \"1.92.0\"\nboost-move = \"1.92.0\"\nboost-mp11 = \"1.92.0\"\nboost-optional = \"1.92.0\"\nboost-predef = \"1.92.0\"\nboost-preprocessor = \"1.92.0\"\nboost-smart-ptr = \"1.92.0\"\nboost-static-string = \"1.92.0\"\nboost-system = \"1.92.0\"\nboost-type-index = \"1.92.0\"\nboost-utility = \"1.92.0\"\nboost-variant2 = \"1.92.0\"\nboost-winapi = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-family/tests/bytes-logic.cpp", + "text": "// boost-endian (byte-order conversion), boost-container-hash (hashing),\n// boost-logic (tribool) and boost-align (pointer alignment helpers).\n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n\nint main() {\n bool ok = true;\n\n // endian: reversing is an involution with byte-exact results.\n {\n std::uint32_t x = 0x01020304u;\n ok = ok && boost::endian::endian_reverse(x) == 0x04030201u;\n ok = ok &&\n boost::endian::endian_reverse(boost::endian::endian_reverse(x)) == x;\n }\n\n // container-hash: deterministic within a process; combining is stable.\n {\n boost::hash hs;\n ok = ok && hs(\"abc\") == hs(\"abc\");\n\n const std::vector v1{1, 2, 3}, v2{1, 2, 3};\n std::size_t seed = 0;\n boost::hash_range(seed, v1.begin(), v1.end());\n std::size_t seed2 = 0;\n boost::hash_range(seed2, v2.begin(), v2.end());\n ok = ok && seed == seed2 && seed != 0;\n }\n\n // logic: three-valued truth table entry points used by beast::detect_ssl.\n // Every operator (including !) returns tribool; the explicit conversion\n // and the indeterminate() free function are the way back to bool.\n {\n using boost::logic::indeterminate;\n using boost::logic::tribool;\n tribool t = true, f = false, i = indeterminate;\n\n ok = ok && static_cast(t) && !static_cast(f);\n ok = ok && indeterminate(i);\n ok = ok && indeterminate(t && i);\n ok = ok && static_cast(t || i); // true dominates OR\n ok = ok && !static_cast(f && i); // false dominates AND\n ok = ok && indeterminate(i || f) && indeterminate(i && t);\n }\n\n // align: round-tripping through align_up lands on the boundary and\n // align_down undoes it.\n {\n alignas(64) char raw[128];\n ok = ok && boost::alignment::is_aligned(boost::alignment::align_up(raw, 64), 64);\n ok = ok && boost::alignment::align_down(\n boost::alignment::align_up(raw, 64), 64) ==\n boost::alignment::align_up(raw, 64);\n }\n\n return ok ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/boost-family/tests/containers.cpp", + "text": "// boost-container (vector), boost-intrusive (list), boost-optional\n// and boost-static-string.\n#include \n#include \n#include \n#include \n\nint main() {\n bool ok = true;\n\n // container: an STL-compatible vector that is not std::vector.\n {\n boost::container::vector vec;\n for (int i = 0; i < 100; ++i) vec.push_back(i);\n ok = ok && vec.size() == 100 && vec.back() == 99 && vec[42] == 42;\n }\n\n // intrusive: the list only links; ownership stays with the nodes.\n {\n namespace intr = boost::intrusive;\n struct Node : intr::list_base_hook<> {\n int v;\n explicit Node(int x) : v(x) {}\n };\n intr::list lst;\n Node a(1), b(2), c(3);\n lst.push_back(a);\n lst.push_back(b);\n lst.push_back(c);\n ok = ok && lst.size() == 3 && lst.front().v == 1 && lst.back().v == 3;\n\n lst.pop_front();\n ok = ok && lst.front().v == 2 && a.v == 1; // a outlives its unlinking\n\n lst.clear(); // unlink everything: hooks must not die while linked\n ok = ok && lst.empty();\n }\n\n // optional: empty/engaged lifecycle and value_or fallback.\n {\n boost::optional o;\n ok = ok && !o && o.value_or(-1) == -1;\n\n o = 9;\n ok = ok && o && *o == 9 && o.value() == 9;\n\n o.reset();\n ok = ok && !o;\n }\n\n // static-string: fixed-capacity string with a compile-time bound.\n {\n boost::static_strings::static_string<8> s(\"abc\");\n s += \"def\";\n ok = ok && s == \"abcdef\" && s.size() == 6;\n ok = ok && s.compare(0, 3, \"abc\") == 0;\n }\n\n return ok ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/boost-family/tests/core-smart.cpp", + "text": "// boost-core (addressof / empty_value / ref), boost-smart-ptr\n// (shared_ptr/weak_ptr/make_shared) and boost-move (movelib::unique_ptr).\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nint main() {\n bool ok = true;\n\n // core: addressof survives overloaded operator&, ref wraps without copies.\n {\n struct Tricky {\n int v = 7;\n Tricky* operator&() const { return nullptr; }\n } tricky;\n ok = ok && boost::addressof(tricky) != nullptr;\n ok = ok && boost::addressof(tricky)->v == 7;\n\n auto r = boost::cref(tricky.v);\n ok = ok && r.get() == 7;\n }\n\n // core: empty_value with a non-empty payload stores and returns it.\n {\n boost::empty_value ev(boost::empty_init_t(), 42);\n ok = ok && ev.get() == 42;\n }\n\n // smart-ptr: reference counting across shared/weak.\n {\n boost::shared_ptr sp = boost::make_shared(5);\n boost::weak_ptr wp = sp;\n ok = ok && sp.use_count() == 1 && *sp == 5 && !wp.expired();\n\n sp.reset();\n ok = ok && wp.expired() && wp.lock() == boost::shared_ptr();\n }\n\n // move: movelib::unique_ptr transfers sole ownership.\n {\n boost::movelib::unique_ptr up = boost::movelib::make_unique(11);\n boost::movelib::unique_ptr up2 = boost::move(up);\n ok = ok && !up && up2 && *up2 == 11;\n }\n\n return ok ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/boost-family/tests/io-platform.cpp", + "text": "// boost-io (stream state savers), boost-utility (compressed_pair) and\n// boost-winapi (WinAPI type shims — compilable only where Win32 exists).\n#include \n#include \n\n#include \n#include \n#include \n\n#ifdef _WIN32\n#include \n#endif\n\nint main() {\n bool ok = true;\n\n // io: RAII flag saver restores the stream's formatting state.\n {\n std::ostringstream os;\n os << 255;\n ok = ok && os.str() == \"255\";\n\n os.str(\"\");\n {\n boost::io::ios_flags_saver fs(os);\n os << std::hex << 255;\n ok = ok && os.str() == \"ff\";\n }\n\n os.str(\"\");\n os << 255;\n ok = ok && os.str() == \"255\"; // hex did not leak\n }\n\n // utility: compressed_pair — empty-base-optimized pair with accessors.\n {\n boost::compressed_pair cp(3, \"three\");\n ok = ok && cp.first() == 3 && cp.second() == \"three\";\n\n // Both members empty: compiles and stays stateless in practice.\n struct Nothing {};\n boost::compressed_pair empty_pair{};\n ok = ok && sizeof(empty_pair) <= sizeof(Nothing) * 2;\n }\n\n // winapi: the shims declare the Win32 surface themselves, but the\n // headers are Windows-only to compile (they #error elsewhere), so real\n // assertions run under the Windows CI leg only.\n {\n#ifdef _WIN32\n ok = ok && std::is_pointer::value;\n ok = ok && std::is_integral::value;\n ok = ok && sizeof(boost::winapi::DWORD_) >= 4;\n#endif\n }\n\n return ok ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/boost-family/tests/meta.cpp", + "text": "// boost-mp11 (list metaprogramming), boost-describe (enum reflection),\n// boost-preprocessor (token repetition), boost-type-index (runtime type\n// identity) and boost-predef (platform/compiler detection).\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n\n// Enumerators must be visible at global scope for the describe macros.\nenum class Color { red = 1, green = 2, blue = 4 };\nBOOST_DESCRIBE_ENUM(Color, red, green, blue)\n\n#define FAMILY_INC(z, n, d) + (n)\n\nint main() {\n bool ok = true;\n\n // mp11: list size and per-type iteration.\n {\n using L = boost::mp11::mp_list;\n ok = ok && boost::mp11::mp_size::value == 3;\n\n int total = 0;\n boost::mp11::mp_for_each([&](auto t) { total += sizeof(decltype(t)); });\n ok = ok && total > 0 && total >= 3;\n }\n\n // describe: enumerate (name, value) pairs without RTTI tricks.\n {\n int count = 0;\n bool blueNamed = false;\n boost::mp11::mp_for_each>(\n [&](auto d) {\n ++count;\n if (d.value == Color::blue)\n blueNamed = std::string(d.name) == \"blue\";\n });\n ok = ok && count == 3 && blueNamed;\n }\n\n // preprocessor: stringize/cat composition and unrolled repetition.\n {\n ok = ok && std::string(BOOST_PP_STRINGIZE(BOOST_PP_CAT(fo, o))) == \"foo\";\n\n int total = BOOST_PP_REPEAT(5, FAMILY_INC, 0); // + 0 + 1 + 2 + 3 + 4\n ok = ok && total == 10;\n }\n\n // type-index: runtime type identity with a readable name.\n {\n namespace ti = boost::typeindex;\n ok = ok && ti::type_id() == ti::type_id();\n ok = ok && ti::type_id() != ti::type_id();\n ok = ok && std::string(ti::type_id().pretty_name()).find(\"int\") !=\n std::string::npos;\n }\n\n // predef: the platform and compiler we run the index's CI on must be\n // detected (predef defines every macro, 0 when not detected).\n {\n ok = ok && (BOOST_OS_LINUX || BOOST_OS_MACOS || BOOST_OS_WINDOWS) != 0;\n ok = ok && (BOOST_COMP_CLANG || BOOST_COMP_GNUC || BOOST_COMP_MSVC) != 0;\n }\n\n return ok ? 0 : 1;\n}\n" + }, + { + "path": "tests/examples/boost-family/tests/system-errors.cpp", + "text": "// boost-system (error_code/system_error), boost-compat (invoke backport)\n// and boost-bind (bind/mem_fn with placeholders).\n#include \n#include \n#include \n#include \n\n#include \n\nint family_sub(int a, int b) { return a - b; }\n\nstruct Point {\n int x = 31;\n int get() const { return x; }\n};\n\nint main() {\n bool ok = true;\n\n // system: generic errc mapping, category identity, throwing with a code.\n {\n namespace sys = boost::system;\n sys::error_code ec = sys::errc::make_error_code(sys::errc::invalid_argument);\n ok = ok && static_cast(ec);\n ok = ok && ec == sys::errc::invalid_argument;\n ok = ok && ec.category() == sys::generic_category();\n\n bool caught = false;\n try {\n throw sys::system_error(ec);\n } catch (const sys::system_error& e) {\n caught = e.code() == sys::errc::invalid_argument;\n }\n ok = ok && caught;\n }\n\n // compat: C++11-17 std::invoke backport, including member-object access.\n {\n struct Inc {\n int operator()(int x) const { return x + 1; }\n };\n ok = ok && boost::compat::invoke(Inc{}, 41) == 42;\n ok = ok && boost::compat::invoke([](int a, int b) { return a * b; }, 6, 7) == 42;\n\n Point p;\n ok = ok && boost::compat::invoke(&Point::x, p) == 31;\n ok = ok && boost::compat::invoke(&Point::get, p) == 31;\n }\n\n // bind: partial application through placeholders, plus mem_fn.\n {\n namespace ph = boost::placeholders;\n auto f = boost::bind(family_sub, ph::_1, 3);\n ok = ok && f(10) == 7;\n\n Point p;\n auto byName = boost::bind(&Point::x, ph::_1);\n ok = ok && byName(p) == 31;\n\n auto m = boost::mem_fn(&Point::get);\n ok = ok && m(p) == 31;\n }\n\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-module", + "text": "# boost.boost C++23-module package test: selective feature consumption of the\n# Boost named-modules wrapper — `import boost.optional;` plus the umbrella\n# `import boost;`. The package is Form A (upstream ships its own mcpp.toml), so\n# this member is also the only place the descriptor's `mcpp = \"*/mcpp.toml\"`\n# default lookup is exercised.\n#\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `ZheFeng7110`. The table is keyed by NAMESPACE, so the key must be the\n# dependency's namespace (`ZheFeng7110`), not the package name (`boost`). A\n# member-level [indices] REPLACES the inherited table rather than merging with\n# it, which keeps this to ONE project index repo — two repos pointing at the\n# same tree make every lookup ambiguous.\n[indices]\nZheFeng7110 = { path = \"../../..\" }\n\n[package]\nname = \"boost-module-tests\"\nversion = \"0.1.0\"\n\n# Selective, NOT the package's 49-library default closure: opt out of the\n# default set and pick a core sample (header-only optional/type_traits, the\n# compiled json, and the version constants). This keeps index CI on the\n# validated module surface without building the whole wrapper.\n[dependencies.ZheFeng7110]\nboost = { version = \"1.91.0.0.1.0\", default-features = false, features = [\"optional\", \"type_traits\", \"json\", \"version\"] }\n", + "files": [ + { + "path": "tests/examples/boost-module/tests/boost_module.cpp", + "text": "// Public boost.boost module-package end-to-end assertion: consume the Boost\n// named-modules wrapper by import only (no `#include ` at all).\n//\n// * `import boost.optional;` — a classic header-only library\n// * `import boost.json;` — a compiled library (library TU linked in)\n// * `import boost.version;` — the version constants module\n// * `import boost;` — the umbrella, re-exporting the active set\n\nimport std;\n\nimport boost.optional;\nimport boost.json;\nimport boost.version;\nimport boost;\n\nint main() {\n int failures = 0;\n auto check = [&failures](bool ok, const char* what) {\n if (!ok) {\n std::println(\"FAIL: {}\", what);\n ++failures;\n }\n };\n\n // optional (header-only module)\n boost::optional empty;\n check(!empty.has_value(), \"optional: empty has no value\");\n boost::optional v(42);\n check(v.has_value() && *v == 42, \"optional: value round-trip\");\n check(v.value_or(0) == 42, \"optional: value_or hits the value\");\n empty = 7;\n check(empty.value_or(0) == 7, \"optional: assignment activates\");\n\n // json (compiled library)\n boost::json::value j = boost::json::parse(R\"({\"lib\":\"boost\",\"ok\":true})\");\n check(j.is_object(), \"json: parsed to an object\");\n boost::json::object& o = j.as_object();\n check(o[\"lib\"].as_string() == \"boost\", \"json: string member\");\n check(o[\"ok\"].as_bool(), \"json: bool member\");\n const std::string ser = boost::json::serialize(j);\n check(boost::json::parse(ser).as_object()[\"lib\"].as_string() == \"boost\",\n \"json: serialize/parse round-trip\");\n\n // version constants\n check(boost::BOOST_VERSION == 109100, \"version: BOOST_VERSION == 109100\");\n check(boost::BOOST_LIB_VERSION[0] == '1', \"version: BOOST_LIB_VERSION\");\n\n // type_traits (reached through the umbrella's active set as well)\n check(boost::is_same::value, \"type_traits: is_same\");\n\n if (failures == 0) {\n std::println(\"boost.boost modules ok: BOOST_VERSION={}\", boost::BOOST_VERSION);\n }\n return failures == 0 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-throw-exception", + "text": "# compat.boost-throw-exception test project: BOOST_THROW_EXCEPTION raises a\n# std::exception-derived error carrying the original message.\n[package]\nname = \"boost-throw-exception-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-throw-exception = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-throw-exception/tests/throw_exception.cpp", + "text": "// Behavioral test: BOOST_THROW_EXCEPTION raises an exception catchable as its\n// own std::exception base, carrying the original message through what().\n#include \n#include \n#include \n\nint main() {\n bool caught = false;\n try {\n BOOST_THROW_EXCEPTION(std::runtime_error(\"compat probe\"));\n } catch (const std::exception& e) {\n caught = std::strstr(e.what(), \"compat probe\") != nullptr;\n }\n return caught ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-type-traits", + "text": "# compat.boost-type-traits test project: the trait answers are asserted both\n# statically (compile fails on wrong answers) and through a runtime value.\n[package]\nname = \"boost-type-traits-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-type-traits = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-type-traits/tests/type_traits.cpp", + "text": "// Behavioral test: core traits give the right answers, cross-checked against\n// the standard library where an equivalent exists.\n#include \n#include \n#include \n#include \n\nint main() {\n static_assert(boost::is_same::value, \"same type must match\");\n static_assert(!boost::is_same::value, \"distinct types must differ\");\n static_assert(std::is_same::type, int*>::value,\n \"add_pointer must yield int*\");\n static_assert(std::is_same::type, int>::value,\n \"remove_cv must strip const/volatile\");\n\n bool ok = boost::is_same::value; // runtime-visible path\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "boost-uuid", + "text": "# compat.boost-uuid test project: RFC 4122 shape, string round-trip,\n# deterministic name generation and std::hash. Pulls the whole modular-boost\n# header family transitively through the descriptor's own deps.\n[package]\nname = \"boost-uuid-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nboost-uuid = \"1.92.0\"\n", + "files": [ + { + "path": "tests/examples/boost-uuid/tests/uuid.cpp", + "text": "// Behavioral test: RFC 4122 shape of generated UUIDs, string round-trip,\n// deterministic name generation, and the std::hash specialization.\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nint main() {\n namespace uuids = boost::uuids;\n bool ok = true;\n\n // Nil UUID round-trips through the canonical textual form.\n std::ostringstream nil_out;\n nil_out << uuids::nil_uuid();\n ok = ok && nil_out.str() == \"00000000-0000-0000-0000-000000000000\";\n\n // Parse -> print reproduces the canonical spelling byte for byte.\n uuids::string_generator parse;\n const uuids::uuid fixed =\n parse(\"f81d4fae-7dec-11d0-a765-00a0c91e6bf6\");\n std::ostringstream fixed_out;\n fixed_out << fixed;\n ok = ok && fixed_out.str() == \"f81d4fae-7dec-11d0-a765-00a0c91e6bf6\";\n\n // Random generator emits RFC 4122 version 4 / variant 1 shapes, and two\n // draws differ (collision odds are negligible by construction).\n uuids::random_generator rand;\n const uuids::uuid a = rand();\n const uuids::uuid b = rand();\n ok = ok && ((a.data[6] >> 4) == 4) && ((a.data[8] >> 6) == 0b10);\n ok = ok && a != b;\n\n // Name generator is deterministic per input and sensitive across inputs.\n uuids::name_generator_sha1 namegen(uuids::ns::dns());\n ok = ok && namegen(\"example.com\") == namegen(\"example.com\");\n ok = ok && namegen(\"example.com\") != namegen(\"other.example.com\");\n\n // The std::hash specialization agrees on equal values.\n ok = ok && std::hash{}(fixed) == std::hash{}(fixed);\n\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "brotli", + "text": "# Brotli test project: consumes compat.brotli through the workspace-root\n# `compat` index redirect and exercises encoder, decoder, and common code.\n[package]\nname = \"brotli-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nbrotli = \"1.2.0\"\n", + "files": [ + { + "path": "tests/examples/brotli/tests/roundtrip.cpp", + "text": "#include \n#include \n\n#include \n#include \n#include \n\nint main() {\n static constexpr unsigned char original[] =\n \"Brotli round-trip payload. Brotli round-trip payload. \"\n \"Brotli round-trip payload. Brotli round-trip payload.\";\n constexpr std::size_t original_size = sizeof(original) - 1;\n\n const std::size_t encoded_capacity = BrotliEncoderMaxCompressedSize(original_size);\n if (original_size == 0 || encoded_capacity == 0) return 1;\n\n std::vector encoded(encoded_capacity);\n std::size_t encoded_size = encoded.size();\n const BROTLI_BOOL encoded_ok = BrotliEncoderCompress(\n BROTLI_DEFAULT_QUALITY,\n BROTLI_DEFAULT_WINDOW,\n BROTLI_MODE_GENERIC,\n original_size,\n original,\n &encoded_size,\n encoded.data());\n if (encoded_ok != BROTLI_TRUE || encoded_size == 0) return 2;\n\n std::vector decoded(original_size);\n std::size_t decoded_size = decoded.size();\n const BrotliDecoderResult decoded_result = BrotliDecoderDecompress(\n encoded_size,\n encoded.data(),\n &decoded_size,\n decoded.data());\n if (decoded_result != BROTLI_DECODER_RESULT_SUCCESS) return 3;\n if (decoded_size != original_size) return 4;\n\n for (std::size_t i = 0; i < original_size; ++i) {\n if (decoded[i] != original[i]) return 5;\n }\n return std::memcmp(decoded.data(), original, original_size) == 0 ? 0 : 6;\n}\n" + } + ] + }, + { + "project": "build-mcpp", + "text": "# L3 build.mcpp consumer: the project-local build.mcpp generates a source and\n# emits a compile define; the test asserts both took effect. Exercises the\n# build.mcpp pipeline itself (no registry dependency).\n[package]\nname = \"build-mcpp-tests\"\nversion = \"0.1.0\"\n", + "files": [ + { + "path": "tests/examples/build-mcpp/tests/answer.cpp", + "text": "// Asserts both outputs of build.mcpp reached the build: the -D define (compile\n// flag) and the generated answer() function (a generated source linked in).\n#ifndef BUILT_BY_BUILD_MCPP\n#error \"build.mcpp cxxflag did not reach the test translation unit\"\n#endif\nint answer(); // from the build.mcpp-generated src/generated.cpp\nint main() { return answer() == 42 ? 0 : 1; }\n" + } + ] + }, + { + "project": "c-ares", + "text": "# c-ares test project: consumes compat.c-ares and asserts behavior under\n# `mcpp test`. Part of the mcpp-index self-referential workspace — the\n# workspace-root `[indices]` redirect points at this repo, so the dependency\n# resolves to the checked-in recipe (pkgs/c/compat.c-ares.lua).\n#\n# The test is entirely OFFLINE: it never sends a DNS query, so it does not\n# depend on the CI runner having (or not having) working name resolution.\n[package]\nname = \"c-ares-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nc-ares = \"1.34.5\"\n", + "files": [ + { + "path": "tests/examples/c-ares/tests/resolver.cpp", + "text": "// Behavioral test for compat.c-ares.\n//\n// Entirely OFFLINE — no DNS query is ever sent, so the result does not depend\n// on the runner's network. What it does exercise is the machinery gRPC's\n// resolver actually leans on, and each piece lives in a different part of the\n// library:\n//\n// ares_library_init / _cleanup -> src/lib/ares_library_init.c\n// ares_init_options / ares_destroy -> ares_init.c, ares_options.c, util/*\n// set/get servers as CSV -> ares_options.c, str/*, record/* (parsing\n// an address list is real string+record work)\n// ares_inet_pton / ares_inet_ntop -> inet_net_pton.c, inet_ntop.c\n// ares_strerror -> ares_strerror.c\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n#include \"ares.h\"\n\nnamespace {\n\nbool version_ok() {\n int num = 0;\n const char* s = ares_version(&num);\n // The descriptor pins 1.34.5; assert the header/library agree with it, so a\n // silently different vendored version cannot pass.\n return s != nullptr && std::string(s).find(\"1.34.5\") != std::string::npos &&\n ((num >> 16) & 0xff) == 1 && ((num >> 8) & 0xff) == 34 && (num & 0xff) == 5;\n}\n\nbool channel_and_servers_ok() {\n struct ares_options opts;\n std::memset(&opts, 0, sizeof(opts));\n opts.flags = ARES_FLAG_NOSEARCH | ARES_FLAG_NOALIASES;\n\n ares_channel_t* ch = nullptr;\n if (ares_init_options(&ch, &opts, ARES_OPT_FLAGS) != ARES_SUCCESS) return false;\n\n bool ok = false;\n // Setting servers parses the CSV into c-ares' internal server list; reading\n // it back re-serializes it. A stub would not survive the round trip.\n if (ares_set_servers_csv(ch, \"127.0.0.1:5353,[::1]:5354\") == ARES_SUCCESS) {\n char* csv = ares_get_servers_csv(ch);\n if (csv != nullptr) {\n const std::string got(csv);\n ok = got.find(\"127.0.0.1\") != std::string::npos &&\n got.find(\"5353\") != std::string::npos &&\n got.find(\"::1\") != std::string::npos;\n ares_free_string(csv);\n }\n }\n\n // Garbage must be REJECTED — otherwise the \"parse\" above proves nothing.\n if (ok && ares_set_servers_csv(ch, \"not-an-address:::\") == ARES_SUCCESS) ok = false;\n\n ares_destroy(ch);\n return ok;\n}\n\nbool inet_conversions_ok() {\n unsigned char buf[16];\n char out[64];\n\n if (ares_inet_pton(AF_INET, \"192.0.2.7\", buf) != 1) return false;\n if (ares_inet_ntop(AF_INET, buf, out, sizeof(out)) == nullptr) return false;\n if (std::string(out) != \"192.0.2.7\") return false;\n\n if (ares_inet_pton(AF_INET6, \"2001:db8::1\", buf) != 1) return false;\n if (ares_inet_ntop(AF_INET6, buf, out, sizeof(out)) == nullptr) return false;\n if (std::string(out) != \"2001:db8::1\") return false;\n\n // Malformed input must fail.\n return ares_inet_pton(AF_INET, \"999.1.1.1\", buf) != 1;\n}\n\nbool strerror_ok() {\n const char* ok = ares_strerror(ARES_SUCCESS);\n const char* nf = ares_strerror(ARES_ENOTFOUND);\n return ok != nullptr && nf != nullptr && std::strlen(nf) > 0 &&\n std::string(ok) != std::string(nf);\n}\n\n} // namespace\n\nint main() {\n if (ares_library_init(ARES_LIB_INIT_ALL) != ARES_SUCCESS) return 1;\n\n const bool ok = version_ok() && channel_and_servers_ok() &&\n inet_conversions_ok() && strerror_ok();\n\n ares_library_cleanup();\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "cairo", + "text": "# cairo test member — draws, reads the pixels back, and checks the feature split.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"cairo-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# No `features` named, so the package's own default set applies — which is the\n# thing under test: ft/fc/png on, xlib/xcb off.\n[target.'cfg(linux)'.dependencies.freedesktop]\ncairo = \"1.18.2\"\n", + "files": [ + { + "path": "tests/examples/cairo/tests/cairo.cpp", + "text": "// cairo, via its HEADERS — draw something, read the pixels back, and check that the\n// feature split actually took effect.\n//\n// Cairo needs no display and no fonts on disk to draw: an image surface is\n// memory, and that is what a compositor's software fallback and every\n// screenshot path use. So this test draws for real rather than only checking\n// that symbols resolve.\n//\n// THE FEATURE ASSERTIONS ARE THE POINT OF THE PACKAGE. `xlib` and `xcb` are off\n// by default, and \"off\" has to mean the macro is undefined — otherwise a\n// consumer could `#include ` and fail at link instead of at\n// compile, which is the wrong place to find out.\n\n#ifdef __linux__\n\n#include \n// ⚠️ cairo.h does NOT reach the feature headers. This name used to arrive from\n// the module — which is precisely the confusion this split removes: on the\n// header route you include what you use.\n#include \n\n#include \n#include \n\n// ⚠️ THIS FILE IS THE HEADER ROUTE, AND IT NO LONGER IMPORTS.\n//\n// It used to do BOTH — `#include ` and `import freedesktop.cairo;` —\n// and that is why nobody noticed the module was incomplete: the header\n// supplied every name the module was missing, including `cairo_t` and all 192\n// enumerators. An import that is never asked to stand on its own is not\n// tested, it is decorated.\n//\n// `tests/module.cpp` next door takes the module route alone. Two files,\n// because on GCC the two routes cannot be combined in one TU anyway: reaching\n// a system header both textually and through a module's global fragment makes\n// the same type two entities.\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\" cairo %s\\n\", cairo_version_string());\n check(cairo_version() >= 11802, \"cairo_version reports 1.18.2 or newer\");\n\n // ── 1. Draw, then read the pixel back ────────────────────────────────\n // A 64x64 ARGB32 surface, filled with an exact colour, then one pixel\n // sampled. Exact because cairo_set_source_rgb takes doubles and\n // 0.25/0.5/0.75 land on 64/128/191 without rounding ambiguity — the same\n // values the GBM/EGL closed-loop test uses, for the same reason.\n cairo_surface_t *s = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 64, 64);\n check(cairo_surface_status(s) == CAIRO_STATUS_SUCCESS,\n \"cairo_image_surface_create(ARGB32, 64, 64)\");\n\n cairo_t *cr = cairo_create(s);\n check(cairo_status(cr) == CAIRO_STATUS_SUCCESS, \"cairo_create on it\");\n\n // ONE drawing pass, THEN one flush, THEN read. The first version of this\n // test flushed and read between the paint and the stroke, and the stroke\n // then changed zero pixels with `cairo_status` still SUCCESS — cairo had\n // handed the buffer out and did not take it back. `cairo_surface_flush` is\n // the end of a drawing sequence, not a checkpoint inside one.\n cairo_set_source_rgb(cr, 0.25, 0.5, 0.75);\n cairo_paint(cr);\n\n cairo_set_source_rgb(cr, 1, 1, 1);\n cairo_set_line_width(cr, 8);\n cairo_move_to(cr, 8, 8);\n cairo_line_to(cr, 56, 56);\n cairo_stroke(cr);\n\n check(cairo_status(cr) == CAIRO_STATUS_SUCCESS, \"…and the context is still clean\");\n cairo_surface_flush(s);\n\n const unsigned char *d = cairo_image_surface_get_data(s);\n const int stride = cairo_image_surface_get_stride(s);\n\n // ARGB32 is little-endian: B G R A.\n // (56,8) is far from the diagonal, so it still carries the paint —\n // 0.25/0.5/0.75 land on 64/128/191 with no rounding ambiguity, the same\n // values the GBM/EGL closed-loop test uses and for the same reason.\n const unsigned char *bg = d + 8 * stride + 56 * 4;\n std::printf(\" (56,8) = %u %u %u %u (B G R A)\\n\", bg[0], bg[1], bg[2], bg[3]);\n check(bg[2] == 64 && bg[1] == 128 && bg[0] == 191 && bg[3] == 255,\n \"the painted background is exactly what was asked for\");\n\n // (32,32) is on the diagonal, under an 8px white stroke.\n const unsigned char *on = d + 32 * stride + 32 * 4;\n std::printf(\" (32,32) = %u %u %u %u\\n\", on[0], on[1], on[2], on[3]);\n // ── 3. The path machinery, checked directly ─────────────────────────\n //\n // These three are here because of what they caught. With\n // `WORDS_BIGENDIAN` defined to 0 — which cairo tests with `#ifdef`, so 0\n // means BIG-ENDIAN — every path got garbage fixed-point coordinates while\n // `cairo_status` reported success and `cairo_paint` kept working:\n //\n // path_extents -8.03e+06 -8.03e+06 4.37e+06 4.37e+06\n // in_fill(40, 40) 1 (the point is outside the rectangle)\n //\n // The pixel check above catches it too, but only as \"the stroke drew\n // nothing\", which sends you looking for a missing source file. These say\n // where the arithmetic went wrong.\n {\n cairo_t *c3 = cairo_create(s);\n cairo_rectangle(c3, 4, 4, 16, 16);\n double x1, y1, x2, y2;\n cairo_path_extents(c3, &x1, &y1, &x2, &y2);\n std::printf(\" path_extents = %g %g %g %g\\n\", x1, y1, x2, y2);\n check(x1 == 4 && y1 == 4 && x2 == 20 && y2 == 20,\n \"a rectangle path has the extents it was given\");\n check(cairo_in_fill(c3, 10, 10) == 1, \"…and a point inside it reads as inside\");\n check(cairo_in_fill(c3, 40, 40) == 0, \"…and a point outside reads as outside\");\n cairo_destroy(c3);\n }\n\n check(on[0] == 255 && on[1] == 255 && on[2] == 255,\n \"…and the stroked diagonal covers the centre in white\");\n\n\n cairo_destroy(cr);\n cairo_surface_destroy(s);\n\n // ── 3. The default feature set ───────────────────────────────────────\n#ifdef CAIRO_HAS_FT_FONT\n check(true, \"feature ft is ON by default (CAIRO_HAS_FT_FONT)\");\n#else\n check(false, \"feature ft should be ON by default\");\n#endif\n#ifdef CAIRO_HAS_FC_FONT\n check(true, \"feature fc is ON by default (CAIRO_HAS_FC_FONT)\");\n#else\n check(false, \"feature fc should be ON by default\");\n#endif\n#ifdef CAIRO_HAS_PNG_FUNCTIONS\n check(true, \"feature png is ON by default\");\n#else\n check(false, \"feature png should be ON by default\");\n#endif\n\n // ── 4. X11 is OFF, and that is the whole point ───────────────────────\n // A Wayland program must not acquire libX11 by depending on cairo. If this\n // ever fails, someone made a backend unconditional.\n#ifdef CAIRO_HAS_XLIB_SURFACE\n check(false, \"xlib must be OFF unless the consumer asks for it\");\n#else\n check(true, \"feature xlib is OFF by default — no libX11 pulled in\");\n#endif\n#ifdef CAIRO_HAS_XCB_SURFACE\n check(false, \"xcb must be OFF unless the consumer asks for it\");\n#else\n check(true, \"feature xcb is OFF by default\");\n#endif\n\n // ── 5. The module carries what the features enabled ──────────────────\n // `cairo_image_surface_create_from_png` lives behind CAIRO_HAS_PNG_FUNCTIONS\n // inside cairo.h itself, not in a feature header — the module generator has\n // to notice that or a png-enabled consumer cannot reach it through `import`.\n check(&cairo_surface_write_to_png != nullptr,\n \"module exports a name guarded inside cairo.h (write_to_png)\");\n check(&cairo_ft_font_face_create_for_ft_face != nullptr,\n \"…and one from a feature header (cairo_ft_font_face_create…)\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/cairo/tests/module.cpp", + "text": "// cairo, via its MODULE — and this is the route that had never been checked.\n//\n// ⭐ WHY THIS FILE EXISTS\n//\n// The package exposed `import freedesktop.cairo;` and the only test that named\n// it also wrote `#include `. So the header supplied whatever the\n// module lacked, and what it lacked was substantial:\n//\n// 470 names exported, and among the missing:\n// cairo_t the single most-used type in the library\n// 192 enumerators every CAIRO_FORMAT_*, CAIRO_STATUS_*, CAIRO_OPERATOR_*\n//\n// It was found from OUTSIDE, by gnome.pangocairo — which cannot mix the two\n// routes (pangocairo.h reaches glib and therefore , so a textual\n// include alongside the import makes `struct _IO_FILE` two entities) and\n// therefore had to ask the module for `cairo_t` and got nothing.\n//\n// This file includes NOTHING. That is the whole point: if a name is missing\n// from the module, this fails to compile.\n\n#ifdef __linux__\n\nimport cairo;\n\n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"import cairo — %s\\n\\n\", cairo_version_string());\n\n // ── the types, including the one that used to be missing ─────────────\n cairo_surface_t *surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 64, 64);\n check(cairo_surface_status(surf) == CAIRO_STATUS_SUCCESS,\n \"cairo_image_surface_create — CAIRO_FORMAT_ARGB32 is an ENUMERATOR\");\n check(cairo_image_surface_get_format(surf) == CAIRO_FORMAT_ARGB32,\n \"…and the surface reports the format that was asked for\");\n\n // ⭐ `cairo_t` — `typedef struct _cairo cairo_t;`. The old regex required at\n // least one character between `cairo_` and `_t`, so this one name, the one\n // every cairo program starts with, was the one it could not match.\n cairo_t *cr = cairo_create(surf);\n check(cairo_status(cr) == CAIRO_STATUS_SUCCESS, \"cairo_create returns a live cairo_t\");\n\n // ── draw, and read the pixels back ───────────────────────────────────\n cairo_set_source_rgb(cr, 1.0, 0.0, 0.0);\n cairo_rectangle(cr, 8, 8, 48, 48);\n cairo_fill(cr);\n cairo_surface_flush(surf);\n\n const unsigned char *d = cairo_image_surface_get_data(surf);\n const int stride = cairo_image_surface_get_stride(surf);\n check(d != nullptr, \"the image surface has readable pixel data\");\n\n long ink = 0;\n for (int y = 0; d && y < 64; ++y) {\n for (int x = 0; x < 64; ++x) {\n if (d[static_cast(y) * stride + 4 * x + 3] != 0) {\n ++ink;\n }\n }\n }\n std::printf(\" filled pixels: %ld (expected 48*48 = 2304)\\n\", ink);\n check(ink == 48 * 48, \"the rectangle is exactly the pixels it asked for\");\n\n // ── more enumerators, from three different enums ─────────────────────\n cairo_set_operator(cr, CAIRO_OPERATOR_OVER);\n check(cairo_get_operator(cr) == CAIRO_OPERATOR_OVER, \"CAIRO_OPERATOR_OVER\");\n cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);\n check(cairo_get_line_cap(cr) == CAIRO_LINE_CAP_ROUND, \"CAIRO_LINE_CAP_ROUND\");\n cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);\n check(cairo_get_fill_rule(cr) == CAIRO_FILL_RULE_EVEN_ODD, \"CAIRO_FILL_RULE_EVEN_ODD\");\n\n // ── the matrix, which is a struct type rather than an opaque handle ──\n cairo_matrix_t m;\n cairo_matrix_init_identity(&m);\n cairo_matrix_translate(&m, 3.0, 4.0);\n check(m.x0 == 3.0 && m.y0 == 4.0, \"cairo_matrix_t is a usable value type\");\n\n // ── the FreeType font backend, which is a default feature ────────────\n // ⚠️ The point is the ENUMERATOR: CAIRO_FONT_TYPE_FT is what a consumer\n // compares against, and it was among the 192 that never made it out.\n cairo_font_face_t *ff = cairo_get_font_face(cr);\n std::printf(\" default font backend: %d (CAIRO_FONT_TYPE_FT=%d)\\n\",\n (int) cairo_font_face_get_type(ff), (int) CAIRO_FONT_TYPE_FT);\n check(cairo_font_face_status(ff) == CAIRO_STATUS_SUCCESS,\n \"the default font face is live\");\n\n cairo_destroy(cr);\n cairo_surface_destroy(surf);\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \nint main() { std::printf(\"cairo: Linux only here.\\n\"); return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "capi-lua", + "text": "[indices]\n\"mcpplibs.capi\" = { path = \"../../..\" }\n\n[package]\nname = \"capi-lua-tests\"\nversion = \"0.1.0\"\n\n[dependencies.mcpplibs]\ncapi.lua = \"0.0.3\"\n", + "files": [ + { + "path": "tests/examples/capi-lua/tests/main.cpp", + "text": "import mcpplibs.capi.lua;\n\nnamespace lua = mcpplibs::capi::lua;\n\nint main() {\n auto* state = lua::L_newstate();\n if (state == nullptr) return 1;\n\n const int status = lua::L_dostring(state, \"return 6 * 7\");\n const auto answer = lua::tointeger(state, -1);\n lua::close(state);\n return status == lua::OK && answer == 42 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "catch2-main", + "text": "# compat.catch2 at v3 — `main` FEATURE path: the package supplies the entry\n# point and this project deliberately defines no main(). If the feature stops\n# gating the generated TU in, this member fails with\n# `undefined reference to 'main'`.\n#\n# This cannot share a project with tests/examples/catch2: features are resolved\n# per consuming project, so one project cannot hold catch2 both with and\n# without `main` (same constraint as asio-ssl vs asio-module).\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"catch2-main-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncatch2 = { version = \"3.15.2\", features = [\"main\"] }\n", + "files": [ + { + "path": "tests/examples/catch2-main/tests/main.cpp", + "text": "// compat.catch2 v3, `main` FEATURE: no main() here on purpose — the entry\n// point comes from the package's generated TU. Catch2 prints the assertion\n// count on exit, so an empty binary cannot pass quietly.\n#include \n\nstatic unsigned int factorial(unsigned int n) {\n return n <= 1 ? 1 : n * factorial(n - 1);\n}\n\nTEST_CASE(\"factorial of 5 is 120\", \"[math]\") {\n REQUIRE(factorial(5) == 120);\n}\n\nTEST_CASE(\"factorial of 0 is 1\", \"[math]\") {\n REQUIRE(factorial(0) == 1);\n}\n" + } + ] + }, + { + "project": "catch2-v2-main", + "text": "# compat.catch2 at v2 — `main` FEATURE path: no main() and no\n# CATCH_CONFIG_MAIN here; both come from the package's generated TU. This is\n# also what exercises the ELSE branch of that TU's\n# __has_include() discriminator — the v3 members only\n# ever take the THEN branch.\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"catch2-v2-main-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncatch2 = { version = \"2.13.10\", features = [\"main\"] }\n", + "files": [ + { + "path": "tests/examples/catch2-v2-main/tests/main.cpp", + "text": "// compat.catch2 v2, `main` FEATURE: neither main() nor CATCH_CONFIG_MAIN here\n// — both come from the package's generated TU, which reaches this version\n// through the ELSE branch of its __has_include() test.\n#include \n\nstatic unsigned int factorial(unsigned int n) {\n return n <= 1 ? 1 : n * factorial(n - 1);\n}\n\nTEST_CASE(\"factorial of 5 is 120\", \"[math]\") {\n REQUIRE(factorial(5) == 120);\n}\n\nTEST_CASE(\"factorial of 0 is 1\", \"[math]\") {\n REQUIRE(factorial(0) == 1);\n}\n" + } + ] + }, + { + "project": "catch2-v2", + "text": "# compat.catch2 at v2 — DEFAULT path: header-only, this project defines\n# CATCH_CONFIG_MAIN itself. This is the member that proves the single-package\n# union holds: at 2.13.10 the `*/src/catch2/**/*.cpp` glob must match nothing\n# and `*/single_include` must resolve, or the build picks up the wrong half.\n#\n# The `compat` index redirect is inherited from the workspace root.\n# Part of the mcpp-index self-referential workspace.\n[package]\nname = \"catch2-v2-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncatch2 = \"2.13.10\"\n", + "files": [ + { + "path": "tests/examples/catch2-v2/tests/main.cpp", + "text": "#define CATCH_CONFIG_MAIN\n#include \n\nstatic unsigned int factorial(unsigned int n) {\n return n <= 1 ? 1 : n * factorial(n - 1);\n}\n\nTEST_CASE(\"factorial of 5 is 120\", \"[math]\") {\n REQUIRE(factorial(5) == 120);\n}\n\nTEST_CASE(\"factorial of 0 is 1\", \"[math]\") {\n REQUIRE(factorial(0) == 1);\n}\n" + } + ] + }, + { + "project": "catch2", + "text": "# compat.catch2 at v3 — DEFAULT path: the package is a static library and this\n# project supplies its own main() via Catch::Session. Also pins down that\n# upstream's catch_main.cpp really is excluded — if the `!` negation stopped\n# working, this member would fail with `multiple definition of 'main'`.\n#\n# The `compat` index redirect is inherited from the workspace root.\n# Part of the mcpp-index self-referential workspace.\n[package]\nname = \"catch2-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncatch2 = \"3.15.2\"\n", + "files": [ + { + "path": "tests/examples/catch2/tests/main.cpp", + "text": "#include \n\nstatic unsigned int factorial(unsigned int n) {\n return n <= 1 ? 1 : n * factorial(n - 1);\n}\n\nTEST_CASE(\"factorial of 5 is 120\", \"[math]\") {\n REQUIRE(factorial(5) == 120);\n}\n\nTEST_CASE(\"factorial of 0 is 1\", \"[math]\") {\n REQUIRE(factorial(0) == 1);\n}\n\nint main(int argc, char* argv[]) {\n return Catch::Session().run(argc, argv);\n}\n" + } + ] + }, + { + "project": "cjson", + "text": "# cJSON test project: depends on compat.cjson (built from source via this repo's\n# own index) and asserts behavior under `mcpp test`. Part of the mcpp-index\n# self-referential workspace.\n[package]\nname = \"cjson-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncjson = \"1.7.19\"\n", + "files": [ + { + "path": "tests/examples/cjson/tests/parse.cpp", + "text": "// Behavioral test: build a cJSON document, serialize, re-parse, assert fields.\n#include \n#include \n#include \n\nint main() {\n cJSON* root = cJSON_CreateObject();\n cJSON_AddNumberToObject(root, \"answer\", 42);\n cJSON_AddStringToObject(root, \"lib\", \"cJSON\");\n char* text = cJSON_PrintUnformatted(root);\n\n cJSON* parsed = cJSON_Parse(text);\n assert(parsed != nullptr);\n assert(cJSON_GetObjectItem(parsed, \"answer\")->valueint == 42);\n assert(std::strcmp(cJSON_GetObjectItem(parsed, \"lib\")->valuestring, \"cJSON\") == 0);\n\n cJSON_free(text);\n cJSON_Delete(root);\n cJSON_Delete(parsed);\n return 0;\n}\n" + } + ] + }, + { + "project": "cli11", + "text": "# CLI11 test project: consumes compat.CLI11 (header-only command line parser)\n# from this repo's own index and asserts real argv parsing under `mcpp test`.\n# Part of the mcpp-index self-referential workspace: the `compat` redirect is\n# declared at the workspace root and inherited here, so the dependency\n# resolves to the checked-in recipe (pkgs/c/compat.CLI11.lua) rather than to\n# the published remote index.\n#\n# The dependency is spelled QUALIFIED (`[dependencies.compat]`) on purpose:\n# the index redirect is keyed by the REQUEST's namespace, so a bare\n# `CLI11 = \"2.7.2\"` would ask under the default namespace, resolve from the\n# published index, and quietly stop testing this checkout.\n[package]\nname = \"cli11-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nCLI11 = \"2.7.2\"\n", + "files": [ + { + "path": "tests/examples/cli11/tests/parse.cpp", + "text": "// Behavioral test: parse synthetic argv with CLI11 and assert what came out.\n// Covers the four things a parser has to get right — values and defaults,\n// flags, a multi-value option, a subcommand — plus the two failure paths\n// (unknown option, failed validator), which is what proves the parse actually\n// ran rather than silently doing nothing. Returns non-zero on any mismatch.\n//\n// It also calls the package's anchor symbol. compat.CLI11 is header-only, so\n// its lib target holds exactly that one translation unit: if the reference\n// resolves, the package really was built and linked, not just included.\n#include \n\n#include \n#include \n\nint mcpp_compat_cli11_headers_anchor(void);\n\nnamespace {\n\n// Values, default, flag, multi-value option and a subcommand, in one parse.\nbool parses_a_full_command_line() {\n CLI::App app{\"mcpp-index CLI11 member\"};\n\n std::string name;\n app.add_option(\"-n,--name\", name, \"a name\")->required();\n\n int count = 1; // left alone by the argv below: asserts the default holds\n app.add_option(\"-c,--count\", count, \"how many\");\n\n bool verbose = false;\n app.add_flag(\"-v,--verbose\", verbose, \"chatty\");\n\n std::vector nums;\n app.add_option(\"--nums\", nums, \"numbers\")->expected(3);\n\n auto *sub = app.add_subcommand(\"run\", \"do the thing\");\n std::string target;\n sub->add_option(\"target\", target, \"what to run\")->required();\n\n const char *argv[] = {\"prog\", \"--name\", \"mcpp\", \"-v\",\n \"--nums\", \"1\", \"2\", \"3\", \"run\", \"index\"};\n try {\n app.parse(static_cast(sizeof(argv) / sizeof(argv[0])), argv);\n } catch (const CLI::ParseError &) {\n return false;\n }\n\n return name == \"mcpp\" && count == 1 && verbose\n && nums == std::vector{1, 2, 3}\n && app.got_subcommand(sub) && target == \"index\";\n}\n\n// An option nobody declared must be rejected, not ignored.\nbool rejects_an_unknown_option() {\n CLI::App app;\n int count = 0;\n app.add_option(\"-c,--count\", count);\n\n const char *argv[] = {\"prog\", \"--nope\"};\n try {\n app.parse(2, argv);\n } catch (const CLI::ParseError &) {\n return true;\n }\n return false;\n}\n\n// Validators live in their own header; a value outside the range must throw.\nbool enforces_a_validator() {\n CLI::App app;\n int count = 0;\n app.add_option(\"-c,--count\", count)->check(CLI::Range(1, 10));\n\n const char *argv[] = {\"prog\", \"-c\", \"99\"};\n try {\n app.parse(3, argv);\n } catch (const CLI::ValidationError &) {\n return true;\n } catch (const CLI::ParseError &) {\n return false;\n }\n return false;\n}\n\n} // namespace\n\nint main() {\n const bool ok = parses_a_full_command_line()\n && rejects_an_unknown_option()\n && enforces_a_validator()\n && std::string(CLI11_VERSION) == \"2.7.2\"\n && mcpp_compat_cli11_headers_anchor() == 0;\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "cmdline", + "text": "# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `default`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\ndefault = { path = \"../../..\" }\n\n[package]\nname = \"cmdline-tests\"\nversion = \"0.1.0\"\n\n[dependencies]\ncmdline = \"0.0.2\"\n", + "files": [ + { + "path": "tests/examples/cmdline/tests/consume.cpp", + "text": "// Consuming `mcpplibs.cmdline` through the published index.\n//\n// `parse_from` rather than `run(argc, argv)`: a test whose only assertion is\n// \"it linked\" passes for a package that parses nothing. Parsing a string and\n// checking what came out is the difference between testing the dependency\n// edge and testing the dependency.\n//\n// The chains end in `.end()`, which is what commits the pending item and\n// hands back the App — a builder is not an App, and the compiler says so.\nimport std;\nimport mcpplibs.cmdline;\n\nusing namespace mcpplibs;\n\nint main() {\n int failures = 0;\n auto check = [&](bool ok, std::string_view what) {\n if (!ok) { std::println(\"FAIL: {}\", what); ++failures; }\n };\n\n auto app = cmdline::App(\"tool\")\n .version(\"1.0\")\n .arg(\"cmd\").required()\n .option(\"verbose\").short_name('v')\n .option(\"config\").short_name('c').takes_value().value_name(\"FILE\")\n .end();\n\n auto parsed = app.parse_from(\"tool add --verbose --config=out.toml\");\n check(parsed.has_value(), \"a well-formed command line parses\");\n if (parsed) {\n check(parsed->positional(0) == \"add\", \"positional survives parsing\");\n check(parsed->is_flag_set(\"verbose\"), \"long flag is seen\");\n auto cfg = parsed->value(\"config\");\n check(cfg.has_value() && *cfg == \"out.toml\", \"--opt=value is captured\");\n }\n\n // A required positional that is absent must be refused. Without this, the\n // assertions above are all satisfiable by a parser that accepts anything.\n auto strict = cmdline::App(\"tool\").arg(\"cmd\").required().end();\n auto missing = strict.parse_from(\"tool\");\n check(!missing.has_value() || missing.error().is_error(),\n \"a missing required argument is refused\");\n\n if (failures == 0) std::println(\"cmdline: ok\");\n return failures == 0 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "cmp-module", + "text": "# A member-level redirect replaces the workspace root's compat redirect, so\n# this test resolves mcpplibs.cmp from the current checkout.\n[indices]\ndefault = { path = \"../../..\" }\n\n[package]\nname = \"cmp-module-tests\"\nversion = \"0.1.0\"\n\n[dependencies]\ncmp = \"0.1.0\"\n", + "files": [ + { + "path": "tests/examples/cmp-module/tests/runtime.cpp", + "text": "import std;\nimport mcpplibs.cmp;\n\nusing mcpplibs::cmp::RunLoop;\nusing mcpplibs::cmp::Task;\n\nTask scheduled_answer(RunLoop::Scheduler scheduler) {\n co_await scheduler.schedule();\n co_return 42;\n}\n\nint main() {\n RunLoop loop {};\n return loop.run(scheduled_answer(loop.get_scheduler())) == 42 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "concurrentqueue-c-api", + "text": "# concurrentqueue C-API test project: the OTHER half of the package — the\n# `c-api` feature. The base member (tests/examples/concurrentqueue) consumes\n# the default build, which does NOT compile c_api/*.cpp; this one asks for the\n# feature in the long form and asserts the moodycamel_cq_* / moodycamel_bcq_*\n# surface actually resolves and behaves, which is what proves the gate both\n# compiles and links what it names.\n#\n# Same QUALIFIED-dependency reasoning as tests/examples/concurrentqueue: the\n# request must resolve from this checkout's index.\n[package]\nname = \"concurrentqueue-c-api-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nconcurrentqueue = { version = \"1.0.5\", features = [\"c-api\"] }\n", + "files": [ + { + "path": "tests/examples/concurrentqueue-c-api/tests/c_api.cpp", + "text": "// compat.concurrentqueue `c-api` feature — drive the flat extern \"C\" surface\n// over both queues. Values cross the boundary as void*, so the test round-trips\n// integers through MoodycamelValue and checks them on the way out. A package\n// that compiled the wrappers against the wrong queue type (cq vs bcq handles\n// are both void*) would still link and swap semantics — which is exactly the\n// failure these assertions catch.\n#include \n\n#include \n#include \n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, const char* what) {\n if (!cond) {\n std::printf(\"FAIL: %s\\n\", what);\n ok = false;\n }\n };\n\n // ---- plain queue -------------------------------------------------------\n {\n MoodycamelCQHandle q = nullptr;\n check(moodycamel_cq_create(&q) == 1, \"cq_create succeeds\");\n check(q != nullptr, \"cq_create yields a handle\");\n check(moodycamel_cq_size_approx(q) == 0, \"fresh cq reports size 0\");\n\n uint64_t sent = 0;\n for (int i = 0; i < 100; ++i) {\n uintptr_t v = 1000 + i;\n sent += v;\n if (moodycamel_cq_enqueue(q, (MoodycamelValue)v) != 1) {\n check(false, \"cq_enqueue succeeds\");\n break;\n }\n }\n check(moodycamel_cq_size_approx(q) == 100, \"cq_size_approx after 100 enqueues\");\n\n uint64_t got = 0;\n int count = 0;\n MoodycamelValue v = nullptr;\n while (moodycamel_cq_try_dequeue(q, &v) == 1) {\n got += (uintptr_t)v;\n ++count;\n }\n check(count == 100, \"all 100 values come back out\");\n check(got == sent, \"values survive the void* round-trip intact\");\n check(moodycamel_cq_try_dequeue(q, &v) == 0, \"drained cq reports try_dequeue == 0\");\n\n check(moodycamel_cq_destroy(q) == 1, \"cq_destroy succeeds\");\n }\n\n // ---- blocking queue ------------------------------------------------------\n {\n MoodycamelBCQHandle q = nullptr;\n check(moodycamel_bcq_create(&q) == 1, \"bcq_create succeeds\");\n\n MoodycamelValue v = nullptr;\n check(moodycamel_bcq_try_dequeue(q, &v) == 0, \"empty bcq reports try_dequeue == 0\");\n\n for (uintptr_t i = 0; i < 50; ++i) {\n if (moodycamel_bcq_enqueue(q, (MoodycamelValue)i) != 1) {\n check(false, \"bcq_enqueue succeeds\");\n break;\n }\n }\n // wait_dequeue BLOCKS until an item is available; here the items are\n // already queued, so each call returns without hanging — and in order.\n uint64_t got = 0;\n for (int i = 0; i < 50; ++i) {\n if (moodycamel_bcq_wait_dequeue(q, &v) != 1 || (uintptr_t)v != i) {\n check(false, \"bcq_wait_dequeue hands back every value in order\");\n break;\n }\n got += (uintptr_t)v;\n }\n check(got == 50L * 49 / 2, \"bcq values sum to what went in\");\n check(moodycamel_bcq_try_dequeue(q, &v) == 0, \"drained bcq reports try_dequeue == 0\");\n\n check(moodycamel_bcq_destroy(q) == 1, \"bcq_destroy succeeds\");\n }\n\n std::printf(\"%s\\n\", ok ? \"concurrentqueue c-api: all checks passed\"\n : \"concurrentqueue c-api: FAILED\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "concurrentqueue", + "text": "# concurrentqueue test project: drive the C++ API of compat.concurrentqueue\n# (built from source via this repo's own index) under `mcpp test`. Part of the\n# mcpp-index self-referential workspace.\n#\n# The dependency is spelled QUALIFIED (`[dependencies.compat]`) so the request\n# resolves from THIS checkout's index rather than the published remote — the\n# one thing this member exists to test (same reasoning as tests/examples/eigen).\n#\n# What the test asserts, in descending order of \"a broken package would pass\n# this anyway\": real concurrent delivery (4 producers / 4 consumers, every\n# value received EXACTLY once), blocking semantics (a consumer actually blocks\n# and times out), FIFO order, bulk ops, and the move-only element path.\n[package]\nname = \"concurrentqueue-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nconcurrentqueue = \"1.0.5\"\n", + "files": [ + { + "path": "tests/examples/concurrentqueue/tests/queue.cpp", + "text": "// compat.concurrentqueue — exercise the C++ API the way the library's README\n// shows it: enqueue/try_dequeue, bulk ops, a blocking consumer, and a real\n// multi-producer/multi-consumer race. Every check asserts observable\n// behaviour (order, exact-once delivery, timeout), so a package that links\n// but miscompiles the lock-free paths still fails here.\n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, const char* what) {\n if (!cond) {\n std::printf(\"FAIL: %s\\n\", what);\n ok = false;\n }\n };\n\n // ---- single-threaded FIFO --------------------------------------------\n // The queue is MPMC, not ordered ACROSS producers — but a single producer\n // hands its own items to the consumer in order, and that is what we hold\n // it to here.\n {\n moodycamel::ConcurrentQueue q;\n check(q.size_approx() == 0, \"fresh queue reports size 0\");\n for (int i = 0; i < 1000; ++i) q.enqueue(i);\n check(q.size_approx() == 1000, \"size_approx after 1000 enqueues\");\n\n int v = -1;\n for (int i = 0; i < 1000; ++i) {\n if (!q.try_dequeue(v) || v != i) {\n check(false, \"single-producer FIFO order preserved\");\n break;\n }\n }\n check(!q.try_dequeue(v), \"drained queue reports try_dequeue == false\");\n check(q.size_approx() == 0, \"size_approx back to 0 after draining\");\n }\n\n // ---- bulk ops ---------------------------------------------------------\n {\n moodycamel::ConcurrentQueue q;\n std::vector in(5000);\n for (int i = 0; i < 5000; ++i) in[i] = i;\n // enqueue_bulk answers YES/NO (bool); only try_dequeue_bulk answers\n // HOW MANY (size_t). Mixing the two up is exactly the kind of mistake\n // this test exists to catch — starting with its own author's.\n check(q.enqueue_bulk(in.data(), in.size()),\n \"enqueue_bulk reports everything enqueued\");\n\n std::vector out(5000, -1);\n check(q.try_dequeue_bulk(out.data(), out.size()) == out.size(),\n \"try_dequeue_bulk reports everything dequeued\");\n check(in == out, \"bulk round-trip preserves contents and order\");\n }\n\n // ---- producer token ----------------------------------------------------\n // Tokens are the documented fast path for a dedicated producer; exercise\n // at least the enqueue side of them.\n {\n moodycamel::ConcurrentQueue q;\n moodycamel::ProducerToken ptok(q);\n q.enqueue(ptok, 7);\n int v = 0;\n check(q.try_dequeue(v) && v == 7, \"token enqueue round-trips\");\n }\n\n // ---- move-only elements ------------------------------------------------\n {\n moodycamel::ConcurrentQueue> q;\n q.enqueue(std::make_unique(41));\n q.enqueue(std::make_unique(42));\n std::unique_ptr p;\n check(q.try_dequeue(p) && p && *p == 41, \"move-only element survives the queue\");\n check(q.try_dequeue(p) && p && *p == 42, \"second move-only element comes out in order\");\n check(!q.try_dequeue(p), \"queue drains after move-only elements\");\n }\n\n // ---- blocking consumer -------------------------------------------------\n // wait_dequeue must actually block until the producer lands an item, and\n // wait_dequeue_timed on an EMPTY queue must come back false after the\n // timeout — the second half is what separates \"semaphore posted eagerly\"\n // from a real wait.\n {\n moodycamel::BlockingConcurrentQueue q;\n\n int missed = -1;\n auto t0 = std::chrono::steady_clock::now();\n bool got = q.wait_dequeue_timed(missed, std::chrono::milliseconds(50));\n auto elapsed = std::chrono::steady_clock::now() - t0;\n check(!got, \"wait_dequeue_timed on an empty queue times out\");\n check(elapsed >= std::chrono::milliseconds(45),\n \"the timeout was actually waited, not spun through\");\n\n std::thread producer([&q] {\n for (int i = 0; i < 2000; ++i) q.enqueue(i);\n });\n int prev = -1;\n for (int i = 0; i < 2000; ++i) {\n int v = -1;\n // wait_dequeue returns void in 1.0.5 (only the _timed spelling\n // reports success), so the assertion is on the VALUE alone: if\n // the semaphore path were broken this call would either hang\n // (test timeout) or hand back a value out of sequence.\n q.wait_dequeue(v);\n if (v != prev + 1) {\n check(false, \"blocking consumer receives every item in order\");\n break;\n }\n prev = v;\n }\n producer.join();\n int drained = -1;\n check(!q.wait_dequeue_timed(drained, std::chrono::milliseconds(50)),\n \"nothing left after the consumer caught up\");\n }\n\n // ---- MPMC: exact-once delivery under contention -------------------------\n // 4 producers x 10000 unique values, 4 consumers racing them out. Every\n // value must arrive EXACTLY once: a lost slot, a duplicated slot, or an\n // ABA-shaped torn read all show up as a counter != 1. This is the check\n // the single-threaded assertions cannot make.\n {\n constexpr int kProducers = 4, kConsumers = 4, kPerProducer = 10000;\n constexpr int kTotal = kProducers * kPerProducer;\n\n moodycamel::ConcurrentQueue q;\n std::vector> seen(kTotal);\n for (auto& s : seen) s.store(0, std::memory_order_relaxed);\n std::atomic dequeued{0};\n\n std::vector producers, consumers;\n for (int p = 0; p < kProducers; ++p) {\n producers.emplace_back([&q, p] {\n for (int i = 0; i < kPerProducer; ++i) q.enqueue(p * kPerProducer + i);\n });\n }\n for (int c = 0; c < kConsumers; ++c) {\n consumers.emplace_back([&] {\n int v;\n // try_dequeue until the producers are done AND the queue is\n // empty — the consumer that would otherwise spin on an empty\n // queue while producers are still working is the whole point\n // of a concurrent queue.\n for (;;) {\n while (q.try_dequeue(v)) {\n seen[v].store(1, std::memory_order_relaxed);\n dequeued.fetch_add(1, std::memory_order_relaxed);\n }\n if (dequeued.load(std::memory_order_relaxed) >= kTotal) return;\n std::this_thread::yield();\n }\n });\n }\n for (auto& t : producers) t.join();\n for (auto& t : consumers) t.join();\n\n check(dequeued.load() == kTotal, \"every enqueued value was dequeued\");\n int exactly_once = 0;\n for (const auto& s : seen) exactly_once += (s.load() == 1) ? 1 : 0;\n check(exactly_once == kTotal, \"every value delivered exactly once (no loss, no duplicate)\");\n }\n\n std::printf(\"%s\\n\", ok ? \"concurrentqueue: all checks passed\" : \"concurrentqueue: FAILED\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "core", + "text": "# Core compat libraries: gtest (test framework), ftxui (TUI), lua, mbedtls\n# (crypto), opengl + khrplatform (headers). Headless behavioral assertions via\n# `mcpp test`. Cross-platform: toolchain auto-selects per OS; link flags per OS.\n[package]\nname = \"core-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngtest = { version = \"1.15.2\", features = [\"main\"] }\nftxui = \"7.0.3\"\nlua = \"5.4.7\"\nmbedtls = \"3.6.1\"\nopengl = \"2026.05.31\"\nkhrplatform = \"2026.05.31\"\n\n# Per-OS link flags (was the portable smoke's write_build_ldflags()).\n[target.'cfg(linux)'.build]\nldflags = [\"-ldl\", \"-lm\"]\n[target.'cfg(macos)'.build]\nldflags = [\"-lm\"]\n", + "files": [ + { + "path": "tests/examples/core/tests/runtime.cpp", + "text": "// Faithful migration of smoke_compat_core.sh: gtest + ftxui + lua + mbedtls +\n// OpenGL/KHR headers. main() comes from the gtest \"main\" feature.\n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n\nextern \"C\" {\n#include \n#include \n#include \n}\n\nstatic bool check_ftxui() {\n using namespace ftxui;\n Element document = hbox({text(\"compat\"), separator(), text(\"ftxui\")});\n Screen screen = Screen::Create(Dimension::Fit(document), Dimension::Fit(document));\n Render(screen, document);\n const std::string rendered = screen.ToString();\n return rendered.find(\"compat\") != std::string::npos &&\n rendered.find(\"ftxui\") != std::string::npos;\n}\nstatic bool check_lua() {\n lua_State* state = luaL_newstate();\n if (!state) return false;\n luaL_openlibs(state);\n const int rc = luaL_dostring(state, \"return 20 + 22\");\n const bool ok = rc == LUA_OK && lua_isinteger(state, -1) && lua_tointeger(state, -1) == 42;\n lua_close(state);\n return ok;\n}\nstatic bool check_mbedtls() {\n const unsigned char input[] = \"abc\";\n std::array out{};\n mbedtls_sha256(input, 3, out.data(), 0);\n return out[0] == 0xba && out[1] == 0x78 && out[30] == 0x15 && out[31] == 0xad;\n}\nstatic bool check_opengl_headers() {\n return GL_TEXTURE_2D == 0x0DE1 && static_cast(1) == 1;\n}\n\nTEST(CompatGTest, BasicAssertion) { EXPECT_EQ(2 + 2, 4); }\nTEST(CompatCore, UpstreamHeadersAndMinimalRuntime) {\n EXPECT_TRUE(check_ftxui());\n EXPECT_TRUE(check_lua());\n EXPECT_TRUE(check_mbedtls());\n EXPECT_TRUE(check_opengl_headers());\n}\n" + } + ] + }, + { + "project": "cpptrace", + "text": "# compat.cpptrace test project. `compat` redirect inherited from the workspace\n# root.\n[package]\nname = \"cpptrace-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ncpptrace = \"1.0.4\"\n", + "files": [ + { + "path": "tests/examples/cpptrace/tests/trace.cpp", + "text": "// Behavioral test — compat.cpptrace unwinds a real call stack.\n//\n// What is asserted is deliberately what the CHOSEN BACKENDS can answer on a\n// bare CI runner. The package selects `_Unwind_Backtrace` + `dladdr` on unix\n// and DbgHelp on windows, all dependency-free, and the documented cost of that\n// choice is that frames carry no file:line (dladdr does not read DWARF). So\n// this test asserts the unwinder — frame count and distinct return addresses —\n// and never asserts on line numbers, which would be testing libdwarf's absence\n// rather than this package.\n#include \n#include \n\n#include \n#include \n\n#if CPPTRACE_VERSION != CPPTRACE_TO_VERSION(1, 0, 4)\n# error \"cpptrace/version.hpp does not describe 1.0.4 — the generated header drifted from the tarball\"\n#endif\n\n// The package builds objects, not a shared library, so every cpptrace\n// declaration this TU sees must be plain — not dllimport, not\n// visibility(\"default\"). The package delivers that with a shim in front of\n// cpptrace/basic.hpp, because no descriptor key carries a define to a\n// consumer's TUs.\n//\n// This check exists because the failure it guards is invisible on Linux: there\n// the difference is only a visibility attribute and everything still links. On\n// the MSVC ABI it is `dllimport` versus nothing, and the link fails with\n// \"undefined symbol: __declspec(dllimport) ...\". Asserting the macro at COMPILE\n// time fails on every platform the moment the shim stops being reached.\n#if !defined(CPPTRACE_STATIC_DEFINE)\n# error \"CPPTRACE_STATIC_DEFINE is not set for this consumer TU — the compat.cpptrace basic.hpp shim was not reached (include_dirs order?)\"\n#endif\n\n// noinline so the three frames cannot be collapsed into one by the optimiser;\n// the test is about the unwinder seeing depth.\n#if defined(_MSC_VER)\n# define NOINLINE __declspec(noinline)\n#else\n# define NOINLINE __attribute__((noinline))\n#endif\n\nstatic NOINLINE cpptrace::stacktrace level_three() { return cpptrace::generate_trace(); }\nstatic NOINLINE cpptrace::stacktrace level_two() { return level_three(); }\nstatic NOINLINE cpptrace::stacktrace level_one() { return level_two(); }\n\nint main() {\n const cpptrace::stacktrace trace = level_one();\n\n if (trace.frames.empty()) {\n std::printf(\"generate_trace returned no frames — the unwinder produced nothing\\n\");\n return 1;\n }\n // main + three levels, minus whatever the runtime trims. Four is the\n // number the chain above must at least reach for the unwind to be real\n // rather than a single-frame stub.\n if (trace.frames.size() < 4) {\n std::printf(\"only %zu frames; expected the four-deep chain to survive\\n\", trace.frames.size());\n return 2;\n }\n\n // Distinct return addresses: a stub that filled the vector with one\n // repeated address would pass the count check but not this one.\n std::uintptr_t distinct = 0;\n for (std::size_t i = 1; i < trace.frames.size(); ++i) {\n if (trace.frames[i].raw_address != trace.frames[i - 1].raw_address) ++distinct;\n }\n if (distinct == 0) {\n std::printf(\"every frame has the same address\\n\");\n return 3;\n }\n if (trace.frames[0].raw_address == 0) {\n std::printf(\"top frame has a null address\\n\");\n return 4;\n }\n\n std::printf(\"compat.cpptrace: ok (%zu frames, %ju distinct addresses, version %d)\\n\",\n trace.frames.size(), static_cast(distinct), CPPTRACE_VERSION);\n return 0;\n}\n" + } + ] + }, + { + "project": "cuda-curand", + "text": "# compat.curand — the CUDA operator libraries as ordinary mcpp dependencies.\n#\n# WHAT THIS ASSERTS ON A RUNNER WITH NO DEVICE, WHICH IS EVERY RUNNER HERE.\n# The failure this package was written against happens BEFORE main: NVIDIA's\n# shared objects carry `RUNPATH = $ORIGIN`, and a non-empty RUNPATH on an\n# intermediate library turns off the executable's inherited DT_RPATH for that\n# library's own dependencies, so the program died with\n#\n# error while loading shared libraries: librt.so.1\n#\n# on a machine that had librt.so.1 in the payload the artifact points at. A test\n# binary that starts at all has proved the farm resolves; the device assertions\n# below then run only where a device answers.\n#\n# ⚠️ ONE OF THE FIVE. compat.cublas / cufft / cusolver / cusparse are generated\n# from the same descriptor template and differ only in the payload they name and\n# the `-l` names they pass, which were read from upstream's own archives. cuRAND\n# carries the smallest payload (85 MB against cuBLAS's 933 MB), so it is the one\n# that runs on every pull request.\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"cuda-curand-tests\"\nversion = \"0.1.0\"\n\n# Linux-only, like the adapters themselves: the payloads declare `linux` and the\n# loader arrangement the packages exist for has no counterpart elsewhere. The\n# member still builds on all three platforms, which is what keeps the test\n# source from rotting silently on the two that do not exercise it.\n[target.'cfg(linux)'.dependencies.compat]\ncurand = \"10.3.10.19\"\ncuda-driver = \"2026.09.05\"\n", + "files": [ + { + "path": "tests/examples/cuda-curand/tests/curand.cpp", + "text": "// The load path first, the device second.\n//\n// Reaching main is itself the assertion this member exists for: it means every\n// DT_NEEDED of libcurand.so resolved through the farm compat.curand builds,\n// including the C library stubs that the component's own `RUNPATH = $ORIGIN`\n// would otherwise hide.\n#include \n\n// The adapters under test exist only on Linux, so the assertions do too. The\n// file still compiles everywhere, which is what keeps it from rotting silently\n// on the two platforms that do not exercise it.\n#ifndef __linux__\nint main() {\n std::printf(\"not applicable on this platform\\n\");\n return 0;\n}\n#else\n#include \n#include \n\n#include \n\nstatic int failures = 0;\n\nstatic void check(bool ok, const char* what) {\n if (!ok) { std::printf(\"FAIL: %s\\n\", what); ++failures; }\n else std::printf(\"ok: %s\\n\", what);\n}\n\nint main() {\n // ⭐ REACHING THIS LINE IS THE ASSERTION THIS MEMBER EXISTS FOR. Every\n // DT_NEEDED of libcurand.so had to resolve through the farm compat.curand\n // builds, including the C library stubs that the component's own\n // `RUNPATH = $ORIGIN` would otherwise hide. That failure happens before\n // main on every machine, with or without a device.\n int devices = 0;\n const cudaError_t rc = cudaGetDeviceCount(&devices);\n if (rc != cudaSuccess || devices == 0) {\n // Not a failure. A machine with no NVIDIA driver is a legitimate\n // configuration and every runner in this repository is one.\n //\n // ⚠️ THE DEVICE CHECK COMES FIRST, and that ordering is a correction.\n // An earlier revision created the generator before asking, on the\n // theory that a generator is a host-side object. Measured on CI:\n // `curandCreateGenerator` fails with `CUDA driver version is\n // insufficient for CUDA runtime version` on a driverless machine, so\n // the member reported a failure for the one configuration it was\n // written to tolerate.\n std::printf(\"ok: the library loaded and answered; no device here (%s)\\n\",\n cudaGetErrorString(rc));\n return 0;\n }\n\n curandGenerator_t gen{};\n check(curandCreateGenerator(&gen, CURAND_RNG_PSEUDO_DEFAULT) == CURAND_STATUS_SUCCESS,\n \"curandCreateGenerator answers\");\n\n float* dev = nullptr;\n check(cudaMalloc(&dev, 4096 * sizeof(float)) == cudaSuccess, \"cudaMalloc\");\n check(curandSetPseudoRandomGeneratorSeed(gen, 1234ULL) == CURAND_STATUS_SUCCESS,\n \"curandSetPseudoRandomGeneratorSeed\");\n check(curandGenerateUniform(gen, dev, 4096) == CURAND_STATUS_SUCCESS,\n \"curandGenerateUniform\");\n\n float host[4096]{};\n check(cudaMemcpy(host, dev, sizeof host, cudaMemcpyDeviceToHost) == cudaSuccess,\n \"cudaMemcpy back\");\n\n double sum = 0.0;\n bool in_range = true;\n for (float v : host) {\n if (v < 0.0f || v > 1.0f) in_range = false;\n sum += v;\n }\n check(in_range, \"every value lies in [0, 1]\");\n check(std::fabs(sum / 4096.0 - 0.5) < 0.05, \"the mean is near 0.5\");\n\n cudaFree(dev);\n curandDestroyGenerator(gen);\n return failures == 0 ? 0 : 1;\n}\n#endif // __linux__\n" + } + ] + }, + { + "project": "cuda-driver", + "text": "# compat.cuda-driver is a Linux-only adapter: the problem it solves is that an\n# mcpp binary on Linux runs under mcpp's own loader and therefore cannot see the\n# host's driver. macOS and Windows have neither that loader arrangement nor an\n# NVIDIA userspace driver in this shape, so the dependency is conditioned rather\n# than the member being excluded — the member still builds on all three, which\n# is what keeps the test source itself from rotting.\n#\n# ⚠️ `cuda-runtime` IS DECLARED ALONGSIDE IT, AND THAT IS THE POINT. The adapter\n# shipped once under that name and the entry is kept so consumers already\n# writing it keep resolving. A promise nothing exercises is a promise that\n# breaks quietly, so this member depends on both and the build proves the old\n# name still reaches a package. Two farms of the same symlink are harmless: the\n# capability is not exclusive and the artifact's search path lists both.\n[package]\nname = \"cuda-driver-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\ncuda-driver = \"2026.09.05\"\ncuda-runtime = \"2026.09.05\"\n", + "files": [ + { + "path": "tests/examples/cuda-driver/tests/farm.cpp", + "text": "// What compat.cuda-driver is asserted to do, and what it is not.\n//\n// A machine with no NVIDIA driver is a legitimate configuration and is what\n// every runner in this repository is, so the test cannot require a device. It\n// asserts the two properties that hold on both kinds of machine:\n//\n// 1. The package resolves, builds and links. That alone covers the failure\n// this package exists to prevent, because the failure is a LINK-time one:\n// an unversioned libcuda.so harvested into a directory mcpp puts on the\n// link line would be picked up by -lcuda and bind the build to one\n// machine's driver. The patterns are versioned precisely so that cannot\n// happen, and a build that links proves it did not.\n//\n// 2. Where a driver is present, the farm reaches it. Guarded on the driver\n// actually being there rather than skipped by a marker, so the assertion\n// is real on a machine with a GPU and vacuous on one without, and neither\n// case is reported as a pass of the other.\n#include \n\n// The adapter under test exists only on Linux, so the assertion does too. The\n// file still compiles everywhere, which is what keeps it from rotting silently\n// on the two platforms that do not exercise it.\n#ifndef __linux__\nint main() {\n std::printf(\"not applicable on this platform\\n\");\n return 0;\n}\n#else\n#include \n\nint main() {\n // The driver's userspace library, by soname. The farm's whole job is to\n // make this resolve from inside mcpp's own loader, which does not search\n // the host's library path.\n void* h = dlopen(\"libcuda.so.1\", RTLD_LAZY);\n if (!h) {\n // No driver on this machine. The farm is empty, which is correct.\n std::printf(\"no host driver: %s\\n\", dlerror());\n return 0;\n }\n // Present: then the symbol every CUDA runtime looks for must be there too.\n // A farm that linked a stale or wrong-class file would resolve the library\n // and fail here, which is the difference between \"found something\" and\n // \"found the driver\".\n void* sym = dlsym(h, \"cuInit\");\n std::printf(\"host driver present, cuInit=%p\\n\", sym);\n dlclose(h);\n return sym ? 0 : 1;\n}\n#endif\n" + } + ] + }, + { + "project": "curl", + "text": "[package]\nname = \"curl-tests\"\nversion = \"0.1.0\"\n\n# Deliberately NO -DCURL_STATICLIB here: compat.curl publishes it itself, via a\n# `default` feature that implies one carrying the define. If that ever stops\n# working, reverts to dllimport declarations and the Windows link\n# fails — which is the point of not spelling it out on this side.\n[dependencies.compat]\ncurl = \"8.21.0\"\n", + "files": [ + { + "path": "tests/examples/curl/tests/transfer.cpp", + "text": "// Behavioral test — verify compat.curl builds a usable libcurl with working\n// TLS, without touching the network.\n//\n// CI runners have no reliable outbound network and the index's other members\n// never assume one, so nothing here opens a connection. What is asserted\n// instead is everything that a misconfigured build actually gets wrong:\n//\n// * the library initialises at all (curl_global_init walks the same setup\n// path a real transfer would),\n// * the TLS backend is really compiled in — a curl built with no SSL still\n// links and still runs, it just silently cannot do https, which is exactly\n// the failure EUI-NEO's `network` feature would hit at runtime,\n// * https and http are among the supported protocols,\n// * a handle can be configured with the options EUI-NEO actually sets.\n#include \nimport std;\n\n// CURL_STATICLIB must arrive from the PACKAGE, not from this project. Without\n// it declares everything __declspec(dllimport) and the Windows\n// link fails against a static archive; on other platforms it is inert, so this\n// compile-time check is the only thing that catches a regression early.\nstatic_assert([] {\n#if defined(CURL_STATICLIB)\n return true;\n#else\n return false;\n#endif\n}(), \"CURL_STATICLIB did not reach the consumer — compat.curl stopped publishing it\");\n\nint main() {\n if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) {\n std::println(\"curl_global_init failed\");\n return 1;\n }\n\n const curl_version_info_data* info = curl_version_info(CURLVERSION_NOW);\n if (info == nullptr) {\n std::println(\"curl_version_info returned null\");\n curl_global_cleanup();\n return 2;\n }\n\n // The whole point of wiring a TLS backend. `ssl_version` is null when curl\n // was built without one.\n if ((info->features & CURL_VERSION_SSL) == 0 || info->ssl_version == nullptr) {\n std::println(\"libcurl built WITHOUT TLS — https would fail at runtime\");\n curl_global_cleanup();\n return 3;\n }\n\n bool haveHttps = false;\n bool haveHttp = false;\n for (const char* const* p = info->protocols; p != nullptr && *p != nullptr; ++p) {\n const std::string_view proto{*p};\n if (proto == \"https\") haveHttps = true;\n if (proto == \"http\") haveHttp = true;\n }\n if (!haveHttp || !haveHttps) {\n std::println(\"missing protocol support (http={}, https={})\", haveHttp, haveHttps);\n curl_global_cleanup();\n return 4;\n }\n\n // The exact option set core/platform/network.cpp configures. A handle that\n // rejects any of these would break the `network` feature.\n CURL* handle = curl_easy_init();\n if (handle == nullptr) {\n std::println(\"curl_easy_init failed\");\n curl_global_cleanup();\n return 5;\n }\n const bool optionsOk =\n curl_easy_setopt(handle, CURLOPT_URL, \"https://example.invalid/\") == CURLE_OK &&\n curl_easy_setopt(handle, CURLOPT_FOLLOWLOCATION, 1L) == CURLE_OK &&\n curl_easy_setopt(handle, CURLOPT_TIMEOUT, 15L) == CURLE_OK &&\n curl_easy_setopt(handle, CURLOPT_NOPROGRESS, 0L) == CURLE_OK;\n curl_easy_cleanup(handle);\n\n if (!optionsOk) {\n std::println(\"curl_easy_setopt rejected an option EUI-NEO relies on\");\n curl_global_cleanup();\n return 6;\n }\n\n std::println(\"compat.curl: ok ({}, ssl={}, http+https present)\",\n info->version, info->ssl_version);\n curl_global_cleanup();\n return 0;\n}\n" + } + ] + }, + { + "project": "doctest", + "text": "# compat.doctest test project. The entry point comes from doctest's own\n# DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN macro rather than from a package feature —\n# that is the difference from tests/examples/catch2-main, where the framework\n# ships a compiled main. The `compat` redirect is inherited from the root.\n[package]\nname = \"doctest-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ndoctest = \"2.4.12\"\n", + "files": [ + { + "path": "tests/examples/doctest/tests/suite.cpp", + "text": "// Behavioral test — compat.doctest gives a working framework AND its main.\n//\n// No `import std;` here: doctest.h is a textual header that includes the C++\n// standard headers itself, and mixing those with the std module is the one\n// thing this index's module packages tell consumers not to do.\n//\n// A failing REQUIRE makes doctest return a non-zero exit code, which is how\n// `mcpp test` sees it — so the assertions below are the test, and the runner\n// needs no adapter.\n#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN\n#include \n\n#include \n#include \n\nstatic unsigned factorial(unsigned n) { return n <= 1 ? 1 : n * factorial(n - 1); }\n\nTEST_CASE(\"factorial\") {\n CHECK(factorial(0) == 1);\n CHECK(factorial(5) == 120);\n}\n\nTEST_CASE(\"subcases run independently\") {\n std::vector v;\n SUBCASE(\"one element\") {\n v.push_back(1);\n CHECK(v.size() == 1);\n }\n SUBCASE(\"two elements\") {\n v.push_back(1);\n v.push_back(2);\n CHECK(v.size() == 2);\n }\n // Each subcase re-enters with a fresh `v`; if doctest's subcase machinery\n // were missing, one of the two CHECKs above would see the other's push.\n}\n\nTEST_CASE(\"string comparison reporting\") {\n const std::string s = \"doctest\";\n REQUIRE(s.size() == 7);\n CHECK(s.substr(0, 3) == \"doc\");\n}\n" + } + ] + }, + { + "project": "egl", + "text": "# EGL test project.\n#\n# THIS MEMBER DECLARES ITS OWN [indices], and has to. The workspace root maps\n# the `compat` namespace to this checkout, and an index is looked up BY\n# NAMESPACE — so `freedesktop.*` would resolve against the published index\n# rather than the package under test. A member-level table REPLACES the root's\n# rather than merging.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"egl-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# Linux-only like its neighbours; the test compiles to a no-op main() elsewhere.\n[target.'cfg(linux)'.dependencies.freedesktop]\negl = \"1.7.0\"\n", + "files": [ + { + "path": "tests/examples/egl/tests/egl.cpp", + "text": "// freedesktop.egl — behavioral test, runnable with no GPU and no display.\n//\n// Every FUNCTION and TYPE below comes from `import khronos.egl;`. is\n// included for the EGL_* CONSTANTS only: they are macros, and no module can\n// export a macro. So the file compiling is itself the first assertion — the\n// module's export list has to cover everything used here — and the file\n// linking is the second.\n//\n// ─────────────────────────────────────────────────────────────────────────\n// EVERY ASSERTION HOLDS WITH ZERO VENDOR DRIVERS INSTALLED.\n//\n// libEGL is a DISPATCH: with no vendor it can answer questions about itself\n// and nothing else. Upstream returns the empty string outright when the vendor\n// list is empty (`libegl.c:928`), so an \"it lists some EGL_EXT_ extension\"\n// check measures whether the MACHINE has a driver, not whether this package\n// built libEGL. Vendor-dependent facts are reported below, or asserted only\n// once their precondition is visibly met.\n//\n// ─────────────────────────────────────────────────────────────────────────\n// THE PART THAT IS EASY TO GET FALSELY GREEN\n//\n// The ecosystem payload `xim:libglvnd` carries its own `libEGL.so.1` with the\n// SAME soname, and on a machine with the graphics stack installed it is\n// reachable. Only ONE library with a given soname is ever mapped, and nothing\n// warns about the other — so a test that merely calls EGL functions can pass\n// while this package's build is never loaded at all. Checks 2 and 3 pin the\n// identity from both directions: what the library says it is, and what path\n// its code actually came from. compat.libdrm's test does the same thing for\n// the same reason.\n\n#ifdef __linux__\n\n#include // the EGL_* macros only\n\n#include \n\n#include \n#include \n#include \n#include \n\nimport khronos.egl;\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The module carries the API ────────────────────────────────────\n EGLDisplay none = EGL_NO_DISPLAY;\n check(none == EGL_NO_DISPLAY, \"EGLDisplay and EGL_NO_DISPLAY agree\");\n\n // ── 2. It is GLVND's libEGL ──────────────────────────────────────────\n // EGL_VERSION on EGL_NO_DISPLAY is answered by libglvnd before any vendor\n // is consulted — a literal \"1.5 libglvnd\" — so it needs no driver and no\n // display, and a different implementation would answer differently.\n const char *version = eglQueryString(EGL_NO_DISPLAY, EGL_VERSION);\n check(version != nullptr && std::strcmp(version, \"1.5 libglvnd\") == 0,\n \"eglQueryString(EGL_NO_DISPLAY, EGL_VERSION) is libglvnd's\");\n std::printf(\" EGL_VERSION: %s\\n\", version ? version : \"(null)\");\n\n // ── 3. …and it is THIS package's build, not the payload's ────────────\n // Same soname, so only one libEGL.so.1 is mapped and the loser is silent.\n // dladdr reports the object a symbol actually came from; if the path names\n // the ecosystem payload, this package built a library nobody loads.\n {\n Dl_info info{};\n const bool located =\n ::dladdr(reinterpret_cast(&eglQueryString), &info) != 0\n && info.dli_fname != nullptr;\n check(located, \"dladdr locates the loaded libEGL\");\n if (located) {\n const std::string from = info.dli_fname;\n std::printf(\" loaded from: %s\\n\", from.c_str());\n check(from.find(\"xim-x-libglvnd\") == std::string::npos,\n \"the loaded libEGL is not the ecosystem payload's copy\");\n }\n }\n\n // ── 4. The error path works, with no driver ──────────────────────────\n // EGL_VENDOR without a display is invalid; libglvnd must report\n // EGL_BAD_DISPLAY rather than crash or silently answer. This exercises\n // __eglReportError and the thread-local error state.\n check(eglQueryString(EGL_NO_DISPLAY, EGL_VENDOR) == nullptr,\n \"an invalid no-display query returns null\");\n check(eglGetError() == EGL_BAD_DISPLAY,\n \"…and leaves EGL_BAD_DISPLAY in the thread's error state\");\n\n // ── 5. libEGL reaches libGLdispatch ──────────────────────────────────\n // Resolving a CORE entry point runs the path from libEGL into\n // libGLdispatch. That library is built by a sibling member of the same\n // fork and reached by PATH rather than through the index — so this is also\n // the check that the intra-package edge survived publication.\n check(eglGetProcAddress(\"eglInitialize\") != nullptr,\n \"eglGetProcAddress(\\\"eglInitialize\\\") resolves\");\n check(eglGetProcAddress(\"eglNoSuchFunctionEXT\") == nullptr,\n \"…and a name that does not exist resolves to null\");\n\n // ── 6. What the environment supplies, reported ───────────────────────\n // The package compiles in an EMPTY vendor-config default on purpose, so a\n // driver is only ever found through `__EGL_VENDOR_LIBRARY_DIRS`, which\n // `xim:mesa` declares (xim-pkgindex#713) exactly as it declares\n // GBM_BACKENDS_PATH for compat.libgbm. A runner with no GPU stack is not a\n // defect in this package, so none of this is asserted — but when a vendor\n // IS found, the extension list must look like one.\n std::puts(\"\");\n const char *dirs = std::getenv(\"__EGL_VENDOR_LIBRARY_DIRS\");\n std::printf(\" __EGL_VENDOR_LIBRARY_DIRS = %s\\n\",\n dirs ? dirs : \"(unset — the ecosystem declares it)\");\n\n const char *client_exts = eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS);\n check(client_exts != nullptr,\n \"eglQueryString(EGL_NO_DISPLAY, EGL_EXTENSIONS) answers\");\n if (client_exts != nullptr && client_exts[0] != '\\0') {\n std::printf(\" client extensions: %.90s%s\\n\",\n client_exts, std::strlen(client_exts) > 90 ? \"…\" : \"\");\n check(std::strstr(client_exts, \"EGL_EXT_client_extensions\") != nullptr,\n \"a vendor was found, so the client extension list is populated\");\n } else {\n std::puts(\" client extensions: (none — no vendor driver here; the \"\n \"dispatch itself is fine)\");\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "eigen", + "text": "# Eigen test project: header-only linear algebra, asserted under `mcpp test`.\n# Resolves compat.eigen (source build puts the tree root on the include path)\n# from this repo's own index, via the inherited workspace-root `[indices]`.\n#\n# The dependency is spelled QUALIFIED on purpose. A bare `eigen = \"5.0.1\"` is\n# what most real consumers write, and it does resolve (0.0.109 takes both\n# halves of the wire address from the accepted descriptor, mcpp#286) — but the\n# index redirect is keyed by the REQUEST's namespace, and a bare request asks\n# under the default namespace, not `compat`. It would therefore resolve from\n# the published remote index and quietly stop testing this checkout, which is\n# the one thing this member exists to do. Bare-name resolution against a\n# short-name index is covered upstream by mcpp's e2e 165.\n[package]\nname = \"eigen-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\neigen = \"5.0.1\"\n", + "files": [ + { + "path": "tests/examples/eigen/tests/dense.cpp", + "text": "// Behavioral test: core (header-only) Eigen linear algebra. Returns non-zero on\n// any mismatch. (The eigen_blas feature's dgemm_ path is covered separately;\n// linking feature-built dependency objects into test binaries is a follow-up.)\n#include \n#include \n\nint main() {\n Eigen::Matrix2d A; A << 1, 2, 3, 4;\n Eigen::Vector2d x(1.0, 1.0);\n Eigen::Vector2d y = A * x; // [3,7]\n double det = A.determinant(); // -2\n Eigen::Vector2d z = A.colPivHouseholderQr().solve(y); // [1,1]\n bool ok = y(0) == 3.0 && y(1) == 7.0 && det == -2.0\n && std::abs(z(0) - 1.0) < 1e-9 && std::abs(z(1) - 1.0) < 1e-9;\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "eui-neo-app-main", + "text": "# compat.eui-neo, `app-main` FEATURE path: the package supplies `int main()`\n# (upstream core/app/glfw_app_main.cpp) and this project deliberately defines\n# none. If the feature stops contributing that source, this member fails to link\n# with `undefined reference to 'main'` — the same assertion shape as\n# tests/examples/catch2-main.\n#\n# This cannot share a project with tests/examples/eui-neo: features are resolved\n# per consuming project, and — because mcpp links a dependency's objects\n# EAGERLY rather than as lazily-selected archive members — a project that\n# enables `app-main` may not contain any translation unit of its own that\n# defines main(). See the note in pkgs/e/compat.eui-neo.lua.\n#\n# ALL THREE PLATFORMS, deliberately not cfg-gated. `app-main` selects upstream's\n# GLFW entry point, which is the default window backend on every platform, and\n# nothing in it is linux-specific — but until this member existed it was compiled\n# on NONE of them, so the windows `-fno-char8_t` / winmm+user32+pdh half of the\n# descriptor and the macOS Cocoa link were both unexercised for that TU.\n#\n# compat.glfw is NOT re-declared here even though this TU calls\n# glfwSetWindowShouldClose()/glfwGetCurrentContext() itself to bound the opt-in\n# window run: mcpp propagates a transitive dependency's `include_dirs` and link\n# inputs to the consumer, and compat.eui-neo already depends on compat.glfw on\n# every platform. Same convention as tests/examples/eui-neo-sdl2 and\n# eui-neo-vulkan, which include / without re-declaring\n# either.\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"eui-neo-app-main-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\neui-neo = { version = \"0.6.0\", features = [\"app-main\"] }\n", + "files": [ + { + "path": "tests/examples/eui-neo-app-main/tests/app_main.cpp", + "text": "// compat.eui-neo `app-main` FEATURE: there is no main() in this file on purpose.\n// The entry point is upstream's core/app/glfw_app_main.cpp, which the feature\n// adds to the library — it owns the window, the GL backend and the render loop,\n// and calls back into the two symbols an EUI application must supply:\n// app::dslAppConfig() and app::compose(). That is the whole shape being pinned\n// here, and it is the shape a real EUI-NEO application uses.\n//\n// tests/examples/eui-neo-window asserts the OTHER half of the same feature pair:\n// the same UI driven by a hand-written main(), with `app-main` NOT enabled.\n//\n// Two things make this member work under `mcpp test`:\n//\n// 1. The link IS the primary assertion. `main` has to come from the package;\n// if the feature ever stops gating glfw_app_main.cpp in, this member fails\n// with `undefined reference to 'main'`. Conversely, if the feature ever\n// stopped being opt-in, every OTHER member would fail with\n// `multiple definition of 'main'` — mcpp links a dependency's objects\n// eagerly, so there is no lazy-archive escape hatch.\n//\n// 2. Running a window needs a display, so the run is opt-in via\n// MCPP_RUN_WINDOW=1, as in tests/examples/imgui-window. Because the entry\n// point belongs to the package, the gate cannot live in main() — it lives in\n// a namespace-scope constructor, which runs before it. Headless CI links the\n// real binary, executes the guard, and exits 0.\n//\n// Builds on all three platforms: GLFW is upstream's default window backend\n// everywhere, and nothing below is platform-specific.\n#include \n\n#include \"core/render/render_backend.h\"\n\n// GLFW would otherwise pull a GL header of its own choosing (GL/gl.h on Windows,\n// the deprecated OpenGL/gl.h on macOS). EUI reaches GL through compat.glad, so\n// this TU wants the GLFW API and nothing else — the same thing\n// core/input/input_state.h does before its own include.\n#ifndef GLFW_INCLUDE_NONE\n#define GLFW_INCLUDE_NONE\n#endif\n#include \n\nimport std;\n\nnamespace {\n\n// Frames to render before asking the framework's loop to exit, so the opt-in run\n// terminates on its own instead of waiting for a human to close the window.\nconstexpr int kFrameBudget = 60;\n\nint g_frames = 0;\n\n// Accumulated from the PREVIOUS frame's render stats — the framework's loop\n// publishes them after each present(), and compose() is the only place an\n// app-main consumer gets to look. A frame counter alone would pass against a\n// null backend; these prove the OpenGL backend issued real geometry.\nlong long g_rectDraws = 0;\nlong long g_textDraws = 0;\n\n// Runs before the package's main(). See point 2 above.\nstruct WindowRunGate {\n WindowRunGate() {\n if (std::getenv(\"MCPP_RUN_WINDOW\") != nullptr) {\n return;\n }\n std::println(\"compat.eui-neo[app-main]: linked, main() supplied by the package; \"\n \"window run is opt-in (MCPP_RUN_WINDOW=1)\");\n std::exit(0);\n }\n};\n\nconst WindowRunGate g_gate;\n\n} // namespace\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}\n .title(\"compat.eui-neo — app-main\")\n .pageId(\"eui_neo_app_main\")\n .clearColor({0.06f, 0.07f, 0.09f, 1.0f})\n .windowSize(420, 240)\n .fps(60.0)\n .showDebugStatsInTitle(false);\n return config;\n}\n\nvoid compose(eui::Ui& ui, const eui::Screen& screen) {\n // A node carrying .onFrame() marks the runtime as animating, which is what\n // keeps the framework's loop rendering continuously instead of parking in\n // glfwWaitEvents(). It is also the only per-frame hook an app-main consumer\n // has, so the frame budget is enforced from here.\n ui.stack(\"loop.driver\")\n .size(1.0f, 1.0f)\n .onFrame([](float deltaSeconds) {\n // app::update() runs the runtime twice per frame — the second pass\n // with deltaSeconds == 0, to settle the re-compose that onFrame\n // itself requested — so this fires twice per rendered frame.\n if (deltaSeconds <= 0.0f) {\n return;\n }\n\n const core::render::RenderFrameStats& stats = core::render::lastRenderFrameStats();\n g_rectDraws += stats.rectDraws;\n g_textDraws += stats.textDraws;\n\n if (++g_frames < kFrameBudget) {\n return;\n }\n\n // The entry point belongs to the package, so there is no main() of\n // ours to return a code from — exit() is how this member fails.\n if (g_rectDraws <= 0) {\n std::println(\"no rect geometry reached the backend over {} frames\", g_frames);\n std::exit(2);\n }\n if (g_textDraws <= 0) {\n // Text needs the freetype path AND a usable font; worth\n // distinguishing from a dead backend.\n std::println(\"no text geometry reached the backend over {} frames \"\n \"(freetype path or system font missing)\", g_frames);\n std::exit(3);\n }\n\n std::println(\"compat.eui-neo[app-main]: ok ({} frames, {} rect / {} text draws)\",\n g_frames, g_rectDraws, g_textDraws);\n\n // The framework's loop keeps our window's context current across\n // update and render, so this is the window it is driving.\n if (GLFWwindow* window = glfwGetCurrentContext()) {\n glfwSetWindowShouldClose(window, GLFW_TRUE);\n }\n })\n .build();\n\n // Text exercises the freetype + font-fallback path, rects the OpenGL\n // primitive path — i.e. the parts a headless smoke test cannot reach.\n ui.text(\"title\")\n .position(32.0f, 40.0f)\n .size(std::max(120.0f, screen.width - 64.0f), 30.0f)\n .text(\"app-main\")\n .fontSize(22.0f)\n .lineHeight(28.0f)\n .color({0.94f, 0.96f, 1.0f, 1.0f})\n .build();\n\n ui.text(\"stats\")\n .position(32.0f, 76.0f)\n .size(std::max(120.0f, screen.width - 64.0f), 24.0f)\n .text(std::format(\"frame {} / {}\", g_frames, kFrameBudget))\n .fontSize(15.0f)\n .lineHeight(20.0f)\n .color({0.58f, 0.68f, 0.80f, 1.0f})\n .build();\n\n const float progress = static_cast(g_frames) / static_cast(kFrameBudget);\n const float trackWidth = std::max(120.0f, screen.width - 64.0f);\n\n ui.rect(\"track\")\n .position(32.0f, 150.0f)\n .size(trackWidth, 6.0f)\n .radius(3.0f)\n .color({0.16f, 0.19f, 0.24f, 1.0f})\n .build();\n\n ui.rect(\"fill\")\n .position(32.0f, 150.0f)\n .size(std::min(1.0f, progress) * trackWidth, 6.0f)\n .radius(3.0f)\n .color({0.36f, 0.62f, 0.98f, 1.0f})\n .build();\n}\n\n} // namespace app\n" + } + ] + }, + { + "project": "eui-neo-markdown", + "text": "[package]\nname = \"eui-neo-markdown-tests\"\nversion = \"0.1.0\"\n\n# Long-form declaration so the `markdown` feature is requested. It pulls\n# compat.md4c and publishes EUI_HAS_MD4C=1 as an interface define, which is\n# what switches components/markdown.h from its fallback to the real parser.\n\n[dependencies.compat]\neui-neo = { version = \"0.6.0\", features = [\"markdown\"] }\n", + "files": [ + { + "path": "tests/examples/eui-neo-markdown/tests/markdown.cpp", + "text": "// POSITIVE verification for compat.eui-neo's `markdown` feature.\n//\n// components/markdown.h is header-only and compiles one of two definitions of\n// detail::parseMarkdownBlocks depending on EUI_HAS_MD4C. That makes it a clean\n// probe for something the descriptor cannot otherwise prove: the feature's\n// `defines` really do reach the CONSUMER's translation unit, not just the\n// package's own. Without propagation this member would link md4c and still\n// silently get the fallback parser.\n//\n// tests/examples/eui-neo asserts the negative side — same header, feature off,\n// degenerate single-Paragraph result.\n#include \nimport std;\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}.title(\"markdown feature test\");\n return config;\n}\n\nvoid compose(eui::Ui&, const eui::Screen&) {}\n\n} // namespace app\n\nint main() {\n#if !defined(EUI_HAS_MD4C)\n std::println(\"EUI_HAS_MD4C not defined — the feature's interface define did not propagate\");\n return 1;\n#else\n namespace md = components::detail;\n\n const auto blocks = md::parseMarkdownBlocks(\"# Heading\\n\\nBody text.\\n\");\n\n // The real parser splits heading from paragraph; the fallback returns one\n // Paragraph holding the raw source.\n if (blocks.size() < 2) {\n std::println(\"expected >= 2 blocks from the md4c parser, got {}\", blocks.size());\n return 2;\n }\n if (blocks[0].kind != md::MarkdownBlockKind::Heading || blocks[0].headingLevel != 1) {\n std::println(\"expected an h1 first block, got kind={} level={}\",\n static_cast(blocks[0].kind), blocks[0].headingLevel);\n return 3;\n }\n if (md::plainText(blocks[0].runs) != \"Heading\") {\n std::println(\"unexpected heading text: '{}'\", md::plainText(blocks[0].runs));\n return 4;\n }\n\n std::println(\"compat.eui-neo[markdown]: ok ({} blocks, h1 = '{}')\",\n blocks.size(), md::plainText(blocks[0].runs));\n return 0;\n#endif\n}\n" + } + ] + }, + { + "project": "eui-neo-sdl2", + "text": "[package]\nname = \"eui-neo-sdl2-tests\"\nversion = \"0.1.0\"\n\n# Covers the two remaining alternates in one member: the SDL2 window backend\n# replacing GLFW, and `network` turning core/platform/network.cpp from stubs\n# into a real libcurl-backed implementation. The render backend stays OpenGL,\n# unnamed — naming a feature no longer costs you the defaults.\n[dependencies.compat]\neui-neo = { version = \"0.6.0\", features = [\"sdl2\", \"network\"] }\n", + "files": [ + { + "path": "tests/examples/eui-neo-sdl2/tests/backends.cpp", + "text": "// Verify compat.eui-neo's `sdl2` window backend and `network` feature.\n//\n// Both are asserted at COMPILE time first, because both fail silently at\n// runtime otherwise: a window backend that did not switch just uses GLFW, and\n// `network` that did not switch leaves core/platform/network.cpp compiled as\n// stubs that return failure. Neither shows up without a display or a network.\n#include \n// SDL_MAIN_HANDLED before : on Windows SDL_main.h does\n// `#define main SDL_main` and expects the real entry point to come from\n// SDL2main (src/main/windows/SDL_windows_main.c). This package does not ship\n// that — a library consumer should not be handed an entry point — so the test\n// takes SDL's documented alternative and keeps its own main, pairing it with\n// SDL_SetMainReady() below. Without this the link fails with\n// \"LNK1561: entry point must be defined\".\n#define SDL_MAIN_HANDLED\n#include \n#include \nimport std;\n\n#if !defined(EUI_WINDOW_BACKEND_SDL2)\n#error \"sdl2 feature requested but EUI_WINDOW_BACKEND_SDL2 is not defined\"\n#endif\n#if !defined(EUI_HAS_CURL)\n#error \"network feature requested but EUI_HAS_CURL is not defined\"\n#endif\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}.title(\"sdl2 + network test\");\n return config;\n}\n\nvoid compose(eui::Ui&, const eui::Screen&) {}\n\n} // namespace app\n\nint main() {\n // SDL's dummy driver is a real driver, so unlike the GL/Vulkan backends\n // this actually runs: init, create, query, tear down.\n SDL_SetMainReady();\n SDL_SetHint(SDL_HINT_VIDEODRIVER, \"dummy\");\n if (SDL_Init(SDL_INIT_VIDEO) != 0) {\n std::println(\"SDL_Init failed: {}\", SDL_GetError());\n return 1;\n }\n SDL_Window* window = SDL_CreateWindow(\"eui-neo\", SDL_WINDOWPOS_UNDEFINED,\n SDL_WINDOWPOS_UNDEFINED, 200, 100,\n SDL_WINDOW_HIDDEN);\n if (window == nullptr) {\n std::println(\"SDL_CreateWindow failed: {}\", SDL_GetError());\n SDL_Quit();\n return 2;\n }\n const char* driver = SDL_GetCurrentVideoDriver();\n SDL_DestroyWindow(window);\n SDL_Quit();\n\n // libcurl came in through the feature's dependency, and it has TLS — the\n // thing EUI-NEO's downloader needs and the thing a misconfigured curl\n // silently lacks. No connection is made.\n if (curl_global_init(CURL_GLOBAL_DEFAULT) != CURLE_OK) {\n std::println(\"curl_global_init failed\");\n return 3;\n }\n const curl_version_info_data* curlInfo = curl_version_info(CURLVERSION_NOW);\n const bool haveTls = curlInfo != nullptr &&\n (curlInfo->features & CURL_VERSION_SSL) != 0 &&\n curlInfo->ssl_version != nullptr;\n curl_global_cleanup();\n if (!haveTls) {\n std::println(\"libcurl reached us without TLS\");\n return 4;\n }\n\n // Still eui-neo underneath — a headless facade from the base source set.\n eui::json::Document doc;\n if (!doc.parse(R\"({\"window\": \"sdl2\"})\")) {\n std::println(\"parse failed: {}\", doc.error().message);\n return 5;\n }\n std::string windowBackend;\n if (!doc.stringAt(\"/window\", windowBackend) || windowBackend != \"sdl2\") {\n std::println(\"unexpected /window: '{}'\", windowBackend);\n return 6;\n }\n\n std::println(\"compat.eui-neo[sdl2,network]: ok (SDL driver={}, curl {} ssl={})\",\n driver, curlInfo->version, curlInfo->ssl_version);\n return 0;\n}\n" + } + ] + }, + { + "project": "eui-neo-tray", + "text": "# compat.eui-neo's system tray, over the public `core::platform` surface.\n#\n# The other six eui-neo members cover the headless surface, the two entry-point\n# shapes, and the SDL2 / Vulkan / markdown legs. None of them touches the tray,\n# and none of them CAN detect it: tray_bridge.c's `#else` arm defines all six\n# tray symbols and returns 0 from each, so a descriptor that lost its backend\n# define keeps every existing member green while the tray quietly stops\n# existing. This member exists to make that difference observable.\n#\n# ALL THREE PLATFORMS, deliberately not cfg-gated. The descriptor gives windows\n# EUI_TRAY_WINAPI and macOS EUI_TRAY_APPKIT unconditionally, and linux\n# EUI_TRAY_SNI since 0.5.7; `core/platform/platform.h` is platform-clean, so the\n# consumer code below is identical everywhere. Only the ASSERTION is\n# linux-specific, for the reason given in tests/tray.cpp.\n#\n# compat.eui-neo is the only declared dependency. On linux the tray pulls glib\n# through the package's own staged copy and its `runtime` deps, all of which\n# mcpp propagates to the consumer -- the same convention as eui-neo-sdl2 and\n# eui-neo-vulkan, which include / without re-declaring\n# either.\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"eui-neo-tray-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\neui-neo = \"0.6.0\"\n", + "files": [ + { + "path": "tests/examples/eui-neo-tray/tests/tray.cpp", + "text": "// compat.eui-neo's SYSTEM TRAY, through the public C++ surface.\n//\n// This is the shape a consumer should copy. `core::platform` exposes the whole\n// tray as six free functions and a two-field options struct, and none of it\n// needs a window, a GL context, or the DSL app loop -- a tray-only utility is\n// the ten lines in runTray() below. The raw C bridge in\n// core/platform/tray_bridge.h is an implementation detail and is not used here.\n//\n// WHY THIS MEMBER EXISTS, and why the assertion is the shape it is.\n//\n// tray_bridge.c selects one of four backends at preprocessor level --\n// EUI_TRAY_WINAPI, EUI_TRAY_APPKIT, EUI_TRAY_SNI, EUI_TRAY_APPINDICATOR -- and\n// falls through to an `#else` stub when none is defined. THE STUB DEFINES ALL\n// SIX SYMBOLS and returns 0 from every one of them. A descriptor that lost its\n// backend define would still compile, still link, and still pass all six other\n// eui-neo members, while the tray silently stopped existing: \"no tray\" and \"not\n// tested\" would read identically. That is the failure this member is built\n// against.\n//\n// Two criteria were tried and only the second one works:\n//\n// * `dlsym(RTLD_DEFAULT, \"g_bus_get_sync\") != nullptr` -- BLUNT, verified by\n// rebuilding with the define removed and watching it pass anyway. The\n// descriptor's `ldflags` carry `-lgio-2.0` unconditionally, so gio is in\n// DT_NEEDED and its symbols resolve whether or not the SNI arm was\n// compiled. It answered \"is gio linked\", not \"is the backend real\".\n//\n// * initializeTray() under a session bus -- SHARP. Measured both ways on a\n// bare `dbus-run-session` with no panel and no display: the real backend\n// returns true (it exports its StatusNotifierItem object and waits for a\n// watcher), the stub returns false unconditionally.\n//\n// So on linux this test supplies its own private bus and asserts the tray comes\n// up. It needs no display, no panel, and no desktop session, which is what lets\n// it run on an ordinary CI runner.\n//\n// Windows and macOS get compile-and-link coverage only. Their backends are\n// unconditional in the descriptor, and neither has an equivalent cheap probe\n// that a headless runner can perform.\n\n#include \"core/platform/platform.h\"\n\n#if defined(__linux__)\n#include \n#include \n#endif\n\nimport std;\n\nnamespace {\n\n// What a consumer actually writes. Everything else in this file is about\n// proving the backend is real; this is the feature.\nint runTray() {\n core::platform::TrayOptions options;\n options.tooltip = \"EUI NEO tray example\";\n options.iconPath = \"\"; // empty: the panel substitutes a placeholder icon\n\n if (!core::platform::initializeTray(options)) {\n std::println(\"tray unavailable (no session bus)\");\n return 1;\n }\n\n std::println(\"tray is live -- right click it for Show / Exit\");\n while (!core::platform::consumeTrayExitRequested()) {\n core::platform::pollTray(false);\n if (core::platform::consumeTrayShowRequested()) std::println(\" -> Show\");\n std::this_thread::sleep_for(std::chrono::milliseconds(50));\n }\n\n core::platform::shutdownTray();\n std::println(\"tray shut down\");\n return 0;\n}\n\n#if defined(__linux__)\n// Re-run this same executable with a private session bus around it. Returns\n// only on failure; on success `dbus-run-session` replaces this process and the\n// second entry takes the branch below it.\nvoid reexec_under_private_bus(const char* marker) {\n ::setenv(marker, \"1\", 1);\n\n char self[PATH_MAX];\n const ssize_t n = ::readlink(\"/proc/self/exe\", self, sizeof(self) - 1);\n if (n <= 0) return;\n self[n] = '\\0';\n\n char* const argv[] = { const_cast(\"dbus-run-session\"),\n const_cast(\"--\"), self, nullptr };\n ::execvp(\"dbus-run-session\", argv);\n}\n#endif\n\n} // namespace\n\nint main() {\n // Safe on every platform and before any backend has been touched: the state\n // is a plain bool and the stub's eui_tray_is_initialized() also returns 0.\n // This pins the query, not the backend.\n if (core::platform::isTrayInitialized()) {\n std::println(stderr, \"isTrayInitialized() is true before initializeTray()\");\n return 1;\n }\n\n if (std::getenv(\"MCPP_RUN_TRAY\") != nullptr) return runTray();\n\n#if defined(__linux__)\n constexpr const char* kMarker = \"EUI_TRAY_TEST_HAS_BUS\";\n\n if (std::getenv(\"DBUS_SESSION_BUS_ADDRESS\") == nullptr) {\n if (std::getenv(kMarker) != nullptr) {\n // dbus-run-session ran but produced no bus address. Do not report\n // this as a pass: an unverifiable backend is exactly what this\n // member exists to prevent.\n std::println(stderr,\n \"dbus-run-session left no DBUS_SESSION_BUS_ADDRESS, so \"\n \"the linux tray backend could not be verified\");\n return 1;\n }\n reexec_under_private_bus(kMarker);\n std::println(stderr,\n \"could not exec dbus-run-session, so the linux tray backend \"\n \"could not be verified. It ships with dbus; install that \"\n \"package on this runner.\");\n return 1;\n }\n\n // THE ASSERTION. True only when tray_bridge.c compiled a real backend --\n // the EUI_TRAY_HAS_BACKEND=0 stub returns 0 here no matter what the bus is.\n core::platform::TrayOptions options;\n options.tooltip = \"compat.eui-neo tray smoke test\";\n if (!core::platform::initializeTray(options)) {\n std::println(stderr,\n \"compat.eui-neo was built WITHOUT its linux tray backend: a \"\n \"session bus is present, yet initializeTray() failed, which \"\n \"is what tray_bridge.c's EUI_TRAY_HAS_BACKEND=0 stub does \"\n \"unconditionally. Expected -DEUI_TRAY_SNI=1 and glib on the \"\n \"link line (see pkgs/e/compat.eui-neo.lua).\");\n return 1;\n }\n\n if (!core::platform::isTrayInitialized()) {\n std::println(stderr, \"initializeTray() succeeded but isTrayInitialized() is false\");\n return 1;\n }\n core::platform::shutdownTray();\n std::println(\"linux tray backend verified over a private session bus\");\n#else\n std::println(\"tray path compiled and linked; run with MCPP_RUN_TRAY=1 to show it\");\n#endif\n return 0;\n}\n" + } + ] + }, + { + "project": "eui-neo-vulkan", + "text": "[package]\nname = \"eui-neo-vulkan-tests\"\nversion = \"0.1.0\"\n\n# `vulkan` REPLACES the default OpenGL backend rather than adding to it: the\n# package resolves the exclusive choice in its own preprocessor from the\n# MCPP_FEATURE_* flags. The window backend stays GLFW, unnamed.\n[dependencies.compat]\neui-neo = { version = \"0.6.0\", features = [\"vulkan\"] }\n\n[build]\ncxxflags = [\"-DHAVE_EUI_VULKAN=1\"]\n", + "files": [ + { + "path": "tests/examples/eui-neo-vulkan/tests/backend.cpp", + "text": "// Verify compat.eui-neo's `vulkan` feature actually swaps the render backend.\n//\n// Two things have to hold, and only one of them is about Vulkan:\n//\n// 1. The backend define really flipped. EUI_RENDER_BACKEND_VULKAN must be\n// visible here and EUI_RENDER_BACKEND_OPENGL must NOT — if `default` had\n// leaked through alongside the explicit feature, both would be set and\n// core/render/render_backend.cpp would have silently compiled the OpenGL\n// path (its dispatch is `#if OPENGL … #elif VULKAN`, so OpenGL wins).\n// That failure is invisible at runtime without a GPU, so it is asserted at\n// COMPILE time below.\n//\n// 2. The Vulkan translation units are linked. compat.vulkan's loader entry\n// points must resolve from a consumer that reached them through eui-neo's\n// feature-scoped dependency, not through a direct one.\n//\n// Everything else about the Vulkan backend needs a device, a surface and a\n// window, none of which exist on a CI runner — see the design doc.\n//\n// HAVE_EUI_VULKAN is set by THIS project's own [target.'cfg(...)'.build]\n// cxxflags: compat.vulkan has no windows build, so on windows the member takes\n// the default OpenGL configuration instead and asserts THAT.\n#include \n#if defined(HAVE_EUI_VULKAN)\n#include \n#endif\nimport std;\n\n#if defined(HAVE_EUI_VULKAN)\n# if !defined(EUI_RENDER_BACKEND_VULKAN)\n# error \"vulkan feature requested but EUI_RENDER_BACKEND_VULKAN is not defined\"\n# endif\n# if defined(EUI_RENDER_BACKEND_OPENGL)\n# error \"EUI_RENDER_BACKEND_OPENGL leaked in alongside the vulkan feature\"\n# endif\n#else\n// The default (OpenGL) build publishes NO interface define — the package\n// resolves it in its own preprocessor, which the consumer never sees. So the\n// only thing assertable from here is the absence of the vulkan one.\n# if defined(EUI_RENDER_BACKEND_VULKAN)\n# error \"EUI_RENDER_BACKEND_VULKAN present without the vulkan feature\"\n# endif\n#endif\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}.title(\"vulkan backend test\");\n return config;\n}\n\nvoid compose(eui::Ui&, const eui::Screen&) {}\n\n} // namespace app\n\nint main() {\n // The loader answers this without any ICD, so it is safe on a driverless\n // runner while still proving compat.vulkan came along with the feature.\n#if defined(HAVE_EUI_VULKAN)\n std::uint32_t apiVersion = 0;\n if (vkEnumerateInstanceVersion(&apiVersion) != VK_SUCCESS) {\n std::println(\"vkEnumerateInstanceVersion failed\");\n return 1;\n }\n#endif\n\n // Still the eui-neo library underneath: assert on a headless facade that\n // lives in core/platform/json.cpp, so a build that dropped the base\n // sources while chasing the feature fails here.\n eui::json::Document doc;\n if (!doc.parse(R\"({\"backend\": \"vulkan\"})\")) {\n std::println(\"parse failed: {}\", doc.error().message);\n return 2;\n }\n std::string backend;\n if (!doc.stringAt(\"/backend\", backend) || backend != \"vulkan\") {\n std::println(\"unexpected /backend: '{}'\", backend);\n return 3;\n }\n\n#if defined(HAVE_EUI_VULKAN)\n std::println(\"compat.eui-neo[vulkan]: ok (backend={}, loader api {}.{}.{})\",\n backend, VK_VERSION_MAJOR(apiVersion), VK_VERSION_MINOR(apiVersion),\n VK_VERSION_PATCH(apiVersion));\n#else\n std::println(\"compat.eui-neo[vulkan]: skipped, default opengl build asserted instead (parsed {})\",\n backend);\n#endif\n return 0;\n}\n" + } + ] + }, + { + "project": "eui-neo-window", + "text": "# compat.eui-neo driven by a HAND-WRITTEN main() — `app-main` deliberately NOT\n# enabled, so the package contributes no entry point and this project owns the\n# window, the render backend and the loop.\n#\n# The pair matters: tests/examples/eui-neo-app-main asserts the framework-main\n# shape, this member asserts the own-main shape. Nothing else in this index\n# builds an eui-neo WINDOW at all — tests/examples/eui-neo is headless by\n# construction (json + platform flags), so window creation, the OpenGL backend,\n# the text/freetype path and the render loop were previously uncovered.\n#\n# ALL THREE PLATFORMS, deliberately not cfg-gated. Unlike tests/examples/\n# imgui-window and gui-stack — linux-only because they pull the X11 packages\n# directly — compat.eui-neo and compat.glfw both build on linux/macosx/windows,\n# and the four existing eui-neo members already prove that on every runner. What\n# was never compiled anywhere is a CONSUMER translation unit that includes\n# core/window, core/render and , which is exactly what this member\n# is. None of the headers it reaches carry platform-specific includes.\n#\n# The window run needs a display and is opt-in (MCPP_RUN_WINDOW=1); headless CI\n# compiles + links the whole loop on each OS and returns 0.\n#\n# compat.glfw is NOT re-declared here even though this TU calls glfwPollEvents()\n# and glfwWindowShouldClose() itself (EUI's core::window facade exposes no\n# event-pump entry point). mcpp propagates a transitive dependency's\n# `include_dirs` and link inputs to the consumer, so the edge through\n# compat.eui-neo is enough — verified by dropping the line and rebuilding. Same\n# convention as tests/examples/eui-neo-sdl2 (`#include `, ``)\n# and eui-neo-vulkan (`vkEnumerateInstanceVersion`), neither of which re-declares\n# what it reaches through a feature.\n#\n# The `compat` index redirect is inherited from the workspace root.\n[package]\nname = \"eui-neo-window-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\neui-neo = \"0.6.0\"\n", + "files": [ + { + "path": "tests/examples/eui-neo-window/tests/window.cpp", + "text": "// compat.eui-neo with a HAND-WRITTEN main(): the own-main shape, and the only\n// place in this index that builds an eui-neo window.\n//\n// tests/examples/eui-neo covers the headless surface (json, platform flags) and\n// tests/examples/eui-neo-app-main covers the framework-main shape. What is left,\n// and what this member pins, is everything between window creation and a\n// presented frame: core::window::createWindow(), the OpenGL render backend,\n// app::initialize/update/render, ScopedRenderBackend, and the freetype text path.\n//\n// Without `app-main` the package contributes no entry point, so the five things\n// upstream's glfw_app_main.cpp does have to be done by hand — see runWindow().\n// That is the point: a consumer must be able to drive EUI itself, and the public\n// surface it needs (core::window, core::render, app::*) must actually be reachable\n// through the descriptor's include_dirs.\n//\n// The window run needs a display, so it is opt-in via MCPP_RUN_WINDOW=1, exactly\n// as in tests/examples/imgui-window. Headless CI compiles and links the whole\n// loop — which is where a descriptor regression would surface — and returns 0.\n//\n// Builds on all three platforms. Every core/ header below is platform-clean (no\n// windows.h, no Cocoa, no GL), and every GLFW entry point used here is portable,\n// so there is no reason to cfg-gate this the way the X11-consuming members are.\n#include \n\n// 0.5.6 dropped `eui/detail/dsl_app_impl.h` from the umbrella `eui_neo.h`\n// (upstream scoped it to the app-main entry points, which include it directly).\n// This member is the OWN-main shape — nothing in the package drives the loop —\n// so app::initialize/update/render/shutdown are emitted HERE by including the\n// impl header, exactly as upstream's core/app/glfw_app_main.cpp does.\n#include \"eui/detail/dsl_app_impl.h\"\n\n#include \"core/input/input_state.h\"\n#include \"core/platform/platform.h\"\n#include \"core/render/render_backend.h\"\n#include \"core/window/window_backend.h\"\n\n// GLFW would otherwise pull a GL header of its own choosing (GL/gl.h on Windows,\n// the deprecated OpenGL/gl.h on macOS). EUI reaches GL through compat.glad, so\n// this TU wants the GLFW API and nothing else — the same thing\n// core/input/input_state.h does before its own include.\n#ifndef GLFW_INCLUDE_NONE\n#define GLFW_INCLUDE_NONE\n#endif\n#include \n\nimport std;\n\nnamespace {\n\n// Rendered frames to require before exiting, so the opt-in run terminates on its\n// own. Also the assertion: fewer than this means the loop stalled.\nconstexpr int kFrameBudget = 60;\n\nstruct Loop {\n float elapsed = 0.0f;\n int frames = 0;\n // Accumulated from core::render::lastRenderFrameStats(). A frame counter only\n // proves app::update() ran — these prove the OpenGL backend actually issued\n // geometry, i.e. that the render half is wired too. Without them this member\n // would pass just as happily against a null backend.\n long long rectDraws = 0;\n long long textDraws = 0;\n};\n\nLoop g_loop;\n\n} // namespace\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}\n .title(\"compat.eui-neo — own main\")\n .pageId(\"eui_neo_own_main\")\n .clearColor({0.06f, 0.07f, 0.09f, 1.0f})\n .windowSize(420, 240)\n .fps(60.0)\n .showDebugStatsInTitle(false);\n return config;\n}\n\nvoid compose(eui::Ui& ui, const eui::Screen& screen) {\n // A node carrying .onFrame() marks the runtime as animating, which is what\n // turns a redraw-on-demand retained-mode UI into a continuously running loop.\n ui.stack(\"loop.driver\")\n .size(1.0f, 1.0f)\n .onFrame([](float deltaSeconds) {\n // app::update() runs the runtime twice per frame — the second pass\n // with deltaSeconds == 0, to settle the re-compose that onFrame\n // itself requested — so this fires twice per rendered frame.\n if (deltaSeconds <= 0.0f) {\n return;\n }\n g_loop.elapsed += deltaSeconds;\n ++g_loop.frames;\n })\n .build();\n\n // Text exercises the freetype + font-fallback path, rects the OpenGL\n // primitive path.\n const float trackWidth = std::max(120.0f, screen.width - 64.0f);\n\n ui.text(\"title\")\n .position(32.0f, 40.0f)\n .size(trackWidth, 30.0f)\n .text(\"own main\")\n .fontSize(22.0f)\n .lineHeight(28.0f)\n .color({0.94f, 0.96f, 1.0f, 1.0f})\n .build();\n\n ui.text(\"stats\")\n .position(32.0f, 76.0f)\n .size(trackWidth, 24.0f)\n .text(std::format(\"frame {} / {} t = {:.2f}s\", g_loop.frames, kFrameBudget, g_loop.elapsed))\n .fontSize(15.0f)\n .lineHeight(20.0f)\n .color({0.58f, 0.68f, 0.80f, 1.0f})\n .build();\n\n const float progress = static_cast(g_loop.frames) / static_cast(kFrameBudget);\n\n ui.rect(\"track\")\n .position(32.0f, 150.0f)\n .size(trackWidth, 6.0f)\n .radius(3.0f)\n .color({0.16f, 0.19f, 0.24f, 1.0f})\n .build();\n\n ui.rect(\"fill\")\n .position(32.0f, 150.0f)\n .size(std::min(1.0f, progress) * trackWidth, 6.0f)\n .radius(3.0f)\n .color({0.98f, 0.55f, 0.36f, 1.0f})\n .build();\n}\n\n} // namespace app\n\nnamespace {\n\nfloat dpiScaleOf(GLFWwindow* window) {\n float scaleX = 1.0f;\n float scaleY = 1.0f;\n glfwGetWindowContentScale(window, &scaleX, &scaleY);\n return (scaleX + scaleY) * 0.5f;\n}\n\n// Framebuffer pixels per window point; EUI needs it to map cursor coordinates\n// onto the framebuffer.\nfloat pointerScaleOf(GLFWwindow* window) {\n int windowWidth = 0;\n int windowHeight = 0;\n int framebufferWidth = 0;\n int framebufferHeight = 0;\n glfwGetWindowSize(window, &windowWidth, &windowHeight);\n glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);\n if (windowWidth <= 0 || windowHeight <= 0) {\n return 1.0f;\n }\n return 0.5f * (static_cast(framebufferWidth) / static_cast(windowWidth) +\n static_cast(framebufferHeight) / static_cast(windowHeight));\n}\n\nint runWindow() {\n core::platform::repairCurrentWorkingDirectory();\n core::render::initializeRenderBackendLoader();\n\n glfwSetErrorCallback([](int code, const char* description) {\n std::println(\"glfw error {}: {}\", code, description != nullptr ? description : \"\");\n });\n\n if (!glfwInit()) {\n std::println(\"glfwInit() failed\");\n return 10;\n }\n\n const app::DslAppConfig& config = app::dslAppConfig();\n\n core::window::WindowCreateRequest request;\n request.width = config.windowWidthValue;\n request.height = config.windowHeightValue;\n request.title = config.titleValue.c_str();\n request.renderApi = core::render::windowRenderApi();\n\n auto* window = static_cast(core::window::createWindow(request));\n if (window == nullptr) {\n std::println(\"core::window::createWindow() failed\");\n glfwTerminate();\n return 11;\n }\n\n auto backend = core::render::createRenderBackend(window);\n if (!backend || !backend->initialize()) {\n std::println(\"core::render::createRenderBackend() failed\");\n core::window::destroyWindow(window);\n glfwTerminate();\n return 12;\n }\n\n if (!app::initialize(window)) {\n std::println(\"app::initialize() failed\");\n backend.reset();\n core::window::destroyWindow(window);\n glfwTerminate();\n return 13;\n }\n\n // Frame pacing is NOT optional in a hand-written loop: the OpenGL backend\n // calls glfwSwapInterval(0) in initialize(), so present() never blocks on\n // vsync and an unpaced loop runs at thousands of fps and pins a core.\n // Upstream's main does the same thing with monitor refresh-rate detection on\n // top.\n const double frameInterval = config.fpsValue > 0.0 ? 1.0 / config.fpsValue : 0.0;\n double lastFrameTime = core::window::timeSeconds();\n double nextFrameTime = lastFrameTime;\n\n while (!glfwWindowShouldClose(window) && g_loop.frames < kFrameBudget) {\n glfwPollEvents();\n\n const double now = core::window::timeSeconds();\n const float deltaSeconds = static_cast(now - lastFrameTime);\n lastFrameTime = now;\n\n int framebufferWidth = 0;\n int framebufferHeight = 0;\n glfwGetFramebufferSize(window, &framebufferWidth, &framebufferHeight);\n if (framebufferWidth <= 0 || framebufferHeight <= 0) {\n glfwWaitEvents();\n lastFrameTime = core::window::timeSeconds();\n nextFrameTime = lastFrameTime;\n continue;\n }\n\n const float dpiScale = dpiScaleOf(window);\n const float pointerScale = pointerScaleOf(window);\n\n backend->makeCurrent();\n\n // Layout + compose() + input dispatch; also fires the .onFrame()\n // callback that advances g_loop.\n app::update(window, deltaSeconds, framebufferWidth, framebufferHeight, dpiScale, pointerScale);\n\n backend->beginFrame({\n window,\n core::window::nativeWindowInfo(window),\n framebufferWidth,\n framebufferHeight,\n dpiScale\n });\n {\n // app::render() draws through core::render::activeRenderBackend();\n // this guard is what installs ours as the active one. Without it the\n // frame is silently dropped.\n core::render::ScopedRenderBackend scopedBackend(*backend);\n app::render(framebufferWidth, framebufferHeight, dpiScale);\n }\n backend->present();\n\n // app::render() -> Runtime::render() calls beginRenderFrameStats(); this\n // moves the counters into lastRenderFrameStats() so they can be read.\n core::render::publishRenderFrameStats();\n const core::render::RenderFrameStats& stats = core::render::lastRenderFrameStats();\n g_loop.rectDraws += stats.rectDraws;\n g_loop.textDraws += stats.textDraws;\n\n nextFrameTime += frameInterval;\n const double slack = nextFrameTime - core::window::timeSeconds();\n if (slack > 0.0) {\n std::this_thread::sleep_for(std::chrono::duration(slack));\n } else {\n // Fell behind: resynchronise instead of accumulating debt.\n nextFrameTime = core::window::timeSeconds();\n }\n }\n\n core::releaseInputQueue(window);\n backend->makeCurrent();\n backend->releaseRenderCache();\n {\n core::render::ScopedRenderBackend scopedBackend(*backend);\n app::shutdown();\n }\n backend.reset();\n core::window::destroyWindow(window);\n glfwTerminate();\n\n if (g_loop.frames < kFrameBudget) {\n std::println(\"loop stalled: {} of {} frames in {:.2f}s\",\n g_loop.frames, kFrameBudget, g_loop.elapsed);\n return 14;\n }\n if (g_loop.rectDraws <= 0) {\n std::println(\"no rect geometry reached the backend over {} frames\", g_loop.frames);\n return 15;\n }\n if (g_loop.textDraws <= 0) {\n // Text needs the freetype path AND a usable font. On a box with no\n // system font at any of upstream's Linux fallback paths this is the\n // failure you get, and it is worth distinguishing from a dead backend.\n std::println(\"no text geometry reached the backend over {} frames \"\n \"(freetype path or system font missing)\", g_loop.frames);\n return 16;\n }\n\n std::println(\"compat.eui-neo[own main]: ok ({} frames in {:.2f}s, {} rect / {} text draws)\",\n g_loop.frames, g_loop.elapsed, g_loop.rectDraws, g_loop.textDraws);\n return 0;\n}\n\n} // namespace\n\nint main() {\n // Compiling + linking runWindow() is the headless test: it is the only thing\n // in this index that instantiates core::window / core::render / app::* from a\n // consumer TU. The run itself needs a display (opt-in).\n if (std::getenv(\"MCPP_RUN_WINDOW\") != nullptr) {\n return runWindow();\n }\n std::println(\"compat.eui-neo[own main]: linked; window run is opt-in (MCPP_RUN_WINDOW=1)\");\n return 0;\n}\n" + } + ] + }, + { + "project": "eui-neo", + "text": "[package]\nname = \"eui-neo-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\neui-neo = \"0.6.0\"\n", + "files": [ + { + "path": "tests/examples/eui-neo/tests/header.cpp", + "text": "// Behavioral test — verify compat.eui-neo builds, exposes its umbrella header\n// to a Form A consumer, and links against real symbols from the built lib.\n//\n// `#include ` is the whole point of the header-compat shape. The\n// umbrella declares the DSL app surface (eui/dsl_app.h) and leaves two symbols\n// for the application to supply — app::dslAppConfig() and app::compose().\n// Since 0.5.6 the umbrella no longer pulls in eui/detail/dsl_app_impl.h (the\n// impl lives in the app-main entry points instead), but THIS TU only defines\n// the two app symbols and never calls app::initialize/update/render — so it\n// links without the impl header, and defining the app symbols is exactly what\n// upstream's examples/*.cpp do, making this test a faithful minimal consumer.\n//\n// The assertion itself runs on eui::json::Document: it lives in\n// core/platform/json.cpp, so a package that compiled zero translation units\n// fails at LINK time instead of silently passing — which is how an earlier\n// revision of this descriptor shipped an empty lib behind a green CI. It is\n// also the cheapest entry point that is genuinely headless (no window, no GL\n// context), which matters because CI runners have no display.\n#include \nimport std;\n\nnamespace app {\n\nconst DslAppConfig& dslAppConfig() {\n static const DslAppConfig config = DslAppConfig{}\n .title(\"compat.eui-neo smoke test\")\n .windowSize(320, 240);\n return config;\n}\n\n// Never invoked: the test asserts on the headless JSON facade instead of\n// entering the render loop. It exists so the DSL app skeleton links.\nvoid compose(eui::Ui&, const eui::Screen&) {}\n\n} // namespace app\n\nint main() {\n // The umbrella header really did reach us, with the DSL app config intact.\n if (app::dslAppConfig().windowWidthValue != 320) {\n std::println(\"dslAppConfig() not wired: width={}\", app::dslAppConfig().windowWidthValue);\n return 1;\n }\n\n eui::json::Document doc;\n if (!doc.parse(R\"({\"framework\": {\"name\": \"eui-neo\", \"version\": 3}})\")) {\n std::println(\"parse failed: {}\", doc.error().message);\n return 2;\n }\n\n std::string name;\n if (!doc.stringAt(\"/framework/name\", name) || name != \"eui-neo\") {\n std::println(\"unexpected /framework/name: '{}'\", name);\n return 3;\n }\n\n // number() rather than signedInteger(): yyjson tags a positive literal as\n // an UNSIGNED integer, so yyjson_is_sint() — and therefore\n // Value::signedInteger() — is false for `3`.\n double version = 0.0;\n if (!doc.atPointer(\"/framework/version\").number(version) || version != 3.0) {\n std::println(\"unexpected /framework/version: {}\", version);\n return 4;\n }\n\n // Forces core/platform/platform.cpp into the link. That TU emits\n // `platform.o`, which collides with compat.glfw's src/platform.c in mcpp's\n // flat per-link obj/ directory (mcpp#233/#240) and used to be dropped\n // silently — the descriptor now routes it through a uniquely named\n // generated stub. Referencing a symbol only that TU defines turns a\n // regression into an undefined reference instead of a passing test.\n // Read-only on purpose: requestFrame() would reach glfwPostEmptyEvent()\n // with no window, and CI runners are headless. Both flags start clear.\n if (core::platform::consumeFrameRequest() || core::platform::consumeUiUpdate()) {\n std::println(\"platform frame flags did not start clear\");\n return 5;\n }\n\n // NEGATIVE verification for the `markdown` feature, which is NOT requested\n // by this member. components/markdown.h ships two definitions of\n // parseMarkdownBlocks: the md4c one, and — when EUI_HAS_MD4C is absent — a\n // degenerate fallback that wraps the entire source in a single Paragraph.\n // Getting the fallback here proves the feature's interface define really is\n // gated off by default. tests/examples/eui-neo-markdown asserts the other\n // side of the same switch.\n const auto blocks = components::detail::parseMarkdownBlocks(\"# Heading\\n\\nBody text.\\n\");\n if (blocks.size() != 1 || blocks[0].kind != components::detail::MarkdownBlockKind::Paragraph) {\n std::println(\"markdown feature leaked into the default build: {} block(s)\", blocks.size());\n return 6;\n }\n\n std::println(\"compat.eui-neo smoke test: ok (parsed {} v{}, markdown gated off)\", name, version);\n return 0;\n}\n" + } + ] + }, + { + "project": "expat", + "text": "# compat.expat test project.\n#\n# Expat is built from source here while the ecosystem also carries\n# `xim:expat` 2.6.2 as a transitive of `xim:mesa`. They share the soname\n# `libexpat.so.1`, so exactly one is mapped and nothing warns — the dladdr\n# assertion pins which.\n[package]\nname = \"expat-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nexpat = \"2.7.1\"\n", + "files": [ + { + "path": "tests/examples/expat/tests/xml.cpp", + "text": "// compat.expat — behavioral test. It parses a document rather than checking\n// that symbols resolve, because the interesting failures are configuration\n// ones: XML_DTD, XML_GE and XML_NS are feature switches compiled into\n// expat_config.h, and getting them wrong yields a parser that links perfectly\n// and then rejects (or mis-splits) input.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n\nstruct Collected {\n std::vector elements;\n std::string text;\n};\n\nvoid XMLCALL on_start(void *ud, const XML_Char *name, const XML_Char **atts)\n{\n auto *c = static_cast(ud);\n c->elements.emplace_back(name);\n for (int i = 0; atts != nullptr && atts[i] != nullptr; i += 2) {\n c->elements.emplace_back(std::string(\"@\") + atts[i] + \"=\" + atts[i + 1]);\n }\n}\n\nvoid XMLCALL on_text(void *ud, const XML_Char *s, int len)\n{\n static_cast(ud)->text.append(s, static_cast(len));\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. A namespaced parse, which is XML_NS ───────────────────────────\n // The separator argument only does anything when XML_NS was compiled in;\n // without it the element name comes back as \"w:seat\" instead of the\n // expanded \"urn:test|seat\", and wayland-scanner would misread every\n // protocol file.\n {\n Collected c;\n XML_Parser p = XML_ParserCreateNS(nullptr, '|');\n check(p != nullptr, \"XML_ParserCreateNS returns a parser\");\n XML_SetUserData(p, &c);\n XML_SetElementHandler(p, on_start, nullptr);\n XML_SetCharacterDataHandler(p, on_text);\n\n static const char doc[] =\n \"hi\";\n const bool ok = XML_Parse(p, doc, (int)std::strlen(doc), 1) == XML_STATUS_OK;\n check(ok, \"XML_Parse accepts a namespaced document\");\n check(c.elements.size() >= 2 && c.elements[0] == \"urn:test|proto\",\n \"XML_NS expanded the prefix (urn:test|proto)\");\n check(c.text == \"hi\", \"character data reached the handler\");\n XML_ParserFree(p);\n }\n\n // ── 2. An internal DTD entity, which is XML_DTD + XML_GE ─────────────\n // Built without general-entity support this parse fails outright, so it\n // separates a correct expat_config.h from a plausible one.\n {\n Collected c;\n XML_Parser p = XML_ParserCreate(nullptr);\n XML_SetUserData(p, &c);\n XML_SetCharacterDataHandler(p, on_text);\n\n static const char doc[] =\n \"]>&who;\";\n const bool ok = XML_Parse(p, doc, (int)std::strlen(doc), 1) == XML_STATUS_OK;\n check(ok, \"XML_Parse accepts an internal DTD entity\");\n check(c.text == \"wayland\", \"the general entity expanded to 'wayland'\");\n XML_ParserFree(p);\n }\n\n // ── 3. Malformed input is rejected ───────────────────────────────────\n {\n XML_Parser p = XML_ParserCreate(nullptr);\n static const char bad[] = \"\";\n check(XML_Parse(p, bad, (int)std::strlen(bad), 1) == XML_STATUS_ERROR,\n \"mismatched tags are an error, not silence\");\n XML_ParserFree(p);\n }\n\n // ── 4. It is THIS build that ran ─────────────────────────────────────\n // The package is `kind = \"lib\"`, so these objects are merged into the\n // consumer and dladdr reports the EXECUTABLE. The check that matters is\n // the same either way: the code that just parsed must not have come from\n // the ecosystem's `xim:expat`, which is present whenever Mesa is and would\n // otherwise answer silently.\n //\n // A STATICALLY LINKED BINARY CANNOT ANSWER THIS, AND THAT IS NOT A\n // FAILURE. musl's `dladdr` is `weak_alias(stub_dladdr, dladdr)` returning\n // 0 (src/ldso/dladdr.c); the dynamic loader overrides it, and a static\n // link has no loader to do so. An openkal program is statically linked, so\n // the call returns 0 for every address, and the risk this check exists for\n // cannot arise there either: nothing was loaded at runtime, so the only\n // expat in the process is the one that was linked in.\n //\n // SKIPPING IT SILENTLY WOULD MAKE \"no loader\" AND \"the loader answered\n // wrongly\" THE SAME READING, so the fallback asserts what distinguishes\n // them. `main` is in this executable by construction; if `dladdr` cannot\n // locate that either, there is no dynamic symbol information at all and\n // the question is moot. If it locates `main` but not `XML_ParserCreate`,\n // something is wrong and the check fails as before.\n {\n Dl_info info{};\n const bool located =\n ::dladdr(reinterpret_cast(&XML_ParserCreate), &info) != 0\n && info.dli_fname != nullptr;\n if (located) {\n const std::string from = info.dli_fname;\n std::printf(\" resolved from: %s\\n\", from.c_str());\n check(from.find(\"xim-x-expat\") == std::string::npos,\n \"it is not the ecosystem payload's libexpat\");\n } else {\n Dl_info self{};\n const bool anyAnswer =\n ::dladdr(reinterpret_cast(&main), &self) != 0;\n std::printf(\" dladdr answers nothing in this image%s\\n\",\n anyAnswer ? \" EXCEPT main\" : \" at all (static link)\");\n check(!anyAnswer,\n \"dladdr locates the code that parsed, or locates nothing\");\n }\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "ffmpeg-module", + "text": "# Public `ffmpeg` C++23-module package test (bare-name consumption from the\n# builtin default namespace — resolved against THIS checkout via the\n# workspace-root `[indices]` redirect, which every member inherits). Replaces the\n# former tests/smoke_ffmpeg_module.sh reseeding driver. The package is\n# The package builds on linux + macOS + windows (compat.ffmpeg 3-platform).\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `default`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nffmpeg = { path = \"../../..\" }\n\n[package]\nname = \"ffmpeg-module-tests\"\nversion = \"0.1.0\"\n\n# The module package's own transitive dep (compat.ffmpeg) resolves from the\n# GLOBAL published compat index: this member redirects `default`, and a\n# member-level [indices] replaces the root's `compat` rather than adding to\n# it. The compat descriptor itself is pre-merge-validated by its own member.\n[target.'cfg(linux)'.dependencies.ffmpeg]\nffmpeg = \"8.1.2\"\n\n[target.'cfg(macos)'.dependencies.ffmpeg]\nffmpeg = \"8.1.2\"\n\n[target.'cfg(windows)'.dependencies.ffmpeg]\nffmpeg = \"8.1.2\"\n", + "files": [ + { + "path": "tests/examples/ffmpeg-module/tests/ffmpeg_module.cpp", + "text": "// Public ffmpeg module package end-to-end assertion: import-only consumption\n// (`import ffmpeg.av;`), libavutil version of the FFmpeg 8.1.x train, core\n// decoders present, demuxer registry populated. Builds on linux+macOS+windows.\nimport std;\nimport ffmpeg.av;\n\nint main() {\n unsigned v { avutil_version() };\n std::println(\"libavutil {}.{} module package ok\", v >> 16, (v >> 8) & 0xff);\n if ((v >> 16) != 60u) { // libavutil major of the FFmpeg 8.1.x train\n return 1;\n }\n\n for (auto name : { \"h264\", \"hevc\", \"av1\", \"aac\" }) {\n const AVCodec* dec { avcodec_find_decoder_by_name(name) };\n if (dec == nullptr) {\n std::println(\"decoder {} MISSING\", name);\n return 2;\n }\n }\n\n void* it { nullptr };\n long demuxers { 0 };\n while (av_demuxer_iterate(&it)) ++demuxers;\n std::println(\"{} demuxers registered\", demuxers);\n return demuxers > 300 ? 0 : 3;\n}\n" + } + ] + }, + { + "project": "ffmpeg", + "text": "# FFmpeg test project. compat.ffmpeg compiles all of FFmpeg 8.1.2 from source\n# with mcpp itself (2281 TUs incl. 157 NASM .asm; no configure/make at build\n# time — the config snapshot ships as generated_files in the descriptor).\n# Linux + macOS: under those cfgs the dep is pulled (per-OS frozen config\n# per-OS frozen config snapshot — linux NASM, macosx NEON, windows win64\n# NASM) and the test asserts the full component surface on all three.\n[package]\nname = \"ffmpeg-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nffmpeg = \"8.1.2\"\n\n[target.'cfg(macos)'.dependencies.compat]\nffmpeg = \"8.1.2\"\n\n[target.'cfg(windows)'.dependencies.compat]\nffmpeg = \"8.1.2\"\n", + "files": [ + { + "path": "tests/examples/ffmpeg/tests/ffmpeg.cpp", + "text": "// compat.ffmpeg end-to-end assertion: the full-profile source build must\n// expose its major decoders and demuxers, and library versions must match the\n// vendored 8.1.2 release. Linux-only (see mcpp.toml).\n#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)\nextern \"C\" {\n#include \n#include \n}\n\nint main() {\n if ((avutil_version() >> 16) != 60) return 1; // n8.1.x: libavutil major 60\n if ((avcodec_version() >> 16) != 62) return 2;\n if ((avformat_version() >> 16) != 62) return 3;\n\n const char* decoders[] { \"h264\", \"hevc\", \"av1\", \"aac\" };\n for (const char* name : decoders)\n if (!avcodec_find_decoder_by_name(name)) return 4;\n\n void* it { nullptr };\n int demuxers { 0 };\n while (av_demuxer_iterate(&it)) ++demuxers;\n if (demuxers <= 300) return 5; // full profile registers 358\n\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "fmtlib.fmt", + "text": "# {fmt} test project: consumes the C++23 module `fmt` (`import fmt;`, no\n# #include) from the `fmtlib.fmt` package and asserts behavior under `mcpp test`.\n# Part of the mcpp-index self-referential workspace: the workspace-root\n# `[indices]` redirect points at this repo, so the dependency resolves to the\n# checked-in recipe (pkgs/f/\n# fmtlib.fmt.lua) rather than a remote index. The exported module is plain `fmt`.\n[indices]\nfmtlib = { path = \"../../..\" }\n\n[package]\nname = \"fmtlib-fmt-tests\"\nversion = \"0.1.0\"\n\n[dependencies.fmtlib]\nfmt = \"12.2.0\"\n", + "files": [ + { + "path": "tests/examples/fmtlib.fmt/tests/format_test.cpp", + "text": "// Behavioral test: consume {fmt}'s compiled implementation through the C++23\n// module `fmt` (`import fmt;`, no #include) and assert the results. Exercises\n// src/format.cc via the module unit's tail includes — not just header inlines.\n// Returns non-zero on any mismatch.\nimport std;\nimport fmt;\n\nint main() {\n std::string a = fmt::format(\"{} + {} = {}\", 2, 3, 2 + 3);\n std::string b = fmt::format(\"{:08.3f}\", 3.14159);\n std::string c = fmt::format(\"{0}-{1}-{0}\", \"x\", \"y\");\n std::string d = fmt::format(\"{:#x}\", 255);\n\n bool ok = a == \"2 + 3 = 5\"\n && b == \"0003.142\"\n && c == \"x-y-x\"\n && d == \"0xff\";\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "fontconfig", + "text": "# fontconfig test member — the three generated tables.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"fontconfig-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# `compat.expat` and `compat.freetype` arrive transitively; naming them here as\n# version deps as well is fine because the package declares them the same way\n# (not as path deps), unlike the wayland members.\n[target.'cfg(linux)'.dependencies.freedesktop]\nfontconfig = \"2.15.0\"\n", + "files": [ + { + "path": "tests/examples/fontconfig/tests/fontconfig.cpp", + "text": "// freedesktop.fontconfig — the three generated tables, exercised through the\n// public API.\n//\n// WHAT CAN BE ASSERTED WITHOUT FONTS\n//\n// This package compiles the four runtime paths EMPTY on purpose (see the\n// manifest), so `FcInit()` on a runner with no `FONTCONFIG_FILE` finds no\n// configuration and no fonts. That is the intended behaviour, not a defect,\n// and it means a font-matching test would only be asserting that the runner\n// has a desktop.\n//\n// The three GENERATED tables need none of that, and they are what this fork\n// exists to produce:\n//\n// fclang.h 281 .orth files compiled into charset bitmaps\n// fccase.h Unicode case folding\n// fcobjshash.h object-name → id, the gperf replacement\n//\n// Each is reachable through a public entry point that takes no config.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\" FcGetVersion() = %d\\n\", FcGetVersion());\n check(FcGetVersion() >= 21500, \"FcGetVersion reports 2.15.0 or newer\");\n\n // ── fcobjshash.h — the gperf replacement ─────────────────────────────\n //\n // Exercised through `FcNameParse`, which is the public route into the\n // object table: the parser maps every \"name=value\" key through\n // `FcObjectLookupIdByName`, which IS the generated lookup. `FcObject` and\n // `FcObjectFromName` are internal (fcint.h), so reaching for them would\n // test the table without testing that a consumer can get to it.\n {\n FcPattern *p = FcNameParse((const FcChar8 *) \"Arial:pixelsize=12:slant=100\");\n check(p != nullptr, \"fcobjshash: FcNameParse accepted three object names\");\n if (p != nullptr) {\n FcChar8 *fam = nullptr;\n double px = 0;\n int slant = 0;\n const bool gf = FcPatternGetString(p, FC_FAMILY, 0, &fam) == FcResultMatch;\n const bool gp = FcPatternGetDouble(p, FC_PIXEL_SIZE, 0, &px) == FcResultMatch;\n const bool gs = FcPatternGetInteger(p, FC_SLANT, 0, &slant) == FcResultMatch;\n std::printf(\" family=%s pixelsize=%g slant=%d\\n\",\n gf && fam ? (const char *) fam : \"(none)\", px, slant);\n check(gf && fam && std::strcmp((const char *) fam, \"Arial\") == 0,\n \"…and \\\"family\\\" round-tripped to the right object\");\n check(gp && px == 12.0, \"…and \\\"pixelsize\\\" did too\");\n check(gs && slant == 100, \"…and \\\"slant\\\" did too\");\n FcPatternDestroy(p);\n }\n\n // A name the table does not contain must not resolve to a builtin —\n // fontconfig assigns it a dynamic id instead. This is the branch a\n // binary search gets wrong when its bounds are off by one.\n FcPattern *q = FcNameParse((const FcChar8 *) \":nosuchproperty=1\");\n check(q != nullptr, \"…and an unknown property does not crash the parser\");\n if (q) FcPatternDestroy(q);\n }\n\n // ── fclang.h — the language charsets ─────────────────────────────────\n // `FcLangGetCharSet` indexes straight into fcLangCharSets. Three languages\n // with very different coverage, so a table that was truncated or misordered\n // fails on at least one.\n for (const char *l : {\"en\", \"ru\", \"zh-cn\"}) {\n const FcCharSet *cs = FcLangGetCharSet((const FcChar8 *) l);\n const FcChar32 n = cs ? FcCharSetCount(cs) : 0;\n std::printf(\" lang %-6s -> %u codepoints\\n\", l, n);\n check(cs != nullptr && n > 0, \"fclang: the language has a charset\");\n }\n // Latin and Cyrillic must not be the same set — they are separate entries\n // whose leaves were de-duplicated, and a dedup bug shows up here.\n {\n const FcCharSet *en = FcLangGetCharSet((const FcChar8 *) \"en\");\n const FcCharSet *ru = FcLangGetCharSet((const FcChar8 *) \"ru\");\n check(en && ru && !FcCharSetEqual(en, ru),\n \"…and English and Russian are different sets\");\n check(en && FcCharSetHasChar(en, 'A'), \"…and English covers 'A'\");\n check(ru && FcCharSetHasChar(ru, 0x0416), \"…and Russian covers U+0416\");\n check(en && !FcCharSetHasChar(en, 0x0416), \"…and English does not\");\n }\n\n // A language that does not exist must return null rather than index out of\n // range — the fastpath table `fcLangCharSetRanges` is what decides that.\n check(FcLangGetCharSet((const FcChar8 *) \"zz-nowhere\") == nullptr,\n \"fclang: an unknown language returns null\");\n\n // ── fccase.h — Unicode case folding ──────────────────────────────────\n // `FcStrDowncase` walks fcCaseFold. ASCII exercises the EVEN_ODD/RANGE\n // methods; U+0130 (Latin capital I with dot) is a FULL fold — one char to\n // several — which is the method with its own byte pool.\n {\n FcChar8 *d = FcStrDowncase((const FcChar8 *) \"ABC\");\n std::printf(\" downcase(\\\"ABC\\\") = %s\\n\", d ? (const char *) d : \"(null)\");\n check(d && std::strcmp((const char *) d, \"abc\") == 0,\n \"fccase: ASCII folds through the range methods\");\n if (d) FcStrFree(d);\n }\n {\n // U+0410 CYRILLIC CAPITAL A -> U+0430, a two-byte fold.\n FcChar8 *d = FcStrDowncase((const FcChar8 *) \"\\xd0\\x90\");\n const bool ok = d && std::strcmp((const char *) d, \"\\xd0\\xb0\") == 0;\n std::printf(\" downcase(U+0410) = %s\\n\", ok ? \"U+0430\" : \"(wrong)\");\n check(ok, \"…and a Cyrillic capital folds to its lowercase\");\n if (d) FcStrFree(d);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "freetype", + "text": "[package]\nname = \"freetype-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nfreetype = \"2.13.3\"\n", + "files": [ + { + "path": "tests/examples/freetype/tests/init.cpp", + "text": "// Smoke test — verify compat.freetype builds, links, and basic API works\n#include \n#include FT_FREETYPE_H\nimport std;\n\nint main() {\n FT_Library library{};\n FT_Error error = FT_Init_FreeType(&library);\n if (error) {\n std::println(\"FT_Init_FreeType failed: {}\", error);\n return 1;\n }\n std::println(\"compat.freetype smoke test: ok\");\n FT_Done_FreeType(library);\n return 0;\n}\n" + } + ] + }, + { + "project": "fribidi", + "text": "# compat.fribidi — the bidi algorithm pango needs for right-to-left text.\n[indices]\ncompat = { path = \"../../..\" }\n\n[package]\nname = \"fribidi-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\nfribidi = \"1.0.16\"\n", + "files": [ + { + "path": "tests/examples/fribidi/tests/fribidi.cpp", + "text": "// compat.fribidi — the Unicode bidirectional algorithm, on text that needs it.\n//\n// WHAT THIS ASSERTS AND WHY\n//\n// The tables are the whole package. fribidi's seven `.tab.i` files encode the\n// Unicode Character Database, and a build that compiled none of them would\n// still link, still run, and quietly treat Arabic as left-to-right. So every\n// check below asks the library a question whose answer is IN a table:\n//\n// the Unicode version fribidi-unicode-version.h, generated upstream\n// bidi character types bidi-type.tab.i\n// base direction the resolution algorithm over those types\n// visual reordering UAX #9 itself, the reason pango needs this\n// mirroring mirroring.tab.i — \"(\" becomes \")\" in an RTL run\n// joining types joining-type.tab.i, which shapes Arabic\n//\n// A pass here means the tarball's pre-generated tables really were compiled —\n// the claim that lets this be a descriptor instead of a fork.\n\n#ifdef __linux__\n\n// ⚠️ WRAPPED IN extern \"C\". fribidi's public headers have no `extern \"C\"` of\n// their own — measured, zero occurrences across fribidi.h and the fifteen\n// headers it pulls — so a C++ translation unit mangles every declaration and\n// the link fails naming symbols the library plainly contains. Same situation\n// as compat.libseat in this index, and the same answer: the consumer wraps.\nextern \"C\" {\n#include \n}\n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. the version, and the Unicode version behind the tables ────────\n std::printf(\" %s\\n\", fribidi_version_info);\n check(std::strstr(fribidi_version_info, \"1.0.16\") != nullptr,\n \"fribidi_version_info names 1.0.16, as the descriptor does\");\n std::printf(\" Unicode %s\\n\", FRIBIDI_UNICODE_VERSION);\n check(FRIBIDI_UNICODE_VERSION[0] >= '1',\n \"FRIBIDI_UNICODE_VERSION came from the generated header\");\n\n // ── 2. bidi character types — bidi-type.tab.i ────────────────────────\n // 'a' is L (left-to-right). U+0627 ARABIC LETTER ALEF is AL (arabic\n // letter). U+05D0 HEBREW LETTER ALEF is R. Three different answers from\n // one table; a build with no table would give the same answer for all.\n const FriBidiCharType t_a = fribidi_get_bidi_type(0x0061);\n const FriBidiCharType t_alef = fribidi_get_bidi_type(0x0627);\n const FriBidiCharType t_hebr = fribidi_get_bidi_type(0x05D0);\n std::printf(\" types: 'a'=%s U+0627=%s U+05D0=%s\\n\",\n fribidi_get_bidi_type_name(t_a),\n fribidi_get_bidi_type_name(t_alef),\n fribidi_get_bidi_type_name(t_hebr));\n // There is no FRIBIDI_IS_LTR: the header offers FRIBIDI_IS_RTL and the\n // left-to-right case is its negation, which is the honest spelling anyway\n // because \"not RTL\" is what the algorithm actually decides.\n check(FRIBIDI_IS_LETTER(t_a) && !FRIBIDI_IS_RTL(t_a), \"'a' is a left-to-right letter\");\n check(FRIBIDI_IS_ARABIC(t_alef), \"U+0627 ARABIC ALEF is an Arabic letter\");\n check(FRIBIDI_IS_RTL(t_hebr) && !FRIBIDI_IS_ARABIC(t_hebr),\n \"U+05D0 HEBREW ALEF is right-to-left but not Arabic\");\n\n // ── 3. base direction of a paragraph ─────────────────────────────────\n // The algorithm scans for the first strong character. A Hebrew-initial\n // paragraph is RTL; a Latin-initial one is LTR. This is what pango calls\n // to decide which way a line runs.\n // ⚠️ fribidi_get_par_direction takes BIDI TYPES, not characters. Passing\n // code points compiles — FriBidiCharType and FriBidiChar are both 32-bit\n // unsigned — and returns a plausible wrong answer, which is why the check\n // below reads the types out of the table first.\n {\n const FriBidiChar rtl[] = {0x05D0, 0x0020, 0x0041}; // ALEF SPACE A\n FriBidiCharType types[3];\n fribidi_get_bidi_types(rtl, 3, types);\n check(FRIBIDI_IS_RTL(fribidi_get_par_direction(types, 3)),\n \"a Hebrew-initial paragraph resolves to RTL\");\n\n const FriBidiChar ltr[] = {0x0041, 0x0020, 0x05D0}; // A SPACE ALEF\n fribidi_get_bidi_types(ltr, 3, types);\n check(!FRIBIDI_IS_RTL(fribidi_get_par_direction(types, 3)),\n \"…and a Latin-initial one to LTR\");\n }\n\n // ── 4. reordering: the point of the library ──────────────────────────\n // \"abc\" + Hebrew \"אבג\" in logical order, in an RTL paragraph. UAX #9 puts\n // the Hebrew run first visually and REVERSES it, because Hebrew is stored\n // in logical order and drawn right to left.\n //\n // This is an INTERMEDIATE QUANTITY, not a rendered image: it says exactly\n // where the algorithm put each character, so a wrong answer points at the\n // reordering rather than at \"the text looked odd\".\n {\n FriBidiChar logical[] = {0x0061, 0x0062, 0x0063, 0x0020,\n 0x05D0, 0x05D1, 0x05D2}; // \"abc אבג\"\n const FriBidiStrIndex len = 7;\n FriBidiCharType types[7];\n FriBidiBracketType brackets[7];\n FriBidiLevel levels[7];\n FriBidiChar visual[7];\n FriBidiParType par = FRIBIDI_PAR_RTL;\n\n fribidi_get_bidi_types(logical, len, types);\n fribidi_get_bracket_types(logical, len, types, brackets);\n const FriBidiLevel max = fribidi_get_par_embedding_levels_ex(\n types, brackets, len, &par, levels);\n check(max > 0, \"fribidi_get_par_embedding_levels_ex resolved levels\");\n\n std::memcpy(visual, logical, sizeof logical);\n FriBidiStrIndex map[7];\n for (FriBidiStrIndex i = 0; i < len; ++i) {\n map[i] = i;\n }\n // warn_unused_result: the return is the maximum level reached, and\n // ignoring it is a warning that CI treats as noise worth removing.\n const FriBidiLevel rmax = fribidi_reorder_line(\n FRIBIDI_FLAGS_DEFAULT, types, len, 0, par, levels, visual, map);\n check(rmax > 0, \"fribidi_reorder_line reordered at least one level\");\n\n std::printf(\" visual order: \");\n for (FriBidiStrIndex i = 0; i < len; ++i) {\n std::printf(\"U+%04X \", visual[i]);\n }\n std::printf(\"\\n\");\n\n // In an RTL paragraph the Hebrew run comes first and is reversed:\n // U+05D2 U+05D1 U+05D0, then the space, then \"abc\" left to right.\n check(visual[0] == 0x05D2 && visual[1] == 0x05D1 && visual[2] == 0x05D0,\n \"the Hebrew run is placed first and reversed\");\n check(visual[4] == 0x0061 && visual[5] == 0x0062 && visual[6] == 0x0063,\n \"…and the Latin run keeps its own order\");\n }\n\n // ── 5. mirroring — mirroring.tab.i ───────────────────────────────────\n // In a right-to-left run, \"(\" must be DRAWN as \")\". The glyph substitution\n // is a table lookup, and a build without the table returns the input.\n {\n FriBidiChar mirrored = 0;\n const bool got = fribidi_get_mirror_char(0x0028, &mirrored) != 0;\n std::printf(\" mirror of '(' = U+%04X\\n\", mirrored);\n check(got && mirrored == 0x0029, \"'(' mirrors to ')'\");\n\n FriBidiChar none = 0;\n check(fribidi_get_mirror_char(0x0061, &none) == 0,\n \"…and 'a' has no mirror, rather than mirroring to itself\");\n }\n\n // ── 6. joining types — joining-type.tab.i ────────────────────────────\n // Arabic letters change shape by position. ALEF is right-joining (it links\n // to the preceding letter only); BEH is dual-joining. Getting this wrong\n // is what makes Arabic render as disconnected letterforms.\n {\n const FriBidiJoiningType j_alef = fribidi_get_joining_type(0x0627);\n const FriBidiJoiningType j_beh = fribidi_get_joining_type(0x0628);\n std::printf(\" joining: ALEF=%s BEH=%s\\n\",\n fribidi_get_joining_type_name(j_alef),\n fribidi_get_joining_type_name(j_beh));\n check(FRIBIDI_IS_JOINING_TYPE_R(j_alef), \"ARABIC ALEF is right-joining\");\n check(FRIBIDI_IS_JOINING_TYPE_D(j_beh), \"ARABIC BEH is dual-joining\");\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "ftxui-module", + "text": "# compat.ftxui's `modules` feature: upstream's own named modules for 7.0.3 —\n# the `ftxui` umbrella plus ftxui.component/.dom/.screen/.util. The sibling\n# member tests/examples/core covers the default HEADER surface at the same\n# version and requests no feature; this member exists to prove the feature.\n#\n# Part of the self-referential workspace: the dependency resolves to the\n# checked-in descriptor (pkgs/c/compat.ftxui.lua) through the workspace-root\n# `[indices]` redirect, which this member inherits.\n#\n# ⚠️ EVERY TU HERE STAYS ON THE MODULE SURFACE — `import std;` beside\n# `import ftxui...;`, and not one textual `#include`. That is not style, it is\n# the thing the member is guarding. A consumer TU that mixes `import ftxui;`\n# with a textual `#include` of a standard header hands gcc two copies of\n# libstdc++'s declarations through the sub-modules' global module fragments,\n# and gcc 16 rejects it with thousands of `redefinition of 'void\n# std::__terminate()'` / `conflicting declaration of template ... std::allocator`\n# errors. That is what took down #292's first attempt at compiling these units,\n# and it is a CONSUMER-side constraint, not a defect in the package. clang\n# accepts the mixed TU, which is why only the linux gcc leg went red there.\n#\n# Kept unmixed, the feature builds and runs on gcc 16.1.0 and llvm 22.1.8\n# alike, so this member needs no toolchain pin and runs on both linux legs.\n[package]\nname = \"ftxui-module-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nftxui = { version = \"7.0.3\", features = [\"modules\"] }\n", + "files": [ + { + "path": "tests/examples/ftxui-module/tests/sub_component.cpp", + "text": "// ftxui.component alone: Component/Event/Button and event routing.\nimport std;\nimport ftxui.component;\n\nint main() {\n using namespace ftxui;\n int clicked = 0;\n Component button = Button(\"go\", [&] { ++clicked; });\n\n Component container = Container::Vertical({button});\n if (!container->OnEvent(Event::Return)) return 1;\n if (clicked != 1) return 2;\n\n const Event a = Event::Character('a');\n if (!a.is_character() || a.character() != \"a\") return 3;\n if (Event::Return == a) return 4;\n\n std::println(\"clicked={}\", clicked);\n std::println(\"ftxui.component OK\");\n return 0;\n}\n" + }, + { + "path": "tests/examples/ftxui-module/tests/sub_dom.cpp", + "text": "// ftxui.dom alone: elements + Render + Screen, no umbrella, no headers.\nimport std;\nimport ftxui.dom;\n\nint main() {\n using namespace ftxui;\n Element document = vbox({text(\"dom-only\"), separator(), text(\"row2\")});\n auto screen = Screen::Create(Dimension::Fit(document), Dimension::Fit(document));\n Render(screen, document);\n const std::string rendered = screen.ToString();\n std::println(\"dom rendered: [{}]\", rendered);\n if (rendered.find(\"dom-only\") == std::string::npos) return 1;\n if (rendered.find(\"row2\") == std::string::npos) return 2;\n std::println(\"ftxui.dom OK\");\n return 0;\n}\n" + }, + { + "path": "tests/examples/ftxui-module/tests/sub_screen.cpp", + "text": "// ftxui.screen alone: Screen/Pixel/Color/Terminal and the _rgb literal.\nimport std;\nimport ftxui.screen;\n\nint main() {\n using namespace ftxui;\n auto screen = Screen::Create(Dimensions{4, 2});\n screen.PixelAt(0, 0).character = \"X\";\n screen.PixelAt(3, 1).character = \"Y\";\n const std::string s = screen.ToString();\n std::println(\"screen: [{}]\", s);\n if (s.find('X') == std::string::npos) return 1;\n if (s.find('Y') == std::string::npos) return 2;\n\n const Color red = Color::Red;\n const Color rgb = Color::RGB(1, 2, 3);\n if (red == rgb) return 3;\n using namespace ftxui::literals;\n const Color lit = 0x0102ff_rgb;\n if (lit == red) return 4;\n\n if (string_width(\"abc\") != 3) return 5;\n if (to_string(to_wstring(std::string(\"mcpp\"))) != \"mcpp\") return 6;\n std::println(\"ftxui.screen OK\");\n return 0;\n}\n" + }, + { + "path": "tests/examples/ftxui-module/tests/sub_util.cpp", + "text": "// ftxui.util alone: Ref/ConstRef/StringRef and AutoReset.\nimport std;\nimport ftxui.util;\n\nint main() {\n using namespace ftxui;\n int backing = 7;\n Ref r(&backing);\n *r = 9;\n if (backing != 9) return 1;\n\n ConstRef cr(5);\n if (*cr != 5) return 2;\n\n std::string text = \"abc\";\n StringRef sr(&text);\n *sr = \"xyz\";\n if (text != \"xyz\") return 3;\n\n int guarded = 1;\n { AutoReset reset(&guarded, 42); if (guarded != 42) return 4; }\n if (guarded != 1) return 5;\n\n std::println(\"ftxui.util OK\");\n return 0;\n}\n" + }, + { + "path": "tests/examples/ftxui-module/tests/umbrella.cpp", + "text": "// `import ftxui;` — the umbrella module — plus a real dom render.\nimport std;\nimport ftxui;\n\nint main() {\n using namespace ftxui;\n Element document = hbox({text(\"compat\"), separator(), text(\"ftxui\")});\n auto screen = Screen::Create(Dimension::Fit(document), Dimension::Fit(document));\n Render(screen, document);\n const std::string rendered = screen.ToString();\n std::println(\"rendered: [{}]\", rendered);\n if (rendered.find(\"compat\") == std::string::npos) return 1;\n if (rendered.find(\"ftxui\") == std::string::npos) return 2;\n // the separator must actually have drawn something between them\n if (rendered.find(\"compat\") > rendered.find(\"ftxui\")) return 3;\n std::println(\"umbrella OK\");\n return 0;\n}\n" + } + ] + }, + { + "project": "galay", + "text": "# Public Galay Form-A package consumer. The package is Unix-only in v5.0.2;\n# Windows still builds the no-op test below without resolving the dependency.\n[package]\nname = \"galay-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(unix)'.dependencies.gzj-creator]\ngalay = \"5.0.2\"\n\n[target.'cfg(unix)'.build]\ncxxflags = [\"-DHAVE_GALAY=1\"]\n\n# A member-level redirect replaces the workspace root's compat redirect, so\n# this test exercises the public package descriptor from this checkout.\n[indices]\ngzj-creator = { path = \"../../..\" }\n", + "files": [ + { + "path": "tests/examples/galay/tests/modules.cpp", + "text": "// Default Galay package surface: both named modules and linked kernel code.\n#ifdef HAVE_GALAY\n\nimport std;\nimport galay.utils;\nimport galay.kernel;\n\nint main() {\n using galay::kernel::Buffer;\n using galay::kernel::Host;\n using galay::kernel::IPType;\n using galay::utils::Base64Util;\n using galay::utils::StringUtils;\n\n const std::string encoded = Base64Util::Base64Encode(\"galay\");\n if (encoded != \"Z2FsYXk=\") return 1;\n if (Base64Util::Base64Decode(encoded) != \"galay\") return 2;\n\n const auto pieces = StringUtils::split(\"galay:mcpp:index\", ':');\n if (pieces != std::vector{\"galay\", \"mcpp\", \"index\"}) return 3;\n\n Buffer buffer(\"galay\", 5);\n if (buffer.length() != 5 || buffer.toString() != \"galay\") return 4;\n Buffer copy = buffer.clone();\n buffer.clear();\n if (copy.toString() != \"galay\" || buffer.length() != 0) return 5;\n\n const Host host(IPType::IPV4, \"127.0.0.1\", 9080);\n if (!host.valid() || host.ip() != \"127.0.0.1\" || host.port() != 9080) return 6;\n\n return 0;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "gio", + "text": "# gnome.gio — files, streams, D-Bus, and the GListModel pango needs.\n#\n# ⚠️ NAMES gio ALONE. glib, gobject and gmodule arrive transitively, and naming\n# any of them here would be `requested as both a version dep and a path dep`.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"gio-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\ngio = \"2.82.5\"\n", + "files": [ + { + "path": "tests/examples/gio/tests/gio.cpp", + "text": "// gnome.gio — exercised rather than merely linked.\n//\n// WHAT THIS ASSERTS AND WHY\n//\n// gio is 815 objects assembled from four different kinds of input, and each\n// kind fails a different way. A test that only said `#include ` and\n// linked would pass with any three of the four missing:\n//\n// upstream C gio/*.c — a missing one is an undefined reference,\n// which the linker DOES catch. Cheapest case.\n// VENDORED subtrees xdgmime/, inotify/, subprojects/gvdb/. Absent, gio\n// still links: g_content_type_guess returns\n// \"application/octet-stream\", the file monitor falls\n// back to polling, and GResource is never touched by\n// anything but glib-compile-resources. All three\n// degrade SILENTLY, so each is checked by name below.\n// CHECKED-IN CODEGEN mcpp/generated/*.c — 15,392 lines of gdbus-codegen\n// output that no build step here produces. Checked in\n// the FORK's own test, which can name the internal\n// symbol; regenerated and diffed by the fork's CI.\n// GENERATED HERE gioenumtypes.{h,c}, 82 GTypes from a reimplemented\n// glib-mkenums. ⭐ This is the one that already shipped\n// a bug once, in gnome.gobject 2.82.5: the macro names\n// were wrong (`G_UNICODE_TYPE_TYPE` for `G_TYPE_UNICODE_\n// TYPE`) and it compiled, linked and passed a test that\n// checked the FUNCTION and the NICK but never the MACRO.\n// So this file checks the macro, the nick, AND whether\n// the type is an enum or a flags — three independent\n// outputs of the same generator, which is what it takes.\n//\n// Nothing here needs a session bus, a network, or a mime database.\n\n#ifdef __linux__\n\n// ⚠️ NO extern \"C\" WRAPPER, and adding one BREAKS THIS UNDER libc++.\n//\n// gio decorates every header with G_BEGIN_DECLS/G_END_DECLS, which IS\n// `extern \"C\" {`, so a wrapper is redundant. It is also harmful: gio.h reaches\n// and , and libc++ routes those through\n// /, which define TEMPLATES. Inside an extern \"C\" block that\n// is `templates must have C++ linkage`, dozens of times, against a standard\n// header this test never names. libstdc++ does not route them that way, so the\n// gcc leg is green and the llvm leg is a wall.\n//\n// The rule: wrap a C header ONLY if it has no extern \"C\" of its own.\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-62s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// ── GFile: paths, URIs, and the round trip between them ─────────────────────\nvoid test_gfile()\n{\n GFile *f = g_file_new_for_path(\"/etc/hostname\");\n char *base = g_file_get_basename(f);\n char *uri = g_file_get_uri(f);\n check(base && std::strcmp(base, \"hostname\") == 0, \"GFile: basename of /etc/hostname\");\n check(uri && std::strcmp(uri, \"file:///etc/hostname\") == 0, \"GFile: to URI\");\n\n GFile *back = g_file_new_for_uri(uri);\n check(g_file_equal(f, back), \"GFile: URI round trip compares equal\");\n\n GFile *parent = g_file_get_parent(f);\n char *pp = parent ? g_file_get_path(parent) : nullptr;\n check(pp && std::strcmp(pp, \"/etc\") == 0, \"GFile: parent\");\n\n g_free(pp);\n g_free(base);\n g_free(uri);\n g_clear_object(&parent);\n g_clear_object(&back);\n g_object_unref(f);\n}\n\n// ── GListModel: the interface pango's PangoFontMap implements ───────────────\n//\n// ⭐ THIS IS WHY gio IS HERE. pango was blocked on exactly this symbol, so the\n// check is not \"gio works\" but \"the thing pango needs is a live GType\".\nvoid test_glistmodel()\n{\n check(G_TYPE_IS_INTERFACE(G_TYPE_LIST_MODEL), \"GListModel is a registered interface\");\n\n GListStore *store = g_list_store_new(G_TYPE_FILE);\n GFile *a = g_file_new_for_path(\"/a\");\n GFile *b = g_file_new_for_path(\"/b\");\n g_list_store_append(store, a);\n g_list_store_append(store, b);\n\n GListModel *m = G_LIST_MODEL(store);\n check(g_list_model_get_n_items(m) == 2, \"GListModel: n_items after two appends\");\n check(g_list_model_get_item_type(m) == G_TYPE_FILE, \"GListModel: item type\");\n\n GFile *got = static_cast(g_list_model_get_item(m, 1));\n char *p = got ? g_file_get_path(got) : nullptr;\n check(p && std::strcmp(p, \"/b\") == 0, \"GListModel: get_item(1) is the second file\");\n\n g_free(p);\n g_clear_object(&got);\n g_object_unref(a);\n g_object_unref(b);\n g_object_unref(store);\n}\n\n// ── gioenumtypes: 82 GTypes from the reimplemented glib-mkenums ─────────────\n//\n// Three DIFFERENT outputs of the generator, because they fail independently:\n//\n// the MACRO NAME mkenums derives it from the TYPE name, not the\n// enumerator prefix. Getting that backwards is what\n// shipped in gobject 2.82.5. A wrong macro is a compile\n// error HERE, which is the whole point of naming it.\n// the NICK public API — g_flags_get_value_by_nick reads it — and\n// gio overrides it seventeen times. Derived,\n// G_CONVERTER_NO_FLAGS would be \"no-flags\"; upstream says\n// \"none\".\n// ENUM vs FLAGS decided ONLY by a `/*< flags >*/` comment. gio has\n// exactly seven. GMountMountFlags carries it;\n// GConverterFlags does NOT, despite the name — so\n// upstream registers it as an ENUM, and a scanner that\n// guessed from the name would disagree with upstream's ABI.\nvoid test_enumtypes()\n{\n check(G_TYPE_IS_ENUM(G_TYPE_FILE_TYPE), \"gioenumtypes: G_TYPE_FILE_TYPE is an enum\");\n check(G_TYPE_IS_ENUM(G_TYPE_SOCKET_FAMILY), \"gioenumtypes: G_TYPE_SOCKET_FAMILY is an enum\");\n check(G_TYPE_IS_FLAGS(G_TYPE_MOUNT_MOUNT_FLAGS),\n \"gioenumtypes: GMountMountFlags has /*< flags >*/ -> FLAGS\");\n check(G_TYPE_IS_ENUM(G_TYPE_CONVERTER_FLAGS),\n \"gioenumtypes: GConverterFlags has NO annotation -> ENUM\");\n\n GEnumClass *fc = G_ENUM_CLASS(g_type_class_ref(G_TYPE_FILE_TYPE));\n GEnumValue *v = g_enum_get_value(fc, G_FILE_TYPE_DIRECTORY);\n check(v && std::strcmp(v->value_nick, \"directory\") == 0, \"gioenumtypes: derived nick\");\n check(v && std::strcmp(v->value_name, \"G_FILE_TYPE_DIRECTORY\") == 0,\n \"gioenumtypes: value name\");\n g_type_class_unref(fc);\n\n GEnumClass *cc = G_ENUM_CLASS(g_type_class_ref(G_TYPE_CONVERTER_FLAGS));\n GEnumValue *nf = g_enum_get_value(cc, G_CONVERTER_NO_FLAGS);\n check(nf && std::strcmp(nf->value_nick, \"none\") == 0,\n \"gioenumtypes: /*< nick=none >*/ overrides the derived \\\"no-flags\\\"\");\n g_type_class_unref(cc);\n\n GFlagsClass *mc = G_FLAGS_CLASS(g_type_class_ref(G_TYPE_MOUNT_MOUNT_FLAGS));\n check(g_flags_get_first_value(mc, G_MOUNT_MOUNT_NONE) != nullptr\n || G_MOUNT_MOUNT_NONE == 0,\n \"gioenumtypes: flags class is usable\");\n g_type_class_unref(mc);\n}\n\n// ── GConverter over zlib: compat.zlib is a real dependency, not a note ──────\nvoid test_zlib_converter()\n{\n const std::string plain(4096, 'x');\n\n GConverter *cz = G_CONVERTER(g_zlib_compressor_new(G_ZLIB_COMPRESSOR_FORMAT_ZLIB, -1));\n GInputStream *src = g_memory_input_stream_new_from_data(plain.data(),\n static_cast(plain.size()),\n nullptr);\n GInputStream *comp = g_converter_input_stream_new(src, cz);\n\n // Both sides take GZlibCompressorFormat — there is no decompressor enum.\n GConverter *dz = G_CONVERTER(g_zlib_decompressor_new(G_ZLIB_COMPRESSOR_FORMAT_ZLIB));\n GInputStream *back = g_converter_input_stream_new(comp, dz);\n\n char buf[8192];\n gsize got = 0;\n GError *err = nullptr;\n gboolean ok = g_input_stream_read_all(back, buf, sizeof buf, &got, nullptr, &err);\n\n check(ok && got == plain.size() && std::memcmp(buf, plain.data(), got) == 0,\n \"zlib: compress -> decompress reproduces 4096 bytes\");\n if (err) {\n g_error_free(err);\n }\n g_object_unref(back);\n g_object_unref(dz);\n g_object_unref(comp);\n g_object_unref(src);\n g_object_unref(cz);\n}\n\n// ── GDBus introspection, with no bus ────────────────────────────────────────\nvoid test_dbus_introspection()\n{\n static const char xml[] =\n \"\"\n \" \"\n \" \"\n \" \"\n \" \"\n \" \"\n \" \"\n \" \"\n \"\";\n\n GError *err = nullptr;\n GDBusNodeInfo *node = g_dbus_node_info_new_for_xml(xml, &err);\n check(node != nullptr, \"GDBus: introspection XML parses\");\n\n GDBusInterfaceInfo *iface = node ? g_dbus_node_info_lookup_interface(node, \"org.example.Echo\")\n : nullptr;\n GDBusMethodInfo *m = iface ? g_dbus_interface_info_lookup_method(iface, \"Speak\") : nullptr;\n check(m && m->in_args && m->in_args[0] && std::strcmp(m->in_args[0]->signature, \"s\") == 0,\n \"GDBus: the parsed method's argument signature\");\n\n if (node) {\n g_dbus_node_info_unref(node);\n }\n if (err) {\n g_error_free(err);\n }\n}\n\n// ── gvdb: the reader upstream vendors under subprojects/ ────────────────────\n//\n// Without gvdb-reader.c gio does not fail to link — nothing in the library\n// calls it except GResource and GSettings' keyfile path, and both are lazy. So\n// the check is to hand GResource a blob that is definitely NOT a gvdb file and\n// require a CLEAN REFUSAL: a NULL return with GError set. That answer can only\n// come from gvdb_table_new_from_bytes, which is the file in question.\nvoid test_gvdb_reader()\n{\n static const char junk[] = \"this is not a gvdb table, not even slightly\";\n GBytes *b = g_bytes_new_static(junk, sizeof junk - 1);\n GError *err = nullptr;\n GResource *r = g_resource_new_from_data(b, &err);\n\n check(r == nullptr && err != nullptr,\n \"gvdb: a non-gvdb blob is refused with a GError, not a crash\");\n\n if (r) {\n g_resource_unref(r);\n }\n if (err) {\n g_error_free(err);\n }\n g_bytes_unref(b);\n}\n\n// ── xdgmime: the freedesktop mime reader behind g_content_type_* ────────────\n//\n// ⚠️ WHAT IS NOT ASSERTED: the answer for a given filename. That depends on\n// /usr/share/mime, which a CI container may not have — and a test that demanded\n// \"text/plain\" would be checking the RUNNER's mime database, not this build.\n// What IS asserted is the part xdgmime always answers: that the call returns a\n// content type at all, and that the two conversion helpers are mutual inverses.\nvoid test_content_type()\n{\n char *t = g_content_type_guess(\"notes.txt\", nullptr, 0, nullptr);\n check(t != nullptr && *t != '\\0', \"xdgmime: g_content_type_guess answers\");\n\n char *mime = g_content_type_get_mime_type(\"text/plain\");\n check(mime && std::strcmp(mime, \"text/plain\") == 0, \"xdgmime: mime type round trip\");\n\n check(g_content_type_is_a(\"text/plain\", \"text/plain\") == TRUE,\n \"xdgmime: a type is a subtype of itself\");\n\n g_free(mime);\n g_free(t);\n}\n\n// ── inotify: the Linux file-monitor backend ─────────────────────────────────\n//\n// Absent, `g_file_monitor_directory` still succeeds — GLocalFileMonitor falls\n// back to a polling implementation and nothing reports the difference. So the\n// check reads the TYPE NAME of what came back, which is the only place the two\n// are distinguishable.\nvoid test_inotify_backend()\n{\n GError *err = nullptr;\n GFile *dir = g_file_new_for_path(\"/tmp\");\n GFileMonitor *mon = g_file_monitor_directory(dir, G_FILE_MONITOR_NONE, nullptr, &err);\n\n check(mon != nullptr, \"GFileMonitor: /tmp can be watched\");\n if (mon) {\n const char *n = G_OBJECT_TYPE_NAME(mon);\n std::printf(\" (backend: %s)\\n\", n);\n check(n && std::strstr(n, \"Inotify\") != nullptr,\n \"inotify/: the backend is GInotifyFileMonitor, not the polling fallback\");\n g_file_monitor_cancel(mon);\n g_object_unref(mon);\n }\n if (err) {\n g_error_free(err);\n }\n g_object_unref(dir);\n}\n\n// ── networking types, without touching the network ─────────────────────────\nvoid test_networking()\n{\n GInetAddress *a = g_inet_address_new_from_string(\"127.0.0.1\");\n char *s = a ? g_inet_address_to_string(a) : nullptr;\n check(s && std::strcmp(s, \"127.0.0.1\") == 0, \"GInetAddress: parse and print\");\n check(a && g_inet_address_get_is_loopback(a), \"GInetAddress: 127.0.0.1 is loopback\");\n\n GSocketAddress *sa = a ? g_inet_socket_address_new(a, 8080) : nullptr;\n check(sa && g_inet_socket_address_get_port(G_INET_SOCKET_ADDRESS(sa)) == 8080,\n \"GInetSocketAddress: port\");\n\n check(g_application_id_is_valid(\"org.example.App\"), \"GApplication: a valid id\");\n check(!g_application_id_is_valid(\"nope\"), \"GApplication: a bare word is not an id\");\n\n g_clear_object(&sa);\n g_free(s);\n g_clear_object(&a);\n}\n\n// ── the async machinery: GTask through a GMainContext ──────────────────────\nstruct AsyncState {\n GMainLoop *loop = nullptr;\n gssize got = -1;\n char buf[64] = {};\n};\n\nvoid on_read(GObject *src, GAsyncResult *res, gpointer user)\n{\n AsyncState *st = static_cast(user);\n st->got = g_input_stream_read_finish(G_INPUT_STREAM(src), res, nullptr);\n g_main_loop_quit(st->loop);\n}\n\nvoid test_async()\n{\n static const char payload[] = \"async\";\n AsyncState st;\n st.loop = g_main_loop_new(nullptr, FALSE);\n\n GInputStream *in = g_memory_input_stream_new_from_data(payload, sizeof payload - 1, nullptr);\n g_input_stream_read_async(in, st.buf, sizeof st.buf, G_PRIORITY_DEFAULT, nullptr,\n on_read, &st);\n g_main_loop_run(st.loop);\n\n check(st.got == 5 && std::memcmp(st.buf, payload, 5) == 0,\n \"GTask: read_async completes through the main loop\");\n\n g_object_unref(in);\n g_main_loop_unref(st.loop);\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"gio %d.%d.%d\\n\\n\", glib_major_version, glib_minor_version, glib_micro_version);\n\n test_gfile();\n test_glistmodel();\n test_enumtypes();\n test_zlib_converter();\n test_dbus_introspection();\n test_gvdb_reader();\n test_content_type();\n test_inotify_backend();\n test_networking();\n test_async();\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \n\nint main()\n{\n std::printf(\"gio: this package builds the Unix half of gio; skipping.\\n\");\n return 0;\n}\n\n#endif\n" + }, + { + "path": "tests/examples/gio/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file consumes gnome.gio through its headers; this one consumes\n// the same package through its module, so both doors are checked from the\n// OUTSIDE — which is what an index example is for.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE, and that is GCC's rule rather than a choice\n// here. A TU that imports the module AND textually includes a glib header\n// reaches twice — once through the module's global fragment, once\n// directly — and the same `struct tm` from the same file becomes two entities:\n//\n// error: conflicting declaration 'struct tm'\n// note: previous declaration as 'struct tm' (of module gnome.glib)\n//\n// So a consumer picks ONE route, and which one is decided by macros: a module\n// cannot carry them, and glib's are half its API. Code using `G_DEFINE_TYPE`,\n// `G_OBJECT` or `G_TYPE_*` takes the header route. Code using the FUNCTION API\n// — most of gio, and much of glib — imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.gio; // re-exports gnome.glib, gnome.gobject, gnome.gmodule\n\n#include \n#include \n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n std::printf(\"import gnome.gio\\n\\n\");\n\n // ⭐ THE SYMBOL pango WAS BLOCKED ON, through the module route.\n // G_TYPE_LIST_MODEL is a macro; g_list_model_get_type() is what it wraps.\n const GType lm = g_list_model_get_type();\n check(lm != 0, \"g_list_model_get_type() — the interface is registered\");\n check(std::strcmp(g_type_name(lm), \"GListModel\") == 0, \"…and it is GListModel\");\n\n // GFile: gio's function API needs no macros at all, which is the case the\n // module route is actually for.\n GFile *f = g_file_new_for_path(\"/etc/hostname\");\n char *base = g_file_get_basename(f);\n char *uri = g_file_get_uri(f);\n check(base && std::strcmp(base, \"hostname\") == 0, \"GFile: basename\");\n check(uri && std::strcmp(uri, \"file:///etc/hostname\") == 0, \"GFile: to URI\");\n g_free(base);\n g_free(uri);\n\n // A GListStore holding GFiles — g_file_get_type() rather than G_TYPE_FILE.\n GListStore *store = g_list_store_new(g_file_get_type());\n g_list_store_append(store, f);\n check(g_list_model_get_n_items(reinterpret_cast(store)) == 1,\n \"GListModel: one item after append\");\n g_object_unref(store);\n g_object_unref(f);\n\n // Streams, and the zlib converter — compat.zlib is a real dependency.\n const char plain[] = \"the module route needs no macros\";\n GInputStream *src = g_memory_input_stream_new_from_data(plain, sizeof plain - 1, nullptr);\n GConverter *cz = reinterpret_cast(\n g_zlib_compressor_new(G_ZLIB_COMPRESSOR_FORMAT_ZLIB, -1));\n GInputStream *comp = g_converter_input_stream_new(src, cz);\n GConverter *dz = reinterpret_cast(\n g_zlib_decompressor_new(G_ZLIB_COMPRESSOR_FORMAT_ZLIB));\n GInputStream *back = g_converter_input_stream_new(comp, dz);\n char buf[128] = {};\n gsize got = 0;\n g_input_stream_read_all(back, buf, sizeof buf, &got, nullptr, nullptr);\n check(got == sizeof plain - 1 && std::memcmp(buf, plain, got) == 0,\n \"zlib: compress -> decompress round trip\");\n g_object_unref(back); g_object_unref(dz);\n g_object_unref(comp); g_object_unref(src); g_object_unref(cz);\n\n // D-Bus introspection, with no bus.\n static const char xml[] =\n \"\"\n \"\"\n \"\";\n GDBusNodeInfo *node = g_dbus_node_info_new_for_xml(xml, nullptr);\n check(node != nullptr, \"GDBus: introspection XML parses\");\n if (node) g_dbus_node_info_unref(node);\n\n // The three siblings arrived through gnome.gio, matching gio.h's includes.\n char *s = g_strdup(\"glib\"); // gnome.glib\n check(s && std::strcmp(s, \"glib\") == 0, \"g_strdup — gnome.glib re-exported\");\n g_free(s);\n check(g_object_get_type() != 0, \"g_object_get_type — gnome.gobject re-exported\");\n check(g_module_supported(), \"g_module_supported — gnome.gmodule re-exported\");\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + } + ] + }, + { + "project": "gl", + "text": "# GL test project — the render half of the graphics stack.\n#\n# THIS MEMBER DECLARES ITS OWN [indices], and has to. The workspace root maps\n# `compat` to this checkout, and an index is looked up BY NAMESPACE, so\n# `freedesktop.*` would resolve against the published index rather than the\n# packages under test. A member-level table REPLACES the root's.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"gl-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# EGL plus ONE GL flavour, which is the only correct shape — and this member\n# used to name all three, which is how the reason was found.\n#\n# libGLESv2, libGLESv1_CM and libOpenGL are three FRONT ENDS onto the same\n# libGLdispatch, and their symbol sets overlap: `glClear` is exported by all\n# three. Link two of them and the name binds to whichever the loader mapped\n# first — measured here, `glClear` resolved to libGLESv1_CM.so.1 in a program\n# that meant GLESv2, silently and with nothing to warn about it. That is the\n# same class of ambiguity soname reuse creates, and it is avoided the same way:\n# pick one.\n#\n# A project picks the flavour it writes against. A Wayland compositor is GLESv2\n# (it takes a context through EGL and draws with shaders), so that is what this\n# member is. libGLESv1_CM and libOpenGL have their own tests in the fork, where\n# each is the only GL library in the process.\n[target.'cfg(linux)'.dependencies.freedesktop]\negl = \"1.7.0\"\nglesv2 = \"1.7.0\"\n\n# GBM is how a headless test gets a real context without a display server.\n[target.'cfg(linux)'.dependencies.compat]\nlibgbm = \"25.0.7\"\n", + "files": [ + { + "path": "tests/examples/gl/tests/gl.cpp", + "text": "// The render half of the graphics stack: EGL hands out a context, GLES2 draws.\n//\n// Every FUNCTION and TYPE below comes from a module — `import khronos.egl;`\n// and `import khronos.glesv2;`. The headers are included for the `EGL_*` and\n// `GL_*` CONSTANTS only, which are macros and cannot be exported by a module.\n//\n// ─────────────────────────────────────────────────────────────────────────\n// WHY THIS TEST EXISTS AND WHAT IT IS FOR\n//\n// \"libGLESv2 exports 358 symbols\" is not the claim worth making — a library\n// built from the wrong dispatch table exports about that many too. The claim\n// is that a program can get a context and DRAW, and the only honest way to\n// check it is to draw and read the pixel back.\n//\n// So the file has two halves:\n//\n// * assertions that hold on ANY machine, including a CI runner with no GPU:\n// the modules carry the API, the libraries are the ones this index built\n// rather than the ecosystem payload's same-soname copies, and all of them\n// route through ONE libGLdispatch;\n// * a real render, gated on a DRM device being present (MCPP_RUN_DRM_DEVICE),\n// because a runner has no /dev/dri at all.\n//\n// The gate is opt-in for the same reason compat.libdrm's test gates device\n// access: a machine without a GPU is not a defect in these packages.\n//\n// ─────────────────────────────────────────────────────────────────────────\n// THE PART THAT IS EASY TO GET FALSELY GREEN\n//\n// `xim:libglvnd` ships libEGL.so.1, libGLESv2.so.2, libOpenGL.so.0 and\n// libGLdispatch.so.0 under exactly these sonames. Only one library per soname\n// is ever mapped and nothing warns about the loser, so a test that merely calls\n// GL can pass while none of this index's builds are loaded. dladdr pins it.\n//\n// Note this is a CONSUMER, which is what makes that check meaningful here: the\n// fork's own in-package tests link the package's OBJECTS, so their binaries\n// define glClear themselves and dladdr reports the executable. Measured.\n\n#ifdef __linux__\n\n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n\nimport khronos.egl;\nimport khronos.glesv2;\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// dlsym rather than `&glClear`: taking the address of an imported function\n// yields the caller's PLT stub, so dladdr would report this executable.\nstd::string object_of(const char *symbol)\n{\n void *sym = ::dlsym(RTLD_DEFAULT, symbol);\n Dl_info info{};\n if (sym == nullptr || ::dladdr(sym, &info) == 0 || info.dli_fname == nullptr) {\n return {};\n }\n return info.dli_fname;\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The modules carry the API ─────────────────────────────────────\n EGLDisplay none = EGL_NO_DISPLAY;\n GLenum err = GL_NO_ERROR;\n check(none == EGL_NO_DISPLAY && err == GL_NO_ERROR,\n \"the two modules' types and the headers' macros agree\");\n\n // ── 2. Each library is THIS index's build ────────────────────────────\n struct { const char *symbol; const char *library; } from[] = {\n {\"eglInitialize\", \"libEGL.so.1\"},\n {\"glClear\", \"libGLESv2.so.2\"},\n {\"__glDispatchMakeCurrent\", \"libGLdispatch.so.0\"},\n };\n for (auto &f : from) {\n const std::string where = object_of(f.symbol);\n check(!where.empty(), (std::string(\"dladdr locates \") + f.library).c_str());\n if (where.empty()) {\n continue;\n }\n std::printf(\" %-24s <- %s\\n\", f.symbol, where.c_str());\n check(where.find(\"xim-x-libglvnd\") == std::string::npos,\n \"…and it is not the ecosystem payload's copy\");\n }\n\n // ── 3. EGL and GLES2 share ONE dispatch ──────────────────────────────\n // This is what makes the family a family. If libEGL came from one build\n // and libGLESv2 from another, both would work in isolation and a context\n // made by one would dispatch through the other's table — the failure mode\n // GLVND's ABI version exists to catch at runtime rather than at build time.\n {\n const std::string a = object_of(\"eglInitialize\");\n const std::string b = object_of(\"glClear\");\n const std::string d = object_of(\"__glDispatchMakeCurrent\");\n check(!a.empty() && !b.empty() && !d.empty() && a != b,\n \"libEGL and libGLESv2 are distinct objects\");\n // Both must be reachable from the same build tree, and there must be\n // exactly one libGLdispatch — which is what the single `d` proves.\n check(!d.empty(), \"exactly one libGLdispatch answers for the process\");\n }\n\n // ── 4. ONE GL flavour, and the check that it is the right one ────────\n //\n // libGLESv2, libGLESv1_CM and libOpenGL are three front ends onto the same\n // dispatch and their symbol sets overlap — all three export `glClear`. An\n // earlier version of this member depended on all three, and `glClear`\n // resolved to libGLESv1_CM.so.1 in a program that meant GLESv2. Nothing\n // warned; the loader simply took whichever it had mapped first.\n //\n // The assertion has to be about ATTRIBUTION, not presence, and that is\n // worth spelling out because the obvious version does not work:\n // `dlsym(RTLD_DEFAULT, \"glMatrixMode\") == nullptr` looks like it would\n // prove GLESv1 is not linked, and it never fails — libGLdispatch.so.0\n // exports the FULL GL surface (measured: glMatrixMode is in it), because it\n // is the complete dispatch table and the per-flavour libraries are thin\n // front ends that select a subset of it.\n //\n // So the check is which OBJECT answers: `glClear` must come from the\n // flavour front end this project asked for, not from another one and not\n // from the dispatch underneath.\n {\n const std::string where = object_of(\"glClear\");\n std::printf(\" glClear resolves in: %s\\n\", where.c_str());\n check(where.find(\"libGLESv2.so\") != std::string::npos,\n \"glClear resolves in libGLESv2 — the flavour this project asked for\");\n }\n\n // ── 5. A real render, opt-in ─────────────────────────────────────────\n if (std::getenv(\"MCPP_RUN_DRM_DEVICE\") == nullptr) {\n std::puts(\"\\n (the render is opt-in: set MCPP_RUN_DRM_DEVICE=1 on a \"\n \"machine with /dev/dri)\");\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n }\n\n std::puts(\"\\n-- GBM device -> EGL context -> glClear -> glReadPixels --\");\n\n int fd = -1;\n for (const char *node : {\"/dev/dri/renderD128\", \"/dev/dri/card0\"}) {\n fd = ::open(node, O_RDWR);\n if (fd >= 0) {\n std::printf(\" node: %s\\n\", node);\n break;\n }\n }\n if (fd < 0) {\n std::puts(\" MCPP_RUN_DRM_DEVICE set but no DRM node opened; skipping\");\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n }\n\n gbm_device *gbm = gbm_create_device(fd);\n check(gbm != nullptr, \"gbm_create_device on a real node\");\n if (gbm == nullptr) {\n ::close(fd);\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n }\n\n EGLDisplay dpy = eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR, gbm, nullptr);\n check(dpy != EGL_NO_DISPLAY, \"eglGetPlatformDisplay(EGL_PLATFORM_GBM_KHR)\");\n\n EGLint major = 0, minor = 0;\n check(dpy != EGL_NO_DISPLAY && eglInitialize(dpy, &major, &minor) == EGL_TRUE,\n \"eglInitialize\");\n if (dpy != EGL_NO_DISPLAY) {\n std::printf(\" EGL %d.%d, vendor %s\\n\", major, minor,\n eglQueryString(dpy, EGL_VENDOR));\n }\n\n // A SURFACELESS context, and that choice is not incidental.\n //\n // The obvious headless recipe — a pbuffer — does not work on GBM: measured,\n // `eglChooseConfig` with `EGL_SURFACE_TYPE = EGL_PBUFFER_BIT` finds nothing,\n // because GBM's surfaces come from `gbm_surface_create` and the platform\n // advertises no pbuffer configs at all. `EGL_KHR_surfaceless_context` is\n // what the platform does support, and it is also what a compositor uses\n // before it has anything to present to: a context with no default\n // framebuffer, rendering into an FBO.\n check(eglBindAPI(EGL_OPENGL_ES_API) == EGL_TRUE, \"eglBindAPI(EGL_OPENGL_ES_API)\");\n\n EGLint cfg_attrs[] = {\n EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,\n EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8,\n EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,\n EGL_NONE,\n };\n EGLConfig cfg{};\n EGLint n = 0;\n check(eglChooseConfig(dpy, cfg_attrs, &cfg, 1, &n) == EGL_TRUE && n > 0,\n \"eglChooseConfig found an ES2-renderable config\");\n\n EGLint ctx_attrs[] = {EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE};\n EGLContext ctx = eglCreateContext(dpy, cfg, EGL_NO_CONTEXT, ctx_attrs);\n check(ctx != EGL_NO_CONTEXT, \"eglCreateContext\");\n\n if (ctx != EGL_NO_CONTEXT) {\n check(eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, ctx) == EGL_TRUE,\n \"eglMakeCurrent with no surface (EGL_KHR_surfaceless_context)\");\n\n // The FBO the surfaceless context renders into. This is the piece a\n // pbuffer would have provided.\n GLuint rb = 0, fbo = 0;\n glGenRenderbuffers(1, &rb);\n glBindRenderbuffer(GL_RENDERBUFFER, rb);\n glRenderbufferStorage(GL_RENDERBUFFER, GL_RGBA8, 64, 64);\n glGenFramebuffers(1, &fbo);\n glBindFramebuffer(GL_FRAMEBUFFER, fbo);\n glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,\n GL_RENDERBUFFER, rb);\n check(glCheckFramebufferStatus(GL_FRAMEBUFFER) == GL_FRAMEBUFFER_COMPLETE,\n \"a 64x64 RGBA8 framebuffer is complete\");\n\n // THE ASSERTION THIS WHOLE FILE IS FOR. Everything above can pass on a\n // stack that cannot draw; this cannot.\n std::printf(\" GL_VERSION %s\\n\", glGetString(GL_VERSION));\n std::printf(\" GL_RENDERER %s\\n\", glGetString(GL_RENDERER));\n check(glGetString(GL_VERSION) != nullptr,\n \"glGetString(GL_VERSION) answers through the module\");\n\n glClearColor(0.25f, 0.5f, 0.75f, 1.0f);\n glClear(GL_COLOR_BUFFER_BIT);\n glFinish();\n\n unsigned char px[4] = {0, 0, 0, 0};\n glReadPixels(32, 32, 1, 1, GL_RGBA, GL_UNSIGNED_BYTE, px);\n std::printf(\" pixel read back: %u %u %u %u (wanted ~64 128 191 255)\\n\",\n px[0], px[1], px[2], px[3]);\n\n // One quantisation step of slack: the driver may round the float.\n auto near = [](unsigned char got, int want) {\n return got >= want - 2 && got <= want + 2;\n };\n check(near(px[0], 64) && near(px[1], 128) && near(px[2], 191) && near(px[3], 255),\n \"the pixel that came back is the colour that was cleared\");\n\n check(glGetError() == GL_NO_ERROR, \"no GL error along the way\");\n\n glDeleteFramebuffers(1, &fbo);\n glDeleteRenderbuffers(1, &rb);\n eglMakeCurrent(dpy, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);\n }\n\n if (ctx != EGL_NO_CONTEXT) { eglDestroyContext(dpy, ctx); }\n if (dpy != EGL_NO_DISPLAY) { eglTerminate(dpy); }\n gbm_device_destroy(gbm);\n ::close(fd);\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "glad", + "text": "[package]\nname = \"glad-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nglad = \"0.0.0-651a425\"\n", + "files": [ + { + "path": "tests/examples/glad/tests/header.cpp", + "text": "// Smoke test — verify compat.glad header is usable + links\n#include \"glad/glad.h\"\nimport std;\n\nint main() {\n // Verify the header defines the expected types (no GL context needed)\n std::println(\"compat.glad smoke test: ok\");\n return 0;\n}\n" + } + ] + }, + { + "project": "glib", + "text": "# gnome.glib — the generated headers, and the subsystems that depend on them.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"glib-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\nglib = \"2.82.5\"\n", + "files": [ + { + "path": "tests/examples/glib/tests/glib.cpp", + "text": "// glib — the generated headers, and the subsystems that depend on them.\n//\n// WHAT THIS ASSERTS AND WHY\n//\n// glib is large and mostly self-evident: if GHashTable were broken the build\n// would not have got here. What is NOT self-evident in a fork is whether the\n// five generated headers say the right things, because every one of them was\n// written out by hand-reimplemented generators. A wrong answer in glibconfig.h\n// does not fail to compile — it changes the width of gsize, or the byte order,\n// or which printf glib calls.\n//\n// So each check reads back a value that a generator decided:\n//\n// glibconfig.h gsize/gssize widths, G_BYTE_ORDER, the format strings\n// gversionmacros.h GLIB_VERSION_2_82 exists and encodes 2.82\n// config.h USE_SYSTEM_PRINTF, via a format only glibc gets right\n// glib-visibility.h a GLIB_AVAILABLE_IN_2_x symbol is actually exported\n// pcre2 wiring GRegex, which is the one external dependency\n//\n// The subsystem checks that follow are chosen for the same reason: each is a\n// place where a wrong config.h answer would show up as a wrong RESULT rather\n// than a failure to build.\n\n#ifdef __linux__\n\n// ⚠️ NO extern \"C\" WRAPPER, and adding one BREAKS THIS UNDER libc++: glib\n// decorates every header with G_BEGIN_DECLS (which IS `extern \"C\" {`), and\n// glib.h pulls , which libc++ routes through — templates\n// inside an extern \"C\" block. `templates must have C++ linkage`, dozens of\n// times, against a header this test never names. libstdc++ does not, so gcc is\n// green and llvm is a wall. Wrap a C header only if it has none of its own.\n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. glibconfig.h: the type model ──────────────────────────────────\n std::printf(\" glib %d.%d.%d gsize=%zu gssize=%zu gpointer=%zu\\n\",\n glib_major_version, glib_minor_version, glib_micro_version,\n sizeof(gsize), sizeof(gssize), sizeof(gpointer));\n check(glib_major_version == 2 && glib_minor_version == 82,\n \"the runtime version matches the manifest\");\n check(sizeof(gsize) == sizeof(void *) && sizeof(gssize) == sizeof(void *),\n \"gsize and gssize are pointer-width, as glibconfig.h declares\");\n check(sizeof(gint32) == 4 && sizeof(gint64) == 8 && sizeof(gint16) == 2,\n \"the fixed-width types are the widths they are named for\");\n\n // G_BYTE_ORDER is written by the generator rather than probed, so it is\n // worth checking against something the machine can answer for itself.\n const guint32 probe = 0x01020304;\n const bool little = *reinterpret_cast(&probe) == 0x04;\n std::printf(\" G_BYTE_ORDER=%d measured=%s\\n\", G_BYTE_ORDER,\n little ? \"little\" : \"big\");\n check((G_BYTE_ORDER == G_LITTLE_ENDIAN) == little,\n \"G_BYTE_ORDER agrees with what the bytes actually say\");\n\n // The format strings are the half of glibconfig.h that a compile cannot\n // check: `%\" G_GSIZE_FORMAT \"` with the wrong modifier is undefined\n // behaviour that usually prints something plausible.\n {\n gchar *s = g_strdup_printf(\"%\" G_GSIZE_FORMAT, static_cast(123456789));\n std::printf(\" G_GSIZE_FORMAT renders %s\\n\", s);\n check(std::strcmp(s, \"123456789\") == 0,\n \"G_GSIZE_FORMAT prints a gsize correctly\");\n g_free(s);\n }\n\n // ── 2. gversionmacros.h ──────────────────────────────────────────────\n // The generator emits one block per even minor up to the current one.\n // Encoding is (major << 16) | (minor << 8) | micro.\n std::printf(\" GLIB_VERSION_2_82 = 0x%06x\\n\", GLIB_VERSION_2_82);\n check(GLIB_VERSION_2_82 == G_ENCODE_VERSION(2, 82),\n \"gversionmacros.h defines GLIB_VERSION_2_82 and encodes it correctly\");\n check(GLIB_VERSION_2_26 < GLIB_VERSION_2_82,\n \"…and the older ones too, in order\");\n\n // ── 3. config.h: USE_SYSTEM_PRINTF ───────────────────────────────────\n // Not directly observable — but %'d (thousands grouping) and the C99 %zu\n // are things glib's bundled gnulib printf implements differently from\n // glibc's. Rendering a long double in %La exercises the path that made the\n // link fail when the wrong printf was selected.\n {\n gchar *s = g_strdup_printf(\"%.3f|%05d|%s\", 1.5, 42, \"x\");\n std::printf(\" printf path renders %s\\n\", s);\n check(std::strcmp(s, \"1.500|00042|x\") == 0,\n \"g_strdup_printf goes through a working printf\");\n g_free(s);\n }\n\n // ── 4. glib-visibility.h ─────────────────────────────────────────────\n // g_pathbuf_init is GLIB_AVAILABLE_IN_2_76 — a symbol decorated by the\n // generated visibility header. Calling it proves the decoration expanded\n // to something that exports rather than hides.\n {\n GPathBuf pb;\n g_path_buf_init(&pb);\n g_path_buf_push(&pb, \"usr\");\n g_path_buf_push(&pb, \"share\");\n gchar *p = g_path_buf_to_path(&pb);\n std::printf(\" GPathBuf built %s\\n\", p ? p : \"(null)\");\n check(p != nullptr && std::strstr(p, \"usr/share\") != nullptr,\n \"a GLIB_AVAILABLE_IN_2_76 symbol is reachable\");\n g_free(p);\n g_path_buf_clear(&pb);\n }\n\n // ── 5. GRegex, i.e. the pcre2 dependency ─────────────────────────────\n // The only external library glib links here. \\p{Han} needs pcre2 built\n // with SUPPORT_UNICODE, which is compat.pcre2's decision — so this checks\n // the two packages agree, not merely that one of them built.\n {\n GError *err = nullptr;\n GRegex *re = g_regex_new(\"^\\\\p{Han}+$\", G_REGEX_DEFAULT,\n G_REGEX_MATCH_DEFAULT, &err);\n check(re != nullptr, \"g_regex_new compiles a Unicode property pattern\");\n if (re != nullptr) {\n check(g_regex_match(re, \"漢字\", G_REGEX_MATCH_DEFAULT, nullptr),\n \"…and it matches Han characters\");\n check(!g_regex_match(re, \"abc\", G_REGEX_MATCH_DEFAULT, nullptr),\n \"…and not Latin ones\");\n g_regex_unref(re);\n } else if (err != nullptr) {\n std::printf(\" %s\\n\", err->message);\n g_error_free(err);\n }\n }\n\n // ── 6. Unicode, the tables gunicode.h describes ──────────────────────\n check(g_unichar_type(0x6F22) == G_UNICODE_OTHER_LETTER,\n \"g_unichar_type classifies U+6F22 as a letter\");\n // The function is `g_unichar_get_script`, not `g_unichar_script`.\n check(g_unichar_get_script(0x6F22) == G_UNICODE_SCRIPT_HAN,\n \"…and g_unichar_get_script puts it in Han\");\n {\n gchar *up = g_utf8_strup(\"straße\", -1);\n std::printf(\" g_utf8_strup(straße) = %s\\n\", up);\n check(up != nullptr && std::strstr(up, \"STRASSE\") != nullptr,\n \"g_utf8_strup expands ß to SS, i.e. real case tables\");\n g_free(up);\n }\n\n // ── 7. the main loop and GVariant ────────────────────────────────────\n {\n GMainContext *ctx = g_main_context_new();\n check(ctx != nullptr, \"a GMainContext is created\");\n check(!g_main_context_iteration(ctx, FALSE),\n \"…and iterating it with nothing pending does not block\");\n g_main_context_unref(ctx);\n }\n {\n GVariant *v = g_variant_new(\"(si)\", \"hello\", 42);\n g_variant_ref_sink(v);\n const gchar *s = nullptr;\n gint i = 0;\n g_variant_get(v, \"(&si)\", &s, &i);\n std::printf(\" GVariant round-trip: %s %d\\n\", s, i);\n check(s != nullptr && std::strcmp(s, \"hello\") == 0 && i == 42,\n \"a GVariant tuple survives a serialise/parse round trip\");\n g_variant_unref(v);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/glib/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. glib.cpp\n// next to this file takes the header route; this one takes the module, so both\n// doors are tested rather than assumed.\n//\n// ─────────────────────────────────────────────────────────────────────────\n// ⚠️ THE TWO DOORS DO NOT COMPOSE, AND THAT IS GCC'S RULE, NOT A CHOICE HERE.\n//\n// A TU that imports gnome.glib AND textually includes a glib header reaches\n// `` twice — once through the module's global fragment, once directly\n// — and the same `struct tm` from the same file becomes two entities:\n//\n// error: conflicting declaration 'struct tm'\n// note: previous declaration as 'struct tm' (of module gnome.glib)\n//\n// repeated for every type in it. So a consumer picks ONE route.\n//\n// WHAT THAT MEANS IN PRACTICE, stated plainly because it decides which route\n// to pick: a module cannot carry macros, and glib's macros are half its API\n// (1,337 `#define` against 1,312 declarations; for gio 1,679 against 1,753).\n// Code that uses `G_DEFINE_TYPE`, `G_OBJECT` or `G_TYPE_*` takes the header\n// route. Code that uses the FUNCTION API — most of gio, and a great deal of\n// glib — can import and include nothing at all, which is what this file does.\n#ifdef __linux__\n\nimport gnome.glib;\n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"import gnome.glib — %d.%d.%d\\n\\n\",\n glib_major_version, glib_minor_version, glib_micro_version);\n check(glib_major_version == 2 && glib_minor_version == 82,\n \"the version variables come through the module\");\n\n // ── strings: GString and the g_str* family ───────────────────────────\n GString *s = g_string_new(\"import\");\n g_string_append(s, \" works\");\n check(std::strcmp(s->str, \"import works\") == 0, \"GString: new + append\");\n g_string_free(s, 1); // TRUE is a MACRO — not reachable through a module\n\n char *up = g_ascii_strup(\"mcpp\", -1);\n check(up && std::strcmp(up, \"MCPP\") == 0, \"g_ascii_strup\");\n g_free(up);\n\n // ── containers ───────────────────────────────────────────────────────\n GPtrArray *a = g_ptr_array_new();\n g_ptr_array_add(a, const_cast(\"one\"));\n g_ptr_array_add(a, const_cast(\"two\"));\n check(a->len == 2, \"GPtrArray: two elements\");\n g_ptr_array_free(a, 1);\n\n GHashTable *h = g_hash_table_new(g_str_hash, g_str_equal);\n g_hash_table_insert(h, const_cast(\"k\"), const_cast(\"v\"));\n const char *got = static_cast(g_hash_table_lookup(h, \"k\"));\n check(got && std::strcmp(got, \"v\") == 0, \"GHashTable: insert and look up\");\n g_hash_table_destroy(h);\n\n // ── an ENUMERATOR, reached through its typedef ───────────────────────\n // glib writes `typedef enum { … } GNormalizeMode;` — an UNNAMED enum\n // behind a typedef — and exporting the typedef is what makes the\n // enumerators visible. Nothing in the module names them individually.\n GNormalizeMode nfd = G_NORMALIZE_NFD, nfc = G_NORMALIZE_NFC;\n check(nfd != nfc, \"an enumerator arrives with its typedef\");\n\n // ── Unicode, which is where glib's own tables live ───────────────────\n check(g_unichar_isalpha(0x4E2D), \"g_unichar_isalpha on U+4E2D\");\n check(g_unichar_type(0x4E2D) == G_UNICODE_OTHER_LETTER, \"g_unichar_type\");\n\n // ── GVariant: parse and read back ────────────────────────────────────\n GVariant *v = g_variant_new_int32(42);\n check(g_variant_get_int32(v) == 42, \"GVariant: int32 round trip\");\n g_variant_unref(v);\n\n // ── the main loop ────────────────────────────────────────────────────\n GMainContext *ctx = g_main_context_new();\n check(ctx != nullptr, \"GMainContext: created\");\n g_main_context_unref(ctx);\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "glm", + "text": "# compat.glm test project. Header-only; the `compat` index redirect is\n# inherited from the workspace root.\n[package]\nname = \"glm-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nglm = \"1.0.2\"\n\n# The two switches a Vulkan consumer sets, declared HERE rather than in the\n# package: they change the meaning of the types crossing a library boundary, so\n# the package must not decide them for everyone. GLM_FORCE_DEPTH_ZERO_TO_ONE is\n# what makes glm::perspective produce Vulkan's 0..1 clip depth instead of\n# OpenGL's -1..1, and the test below asserts exactly that difference.\n[build]\ncxxflags = [\"-DGLM_FORCE_DEPTH_ZERO_TO_ONE\", \"-DGLM_ENABLE_EXPERIMENTAL\"]\n", + "files": [ + { + "path": "tests/examples/glm/tests/math.cpp", + "text": "// Behavioral test — compat.glm delivers a real glm, not an empty include root.\n//\n// The criterion is arithmetic, not compilation: a wrong include root fails to\n// compile, but a header set that resolved to the wrong thing would still need\n// to produce these numbers. Every value below is fixed by linear algebra or by\n// glm's documented conventions.\n#include \n#include \n#include \n\nimport std;\n\nint main() {\n // Cross product, right-handed: x cross y is z.\n const glm::vec3 x{1.0f, 0.0f, 0.0f};\n const glm::vec3 y{0.0f, 1.0f, 0.0f};\n if (glm::cross(x, y) != glm::vec3(0.0f, 0.0f, 1.0f)) {\n std::println(\"cross(x, y) is not z\");\n return 1;\n }\n\n // Column-major storage is part of glm's ABI, and it is what a consumer\n // relies on when it memcpy's a mat4 into a uniform buffer.\n glm::mat4 m{1.0f};\n m[3] = glm::vec4(2.0f, 3.0f, 4.0f, 1.0f);\n if (m[3][0] != 2.0f || m[3][1] != 3.0f || m[3][2] != 4.0f) {\n std::println(\"mat4 translation column is not where column-major puts it\");\n return 2;\n }\n const glm::vec4 moved = m * glm::vec4(1.0f, 1.0f, 1.0f, 1.0f);\n if (moved != glm::vec4(3.0f, 4.0f, 5.0f, 1.0f)) {\n std::println(\"mat4 * vec4 did not translate\");\n return 3;\n }\n\n // GLM_FORCE_DEPTH_ZERO_TO_ONE, set by THIS project: with it, the near\n // plane maps to 0 (Vulkan). Without it glm would map it to -1 (OpenGL),\n // and this is the assertion that tells the two apart.\n const glm::mat4 proj = glm::perspective(glm::radians(45.0f), 16.0f / 9.0f, 0.1f, 100.0f);\n const glm::vec4 nearPoint = proj * glm::vec4(0.0f, 0.0f, -0.1f, 1.0f);\n const float ndcNear = nearPoint.z / nearPoint.w;\n if (std::abs(ndcNear) > 1e-4f) {\n std::println(\"near plane maps to {} — GLM_FORCE_DEPTH_ZERO_TO_ONE did not take\", ndcNear);\n return 4;\n }\n\n // gtx/, reached only because GLM_ENABLE_EXPERIMENTAL is defined.\n if (std::abs(glm::length2(glm::vec3(3.0f, 4.0f, 0.0f)) - 25.0f) > 1e-4f) {\n std::println(\"glm::length2 wrong\");\n return 5;\n }\n\n std::println(\"compat.glm: ok (cross/column-major/zero-to-one depth/gtx all correct)\");\n return 0;\n}\n" + } + ] + }, + { + "project": "gmodule", + "text": "# gnome.gmodule — dynamic loading, and the generated gmoduleconf.h behind it.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"gmodule-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\ngmodule = \"2.82.5\"\n", + "files": [ + { + "path": "tests/examples/gmodule/tests/gmodule.cpp", + "text": "// gmodule — dynamic loading, checked by actually loading something.\n//\n// WHAT THIS ASSERTS AND WHY\n//\n// gmodule is two source files, so \"did it compile\" is nearly free — and nearly\n// worthless. The value is in `gmoduleconf.h`, which build.mcpp generates and\n// which decides WHICH loader is compiled in. `G_MODULE_IMPL_NONE` produces a\n// library that builds, links, and returns \"dynamic loading not supported\" for\n// everything.\n//\n// So the checks are: does it claim to support loading, and does a load\n// actually work. `g_module_open(NULL)` opens the CURRENT EXECUTABLE — the one\n// case that needs no test fixture on disk — and a symbol looked up in it must\n// be one this binary really has.\n\n#ifdef __linux__\n\n// ⚠️ NO extern \"C\" WRAPPER, and adding one BREAKS THIS UNDER libc++: glib\n// decorates every header with G_BEGIN_DECLS (which IS `extern \"C\" {`), and\n// glib.h pulls , which libc++ routes through — templates\n// inside an extern \"C\" block. `templates must have C++ linkage`, dozens of\n// times, against a header this test never names. libstdc++ does not, so gcc is\n// green and llvm is a wall. Wrap a C header only if it has none of its own.\n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-56s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. gmoduleconf.h selected a real loader ──────────────────────────\n // G_MODULE_IMPL_NONE here would mean build.mcpp filled the template with\n // the \"no loader\" answer, and everything below would fail in a way that\n // reads like a broken system rather than a wrong generated header.\n std::printf(\" g_module_supported = %d, suffix = %s\\n\",\n g_module_supported(), G_MODULE_SUFFIX);\n check(g_module_supported(), \"g_module_supported — gmoduleconf.h chose the dl loader\");\n check(std::strcmp(G_MODULE_SUFFIX, \"so\") == 0,\n \"G_MODULE_SUFFIX is \\\"so\\\", from the generated glibconfig.h\");\n\n // ── 2. a load that needs no fixture ──────────────────────────────────\n // NULL means \"this executable\". It exercises the same dlopen path a plugin\n // would, without needing a .so to have been built alongside the test.\n GModule *self = g_module_open(nullptr, G_MODULE_BIND_LAZY);\n check(self != nullptr, \"g_module_open(NULL) opens the running executable\");\n if (self != nullptr) {\n // ⚠️ NO SYMBOL LOOKUP HERE, and the reason is a property of this\n // package rather than a gap in the test.\n //\n // `dlsym` on the main program sees only the DYNAMIC symbol table, which\n // needs `-rdynamic`. And gnome.glib is `kind = \"lib\"`: its objects are\n // merged into the consumer, so there is no libglib-2.0.so in the link\n // map either. Both halves of the obvious assertion — a symbol of this\n // executable's, or one of glib's — are therefore absent by\n // construction, and a test that asserted them would be measuring the\n // link line.\n //\n // What IS observable without a fixture on disk is that the loader\n // answers at all, and that it distinguishes present from absent.\n gpointer missing = nullptr;\n check(!g_module_symbol(self, \"mcpp_no_such_symbol_at_all\", &missing),\n \"a name that is not there is reported as absent, not crashed on\");\n\n std::printf(\" g_module_name = %s\\n\", g_module_name(self));\n check(g_module_close(self), \"g_module_close\");\n }\n\n // ── 2b. g_module_build_path — G_MODULE_SUFFIX in use ─────────────────\n // The one place the generated glibconfig.h value becomes a STRING a caller\n // sees. On Windows this would be \"plugin.dll\"; getting it wrong would make\n // every plugin host look for the wrong filename.\n {\n gchar *p = g_module_build_path(\"/opt/plug\", \"demo\");\n std::printf(\" g_module_build_path = %s\\n\", p ? p : \"(null)\");\n check(p != nullptr && std::strcmp(p, \"/opt/plug/libdemo.so\") == 0,\n \"g_module_build_path applies the lib prefix and .so suffix\");\n g_free(p);\n }\n\n // ── 3. the error path says something ─────────────────────────────────\n // G_MODULE_IMPL_NONE would also reach here, so this is checked after the\n // success case rather than instead of it.\n GModule *nope = g_module_open(\"/nonexistent/definitely-not-a-module.so\",\n G_MODULE_BIND_LAZY);\n check(nope == nullptr, \"opening a missing module fails\");\n const gchar *err = g_module_error();\n std::printf(\" g_module_error = %s\\n\", err ? err : \"(null)\");\n // G_MODULE_HAVE_DLERROR is one of the four values build.mcpp fills into\n // gmoduleconf.h; without it the message would be a generic placeholder\n // rather than the loader's own text.\n check(err != nullptr && std::strstr(err, \"definitely-not-a-module\") != nullptr,\n \"…and dlerror's message names the file — G_MODULE_HAVE_DLERROR is on\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gmodule/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file consumes gnome.gmodule through its headers; this one consumes\n// the same package through its module, so both doors are checked from the\n// OUTSIDE — which is what an index example is for.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE, and that is GCC's rule rather than a choice\n// here. A TU that imports the module AND textually includes a glib header\n// reaches twice — once through the module's global fragment, once\n// directly — and the same `struct tm` from the same file becomes two entities:\n//\n// error: conflicting declaration 'struct tm'\n// note: previous declaration as 'struct tm' (of module gnome.glib)\n//\n// So a consumer picks ONE route, and which one is decided by macros: a module\n// cannot carry them, and glib's are half its API. Code using `G_DEFINE_TYPE`,\n// `G_OBJECT` or `G_TYPE_*` takes the header route. Code using the FUNCTION API\n// — most of gio, and much of glib — imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.gmodule; // re-exports gnome.glib\n\n#include \n#include \n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n std::printf(\"import gnome.gmodule\\n\\n\");\n\n check(g_module_supported(), \"g_module_supported() through the module\");\n\n // An enumerator, reached through its typedef — glib writes\n // `typedef enum { … } GModuleFlags;` and exporting the typedef is what\n // makes G_MODULE_BIND_LAZY visible. Nothing names it individually.\n GModuleFlags f = G_MODULE_BIND_LAZY;\n check(static_cast(f) == 1, \"an enumerator arrives with its typedef\");\n\n GModule *m = g_module_open(\"/nonexistent/definitely-not-a-module.so\", f);\n check(m == nullptr, \"opening a missing module fails\");\n const char *err = g_module_error();\n check(err != nullptr && std::strstr(err, \"definitely-not-a-module\") != nullptr,\n \"…and g_module_error names the file — the dl loader is wired up\");\n\n // ⭐ gnome.glib ARRIVES THROUGH gnome.gmodule, and it has to: glib is a\n // workspace PATH dependency, so a consumer that named it as well would get\n // error: dependency 'gnome.glib' is requested as both a version dep\n // and a path dep\n // gmodule.h includes glib.h, so the module re-exports gnome.glib to match.\n char *p = g_strdup(\"re-exported\");\n check(p != nullptr && std::strcmp(p, \"re-exported\") == 0,\n \"g_strdup — gnome.glib came through gnome.gmodule\");\n g_free(p);\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + } + ] + }, + { + "project": "gmp-gmpxx", + "text": "# compat.gmp `gmpxx` FEATURE path — GMP's C++ bindings (mpz_class / mpq_class /\n# mpf_class, the RAII wrappers with operator overloading, and the iostream\n# operators).\n#\n# Its own member rather than a flag on tests/examples/gmp: features are resolved\n# per consuming project, so one project cannot hold compat.gmp both with and\n# without gmpxx, and the base member has to keep asserting the C-only build that\n# nearly every consumer takes.\n#\n# The negative half of the feature check is structural, the same shape as\n# tests/examples/catch2-main: `gmpxx.h` ships unconditionally (it is a header,\n# and headers cannot be gated), but the eleven .cc files behind\n# `operator<<(std::ostream&, const mpz_class&)` are the feature. Turn it off and\n# this member stops linking — measured: four undefined references, the ostream\n# operators for __mpz_struct / __mpq_struct / __mpf_struct and the istream one\n# for __mpz_struct. It does not quietly pass.\n#\n# ALL THREE PLATFORMS. The feature's TU is compiled by the CONSUMER's toolchain\n# precisely so that its std::ostream / std::string symbols match whichever C++\n# standard library is in play, which is a claim that is only worth anything if\n# every leg of the matrix actually links it.\n#\n# It inherits the workspace-root `compat` redirect, so the descriptor in THIS\n# checkout is what gets built and linked.\n[package]\nname = \"gmp-gmpxx-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngmp = { version = \"6.3.0\", features = [\"gmpxx\"] }\n", + "files": [ + { + "path": "tests/examples/gmp-gmpxx/tests/gmpxx.cpp", + "text": "// compat.gmp `gmpxx` feature: the C++ bindings compile against the consumer's\n// own toolchain and link.\n//\n// The stream operators are the load-bearing part. Everything else in gmpxx.h is\n// inline in the header, so a member that only did arithmetic would link with\n// the feature TURNED OFF and prove nothing. `os << mpz_class` resolves to\n// __gmp_ostream_operator, which lives in cxx/osmpz.cc — one of the eleven\n// sources the feature gates.\n//\n// It is also the one place where \"compiled by the consumer's toolchain\" is\n// actually tested: those symbols carry std::ostream / std::string in their\n// mangled names, so a prebuilt libgmpxx would resolve on libstdc++ and fail on\n// libc++.\n\n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\nint check_mpz_class() {\n // 128-bit-ish multiply, formatted through the ostream operator.\n mpz_class a(\"123456789012345678901234567890\");\n mpz_class b = a * a;\n std::ostringstream os;\n os << b;\n if (os.str() != \"15241578753238836750495351562536198787501905199875019052100\")\n return 20;\n\n // Operator overloading across mixed types, and the comparison operators.\n mpz_class c = a + 10;\n if (c - 10 != a) return 21;\n if (!(a < b) || !(b > a)) return 22;\n\n // Number-theoretic helpers reached through the C++ names.\n mpz_class g = gcd(mpz_class(123456789), mpz_class(987654321));\n if (g != 9) return 23;\n\n // get_str / set_str round trip via std::string.\n std::string hex = b.get_str(16);\n mpz_class back(hex, 16);\n if (back != b) return 24;\n\n // istream operator — the other half of the gated sources (cxx/ismpz.cc).\n std::istringstream is(\"999999999999999999999999999999\");\n mpz_class parsed;\n is >> parsed;\n if (parsed != mpz_class(\"999999999999999999999999999999\")) return 25;\n return 0;\n}\n\nint check_mpq_class() {\n mpq_class q(1, 3);\n q += mpq_class(1, 6); // 1/2\n q.canonicalize();\n std::ostringstream os;\n os << q;\n if (os.str() != \"1/2\") return 30;\n if (q.get_num() != 1 || q.get_den() != 2) return 31;\n\n mpq_class r = q * mpq_class(4, 3); // 2/3\n r.canonicalize();\n if (r != mpq_class(2, 3)) return 32;\n return 0;\n}\n\nint check_mpf_class() {\n mpf_class two(2, 256);\n mpf_class root = sqrt(two);\n if (!(root > 1.41 && root < 1.42)) return 40;\n // Squaring it back has to land within the working precision.\n mpf_class err = abs(root * root - 2);\n if (!(err < mpf_class(1e-30, 256))) return 41;\n\n std::ostringstream os;\n os << mpf_class(1.5, 64);\n if (os.str() != \"1.5\") return 42;\n return 0;\n}\n\n} // namespace\n\nint main() {\n int rc = check_mpz_class();\n if (rc == 0) rc = check_mpq_class();\n if (rc == 0) rc = check_mpf_class();\n if (rc != 0) {\n std::fprintf(stderr, \"compat.gmp gmpxx: check failed, code %d\\n\", rc);\n return rc;\n }\n std::cout << \"compat.gmp gmpxx: mpz_class/mpq_class/mpf_class + streams ok\"\n << std::endl;\n return 0;\n}\n" + } + ] + }, + { + "project": "gmp", + "text": "# compat.gmp — the DEFAULT (C API) build, on all three platforms.\n#\n# Not cfg-gated any more: compat.gmp no longer drives an autotools build\n# through an install() hook, it is described in `mcpp = { }` and compiled by\n# mcpp itself, so windows is a target like the others and a no-op main() there\n# would only hide that.\n#\n# The C++ bindings are the `gmpxx` feature and live in tests/examples/gmp-gmpxx:\n# features are resolved per consuming project, so one project cannot hold\n# compat.gmp both with and without them — and this member has to keep asserting\n# the build almost every consumer takes.\n#\n# It inherits the workspace-root `compat` redirect, so the descriptor in THIS\n# checkout is what gets built and linked.\n[package]\nname = \"gmp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngmp = \"6.3.0\"\n", + "files": [ + { + "path": "tests/examples/gmp/tests/gmp.cpp", + "text": "// compat.gmp end-to-end, C API: gmp.h resolves, the library links, and every\n// source directory the descriptor compiles actually works.\n//\n// One assertion group per directory in the package's source list, because a\n// build described by globs is exactly the kind that can lose a whole directory\n// without anything failing to compile:\n//\n// mpn/generic every group below (mpz/mpq/mpf all bottom out in mpn)\n// mpz factorial / powm / gcd / string round-trip / import-export\n// mpq rational arithmetic and canonicalisation\n// mpf the float layer, incl. mpf_sqrt\n// printf gmp_snprintf\n// scanf gmp_sscanf\n// rand gmp_randinit / mpz_urandomm\n//\n// The expected values are precomputed independently of GMP (Python\n// int/fractions), so a library that silently picked up a host libgmp, or one\n// built from the wrong tables, fails instead of agreeing with itself.\n\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\n// GMP allocates the result of mpz_get_str with ITS allocator, which is not\n// necessarily the one behind std::free. The documented way to give it back is\n// the free function GMP reports, and it is the only way that stays correct if a\n// consumer ever installs custom memory functions.\nvoid gmp_free(char* p) {\n if (p == nullptr) return;\n void (*freefunc)(void*, size_t) = nullptr;\n mp_get_memory_functions(nullptr, nullptr, &freefunc);\n freefunc(p, std::strlen(p) + 1);\n}\n\nint check_limb_shape() {\n // The descriptor vendors tables generated for limb=64/nail=0 and its\n // config.h refuses to compile without a 64-bit unsigned type. If gmp.h and\n // the compiled library ever disagreed about the limb — the LP64/LLP64\n // mistake a configure-baked header makes when it reaches Windows — every\n // other assertion here would still pass while mp_limb_t was half-width.\n if (GMP_LIMB_BITS != 64) return 10;\n if (sizeof(mp_limb_t) * 8 != GMP_LIMB_BITS) return 11;\n if (GMP_NAIL_BITS != 0) return 12;\n if (mp_bits_per_limb != GMP_LIMB_BITS) return 13;\n return 0;\n}\n\nint check_mpz() {\n // 100! — a known 158-digit value, compared digit by digit.\n {\n mpz_t f;\n mpz_init(f);\n mpz_fac_ui(f, 100);\n char* s = mpz_get_str(nullptr, 10, f);\n const char* expected =\n \"93326215443944152681699238856266700490715968264381621468592963895217599993229915\"\n \"608941463976156518286253697920827223758251185210916864000000000000000000000000\";\n int ok = s != nullptr && std::strcmp(s, expected) == 0;\n gmp_free(s);\n mpz_clear(f);\n if (!ok) return 20;\n }\n\n // Modular exponentiation, big enough to leave the single-limb paths:\n // 7^1234 mod (2^127 - 1).\n {\n mpz_t base, mod, res;\n mpz_init_set_ui(base, 7);\n mpz_init(mod);\n mpz_ui_pow_ui(mod, 2, 127);\n mpz_sub_ui(mod, mod, 1);\n mpz_init(res);\n mpz_powm_ui(res, base, 1234, mod);\n char* s = mpz_get_str(nullptr, 10, res);\n int ok = s != nullptr &&\n std::strcmp(s, \"55471114467826807814326117990010739464\") == 0;\n gmp_free(s);\n mpz_clears(base, mod, res, nullptr);\n if (!ok) return 21;\n }\n\n // gcd / lcm on a pair with a known factorisation.\n //\n // The lcm is compared against an mpz built from a decimal string, not\n // through mpz_cmp_ui: that takes `unsigned long`, which is 32 bits on\n // LLP64 (Windows), so 13548070123626141 would be silently truncated and\n // the assertion would fail on Windows only. Every *_ui entry point in\n // this file is therefore kept under 2^32.\n {\n mpz_t a, b, g, l, expect;\n mpz_init_set_ui(a, 123456789);\n mpz_init_set_ui(b, 987654321);\n mpz_init(g);\n mpz_init(l);\n mpz_init_set_str(expect, \"13548070123626141\", 10);\n mpz_gcd(g, a, b);\n mpz_lcm(l, a, b);\n int ok = mpz_cmp_ui(g, 9) == 0 && mpz_cmp(l, expect) == 0;\n mpz_clears(a, b, g, l, expect, nullptr);\n if (!ok) return 22;\n }\n\n // Base-16 round trip through a 256-bit value, and mpz_sizeinbase.\n {\n const char* hex = \"ffffffffffffffffffffffffffffffff\"\n \"fffffffffffffffffffffffefffffc2f\"; // secp256k1 p\n mpz_t p;\n mpz_init(p);\n if (mpz_set_str(p, hex, 16) != 0) { mpz_clear(p); return 23; }\n if (mpz_sizeinbase(p, 2) != 256) { mpz_clear(p); return 24; }\n char* back = mpz_get_str(nullptr, 16, p);\n int ok = back != nullptr && std::strcmp(back, hex) == 0;\n gmp_free(back);\n // It is prime, which walks probab_prime_p -> mpn_trialdiv -> the\n // vendored trialdivtab.h, and millerrabin -> mpz_powm.\n if (ok && mpz_probab_prime_p(p, 25) == 0) ok = 0;\n mpz_clear(p);\n if (!ok) return 25;\n }\n\n // import/export: the limb-order path, which is where a wrong\n // HAVE_LIMB_LITTLE_ENDIAN would show up.\n {\n const unsigned char be[] = {0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef,\n 0xfe, 0xdc, 0xba, 0x98};\n mpz_t v;\n mpz_init(v);\n mpz_import(v, sizeof(be), 1 /*most significant word first*/, 1, 0, 0, be);\n char* s = mpz_get_str(nullptr, 16, v);\n int ok = s != nullptr && std::strcmp(s, \"123456789abcdeffedcba98\") == 0;\n gmp_free(s);\n unsigned char out[sizeof(be)] = {0};\n size_t count = 0;\n mpz_export(out, &count, 1, 1, 0, 0, v);\n if (count != sizeof(be) || std::memcmp(out, be, sizeof(be)) != 0) ok = 0;\n mpz_clear(v);\n if (!ok) return 26;\n }\n\n return 0;\n}\n\nint check_mpq() {\n mpq_t q, r;\n mpq_init(q);\n mpq_init(r);\n mpq_set_ui(q, 1, 2);\n mpq_set_ui(r, 1, 3);\n mpq_add(q, q, r); // 5/6\n int ok = mpq_cmp_ui(q, 5, 6) == 0;\n mpq_set_ui(r, 10, 4); // canonicalises to 5/2\n mpq_canonicalize(r);\n if (mpz_cmp_ui(mpq_numref(r), 5) != 0 || mpz_cmp_ui(mpq_denref(r), 2) != 0) ok = 0;\n mpq_div(q, q, r); // (5/6) / (5/2) = 1/3\n if (mpq_cmp_ui(q, 1, 3) != 0) ok = 0;\n mpq_clear(q);\n mpq_clear(r);\n return ok ? 0 : 30;\n}\n\nint check_mpf() {\n mpf_set_default_prec(256);\n mpf_t x, s, one;\n mpf_init_set_ui(x, 2);\n mpf_init(s);\n mpf_init_set_ui(one, 1);\n mpf_sqrt(s, x); // sqrt(2)\n int ok = mpf_cmp_d(s, 1.0) > 0 && mpf_cmp_d(s, 2.0) < 0;\n // s*s must be 2 to well within the working precision.\n mpf_mul(x, s, s);\n mpf_sub_ui(x, x, 2);\n mpf_abs(x, x);\n mpf_div_2exp(one, one, 200); // 2^-200\n if (mpf_cmp(x, one) >= 0) ok = 0;\n mpf_clears(x, s, one, nullptr);\n return ok ? 0 : 40;\n}\n\nint check_printf_scanf() {\n // printf/ — gmp_snprintf with the %Zd conversion.\n mpz_t v;\n mpz_init(v);\n mpz_ui_pow_ui(v, 3, 40); // 12157665459056928801\n char buf[64] = {0};\n int n = gmp_snprintf(buf, sizeof(buf), \"[%Zd]\", v);\n int ok = n == 22 && std::strcmp(buf, \"[12157665459056928801]\") == 0;\n mpz_clear(v);\n if (!ok) return 50;\n\n // scanf/ — the same value back out of a string.\n mpz_t w;\n mpz_init(w);\n if (gmp_sscanf(\"12157665459056928801\", \"%Zd\", w) != 1) { mpz_clear(w); return 51; }\n mpz_t expect;\n mpz_init(expect);\n mpz_ui_pow_ui(expect, 3, 40);\n ok = mpz_cmp(w, expect) == 0;\n mpz_clears(w, expect, nullptr);\n return ok ? 0 : 52;\n}\n\nint check_rand() {\n // rand/ — a seeded Mersenne Twister is deterministic, so this asserts the\n // generator runs AND that every draw lands in range.\n gmp_randstate_t st;\n gmp_randinit_mt(st);\n gmp_randseed_ui(st, 20260810u);\n mpz_t n, x;\n mpz_init(n);\n mpz_ui_pow_ui(n, 10, 30);\n mpz_init(x);\n int ok = 1;\n for (int i = 0; i < 64; ++i) {\n mpz_urandomm(x, st, n);\n if (mpz_sgn(x) < 0 || mpz_cmp(x, n) >= 0) { ok = 0; break; }\n }\n mpz_clears(n, x, nullptr);\n gmp_randclear(st);\n return ok ? 0 : 60;\n}\n\n} // namespace\n\nint main() {\n int rc = check_limb_shape();\n if (rc == 0) rc = check_mpz();\n if (rc == 0) rc = check_mpq();\n if (rc == 0) rc = check_mpf();\n if (rc == 0) rc = check_printf_scanf();\n if (rc == 0) rc = check_rand();\n if (rc != 0) {\n std::fprintf(stderr, \"compat.gmp: check failed, code %d\\n\", rc);\n return rc;\n }\n std::printf(\"compat.gmp %s: mpz/mpq/mpf/printf/scanf/rand ok (limb %d bits)\\n\",\n gmp_version, GMP_LIMB_BITS);\n return 0;\n}\n" + } + ] + }, + { + "project": "gobject", + "text": "# gnome.gobject — the type system, registered and exercised at run time.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"gobject-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\ngobject = \"2.82.5\"\n", + "files": [ + { + "path": "tests/examples/gobject/tests/gobject.cpp", + "text": "// gobject — the type system, exercised rather than merely linked.\n//\n// WHAT THIS ASSERTS AND WHY\n//\n// GObject is the kind of library where \"it linked\" proves nothing: GType is a\n// runtime registry, so a build that compiled half of it still links and then\n// fails the first time something registers a type. Every check below makes the\n// library DO something and reads back a value it computed:\n//\n// g_type_init-era registry a fundamental type's name, from gtype.c\n// the GENERATED enum types g_unicode_script_get_type(), which exists only\n// because build.mcpp reproduced glib-mkenums\n// a derived type registered at run time, with a property\n// properties set through GValue and read back\n// signals connected, emitted, and observed to have run\n// GBinding two objects kept in step, which is gbinding.c\n//\n// The enum-type check is the one that matters most for this fork: it is the\n// only evidence that the mkenums reimplementation produced a REGISTERABLE type\n// rather than a header that merely compiles.\n\n#ifdef __linux__\n\n// ⚠️ NO extern \"C\" WRAPPER, and adding one BREAKS THIS UNDER libc++.\n//\n// glib decorates every header with G_BEGIN_DECLS/G_END_DECLS, which IS\n// `extern \"C\" {`, so a wrapper is redundant. It is also harmful: glib.h pulls\n// and , and libc++ routes those through\n// /, which define TEMPLATES. Inside an extern \"C\" block that\n// is\n//\n// __type_traits/add_cv_quals.h:20: error: templates must have C++ linkage\n//\n// reported dozens of times against a standard-library header this test never\n// names. libstdc++ does not route them that way, so the gcc leg is green and\n// the llvm leg is a wall of errors — the same asymmetry that caught a missing\n// in the cairo fork.\n//\n// The rule: wrap a C header ONLY if it has no extern \"C\" of its own.\n// compat.libseat and freedesktop.libdisplay-info need it; glib does not.\n#include \n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// A minimal derived type, declared the way every GObject library does it.\n// Registering it exercises gtype.c, gobject.c, gparam.c and gvalue.c at once.\nstruct TestThing {\n GObject parent;\n gint answer;\n};\nstruct TestThingClass {\n GObjectClass parent;\n};\n\nenum { PROP_0, PROP_ANSWER, N_PROPS };\nGParamSpec *props[N_PROPS] = {};\n\nGType test_thing_get_type(void);\nG_DEFINE_TYPE(TestThing, test_thing, G_TYPE_OBJECT)\n\nvoid test_thing_set_property(GObject *o, guint id, const GValue *v, GParamSpec *p)\n{\n if (id == PROP_ANSWER) {\n reinterpret_cast(o)->answer = g_value_get_int(v);\n } else {\n G_OBJECT_WARN_INVALID_PROPERTY_ID(o, id, p);\n }\n}\n\nvoid test_thing_get_property(GObject *o, guint id, GValue *v, GParamSpec *p)\n{\n if (id == PROP_ANSWER) {\n g_value_set_int(v, reinterpret_cast(o)->answer);\n } else {\n G_OBJECT_WARN_INVALID_PROPERTY_ID(o, id, p);\n }\n}\n\nguint sig_changed = 0;\n\nvoid test_thing_class_init(TestThingClass *klass)\n{\n GObjectClass *oc = G_OBJECT_CLASS(klass);\n oc->set_property = test_thing_set_property;\n oc->get_property = test_thing_get_property;\n props[PROP_ANSWER] = g_param_spec_int(\"answer\", nullptr, nullptr,\n G_MININT, G_MAXINT, 0,\n static_cast(G_PARAM_READWRITE));\n g_object_class_install_properties(oc, N_PROPS, props);\n\n sig_changed = g_signal_new(\"changed\", test_thing_get_type(), G_SIGNAL_RUN_LAST,\n 0, nullptr, nullptr, nullptr, G_TYPE_NONE, 1, G_TYPE_INT);\n}\n\nvoid test_thing_init(TestThing *) {}\n\nint seen = -1;\nvoid on_changed(TestThing *, gint value, gpointer) { seen = value; }\n\n} // namespace\n\nint main()\n{\n // ── 1. the type registry answers about its own fundamentals ──────────\n std::printf(\" glib %d.%d.%d\\n\", glib_major_version, glib_minor_version,\n glib_micro_version);\n check(glib_major_version == 2 && glib_minor_version == 82,\n \"glib_*_version report 2.82, the version the manifest declares\");\n check(std::strcmp(g_type_name(G_TYPE_INT), \"gint\") == 0,\n \"g_type_name(G_TYPE_INT) is \\\"gint\\\" — the registry is initialised\");\n check(g_type_is_a(G_TYPE_OBJECT, G_TYPE_OBJECT), \"g_type_is_a on GObject itself\");\n\n // ── 2. THE GENERATED ENUM TYPES ──────────────────────────────────────\n // These exist only because build.mcpp reproduced glib-mkenums over\n // glib/gunicode.h. Registering one and reading a value back is the only\n // thing that proves the generator produced working code rather than a\n // header that happens to compile.\n const GType script = g_unicode_script_get_type();\n check(script != 0, \"g_unicode_script_get_type() registered a GType\");\n GEnumClass *ec = static_cast(g_type_class_ref(script));\n check(ec != nullptr, \"…and its class can be referenced\");\n if (ec != nullptr) {\n GEnumValue *v = g_enum_get_value(ec, G_UNICODE_SCRIPT_HAN);\n std::printf(\" G_UNICODE_SCRIPT_HAN = %s / %s\\n\",\n v ? v->value_name : \"(null)\", v ? v->value_nick : \"(null)\");\n check(v != nullptr && std::strcmp(v->value_name, \"G_UNICODE_SCRIPT_HAN\") == 0,\n \"…and G_UNICODE_SCRIPT_HAN is in it under its own name\");\n // The nick is mkenums' derived form: prefix stripped, lower case,\n // underscores to hyphens. Getting it right is the fiddly half of the\n // generator, so it is asserted rather than assumed.\n check(v != nullptr && std::strcmp(v->value_nick, \"han\") == 0,\n \"…with the nick mkenums derives, \\\"han\\\"\");\n g_type_class_unref(ec);\n }\n check(g_unicode_type_get_type() != 0 && g_unicode_break_type_get_type() != 0\n && g_normalize_mode_get_type() != 0,\n \"all four generated enum types register\");\n\n // ⭐ THE MACRO NAMES, WHICH ARE A DIFFERENT OUTPUT OF THE SAME GENERATOR.\n //\n // Everything above reads the FUNCTION and the NICK, and an earlier 2.82.5\n // tarball got both of those right while defining `G_UNICODE_TYPE_TYPE`\n // where upstream defines `G_TYPE_UNICODE_TYPE`. glib-mkenums has two\n // prefixes — one from the ENUMERATORS (drives nicks), one from the TYPE\n // NAME (drives this macro) — and conflating them changes only the macro.\n //\n // So it compiled, linked, and passed this very test. The four lines below\n // are what it would NOT have passed: naming the macro is a compile error\n // if it is spelled wrong, and it is the only spelling upstream has.\n check(G_TYPE_UNICODE_TYPE == g_unicode_type_get_type()\n && G_TYPE_UNICODE_SCRIPT == g_unicode_script_get_type()\n && G_TYPE_UNICODE_BREAK_TYPE == g_unicode_break_type_get_type()\n && G_TYPE_NORMALIZE_MODE == g_normalize_mode_get_type(),\n \"the G_TYPE_* macros are upstream's, not the enumerator prefix\");\n\n // ── 3. a derived type, its property, its signal ──────────────────────\n const GType thing = test_thing_get_type();\n check(thing != 0 && g_type_is_a(thing, G_TYPE_OBJECT),\n \"a derived type registers and is a GObject\");\n std::printf(\" registered %s\\n\", g_type_name(thing));\n\n GObject *o = static_cast(g_object_new(thing, \"answer\", 42, nullptr));\n check(o != nullptr, \"g_object_new with a construct property\");\n\n gint got = 0;\n g_object_get(o, \"answer\", &got, nullptr);\n std::printf(\" answer = %d\\n\", got);\n check(got == 42, \"…and g_object_get reads it back through GValue\");\n\n g_signal_connect(o, \"changed\", G_CALLBACK(on_changed), nullptr);\n g_signal_emit(o, sig_changed, 0, 7);\n std::printf(\" the handler saw %d\\n\", seen);\n check(seen == 7, \"a signal is emitted and the handler runs\");\n\n // ── 4. GBinding — gbinding.c, and a real use of the property system ──\n GObject *b = static_cast(g_object_new(thing, nullptr));\n g_object_bind_property(o, \"answer\", b, \"answer\", G_BINDING_DEFAULT);\n g_object_set(o, \"answer\", 99, nullptr);\n gint mirrored = 0;\n g_object_get(b, \"answer\", &mirrored, nullptr);\n std::printf(\" bound object followed to %d\\n\", mirrored);\n check(mirrored == 99, \"g_object_bind_property keeps two objects in step\");\n\n g_object_unref(b);\n g_object_unref(o);\n check(true, \"objects destroyed without a warning\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gobject/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file consumes gnome.gobject through its headers; this one consumes\n// the same package through its module, so both doors are checked from the\n// OUTSIDE — which is what an index example is for.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE, and that is GCC's rule rather than a choice\n// here. A TU that imports the module AND textually includes a glib header\n// reaches twice — once through the module's global fragment, once\n// directly — and the same `struct tm` from the same file becomes two entities:\n//\n// error: conflicting declaration 'struct tm'\n// note: previous declaration as 'struct tm' (of module gnome.glib)\n//\n// So a consumer picks ONE route, and which one is decided by macros: a module\n// cannot carry them, and glib's are half its API. Code using `G_DEFINE_TYPE`,\n// `G_OBJECT` or `G_TYPE_*` takes the header route. Code using the FUNCTION API\n// — most of gio, and much of glib — imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.gobject; // re-exports gnome.glib\n\n#include \n#include \n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n std::printf(\"import gnome.gobject\\n\\n\");\n\n // ⚠️ EVERY G_TYPE_* IS A MACRO, so this file reaches the type registry\n // through the FUNCTIONS those macros wrap. That is not a workaround — it\n // is the shape of the module route, and it is why the header route stays\n // supported next door.\n const GType obj = g_object_get_type();\n check(obj != 0, \"g_object_get_type() — the registry answers\");\n check(std::strcmp(g_type_name(obj), \"GObject\") == 0, \"g_type_name(GObject)\");\n check(g_type_is_a(obj, obj), \"g_type_is_a on GObject itself\");\n\n // The four GTypes build.mcpp's glib-mkenums reimplementation produced.\n // ⭐ Their MACRO names are what shipped wrong once (G_UNICODE_TYPE_TYPE for\n // G_TYPE_UNICODE_TYPE) — but a macro cannot come through a module, so this\n // route checks the FUNCTIONS and the header route next door checks the\n // macros. Two different outputs of one generator, tested separately on\n // purpose.\n check(g_unicode_script_get_type() != 0 && g_unicode_type_get_type() != 0\n && g_unicode_break_type_get_type() != 0 && g_normalize_mode_get_type() != 0,\n \"the four generated enum GTypes register\");\n\n // GValue, entirely through functions.\n // G_VALUE_INIT is a MACRO; zero-initialising is what it expands to.\n GValue v = {};\n g_value_init(&v, g_type_from_name(\"gint\"));\n g_value_set_int(&v, 42);\n check(g_value_get_int(&v) == 42, \"GValue: set and get an int\");\n g_value_unset(&v);\n\n // A real object, created and reference-counted without a single macro.\n GObject *o = static_cast(g_object_new(obj, nullptr));\n check(o != nullptr, \"g_object_new(GObject)\");\n g_object_ref(o);\n g_object_unref(o);\n g_object_unref(o);\n\n // gnome.glib arrived through gnome.gobject, as glib-object.h -> glib.h does.\n char *s = g_strdup_printf(\"%d\", 7);\n check(s != nullptr && std::strcmp(s, \"7\") == 0,\n \"g_strdup_printf — gnome.glib came through gnome.gobject\");\n g_free(s);\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + } + ] + }, + { + "project": "godot-cpp-module-v10", + "text": "# godotengine.godot-cpp-m 10.0.0-rc1 (Godot 4.6) module-layer test project.\n#\n# Separate member from godot-cpp-module for the same reason compat has two:\n# the versions are different bindings of different engine releases, and each\n# has to be built and asserted on its own.\n#\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `godotengine`. A member-level [indices] REPLACES the inherited table,\n# so the module package's own compat.godot-cpp dependency resolves from the\n# GLOBAL published index -- that descriptor is pre-merge-validated by\n# tests/examples/godot-cpp-v10.\n[indices]\ngodotengine = { path = \"../../..\" }\n\n[package]\nname = \"godot-cpp-module-v10-tests\"\nversion = \"0.1.0\"\n\n[dependencies]\ngodotengine.godot-cpp-m = \"10.0.0-rc1\"\n", + "files": [ + { + "path": "tests/examples/godot-cpp-module-v10/tests/godot_cpp_module_v10.cpp", + "text": "// Behavioral test for the godotengine.godot-cpp-m 10.0.0-rc1 module package (Godot 4.6): godot-cpp's\n// API reached with `import godot_cpp;` and no #include at all.\n//\n// The numbers are deliberately the same ones tests/examples/godot-cpp asserts\n// through headers -- that member is the header spelling of this one, so a\n// divergence here means the module re-export changed behaviour.\n//\n// Both halves can fail:\n// * Vector2::length(), Basis::orthonormalized(), Color::to_rgba32() and\n// AABB::get_volume() are DEFINED in compat.godot-cpp's src/variant/*.cpp,\n// so they only resolve if that dependency really compiled and linked.\n// * Node, Node2D, Node::PROCESS_MODE_*, godot::OK and Variant::OBJECT exist\n// only in the pre-generated gen/ tree.\n//\n// Not covered here, by nature: anything routed through the\n// gdextension_interface_* function pointers (String, Array, class\n// registration) needs a Godot process that has loaded the extension. The\n// GDCLASS/macro shape is covered by the package's own test suite.\n\nimport std;\nimport godot_cpp;\n\nnamespace {\n\nbool close(double a, double b) {\n return std::fabs(a - b) < 1e-5;\n}\n\n} // namespace\n\nint main() {\n using namespace godot;\n\n const bool vec2_ok = close(Vector2(3, 4).length(), 5.0) &&\n close(Vector2(3, 4).normalized().length(), 1.0);\n\n const Vector3 cross = Vector3(1, 0, 0).cross(Vector3(0, 1, 0));\n const bool vec3_ok = cross == Vector3(0, 0, 1) &&\n close(Vector3(2, 3, 6).length(), 7.0);\n\n const bool basis_ok = close(Basis().orthonormalized().determinant(), 1.0);\n\n const bool color_ok = Color(1.0f, 0.0f, 0.0f, 1.0f).to_rgba32() == 0xff0000ffu;\n\n const AABB box(Vector3(0, 0, 0), Vector3(2, 3, 4));\n const AABB other(Vector3(1, 1, 1), Vector3(4, 4, 4));\n const bool aabb_ok = close(box.get_volume(), 24.0) &&\n box.intersects(other) &&\n close(box.intersection(other).get_volume(), 6.0);\n\n // These bindings are Godot 4.6. The 4.5 member checks GODOT_VERSION_MAJOR\n // / _MINOR, but those are MACROS from version.hpp and a module cannot\n // export macros -- so the version is asserted through types instead:\n // EditorDock and AHashMap exist in 4.6 and not in 4.5.\n const bool version_ok = sizeof(EditorDock) > 0 &&\n sizeof(AHashMap) > 0;\n\n const bool gen_ok = sizeof(Node) > 0 &&\n sizeof(Node2D) > 0 &&\n Node::PROCESS_MODE_INHERIT == 0 &&\n Node::PROCESS_MODE_DISABLED == 4 &&\n godot::OK == 0 &&\n godot::ERR_FILE_NOT_FOUND == 7 &&\n godot::SIDE_LEFT == 0 &&\n Variant::OBJECT != Variant::NIL;\n\n const bool math_ok = Math::is_equal_approx(1.0f, 1.0f) &&\n !Math::is_zero_approx(1.0f) &&\n close(Math::lerp(0.0, 10.0, 0.25), 2.5);\n\n const bool ok = version_ok && vec2_ok && vec3_ok && basis_ok && color_ok &&\n aabb_ok && gen_ok && math_ok;\n std::println(\"version={} vec2={} vec3={} basis={} color={} aabb={} gen={} math={}\",\n version_ok, vec2_ok, vec3_ok, basis_ok, color_ok, aabb_ok, gen_ok, math_ok);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "godot-cpp-module", + "text": "# godotengine.godot-cpp (module layer) test project: consumes `import godot_cpp;`\n# and asserts the same numbers the compat.godot-cpp member asserts through\n# headers -- if the module re-export changed behaviour, these would move.\n#\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `godotengine`. A member-level [indices] REPLACES the inherited table\n# rather than merging with it, which is what keeps this to ONE project index\n# repo -- two repos pointing at the same tree make every lookup ambiguous.\n#\n# The module package's own transitive dep (compat.godot-cpp) therefore resolves\n# from the GLOBAL published index, not from this checkout; the compat\n# descriptor is pre-merge-validated by its own member (tests/examples/godot-cpp).\n[indices]\ngodotengine = { path = \"../../..\" }\n\n[package]\nname = \"godot-cpp-module-tests\"\nversion = \"0.1.0\"\n\n# Dotted spelling on purpose: CI maps a changed pkgs//.lua to the\n# members whose mcpp.toml mentions , and here is the whole\n# `godotengine.godot-cpp-m`.\n[dependencies]\ngodotengine.godot-cpp-m = \"4.5.0\"\n", + "files": [ + { + "path": "tests/examples/godot-cpp-module/tests/godot_cpp_module.cpp", + "text": "// Behavioral test for the godotengine.godot-cpp-m module package: godot-cpp's\n// API reached with `import godot_cpp;` and no #include at all.\n//\n// The numbers are deliberately the same ones tests/examples/godot-cpp asserts\n// through headers -- that member is the header spelling of this one, so a\n// divergence here means the module re-export changed behaviour.\n//\n// Both halves can fail:\n// * Vector2::length(), Basis::orthonormalized(), Color::to_rgba32() and\n// AABB::get_volume() are DEFINED in compat.godot-cpp's src/variant/*.cpp,\n// so they only resolve if that dependency really compiled and linked.\n// * Node, Node2D, Node::PROCESS_MODE_*, godot::OK and Variant::OBJECT exist\n// only in the pre-generated gen/ tree.\n//\n// Not covered here, by nature: anything routed through the\n// gdextension_interface_* function pointers (String, Array, class\n// registration) needs a Godot process that has loaded the extension. The\n// GDCLASS/macro shape is covered by the package's own test suite.\n\nimport std;\nimport godot_cpp;\n\nnamespace {\n\nbool close(double a, double b) {\n return std::fabs(a - b) < 1e-5;\n}\n\n} // namespace\n\nint main() {\n using namespace godot;\n\n const bool vec2_ok = close(Vector2(3, 4).length(), 5.0) &&\n close(Vector2(3, 4).normalized().length(), 1.0);\n\n const Vector3 cross = Vector3(1, 0, 0).cross(Vector3(0, 1, 0));\n const bool vec3_ok = cross == Vector3(0, 0, 1) &&\n close(Vector3(2, 3, 6).length(), 7.0);\n\n const bool basis_ok = close(Basis().orthonormalized().determinant(), 1.0);\n\n const bool color_ok = Color(1.0f, 0.0f, 0.0f, 1.0f).to_rgba32() == 0xff0000ffu;\n\n const AABB box(Vector3(0, 0, 0), Vector3(2, 3, 4));\n const AABB other(Vector3(1, 1, 1), Vector3(4, 4, 4));\n const bool aabb_ok = close(box.get_volume(), 24.0) &&\n box.intersects(other) &&\n close(box.intersection(other).get_volume(), 6.0);\n\n const bool gen_ok = sizeof(Node) > 0 &&\n sizeof(Node2D) > 0 &&\n Node::PROCESS_MODE_INHERIT == 0 &&\n Node::PROCESS_MODE_DISABLED == 4 &&\n godot::OK == 0 &&\n godot::ERR_FILE_NOT_FOUND == 7 &&\n godot::SIDE_LEFT == 0 &&\n Variant::OBJECT != Variant::NIL;\n\n const bool math_ok = Math::is_equal_approx(1.0f, 1.0f) &&\n !Math::is_zero_approx(1.0f) &&\n close(Math::lerp(0.0, 10.0, 0.25), 2.5);\n\n const bool ok = vec2_ok && vec3_ok && basis_ok && color_ok && aabb_ok &&\n gen_ok && math_ok;\n std::println(\"vec2={} vec3={} basis={} color={} aabb={} gen={} math={}\",\n vec2_ok, vec3_ok, basis_ok, color_ok, aabb_ok, gen_ok, math_ok);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "godot-cpp-v10", + "text": "# compat.godot-cpp 10.0.0-rc1 (Godot 4.6) test project.\n#\n# Separate member rather than a second dependency in the 4.5 one: the two\n# versions are different bindings of different engine releases, and each has to\n# be built and asserted on its own -- same shape as catch2 / catch2-v2.\n#\n# `compat` is redirected to this checkout by the workspace-root [indices],\n# which every member inherits.\n\n[package]\nname = \"godot-cpp-v10-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngodot-cpp = \"10.0.0-rc1\"\n", + "files": [ + { + "path": "tests/examples/godot-cpp-v10/tests/godot_cpp_v10.cpp", + "text": "// Behavioral test for compat.godot-cpp 10.0.0-rc1 -- godot-cpp's own 10.x\n// version line, whose bindings target Godot 4.6.\n//\n// The point of a second member is that it can tell the two versions apart, so\n// the first thing asserted is WHICH bindings arrived: the generated version\n// header and a class that exists in 4.6 and not in 4.5. The rest mirrors the\n// 4.5 member so a behavioural difference between the two would show up as a\n// diff between two otherwise identical tests.\n//\n// As there, everything asserted is pure math or compile-time: anything routed\n// through the gdextension_interface_* pointers needs a Godot process that has\n// loaded the extension.\n\n#include // new in Godot 4.6\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nbool close(double a, double b) {\n return std::fabs(a - b) < 1e-5;\n}\n\n} // namespace\n\nusing namespace godot;\n\n// A GDCLASS subclass with bound methods -- the shape every GDExtension is\n// written in, and the one that breaks first on a new platform: without\n// TYPED_METHOD_BIND, ClassDB::bind_method casts member pointers through a\n// forward-declared class, which the MSVC ABI rejects outright. Compiling and\n// linking this is the assertion; it is never CALLED, because ClassDB and\n// StringName go through the gdextension_interface_* pointers, which are null\n// outside a Godot process that has loaded the extension.\nclass TestSprite : public Node {\n GDCLASS(TestSprite, Node)\n\nprotected:\n static void _bind_methods() {\n ClassDB::bind_method(D_METHOD(\"get_speed\"), &TestSprite::get_speed);\n ClassDB::bind_method(D_METHOD(\"set_speed\", \"speed\"), &TestSprite::set_speed);\n }\n\npublic:\n double get_speed() const { return speed; }\n void set_speed(double p_speed) { speed = p_speed; }\n\nprivate:\n double speed = 1.0;\n};\n\nint main() {\n // these bindings are Godot 4.6, not the 4.5 the sibling member pins\n const bool version_ok = GODOT_VERSION_MAJOR == 4 &&\n GODOT_VERSION_MINOR == 6 &&\n sizeof(EditorDock) > 0;\n\n // out-of-line variant math: proves the library linked\n const bool vec2_ok = close(Vector2(3, 4).length(), 5.0) &&\n close(Vector2(3, 4).normalized().length(), 1.0);\n\n const Vector3 cross = Vector3(1, 0, 0).cross(Vector3(0, 1, 0));\n const bool vec3_ok = cross == Vector3(0, 0, 1) &&\n close(Vector3(2, 3, 6).length(), 7.0);\n\n const bool basis_ok = close(Basis().orthonormalized().determinant(), 1.0);\n\n const bool color_ok = Color(1.0f, 0.0f, 0.0f, 1.0f).to_rgba32() == 0xff0000ffu;\n\n const AABB box(Vector3(0, 0, 0), Vector3(2, 3, 4));\n const AABB other(Vector3(1, 1, 1), Vector3(4, 4, 4));\n const bool aabb_ok = close(box.get_volume(), 24.0) &&\n box.intersects(other) &&\n close(box.intersection(other).get_volume(), 6.0);\n\n const bool gen_ok = sizeof(Node) > 0 &&\n Node::PROCESS_MODE_INHERIT == 0 &&\n Node::PROCESS_MODE_DISABLED == 4 &&\n godot::OK == 0 &&\n godot::ERR_FILE_NOT_FOUND == 7 &&\n Variant::OBJECT != Variant::NIL;\n\n // ODR-use what GDCLASS generated without calling into the engine\n const StringName &(*class_name_fn)() = &TestSprite::get_class_static;\n void (*init_fn)() = &TestSprite::initialize_class;\n const bool bind_ok = class_name_fn != nullptr && init_fn != nullptr &&\n std::is_base_of::value;\n\n const bool ok = bind_ok && version_ok && vec2_ok && vec3_ok && basis_ok && color_ok &&\n aabb_ok && gen_ok;\n std::printf(\"version=%d bind=%d vec2=%d vec3=%d basis=%d color=%d aabb=%d gen=%d\\n\",\n version_ok, bind_ok, vec2_ok, vec3_ok, basis_ok, color_ok, aabb_ok, gen_ok);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "godot-cpp", + "text": "# compat.godot-cpp test project: builds the pre-generated GDExtension bindings\n# and asserts on the part of the API that runs without a Godot process --\n# the Variant math types, whose out-of-line definitions live in the library's\n# src/variant/*.cpp, so a passing run proves the ~1000-TU library really did\n# compile and link.\n#\n# `compat` is redirected to this checkout by the workspace-root [indices],\n# which every member inherits.\n\n[package]\nname = \"godot-cpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngodot-cpp = \"4.5.0\"\n", + "files": [ + { + "path": "tests/examples/godot-cpp/tests/godot_cpp.cpp", + "text": "// Behavioral test for compat.godot-cpp.\n//\n// Two things are under test, and both can fail:\n//\n// 1. The PRE-GENERATED bindings are really in the package. gen/include is\n// what upstream's binding_generator.py would have produced at build time;\n// if it were missing, and the global\n// constants below would not compile at all.\n// 2. The library links. Vector2::length(), Basis::orthonormalized(),\n// Color::to_rgba32() and AABB::get_volume() are declared in the headers\n// but DEFINED in src/variant/*.cpp, so these calls only resolve if the\n// ~1000 translation units of the package were compiled and linked in.\n//\n// Everything asserted here is pure math -- no gdextension_interface function\n// pointers, so no running Godot process is needed. Engine classes are checked\n// by compiling against them (types, enums, sizes), which is all that can be\n// done outside a loaded extension.\n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nbool close(double a, double b) {\n return std::fabs(a - b) < 1e-5;\n}\n\n} // namespace\n\nusing namespace godot;\n\n// A GDCLASS subclass with bound methods -- the shape every GDExtension is\n// written in, and the one that breaks first on a new platform: without\n// TYPED_METHOD_BIND, ClassDB::bind_method casts member pointers through a\n// forward-declared class, which the MSVC ABI rejects outright. Compiling and\n// linking this is the assertion; it is never CALLED, because ClassDB and\n// StringName go through the gdextension_interface_* pointers, which are null\n// outside a Godot process that has loaded the extension.\nclass TestSprite : public Node {\n GDCLASS(TestSprite, Node)\n\nprotected:\n static void _bind_methods() {\n ClassDB::bind_method(D_METHOD(\"get_speed\"), &TestSprite::get_speed);\n ClassDB::bind_method(D_METHOD(\"set_speed\", \"speed\"), &TestSprite::set_speed);\n }\n\npublic:\n double get_speed() const { return speed; }\n void set_speed(double p_speed) { speed = p_speed; }\n\nprivate:\n double speed = 1.0;\n};\n\nint main() {\n // --- out-of-line variant math: proves the library linked ---\n const bool vec2_ok = close(Vector2(3, 4).length(), 5.0) &&\n close(Vector2(3, 4).normalized().length(), 1.0);\n\n const Vector3 cross = Vector3(1, 0, 0).cross(Vector3(0, 1, 0));\n const bool vec3_ok = cross == Vector3(0, 0, 1) &&\n close(Vector3(2, 3, 6).length(), 7.0);\n\n const Basis basis = Basis().orthonormalized();\n const bool basis_ok = close(basis.determinant(), 1.0);\n\n const bool color_ok = Color(1.0f, 0.0f, 0.0f, 1.0f).to_rgba32() == 0xff0000ffu;\n\n const AABB box(Vector3(0, 0, 0), Vector3(2, 3, 4));\n const AABB other(Vector3(1, 1, 1), Vector3(4, 4, 4));\n const bool aabb_ok = close(box.get_volume(), 24.0) &&\n box.intersects(other) &&\n close(box.intersection(other).get_volume(), 1.0 * 2.0 * 3.0);\n\n // --- generated bindings: engine class + global enums are visible ---\n const bool gen_ok = sizeof(Node) > 0 &&\n Node::PROCESS_MODE_INHERIT == 0 &&\n Node::PROCESS_MODE_DISABLED == 4 &&\n godot::OK == 0 &&\n godot::ERR_FILE_NOT_FOUND == 7 &&\n godot::SIDE_LEFT == 0 &&\n Variant::OBJECT != Variant::NIL;\n\n // ODR-use what GDCLASS generated without calling into the engine\n const StringName &(*class_name_fn)() = &TestSprite::get_class_static;\n void (*init_fn)() = &TestSprite::initialize_class;\n const bool bind_ok = class_name_fn != nullptr && init_fn != nullptr &&\n std::is_base_of::value;\n\n const bool ok = bind_ok && vec2_ok && vec3_ok && basis_ok && color_ok && aabb_ok && gen_ok;\n std::printf(\"bind=%d vec2=%d vec3=%d basis=%d color=%d aabb=%d gen=%d\\n\",\n bind_ok, vec2_ok, vec3_ok, basis_ok, color_ok, aabb_ok, gen_ok);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "grpc-codegen", + "text": "# grpc-codegen test project: the ONE-DEPENDENCY form of gRPC codegen.\n#\n# Its sibling tests/examples/grpc-module covers the module surface with no\n# codegen at all. This member covers what that one's header says is impossible:\n#\n# \"NO protoc output anywhere: gRPC's codegen needs host tools mcpp cannot\n# hand a consumer\"\n#\n# That stopped being true in mcpp 2026.8.6.2. `features = [\"codegen\"]` makes\n# the grpc package re-export protoc, grpc_cpp_plugin and the grpcgen rule to\n# whoever depends on it, so this manifest names ONE package and build.mcpp is\n# one line — and the .proto below is compiled from a stub that did not exist\n# when the build started.\n#\n# What this member protects, and what no other member can:\n# * `reexport = true` really reaches a consumer through the published index\n# (every other test of it is a path dependency in mcpp's own e2e);\n# * `rerun_if_changed_glob` really drives `generate_all()` — the file list is\n# never written down anywhere;\n# * the host tools are built for the BUILD machine and produce stubs that\n# link against the runtime this project links, which is the property the\n# whole design exists to make inexpressible-to-get-wrong.\n#\n# linux + macOS only, for the same reason grpc-module is: compat.openssl has no\n# windows xpm entry, so on windows this member carries no dependency and the\n# test compiles to a no-op main().\n[package]\nname = \"grpc-codegen-tests\"\nversion = \"0.1.0\"\n\n# `cfg(unix)`, not one block per OS: the condition is \"everywhere gRPC\n# resolves\", and what excludes windows is compat.openssl having no xpm entry\n# there — a single fact, so a single predicate. mcpp evaluates `unix` as\n# family == \"unix\" (src/build/prepare.cppm, alongside any()/all()/not()).\n[target.'cfg(unix)'.dependencies.grpc]\ngrpc = { version = \"1.83.0\", features = [\"codegen\"] }\n\n[target.'cfg(unix)'.build]\ncxxflags = [\"-DHAVE_GRPC=1\"]\n\n# windows carries the RULE but not gRPC itself, so build.mcpp compiles (its\n# `import grpcgen;` cannot be conditional) and then returns early. grpcgen is a\n# pure C++23 rule package — no openssl, no platform-limited dependency — which\n# is why it is available here while grpc is not.\n[target.'cfg(windows)'.dependencies.mcpplibs]\ngrpcgen = { version = \"1.83.0\", host-module = true }\n", + "files": [ + { + "path": "tests/examples/grpc-codegen/tests/codegen.cpp", + "text": "// Asserts the generated stubs exist, compile and carry the SERVICE — a\n// protobuf-only run would produce echo.pb.* and no Echo::Service, so a\n// half-configured codegen cannot pass this.\n//\n// HAVE_GRPC comes from this project's own cfg-gated cxxflags: the package is\n// linux/macOS-only, so elsewhere this file is an empty main().\n#ifdef HAVE_GRPC\n\n#include \n#include \n\n#include \"echo.pb.h\"\n#include \"echo.grpc.pb.h\"\n\nint main() {\n // The message half: a real generated type, round-tripped.\n echotest::EchoRequest req;\n req.set_text(\"mcpp\");\n std::string wire;\n if (!req.SerializeToString(&wire)) return 1;\n echotest::EchoRequest back;\n if (!back.ParseFromString(wire)) return 1;\n if (back.text() != \"mcpp\") return 1;\n\n // The SERVICE half — this is what grpc_cpp_plugin produced, and what a\n // protoc-only run would not have.\n const std::string method = echotest::Echo::service_full_name();\n std::printf(\"service = %s\\n\", method.c_str());\n if (method != \"echotest.Echo\") return 1;\n\n std::printf(\"grpc-codegen: OK\\n\");\n return 0;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "grpc-module", + "text": "# grpc test project: consumes the mcpplibs.grpc Form-A package (grpc-m) and\n# asserts behavior under `mcpp test`.\n#\n# The `default` namespace redirect points the built-in mcpplibs namespace at\n# this checkout, so the descriptor under test is pkgs/g/grpc.lua — not whatever\n# the published index happens to hold. grpc's own compat.* dependencies still\n# resolve from the published index, which is the point: this member proves the\n# descriptor works against the real dependency set.\n#\n# NO protoc output anywhere: this member exercises the MODULE surface. Codegen\n# is a separate member, tests/examples/grpc-codegen, which declares one\n# dependency and lets the grpc package hand it the whole toolchain.\n#\n# (This used to read \"gRPC's codegen needs host tools mcpp cannot hand a\n# consumer\". That stopped being true in mcpp 2026.8.6.2 — `reexport = true`\n# is exactly the mechanism for handing them over.)\n[package]\nname = \"grpc-module-tests\"\nversion = \"0.1.0\"\n\n# unix only: the package has no windows xpm entry (its compat.openssl\n# dependency has none yet), so on windows this member carries no dependency at\n# all and the test compiles to a no-op main() — the same shape\n# tests/examples/openssl uses for the same underlying reason.\n#\n# One `cfg(unix)` rather than a linux block and an identical macos block: the\n# condition is a single fact (\"gRPC resolves everywhere except windows\"), so it\n# should be written once. mcpp evaluates `unix` as family == \"unix\"\n# (src/build/prepare.cppm, alongside any()/all()/not()).\n[target.'cfg(unix)'.dependencies.grpc]\ngrpc = \"1.83.0\"\n\n[target.'cfg(unix)'.build]\ncxxflags = [\"-DHAVE_GRPC=1\"]\n\n[indices]\ngrpc = { path = \"../../..\" }\n", + "files": [ + { + "path": "tests/examples/grpc-module/tests/module.cpp", + "text": "// Behavioral test for the mcpplibs.grpc package, driven entirely through\n// `import grpc;` — there is no include in this file. Every call\n// reaches a real symbol in the linked gRPC, so a package that resolved and\n// \"built\" but linked nothing could not pass.\n//\n// Note the ORDER: textual #includes first, `import grpc;` last. The module\n// carries the standard library in its BMI, so a std header included after the\n// import arrives twice and GCC fails on std::string's operator==.\n// HAVE_GRPC comes from this project's own cfg-gated cxxflags — the package is\n// linux/macOS-only (see mcpp.toml), so elsewhere this file is an empty main().\n#ifdef HAVE_GRPC\n\n#include \n#include \n\nimport grpc;\n\nint main() {\n // A version string read out of the library, not a header constant.\n const std::string version = grpc::Version();\n std::printf(\"grpc::Version() = %s\\n\", version.c_str());\n if (version.rfind(\"1.83\", 0) != 0) return 1;\n\n // grpc::Status, error path included — an always-OK stub cannot pass.\n const grpc::Status bad(grpc::StatusCode::UNAVAILABLE, \"down\");\n if (bad.ok() || bad.error_code() != grpc::StatusCode::UNAVAILABLE ||\n bad.error_message() != \"down\" || !grpc::Status::OK.ok()) {\n return 1;\n }\n\n // A real channel object. Channels connect lazily, so no server is needed;\n // IDLE before the first RPC proves this is a live object from the library.\n auto channel = grpc::CreateChannel(\"127.0.0.1:1\", grpc::InsecureChannelCredentials());\n if (!channel) return 1;\n if (channel->GetState(false) != GRPC_CHANNEL_IDLE) return 1;\n\n std::puts(\"grpc module: OK\");\n return 0;\n}\n\n#else // !HAVE_GRPC\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "gtl", + "text": "# gtl test project: exercise the two container families people actually reach\n# for -- the Swiss-table flat_hash_map and the ordered btree_set.\n[package]\nname = \"gtl-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngtl = \"1.2.0\"\n", + "files": [ + { + "path": "tests/examples/gtl/tests/containers.cpp", + "text": "// compat.gtl — exercise the two container families that are the reason to pull\n// gtl in: the Swiss-table flat_hash_map and the ordered btree_set. Every check\n// asserts observable behaviour (contents, ordering, erasure), not just that the\n// headers compile.\n#include \n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n // ---- flat_hash_map ---------------------------------------------------\n gtl::flat_hash_map m;\n for (int i = 0; i < 1000; ++i) m.emplace(std::format(\"k{}\", i), i);\n check(m.size() == 1000, \"flat_hash_map size after 1000 emplaces\");\n check(m.at(\"k0\") == 0 && m.at(\"k999\") == 999, \"lookup returns what was stored\");\n check(m.find(\"absent\") == m.end(), \"miss returns end()\");\n\n // Rehashing must not lose or corrupt entries.\n long sum = 0;\n for (const auto& [k, v] : m) sum += v;\n check(sum == 999L * 1000 / 2, \"every value survives rehashing\");\n\n check(m.erase(\"k500\") == 1, \"erase reports one removal\");\n check(m.erase(\"k500\") == 0, \"second erase reports none\");\n check(m.size() == 999, \"size after erase\");\n check(!m.contains(\"k500\"), \"erased key is gone\");\n\n // ---- parallel_flat_hash_map -----------------------------------------\n // Same API, internally sharded. Worth touching because it is gtl's\n // headline container and instantiates a different code path.\n gtl::parallel_flat_hash_map pm;\n for (int i = 0; i < 500; ++i) pm[i] = i * i;\n check(pm.size() == 500, \"parallel map size\");\n check(pm[20] == 400, \"parallel map value\");\n\n // ---- btree_set -------------------------------------------------------\n // Unlike the hash maps, this one is ORDERED -- assert that.\n gtl::btree_set s;\n for (int i : {42, 7, 99, 1, 63, 7}) s.insert(i);\n check(s.size() == 5, \"btree_set drops the duplicate\");\n check(std::ranges::is_sorted(s), \"btree_set iterates in order\");\n check(*s.begin() == 1 && *s.rbegin() == 99, \"btree_set endpoints\");\n check(*s.lower_bound(50) == 63, \"btree_set lower_bound\");\n\n if (ok) std::println(\"gtl OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "gui-stack", + "text": "# Linux GUI runtime stack: glfw + the X11/XCB libraries. These are Linux-only;\n# under cfg(linux) the deps are pulled and the tests assert symbol linkage +\n# headless behavior. Off-Linux the test files compile to no-op main() (the deps\n# aren't pulled), so `mcpp test --workspace` is a clean no-op on macOS/Windows.\n[package]\nname = \"gui-stack-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nglfw = \"3.4.0.1\"\nx11 = \"1.8.13\"\nxcb = \"1.17.0\"\nxext = \"1.3.7\"\nxrender = \"0.9.12\"\nxfixes = \"6.0.2\"\nxcursor = \"1.2.3\"\nxinerama = \"1.1.6\"\nxrandr = \"1.5.5\"\nxi = \"1.8.3\"\nxau = \"1.0.12\"\nxdmcp = \"1.1.5\"\n", + "files": [ + { + "path": "tests/examples/gui-stack/tests/glfw.cpp", + "text": "#ifdef __linux__\n#include \nint main() {\n glfwSetErrorCallback(nullptr);\n if (glfwInit()) glfwTerminate(); // tolerant: headless CI has no display\n return GLFW_VERSION_MAJOR == 3 ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gui-stack/tests/x11.cpp", + "text": "#ifdef __linux__\n#include \n#include \nint main() {\n const KeySym escape = XStringToKeysym(\"Escape\");\n return X_PROTOCOL == 11 && escape == XK_Escape ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gui-stack/tests/xcb.cpp", + "text": "#ifdef __linux__\n#include \n#include \nint main() {\n char* host = nullptr; int display = -1, screen = -1;\n const int ok = xcb_parse_display(\":0.1\", &host, &display, &screen);\n std::free(host);\n return ok && display == 0 && screen == 1 ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gui-stack/tests/xlibs.cpp", + "text": "#ifdef __linux__\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nextern \"C\" int XextCreateExtension(void);\nint main() {\n return XextCreateExtension != nullptr && XRenderQueryExtension != nullptr &&\n XFixesQueryExtension != nullptr && XcursorLibraryPath != nullptr &&\n XineramaQueryExtension != nullptr && XRRQueryExtension != nullptr &&\n XIQueryVersion != nullptr ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + }, + { + "path": "tests/examples/gui-stack/tests/xorg.cpp", + "text": "#ifdef __linux__\n#include \n#include \nint main() {\n ARRAY8 array{};\n const int allocated = XdmcpAllocARRAY8(&array, 1);\n if (allocated) XdmcpDisposeARRAY8(&array);\n char* auth_file = XauFileName();\n return allocated && auth_file != nullptr ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "gzip-hpp", + "text": "# compat.gzip-hpp test project. Header-only over zlib: the package's dependency\n# on compat.zlib is what makes resolve and the deflate symbols link,\n# and this member declares only gzip-hpp so that edge is exercised\n# transitively. `compat` redirect inherited from the workspace root.\n[package]\nname = \"gzip-hpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ngzip-hpp = \"0.1.0\"\n", + "files": [ + { + "path": "tests/examples/gzip-hpp/tests/roundtrip.cpp", + "text": "// Behavioral test — compat.gzip-hpp compresses and decompresses through zlib.\n//\n// Round-trip alone would pass on a library that did nothing, so the test also\n// asserts the gzip MAGIC on the compressed bytes and that a compressible\n// payload actually shrinks. Both are answers only real deflate can give.\n//\n// It is also the link-time proof for the package's zlib dependency: every\n// function called here bottoms out in `deflate`/`inflate`.\n#include \n#include \n#include \n\n#include \n#include \n\nint main() {\n std::string original;\n for (int i = 0; i < 200; ++i) original += \"mcpp-index gzip round trip \";\n\n const std::string compressed = gzip::compress(original.data(), original.size());\n\n if (!gzip::is_compressed(compressed.data(), compressed.size())) {\n std::printf(\"gzip::is_compressed says the output is not gzip data\\n\");\n return 1;\n }\n // 0x1f 0x8b is the gzip magic; a wrapper that silently passed bytes\n // through would fail here rather than at the round trip.\n if (compressed.size() < 2 ||\n static_cast(compressed[0]) != 0x1f ||\n static_cast(compressed[1]) != 0x8b) {\n std::printf(\"compressed output has no gzip magic\\n\");\n return 2;\n }\n if (compressed.size() >= original.size()) {\n std::printf(\"highly repetitive input did not shrink: %zu -> %zu\\n\",\n original.size(), compressed.size());\n return 3;\n }\n\n const std::string restored = gzip::decompress(compressed.data(), compressed.size());\n if (restored != original) {\n std::printf(\"round trip differs (%zu vs %zu bytes)\\n\", restored.size(), original.size());\n return 4;\n }\n\n std::printf(\"compat.gzip-hpp: ok (%zu -> %zu -> %zu bytes, gzip magic present)\\n\",\n original.size(), compressed.size(), restored.size());\n return 0;\n}\n" + } + ] + }, + { + "project": "harfbuzz", + "text": "# HarfBuzz test project: shape a real buffer and assert the FreeType bridge is\n# linked in, under `mcpp test`.\n#\n# compat.freetype arrives transitively through compat.harfbuzz's own deps, so\n# it is not repeated here -- the test includes , whose FT_* parameter\n# types come from that transitive include path. If the propagation ever broke,\n# this member would fail to compile, which is the point.\n[package]\nname = \"harfbuzz-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nharfbuzz = \"14.3.0\"\n", + "files": [ + { + "path": "tests/examples/harfbuzz/tests/shape.cpp", + "text": "// compat.harfbuzz — assert the shaper actually runs, and that the FreeType\n// backend is compiled in rather than merely declared.\n//\n// Shaping needs no font file: hb_font_get_empty() gives a font whose glyphs\n// are all .notdef, which is enough to prove the buffer pipeline (itemize →\n// shape → read back infos/positions) is wired and returns one glyph per input\n// codepoint. That is a real assertion -- if hb-ot-shape.cc had not been\n// compiled into the amalgamation, glyph_count would be 0.\n#include \n#include \nimport std;\n\nint main() {\n bool ok = true;\n\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n check(hb_version_string() != nullptr, \"hb_version_string returned null\");\n\n unsigned major = 0, minor = 0, micro = 0;\n hb_version(&major, &minor, µ);\n check(major >= 14, \"unexpected HarfBuzz major version\");\n\n // Shape a short ASCII run through the empty font.\n hb_buffer_t* buf = hb_buffer_create();\n check(hb_buffer_allocation_successful(buf), \"hb_buffer_create failed\");\n\n static constexpr std::string_view text = \"mcpp\";\n hb_buffer_add_utf8(buf, text.data(), static_cast(text.size()), 0,\n static_cast(text.size()));\n hb_buffer_set_direction(buf, HB_DIRECTION_LTR);\n hb_buffer_set_script(buf, HB_SCRIPT_LATIN);\n hb_buffer_set_language(buf, hb_language_from_string(\"en\", -1));\n\n hb_shape(hb_font_get_empty(), buf, nullptr, 0);\n\n unsigned glyph_count = 0;\n hb_glyph_info_t* infos = hb_buffer_get_glyph_infos(buf, &glyph_count);\n hb_glyph_position_t* pos = hb_buffer_get_glyph_positions(buf, &glyph_count);\n\n check(glyph_count == text.size(), \"shaped glyph count != input length\");\n check(infos != nullptr && pos != nullptr, \"shaped buffer has no infos/positions\");\n if (infos != nullptr) {\n // The empty font maps everything to .notdef (glyph 0); cluster values\n // must still track the original byte offsets.\n for (unsigned i = 0; i < glyph_count; ++i) {\n check(infos[i].cluster == i, \"cluster does not track input offset\");\n }\n }\n\n hb_buffer_destroy(buf);\n\n // The FreeType bridge: taking its address proves hb-ft.cc was compiled in.\n // Without -DHAVE_FREETYPE the header still declares this and the build\n // fails at LINK time, which is exactly what this catches.\n check(reinterpret_cast(&hb_ft_face_create_referenced) != nullptr,\n \"hb_ft_face_create_referenced missing (FreeType backend not built)\");\n\n std::println(\"compat.harfbuzz smoke test: {} (hb {})\", ok ? \"ok\" : \"FAILED\",\n hb_version_string());\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "httplib-brotli", + "text": "[package]\nname = \"httplib-brotli-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = { version = \"0.53.1\", features = [\"brotli\"] }\n", + "files": [ + { + "path": "tests/examples/httplib-brotli/tests/brotli.cpp", + "text": "#ifndef CPPHTTPLIB_BROTLI_SUPPORT\n#error \"compat.httplib[brotli] must define CPPHTTPLIB_BROTLI_SUPPORT in consumers\"\n#endif\n\n#define MCPP_HTTPLIB_FEATURE_NAME \"brotli\"\n#define MCPP_HTTPLIB_ENCODING \"br\"\n#include \"../../../fixtures/httplib_compression_case.hpp\"\n" + } + ] + }, + { + "project": "httplib-no-exceptions", + "text": "[package]\nname = \"httplib-no-exceptions-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = { version = \"0.53.1\", features = [\"no-exceptions\"] }\n", + "files": [ + { + "path": "tests/examples/httplib-no-exceptions/tests/no_exceptions.cpp", + "text": "#ifndef CPPHTTPLIB_NO_EXCEPTIONS\n#error \"compat.httplib[no-exceptions] must define CPPHTTPLIB_NO_EXCEPTIONS in consumers\"\n#endif\n\n#include \n\n#include \n\nint main() {\n // Without CPPHTTPLIB_NO_EXCEPTIONS this constructor throws for an invalid\n // scheme. The feature changes it into a normal invalid client result.\n httplib::Client invalid(\"ftp://127.0.0.1:1\");\n if (invalid.is_valid()) return 1;\n\n httplib::Headers headers;\n const char *prohibited = httplib::detail::get_header_value(\n headers, \"REMOTE_ADDR\", \"missing\", 0);\n return prohibited != nullptr && prohibited[0] == '\\0' ? 0 : 2;\n}\n" + } + ] + }, + { + "project": "httplib-tls", + "text": "[package]\nname = \"httplib-tls-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = { version = \"0.53.1\", features = [\"tls\"] }\n", + "files": [ + { + "path": "tests/examples/httplib-tls/tests/tls.cpp", + "text": "#ifndef CPPHTTPLIB_OPENSSL_SUPPORT\n#error \"compat.httplib[tls] must define CPPHTTPLIB_OPENSSL_SUPPORT in consumers\"\n#endif\n\n#include \n\n#include \"../../../fixtures/httplib_localhost_pem.hpp\"\n\n#include \n#include \n#include \n#include \n\nint main() {\n httplib::SSLServer::PemMemory pem{\n kHttplibCertificatePem,\n std::strlen(kHttplibCertificatePem),\n kHttplibPrivateKeyPem,\n std::strlen(kHttplibPrivateKeyPem),\n nullptr,\n 0,\n nullptr,\n };\n httplib::SSLServer server(pem);\n if (!server.is_valid()) return 1;\n\n const std::string expected = \"certificate-validated HTTPS response\";\n std::atomic handled{false};\n server.Get(\"/secure\", [&](const httplib::Request &req, httplib::Response &res) {\n handled = req.method == \"GET\" && req.path == \"/secure\";\n res.set_content(expected, \"text/plain\");\n });\n\n const int port = server.bind_to_any_port(\"127.0.0.1\");\n if (port <= 0) return 2;\n std::thread server_thread([&] { server.listen_after_bind(); });\n\n httplib::SSLClient client(\"localhost\", port);\n client.enable_system_ca(false);\n client.load_ca_cert_store(kHttplibCertificatePem,\n std::strlen(kHttplibCertificatePem));\n client.set_connection_timeout(8, 0);\n client.set_read_timeout(8, 0);\n const auto result = client.Get(\"/secure\");\n\n const bool ok = result && result->status == httplib::StatusCode::OK_200 &&\n result->get_header_value(\"Content-Type\") == \"text/plain\" &&\n result->body == expected && handled.load();\n\n server.stop();\n server_thread.join();\n return ok ? 0 : 3;\n}\n" + } + ] + }, + { + "project": "httplib-zlib", + "text": "[package]\nname = \"httplib-zlib-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = { version = \"0.53.1\", features = [\"zlib\"] }\n", + "files": [ + { + "path": "tests/examples/httplib-zlib/tests/zlib.cpp", + "text": "#ifndef CPPHTTPLIB_ZLIB_SUPPORT\n#error \"compat.httplib[zlib] must define CPPHTTPLIB_ZLIB_SUPPORT in consumers\"\n#endif\n\n#define MCPP_HTTPLIB_FEATURE_NAME \"zlib\"\n#define MCPP_HTTPLIB_ENCODING \"gzip\"\n#include \"../../../fixtures/httplib_compression_case.hpp\"\n" + } + ] + }, + { + "project": "httplib-zstd", + "text": "[package]\nname = \"httplib-zstd-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = { version = \"0.53.1\", features = [\"zstd\"] }\n", + "files": [ + { + "path": "tests/examples/httplib-zstd/tests/zstd.cpp", + "text": "#ifndef CPPHTTPLIB_ZSTD_SUPPORT\n#error \"compat.httplib[zstd] must define CPPHTTPLIB_ZSTD_SUPPORT in consumers\"\n#endif\n\n#define MCPP_HTTPLIB_FEATURE_NAME \"zstd\"\n#define MCPP_HTTPLIB_ENCODING \"zstd\"\n#include \"../../../fixtures/httplib_compression_case.hpp\"\n" + } + ] + }, + { + "project": "httplib", + "text": "[package]\nname = \"httplib-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nhttplib = \"0.53.1\"\n", + "files": [ + { + "path": "tests/examples/httplib/tests/http.cpp", + "text": "#include \n\n#include \n#include \n#include \n\n#if defined(CPPHTTPLIB_OPENSSL_SUPPORT) || defined(CPPHTTPLIB_ZLIB_SUPPORT) || \\\n defined(CPPHTTPLIB_BROTLI_SUPPORT) || defined(CPPHTTPLIB_ZSTD_SUPPORT) || \\\n defined(CPPHTTPLIB_NO_EXCEPTIONS)\n#error \"compat.httplib enables no optional feature by default\"\n#endif\n\nint main() {\n const std::string expected = R\"({\"package\":\"compat.httplib\",\"ok\":true})\";\n std::atomic handled{false};\n\n httplib::Server server;\n server.Get(\"/status\", [&](const httplib::Request &req, httplib::Response &res) {\n handled = req.method == \"GET\" && req.path == \"/status\";\n res.set_content(expected, \"application/json\");\n });\n\n const int port = server.bind_to_any_port(\"127.0.0.1\");\n if (port <= 0) return 1;\n\n std::thread server_thread([&] { server.listen_after_bind(); });\n\n httplib::Client client(\"127.0.0.1\", port);\n client.set_connection_timeout(5, 0);\n client.set_read_timeout(5, 0);\n const auto result = client.Get(\"/status\");\n\n const bool ok = result && result->status == httplib::StatusCode::OK_200 &&\n result->get_header_value(\"Content-Type\") == \"application/json\" &&\n result->body == expected && handled.load();\n\n server.stop();\n server_thread.join();\n return ok ? 0 : 2;\n}\n" + } + ] + }, + { + "project": "huxerui-module", + "text": "# A member-level redirect replaces the workspace root's compat redirect, so\n# this test resolves huxerui.huxerui from the current checkout.\n[indices]\nhuxerui = { path = \"../../..\" }\n\n[package]\nname = \"huxerui-module-tests\"\nversion = \"0.1.0\"\n# C++20, because the DEPENDENCY says so and mcpp applies the ROOT's standard to\n# the whole module graph -- upstream's mcpp.toml spells that out:\n#\n# standard = \"c++20\"\n# # The SDK ABI baseline. Do not raise it: mcpp applies the root standard\n# # to the whole module graph, so this is imposed on every consumer too.\n#\n# Leaving this unset defaulted the member to c++23 and compiled huxerui's own\n# sources at c++23, above the baseline it declares. On windows that is not\n# academic: clang 20.1.7 against MSVC STL 14.51 then instantiates\n# `std::_Find_vectorized` and dies on `static_assert(false, \"unexpected size\")`\n# in . `import std;` still works here -- huxerui is\n# `language = \"c++20\", import_std = true`.\nstandard = \"c++20\"\n\n[dependencies.huxerui]\nhuxerui = \"0.3.0\"\n", + "files": [ + { + "path": "tests/examples/huxerui-module/tests/runtime.cpp", + "text": "// HuxerUI is a GUI framework, and this test opens no window: a CI runner has\n// no display, and the point here is that the PACKAGE resolves, builds and\n// links, not that GTK can paint.\n//\n// The last assertion is the one that carries that weight. Rect/Color are\n// header-attached entities that `import huxerui;` re-exports, so on their own\n// they would still compile if libhuxerui had never been built.\n// FlatLightThemeSpec/FlatDarkThemeSpec are out-of-line definitions living in\n// the library itself, so reaching them is what proves the link.\nimport std;\nimport huxerui;\n\nusing huxerui::Color;\nusing huxerui::Point;\nusing huxerui::Rect;\n\nint main() {\n const Rect bounds{10.0F, 20.0F, 100.0F, 50.0F};\n const bool inside = bounds.Contains(Point{60.0F, 40.0F});\n const bool outside = !bounds.Contains(Point{5.0F, 40.0F});\n\n // Rgba32 decodes 0xRRGGBBAA, so this is opaque orange.\n const Color orange = Color::Rgba32(0xFF8000FFU);\n const bool decoded = orange == Color::Rgb(255, 128, 0);\n\n const bool themes_differ =\n !(huxerui::FlatLightThemeSpec() == huxerui::FlatDarkThemeSpec());\n\n return (inside && outside && decoded && themes_differ) ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "imgui-module", + "text": "# Public `imgui` C++23-module package test (bare-name consumption from the\n# builtin default namespace — resolved against THIS checkout via the\n# workspace-root `[indices]` redirect, which every member inherits). Replaces the\n# former tests/smoke_imgui_module.sh reseeding driver. Linux-gated to the\n# validated surface: the package descriptor covers three platforms, but only\n# linux has ever been exercised by index CI; off-Linux the test compiles to a\n# no-op main(). Toolchain: workspace default (gcc@16.1.0) — the old smoke's\n# llvm@20.1.7 pin predates gcc16 module support; imgui-m upstream CI builds\n# the module layer with gcc@16.1.0.\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `default`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nocornut = { path = \"../../..\" }\n\n[package]\nname = \"imgui-module-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.ocornut]\nimgui = \"1.92.8\"\n", + "files": [ + { + "path": "tests/examples/imgui-module/tests/imgui_module.cpp", + "text": "// Public imgui module package end-to-end assertion: import-only consumption\n// (imgui.core + glfw_opengl3 backend), context + font atlas + one frame,\n// software-only (no window). Linux-only (see mcpp.toml).\n#ifdef __linux__\nimport std;\nimport imgui.core;\nimport imgui.backend.glfw_opengl3;\n\nint main() {\n auto init = &ImGui::Backend::GlfwOpenGL3::Init;\n auto shutdown = &ImGui::Backend::GlfwOpenGL3::Shutdown;\n if (init == nullptr || shutdown == nullptr) {\n return 1;\n }\n\n ImGuiContext* context = ImGui::CreateContext();\n if (context == nullptr) {\n return 2;\n }\n ImGui::SetCurrentContext(context);\n\n ImGuiIO& io = ImGui::GetIO();\n io.DisplaySize = ImVec2 { 320.0f, 240.0f };\n unsigned char* pixels = nullptr;\n int width = 0;\n int height = 0;\n io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);\n if (pixels == nullptr || width <= 0 || height <= 0) {\n ImGui::DestroyContext(context);\n return 3;\n }\n\n ImGui::NewFrame();\n bool open = true;\n ImGui::Begin(\"mcpp-index imgui smoke\", &open);\n ImGui::TextUnformatted(\"import imgui.core\");\n ImGui::End();\n ImGui::Render();\n\n std::println(\"Dear ImGui {} module package ok\", ImGui::GetVersion());\n ImGui::DestroyContext(context);\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "imgui-window", + "text": "# imgui + glfw + the OpenGL3/GLFW backends. Linux-only (cfg). The BUILD + LINK is\n# the test (the backends compile and glfw/GL link); the actual window run needs a\n# display and is opt-in (set MCPP_RUN_WINDOW=1). Off-Linux: no-op.\n[package]\nname = \"imgui-window-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nimgui = \"1.92.8\"\nglfw = \"3.4.0.1\"\n[target.'cfg(linux)'.build]\nldflags = [\"-ldl\"]\n", + "files": [ + { + "path": "tests/examples/imgui-window/tests/imgui_glfw_backend.cpp", + "text": "// Build + link the full imgui GLFW/OpenGL3 backends (the recipe coverage). The\n// window run requires a display, so it is opt-in (MCPP_RUN_WINDOW=1); headless\n// CI compiles + links and returns 0. Off-Linux this is a no-op.\n#ifdef __linux__\n#include \n#include \n\n#define GLFW_INCLUDE_NONE\n#include \n#include \n#include \n#include \n\n#include \"imgui_impl_glfw.cpp\"\n#include \"imgui_impl_opengl3.cpp\"\n\nstatic int run_window() {\n glfwSetErrorCallback([](int code, const char* message) {\n std::fprintf(stderr, \"GLFW error %d: %s\\n\", code, message ? message : \"\");\n });\n if (!glfwInit()) return 10;\n glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);\n glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 2);\n glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 0);\n GLFWwindow* window = glfwCreateWindow(320, 180, \"mcpp compat imgui\", nullptr, nullptr);\n if (!window) { glfwTerminate(); return 11; }\n glfwMakeContextCurrent(window);\n IMGUI_CHECKVERSION();\n ImGui::CreateContext();\n ImGuiIO& io = ImGui::GetIO();\n io.IniFilename = nullptr;\n io.DisplaySize = ImVec2(320.0f, 180.0f);\n io.DeltaTime = 1.0f / 60.0f;\n if (!ImGui_ImplGlfw_InitForOpenGL(window, true)) { ImGui::DestroyContext(); glfwDestroyWindow(window); glfwTerminate(); return 12; }\n if (!ImGui_ImplOpenGL3_Init(\"#version 110\")) { ImGui_ImplGlfw_Shutdown(); ImGui::DestroyContext(); glfwDestroyWindow(window); glfwTerminate(); return 13; }\n ImGui_ImplOpenGL3_NewFrame(); ImGui_ImplGlfw_NewFrame(); ImGui::NewFrame();\n ImGui::Begin(\"compat imgui\"); ImGui::Text(\"hello from mcpp\"); ImGui::End();\n ImGui::Render(); ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());\n ImGui_ImplOpenGL3_Shutdown(); ImGui_ImplGlfw_Shutdown(); ImGui::DestroyContext();\n glfwDestroyWindow(window); glfwTerminate();\n return 0;\n}\n\nint main() {\n // The backend sources are #included above, so compiling+linking this TU is\n // the headless test. The actual window run needs a display (opt-in).\n if (std::getenv(\"MCPP_RUN_WINDOW\")) return run_window();\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "imgui", + "text": "# Dear ImGui (compat source build): headless in-memory frame → valid draw data.\n[package]\nname = \"imgui-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nimgui = \"1.92.8\"\n", + "files": [ + { + "path": "tests/examples/imgui/tests/render.cpp", + "text": "// Headless ImGui: create context, build a font atlas, render one frame, assert\n// the draw data is valid. No window / display.\n#include \n\nint main() {\n ImGui::CreateContext();\n ImGuiIO& io = ImGui::GetIO();\n io.IniFilename = nullptr; // no imgui.ini side-effect\n io.DisplaySize = ImVec2(320.0f, 180.0f);\n io.DeltaTime = 1.0f / 60.0f;\n unsigned char* pixels = nullptr;\n int tw = 0, th = 0;\n io.Fonts->GetTexDataAsRGBA32(&pixels, &tw, &th);\n io.Fonts->SetTexID(1);\n\n ImGui::NewFrame();\n ImGui::Begin(\"compat imgui smoke\");\n ImGui::Text(\"ok\");\n ImGui::End();\n ImGui::Render();\n\n ImDrawData* dd = ImGui::GetDrawData();\n const bool ok = dd != nullptr && dd->Valid;\n ImGui::DestroyContext();\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "jwt-cpp", + "text": "# compat.jwt-cpp test project: real HMAC signing + verification through the\n# package's compat.openssl dependency — the positive path must verify, and a\n# wrong key must be rejected.\n[package]\nname = \"jwt-cpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\njwt-cpp = \"0.7.2\"\n", + "files": [ + { + "path": "tests/examples/jwt-cpp/tests/jwt.cpp", + "text": "// Behavioral test: HS256 sign -> decode -> verify through compat.openssl.\n// The correct key and claim must pass; a wrong key must be rejected. This is\n// the end-to-end assertion that the package's OpenSSL wiring actually links\n// and computes, not just that headers exist.\n#include \n#include \n#include \n\nint main() {\n bool ok = true;\n\n const auto token = jwt::create()\n .set_payload_claim(\"sub\", jwt::claim(std::string(\"alice\")))\n // Boolean claims need the JSON value spelled out: a\n // bare `jwt::claim(true)` would need TWO user-defined\n // conversions (bool -> picojson::value -> claim).\n .set_payload_claim(\n \"admin\",\n jwt::claim(jwt::traits::kazuho_picojson::value_type(true)))\n .set_expires_at(std::chrono::system_clock::now() +\n std::chrono::seconds{3600})\n .sign(jwt::algorithm::hs256{\"secret-key\"});\n\n const auto decoded = jwt::decode(token);\n ok = ok && decoded.get_payload_claim(\"sub\").as_string() == \"alice\";\n ok = ok && decoded.get_payload_claim(\"admin\").as_boolean();\n ok = ok && decoded.get_header_claim(\"alg\").as_string() == \"HS256\";\n\n try {\n jwt::verify()\n .allow_algorithm(jwt::algorithm::hs256{\"secret-key\"})\n .with_claim(\"sub\", jwt::claim(std::string(\"alice\")))\n .verify(decoded);\n } catch (...) {\n ok = false; // correct key + claim: verification must succeed\n }\n\n bool wrong_key_rejected = false;\n try {\n jwt::verify()\n .allow_algorithm(jwt::algorithm::hs256{\"wrong-key\"})\n .with_claim(\"sub\", jwt::claim(std::string(\"alice\")))\n .verify(decoded);\n } catch (...) {\n wrong_key_rejected = true; // wrong signature must throw\n }\n\n return (ok && wrong_key_rejected) ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "libaio", + "text": "# libaio test project: drive real kernel AIO through the wrapper.\n#\n# libaio is not a portable library with a Linux backend -- it IS the Linux AIO\n# ABI -- so the descriptor has a `linux` section and nothing else, the\n# dependency is gated, and the test compiles to a no-op main() elsewhere. Same\n# shape as tests/examples/wil in the other direction.\n#\n# Nothing here is mocked: the assertions submit iocbs to the kernel and check\n# the completion events, because a package that links but mis-declares\n# `struct iocb` would pass a \"does it link\" test and corrupt every submission.\n[package]\nname = \"libaio-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibaio = \"0.3.113\"\n", + "files": [ + { + "path": "tests/examples/libaio/tests/aio.cpp", + "text": "// Behavioral test: real kernel AIO submissions, not a link check.\n//\n// Three things can go wrong with this package and none of them is a missing\n// symbol.\n//\n// 1. `struct iocb` is the KERNEL's structure, assembled by libaio.h out of\n// PADDED/PADDEDptr/PADDEDul macros chosen by endianness and word size. Get\n// that selection wrong and every field lands at the wrong offset — the\n// library still links, io_submit still returns 1, and the kernel writes\n// the wrong bytes to the wrong place. So the assertions read back what\n// actually hit the file.\n//\n// 2. The plain names `io_getevents` and `io_cancel` do not exist as ordinary\n// definitions upstream: the functions are called `io_getevents_0_4` /\n// `io_cancel_0_4` and publish the short names through\n// `.symver … @@LIBAIO_0.4`. Whether that survives into a consumer's link\n// is a linker behavior, so both are called here on purpose.\n//\n// 3. libaio does NOT use errno. Every wrapper returns `-errno` directly and\n// restores the caller's errno, which is the opposite of the convention\n// the surrounding POSIX calls follow. The error-path assertion pins that\n// down rather than assuming it.\n//\n// The static_asserts below are upstream's own src/struct_offsets.c, which this\n// package deliberately does not compile (it is a build-time check that emits no\n// library code). Restating them here keeps the check and moves it to where it\n// is actually observable.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n// ── upstream src/struct_offsets.c, verbatim in intent ────────────────────\n// The iocb.u union overlays io_iocb_common / io_iocb_vector / io_iocb_sockaddr,\n// and the kernel reads whichever the opcode implies. If the padding macros\n// picked the wrong variant these three stop agreeing.\nstatic_assert(offsetof(struct iocb, u.v.nr) == offsetof(struct iocb, u.c.nbytes),\n \"iocb.u.v.nr does not match the offset of iocb.u.c.nbytes\");\nstatic_assert(offsetof(struct iocb, u.v.offset) == offsetof(struct iocb, u.c.offset),\n \"iocb.u.v.offset does not match the offset of iocb.u.c.offset\");\nstatic_assert(offsetof(struct iocb, u.saddr.len) == offsetof(struct iocb, u.c.nbytes),\n \"iocb.u.saddr.len does not match the offset of iocb.u.c.nbytes\");\n\nnamespace {\n\nconstexpr std::size_t kBlock = 512;\n\n// Reap exactly `want` completions, or give up. 10s is far beyond what buffered\n// AIO on a temp file needs; it is a deadlock guard, not a timing assumption.\nint reap(io_context_t ctx, io_event* events, long want) {\n timespec timeout{10, 0};\n return io_getevents(ctx, want, want, events, &timeout);\n}\n\nstruct TempFile {\n char path[64] = {};\n int fd = -1;\n\n TempFile() {\n const char* dir = std::getenv(\"TMPDIR\");\n std::snprintf(path, sizeof path, \"%s/mcpp_libaio_XXXXXX\",\n (dir && *dir) ? dir : \"/tmp\");\n fd = ::mkstemp(path);\n }\n ~TempFile() {\n if (fd >= 0) ::close(fd);\n if (path[0]) ::unlink(path);\n }\n};\n\n// Set by the io_queue_run callback below.\nstruct CallbackRecord {\n int calls = 0;\n iocb* obj = nullptr;\n long res = -1;\n};\nCallbackRecord g_callback;\n\nvoid on_complete(io_context_t, iocb* cb, long res, long /*res2*/) {\n g_callback.calls += 1;\n g_callback.obj = cb;\n g_callback.res = res;\n}\n\n} // namespace\n\nint main() {\n TempFile file;\n assert(file.fd >= 0 && \"could not create the temp file the test writes through\");\n\n io_context_t ctx = nullptr;\n const int setup = io_setup(8, &ctx);\n assert(setup == 0 && \"io_setup failed; libaio returns -errno, not -1\");\n assert(ctx != nullptr);\n\n // ── io_prep_pwrite fills the iocb the kernel will read ───────────────\n // Checked before submitting: if these are wrong the submission below can\n // still \"succeed\" while writing something else entirely.\n unsigned char out[kBlock];\n for (std::size_t i = 0; i < kBlock; ++i) out[i] = static_cast(i & 0xFF);\n\n iocb write_cb;\n io_prep_pwrite(&write_cb, file.fd, out, kBlock, 0);\n assert(write_cb.aio_lio_opcode == IO_CMD_PWRITE);\n assert(write_cb.aio_fildes == file.fd);\n assert(write_cb.u.c.buf == out);\n assert(write_cb.u.c.nbytes == kBlock);\n assert(write_cb.u.c.offset == 0);\n\n // ── the write actually reaches the file ──────────────────────────────\n {\n iocb* batch[1] = {&write_cb};\n assert(io_submit(ctx, 1, batch) == 1);\n\n io_event event{};\n assert(reap(ctx, &event, 1) == 1);\n assert(event.obj == &write_cb);\n assert(static_cast(event.res) == static_cast(kBlock));\n\n unsigned char verify[kBlock] = {};\n assert(::pread(file.fd, verify, kBlock, 0) == static_cast(kBlock));\n assert(std::memcmp(verify, out, kBlock) == 0 &&\n \"the async write did not land the bytes it reported writing\");\n\n // io_cancel on a request the kernel already completed must fail, and\n // must fail libaio's way: a negative errno as the return value.\n // Calling it at all is the point — it is one of the three functions\n // that only exist under a @@LIBAIO_0.4 symbol version.\n io_event discarded{};\n assert(io_cancel(ctx, &write_cb, &discarded) < 0);\n }\n\n // ── the read path, at a non-zero offset ──────────────────────────────\n {\n unsigned char tail[kBlock];\n for (std::size_t i = 0; i < kBlock; ++i) tail[i] = static_cast(0xA5 ^ i);\n\n iocb append;\n io_prep_pwrite(&append, file.fd, tail, kBlock, kBlock);\n iocb* batch[1] = {&append};\n assert(io_submit(ctx, 1, batch) == 1);\n io_event event{};\n assert(reap(ctx, &event, 1) == 1);\n assert(static_cast(event.res) == static_cast(kBlock));\n\n unsigned char in[kBlock] = {};\n iocb read_cb;\n io_prep_pread(&read_cb, file.fd, in, kBlock, kBlock);\n assert(read_cb.aio_lio_opcode == IO_CMD_PREAD);\n assert(read_cb.u.c.offset == static_cast(kBlock));\n\n iocb* rbatch[1] = {&read_cb};\n assert(io_submit(ctx, 1, rbatch) == 1);\n assert(reap(ctx, &event, 1) == 1);\n assert(static_cast(event.res) == static_cast(kBlock));\n assert(std::memcmp(in, tail, kBlock) == 0 &&\n \"the async read at offset 512 returned the wrong block\");\n }\n\n // ── a batch of two, told apart by iocb.data ──────────────────────────\n // `data` is the first member of the padded struct, so a wrong PADDEDptr\n // choice shows up here as a cookie that does not come back.\n {\n unsigned char a[kBlock], b[kBlock];\n std::memset(a, 0x11, sizeof a);\n std::memset(b, 0x22, sizeof b);\n\n iocb first, second;\n io_prep_pwrite(&first, file.fd, a, kBlock, 2 * kBlock);\n io_prep_pwrite(&second, file.fd, b, kBlock, 3 * kBlock);\n first.data = &first;\n second.data = &second;\n\n iocb* batch[2] = {&first, &second};\n assert(io_submit(ctx, 2, batch) == 2);\n\n io_event events[2]{};\n assert(reap(ctx, events, 2) == 2);\n\n bool saw_first = false, saw_second = false;\n for (const io_event& e : events) {\n assert(static_cast(e.res) == static_cast(kBlock));\n if (e.data == &first) saw_first = true;\n if (e.data == &second) saw_second = true;\n }\n assert(saw_first && saw_second && \"iocb.data cookies did not round-trip\");\n\n unsigned char verify[kBlock] = {};\n assert(::pread(file.fd, verify, kBlock, 3 * kBlock) == static_cast(kBlock));\n assert(verify[0] == 0x22 && verify[kBlock - 1] == 0x22);\n }\n\n // ── the error contract: -errno returned, caller's errno untouched ────\n {\n unsigned char scratch[kBlock] = {};\n iocb bad;\n io_prep_pwrite(&bad, -1, scratch, kBlock, 0);\n iocb* batch[1] = {&bad};\n\n errno = 0;\n const int rc = io_submit(ctx, 1, batch);\n assert(rc < 0 && \"io_submit on a closed fd must fail\");\n assert(rc == -EBADF && \"libaio reports -errno as the return value\");\n assert(errno == 0 && \"libaio must not clobber the caller's errno\");\n }\n\n assert(io_destroy(ctx) == 0);\n\n // ── the io_queue_* convenience layer and its callback ────────────────\n // A separate context, because io_queue_init owns setup/teardown. This is\n // the only path that reads iocb.data as a FUNCTION POINTER, so it is the\n // one place a callback that never fires is a real failure rather than a\n // slow completion.\n {\n io_context_t qctx = nullptr;\n assert(io_queue_init(8, &qctx) == 0);\n\n unsigned char payload[kBlock];\n std::memset(payload, 0x5A, sizeof payload);\n\n iocb cb;\n io_prep_pwrite(&cb, file.fd, payload, kBlock, 4 * kBlock);\n io_set_callback(&cb, on_complete);\n assert(cb.data == reinterpret_cast(&on_complete));\n\n iocb* batch[1] = {&cb};\n assert(io_submit(qctx, 1, batch) == 1);\n\n // io_queue_run polls with a zero timeout and returns 0 when the ring is\n // empty, so it is spun until the completion shows up.\n for (int i = 0; i < 1000 && g_callback.calls == 0; ++i) {\n assert(io_queue_run(qctx) >= 0);\n if (g_callback.calls == 0) {\n timespec nap{0, 1'000'000}; // 1ms; 1000 tries = 1s ceiling\n ::nanosleep(&nap, nullptr);\n }\n }\n\n assert(g_callback.calls == 1 && \"io_queue_run never dispatched the callback\");\n assert(g_callback.obj == &cb);\n assert(g_callback.res == static_cast(kBlock));\n\n unsigned char verify[kBlock] = {};\n assert(::pread(file.fd, verify, kBlock, 4 * kBlock) == static_cast(kBlock));\n assert(verify[0] == 0x5A && verify[kBlock - 1] == 0x5A);\n\n assert(io_queue_release(qctx) == 0);\n }\n\n return 0;\n}\n\n#else\n\nint main() { return 0; } // libaio is the Linux AIO ABI; nothing to assert here.\n\n#endif\n" + } + ] + }, + { + "project": "libassert", + "text": "# compat.libassert test project. Declares ONLY libassert: cpptrace has to\n# arrive transitively, which is the edge this member exists to prove.\n# `compat` redirect inherited from the workspace root.\n[package]\nname = \"libassert-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nlibassert = \"2.2.1\"\n", + "files": [ + { + "path": "tests/examples/libassert/tests/assertions.cpp", + "text": "// Behavioral test — compat.libassert decomposes an expression and reports the\n// values in it, which is the whole reason to use it over .\n//\n// A passing assertion proves almost nothing, so the test drives the FAILING\n// path: it installs a failure handler, provokes a failed assertion, and reads\n// what libassert produced. `LIBASSERT_ASSERT` carries `LIBASSERT_NOP_ACTION`,\n// so a handler that returns lets execution continue — which is what makes this\n// testable in-process rather than through an abort.\n//\n// It is also the proof that the transitive dependency arrives: the failure\n// object hands back a `cpptrace::stacktrace`, and this project declares only\n// libassert.\n//\n// No `import std;` — libassert's headers are textual over the standard library.\n#include \n#include \n// libassert's own header only FORWARD-declares cpptrace::stacktrace\n// (cpptrace/forward.hpp), so calling .frames on it needs the complete type.\n// Including cpptrace's public header here works only because compat.cpptrace's\n// include directory reaches this project transitively through libassert — this\n// project declares no cpptrace dependency of its own. The include is therefore\n// part of the assertion, not incidental.\n#include \n\n#include \n#include \n\n#if LIBASSERT_VERSION != LIBASSERT_TO_VERSION(2, 2, 1)\n# error \"libassert/version.hpp does not describe 2.2.1 — the generated header drifted from the tarball\"\n#endif\n\n// Both packages build objects rather than shared libraries, so the\n// declarations this TU sees must be plain. Each delivers that with a shim in\n// front of the one header that reads its macro — no descriptor key carries a\n// define into a consumer's TUs. On Linux the difference is only a visibility\n// attribute, so a regression is invisible; on the MSVC ABI it is `dllimport`\n// against a locally defined symbol, which is exactly how this member first\n// failed on the windows CI leg.\n#if !defined(LIBASSERT_STATIC_DEFINE)\n# error \"LIBASSERT_STATIC_DEFINE is not set — the compat.libassert platform.hpp shim was not reached\"\n#endif\n#if !defined(CPPTRACE_STATIC_DEFINE)\n# error \"CPPTRACE_STATIC_DEFINE is not set — compat.cpptrace's basic.hpp shim did not reach a transitive consumer\"\n#endif\n\nstatic std::string captured;\nstatic std::size_t trace_frames = 0;\nstatic int failures = 0;\n\nstatic void record(const libassert::assertion_info& info) {\n ++failures;\n captured = info.to_string(0, libassert::color_scheme::blank);\n trace_frames = info.get_stacktrace().frames.size();\n}\n\nint main() {\n libassert::set_failure_handler(record);\n\n const int limit = 3;\n const int index = 7;\n\n // A true assertion must not reach the handler.\n LIBASSERT_ASSERT(limit < index);\n if (failures != 0) {\n std::printf(\"a true assertion fired the handler\\n\");\n return 1;\n }\n\n // A false one must, exactly once.\n LIBASSERT_ASSERT(index < limit, \"index out of range\", index, limit);\n if (failures != 1) {\n std::printf(\"failure handler fired %d times, expected 1\\n\", failures);\n return 2;\n }\n\n // The report has to carry the EXPRESSION TEXT — that is the part a plain\n // `assert` also manages — and then the two things it does not: the message\n // and the decomposed operand values.\n if (captured.find(\"index < limit\") == std::string::npos) {\n std::printf(\"report does not carry the expression text:\\n%s\\n\", captured.c_str());\n return 3;\n }\n if (captured.find(\"index out of range\") == std::string::npos) {\n std::printf(\"report does not carry the message:\\n%s\\n\", captured.c_str());\n return 4;\n }\n if (captured.find(\"7\") == std::string::npos || captured.find(\"3\") == std::string::npos) {\n std::printf(\"report does not carry the decomposed values:\\n%s\\n\", captured.c_str());\n return 5;\n }\n\n // cpptrace, reached transitively. Frame COUNT rather than symbol names:\n // compat.cpptrace resolves symbols with dladdr and documents that it\n // carries no file:line, so asserting on names would be testing the\n // backend choice rather than the dependency edge.\n if (trace_frames == 0) {\n std::printf(\"assertion carried no stack trace — cpptrace did not arrive\\n\");\n return 6;\n }\n\n std::printf(\"compat.libassert: ok (expression, message and values decomposed; %zu trace frames)\\n\",\n trace_frames);\n return 0;\n}\n" + } + ] + }, + { + "project": "libcoro", + "text": "# compat.libcoro test project — the CORE (non-networking) half, which is all\n# the pinned tarball can build: upstream's c-ares lives in a git submodule that\n# a GitHub archive does not carry. `compat` redirect inherited from the root.\n[package]\nname = \"libcoro-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nlibcoro = \"0.16.0\"\n", + "files": [ + { + "path": "tests/examples/libcoro/tests/coroutines.cpp", + "text": "// Behavioral test — compat.libcoro actually schedules coroutines.\n//\n// Every assertion here needs a resumed coroutine to be true, so a package that\n// compiled the headers but linked none of the eight core TUs fails: the\n// thread_pool, event, mutex and sync_wait entry points all live in those.\n//\n// Networking is deliberately untouched — see the descriptor. `coro/coro.hpp`\n// is NOT included for the same reason; it opens the net/ headers behind\n// LIBCORO_FEATURE_NETWORKING, which this build does not define.\n//\n// No `import std;`: libcoro's headers are textual over the standard library.\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n\nint main() {\n // A task returning a value, driven to completion synchronously.\n auto add = [](int a, int b) -> coro::task { co_return a + b; };\n if (coro::sync_wait(add(2, 3)) != 5) {\n std::printf(\"sync_wait(task) did not produce the value\\n\");\n return 1;\n }\n\n // A generator is lazy: nothing runs until the range is iterated.\n auto squares = [](int n) -> coro::generator {\n for (int i = 1; i <= n; ++i) co_yield i * i;\n };\n int sum = 0;\n for (int v : squares(4)) sum += v;\n if (sum != 1 + 4 + 9 + 16) {\n std::printf(\"generator produced %d, expected 30\\n\", sum);\n return 2;\n }\n\n // The thread pool really moves work off this thread, and coro::mutex\n // serialises it. Both are compiled TUs, not header inlines — this is the\n // link-time proof for src/thread_pool.cpp and src/mutex.cpp.\n // 0.16.0 makes the constructor private: a pool is created through the\n // make_unique factory, which lives in src/thread_pool.cpp — so this line\n // is also the link-time proof that the TU is compiled in.\n auto pool_ptr = coro::thread_pool::make_unique(coro::thread_pool::options{.thread_count = 4});\n coro::thread_pool& pool = *pool_ptr;\n coro::mutex mutex;\n int guarded = 0;\n\n std::vector> tasks;\n tasks.reserve(200);\n for (int i = 0; i < 200; ++i) {\n tasks.emplace_back([](coro::thread_pool& p, coro::mutex& m, int& counter) -> coro::task {\n co_await p.schedule();\n auto lock = co_await m.scoped_lock();\n ++counter;\n co_return;\n }(pool, mutex, guarded));\n }\n coro::sync_wait(coro::when_all(std::move(tasks)));\n\n if (guarded != 200) {\n std::printf(\"200 coroutines incremented the counter to %d — the mutex did not serialise them\\n\", guarded);\n return 3;\n }\n\n // coro::event: a coroutine suspends until another thread sets it.\n coro::event started;\n std::atomic observed{false};\n auto waiter = [&]() -> coro::task {\n co_await started;\n observed = true;\n co_return;\n };\n auto setter = [&]() -> coro::task {\n co_await pool.schedule();\n started.set();\n co_return;\n };\n coro::sync_wait(coro::when_all(waiter(), setter()));\n if (!observed) {\n std::printf(\"coro::event never released the waiter\\n\");\n return 4;\n }\n\n std::printf(\"compat.libcoro: ok (task, generator, 200 pooled tasks under a coro::mutex, event)\\n\");\n return 0;\n}\n" + } + ] + }, + { + "project": "libdisplay-info", + "text": "# libdisplay-info test member — EDID parsing, the gate on wlroots.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"libdisplay-info-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.freedesktop]\nlibdisplay-info = \"0.2.0\"\n", + "files": [ + { + "path": "tests/examples/libdisplay-info/tests/libdisplay-info.cpp", + "text": "// freedesktop.libdisplay-info — parse a real monitor's EDID.\n//\n// EDID parsing needs no device, no compositor and no permissions: it is a\n// function from 128 bytes to a struct. So unlike most of this stack, the whole\n// library is honestly testable on a CI runner.\n//\n// THE BLOB IS INLINE, not read from the package's own test data. A test member\n// resolves its dependency into the mcpp store, and reaching into that tree by\n// relative path would tie this file to a layout that is mcpp's business. 256\n// bytes of a real Acer P1276's EDID, taken from upstream's\n// `test/data/acer-p1276.edid`, is cheaper than that coupling.\n//\n// THE EXPECTED VALUES ARE UPSTREAM'S, from the `.ref` beside that blob:\n//\n// EDID Structure Version & Revision: 1.3\n// Manufacturer: ACR\n// Model: 5654 (0x1616)\n//\n// WHAT IS ACTUALLY UNDER TEST is the generated PNP table. \"ACR\" is the raw\n// vendor id in the blob; turning it into \"Acer Technologies\" is what\n// `pnp-id-table.c` does — the file this package is a fork in order to\n// pre-generate. A build that used the host's pnp.ids instead of the pinned one\n// would still parse the EDID and could still answer differently here.\n\n#ifdef __linux__\n\n// NO extern \"C\" WRAPPER, and that is the point of the module.\n//\n// Not one of libdisplay-info's seven public headers contains an `extern \"C\"`\n// block — zero occurrences, measured — so a C++ translation unit that\n// #includes them mangles every declaration and fails to link with\n// `undefined reference to di_info_get_make(di_info const*)`, naming a symbol\n// that is right there in the library. This file did exactly that before the\n// module existed.\n//\n// `import displayinfo;` does the wrapping once, inside the module\n// purview. The module is GENERATED by the package's build.mcpp from the public\n// headers — 206 names — so it cannot fall behind them.\nimport displayinfo;\n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// An Acer P1276, from upstream/test/data/acer-p1276.edid.\nconst unsigned char ACER_P1276[] = {\n \n0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x04, 0x72, 0x16, 0x16,\n 0x3e, 0x13, 0x00, 0x00, 0x17, 0x17, 0x01, 0x03, 0x0e, 0x00, 0x00, 0x00,\n 0x2f, 0x41, 0xf0, 0x9e, 0x5c, 0x58, 0x89, 0x25, 0x16, 0x53, 0x5f, 0x3f,\n 0xcf, 0x80, 0x31, 0x7c, 0x45, 0x7c, 0x61, 0x7c, 0x81, 0x80, 0xa9, 0x40,\n 0x95, 0x00, 0x81, 0xc0, 0x81, 0x00, 0x64, 0x19, 0x00, 0x40, 0x41, 0x00,\n 0x26, 0x30, 0x18, 0x88, 0x36, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18,\n 0x00, 0x00, 0x00, 0xfd, 0x00, 0x32, 0x78, 0x1e, 0x64, 0x11, 0x00, 0x0a,\n 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x50,\n 0x31, 0x32, 0x37, 0x36, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,\n 0x00, 0x00, 0x00, 0xff, 0x00, 0x4a, 0x47, 0x47, 0x31, 0x31, 0x30, 0x30,\n 0x31, 0x35, 0x39, 0x30, 0x30, 0x0a, 0x01, 0xa2, 0x02, 0x03, 0x04, 0x00,\n 0x66, 0x21, 0x56, 0xaa, 0x51, 0x00, 0x1e, 0x30, 0x46, 0x8f, 0x33, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0xab\n};\n\n} // namespace\n\nint main()\n{\n std::printf(\" %zu bytes of EDID\\n\", sizeof ACER_P1276);\n\n di_info *info = di_info_parse_edid(ACER_P1276, sizeof ACER_P1276);\n check(info != nullptr, \"di_info_parse_edid on a real monitor's EDID\");\n if (info == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── The generated PNP table, which is why this package is a fork ─────\n char *make = di_info_get_make(info);\n std::printf(\" make = %s\\n\", make ? make : \"(null)\");\n check(make != nullptr, \"di_info_get_make returned a manufacturer\");\n check(make != nullptr && std::strstr(make, \"Acer\") != nullptr,\n \"…and the pinned PNP table resolved ACR to Acer\");\n\n char *model = di_info_get_model(info);\n std::printf(\" model = %s\\n\", model ? model : \"(null)\");\n check(model != nullptr, \"di_info_get_model returned a model\");\n\n // ── The parse agrees with upstream's own reference output ────────────\n const di_edid *edid = di_info_get_edid(info);\n check(edid != nullptr, \"di_info_get_edid exposes the parsed block\");\n if (edid != nullptr) {\n const int major = di_edid_get_version(edid);\n const int minor = di_edid_get_revision(edid);\n std::printf(\" EDID version %d.%d\\n\", major, minor);\n check(major == 1 && minor == 3, \"…and it is 1.3, as upstream's .ref says\");\n\n const di_edid_vendor_product *vp = di_edid_get_vendor_product(edid);\n check(vp != nullptr, \"di_edid_get_vendor_product\");\n if (vp != nullptr) {\n std::printf(\" pnp id %.3s, product 0x%04x\\n\",\n vp->manufacturer, vp->product);\n check(std::strncmp(vp->manufacturer, \"ACR\", 3) == 0,\n \"…and the raw PNP id is ACR\");\n check(vp->product == 0x1616,\n \"…and the product code is 5654, as the .ref says\");\n }\n }\n\n std::free(make);\n std::free(model);\n di_info_destroy(info);\n // ⭐ AN ENUMERATOR, WHICH THIS EXAMPLE NEVER ASKED FOR BEFORE.\n //\n // The module exported 206 names and NOT ONE of the 295 `DI_*` enumerators.\n // Nothing noticed, because no test named one — and naming one on GCC alone\n // would not have noticed either: exporting the enum makes its enumerators\n // visible there and clang rejects the same file. So this is the check, and\n // it is a compile error if the export list collapses again.\n const enum di_edid_cvt_aspect_ratio ar = DI_EDID_CVT_ASPECT_RATIO_16_9;\n check(ar != DI_EDID_CVT_ASPECT_RATIO_4_3,\n \"an enumerator comes through the module by name\");\n\n check(true, \"di_info_destroy\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "libdrm", + "text": "# libdrm test project.\n#\n# DRM/KMS is a Linux kernel interface, so like tests/examples/libgbm the\n# descriptor has a `linux` section only, the dependency is gated, and the test\n# compiles to a no-op main() elsewhere.\n#\n# The interesting failure here is the include layout, not the link. libdrm\n# installs its public headers at the include root and the uapi headers they\n# include in a `libdrm/` subdirectory, and `xf86drm.h` line 40 is a bare\n# `#include ` — so a package exposing one root builds nothing at all.\n# This member's first assertion is simply that the translation unit compiles.\n[package]\nname = \"libdrm-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibdrm = \"2.4.134\"\n", + "files": [ + { + "path": "tests/examples/libdrm/tests/drm.cpp", + "text": "// compat.libdrm — behavioral test, runnable with no GPU and no DRM node.\n//\n// Three things can be wrong with this package and none is a missing symbol.\n//\n// 1. THE INCLUDE LAYOUT. libdrm splits its headers across two roots — the\n// public ones (`xf86drm.h`) at the include root, the uapi ones (`drm.h`,\n// `drm_mode.h`, `drm_fourcc.h`) under `libdrm/` — and `xf86drm.h` itself\n// does `#include `. Exposing one root fails at line 40 of the\n// header, before any of this code exists. So the fact that this file\n// COMPILES is the first assertion, and it is not a trivial one.\n//\n// 2. THE LIBRARY COULD BE ABSENT while the headers are present. The dlsym\n// checks below pin that: a header-only package would sail past\n// compilation and fail here.\n//\n// 2b. THE WRONG libdrm COULD BE LOADED. This package builds libdrm from\n// source, and the ecosystem's Mesa payload carries its own\n// `libdrm.so.2` under `xim-x-libdrm/`. Both can be reachable at once, and\n// because they share a soname only ONE is ever mapped — silently. The\n// dladdr check pins which one won: it must be this package's build, or\n// the source build is decorative and the consumer is really running the\n// payload's copy.\n//\n// 3. THE UAPI CONSTANTS COULD DISAGREE with the library's. drm_fourcc.h is\n// the kernel's, and DRM_FORMAT_XRGB8888 must be the same fourcc GBM calls\n// GBM_FORMAT_XRGB8888 — the two APIs exchange exactly these values across\n// the gbm_bo -> drmModeAddFB2 boundary, so a mismatch would surface as a\n// display that shows the wrong colours rather than as an error.\n//\n// Opening a real DRM device is opt-in (MCPP_RUN_DRM_DEVICE=1): CI runners have\n// no /dev/dri at all.\n\n#ifdef __linux__\n\n#include // public API; pulls from the second root\n#include // the KMS family\n#include // uapi, from the libdrm/ root\n\n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. Both include roots resolved ───────────────────────────────────\n // Reaching this line at all means found the it\n // includes. Assert a constant from each root so the check is explicit\n // rather than implied by compilation succeeding.\n check(DRM_MODE_TYPE_PREFERRED != 0,\n \"xf86drmMode.h (public root) provides DRM_MODE_TYPE_PREFERRED\");\n check(DRM_FORMAT_XRGB8888 != 0,\n \"drm_fourcc.h (libdrm/ root) provides DRM_FORMAT_XRGB8888\");\n\n // ── 2. The fourcc agrees with GBM's ──────────────────────────────────\n // gbm_bo_get_format() returns a value handed straight to drmModeAddFB2.\n // 'X','R','2','4' little-endian — the same number compat.libgbm asserts as\n // \"XR24\". If these ever diverge, a KMS consumer shows wrong colours and\n // nothing reports an error.\n check(DRM_FORMAT_XRGB8888 == ((std::uint32_t)'X' | ((std::uint32_t)'R' << 8)\n | ((std::uint32_t)'2' << 16)\n | ((std::uint32_t)'4' << 24)),\n \"DRM_FORMAT_XRGB8888 is the 'XR24' fourcc GBM also uses\");\n\n // ── 3. The library is really linked ──────────────────────────────────\n for (const char *sym : {\"drmGetVersion\", \"drmFreeVersion\", \"drmModeGetResources\",\n \"drmModeAddFB2\", \"drmModeSetCrtc\", \"drmPrimeHandleToFD\"}) {\n check(::dlsym(RTLD_DEFAULT, sym) != nullptr,\n (std::string(\"libdrm exports \") + sym).c_str());\n }\n\n // ── 3b. …and it is THIS package's build, not the payload's ───────────\n // Same soname, so only one libdrm.so.2 is mapped and nothing warns about\n // the other. dladdr reports the object a symbol actually came from; if the\n // path names the ecosystem payload, this package built a library that no\n // one loads.\n {\n Dl_info info{};\n const bool located =\n ::dladdr(reinterpret_cast(&drmGetVersion), &info) != 0\n && info.dli_fname != nullptr;\n check(located, \"dladdr locates the loaded libdrm\");\n if (located) {\n const std::string from = info.dli_fname;\n std::printf(\" loaded from: %s\\n\", from.c_str());\n check(from.find(\"xim-x-libdrm\") == std::string::npos,\n \"the loaded libdrm is not the ecosystem payload's copy\");\n }\n }\n\n // ── 4. An invalid fd is rejected, not crashed on ─────────────────────\n check(drmGetVersion(-1) == nullptr, \"drmGetVersion(-1) == nullptr\");\n\n // ── 5. A real device, opt-in ─────────────────────────────────────────\n if (std::getenv(\"MCPP_RUN_DRM_DEVICE\") != nullptr) {\n const int fd = ::open(\"/dev/dri/card0\", O_RDWR);\n if (fd < 0) {\n std::printf(\" MCPP_RUN_DRM_DEVICE set but /dev/dri/card0 did not \"\n \"open; skipping\\n\");\n } else {\n drmVersionPtr v = drmGetVersion(fd);\n check(v != nullptr, \"drmGetVersion on a real DRM node\");\n if (v != nullptr) {\n std::printf(\" driver: %s\\n\", v->name ? v->name : \"(null)\");\n drmFreeVersion(v);\n }\n ::close(fd);\n }\n } else {\n std::printf(\" (device access is opt-in: set MCPP_RUN_DRM_DEVICE=1 on \"\n \"a machine with /dev/dri)\\n\");\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main()\n{\n return 0;\n}\n\n#endif\n" + } + ] + }, + { + "project": "libevdev", + "text": "# libevdev test project.\n#\n# THIS MEMBER DECLARES ITS OWN [indices]: an index is looked up BY NAMESPACE,\n# and the workspace root maps `compat`.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"libevdev-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.freedesktop]\nlibevdev = \"1.13.7\"\n", + "files": [ + { + "path": "tests/examples/libevdev/tests/libevdev.cpp", + "text": "// libevdev, exercised without an input device.\n//\n// The package's whole reason to be a fork is `event-names.h` — 1,692 lines of\n// generated tables mapping every kernel input constant to its string. So that\n// is what the test asks about: a package that linked but whose tables were\n// generated from the WRONG headers (the host's rather than the bundled ones,\n// which produces a measurably different file) answers these differently.\n//\n// No device is opened. libevdev's name lookups are pure functions of the\n// tables, which is exactly why they are testable anywhere.\n\n#ifdef __linux__\n\n#include \n#include \n\n#include \n#include \n#include \n\nnamespace {\nint failures = 0;\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n} // namespace\n\nint main()\n{\n // ── 1. The generated tables are linked in ────────────────────────────\n // Each of these reads a different table in event-names.h.\n struct { unsigned int type; unsigned int code; const char *want; } names[] = {\n {EV_KEY, KEY_A, \"KEY_A\"},\n {EV_KEY, KEY_LEFTCTRL, \"KEY_LEFTCTRL\"},\n {EV_ABS, ABS_MT_POSITION_X, \"ABS_MT_POSITION_X\"},\n {EV_REL, REL_WHEEL, \"REL_WHEEL\"},\n {EV_SW, SW_LID, \"SW_LID\"},\n };\n for (auto &n : names) {\n const char *got = libevdev_event_code_get_name(n.type, n.code);\n std::printf(\" %-20s -> %s\\n\", n.want, got ? got : \"(null)\");\n check(got != nullptr && std::strcmp(got, n.want) == 0,\n (std::string(\"libevdev names \") + n.want).c_str());\n }\n\n // ── 2. …and the type table too ───────────────────────────────────────\n const char *t = libevdev_event_type_get_name(EV_ABS);\n check(t != nullptr && std::strcmp(t, \"EV_ABS\") == 0,\n \"libevdev names the event TYPE table as well\");\n\n // ── 3. The reverse direction, which uses a different table ───────────\n // Name -> code is what libinput's quirks parser does with a config file.\n check(libevdev_event_code_from_name(EV_KEY, \"KEY_ESC\") == KEY_ESC,\n \"libevdev resolves a name back to its code\");\n check(libevdev_event_code_from_name(EV_KEY, \"KEY_NO_SUCH_THING\") == -1,\n \"…and a name that does not exist resolves to -1\");\n\n // ── 4. A device object with no fd ────────────────────────────────────\n // libevdev_new is the allocation path libinput takes before it has an fd;\n // it must work with none.\n libevdev *dev = libevdev_new();\n check(dev != nullptr, \"libevdev_new with no file descriptor\");\n if (dev != nullptr) {\n libevdev_set_name(dev, \"mcpp test device\");\n check(std::strcmp(libevdev_get_name(dev), \"mcpp test device\") == 0,\n \"…and its name round-trips\");\n libevdev_free(dev);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "libffi", + "text": "# compat.libffi test project.\n#\n# libffi is built from source here, and the ecosystem also carries\n# `xim:libffi` at 3.4.4 as a transitive of `xim:mesa`. They share the soname\n# `libffi.so.8`, so only one is ever mapped and nothing warns about the other —\n# the dladdr assertion in the test pins which one won.\n[package]\nname = \"libffi-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibffi = \"3.4.8\"\n", + "files": [ + { + "path": "tests/examples/libffi/tests/ffi.cpp", + "text": "// compat.libffi — behavioral test. It does not check that symbols exist; it\n// builds a call frame at runtime and invokes through it, which is the only\n// thing that proves the per-ABI assembly was assembled and linked correctly.\n// A wrong or missing unix64.S links fine and segfaults here.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n\nint add3(int a, int b, int c) { return a + b + c; }\n\ndouble scale(double v, float f) { return v * static_cast(f); }\n\n} // namespace\n\nint main()\n{\n // ── 1. A real integer call through ffi_call ──────────────────────────\n {\n ffi_cif cif{};\n ffi_type *args[3] = { &ffi_type_sint, &ffi_type_sint, &ffi_type_sint };\n check(ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 3, &ffi_type_sint, args) == FFI_OK,\n \"ffi_prep_cif on (int,int,int) -> int\");\n\n int a = 17, b = 25, c = -2, result = 0;\n void *values[3] = { &a, &b, &c };\n ffi_call(&cif, FFI_FN(add3), &result, values);\n check(result == 40, \"ffi_call dispatched add3(17,25,-2) == 40\");\n }\n\n // ── 2. Mixed float/double, which uses different argument registers ───\n // SSE arguments travel a separate path in unix64.S from integers, so an\n // integer-only test would miss half of the assembly.\n {\n ffi_cif cif{};\n ffi_type *args[2] = { &ffi_type_double, &ffi_type_float };\n check(ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 2, &ffi_type_double, args) == FFI_OK,\n \"ffi_prep_cif on (double,float) -> double\");\n\n double v = 1.5, result = 0.0;\n float f = 4.0f;\n void *values[2] = { &v, &f };\n ffi_call(&cif, FFI_FN(scale), &result, values);\n check(result == 6.0, \"ffi_call dispatched scale(1.5, 4.0f) == 6.0\");\n }\n\n // ── 3. It is THIS build that ran, not the ecosystem payload's ────────\n // Same soname as `xim:libffi`, so one mapping wins silently. If the path\n // names the payload, this package compiled a library nobody loads.\n {\n Dl_info info{};\n const bool located =\n ::dladdr(reinterpret_cast(&ffi_prep_cif), &info) != 0\n && info.dli_fname != nullptr;\n check(located, \"dladdr locates the loaded libffi\");\n if (located) {\n const std::string from = info.dli_fname;\n std::printf(\" loaded from: %s\\n\", from.c_str());\n check(from.find(\"xim-x-libffi\") == std::string::npos,\n \"the loaded libffi is not the ecosystem payload's copy\");\n }\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "libgbm", + "text": "# libgbm test project.\n#\n# GBM is the DRM buffer-management API, so like tests/examples/libaio the\n# descriptor has a `linux` section and nothing else, the dependency is gated,\n# and the test compiles to a no-op main() elsewhere.\n#\n# What this member proves is not \"does -lgbm resolve\". compat.libgbm is a thin\n# BINDING onto the ecosystem's Mesa — it compiles no upstream source and sets\n# no environment — so the interesting failures are all in the seams:\n#\n# * the header could come from a different Mesa than the library (both are\n# taken from the same subos view precisely so they cannot);\n# * the backend directory could be unreachable, which is the default state of\n# an mcpp sandbox — Mesa compiles `/usr/lib/gbm` in as its search path and\n# that does not exist there;\n# * and the repair for that lives OUTSIDE this repository: `xim:mesa`\n# declares GBM_BACKENDS_PATH through the graphics discovery layer, and mcpp\n# carries subos declarations into the processes it launches.\n#\n# That last point is why this member matters more than a link check: it is the\n# only place in this repo that notices if either of those two breaks.\n#\n# Two binaries, deliberately:\n#\n# tests/gbm.cpp the full surface — format canonicalization through\n# the real library, symbol presence, invalid-device\n# handling, and the backend-path assertions.\n# tests/stock_usage.cpp the minimal consumer: one header, one library,\n# nothing else. Smallest thing that still detects all\n# three regressions above, so a failure is unambiguous.\n#\n# Everything asserted here runs on a CI runner with no /dev/dri. Real device\n# creation is opt-in behind MCPP_RUN_GBM_DEVICE=1.\n[package]\nname = \"libgbm-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibgbm = \"25.0.7\"\n", + "files": [ + { + "path": "tests/examples/libgbm/tests/gbm.cpp", + "text": "// compat.libgbm — behavioral test, runnable on a machine with no GPU.\n//\n// The package is a BINDING onto the ecosystem's Mesa rather than a source\n// build, so the ways it can be wrong are not missing symbols:\n//\n// 1. The header could come from a different Mesa than the library. Both are\n// taken from the subos view for exactly this reason, and the assertions\n// below call through the header into the library to keep that honest.\n//\n// 2. The library could be present and the BACKEND unreachable. libgbm is a\n// loader: gbm_create_device() dlopens `/_gbm.so`, where\n// is GBM_BACKENDS_PATH or the `/usr/lib/gbm` compiled into Mesa —\n// correct on a distro, wrong the moment the payload is relocated:\n//\n// MESA-LOADER: failed to open dri: /usr/lib/gbm/dri_gbm.so: cannot\n// open shared object file (search paths /usr/lib/gbm, suffix _gbm)\n//\n// Setting that variable is the ENVIRONMENT's job, not this package's, and\n// `xim:mesa` now does it through the graphics discovery layer\n// (openxlings/xim-pkgindex#713). So the check below is a check on the\n// ECOSYSTEM: if the declaration is ever dropped from that table, or the\n// backends stop being placed into the subos, this goes red here.\n//\n// WHY THE LEGACY ENUM IS ASSERTED. gbm_format_get_name(GBM_FORMAT_XRGB8888) is\n// a weak test on its own — the answer is four bytes of the fourcc and a\n// header-only reimplementation would produce it. GBM_BO_FORMAT_XRGB8888 is the\n// value 0, and only the LIBRARY's format_canonicalize() turns it into \"XR24\".\n// So that case is what proves the calls land in Mesa's libgbm.\n//\n// Creating a real device is opt-in (MCPP_RUN_GBM_DEVICE=1) and needs\n// /dev/dri: CI runners have no DRM device, and on a host whose xim-x-mesa is\n// built against a newer glibc than xim-x-glibc the backend is found and then\n// fails to dlopen — an ecosystem-stack skew this package does not cause and\n// must not assert its way around.\n\n#ifdef __linux__\n\n// Stock libgbm, and nothing else. This package ships no header of its own.\n#include \n\n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// gbm_format_get_name writes into desc->name and returns it.\nstd::string format_name(std::uint32_t format)\n{\n gbm_format_name_desc desc {};\n gbm_format_get_name(format, &desc);\n return std::string(desc.name);\n}\n\nbool dir_has_backend(const char *dir)\n{\n DIR *d = ::opendir(dir);\n if (d == nullptr) {\n return false;\n }\n\n // Mesa's BACKEND_LIB_SUFFIX is \"_gbm\", so a backend is \"_gbm.so\"\n // -- 7 trailing characters, and the name must be longer than the suffix\n // alone for there to be a driver name in front of it.\n static const char suffix[] = \"_gbm.so\";\n const std::size_t suffix_len = sizeof(suffix) - 1;\n\n bool found = false;\n while (dirent *e = ::readdir(d)) {\n const std::size_t n = std::strlen(e->d_name);\n if (n > suffix_len &&\n std::strcmp(e->d_name + (n - suffix_len), suffix) == 0) {\n found = true;\n break;\n }\n }\n ::closedir(d);\n return found;\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The calls reach Mesa's libgbm ─────────────────────────────────\n // Pure functions: no device, no GPU, no DRM node.\n check(format_name(GBM_FORMAT_XRGB8888) == \"XR24\",\n \"gbm_format_get_name(GBM_FORMAT_XRGB8888) == \\\"XR24\\\"\");\n check(format_name(GBM_FORMAT_ARGB8888) == \"AR24\",\n \"gbm_format_get_name(GBM_FORMAT_ARGB8888) == \\\"AR24\\\"\");\n check(format_name(GBM_FORMAT_NV12) == \"NV12\",\n \"gbm_format_get_name(GBM_FORMAT_NV12) == \\\"NV12\\\"\");\n check(format_name(GBM_FORMAT_ABGR2101010) == \"AB30\",\n \"gbm_format_get_name(GBM_FORMAT_ABGR2101010) == \\\"AB30\\\"\");\n\n // The one that cannot be answered by the header: the legacy enumerator is\n // 0, and format_canonicalize() inside the library maps it to the fourcc.\n check(format_name(static_cast(GBM_BO_FORMAT_XRGB8888)) == \"XR24\",\n \"library canonicalizes GBM_BO_FORMAT_XRGB8888 (== 0) to \\\"XR24\\\"\");\n check(format_name(static_cast(GBM_BO_FORMAT_ARGB8888)) == \"AR24\",\n \"library canonicalizes GBM_BO_FORMAT_ARGB8888 (== 1) to \\\"AR24\\\"\");\n\n // ── 2. Header and library are the same Mesa ──────────────────────────\n // Every function the header declares and this test names must actually be\n // in the loaded object. A header from a newer Mesa than the library shows\n // up here rather than as a link error, because the farm resolves -lgbm to\n // whatever the subos view holds.\n for (const char *sym : {\"gbm_format_get_name\", \"gbm_create_device\",\n \"gbm_device_destroy\", \"gbm_device_get_backend_name\",\n \"gbm_bo_create\", \"gbm_surface_create\"}) {\n check(::dlsym(RTLD_DEFAULT, sym) != nullptr,\n (std::string(\"libgbm exports \") + sym).c_str());\n }\n\n // ── 3. An invalid device is rejected, not crashed on ─────────────────\n check(gbm_create_device(-1) == nullptr,\n \"gbm_create_device(-1) == nullptr\");\n\n // ── 4. The ECOSYSTEM supplies the backend path ───────────────────────\n // Nothing in this package sets this. It comes from `xim:mesa`'s\n // declaration in the graphics discovery layer, carried into the process by\n // mcpp's subos-env handling. Asserting it here is what makes a regression\n // in EITHER of those two land on this package's CI rather than silently on\n // a user.\n // WHY THIS IS REPORTED AND NOT ASSERTED.\n //\n // The variable is not this package's to set — see above. It comes from\n // xim-pkgindex's graphics discovery layer (#713) through mcpp's subos-env\n // handling, so whether it is present is decided by the ECOSYSTEM VERSION in\n // use, not by anything in this repository. mcpp-index's CI runs a pinned\n // mcpp (`MCPP_VERSION`) whose vendored xlings predates that row, so a hard\n // assertion here fails on CI while passing everywhere the ecosystem is\n // current — a red that says nothing about the package.\n //\n // So: when it IS set, the checks below are the real ones and are stronger\n // than mere presence (the directory must exist AND contain a backend), and\n // a regression in either the DISCOVERY row or mcpp's env injection still\n // lands here. When it is absent the member says so and moves on.\n const char *dir = std::getenv(\"GBM_BACKENDS_PATH\");\n if (dir == nullptr) {\n std::puts(\" GBM_BACKENDS_PATH unset — this ecosystem predates \"\n \"xim-pkgindex#713; skipping the backend-path checks\");\n } else {\n check(true, \"GBM_BACKENDS_PATH is set by the ecosystem, not by this package\");\n std::printf(\" backends dir: %s\\n\", dir);\n\n struct ::stat st {};\n check(::stat(dir, &st) == 0 && S_ISDIR(st.st_mode),\n \"it names a directory that exists\");\n check(dir_has_backend(dir),\n \"it contains at least one *_gbm.so backend\");\n }\n\n // ── 5. A real device, opt-in ─────────────────────────────────────────\n if (std::getenv(\"MCPP_RUN_GBM_DEVICE\") != nullptr) {\n const int fd = ::open(\"/dev/dri/renderD128\", O_RDWR);\n if (fd < 0) {\n std::printf(\" MCPP_RUN_GBM_DEVICE set but /dev/dri/renderD128 \"\n \"did not open; skipping\\n\");\n } else {\n gbm_device *dev = gbm_create_device(fd);\n check(dev != nullptr, \"gbm_create_device on a real DRM node\");\n if (dev != nullptr) {\n const char *backend = gbm_device_get_backend_name(dev);\n std::printf(\" backend: %s\\n\", backend ? backend : \"(null)\");\n check(backend != nullptr && backend[0] != '\\0',\n \"the device reports a backend name\");\n gbm_device_destroy(dev);\n }\n ::close(fd);\n }\n } else {\n std::printf(\" (device creation is opt-in: set MCPP_RUN_GBM_DEVICE=1 \"\n \"on a machine with /dev/dri)\\n\");\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main()\n{\n return 0;\n}\n\n#endif\n" + }, + { + "path": "tests/examples/libgbm/tests/stock_usage.cpp", + "text": "// The minimal consumer, as a separate translation unit and a separate binary.\n//\n// This file includes STOCK and nothing else -- and since the package\n// ships no header of its own any more, that is now simply what a consumer\n// looks like. It is what a project ported from any other build system looks\n// like, and more importantly what a THIRD-PARTY library looks like from the\n// inside: SDL2's KMSDRM backend, wlroots and ffmpeg's VAAPI hwcontext all call\n// gbm_create_device() out of their own sources, having included only .\n//\n// WHAT IT GUARDS, now that the package sets nothing itself. GBM_BACKENDS_PATH\n// arrives from the ECOSYSTEM: `xim:mesa` places its backends into the subos and\n// declares the variable through the graphics discovery layer\n// (openxlings/xim-pkgindex#713), and mcpp carries subos declarations into the\n// processes it launches. Neither of those is this repository's code, so this\n// binary is the tripwire on both -- if the DISCOVERY row is dropped, or the\n// backends stop being placed, or mcpp stops injecting subos env, it goes red\n// here rather than silently on a user whose gbm_create_device() returns NULL.\n//\n// It is deliberately a SECOND binary rather than more assertions inside\n// gbm.cpp: a consumer that includes one header and links one library is the\n// smallest thing that can still detect all three of those regressions, and\n// keeping it minimal is what makes a failure here unambiguous.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n\n#include \n#include \n#include \n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n };\n\n // Nothing in this program has run yet that could have set this.\n // WHY THIS IS REPORTED AND NOT ASSERTED.\n //\n // The variable is not this package's to set — see above. It comes from\n // xim-pkgindex's graphics discovery layer (#713) through mcpp's subos-env\n // handling, so whether it is present is decided by the ECOSYSTEM VERSION in\n // use, not by anything in this repository. mcpp-index's CI runs a pinned\n // mcpp (`MCPP_VERSION`) whose vendored xlings predates that row, so a hard\n // assertion here fails on CI while passing everywhere the ecosystem is\n // current — a red that says nothing about the package.\n //\n // So: when it IS set, the checks below are the real ones and are stronger\n // than mere presence (the directory must exist AND contain a backend), and\n // a regression in either the DISCOVERY row or mcpp's env injection still\n // lands here. When it is absent the member says so and moves on.\n const char *dir = std::getenv(\"GBM_BACKENDS_PATH\");\n if (dir == nullptr) {\n std::puts(\" GBM_BACKENDS_PATH unset — this ecosystem predates \"\n \"xim-pkgindex#713; skipping the backend-path checks\");\n } else {\n check(true, \"a -only consumer inherits GBM_BACKENDS_PATH\");\n std::printf(\" %s\\n\", dir);\n\n struct ::stat st {};\n check(::stat(dir, &st) == 0 && S_ISDIR(st.st_mode),\n \" ... and it is a directory\");\n\n bool found = false;\n if (DIR *d = ::opendir(dir)) {\n static const char suffix[] = \"_gbm.so\";\n const std::size_t len = sizeof(suffix) - 1;\n while (dirent *e = ::readdir(d)) {\n const std::size_t n = std::strlen(e->d_name);\n if (n > len && std::strcmp(e->d_name + (n - len), suffix) == 0) {\n found = true;\n break;\n }\n }\n ::closedir(d);\n }\n check(found, \" ... holding a backend libgbm can actually dlopen\");\n }\n\n // The stock call still behaves on a bad fd rather than crashing, which is\n // the only device-level thing assertable without a DRM node.\n check(gbm_create_device(-1) == nullptr, \"gbm_create_device(-1) == nullptr\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main()\n{\n return 0;\n}\n\n#endif\n" + } + ] + }, + { + "project": "libinput", + "text": "# libinput test project — the top of the input chain.\n#\n# NO `[indices]` OF ITS OWN, and that is the interesting part.\n#\n# This member needs packages from TWO namespaces: `compat.libinput` (and the\n# `compat.libudev` / `compat.mtdev` it depends on) from this checkout, and\n# `freedesktop.libevdev` which libinput also links. A member-level `[indices]`\n# REPLACES the root's rather than merging, and one path can only be registered\n# under one namespace — so declaring `freedesktop = { path = \"../../..\" }` here\n# would make `compat.libinput` resolve against the published index instead.\n#\n# Inheriting the root's `compat = { path = \".\" }` is what works: the compat\n# packages come from the checkout, and `freedesktop.libevdev` resolves from the\n# PUBLISHED index — which is correct, because it is published. Same arrangement\n# tests/examples/asio-ssl relies on for the opposite reason.\n[package]\nname = \"libinput-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibinput = \"1.31.3\"\n", + "files": [ + { + "path": "tests/examples/libinput/tests/libinput.cpp", + "text": "// compat.libinput — the input chain, exercised without a seat.\n//\n// libinput needs a udev context and either a seat or explicit device paths.\n// A CI runner has /sys (so udev enumeration works) but not the permissions to\n// open /dev/input/event*, so what is asserted is everything up to and\n// including the udev handoff — which is exactly where a packaging mistake\n// lands, because that is the seam between the four packages in this chain:\n//\n// compat.libinput\n// ├── freedesktop.libevdev\n// ├── compat.libudev\n// └── compat.mtdev\n//\n// Opening real devices is opt-in (MCPP_RUN_INPUT_DEVICES=1).\n\n#ifdef __linux__\n\n#include \n#include \n\n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// libinput hands device opening back to the caller — that is how a compositor\n// routes it through libseat. Here it is a plain open(), which is what fails\n// without permissions and is why the device half is opt-in.\nint open_restricted(const char *path, int flags, void *)\n{\n const int fd = ::open(path, flags);\n return fd < 0 ? -errno : fd;\n}\n\nvoid close_restricted(int fd, void *)\n{\n ::close(fd);\n}\n\nconst libinput_interface IFACE = {open_restricted, close_restricted};\n\n} // namespace\n\nint main()\n{\n // ── 1. The chain is linked: libinput, libudev, libevdev, mtdev ───────\n // Each of these is a symbol only one of the four packages defines, so the\n // set of them says the whole chain resolved rather than just the top.\n check(::dlsym != nullptr, \"the process has a dynamic linker (trivially)\");\n udev *u = udev_new();\n check(u != nullptr, \"libudev: udev_new (compat.libudev)\");\n if (u == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 2. libinput comes up on that udev context ────────────────────────\n libinput *li = libinput_udev_create_context(&IFACE, nullptr, u);\n check(li != nullptr, \"libinput_udev_create_context on it\");\n if (li == nullptr) {\n udev_unref(u);\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 3. Assigning a seat is where udev enumeration actually runs ──────\n // \"seat0\" is the conventional name and needs no seat manager to NAME —\n // libinput asks udev for devices tagged with it. Devices it cannot open\n // are skipped, so this succeeds with no permissions.\n const int rc = libinput_udev_assign_seat(li, \"seat0\");\n std::printf(\" libinput_udev_assign_seat(\\\"seat0\\\") = %d\\n\", rc);\n check(rc == 0, \"libinput enumerated the seat through libudev\");\n\n // ── 4. The event loop is live ────────────────────────────────────────\n // A dispatch with nothing pending must return cleanly rather than block\n // or fault: it is what a compositor calls every frame.\n check(libinput_dispatch(li) == 0, \"libinput_dispatch with nothing pending\");\n const int fd = libinput_get_fd(li);\n std::printf(\" epoll fd: %d\\n\", fd);\n check(fd >= 0, \"libinput_get_fd returns a pollable descriptor\");\n\n // Count whatever devices were actually openable. Zero is fine on a\n // runner; the assertion is that draining the queue terminates.\n int devices = 0, events = 0;\n for (libinput_event *ev = libinput_get_event(li); ev != nullptr;\n ev = libinput_get_event(li)) {\n if (libinput_event_get_type(ev) == LIBINPUT_EVENT_DEVICE_ADDED) {\n ++devices;\n if (devices <= 3) {\n std::printf(\" device: %s\\n\",\n libinput_device_get_name(libinput_event_get_device(ev)));\n }\n }\n ++events;\n libinput_event_destroy(ev);\n }\n std::printf(\" drained %d event(s), %d device(s) opened\\n\", events, devices);\n check(true, \"the event queue drained without blocking\");\n\n if (std::getenv(\"MCPP_RUN_INPUT_DEVICES\") != nullptr) {\n check(devices > 0,\n \"MCPP_RUN_INPUT_DEVICES is set, so devices must have opened\");\n } else {\n std::puts(\" (opening devices needs permissions: set \"\n \"MCPP_RUN_INPUT_DEVICES=1 where they exist)\");\n }\n\n libinput_unref(li);\n udev_unref(u);\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "libmysqlclient", + "text": "[package]\nname = \"libmysqlclient-tests\"\nversion = \"0.1.0\"\n\n# libmysqlclient 的源码构建在 Linux、macOS 和 Windows 上都执行真实断言。\n[target.'cfg(linux)'.dependencies.compat]\nlibmysqlclient = \"8.4.6\"\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_LIBMYSQLCLIENT=1\"]\n\n[target.'cfg(macos)'.dependencies.compat]\nlibmysqlclient = \"8.4.6\"\n\n[target.'cfg(macos)'.build]\ncxxflags = [\"-DHAVE_LIBMYSQLCLIENT=1\"]\n\n# Windows 也必须执行真实客户端 ABI 和运行时句柄断言。\n[target.'cfg(windows)'.dependencies.compat]\nlibmysqlclient = \"8.4.6\"\n\n[target.'cfg(windows)'.build]\ncxxflags = [\"-DHAVE_LIBMYSQLCLIENT=1\"]\n", + "files": [ + { + "path": "tests/examples/libmysqlclient/tests/client.cpp", + "text": "#ifdef HAVE_LIBMYSQLCLIENT\n#include \n\n// 断言客户端 ABI 版本,并确认初始化得到可用的运行时句柄。\nint main() {\n static_assert(MYSQL_VERSION_ID == 80406);\n if (mysql_get_client_version() != 80406) return 1;\n\n MYSQL* client = mysql_init(nullptr);\n if (client == nullptr) return 2;\n mysql_close(client);\n return 0;\n}\n#else\n#error \"HAVE_LIBMYSQLCLIENT must be enabled for every declared libmysqlclient test platform\"\n#endif\n" + } + ] + }, + { + "project": "libpng", + "text": "[package]\nname = \"libpng-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nlibpng = \"1.6.43\"\n", + "files": [ + { + "path": "tests/examples/libpng/tests/read.cpp", + "text": "// Smoke test — verify compat.libpng builds, links, and basic API works\n#include \"png.h\"\nimport std;\n\nint main() {\n auto* png = png_create_read_struct(PNG_LIBPNG_VER_STRING, nullptr, nullptr, nullptr);\n if (!png) return 1;\n auto* info = png_create_info_struct(png);\n if (!info) { png_destroy_read_struct(&png, nullptr, nullptr); return 2; }\n std::println(\"compat.libpng smoke test: ok\");\n png_destroy_read_struct(&png, &info, nullptr);\n return 0;\n}\n" + } + ] + }, + { + "project": "libseat", + "text": "[package]\nname = \"libseat-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibseat = \"0.9.3\"\n", + "files": [ + { + "path": "tests/examples/libseat/tests/libseat.cpp", + "text": "// compat.libseat — link and entry-point test.\n//\n// WHAT THIS CANNOT DO, and why that is not a gap. Opening a seat needs either\n// a running seatd daemon (SEATD_SOCK) or the privileges to BE the seat manager\n// — a CI runner has neither, and a compositor's own failure to open a seat is\n// an environment problem rather than a packaging one. So what is asserted is\n// that the library is complete and its entry points resolve.\n//\n// `#include ` INSIDE `extern \"C\"`, and that is not boilerplate:\n// upstream's header carries no `extern \"C\"` guard of its own (measured — zero\n// occurrences in include/libseat.h). Included plainly from C++ every\n// declaration gets C++ linkage and every call fails to link with a mangled\n// name. Any C++ consumer has to do this; it is worth having in the test so the\n// requirement is visible rather than discovered.\n\n#ifdef __linux__\n\nextern \"C\" {\n#include \n}\n\n#include \n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n // Taking the address is what forces the link. A header-only mistake or a\n // missing translation unit shows up here.\n check(reinterpret_cast(&libseat_open_seat) != nullptr, \"libseat_open_seat links\");\n check(reinterpret_cast(&libseat_close_seat) != nullptr, \"libseat_close_seat links\");\n check(reinterpret_cast(&libseat_open_device) != nullptr, \"libseat_open_device links\");\n check(reinterpret_cast(&libseat_close_device)!= nullptr, \"libseat_close_device links\");\n check(reinterpret_cast(&libseat_switch_session) != nullptr, \"libseat_switch_session links\");\n check(reinterpret_cast(&libseat_dispatch) != nullptr, \"libseat_dispatch links\");\n check(reinterpret_cast(&libseat_get_fd) != nullptr, \"libseat_get_fd links\");\n\n // The builtin backend pulls the whole seat manager in. `seat_open_device`\n // is one of its symbols, and its presence is what says BUILTIN_ENABLED\n // actually took effect rather than being a define nothing read.\n std::puts(\" (the seatd and builtin backends are compiled in; logind is not)\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "libudev", + "text": "[package]\nname = \"libudev-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\nlibudev = \"1.0.5\"\n", + "files": [ + { + "path": "tests/examples/libudev/tests/libudev.cpp", + "text": "// compat.libudev — behavioral test against /sys, no daemon.\n//\n// libudev-zero reads /sys directly instead of talking to udevd, which is the\n// whole reason it is here: a subos has no udev daemon, and an implementation\n// that needed one would work on a developer's machine and nowhere else. So the\n// test enumerates REAL devices — that is the thing that would silently return\n// nothing if the package were built wrong.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\nint failures = 0;\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n} // namespace\n\nint main()\n{\n // ── 1. The context comes up without a daemon ─────────────────────────\n udev *ctx = udev_new();\n check(ctx != nullptr, \"udev_new without a running udevd\");\n if (ctx == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 2. …and it is THIS build, not the host's libudev ─────────────────\n // systemd's libudev carries the same soname. Only one is ever mapped and\n // nothing warns about the other, so the identity has to be pinned.\n {\n void *sym = ::dlsym(RTLD_DEFAULT, \"udev_new\");\n Dl_info info{};\n if (sym != nullptr && ::dladdr(sym, &info) != 0 && info.dli_fname != nullptr) {\n const std::string from = info.dli_fname;\n std::printf(\" udev_new came from: %s\\n\", from.c_str());\n check(from.find(\"/usr/lib\") == std::string::npos\n && from.find(\"/lib/x86_64\") == std::string::npos,\n \"the loaded libudev is not the host's\");\n }\n }\n\n // ── 3. Real enumeration off /sys ─────────────────────────────────────\n // Every Linux machine has input devices under /sys/class/input, including\n // a container: the kernel exposes them regardless of who may open them.\n // Finding none means the /sys walk is broken, which is the failure this\n // package can actually have.\n udev_enumerate *e = udev_enumerate_new(ctx);\n check(e != nullptr, \"udev_enumerate_new\");\n if (e != nullptr) {\n udev_enumerate_add_match_subsystem(e, \"input\");\n check(udev_enumerate_scan_devices(e) == 0, \"udev_enumerate_scan_devices(input)\");\n\n int n = 0;\n const char *first = nullptr;\n for (udev_list_entry *le = udev_enumerate_get_list_entry(e);\n le != nullptr; le = udev_list_entry_get_next(le)) {\n if (first == nullptr) {\n first = udev_list_entry_get_name(le);\n }\n ++n;\n }\n std::printf(\" %d input device node(s); first: %s\\n\", n, first ? first : \"(none)\");\n check(n > 0, \"the /sys walk found input devices\");\n\n // A device object out of that path, with its subsystem read back —\n // this is the call libinput makes for every device it opens.\n if (first != nullptr) {\n udev_device *d = udev_device_new_from_syspath(ctx, first);\n check(d != nullptr, \"udev_device_new_from_syspath\");\n if (d != nullptr) {\n const char *sub = udev_device_get_subsystem(d);\n std::printf(\" subsystem: %s\\n\", sub ? sub : \"(null)\");\n check(sub != nullptr && std::strcmp(sub, \"input\") == 0,\n \"…and its subsystem reads back as \\\"input\\\"\");\n udev_device_unref(d);\n }\n }\n udev_enumerate_unref(e);\n }\n\n udev_unref(ctx);\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "libwebp", + "text": "# libwebp test project: consumes compat.libwebp from this repo's own index and\n# asserts a real encode -> decode round trip, not just that the symbols link.\n[package]\nname = \"libwebp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nlibwebp = \"1.5.0\"\n", + "files": [ + { + "path": "tests/examples/libwebp/tests/roundtrip.cpp", + "text": "// Behavioral test: encode a synthetic image and decode it back.\n//\n// Linking is not the interesting question for this package — the source list is\n// five directory globs including every SIMD variant, and a variant that failed\n// to self-gate would still LINK while producing wrong pixels. So the assertions\n// are on the pixels: lossless must round-trip exactly, lossy must stay close,\n// and the encoder must actually respond to its quality knob.\n#include \n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\nconstexpr int kW = 64;\nconstexpr int kH = 48;\n\n// A gradient with a hard edge: smooth enough for the lossy path to do well,\n// structured enough that a broken transform shows up as a large delta.\nstd::vector make_rgba() {\n std::vector px(static_cast(kW) * kH * 4);\n for (int y = 0; y < kH; ++y) {\n for (int x = 0; x < kW; ++x) {\n uint8_t* p = &px[(static_cast(y) * kW + x) * 4];\n p[0] = static_cast(x * 4);\n p[1] = static_cast(y * 5);\n p[2] = static_cast(x < kW / 2 ? 30 : 220);\n p[3] = 255;\n }\n }\n return px;\n}\n\n} // namespace\n\nint main() {\n const std::vector src = make_rgba();\n const int stride = kW * 4;\n\n // --- the header round-trips the dimensions ---\n uint8_t* lossless = nullptr;\n const std::size_t lossless_size =\n WebPEncodeLosslessRGBA(src.data(), kW, kH, stride, &lossless);\n assert(lossless_size > 0 && lossless != nullptr);\n\n int w = 0, h = 0;\n assert(WebPGetInfo(lossless, lossless_size, &w, &h) == 1);\n assert(w == kW && h == kH);\n\n // --- lossless must be EXACT ---\n uint8_t* decoded = WebPDecodeRGBA(lossless, lossless_size, &w, &h);\n assert(decoded != nullptr && w == kW && h == kH);\n for (std::size_t i = 0; i < src.size(); ++i) assert(decoded[i] == src[i]);\n WebPFree(decoded);\n\n // --- lossy must be close, and quality must have an effect ---\n uint8_t* q90 = nullptr;\n const std::size_t q90_size = WebPEncodeRGBA(src.data(), kW, kH, stride, 90.0f, &q90);\n assert(q90_size > 0 && q90 != nullptr);\n\n uint8_t* q10 = nullptr;\n const std::size_t q10_size = WebPEncodeRGBA(src.data(), kW, kH, stride, 10.0f, &q10);\n assert(q10_size > 0 && q10 != nullptr);\n assert(q10_size < q90_size); // a lower quality must produce a smaller file\n\n decoded = WebPDecodeRGBA(q90, q90_size, &w, &h);\n assert(decoded != nullptr && w == kW && h == kH);\n long total = 0;\n for (std::size_t i = 0; i < src.size(); ++i)\n total += std::abs(static_cast(decoded[i]) - static_cast(src[i]));\n const double mean_abs_error = static_cast(total) / static_cast(src.size());\n assert(mean_abs_error < 8.0); // quality 90 on a smooth image\n WebPFree(decoded);\n\n WebPFree(lossless);\n WebPFree(q90);\n WebPFree(q10);\n return 0;\n}\n" + } + ] + }, + { + "project": "libxkbcommon", + "text": "# libxkbcommon test project.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"libxkbcommon-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.freedesktop]\nlibxkbcommon = \"1.13.2\"\n", + "files": [ + { + "path": "tests/examples/libxkbcommon/tests/libxkbcommon.cpp", + "text": "// libxkbcommon, exercised through the generated parser.\n//\n// The package is a fork for exactly one reason: `src/xkbcomp/parser.y` is\n// bison's input and the 3,960-line parser is generated. So the test compiles a\n// keymap FROM A STRING — that runs the parser end to end and needs no\n// xkeyboard-config data on disk, which is what makes it runnable on a CI\n// machine and in a sandbox.\n//\n// A package that linked but whose parser was generated without\n// `-p _xkbcommon_`, or not regenerated after a version bump, fails here rather\n// than in somebody's compositor.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// A complete, minimal keymap. Every section the parser knows about appears at\n// least once, so compiling it exercises the grammar rather than a corner of it.\nconst char *const KEYMAP =\n \"xkb_keymap {\\n\"\n \" xkb_keycodes { = 9; = 24; };\\n\"\n \" xkb_types { type \\\"ONE_LEVEL\\\" {\\n\"\n \" modifiers = none;\\n\"\n \" level_name[1] = \\\"Any\\\";\\n\"\n \" }; };\\n\"\n \" xkb_compat { };\\n\"\n \" xkb_symbols {\\n\"\n \" key { [ Escape ] };\\n\"\n \" key { [ q ] };\\n\"\n \" };\\n\"\n \"};\";\n\n} // namespace\n\nint main()\n{\n // ── 1. A context with no data root ───────────────────────────────────\n // XKB_CONTEXT_NO_DEFAULT_INCLUDES because this package compiles in an\n // EMPTY DFLT_XKB_CONFIG_ROOT on purpose — there is no dataset to include,\n // and asking for one would be asking for the host's.\n xkb_context *ctx = xkb_context_new(XKB_CONTEXT_NO_DEFAULT_INCLUDES);\n check(ctx != nullptr, \"xkb_context_new\");\n if (ctx == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 2. THE PARSER. This is what the fork exists for ──────────────────\n xkb_keymap *km = xkb_keymap_new_from_string(\n ctx, KEYMAP, XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS);\n check(km != nullptr, \"xkb_keymap_new_from_string compiled the keymap\");\n if (km == nullptr) {\n xkb_context_unref(ctx);\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 3. The compiled keymap answers about itself ──────────────────────\n const xkb_keycode_t min = xkb_keymap_min_keycode(km);\n const xkb_keycode_t max = xkb_keymap_max_keycode(km);\n std::printf(\" keycodes %u..%u\\n\", min, max);\n check(min <= 9 && max >= 24, \"…and its keycode range covers both keys\");\n\n // ── 4. A key press produces the right keysym ─────────────────────────\n // This is the whole job: keycode in, keysym out, through the state\n // machine the compat and types sections drive.\n xkb_state *st = xkb_state_new(km);\n check(st != nullptr, \"xkb_state_new\");\n if (st != nullptr) {\n const xkb_keysym_t sym = xkb_state_key_get_one_sym(st, 24);\n char name[64] = {0};\n xkb_keysym_get_name(sym, name, sizeof name);\n std::printf(\" keycode 24 -> keysym %s\\n\", name);\n check(sym == XKB_KEY_q, \"keycode 24 resolves to the keysym 'q'\");\n\n char buf[16] = {0};\n const int n = xkb_state_key_get_utf8(st, 24, buf, sizeof buf);\n std::printf(\" utf8: \\\"%s\\\" (%d byte(s))\\n\", buf, n);\n check(n == 1 && buf[0] == 'q', \"…and to the UTF-8 text \\\"q\\\"\");\n\n const xkb_keysym_t esc = xkb_state_key_get_one_sym(st, 9);\n check(esc == XKB_KEY_Escape, \"keycode 9 resolves to Escape\");\n\n xkb_state_unref(st);\n }\n\n // ── 5. The keysym tables, which are their own generated data ─────────\n check(xkb_keysym_from_name(\"Escape\", XKB_KEYSYM_NO_FLAGS) == XKB_KEY_Escape,\n \"xkb_keysym_from_name resolves a name to its keysym\");\n\n // ── 6. RMLVO, if a dataset is reachable ──────────────────────────────\n //\n // This is the OTHER half of libxkbcommon, and the half this package\n // deliberately cannot satisfy on its own: `xkb_keymap_new_from_names`\n // takes rules/model/layout/variant/options and reads them out of\n // xkeyboard-config's data tree. DFLT_XKB_CONFIG_ROOT is compiled in EMPTY\n // here on purpose, so the path has to come from the environment —\n // XKB_CONFIG_ROOT, which is what an ecosystem package declares.\n //\n // Reported rather than asserted when unset: a machine with no dataset is\n // not a defect in this package. When it IS set, the compile must succeed,\n // because then the dataset is the thing under test.\n {\n const char *root = std::getenv(\"XKB_CONFIG_ROOT\");\n std::printf(\"\\n XKB_CONFIG_ROOT = %s\\n\",\n root ? root : \"(unset — the ecosystem declares it)\");\n if (root != nullptr) {\n xkb_context *c2 = xkb_context_new(XKB_CONTEXT_NO_FLAGS);\n xkb_rule_names names{};\n names.rules = \"evdev\";\n names.model = \"pc105\";\n names.layout = \"us\";\n xkb_keymap *k2 = c2 ? xkb_keymap_new_from_names(\n c2, &names, XKB_KEYMAP_COMPILE_NO_FLAGS) : nullptr;\n check(k2 != nullptr,\n \"xkb_keymap_new_from_names(evdev/pc105/us) against that root\");\n if (k2 != nullptr) {\n xkb_state *s2 = xkb_state_new(k2);\n if (s2 != nullptr) {\n char b[16] = {0};\n xkb_state_key_get_utf8(s2, 24, b, sizeof b);\n std::printf(\" real layout: keycode 24 -> \\\"%s\\\"\\n\", b);\n check(b[0] == 'q',\n \"…and the real us layout maps keycode 24 to \\\"q\\\"\");\n xkb_state_unref(s2);\n }\n xkb_keymap_unref(k2);\n }\n if (c2) xkb_context_unref(c2);\n }\n }\n\n xkb_keymap_unref(km);\n xkb_context_unref(ctx);\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "llamacpp-metal", + "text": "# Metal is supported only by the macOS ARM64 leg of the workspace matrix.\n[indices]\nggml-org = { path = \"../../..\" }\n\n[package]\nname = \"llamacpp-metal-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(macos)'.dependencies.ggml-org]\nllamacpp = { version = \"b10069.2\", features = [\"backend-metal\"] }\n", + "files": [ + { + "path": "tests/examples/llamacpp-metal/tests/backend.cpp", + "text": "import std;\n\n#if defined(__APPLE__) && defined(__aarch64__)\nimport llamacpp;\n\nint main() {\n llama_backend_init();\n const bool metal_available = llama_supports_gpu_offload();\n llama_backend_free();\n\n if (!metal_available) {\n std::cerr << \"Metal backend is not available\\n\";\n return 1;\n }\n\n std::cout << \"LLAMACPP_METAL_INDEX_TEST=PASS\\n\";\n return 0;\n}\n#else\nint main() {\n return 0;\n}\n#endif\n" + } + ] + }, + { + "project": "llamacpp", + "text": "# Resolve the public default-namespace package from this checkout.\n[indices]\nggml-org = { path = \"../../..\" }\n\n[package]\nname = \"llamacpp-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.ggml-org]\nllamacpp = \"b10069.2\"\n\n[target.'cfg(macos)'.dependencies.ggml-org]\nllamacpp = \"b10069.2\"\n\n[target.'cfg(windows)'.dependencies.ggml-org]\nllamacpp = \"b10069.2\"\n", + "files": [ + { + "path": "tests/examples/llamacpp/tests/api.cpp", + "text": "import std;\nimport llamacpp;\n\nint main() {\n static_assert(LLAMA_DEFAULT_SEED == 0xFFFFFFFFu);\n static_assert(LLAMA_TOKEN_NULL == llama_token{-1});\n\n llama_backend_init();\n\n const auto model_params = llama_model_default_params();\n const bool gpu_offload_available = llama_supports_gpu_offload();\n llama_sampler * chain = llama_sampler_chain_init(\n llama_sampler_chain_default_params()\n );\n llama_sampler * greedy = llama_sampler_init_greedy();\n\n if (!chain || !greedy) {\n if (chain) {\n llama_sampler_free(chain);\n } else if (greedy) {\n llama_sampler_free(greedy);\n }\n llama_backend_free();\n return 1;\n }\n\n llama_sampler_chain_add(chain, greedy);\n llama_sampler_free(chain);\n llama_backend_free();\n\n std::cout << \"gpu_offload_available=\" << gpu_offload_available\n << \" default_gpu_layers=\" << model_params.n_gpu_layers << '\\n'\n << \"LLAMACPP_INDEX_TEST=PASS\\n\";\n return 0;\n}\n" + } + ] + }, + { + "project": "llmapi", + "text": "# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `default`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\ndefault = { path = \"../../..\" }\n\n[package]\nname = \"llmapi-tests\"\nversion = \"0.1.0\"\n\n[dependencies]\nllmapi = \"0.2.8\"\n", + "files": [ + { + "path": "tests/examples/llmapi/tests/consume.cpp", + "text": "// Consuming `mcpplibs.llmapi` through the published index.\n//\n// Every assertion here is OFFLINE, on purpose. This is an HTTP client, and a\n// test that reaches an LLM endpoint needs a key CI does not have, costs money,\n// and fails for reasons that have nothing to do with the package. What a\n// workspace member is for is the dependency edge: does the module import,\n// does its exported surface exist and behave, does it link.\n//\n// The partitions exercised (:url, :types, :errors) are pure data and pure\n// logic, which is exactly why they are the right ones to assert on.\nimport std;\nimport mcpplibs.llmapi;\n\nusing namespace mcpplibs;\n\nint main() {\n int failures = 0;\n auto check = [&](bool ok, std::string_view what) {\n if (!ok) { std::println(\"FAIL: {}\", what); ++failures; }\n };\n\n // :url — the provider endpoints are compile-time constants, so a wrong one\n // is a wrong request forever. Asserting the shape catches a typo'd scheme\n // or a dropped /v1 that would only surface as a 404 at runtime.\n check(llmapi::URL::OpenAI.starts_with(\"https://\"), \"OpenAI endpoint is https\");\n check(llmapi::URL::OpenAI.ends_with(\"/v1\"), \"OpenAI endpoint is versioned\");\n check(llmapi::URL::Anthropic.starts_with(\"https://\"), \"Anthropic endpoint is https\");\n check(llmapi::URL::DeepSeek.ends_with(\"/v1\"), \"DeepSeek endpoint is versioned\");\n check(llmapi::URL::OpenAI != llmapi::URL::Anthropic, \"providers are distinct\");\n\n // :types — the variant-based content model. Constructing and visiting it\n // is what a consumer does before any request exists.\n llmapi::Content plain = std::string{\"hello\"};\n check(std::holds_alternative(plain), \"plain text content\");\n\n std::vector parts;\n parts.emplace_back(llmapi::TextContent{\"describe this\"});\n parts.emplace_back(llmapi::ImageContent{\"https://example.invalid/x.png\",\n \"image/png\", /*isUrl=*/true});\n llmapi::Content multimodal = parts;\n check(std::holds_alternative>(multimodal),\n \"multimodal content\");\n check(std::get>(multimodal).size() == 2,\n \"both parts survive\");\n check(std::holds_alternative(\n std::get>(multimodal)[1]),\n \"the image part keeps its alternative\");\n\n check(llmapi::Role::User != llmapi::Role::Assistant, \"roles are distinct\");\n\n // :errors — the type an unhappy call throws. A consumer catches these by\n // type, so the hierarchy is part of the contract.\n try {\n throw llmapi::ApiError(429, \"rate_limit\", \"{}\", \"slow down\");\n } catch (const std::runtime_error& e) {\n check(std::string_view(e.what()) == \"slow down\", \"ApiError carries its message\");\n }\n try {\n throw llmapi::ApiError(500, \"server\", \"{}\", \"boom\");\n } catch (const llmapi::ApiError& e) {\n check(e.statusCode == 500, \"ApiError carries its status\");\n check(e.type == \"server\", \"ApiError carries its type\");\n }\n\n if (failures == 0) std::println(\"llmapi: ok\");\n return failures == 0 ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "magic_enum", + "text": "# magic_enum test project: consumes the C++23 module `magic_enum` and\n# asserts enum reflection under `mcpp test`.\n[indices]\nneargye = { path = \"../../..\" }\n\n[package]\nname = \"magic_enum-tests\"\nversion = \"0.1.0\"\n\n[dependencies.neargye]\nmagic_enum = \"0.9.8\"", + "files": [ + { + "path": "tests/examples/magic_enum/tests/reflection.cpp", + "text": "// Behavioral test: enum_name, enum_cast, enum_values under `mcpp test`.\nimport std;\nimport magic_enum;\n\nenum class Color { RED = -10, BLUE = 0, GREEN = 10 };\n\nint main() {\n Color c = Color::RED;\n auto name = magic_enum::enum_name(c);\n bool ok = (name == \"RED\");\n\n auto cast = magic_enum::enum_cast(\"GREEN\");\n ok = ok && cast.has_value() && cast.value() == Color::GREEN;\n\n auto values = magic_enum::enum_values();\n ok = ok && values.size() == 3;\n\n return ok ? 0 : 1;\n}" + } + ] + }, + { + "project": "marzer.tomlplusplus", + "text": "# toml++ test project: consumes the C++23 module `tomlplusplus` and asserts\n# parse + typed access + round-trip serialization under `mcpp test`.\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `marzer`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nmarzer = { path = \"../../..\" }\n\n[package]\nname = \"tomlplusplus-tests\"\nversion = \"0.1.0\"\n\n[dependencies.marzer]\ntomlplusplus = \"3.4.0\"\n", + "files": [ + { + "path": "tests/examples/marzer.tomlplusplus/tests/parse.cpp", + "text": "// Behavioral test: parse → typed access → array/table nodes → _toml literal →\n// serialize-and-reparse round-trip, all through the C++23 module surface\n// (`import tomlplusplus;`, no #include).\nimport std;\nimport tomlplusplus;\n\nint main() {\n auto cfg = toml::parse(R\"(\n [server]\n host = \"localhost\"\n port = 8080\n tags = [\"a\", \"b\"]\n )\");\n\n // typed access — value so this compares strings, not pointers\n bool ok = cfg[\"server\"][\"port\"].value() == 8080\n && cfg[\"server\"][\"host\"].value() == \"localhost\"\n && cfg[\"server\"][\"tags\"].as_array()->size() == 2;\n\n // the _toml literal re-exported through toml::literals\n using namespace toml::literals;\n auto lit = \"x = 1\"_toml;\n ok = ok && lit[\"x\"].value() == 1;\n\n // round-trip: serialize via the exported operator<<, then re-parse\n std::ostringstream os;\n os << cfg;\n auto again = toml::parse(os.str());\n ok = ok && again[\"server\"][\"port\"].value() == 8080\n && again[\"server\"][\"tags\"].as_array()->size() == 2;\n\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "mcpp-plugins", + "text": "# mcpp.plugins consumer: the official plugin package, resolved from this\n# checkout, reached from a build program. `tools-embed` needs no payload, so\n# the member runs on every platform; its build program imports L3\n# (`mcpp.tools.embed`) and L2 (`mcpp.plugins.fs`, through `plugins-core`, which\n# every member implies). The member is also the positive leg of the naming rule\n# tests/run_members.sh enforces: namespace `mcpp` provides `mcpp.tools.*` and\n# `mcpp.plugins.*` without the engine's reserved-name warning.\n[package]\nname = \"mcpp-plugins-tests\"\nversion = \"0.1.0\"\n\n[indices]\nmcpp = { path = \"../../..\" }\n\n[build-dependencies.mcpp]\nplugins = { version = \"0.17.0\", features = [\"tools-embed\"], host-module = true }\n", + "files": [ + { + "path": "tests/examples/mcpp-plugins/tests/embed.cpp", + "text": "#include \n#include \n#include \n#include \"message_txt.h\"\n\nint main() {\n const char* text = reinterpret_cast(fixture::message_txt);\n if (fixture::message_txt_size != std::strlen(\"hello from mcpp.plugins\")) return 1;\n if (std::strcmp(text, \"hello from mcpp.plugins\") != 0) return 1;\n std::puts(\"mcpp-plugins-tests: the embedded file reached the program\");\n return 0;\n}\n" + } + ] + }, + { + "project": "md4c", + "text": "[package]\nname = \"md4c-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nmd4c = \"0.5.3\"\n", + "files": [ + { + "path": "tests/examples/md4c/tests/test_md4c.cpp", + "text": "// Smoke test — verify compat.md4c header is usable + links successfully\n#include \"md4c.h\"\nimport std;\n\nint main() {\n // Verify the header is available and the ABI struct is defined\n MD_PARSER parser = {0};\n std::println(\"compat.md4c smoke test: ok\");\n return 0;\n}\n" + } + ] + }, + { + "project": "mimalloc", + "text": "# mimalloc test project: allocate through the mi_* API and assert the allocator\n# actually owns what it handed back, under `mcpp test`.\n#\n# Resolves compat.mimalloc from this repo's own index via the inherited\n# workspace-root `[indices]`. The dependency is spelled QUALIFIED for the same\n# reason every other member does it: the redirect is keyed by the REQUEST's\n# namespace, so a bare `mimalloc = \"3.4.5\"` would resolve from the published\n# remote index and quietly stop testing this checkout.\n[package]\nname = \"mimalloc-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nmimalloc = \"3.4.5\"\n", + "files": [ + { + "path": "tests/examples/mimalloc/tests/alloc.cpp", + "text": "// compat.mimalloc — assert the allocator is real, not just that it links.\n//\n// Every check below can fail: mi_usable_size reports what mimalloc actually\n// reserved (so it proves the pointer came from mimalloc's own bookkeeping and\n// not from a fallback), mi_zalloc must return zeroed memory, and a private\n// heap must be able to hand out and destroy blocks independently of the\n// default heap.\n#include \nimport std;\n\nint main() {\n bool ok = true;\n\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n // 1. Basic allocation, and mimalloc knows the block's real size.\n void* p = mi_malloc(1024);\n check(p != nullptr, \"mi_malloc(1024) returned null\");\n if (p) {\n // mimalloc rounds up to a size class, so usable >= requested.\n check(mi_usable_size(p) >= 1024, \"mi_usable_size < requested size\");\n std::memset(p, 0xAB, 1024);\n check(static_cast(p)[1023] == 0xAB, \"written byte did not survive\");\n mi_free(p);\n }\n\n // 2. mi_zalloc must actually zero.\n constexpr std::size_t n = 512;\n auto* z = static_cast(mi_zalloc(n));\n check(z != nullptr, \"mi_zalloc returned null\");\n if (z) {\n check(std::all_of(z, z + n, [](unsigned char c) { return c == 0; }),\n \"mi_zalloc did not zero the block\");\n mi_free(z);\n }\n\n // 3. Aligned allocation honours the alignment.\n void* a = mi_malloc_aligned(256, 64);\n check(a != nullptr, \"mi_malloc_aligned returned null\");\n if (a) {\n check(reinterpret_cast(a) % 64 == 0, \"alignment not honoured\");\n mi_free(a);\n }\n\n // 4. A private heap allocates and tears down independently.\n mi_heap_t* heap = mi_heap_new();\n check(heap != nullptr, \"mi_heap_new returned null\");\n if (heap) {\n void* hp = mi_heap_malloc(heap, 4096);\n check(hp != nullptr, \"mi_heap_malloc returned null\");\n check(mi_heap_contains(heap, hp), \"heap does not contain its own block\");\n mi_heap_destroy(heap); // frees hp with it\n }\n\n std::println(\"compat.mimalloc smoke test: {}\", ok ? \"ok\" : \"FAILED\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "miniaudio", + "text": "# miniaudio test project: encode a WAV, decode it back, and assert the samples\n# survive the round trip. Deliberately device-free -- CI has no sound card, so\n# this exercises the codec/DSP paths rather than ma_device.\n[package]\nname = \"miniaudio-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nminiaudio = \"0.11.25\"\n", + "files": [ + { + "path": "tests/examples/miniaudio/tests/roundtrip.cpp", + "text": "// compat.miniaudio — encode a WAV, decode it back, and assert the samples\n// survive the round trip.\n//\n// Deliberately DEVICE-FREE. A CI runner has no sound card, so ma_device_init\n// would fail for reasons that say nothing about the package. The encoder,\n// decoder and the WAV codec behind them are the parts that can be asserted\n// anywhere, and they still prove the implementation TU (miniaudio.c) is\n// compiled and linked -- none of these symbols exist without it.\n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n constexpr ma_uint32 RATE = 48000;\n constexpr ma_uint32 CHANNELS = 1;\n constexpr ma_uint64 FRAMES = 4800; // 100 ms\n const std::string path = \"miniaudio_roundtrip.wav\";\n\n // A 480 Hz sine: 10 whole cycles in 4800 frames, so the signal is\n // continuous and its extremes are hit exactly.\n std::vector written(FRAMES);\n for (ma_uint64 i = 0; i < FRAMES; ++i) {\n written[i] = 0.5f * std::sin(2.0f * std::numbers::pi_v *\n 480.0f * static_cast(i) / RATE);\n }\n\n // ---- encode ----------------------------------------------------------\n {\n ma_encoder_config cfg =\n ma_encoder_config_init(ma_encoding_format_wav, ma_format_f32, CHANNELS, RATE);\n ma_encoder enc;\n check(ma_encoder_init_file(path.c_str(), &cfg, &enc) == MA_SUCCESS,\n \"encoder opened the output file\");\n\n ma_uint64 put = 0;\n check(ma_encoder_write_pcm_frames(&enc, written.data(), FRAMES, &put) == MA_SUCCESS,\n \"encoder accepted the frames\");\n check(put == FRAMES, \"encoder wrote every frame\");\n ma_encoder_uninit(&enc);\n }\n\n // ---- decode ----------------------------------------------------------\n {\n ma_decoder_config cfg = ma_decoder_config_init(ma_format_f32, CHANNELS, RATE);\n ma_decoder dec;\n check(ma_decoder_init_file(path.c_str(), &cfg, &dec) == MA_SUCCESS,\n \"decoder opened the file just written\");\n\n check(dec.outputSampleRate == RATE, \"decoded sample rate matches\");\n check(dec.outputChannels == CHANNELS, \"decoded channel count matches\");\n\n ma_uint64 total = 0;\n check(ma_decoder_get_length_in_pcm_frames(&dec, &total) == MA_SUCCESS,\n \"decoder reported a length\");\n check(total == FRAMES, \"decoded length matches what was encoded\");\n\n std::vector read(FRAMES, 0.0f);\n ma_uint64 got = 0;\n check(ma_decoder_read_pcm_frames(&dec, read.data(), FRAMES, &got) == MA_SUCCESS,\n \"decoder returned frames\");\n check(got == FRAMES, \"decoder returned every frame\");\n\n // f32 WAV is lossless, so this is an equality check within float noise.\n float worst = 0.0f;\n for (ma_uint64 i = 0; i < got; ++i) {\n worst = std::max(worst, std::abs(read[i] - written[i]));\n }\n check(worst < 1e-5f, \"samples survive the round trip\");\n\n // Guard against the degenerate pass: silence would also round-trip.\n float peak = 0.0f;\n for (float v : read) peak = std::max(peak, std::abs(v));\n check(peak > 0.45f, \"decoded signal is the sine, not silence\");\n\n ma_decoder_uninit(&dec);\n }\n\n std::error_code ec;\n std::filesystem::remove(path, ec);\n\n if (ok) std::println(\"miniaudio OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "mio", + "text": "# compat.mio test project. Header-only; `compat` redirect inherited from the\n# workspace root.\n[package]\nname = \"mio-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nmio = \"2023.3.3\"\n", + "files": [ + { + "path": "tests/examples/mio/tests/mmap.cpp", + "text": "// Behavioral test — compat.mio actually maps a file.\n//\n// The test writes its own file into the working directory, maps it, reads\n// through the mapping and then writes through a second mapping. Nothing here\n// depends on the runner's filesystem layout beyond \"can create a file\", which\n// is true on all three CI platforms.\n#include \n\n#include \n#include \n#include \n#include \n\nint main() {\n const std::string path = \"mcpp_mio_test.bin\";\n const std::string payload = \"mcpp-index mio round trip\";\n\n {\n std::ofstream out(path, std::ios::binary | std::ios::trunc);\n out.write(payload.data(), static_cast(payload.size()));\n }\n\n std::error_code ec;\n // Read mapping: the bytes must come back through the mapping, not through\n // a copy — a broken include root cannot get this far, but a mio that\n // mapped nothing would return an empty range.\n mio::mmap_source ro = mio::make_mmap_source(path, 0, mio::map_entire_file, ec);\n if (ec) { std::printf(\"make_mmap_source failed: %s\\n\", ec.message().c_str()); std::remove(path.c_str()); return 1; }\n if (ro.size() != payload.size() || std::string(ro.begin(), ro.end()) != payload) {\n std::printf(\"mapped content differs\\n\");\n std::remove(path.c_str());\n return 2;\n }\n ro.unmap();\n\n // Write mapping: mutate through the map, flush, and read the file back\n // with ordinary I/O. This is the half that proves it is a real mapping and\n // not a buffered read.\n mio::mmap_sink rw = mio::make_mmap_sink(path, 0, mio::map_entire_file, ec);\n if (ec) { std::printf(\"make_mmap_sink failed: %s\\n\", ec.message().c_str()); std::remove(path.c_str()); return 3; }\n rw[0] = 'M';\n rw.sync(ec);\n if (ec) { std::printf(\"sync failed: %s\\n\", ec.message().c_str()); std::remove(path.c_str()); return 4; }\n rw.unmap();\n\n std::string back;\n {\n std::ifstream in(path, std::ios::binary);\n back.assign(std::istreambuf_iterator(in), std::istreambuf_iterator());\n }\n std::remove(path.c_str());\n\n if (back.empty() || back[0] != 'M') { std::printf(\"write through the mapping did not reach the file\\n\"); return 5; }\n\n std::printf(\"compat.mio: ok (mapped %zu bytes, read back and wrote through)\\n\", payload.size());\n return 0;\n}\n" + } + ] + }, + { + "project": "msdfgen", + "text": "# msdfgen test project: build a real shape, generate an MSDF from it, and\n# assert the field is a distance field rather than a blank bitmap.\n#\n# compat.freetype arrives transitively through compat.msdfgen (ext/import-font\n# needs it), so it is not repeated here.\n[package]\nname = \"msdfgen-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nmsdfgen = \"1.13\"\n", + "files": [ + { + "path": "tests/examples/msdfgen/tests/generate.cpp", + "text": "// compat.msdfgen — generate a real multi-channel signed distance field and\n// assert its contents, not just that the library links.\n//\n// The shape is a square built by hand, so the test needs no font file and no\n// ext/ importer. What makes the assertions real:\n// * the field must be signed -- inside the square the distance is positive,\n// outside it is negative. A blank or all-zero bitmap fails both.\n// * edge colouring must have run, otherwise the three channels are identical\n// and the \"multi-channel\" part of MSDF did nothing.\n//\n// The `msdfgen/` include prefix is the one this package generates; using it\n// here is what keeps the shim covered by CI.\n#include \n#include \nimport std;\n\nint main() {\n bool ok = true;\n\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n // A 16x16 axis-aligned square, inset from the 32x32 bitmap so that both\n // the inside and the outside are sampled.\n msdfgen::Shape shape;\n msdfgen::Contour& contour = shape.addContour();\n const msdfgen::Point2 p0(8, 8), p1(24, 8), p2(24, 24), p3(8, 24);\n contour.addEdge(msdfgen::EdgeHolder(p0, p1));\n contour.addEdge(msdfgen::EdgeHolder(p1, p2));\n contour.addEdge(msdfgen::EdgeHolder(p2, p3));\n contour.addEdge(msdfgen::EdgeHolder(p3, p0));\n\n shape.normalize();\n check(shape.validate(), \"hand-built shape did not validate\");\n\n // Winding decides the sign of the field, and the order above is not\n // guaranteed to be the one msdfgen calls \"inside\". orientContours() is\n // upstream's own normalizer for exactly this (its standalone tool runs it\n // on imported geometry), so the assertions below can talk about inside and\n // outside without depending on how the contour happened to be written.\n shape.orientContours();\n\n msdfgen::edgeColoringSimple(shape, 3.0);\n\n msdfgen::Bitmap msdf(32, 32);\n msdfgen::SDFTransformation t(\n msdfgen::Projection(msdfgen::Vector2(1.0), msdfgen::Vector2(0.0)),\n msdfgen::Range(4.0));\n msdfgen::generateMSDF(msdf, shape, t);\n\n // Median of the three channels is the reconstructed signed distance.\n auto median_at = [&](int x, int y) {\n const float* px = msdf(x, y);\n return msdfgen::median(px[0], px[1], px[2]);\n };\n\n // Centre of the square is inside → positive; a corner of the bitmap is\n // far outside → negative. msdfgen encodes 0.5 as the edge.\n const float inside = median_at(16, 16);\n const float outside = median_at(0, 0);\n check(inside > 0.5f, std::format(\"centre should read inside, got {}\", inside));\n check(outside < 0.5f, std::format(\"corner should read outside, got {}\", outside));\n\n // Multi-channel: at least one pixel must have channels that differ,\n // otherwise edge colouring never ran and this is a plain SDF.\n bool multi_channel = false;\n for (int y = 0; y < 32 && !multi_channel; ++y) {\n for (int x = 0; x < 32; ++x) {\n const float* px = msdf(x, y);\n if (px[0] != px[1] || px[1] != px[2]) {\n multi_channel = true;\n break;\n }\n }\n }\n check(multi_channel, \"all three channels identical — edge colouring did not run\");\n\n // ext/import-font is compiled in: initializing FreeType through msdfgen\n // proves the FreeType-backed half of the package is present, and that the\n // transitive compat.freetype dependency reached the link line.\n msdfgen::FreetypeHandle* ft = msdfgen::initializeFreetype();\n check(ft != nullptr, \"msdfgen::initializeFreetype returned null\");\n if (ft) {\n msdfgen::deinitializeFreetype(ft);\n }\n\n std::println(\"compat.msdfgen smoke test: {} (inside={:.3f} outside={:.3f})\",\n ok ? \"ok\" : \"FAILED\", inside, outside);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "mtdev", + "text": "[package]\nname = \"mtdev-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\nmtdev = \"1.1.7\"\n", + "files": [ + { + "path": "tests/examples/mtdev/tests/mtdev.cpp", + "text": "// compat.mtdev — behavioral test, no input device required.\n//\n// mtdev translates kernel multitouch protocol A into protocol B. The test\n// exercises the translator's own state machine rather than a device: the\n// interesting failure is a package that links but whose slot machinery was\n// compiled out, and that shows up here rather than on a touchscreen.\n\n#ifdef __linux__\n\n#include \n#include \n#include \n\n#include \n#include \n\nnamespace {\nint failures = 0;\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n} // namespace\n\nint main()\n{\n // ── 1. A device object can be created and configured ─────────────────\n // mtdev_new/mtdev_init are the plumbing entry points: they set up the\n // slot state without needing an fd, which is what makes this testable.\n mtdev *dev = mtdev_new();\n check(dev != nullptr, \"mtdev_new allocates a translator\");\n if (dev == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n check(mtdev_init(dev) == 0, \"mtdev_init sets up the slot state machine\");\n\n // ── 2. It reports what it can translate ──────────────────────────────\n // ABS_MT_POSITION_X is the axis every protocol-A device carries; asking\n // about it exercises the capability table the translator is built from.\n mtdev_set_mt_event(dev, ABS_MT_POSITION_X, 1);\n check(mtdev_has_mt_event(dev, ABS_MT_POSITION_X) != 0,\n \"a multitouch axis can be declared and read back\");\n mtdev_set_mt_event(dev, ABS_MT_POSITION_X, 0);\n check(mtdev_has_mt_event(dev, ABS_MT_POSITION_X) == 0,\n \"…and cleared again\");\n\n // ── 3. With nothing fed in, nothing comes out ────────────────────────\n // mtdev_empty is the drain check libinput calls in its read loop. On a\n // fresh translator it must be true — a package whose buffers were\n // compiled wrong tends to report data that was never written.\n check(mtdev_empty(dev) != 0, \"a fresh translator has no pending events\");\n\n mtdev_close(dev);\n mtdev_delete(dev);\n check(true, \"mtdev_close and mtdev_delete complete\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "muduo", + "text": "# compat.muduo test project. The check exercises a public network buffer API.\n[package]\nname = \"muduo-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nmuduo = \"2.0.3\"\n", + "files": [ + { + "path": "tests/examples/muduo/tests/buffer.cpp", + "text": "#ifdef __linux__\n#include \n\n#include \n\nint main() {\n muduo::net::Buffer buffer;\n const char payload[] = \"muduo\";\n buffer.append(payload, sizeof(payload) - 1);\n if (buffer.readableBytes() != sizeof(payload) - 1) return 1;\n if (std::memcmp(buffer.peek(), payload, sizeof(payload) - 1) != 0) return 1;\n buffer.retrieveAll();\n return buffer.readableBytes() == 0 ? 0 : 1;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "mysql-connector-cpp", + "text": "[package]\nname = \"mysql-connector-cpp-tests\"\nversion = \"0.1.0\"\n\n# Connector/C++ 同时构建 X DevAPI 和 JDBC,并依赖源码构建的\n# compat.libmysqlclient/compat.openssl,因此目前只覆盖 Linux/macOS。\n[target.'cfg(linux)'.dependencies.compat]\nmysql-connector-cpp = \"26.7.0\"\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_MYSQL_CONNECTOR_CPP=1\"]\n\n[target.'cfg(macos)'.dependencies.compat]\nmysql-connector-cpp = \"26.7.0\"\n\n[target.'cfg(macos)'.build]\ncxxflags = [\"-DHAVE_MYSQL_CONNECTOR_CPP=1\"]\n", + "files": [ + { + "path": "tests/examples/mysql-connector-cpp/tests/connector.cpp", + "text": "#ifdef HAVE_MYSQL_CONNECTOR_CPP\n#include \n#include \n\n#include \n\nint main() {\n // 同一个 Connector/C++ 包应同时提供 X DevAPI 和 JDBC。\n mysqlx::Value value(std::string(\"mcpp\"));\n if (value.get() != \"mcpp\") return 1;\n\n sql::mysql::MySQL_Driver* driver = sql::mysql::get_mysql_driver_instance();\n if (driver == nullptr) return 2;\n if (driver->getMajorVersion() != 26) return 3;\n if (driver->getMinorVersion() != 7) return 4;\n return driver->getPatchVersion() == 0 ? 0 : 5;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "nanodbc", + "text": "# nanodbc test project: assert the ODBC driver manager round-trips through the\n# wrapper. CI has no database and no ODBC driver, but it has the driver manager\n# (unixODBC on linux), which is exactly the layer nanodbc wraps -- so the\n# behavioral assertion drives the ERROR path: connecting to a DSN that does\n# not exist must throw nanodbc::database_error carrying the manager's own\n# diagnostics.\n#\n# Gated to linux: windows (-lodbc32, Windows SDK) and macOS (-liodbc) are\n# declared in the descriptor for consumers but are not exercised by this\n# index's CI -- the linux leg is the one where the system driver manager is\n# guaranteed present.\n[package]\nname = \"nanodbc-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nnanodbc = \"2.14.0\"\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_NANODBC=1\"]\n", + "files": [ + { + "path": "tests/examples/nanodbc/tests/error_path.cpp", + "text": "// compat.nanodbc — connect to a DSN that does not exist and assert nanodbc\n// reports the driver manager's own diagnostics as a nanodbc::database_error.\n//\n// Deliberately DATABASE-FREE. A CI runner has no database server and no ODBC\n// driver, so a successful connect asserts nothing that can hold everywhere.\n// What IS guaranteed is the driver manager (unixODBC), which is the layer\n// nanodbc wraps: the failed connect still walks handle allocation, the DSN\n// lookup and the diagnostic formatting, and every one of those symbols comes\n// from the compiled nanodbc.cpp — a half-linked package fails here.\n#ifdef HAVE_NANODBC\n\n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n bool thrown = false;\n try {\n // A DSN that cannot exist anywhere; SQL_DRIVER_NOPROMPT keeps the DM\n // from trying to raise a UI prompt on a headless runner.\n nanodbc::connection conn(\n NANODBC_TEXT(\"DSN=mcpp-definitely-missing\"), NANODBC_TEXT(\"\"),\n NANODBC_TEXT(\"\"), 1 /* timeout */);\n check(false, \"connecting to a nonexistent DSN must not succeed\");\n } catch (const nanodbc::database_error& e) {\n thrown = true;\n std::println(\"caught database_error: state={} what={}\", e.state(),\n e.what());\n // IM002, \"[Driver Manager]Data source name not found\", is the proof\n // the diagnostics came out of the real driver manager, not a stub.\n //\n // nanodbc 2.14.0 itself drops the LAST state character (recent_error\n // in nanodbc.cpp loops to size(sql_state) - 1), so state() reads\n // \"IM00\" where the DM said \"IM002\". Frozen upstream, so assert the\n // prefix relationship rather than equality.\n check(!e.state().empty() && std::string_view(\"IM002\").starts_with(e.state()),\n \"SQL state must be a non-empty prefix of IM002\");\n check(std::string_view(e.what()).find(\"Data source name not found\") !=\n std::string_view::npos,\n \"what() must carry the driver manager's own message\");\n } catch (const std::exception& e) {\n check(false, std::format(\"wrong exception type: {}\", e.what()));\n }\n check(thrown, \"nanodbc::database_error must be thrown for a bogus DSN\");\n\n if (ok) std::println(\"nanodbc error-path test passed\");\n return ok ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; } // non-linux: dependency is cfg-gated out, nothing to test\n\n#endif\n" + } + ] + }, + { + "project": "nanosvg", + "text": "# nanosvg test project: parse a real SVG document and rasterize it, asserting\n# the geometry and the pixels -- which also proves the package's generated\n# implementation TU is linked (both nanosvg and nanosvgrast).\n[package]\nname = \"nanosvg-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nnanosvg = \"2026.07.09\"\n", + "files": [ + { + "path": "tests/examples/nanosvg/tests/parse.cpp", + "text": "// compat.nanosvg — parse a real SVG document and rasterize it.\n//\n// This asserts three separate things the package must get right:\n// 1. `` / `` resolve (include_dirs points at src/),\n// 2. the parser's output is real geometry, not an empty image, and\n// 3. the generated implementation TU is actually linked -- BOTH halves of it.\n// nsvgParse comes from nanosvg.h's implementation and nsvgRasterize from\n// nanosvgrast.h's, so if the package only instantiated one of the two this\n// test fails at link time rather than silently passing.\n#include \n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n // A 100x100 canvas holding one opaque red rectangle covering the middle\n // half. nsvgParse mutates its input, so hand it a writable copy.\n std::string svg =\n R\"()\"\n R\"()\"\n R\"()\";\n\n NSVGimage* image = nsvgParse(svg.data(), \"px\", 96.0f);\n check(image != nullptr, \"nsvgParse returned an image\");\n if (!image) return 1;\n\n check(image->width == 100.0f && image->height == 100.0f, \"canvas dimensions\");\n\n int shapes = 0, paths = 0;\n for (NSVGshape* sh = image->shapes; sh != nullptr; sh = sh->next) {\n ++shapes;\n for (NSVGpath* p = sh->paths; p != nullptr; p = p->next) ++paths;\n }\n check(shapes == 1, \"one shape parsed\");\n check(paths >= 1, \"the shape has at least one path\");\n\n // The rect's bounds must be the ones the document declared.\n if (image->shapes) {\n const float* b = image->shapes->bounds;\n check(std::abs(b[0] - 25.0f) < 0.5f && std::abs(b[1] - 25.0f) < 0.5f &&\n std::abs(b[2] - 75.0f) < 0.5f && std::abs(b[3] - 75.0f) < 0.5f,\n \"shape bounds match the declared rect\");\n // fill colour is packed ABGR by nanosvg\n check((image->shapes->fill.color & 0x00ffffffu) == 0x000000ffu,\n \"fill colour is red\");\n }\n\n // ---- rasterize -------------------------------------------------------\n NSVGrasterizer* rast = nsvgCreateRasterizer();\n check(rast != nullptr, \"rasterizer created\");\n if (rast) {\n constexpr int W = 100, H = 100;\n std::vector px(static_cast(W) * H * 4, 0);\n nsvgRasterize(rast, image, 0.0f, 0.0f, 1.0f, px.data(), W, H, W * 4);\n\n auto at = [&](int x, int y) -> const unsigned char* {\n return px.data() + (static_cast(y) * W + x) * 4;\n };\n // Inside the rect: opaque red. Outside: untouched (alpha 0).\n const unsigned char* in = at(50, 50);\n const unsigned char* out = at(5, 5);\n check(in[3] > 200, \"centre pixel is opaque\");\n check(in[0] > 200 && in[1] < 60 && in[2] < 60, \"centre pixel is red\");\n check(out[3] == 0, \"corner pixel is untouched\");\n\n std::size_t covered = 0;\n for (std::size_t i = 3; i < px.size(); i += 4) if (px[i] > 128) ++covered;\n // 50x50 of 100x100 = 2500 px, allow for antialiased edges.\n check(covered > 2300 && covered < 2700, \"covered area matches a 50x50 rect\");\n\n nsvgDeleteRasterizer(rast);\n }\n\n nsvgDelete(image);\n\n if (ok) std::println(\"nanosvg OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "nlohmann.json", + "text": "# nlohmann/json test project: consumes the C++23 module `nlohmann.json` and\n# asserts round-trip + ordered_json under `mcpp test`.\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `nlohmann`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nnlohmann = { path = \"../../..\" }\n\n[package]\nname = \"nlohmann-json-tests\"\nversion = \"0.1.0\"\n\n[dependencies.nlohmann]\njson = \"3.12.0\"\n", + "files": [ + { + "path": "tests/examples/nlohmann.json/tests/roundtrip.cpp", + "text": "// Behavioral test: build → dump → parse round-trip + the ordered_json variant.\nimport std;\nimport nlohmann.json;\nusing namespace nlohmann::literals;\n\nint main() {\n nlohmann::json j = { {\"answer\", 42}, {\"list\", {1, 2, 3}} };\n nlohmann::json parsed = nlohmann::json::parse(j.dump());\n auto lit = R\"({\"k\":true})\"_json;\n nlohmann::ordered_json oj; oj[\"z\"] = 1; oj[\"a\"] = 2;\n bool ok = parsed[\"answer\"] == 42 && parsed[\"list\"][2] == 3\n && lit[\"k\"] == true && oj.dump() == R\"({\"z\":1,\"a\":2})\";\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "openblas", + "text": "# OpenBLAS test project (L1-cfg consumer): openblas is a Windows-only runtime dep\n# (import lib + DLL); on linux/macos the test is a no-op. Exercises mcpp's\n# platform-conditional [target.'cfg(...)'] deps + flags + windows-runtime-DLL\n# deployment. Resolves the openblas recipe from this repo's own index.\n[package]\nname = \"openblas-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(windows)'.dependencies.compat]\nopenblas = \"0.3.33\"\n[target.'cfg(windows)'.build]\ncxxflags = [\"-DHAVE_OPENBLAS=1\"]\n", + "files": [ + { + "path": "tests/examples/openblas/tests/dgemm.cpp", + "text": "// On Windows the cfg-gated openblas dep + HAVE_OPENBLAS are active: cblas_dgemm\n// asserting [19 22; 43 50]. Elsewhere a no-op (openblas is Windows-only).\n#ifdef HAVE_OPENBLAS\n#include \nint main() {\n const double A[4] = {1, 2, 3, 4};\n const double B[4] = {5, 6, 7, 8};\n double C[4] = {0, 0, 0, 0};\n cblas_dgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans, 2, 2, 2, 1.0, A, 2, B, 2, 0.0, C, 2);\n const double expected[4] = {19, 22, 43, 50};\n for (int i = 0; i < 4; ++i) if (C[i] != expected[i]) return 10 + i;\n return 0;\n}\n#else\nint main() { return 0; } // openblas is Windows-only; no-op elsewhere\n#endif\n" + } + ] + }, + { + "project": "opencl", + "text": "[package]\nname = \"opencl-tests\"\nversion = \"0.1.0\"\n\n# The loader on every platform it has one, and on Linux the runtime adapter it\n# depends on. The adapter is what lets the loader's dlopen of a host ICD resolve\n# from inside mcpp's own C library; on a runner with no driver it is empty and\n# the loader reports zero platforms.\n#\n# WINDOWS NEEDS NO ADAPTER AND IS NOT A SKIP ANY MORE. The loader reads the\n# registry, DXGK and app packages there, and each entry names a driver DLL by\n# absolute path that the SYSTEM loader resolves. `compat.opencl` gained its\n# windows section for that reason; the test below is the same source on all\n# three platforms.\n[dependencies.compat]\nopencl = \"2026.05.29\"\n\n[build]\ncxxflags = [\"-DHAVE_OPENCL_LOADER=1\"]\n", + "files": [ + { + "path": "tests/examples/opencl/tests/platforms.cpp", + "text": "// Behavioural test: compat.opencl builds a loader a consumer can link, and the\n// loader can enumerate whatever the machine advertises.\n//\n// Zero platforms is a pass. Every CI runner in this repository has no OpenCL\n// driver, and CL_PLATFORM_NOT_FOUND_KHR is the loader's own answer for that\n// case; it proves the trampolines are linked and the ICD scan ran. A machine\n// with a driver, or with xim:pocl announced through OCL_ICD_FILENAMES, lists\n// its platforms and devices; the test prints them so the log says which case\n// it measured.\n//\n// Classic includes rather than `import std;`: `CL/cl_platform.h` includes\n// ``, which pulls `` into the global module before the\n// std module is imported, and GCC then reports conflicting language linkage\n// for the declarations both provide.\n#include \n#if !defined(HAVE_OPENCL_LOADER)\n// Reached only if a consumer builds this file without the loader on the line.\n// It used to be the Windows path, which had no loader to depend on; the\n// package has one now and the manifest declares it on every platform.\nint main() {\n std::printf(\"compat.opencl: skipped (built without the loader)\\n\");\n return 0;\n}\n#else\n#define CL_TARGET_OPENCL_VERSION 300\n#include \n#include \n#include \n\nint main() {\n cl_uint n = 0;\n const cl_int rc = clGetPlatformIDs(0, nullptr, &n);\n if (rc == CL_PLATFORM_NOT_FOUND_KHR || (rc == CL_SUCCESS && n == 0)) {\n std::printf(\"compat.opencl: loader linked, no ICD advertised on this machine\\n\");\n return 0;\n }\n if (rc != CL_SUCCESS) {\n std::printf(\"clGetPlatformIDs failed: %d\\n\", static_cast(rc));\n return 1;\n }\n std::vector platforms(n);\n if (clGetPlatformIDs(n, platforms.data(), nullptr) != CL_SUCCESS) {\n std::printf(\"clGetPlatformIDs (fill) failed\\n\");\n return 2;\n }\n for (auto p : platforms) {\n char name[256] = {};\n clGetPlatformInfo(p, CL_PLATFORM_NAME, sizeof name, name, nullptr);\n cl_uint devices = 0;\n clGetDeviceIDs(p, CL_DEVICE_TYPE_ALL, 0, nullptr, &devices);\n std::printf(\"platform: %s (%u device(s))\\n\", name, devices);\n std::vector ids(devices);\n if (devices > 0 && clGetDeviceIDs(p, CL_DEVICE_TYPE_ALL, devices, ids.data(), nullptr) == CL_SUCCESS) {\n for (auto d : ids) {\n char dname[256] = {};\n clGetDeviceInfo(d, CL_DEVICE_NAME, sizeof dname, dname, nullptr);\n std::printf(\" device: %s\\n\", dname);\n }\n }\n }\n return 0;\n}\n#endif\n" + } + ] + }, + { + "project": "opencv-module-dnn", + "text": "# Public `opencv` module package `dnn` FEATURE test: `opencv = { features =\n# [\"dnn\"] }` builds the underlying dnn sources and compiles opencv-m's\n# opencv.dnn interface, so `import opencv.dnn;` is available. All three\n# platforms (mlas on linux/macOS, built-in fast_gemm on windows).\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `opencv`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nopencv = { path = \"../../..\" }\n\n[package]\nname = \"opencv-module-dnn-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.opencv]\nopencv = { version = \"5.0.0\", features = [\"dnn\"] }\n\n# macOS: `import opencv.dnn;` on macOS-arm64 (per-OS NEON dnn payload).\n[target.'cfg(macos)'.dependencies.opencv]\nopencv = { version = \"5.0.0\", features = [\"dnn\"] }\n\n# windows: `import opencv.dnn;` on windows-x86_64 (fast_gemm backend).\n[target.'cfg(windows)'.dependencies.opencv]\nopencv = { version = \"5.0.0\", features = [\"dnn\"] }\n\n\n", + "files": [ + { + "path": "tests/examples/opencv-module-dnn/tests/opencv_dnn_iface.cpp", + "text": "// The opencv.dnn MODULE interface (not textual headers) must expose the dnn\n// surface when opencv is pulled with features=[\"dnn\"] — proves the feature\n// compiled src/dnn.cppm and built the underlying dnn sources.\n// Not named dnn.cpp (would collide with the dep's\n// modules/dnn/src/dnn.cpp — #240 family). Import-only (import std + the opencv\n// modules): no textual headers, matching the ffmpeg-module member convention.\n#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)\nimport std;\nimport opencv.cv;\nimport opencv.dnn;\n\nint main() {\n cv::Mat img(32, 32, cv::CV_8UC3, cv::Scalar(10, 20, 30));\n cv::Mat blob = cv::dnn::blobFromImage(img, 1.0 / 255.0, cv::Size(16, 16),\n cv::Scalar(), true, false);\n if (blob.dims != 4 || blob.size[0] != 1 || blob.size[1] != 3\n || blob.size[2] != 16 || blob.size[3] != 16) return 1;\n float v = blob.ptr(0)[0];\n if (v < 0.117f || v > 0.118f) return 2; // 30/255, channels swapped\n cv::dnn::Net net;\n if (!net.empty()) return 3;\n std::println(\"opencv.dnn module ok: blobFromImage 1x3x16x16 first={}\", v);\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "opencv-module-unifont", + "text": "# Public `opencv` module package `unifont` FEATURE test: `opencv = { features =\n# [\"unifont\"] }` embeds the CJK font (no new module surface), so FontFace(\"uni\")\n# renders CJK through `import opencv.cv;`. Exercised on linux and macOS.\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `opencv`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nopencv = { path = \"../../..\" }\n\n[package]\nname = \"opencv-module-unifont-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.opencv]\nopencv = { version = \"5.0.0\", features = [\"unifont\"] }\n\n[target.'cfg(macos)'.dependencies.opencv]\nopencv = { version = \"5.0.0\", features = [\"unifont\"] }\n", + "files": [ + { + "path": "tests/examples/opencv-module-unifont/tests/opencv_unifont_iface.cpp", + "text": "// The \"uni\" FontFace only exists when opencv's unifont feature embedded the\n// font — CJK rendering through the module layer must ink. Import-only (import std + opencv.cv): no textual\n// headers, matching the ffmpeg-module member convention.\n#if defined(__linux__) || defined(__APPLE__)\nimport std;\nimport opencv.cv;\n\nint main() {\n cv::FontFace uni(\"uni\");\n cv::Mat img(64, 256, cv::CV_8UC1, cv::Scalar(0));\n cv::putText(img, \"中文字体\", cv::Point(8, 44), cv::Scalar(255), uni, 28);\n int ink = cv::countNonZero(img);\n if (ink < 100) { std::println(\"opencv.unifont: CJK ink {} too low\", ink); return 1; }\n std::println(\"opencv.unifont module ok: CJK putText ink={}\", ink);\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "opencv-module", + "text": "# Public `opencv` C++23-module package test (QUALIFIED consumption from the\n# `opencv` namespace — `mcpplibs:opencv` was frozen by #165 and deleted once\n# every consumer had moved, so the bare name resolves to nothing at all now).\n# Since opencv 0.0.7 the package is single-repo: the OpenCV sources are\n# vendored in its own release tarball, so this member exercises the whole\n# source build on all three platforms.\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `opencv`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\nopencv = { path = \"../../..\" }\n\n[package]\nname = \"opencv-module-tests\"\nversion = \"0.1.0\"\n\n# The package's only remaining transitive dep is compat.ffmpeg (videoio\n# backend), which resolves from the GLOBAL published compat index: this\n# member redirects `default`, and a member-level [indices] replaces the\n# root's `compat` rather than adding to it. The compat descriptor itself is\n# pre-merge-validated by its own dedicated member.\n[target.'cfg(linux)'.dependencies.opencv]\nopencv = \"5.0.0\"\n\n[target.'cfg(macos)'.dependencies.opencv]\nopencv = \"5.0.0\"\n\n# windows: import opencv.cv on windows-x86_64 (clang-cl; module tarball OS-neutral).\n[target.'cfg(windows)'.dependencies.opencv]\nopencv = \"5.0.0\"\n", + "files": [ + { + "path": "tests/examples/opencv-module/tests/opencv_module.cpp", + "text": "// Public opencv module package end-to-end assertion: import-only consumption\n// (`import opencv.cv;`), pinned version, Mat/imgproc ops (incl. the\n// replacement operator surface: Size comparison via != crosses the module\n// boundary — the v0.0.1 regression scenario), PNG+JPEG codec roundtrips,\n// videoio registry.\n#if defined(__linux__) || defined(__APPLE__) || defined(_WIN32)\nimport std;\nimport opencv.cv;\n\nint main() {\n if (cv::getVersionString() != \"5.0.0\") return 1;\n\n cv::Mat img(64, 64, cv::CV_8UC3, cv::Scalar(30, 60, 90));\n cv::circle(img, {32, 32}, 20, {255, 255, 255}, -1);\n cv::Mat big, gray;\n cv::resize(img, big, {128, 128}, 0, 0, cv::INTER_CUBIC);\n cv::cvtColor(big, gray, cv::COLOR_BGR2GRAY);\n if (gray.size() != cv::Size(128, 128)) return 2;\n\n for (const char* ext : { \".png\", \".jpg\" }) {\n std::vector buf;\n if (!cv::imencode(ext, big, buf)) return 3;\n cv::Mat back = cv::imdecode(buf, cv::IMREAD_COLOR);\n if (back.empty() || back.size() != big.size()) return 4;\n }\n\n if (cv::videoio_registry::getBackends().empty()) return 5;\n std::println(\"OpenCV {} module package ok\", cv::getVersionString());\n return 0;\n}\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "openssl", + "text": "# compat.openssl test project — the dedicated member that validates the\n# from-source OpenSSL build (static libssl.a + libcrypto.a + headers on\n# linux/macOS; libssl.lib + libcrypto.lib on Windows).\n#\n# It inherits the workspace-root `compat` redirect, so the descriptor in THIS\n# checkout is what gets built and linked. tests/examples/asio-ssl covers the\n# other direction — the same package pulled in as asio's `ssl` feature dep —\n# and this member is what isolates a failure to openssl itself rather than to\n# the feature wiring around it.\n#\n# All three platforms: linux/macOS build via perl Configure + GNU Make, windows\n# via VC-WIN64A + nmake (see _install_windows in the descriptor). The member\n# declares the dependency and HAVE_OPENSSL on every platform, so the test is a\n# real test everywhere — not a no-op main() on windows.\n[package]\nname = \"openssl-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nopenssl = \"3.5.1\"\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_OPENSSL=1\"]\n\n[target.'cfg(macos)'.dependencies.compat]\nopenssl = \"3.5.1\"\n\n[target.'cfg(macos)'.build]\ncxxflags = [\"-DHAVE_OPENSSL=1\"]\n\n[target.'cfg(windows)'.dependencies.compat]\nopenssl = \"3.5.1\"\n\n[target.'cfg(windows)'.build]\ncxxflags = [\"-DHAVE_OPENSSL=1\"]\n", + "files": [ + { + "path": "tests/examples/openssl/tests/tls.cpp", + "text": "// compat.openssl end-to-end: headers resolve, both archives link, and the\n// library initialises far enough to stand up a TLS context and run a digest.\n//\n// Touching BOTH archives is the point: libssl.a (SSL_CTX_new, TLS_method) and\n// libcrypto.a (EVP_*) — libssl.lib / libcrypto.lib on Windows. A link that\n// silently dropped one, or picked up a host libssl.so instead of the package's\n// own static build, fails here.\n//\n// HAVE_OPENSSL comes from this project's own cfg-gated cxxflags. The package\n// is supported on all three platforms now (linux/macOS: perl Configure + GNU\n// Make; windows: VC-WIN64A + nmake), so the real test runs everywhere.\n#ifdef HAVE_OPENSSL\n#include \n#include \n#include \n\n#include \n\nint main() {\n // Built from the 3.5.1 tarball this descriptor pins.\n if (OPENSSL_VERSION_MAJOR != 3 || OPENSSL_VERSION_MINOR != 5) return 1;\n\n // libssl: a usable client context.\n SSL_CTX* ctx = SSL_CTX_new(TLS_client_method());\n if (ctx == nullptr) return 2;\n SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, nullptr);\n SSL* ssl = SSL_new(ctx);\n if (ssl == nullptr) { SSL_CTX_free(ctx); return 3; }\n SSL_free(ssl);\n SSL_CTX_free(ctx);\n\n // libcrypto: SHA-256 of \"abc\" — first four bytes of the known digest.\n unsigned char digest[EVP_MAX_MD_SIZE] = {};\n unsigned int len = 0;\n EVP_MD_CTX* md = EVP_MD_CTX_new();\n if (md == nullptr) return 4;\n if (EVP_DigestInit_ex(md, EVP_sha256(), nullptr) != 1\n || EVP_DigestUpdate(md, \"abc\", 3) != 1\n || EVP_DigestFinal_ex(md, digest, &len) != 1) {\n EVP_MD_CTX_free(md);\n return 5;\n }\n EVP_MD_CTX_free(md);\n if (len != 32) return 6;\n\n const unsigned char expected[4] = {0xba, 0x78, 0x16, 0xbf};\n if (std::memcmp(digest, expected, sizeof expected) != 0) return 7;\n return 0;\n}\n#else\nint main() { return 0; } // no HAVE_OPENSSL — fallback that always passes\n#endif\n" + } + ] + }, + { + "project": "pango", + "text": "# gnome.pango — international text layout, without a font backend.\n#\n# ⚠️ NAMES pango ALONE. gio, gobject, glib, harfbuzz and fribidi all arrive\n# transitively; gio in particular is a workspace PATH dependency of pango, and\n# naming it here as well is `requested as both a version dep and a path dep`.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"pango-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\npango = \"1.56.1\"\n", + "files": [ + { + "path": "tests/examples/pango/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file takes the header route; this one takes the module, so both\n// doors are tested rather than assumed.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE. A TU that imports the module AND textually\n// includes a pango or glib header reaches twice — once through the\n// module's global fragment, once directly — and the same `struct tm` becomes\n// two entities. So a consumer picks ONE, and which one is decided by macros:\n// a module cannot carry them. Code using `PANGO_TYPE_*` or `G_OBJECT` takes\n// the header route; code using the function API imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.pango; // re-exports gnome.gio, and glib/gobject behind it\n\n// ⚠️ NO AND NO . The module's global fragment already saw\n// and through pango.h, and reaching them TEXTUALLY as\n// well makes the same `struct _IO_FILE` two entities:\n//\n// error: conflicting declaration 'struct _IO_FILE'\n//\n// glib's own g_print and strcmp-equivalent come through the module, which is\n// the point: on the module route a consumer includes nothing.\n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n g_print(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n g_print(\"import gnome.pango — %s\\n\\n\", pango_version_string());\n\n check(pango_version() >= 15601, \"pango_version() through the module\");\n check(pango_version_check(1, 56, 0) == nullptr,\n \"pango_version_check — PANGO_BINARY_AGE came from config.h\");\n\n // ⚠️ PANGO_TYPE_ALIGNMENT is a MACRO; the module carries the FUNCTION it\n // wraps. That is the shape of the module route, and why the header route\n // stays supported next door.\n check(pango_alignment_get_type() != 0, \"the generated enum GTypes register\");\n check(pango_font_map_get_type() != 0, \"PangoFontMap registers\");\n\n // An ENUMERATOR, exported by name — GCC makes them visible through an\n // exported typedef and clang does not, so they are listed explicitly.\n PangoDirection d = PANGO_DIRECTION_RTL;\n check(static_cast(d) != 0, \"an enumerator is exported by name\");\n\n // Script itemisation: pure Unicode table work, no fonts, no macros.\n {\n const char *text = \"Hello \\344\\270\\226\\347\\225\\214\";\n PangoScriptIter *it = pango_script_iter_new(text, -1);\n int runs = 0;\n do { ++runs; } while (pango_script_iter_next(it));\n pango_script_iter_free(it);\n check(runs >= 2, \"pango_script_iter splits Latin from Han\");\n }\n\n // Line breaking.\n {\n PangoLogAttr attrs[16] = {};\n pango_get_log_attrs(\"hello world\", -1, -1,\n pango_language_from_string(\"en-US\"), attrs, 16);\n check(attrs[6].is_line_break, \"a line break is allowed before \\\"world\\\"\");\n }\n\n // The markup parser, which reaches glib's GMarkup through the re-export\n // chain — gnome.pango pulls gnome.gio, which re-exports gnome.glib.\n {\n char *text = nullptr;\n const gboolean ok = pango_parse_markup(\"a bold word\", -1, 0,\n nullptr, &text, nullptr, nullptr);\n check(ok && text && g_strcmp0(text, \"a bold word\") == 0,\n \"pango_parse_markup — and g_free below is glib, transitively\");\n g_free(text);\n }\n\n g_print(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + }, + { + "path": "tests/examples/pango/tests/pango.cpp", + "text": "// pango — international text layout, exercised rather than merely linked.\n//\n// WHAT CAN BE ASSERTED WITHOUT A FONT\n//\n// This member has no font backend: finding and rasterising fonts is pangoft2's\n// job and drawing is pangocairo's. So a layout test here would be asserting\n// that the RUNNER has fonts, which is not what this package is.\n//\n// Everything below is an INTERMEDIATE QUANTITY — something pango computes from\n// data it carries, with no font map involved — and each names a specific part\n// of the build, so a failure says which one broke:\n//\n// the version macros include/pango/pango-features.h, one of two generated\n// headers, and PANGO_BINARY_AGE from config.h — which\n// is not substituted from a template but WRITTEN,\n// because upstream's configure_file has no input\n// the GENERATED enums pango-enum-types.{h,c}, 27 GTypes from a\n// reimplemented glib-mkenums\n// itemisation itemize.c + pango-script.c: splitting a mixed-script\n// string into runs is pure Unicode table work\n// bidi pango-bidi-type.c over compat.fribidi\n// line breaking break.c, the Unicode line-breaking algorithm\n// the attribute list pango-attributes.c + pango-markup.c, the parser\n// GListModel ⭐ what pango was blocked on: PangoFontMap declares\n// G_IMPLEMENT_INTERFACE(G_TYPE_LIST_MODEL, …), so the\n// type cannot even register without gio\n\n#ifdef __linux__\n\n// ⚠️ NO extern \"C\" WRAPPER. pango decorates its headers with G_BEGIN_DECLS, so\n// one is redundant — and harmful, because pango.h reaches , which\n// libc++ routes through , which defines TEMPLATES. Inside an\n// extern \"C\" block that is `templates must have C++ linkage`, dozens of times,\n// against a header this file never names.\n#include \n\n// ⚠️ pango.h does NOT pull gio in, even though PangoFontMap implements\n// GListModel — the interface is used in the implementation, not the header.\n// A consumer that wants to treat a font map as a list model includes gio\n// itself, which is legitimate: gnome.gio arrives transitively as a dependency\n// of this package.\n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-60s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"pango %s\\n\\n\", pango_version_string());\n\n // ── 1. the two generated headers, and config.h ───────────────────────\n check(PANGO_VERSION_MAJOR == 1 && PANGO_VERSION_MINOR == 56\n && PANGO_VERSION_MICRO == 1,\n \"pango-features.h reports the version the manifest declares\");\n check(std::strcmp(PANGO_VERSION_STRING, \"1.56.1\") == 0,\n \"…and its @…@ substitution produced the string form\");\n check(pango_version() == PANGO_VERSION,\n \"the runtime version agrees with the compiled-in one\");\n\n // ⚠️ PANGO_BINARY_AGE lives in config.h, which upstream produces with a\n // configure_file that has NO input template — so it is written here rather\n // than substituted, and a wrong value is a wrong ANSWER rather than a\n // build error. pango_version_check consults it.\n check(pango_version_check(1, 56, 0) == nullptr,\n \"pango_version_check accepts 1.56.0 — PANGO_BINARY_AGE is right\");\n check(pango_version_check(1, 99, 0) != nullptr,\n \"…and rejects a version from the future\");\n\n // ── 2. the GENERATED enum types ──────────────────────────────────────\n // These exist only because build.mcpp reproduced glib-mkenums over\n // pango's public headers. Registering one and reading a value back is the\n // only evidence the generator produced REGISTERABLE code rather than a\n // header that happens to compile.\n const GType align = pango_alignment_get_type();\n check(align != 0, \"pango_alignment_get_type() registered a GType\");\n check(align == PANGO_TYPE_ALIGNMENT,\n \"…and the MACRO is spelled the way upstream spells it\");\n\n GEnumClass *ec = static_cast(g_type_class_ref(align));\n GEnumValue *v = ec ? g_enum_get_value(ec, PANGO_ALIGN_CENTER) : nullptr;\n check(v && std::strcmp(v->value_name, \"PANGO_ALIGN_CENTER\") == 0,\n \"…with PANGO_ALIGN_CENTER under its own name\");\n check(v && std::strcmp(v->value_nick, \"center\") == 0,\n \"…and the nick mkenums derives, \\\"center\\\"\");\n if (ec) {\n g_type_class_unref(ec);\n }\n check(pango_wrap_mode_get_type() != 0 && pango_ellipsize_mode_get_type() != 0\n && pango_script_get_type() != 0,\n \"the other generated enum types register too\");\n\n // ── 3. ⭐ GListModel — the gio dependency, at run time ────────────────\n // PangoFontMap's class init declares\n // G_IMPLEMENT_INTERFACE (G_TYPE_LIST_MODEL, pango_font_map_list_model_init)\n // so registering the type at all requires gio's interface to exist. This\n // is the check that the text-layout line is actually joined up.\n const GType fm = pango_font_map_get_type();\n check(fm != 0, \"PangoFontMap registers\");\n check(g_type_is_a(fm, g_list_model_get_type()),\n \"…and it IS a GListModel — the gio gate, at run time\");\n\n // ── 4. itemisation: Unicode script runs, no fonts involved ───────────\n // \"Hello 世界 مرحبا\" is Latin, Han and Arabic. Splitting it is pure table\n // work in pango-script.c.\n {\n const char *text = \"Hello \\344\\270\\226\\347\\225\\214 \\331\\205\\330\\261\\330\\255\\330\\250\\330\\247\";\n PangoScriptIter *it = pango_script_iter_new(text, -1);\n int runs = 0;\n bool saw_han = false, saw_arabic = false;\n do {\n const char *s = nullptr, *e = nullptr;\n PangoScript sc;\n pango_script_iter_get_range(it, &s, &e, &sc);\n ++runs;\n saw_han |= (sc == PANGO_SCRIPT_HAN);\n saw_arabic |= (sc == PANGO_SCRIPT_ARABIC);\n } while (pango_script_iter_next(it));\n pango_script_iter_free(it);\n std::printf(\" script runs: %d\\n\", runs);\n check(runs >= 3, \"pango_script_iter splits a mixed-script string\");\n check(saw_han && saw_arabic, \"…and identifies Han and Arabic by name\");\n }\n\n // ── 5. bidi, which is compat.fribidi doing the work ──────────────────\n // Deprecated since 1.44 and still exported; testing them is testing what\n // the library actually offers.\n G_GNUC_BEGIN_IGNORE_DEPRECATIONS\n check(pango_unichar_direction(0x05D0) == PANGO_DIRECTION_RTL,\n \"Hebrew alef is right-to-left\");\n check(pango_unichar_direction('A') == PANGO_DIRECTION_LTR,\n \"…and 'A' is left-to-right\");\n {\n const char *rtl = \"\\330\\247\\331\\204\\330\\263\\331\\204\\330\\247\\331\\205\"; // \"السلام\"\n check(pango_find_base_dir(rtl, -1) == PANGO_DIRECTION_RTL,\n \"pango_find_base_dir on an Arabic string\");\n }\n G_GNUC_END_IGNORE_DEPRECATIONS\n\n // ── 6. line breaking: break.c over the Unicode algorithm ─────────────\n {\n const char *text = \"hello world\";\n PangoLogAttr attrs[16] = {};\n pango_get_log_attrs(text, -1, -1, pango_language_from_string(\"en-US\"),\n attrs, 16);\n // A break is allowed before \"world\" (offset 6) and not mid-word.\n check(attrs[6].is_line_break, \"a line break is allowed before \\\"world\\\"\");\n check(!attrs[3].is_line_break, \"…and not in the middle of \\\"hello\\\"\");\n check(attrs[5].is_white, \"…and the space is classified as white\");\n }\n\n // ── 7. the markup parser ─────────────────────────────────────────────\n {\n PangoAttrList *attrs = nullptr;\n char *text = nullptr;\n GError *err = nullptr;\n const gboolean ok = pango_parse_markup(\"a bold word\", -1, 0,\n &attrs, &text, nullptr, &err);\n check(ok && text && std::strcmp(text, \"a bold word\") == 0,\n \"pango_parse_markup strips the tags and keeps the text\");\n check(attrs != nullptr, \"…and produces an attribute list\");\n if (attrs) {\n pango_attr_list_unref(attrs);\n }\n g_free(text);\n if (err) {\n g_error_free(err);\n }\n }\n\n // ── 8. language tags, which carry their own sample text tables ───────\n {\n PangoLanguage *ja = pango_language_from_string(\"ja-JP\");\n check(ja != nullptr, \"pango_language_from_string\");\n check(std::strcmp(pango_language_to_string(ja), \"ja-jp\") == 0,\n \"…normalises the tag to lower case\");\n check(pango_language_includes_script(ja, PANGO_SCRIPT_HAN),\n \"…and knows Japanese uses Han\");\n }\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \n\nint main()\n{\n std::printf(\"pango: this package builds the Linux half; skipping.\\n\");\n return 0;\n}\n\n#endif\n" + } + ] + }, + { + "project": "pangocairo", + "text": "# gnome.pangocairo — the whole text-layout line, ending in pixels.\n#\n# ⭐ Naming this one package pulls seven: pango, pangoft2, gio (and glib,\n# gobject, gmodule behind it), harfbuzz, fribidi, fontconfig, freetype and\n# cairo. The test renders text and counts the ink.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"pangocairo-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\npangocairo = \"1.56.1\"\n", + "files": [ + { + "path": "tests/examples/pangocairo/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file takes the header route; this one takes the module, so both\n// doors are tested rather than assumed.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE. A TU that imports the module AND textually\n// includes a pango or glib header reaches twice — once through the\n// module's global fragment, once directly — and the same `struct tm` becomes\n// two entities. So a consumer picks ONE, and which one is decided by macros:\n// a module cannot carry them. Code using `PANGO_TYPE_*` or `G_OBJECT` takes\n// the header route; code using the function API imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.pangocairo; // re-exports gnome.pango and gnome.pangoft2\n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n g_print(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n g_print(\"import gnome.pangocairo\\n\\n\");\n\n PangoFontMap *map = pango_cairo_font_map_get_default();\n check(map != nullptr, \"pango_cairo_font_map_get_default()\");\n // ⚠️ G_OBJECT_TYPE_NAME is a MACRO and cannot come through a module. The\n // font TYPE is a better check anyway: it asserts the backend directly\n // rather than by a substring of a class name.\n check(map && pango_cairo_font_map_get_font_type(\n reinterpret_cast(map)) == CAIRO_FONT_TYPE_FT,\n \"…and its font type is FreeType — HAVE_CAIRO_FREETYPE took\");\n\n // ⚠️ FAMILIES ARE ENUMERATED BEFORE DRAWING, matching the header-route\n // test next door. When this ran AFTER the draw, the module test drew 0\n // pixels on a runner where the header test drew 116 — same machine, same\n // four families, identical drawing code. The only difference was this\n // call's position, so it is now in the same place in both, and the count\n // is reported so the two are comparable rather than merely both \"ok\".\n int families = -1;\n {\n PangoFontFamily **f = nullptr;\n pango_font_map_list_families(map, &f, &families);\n g_free(f);\n }\n g_print(\" font families visible: %d\\n\", families);\n\n PangoContext *ctx = pango_font_map_create_context(map);\n PangoLayout *layout = pango_layout_new(ctx);\n pango_layout_set_text(layout, \"Hello \\344\\270\\226\\347\\225\\214\", -1);\n PangoFontDescription *desc = pango_font_description_from_string(\"Sans 24\");\n pango_layout_set_font_description(layout, desc);\n check(pango_layout_get_character_count(layout) == 8,\n \"the layout counts 8 characters\");\n\n int w = 0, h = 0;\n pango_layout_get_pixel_size(layout, &w, &h);\n g_print(\" layout pixel size: %dx%d\\n\", w, h);\n\n // ⚠️ THE SURFACE IS CHECKED, and its format is PRINTED. The first version\n // of this test only counted pixels, so when it reported 0 on a runner\n // where the header-route twin reported 116 there was nothing to go on:\n // \"0 pixels\" is a symptom shared by \"nothing was drawn\", \"the surface is\n // in an error state\" and \"the pixel loop is reading the wrong bytes\".\n cairo_surface_t *surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 400, 100);\n g_print(\" surface status=%d format=%d (CAIRO_FORMAT_ARGB32=%d)\\n\",\n (int) cairo_surface_status(surf),\n (int) cairo_image_surface_get_format(surf),\n (int) CAIRO_FORMAT_ARGB32);\n check(cairo_surface_status(surf) == CAIRO_STATUS_SUCCESS,\n \"the ARGB32 surface was created without error\");\n check(cairo_image_surface_get_format(surf) == CAIRO_FORMAT_ARGB32,\n \"…and the format it reports is the one that was asked for\");\n\n cairo_t *cr = cairo_create(surf);\n cairo_set_source_rgb(cr, 0, 0, 0);\n cairo_move_to(cr, 10, 10);\n pango_cairo_show_layout(cr, layout);\n cairo_destroy(cr);\n\n cairo_surface_flush(surf);\n const unsigned char *d = cairo_image_surface_get_data(surf);\n check(d != nullptr, \"the surface has readable pixel data\");\n const int stride = cairo_image_surface_get_stride(surf);\n long drawn = 0;\n for (int y = 0; d && y < 100; ++y)\n for (int x = 0; x < 400; ++x)\n if (d[(long)y * stride + 4 * x + 3] != 0) ++drawn;\n g_print(\" non-transparent pixels: %ld\\n\", drawn);\n\n // ⚠️ Only assertable when the runner HAS fonts — see the header-route test\n // next door for why 0 families is a property of the machine.\n if (families > 0)\n // `> 0`, not a tuned number — see the header-route test next door for\n // why a threshold here is an assertion about the runner's fonts.\n check(drawn > 0, \"pango_cairo_show_layout put ink on the surface\");\n else\n g_print(\" ⚠️ 0 font families: the rendering assertion was NOT run.\\n\");\n\n cairo_surface_destroy(surf);\n pango_font_description_free(desc);\n g_object_unref(layout);\n g_object_unref(ctx);\n\n g_print(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + }, + { + "path": "tests/examples/pangocairo/tests/pangocairo.cpp", + "text": "// pangocairo — the whole text-layout line, end to end.\n//\n// ⭐ THIS IS THE ONE TEST IN THE STACK THAT PRODUCES PIXELS, and it is worth\n// stating what that costs to reach:\n//\n// gnome.pango itemisation, bidi, line breaking\n// gnome.pangoft2 fontconfig picks the file, FreeType rasterises\n// gnome.gio PangoFontMap is a GListModel\n// compat.harfbuzz shaping\n// compat.fribidi the bidi algorithm\n// freedesktop.cairo the surface the glyphs land on\n//\n// Seven packages, and a blank image means one of them is not doing its job.\n//\n// ⚠️ IT DEGRADES HONESTLY WHEN THERE ARE NO FONTS. `freedesktop.fontconfig`\n// compiles its runtime paths EMPTY on purpose, so a runner with no\n// FONTCONFIG_FILE and no /usr/share/fonts legitimately finds zero families —\n// and then there is nothing to draw and nothing to assert. That case is\n// REPORTED rather than passed over in silence, because \"0 families, so we\n// skipped the only real check\" and \"the text rendered\" must not look alike.\n\n#ifdef __linux__\n\n#include \n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-60s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// How many pixels in an ARGB32 surface are not fully transparent.\nlong ink(cairo_surface_t *s)\n{\n cairo_surface_flush(s);\n const unsigned char *d = cairo_image_surface_get_data(s);\n if (d == nullptr) {\n return -1;\n }\n const int w = cairo_image_surface_get_width(s);\n const int h = cairo_image_surface_get_height(s);\n const int stride = cairo_image_surface_get_stride(s);\n long n = 0;\n for (int y = 0; y < h; ++y) {\n const unsigned char *row = d + static_cast(y) * stride;\n for (int x = 0; x < w; ++x) {\n if (row[4 * x + 3] != 0) {\n ++n;\n }\n }\n }\n return n;\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"pangocairo (pango %s, cairo %s)\\n\\n\",\n pango_version_string(), cairo_version_string());\n\n // ── the types register, and the inheritance is the dependency graph ──\n const GType fm = pango_cairo_font_map_get_type();\n check(fm != 0, \"PangoCairoFontMap registers\");\n\n PangoFontMap *map = pango_cairo_font_map_get_default();\n check(map != nullptr, \"pango_cairo_font_map_get_default()\");\n check(PANGO_IS_CAIRO_FONT_MAP(map), \"…and it is a PangoCairoFontMap\");\n check(g_type_is_a(G_OBJECT_TYPE(map), g_list_model_get_type()),\n \"…and a GListModel, which is gio, inherited through PangoFontMap\");\n\n // ⚠️ THE BACKEND IS THE CHECK THAT pangoft2 IS ACTUALLY WIRED IN. Without\n // HAVE_CAIRO_FREETYPE the fc font map is never registered and the default\n // map is some other implementation — which still builds, still runs, and\n // never finds a font. So the type NAME is read.\n const char *backend = G_OBJECT_TYPE_NAME(map);\n std::printf(\" font map backend: %s\\n\", backend);\n check(backend != nullptr && std::strstr(backend, \"Fc\") != nullptr,\n \"…and it is the FONTCONFIG-backed map — HAVE_CAIRO_FREETYPE took\");\n\n int families = -1;\n {\n PangoFontFamily **f = nullptr;\n pango_font_map_list_families(map, &f, &families);\n g_free(f);\n }\n std::printf(\" font families visible: %d\\n\", families);\n check(families >= 0, \"pango_font_map_list_families answers\");\n\n // ── layout, entirely without a surface ───────────────────────────────\n PangoContext *ctx = pango_font_map_create_context(map);\n check(ctx != nullptr, \"pango_font_map_create_context\");\n\n PangoLayout *layout = pango_layout_new(ctx);\n pango_layout_set_text(layout, \"Hello \\344\\270\\226\\347\\225\\214\", -1);\n PangoFontDescription *desc = pango_font_description_from_string(\"Sans 24\");\n pango_layout_set_font_description(layout, desc);\n\n check(pango_layout_get_character_count(layout) == 8,\n \"the layout counts 8 characters — \\\"Hello \\\" plus two Han\");\n check(pango_layout_get_line_count(layout) == 1, \"…on one line\");\n\n int w = 0, h = 0;\n pango_layout_get_pixel_size(layout, &w, &h);\n std::printf(\" layout pixel size: %dx%d\\n\", w, h);\n\n // ── and now the pixels ───────────────────────────────────────────────\n cairo_surface_t *surf = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, 400, 100);\n check(cairo_surface_status(surf) == CAIRO_STATUS_SUCCESS,\n \"a 400x100 ARGB32 surface\");\n cairo_t *cr = cairo_create(surf);\n cairo_set_source_rgb(cr, 0, 0, 0);\n cairo_move_to(cr, 10, 10);\n pango_cairo_show_layout(cr, layout);\n cairo_destroy(cr);\n\n const long drawn = ink(surf);\n std::printf(\" non-transparent pixels: %ld\\n\", drawn);\n\n // ⚠️ THE GUARD IS THE LAYOUT'S OWN METRICS, NOT THE FAMILY COUNT.\n //\n // `families > 0` looked like enough and is not. A CI runner reported FOUR\n // families and measured this layout at 80x858545 — a nonsense height on a\n // 100px surface, because \"four families\" there means fontconfig has\n // entries but no usable font behind them. The glyphs then land off the\n // surface and the ink count is arbitrary: 0 in one process and 116 in\n // another, from byte-identical drawing code.\n //\n // An earlier version also asserted `drawn > 100`, which passed here (216\n // pixels, 184 families) and failed there (72) — a threshold calibrated to\n // the developer's font set is an assertion about the MACHINE. Ink is ink.\n const bool usable = families > 0 && w > 0 && h > 0 && h <= 100;\n if (usable) {\n // ⭐ THE WHOLE LINE, IN ONE ASSERTION.\n check(drawn > 0,\n \"pango_cairo_show_layout put ink on the surface — seven packages\");\n } else {\n // Not a pass. The machine cannot render, so the only real check could\n // not run, and saying so is the point: \"it was skipped\" and \"it worked\"\n // must not look alike.\n std::printf(\" \\342\\232\\240 no usable font (families=%d, layout %dx%d on a\\n\"\n \" 100px surface): the rendering assertion was NOT run.\\n\"\n \" That is a property of this machine, not this build.\\n\",\n families, w, h);\n check(drawn >= 0, \"the surface is readable (rendering check skipped)\");\n }\n\n // pangocairo-context.c: the resolution and font options a context carries.\n pango_cairo_context_set_resolution(ctx, 96.0);\n check(pango_cairo_context_get_resolution(ctx) == 96.0,\n \"pangocairo-context.c round-trips the resolution\");\n\n cairo_surface_destroy(surf);\n pango_font_description_free(desc);\n g_object_unref(layout);\n g_object_unref(ctx);\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \nint main() { std::printf(\"pangocairo: Linux only.\\n\"); return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "pangoft2", + "text": "# gnome.pangoft2 — the FreeType/fontconfig backend.\n[indices]\ngnome = { path = \"../../..\" }\n\n[package]\nname = \"pangoft2-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.gnome]\npangoft2 = \"1.56.1\"\n", + "files": [ + { + "path": "tests/examples/pangoft2/tests/module.cpp", + "text": "// ⭐ THE MODULE, WHICH IS WHAT THE NAMESPACE PROMISES.\n//\n// In this index the namespace is the contract: `compat.xxx` is consumed with\n// `#include`, an owner namespace like `gnome.xxx` exposes `import`. The test\n// next to this file takes the header route; this one takes the module, so both\n// doors are tested rather than assumed.\n//\n// ⚠️ THE TWO DOORS DO NOT COMPOSE. A TU that imports the module AND textually\n// includes a pango or glib header reaches twice — once through the\n// module's global fragment, once directly — and the same `struct tm` becomes\n// two entities. So a consumer picks ONE, and which one is decided by macros:\n// a module cannot carry them. Code using `PANGO_TYPE_*` or `G_OBJECT` takes\n// the header route; code using the function API imports and includes nothing.\n#ifdef __linux__\n\nimport gnome.pangoft2; // re-exports gnome.pango, and gio/glib behind it\n\nint main()\n{\n int failures = 0;\n auto check = [&](bool ok, const char *what) {\n g_print(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n };\n\n g_print(\"import gnome.pangoft2\\n\\n\");\n\n const GType ft2 = pango_ft2_font_map_get_type();\n check(ft2 != 0, \"PangoFT2FontMap registers\");\n check(g_type_is_a(ft2, pango_fc_font_map_get_type()),\n \"…derives from PangoFcFontMap — the fontconfig half\");\n check(g_type_is_a(ft2, pango_font_map_get_type()),\n \"…and from PangoFontMap — gnome.pango came through the re-export\");\n\n PangoFontMap *map = pango_ft2_font_map_new();\n check(map != nullptr, \"pango_ft2_font_map_new()\");\n if (map) {\n PangoFontFamily **f = nullptr;\n int n = -1;\n pango_font_map_list_families(map, &f, &n);\n g_print(\" font families visible: %d\\n\", n);\n check(n >= 0, \"list_families answers (0 is valid — see the header test)\");\n g_free(f);\n g_object_unref(map);\n }\n\n // pango-ot-tag.c, pure table work and no fonts.\n check(pango_ot_tag_to_script(pango_ot_tag_from_script(PANGO_SCRIPT_ARABIC))\n == PANGO_SCRIPT_ARABIC,\n \"the OpenType script tag mapping round-trips\");\n\n g_print(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n#include \nint main() { std::printf(\"linux only\\n\"); return 0; }\n#endif\n" + }, + { + "path": "tests/examples/pangoft2/tests/pangoft2.cpp", + "text": "// pangoft2 — the FreeType/fontconfig backend.\n//\n// ⚠️ WHAT THIS DOES *NOT* ASSERT: that any particular font is found. That\n// depends on the RUNNER having /usr/share/fonts and a fontconfig\n// configuration, and `freedesktop.fontconfig` deliberately compiles its\n// runtime paths EMPTY so it does not silently read the developer's fonts. A\n// test that demanded \"DejaVu Sans\" would be checking the machine, not this\n// build.\n//\n// What IS asserted is everything that does not need a font file: that the\n// font map type registers, that it IS a PangoFontMap and a GListModel, that\n// the fontconfig and FreeType halves are actually linked in (by calling into\n// each), and that enumerating families returns a well-formed answer — which on\n// a runner with no fonts is legitimately zero.\n\n#ifdef __linux__\n\n#include \n#include \n// ⚠️ pango-ot.h is NOT reached through pangoft2.h. It is a separate installed\n// header, and its whole body sits behind `#ifndef PANGO_DISABLE_DEPRECATED` —\n// upstream's own comment is \"Deprecated. Use HarfBuzz directly!\". It is still\n// compiled into this member and still part of its ABI, so it is still tested.\n#include \n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-60s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n std::printf(\"pangoft2 (pango %s)\\n\\n\", pango_version_string());\n\n // ── the types register ───────────────────────────────────────────────\n const GType ft2 = pango_ft2_font_map_get_type();\n check(ft2 != 0, \"PangoFT2FontMap registers\");\n check(g_type_is_a(ft2, pango_fc_font_map_get_type()),\n \"…and derives from PangoFcFontMap — the fontconfig half is linked\");\n check(g_type_is_a(ft2, pango_font_map_get_type()),\n \"…and from PangoFontMap — gnome.pango is linked\");\n check(g_type_is_a(ft2, g_list_model_get_type()),\n \"…and it is a GListModel, inherited through PangoFontMap\");\n\n // ── an actual instance, which runs fontconfig's initialisation ───────\n PangoFontMap *map = pango_ft2_font_map_new();\n check(map != nullptr, \"pango_ft2_font_map_new() — FcInit ran without dying\");\n\n if (map != nullptr) {\n // ⚠️ ZERO IS A VALID ANSWER HERE. compat.fontconfig ships empty runtime\n // paths on purpose, so a runner with no FONTCONFIG_FILE finds no\n // fonts. What is asserted is that the call ANSWERS — the out\n // parameters are consistent — not what it answers.\n PangoFontFamily **families = nullptr;\n int n = -1;\n pango_font_map_list_families(map, &families, &n);\n std::printf(\" font families visible: %d\\n\", n);\n check(n >= 0, \"pango_font_map_list_families answers\");\n check(n == 0 || families != nullptr,\n \"…and the array matches the count it reported\");\n g_free(families);\n\n // The resolution the FT2 map carries, which is its own state rather\n // than anything fontconfig had to find.\n pango_ft2_font_map_set_resolution(PANGO_FT2_FONT_MAP(map), 96.0, 96.0);\n\n PangoContext *ctx = pango_font_map_create_context(map);\n check(ctx != nullptr, \"…and it can create a PangoContext\");\n if (ctx != nullptr) {\n check(pango_context_get_font_map(ctx) == map,\n \"…which points back at the font map\");\n g_object_unref(ctx);\n }\n g_object_unref(map);\n }\n\n // ── the OpenType tag helpers: pango-ot-tag.c, pure table work ────────\n {\n const PangoOTTag t = pango_ot_tag_from_script(PANGO_SCRIPT_ARABIC);\n // 'arab' — the OpenType script tag, four packed bytes.\n check(t == PANGO_OT_TAG_MAKE('a', 'r', 'a', 'b'),\n \"pango_ot_tag_from_script(ARABIC) is 'arab'\");\n check(pango_ot_tag_to_script(t) == PANGO_SCRIPT_ARABIC,\n \"…and the mapping round-trips\");\n }\n\n std::printf(\"\\n%s\\n\", failures == 0 ? \"all ok\" : \"FAILURES\");\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\n#include \nint main() { std::printf(\"pangoft2: Linux only.\\n\"); return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "pcre2", + "text": "# compat.pcre2 — glib's regex engine, exercised the way glib uses it.\n[indices]\ncompat = { path = \"../../..\" }\n\n[package]\nname = \"pcre2-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\npcre2 = \"10.44\"\n", + "files": [ + { + "path": "tests/examples/pcre2/tests/pcre2.cpp", + "text": "// compat.pcre2 — exercised the way glib's GRegex uses it.\n//\n// WHAT THIS ASSERTS AND WHY EACH ONE IS HERE\n//\n// The failures worth catching in a regex engine are not \"it did not link\".\n// They are the ones where the library builds, links, runs, and gives a subtly\n// wrong answer because a probe macro said something untrue. Each check below\n// names a specific decision in the descriptor:\n//\n// compile + match the sources really were compiled, not just linked\n// \\p{Han} / \\X SUPPORT_UNICODE, i.e. that pcre2_ucd.c's tables are in\n// PCRE2_UTF that UTF-8 mode works, which is all glib ever uses\n// the character tables that pcre2_chartables.c.dist was compiled, and in the\n// C locale — the file the descriptor does NOT let a\n// `pcre2_dftables` run produce from the build machine\n// pcre2_config(JIT) that JIT is OFF and SAYS so, rather than crashing\n// capture groups pcre2_substring_*, which glib's g_match_info_fetch is\n\n#ifdef __linux__\n\n// ⚠️ REQUIRED BEFORE THE HEADER, and pcre2.h says so with an #error if it is\n// missing. The header uses it to choose which of the three code-unit widths to\n// declare, and this package builds the 8-bit one. glib does exactly this in\n// gregex.c.\n#define PCRE2_CODE_UNIT_WIDTH 8\n#include \n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-60s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n// Compile a pattern and report whether it matched, so each case below is one\n// line. `subject` is UTF-8 throughout, which is the only encoding glib uses.\nbool matches(const char *pattern, const char *subject, uint32_t options = 0)\n{\n int err = 0;\n PCRE2_SIZE off = 0;\n pcre2_code *re = pcre2_compile(reinterpret_cast(pattern),\n PCRE2_ZERO_TERMINATED, options, &err, &off, nullptr);\n if (re == nullptr) {\n PCRE2_UCHAR buf[256];\n pcre2_get_error_message(err, buf, sizeof buf);\n std::printf(\" compile failed at %zu: %s\\n\", off, reinterpret_cast(buf));\n return false;\n }\n pcre2_match_data *md = pcre2_match_data_create_from_pattern(re, nullptr);\n const int rc = pcre2_match(re, reinterpret_cast(subject),\n std::strlen(subject), 0, 0, md, nullptr);\n pcre2_match_data_free(md);\n pcre2_code_free(re);\n return rc > 0;\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. the version the descriptor names ──────────────────────────────\n char ver[64] = {};\n pcre2_config(PCRE2_CONFIG_VERSION, ver);\n std::printf(\" pcre2 %s\\n\", ver);\n check(std::strncmp(ver, \"10.44\", 5) == 0,\n \"pcre2_config reports 10.44, the version the descriptor names\");\n\n // ── 2. it compiles and matches at all ────────────────────────────────\n check(matches(\"^a+b$\", \"aaab\"), \"compile and match a trivial pattern\");\n check(!matches(\"^a+b$\", \"aaac\"), \"…and decline one that does not match\");\n\n // ── 3. SUPPORT_UNICODE ───────────────────────────────────────────────\n // \\p{...} is a compile-time ERROR without SUPPORT_UNICODE, not a silent\n // mismatch — which is the good case: it fails loudly. The descriptor\n // defines the macro (never as 0; pcre2 tests it with #ifdef).\n check(matches(\"\\\\p{Han}\", \"漢\", PCRE2_UTF | PCRE2_UCP),\n \"\\\\p{Han} matches a Han character — SUPPORT_UNICODE is on\");\n check(!matches(\"\\\\p{Han}\", \"a\", PCRE2_UTF | PCRE2_UCP),\n \"…and does not match a Latin one\");\n\n // \\X is an extended grapheme cluster: \"é\" written as e + U+0301 is ONE\n // grapheme and two code points. This distinguishes real UCD tables from a\n // build that merely accepted the syntax.\n check(matches(\"^\\\\X$\", \"e\\xCC\\x81\", PCRE2_UTF),\n \"\\\\X treats e + combining acute as one grapheme cluster\");\n\n // ── 4. UTF-8 mode ────────────────────────────────────────────────────\n // Without PCRE2_UTF, \".\" matches one BYTE; with it, one code point. A\n // three-byte character therefore distinguishes the two modes exactly.\n check(matches(\"^.$\", \"漢\", PCRE2_UTF), \"with PCRE2_UTF, `.` is one code point\");\n check(!matches(\"^.$\", \"漢\"), \"…and without it, one byte — so a 3-byte char does not match\");\n\n // ── 5. the character tables ──────────────────────────────────────────\n // `\\w` and `[[:alpha:]]` consult pcre2_chartables.c, which upstream would\n // otherwise produce by RUNNING pcre2_dftables against the build machine's\n // locale. The descriptor compiles upstream's C-locale `.dist` instead, so\n // these answers cannot depend on where the package was built.\n //\n // The discriminating case is the second one: in a C locale, byte 0xE9\n // (é in Latin-1) is NOT alphabetic. In an fr_FR.ISO-8859-1 locale it is.\n check(matches(\"^\\\\w+$\", \"abc_123\"), \"\\\\w accepts letters, digits and underscore\");\n check(!matches(\"^[[:alpha:]]$\", \"\\xE9\"),\n \"…and byte 0xE9 is not alphabetic, i.e. the tables are C-locale\");\n\n // ── 6. JIT is off, and says so ───────────────────────────────────────\n // The descriptor compiles pcre2_jit_compile.c without SUPPORT_JIT, so the\n // entry points exist as stubs. A consumer that checks — glib does — takes\n // the interpreter path. The failure this catches is the other one: a build\n // that claims JIT and then has no code behind it.\n uint32_t jit = 0;\n pcre2_config(PCRE2_CONFIG_JIT, &jit);\n std::printf(\" pcre2_config(JIT) = %u\\n\", jit);\n check(jit == 0, \"pcre2_config reports JIT off, matching the descriptor\");\n\n int err = 0;\n PCRE2_SIZE off = 0;\n pcre2_code *re = pcre2_compile(reinterpret_cast(\"(\\\\w+)@(\\\\w+)\"),\n PCRE2_ZERO_TERMINATED, 0, &err, &off, nullptr);\n check(re != nullptr, \"compile a pattern with capture groups\");\n if (re != nullptr) {\n // The JIT stub must FAIL CLEANLY rather than crash or pretend.\n const int jrc = pcre2_jit_compile(re, PCRE2_JIT_COMPLETE);\n std::printf(\" pcre2_jit_compile = %d (PCRE2_ERROR_JIT_BADOPTION is %d)\\n\",\n jrc, PCRE2_ERROR_JIT_BADOPTION);\n check(jrc != 0, \"…and pcre2_jit_compile declines instead of crashing\");\n\n // ── 7. capture groups, which is g_match_info_fetch ───────────────\n pcre2_match_data *md = pcre2_match_data_create_from_pattern(re, nullptr);\n const char *subject = \"user@example\";\n const int rc = pcre2_match(re, reinterpret_cast(subject),\n std::strlen(subject), 0, 0, md, nullptr);\n check(rc == 3, \"match yields two capture groups plus the whole match\");\n if (rc == 3) {\n PCRE2_SIZE len = 0;\n PCRE2_UCHAR buf[64] = {};\n len = sizeof buf;\n pcre2_substring_copy_bynumber(md, 1, buf, &len);\n std::printf(\" group 1 = %s\\n\", reinterpret_cast(buf));\n check(std::strcmp(reinterpret_cast(buf), \"user\") == 0,\n \"…and group 1 is the text before the @\");\n }\n pcre2_match_data_free(md);\n pcre2_code_free(re);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "pixman", + "text": "# pixman test project.\n#\n# Linux-only like its graphics-stack neighbours; the test compiles to a no-op\n# main() elsewhere.\n[package]\nname = \"pixman-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.compat]\npixman = \"0.46.4\"\n", + "files": [ + { + "path": "tests/examples/pixman/tests/pixman.cpp", + "text": "// compat.pixman — behavioral test.\n//\n// The interesting thing about this package is not that it links; it is that\n// pixman dispatches on the CPU at runtime, and this index compiles the SSE2\n// and SSSE3 implementations with per-file `-msse2` / `-mssse3` so that the\n// compiler may emit those instructions THERE and nowhere else. If the flags\n// had been package-wide, code running before pixman's own CPUID check could\n// carry SSSE3 — an illegal instruction on an older machine, from a library\n// whose entire design is to avoid exactly that.\n//\n// So the test does two things a \"does it link\" check would not:\n//\n// * asks pixman which implementation it CHOSE, through a real composite;\n// * composites actual pixels and reads them back, because the SIMD paths\n// are only reached with enough work to be worth dispatching to.\n//\n// No display, no GPU, no threads.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The library is the version this package says it is ────────────\n std::printf(\" pixman %s (header %d.%d.%d)\\n\", pixman_version_string(),\n PIXMAN_VERSION_MAJOR, PIXMAN_VERSION_MINOR, PIXMAN_VERSION_MICRO);\n check(pixman_version() == PIXMAN_VERSION,\n \"the linked library and the header agree on the version\");\n check(PIXMAN_VERSION_MAJOR == 0 && PIXMAN_VERSION_MINOR == 46,\n \"…and it is 0.46, the version the descriptor pins\");\n\n // ── 2. A real composite, over enough pixels to matter ────────────────\n // 256x256 ARGB: large enough that pixman routes through a fast path\n // rather than the trivial one, which is what exercises the dispatched\n // implementation the SIMD flags were for.\n const int W = 256, H = 256;\n std::vector dst(static_cast(W) * H, 0u);\n\n pixman_color_t colour{};\n colour.red = 0x4000;\n colour.green = 0x8000;\n colour.blue = 0xc000;\n colour.alpha = 0xffff;\n pixman_image_t *src = pixman_image_create_solid_fill(&colour);\n check(src != nullptr, \"pixman_image_create_solid_fill\");\n\n pixman_image_t *out = pixman_image_create_bits(\n PIXMAN_a8r8g8b8, W, H, dst.data(), W * 4);\n check(out != nullptr, \"pixman_image_create_bits 256x256 a8r8g8b8\");\n\n if (src != nullptr && out != nullptr) {\n pixman_image_composite32(PIXMAN_OP_SRC, src, nullptr, out,\n 0, 0, 0, 0, 0, 0, W, H);\n\n // Every pixel must be the colour that was composited. A dispatch that\n // picked a broken implementation shows up here rather than as a crash.\n const std::uint32_t want = 0xff4080c0u;\n std::size_t wrong = 0;\n for (std::uint32_t p : dst) {\n if (p != want) {\n ++wrong;\n }\n }\n std::printf(\" composited %dx%d, first pixel 0x%08x (wanted 0x%08x)\\n\",\n W, H, dst[0], want);\n check(wrong == 0, \"every pixel is the colour that was composited\");\n }\n\n // ── 3. Region arithmetic, the other half of what a compositor uses ───\n // Damage tracking is pixman_region32, and it is pure C — no SIMD, but it\n // is the part a Wayland compositor calls on every frame.\n {\n pixman_region32_t a, b, r;\n pixman_region32_init_rect(&a, 0, 0, 100, 100);\n pixman_region32_init_rect(&b, 50, 50, 100, 100);\n pixman_region32_init(&r);\n check(pixman_region32_union(&r, &a, &b) != 0, \"pixman_region32_union\");\n const pixman_box32_t *e = pixman_region32_extents(&r);\n std::printf(\" union extents: (%d,%d)-(%d,%d)\\n\", e->x1, e->y1, e->x2, e->y2);\n check(e->x1 == 0 && e->y1 == 0 && e->x2 == 150 && e->y2 == 150,\n \"…and the extents are the union of the two rects\");\n pixman_region32_fini(&a);\n pixman_region32_fini(&b);\n pixman_region32_fini(&r);\n }\n\n if (src != nullptr) { pixman_image_unref(src); }\n if (out != nullptr) { pixman_image_unref(out); }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "plf-hive", + "text": "# plf-hive test project: assert the property that makes a hive worth using --\n# element addresses survive erasure of their neighbours -- rather than merely\n# that the header parses.\n#\n# Qualified `compat.plf-hive` so the workspace-root [indices] redirect applies\n# and this tests THIS checkout, not the published remote index.\n[package]\nname = \"plf-hive-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nplf-hive = \"2026.07.31\"\n", + "files": [ + { + "path": "tests/examples/plf-hive/tests/hive.cpp", + "text": "// compat.plf-hive — assert the guarantee that distinguishes a hive from a\n// vector or a deque: an element's ADDRESS stays valid while its neighbours are\n// erased and while new elements are inserted. If the package silently resolved\n// to something else, or the header were stubbed, these checks fail rather than\n// passing vacuously.\n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n plf::hive h;\n\n // Fill, and remember where a few elements physically live.\n std::vector::iterator> its;\n for (int i = 0; i < 200; ++i) its.push_back(h.insert(i));\n check(h.size() == 200, \"size after 200 inserts\");\n\n const int* addr_of_50 = &*its[50];\n const int* addr_of_150 = &*its[150];\n\n // Erase every even element. In a vector this would invalidate everything\n // after the first erasure; in a hive nothing moves.\n std::size_t erased = 0;\n for (int i = 0; i < 200; i += 2) {\n h.erase(its[static_cast(i)]);\n ++erased;\n }\n check(erased == 100, \"erased 100 elements\");\n check(h.size() == 100, \"size after erasures\");\n check(&*its[51] != nullptr, \"surviving iterator still dereferenceable\");\n check(addr_of_50 != nullptr && addr_of_150 != nullptr, \"addresses captured\");\n\n // Odd elements survived, at their original addresses.\n const int* addr_of_51 = &*its[51];\n check(*its[51] == 51, \"surviving element keeps its value\");\n\n // Inserting again reuses the erased slots -- and must not relocate anything.\n for (int i = 0; i < 100; ++i) h.insert(1000 + i);\n check(h.size() == 200, \"size after refill\");\n check(&*its[51] == addr_of_51, \"address stable across erase + insert\");\n check(*its[51] == 51, \"value stable across erase + insert\");\n\n // The surviving originals are exactly the odd numbers.\n long odd_sum = 0;\n long expected = 0;\n for (int v : h) if (v < 1000) odd_sum += v;\n for (int i = 1; i < 200; i += 2) expected += i;\n check(odd_sum == expected, \"surviving elements are exactly the odd ones\");\n\n // Erasing through the range interface and clearing must leave it empty.\n h.clear();\n check(h.empty(), \"empty after clear\");\n check(h.begin() == h.end(), \"begin == end when empty\");\n\n if (ok) std::println(\"plf::hive OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "protobuf-gzip", + "text": "# protobuf `gzip` feature member. The sibling tests/examples/protobuf covers\n# the DEFAULT build, where io/gzip_stream.cc compiles to an empty TU and no\n# zlib dependency is pulled at all.\n#\n# Here the feature is requested, which defines HAVE_ZLIB=1 for protobuf's own\n# TUs and pulls compat.zlib as the provider. The long-form dependency spelling\n# is what carries `features`.\n[package]\nname = \"protobuf-gzip-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nprotobuf = { version = \"35.1\", features = [\"gzip\"] }\n", + "files": [ + { + "path": "tests/examples/protobuf-gzip/tests/gzip.cpp", + "text": "// Behavioral test for compat.protobuf's `gzip` feature.\n//\n// io/gzip_stream.cc is wrapped head-to-toe in `#if HAVE_ZLIB`, so with the\n// feature OFF the TU is empty and GzipOutputStream / GzipInputStream have no\n// definitions — this member therefore fails to LINK without the feature,\n// which is the negative half of the verification (see the design doc).\n//\n// With it on: compress a payload, assert the compressed form is actually\n// smaller and is a real gzip stream (magic 0x1f 0x8b), then decompress and\n// assert byte equality.\n#include \n\n#include \"google/protobuf/io/coded_stream.h\"\n#include \"google/protobuf/io/gzip_stream.h\"\n#include \"google/protobuf/io/zero_copy_stream_impl_lite.h\"\n\nnamespace gpb = google::protobuf;\n\nnamespace {\n\nstd::string make_payload() {\n // Highly repetitive, so a working deflate must shrink it a lot. Random\n // data would leave \"compressed\" ~= \"original\" and make the size assertion\n // meaningless.\n std::string s;\n for (int i = 0; i < 2000; ++i) s += \"the quick brown fox jumps over the lazy dog\\n\";\n return s;\n}\n\nbool compress(const std::string& in, std::string* out) {\n gpb::io::StringOutputStream sink(out);\n gpb::io::GzipOutputStream::Options opts;\n opts.format = gpb::io::GzipOutputStream::GZIP;\n gpb::io::GzipOutputStream gz(&sink, opts);\n {\n // GzipOutputStream is a ZeroCopyOutputStream (Next/BackUp); CodedOutputStream\n // is the adapter that turns that into a plain buffer write. It must be\n // destroyed — i.e. flushed back into gz — before gz.Close().\n gpb::io::CodedOutputStream coded(&gz);\n coded.WriteRaw(in.data(), static_cast(in.size()));\n if (coded.HadError()) return false;\n }\n return gz.Close();\n}\n\nbool decompress(const std::string& in, std::string* out) {\n gpb::io::ArrayInputStream source(in.data(), static_cast(in.size()));\n gpb::io::GzipInputStream gz(&source, gpb::io::GzipInputStream::GZIP);\n\n const void* chunk = nullptr;\n int size = 0;\n while (gz.Next(&chunk, &size)) {\n if (size > 0) out->append(static_cast(chunk), static_cast(size));\n }\n return gz.ZlibErrorMessage() == nullptr;\n}\n\n} // namespace\n\nint main() {\n const std::string original = make_payload();\n\n std::string compressed;\n if (!compress(original, &compressed)) return 1;\n\n // A real gzip member starts with the 0x1f 0x8b magic.\n if (compressed.size() < 2) return 1;\n if (static_cast(compressed[0]) != 0x1f) return 1;\n if (static_cast(compressed[1]) != 0x8b) return 1;\n\n // Repetitive input must compress substantially; this also proves deflate\n // ran rather than the stream passing bytes through.\n if (compressed.size() >= original.size() / 10) return 1;\n\n std::string restored;\n if (!decompress(compressed, &restored)) return 1;\n\n return restored == original ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "protobuf-protoc", + "text": "# protobuf `protoc` target member — the compiler, not the runtime.\n#\n# The sibling tests/examples/protobuf covers the runtime and deliberately uses\n# NO generated code. This member is its complement: every line of the message\n# API it touches was emitted, during this build, by a protoc that mcpp built\n# from the SAME descriptor that provides the runtime being linked.\n#\n# That co-provenance is the point. protoc and libprotobuf must agree on the\n# generated-code ABI, and a mismatch there is a runtime failure, not a build\n# error. Here it is not expressible: `tools = [\"protoc\"]` makes the tool's\n# version the dependency's version.\n#\n# WINDOWS: linux + macOS only, matching the descriptor — compat.protobuf does\n# not declare the `protoc` target on windows, because the tool sub-build fails\n# there for reasons not yet diagnosed (see the comment in the descriptor's\n# windows block). The dependency below is therefore per-OS, and tests/codegen.cpp\n# compiles to a visible skip on windows rather than a test that silently proves\n# nothing.\n[package]\nname = \"protobuf-protoc-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# One dependency, two roles: `features` shapes what gets LINKED (the runtime),\n# `tools` asks for a host binary out of the same package. `protoc` pulls in\n# libprotoc's 138 TUs and needs `upb` for the upb generator's runtime — the\n# descriptor's `required_features` states that, so asking for the tool is\n# enough and this manifest does not have to know it.\n[target.'cfg(linux)'.dependencies.compat]\nprotobuf = { version = \"35.1\", tools = [\"protoc\"] }\n\n[target.'cfg(macos)'.dependencies.compat]\nprotobuf = { version = \"35.1\", tools = [\"protoc\"] }\n\n# The runtime alone, so the member still builds and links something real here.\n[target.'cfg(windows)'.dependencies.compat]\nprotobuf = \"35.1\"\n", + "files": [ + { + "path": "tests/examples/protobuf-protoc/tests/codegen.cpp", + "text": "// Behavioral test for compat.protobuf's `protoc` target.\n//\n// Every type used here was emitted DURING THIS BUILD by a protoc that mcpp\n// compiled from the same package that provides the runtime being linked. The\n// test therefore asserts the thing that actually matters about a code\n// generator shipped as a dependency: that its output and the runtime agree.\n//\n// What it drives, and what would break first on a generator/runtime mismatch:\n//\n// nested message + accessors generated_message_reflection.cc\n// enum generated_enum_util.cc\n// repeated message field repeated_ptr_field.cc\n// map field map_field.cc\n// oneof the generated case() discriminator\n// well-known type import timestamp.pb.cc (proves the -I resolved)\n// serialize -> parse round trip wire_format_lite.cc, parse_context.cc\n// reflection over generated msg descriptor.cc against the generated pool\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n// compat.protobuf declares no `protoc` target on windows, so nothing was\n// generated and there is nothing to assert. A loud skip beats a test that\n// passes without exercising anything.\n#ifdef _WIN32\nint main() {\n std::puts(\"skipped: compat.protobuf has no protoc target on windows\");\n return 0;\n}\n#else\n\n#include \"google/protobuf/util/time_util.h\"\n\n#include \"inventory.pb.h\"\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char* what) {\n if (!ok) {\n std::fprintf(stderr, \"FAIL: %s\\n\", what);\n ++failures;\n }\n}\n\n} // namespace\n\nint main() {\n GOOGLE_PROTOBUF_VERIFY_VERSION;\n\n inventory::Inventory inv;\n\n inventory::Item* widget = inv.add_items();\n widget->set_sku(\"WIDGET-1\");\n widget->set_quantity(7);\n widget->set_grade(inventory::GRADE_A);\n widget->mutable_dimensions()->set_width(2.5);\n widget->mutable_dimensions()->set_height(4.0);\n widget->set_supplier(\"acme\");\n\n inventory::Item* gizmo = inv.add_items();\n gizmo->set_sku(\"GIZMO-2\");\n gizmo->set_quantity(3);\n gizmo->set_grade(inventory::GRADE_B);\n gizmo->set_warehouse(\"east\");\n\n (*inv.mutable_totals_by_grade())[\"A\"] = 7;\n (*inv.mutable_totals_by_grade())[\"B\"] = 3;\n\n // The well-known type. Reaching this line at all proves protoc resolved\n // the import, and setting it proves timestamp.pb.cc is in the runtime.\n *inv.mutable_updated_at() =\n google::protobuf::util::TimeUtil::SecondsToTimestamp(1735689600);\n\n std::string wire;\n check(inv.SerializeToString(&wire), \"serialize\");\n check(!wire.empty(), \"wire is non-empty\");\n\n inventory::Inventory back;\n check(back.ParseFromString(wire), \"parse\");\n\n check(back.items_size() == 2, \"two items survived the round trip\");\n check(back.items(0).sku() == \"WIDGET-1\", \"item 0 sku\");\n check(back.items(0).quantity() == 7, \"item 0 quantity\");\n check(back.items(0).grade() == inventory::GRADE_A, \"item 0 enum\");\n check(back.items(0).dimensions().width() == 2.5, \"nested message field\");\n check(back.items(0).source_case() == inventory::Item::kSupplier,\n \"oneof discriminator (supplier)\");\n check(back.items(0).supplier() == \"acme\", \"oneof value\");\n check(back.items(1).source_case() == inventory::Item::kWarehouse,\n \"oneof discriminator (warehouse)\");\n check(back.totals_by_grade().size() == 2, \"map size\");\n check(back.totals_by_grade().at(\"A\") == 7, \"map lookup\");\n check(google::protobuf::util::TimeUtil::TimestampToSeconds(\n back.updated_at()) == 1735689600,\n \"well-known Timestamp round trip\");\n\n // Reflection over the generated pool: the descriptor protoc emitted has to\n // describe the C++ class it emitted beside it.\n const google::protobuf::Descriptor* d = inventory::Item::descriptor();\n check(d != nullptr && d->full_name() == \"inventory.Item\",\n \"descriptor full name\");\n check(d != nullptr && d->FindFieldByName(\"sku\") != nullptr,\n \"descriptor knows the sku field\");\n check(d != nullptr && d->oneof_decl_count() == 1,\n \"descriptor knows the oneof\");\n\n google::protobuf::ShutdownProtobufLibrary();\n\n if (failures != 0) {\n std::fprintf(stderr, \"%d check(s) failed\\n\", failures);\n return 1;\n }\n std::puts(\"protoc-generated code round-trips against the linked runtime\");\n return 0;\n}\n\n#endif // _WIN32\n" + } + ] + }, + { + "project": "protobuf-upb", + "text": "# protobuf `upb` feature member. The sibling tests/examples/protobuf covers the\n# DEFAULT build, where none of upb's 64 C TUs are compiled at all.\n#\n# upb is protobuf's small C runtime, vendored in the same tarball since v22.\n# gRPC is what needs it — its generated src/core/ext/upb-gen/**.c links against\n# this runtime — so this member is the standalone proof that the feature builds\n# and works before gRPC depends on it.\n[package]\nname = \"protobuf-upb-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nprotobuf = { version = \"35.1\", features = [\"upb\"] }\n", + "files": [ + { + "path": "tests/examples/protobuf-upb/tests/upb_runtime.cpp", + "text": "// Behavioral test for compat.protobuf's `upb` feature.\n//\n// Drives BOTH runtimes across the same bytes, which is the strongest thing\n// this member can assert: the C++ runtime builds a FileDescriptorProto and\n// serializes it, then upb parses those bytes and loads them into a upb_DefPool\n// and answers reflection queries about them.\n//\n// What that reaches:\n// upb_Arena / upb_DefPool -> upb/mem/arena.c, reflection/def_pool.c\n// parse of a descriptor message -> upb/wire/decode.c, message/*.c, mini_table/*.c\n// AddFile + name lookups -> upb/reflection/{file,message,field}_def.c\n// the descriptor tables themselves -> upb/reflection/cmake/…/descriptor.upb_minitable.c\n//\n// That last one is the reason this test exists in this shape: those tables are\n// NOT in upstream's libupb_srcs, they come from cmake/libupb.cmake's\n// bootstrap_sources, and leaving them out fails only at LINK time. Anything\n// that merely compiles upb would not notice.\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n#include \"google/protobuf/descriptor.pb.h\"\n\n// NOT wrapped in extern \"C\": upb's headers manage their own linkage, and\n// upb/message/accessors.h deliberately declares C++ OVERLOADS outside the\n// extern \"C\" block. Forcing the whole tree into C linkage makes those overloads\n// collide with their C counterparts (\"conflicting declaration of C function\").\n#include \"google/protobuf/descriptor.upb.h\"\n#include \"upb/base/status.h\"\n#include \"upb/mem/arena.h\"\n#include \"upb/reflection/def_pool.h\"\n#include \"upb/reflection/field_def.h\"\n#include \"upb/reflection/file_def.h\"\n#include \"upb/reflection/message_def.h\"\n\nnamespace gpb = google::protobuf;\n\nnamespace {\n\n// Build the schema with the C++ runtime and hand upb its wire bytes.\nstd::string make_descriptor_bytes() {\n gpb::FileDescriptorProto file;\n file.set_name(\"mcpp_upb_test.proto\");\n file.set_package(\"mcpp.test\");\n file.set_syntax(\"proto3\");\n\n gpb::DescriptorProto* msg = file.add_message_type();\n msg->set_name(\"Person\");\n\n gpb::FieldDescriptorProto* name = msg->add_field();\n name->set_name(\"name\");\n name->set_number(1);\n name->set_type(gpb::FieldDescriptorProto::TYPE_STRING);\n name->set_label(gpb::FieldDescriptorProto::LABEL_OPTIONAL);\n\n gpb::FieldDescriptorProto* id = msg->add_field();\n id->set_name(\"id\");\n id->set_number(2);\n id->set_type(gpb::FieldDescriptorProto::TYPE_INT32);\n id->set_label(gpb::FieldDescriptorProto::LABEL_OPTIONAL);\n\n return file.SerializeAsString();\n}\n\n} // namespace\n\nint main() {\n const std::string wire = make_descriptor_bytes();\n if (wire.empty()) return 1;\n\n upb_Arena* arena = upb_Arena_New();\n if (arena == nullptr) return 1;\n\n // upb parses what the C++ runtime wrote.\n google_protobuf_FileDescriptorProto* parsed =\n google_protobuf_FileDescriptorProto_parse(wire.data(), wire.size(), arena);\n if (parsed == nullptr) {\n upb_Arena_Free(arena);\n return 1;\n }\n\n upb_DefPool* pool = upb_DefPool_New();\n if (pool == nullptr) {\n upb_Arena_Free(arena);\n return 1;\n }\n\n upb_Status status;\n upb_Status_Clear(&status);\n const upb_FileDef* file_def = upb_DefPool_AddFile(pool, parsed, &status);\n\n int rc = 1;\n if (file_def != nullptr) {\n const upb_MessageDef* msg = upb_DefPool_FindMessageByName(pool, \"mcpp.test.Person\");\n if (msg != nullptr && upb_MessageDef_FieldCount(msg) == 2) {\n const upb_FieldDef* f_name = upb_MessageDef_FindFieldByName(msg, \"name\");\n const upb_FieldDef* f_id = upb_MessageDef_FindFieldByName(msg, \"id\");\n const bool names_ok =\n std::string(upb_FileDef_Name(file_def)) == \"mcpp_upb_test.proto\" &&\n std::string(upb_MessageDef_FullName(msg)) == \"mcpp.test.Person\";\n // A name that was never declared must NOT resolve — otherwise a\n // stub lookup returning something would pass everything above.\n const bool absent_ok =\n upb_DefPool_FindMessageByName(pool, \"mcpp.test.Missing\") == nullptr;\n if (f_name != nullptr && f_id != nullptr && names_ok && absent_ok &&\n upb_FieldDef_Number(f_name) == 1 && upb_FieldDef_Number(f_id) == 2) {\n rc = 0;\n }\n }\n }\n\n upb_DefPool_Free(pool);\n upb_Arena_Free(arena);\n return rc;\n}\n" + } + ] + }, + { + "project": "protobuf", + "text": "# protobuf test project: consumes compat.protobuf (which in turn pulls\n# compat.abseil) and asserts behavior under `mcpp test`. Part of the\n# mcpp-index self-referential workspace — the workspace-root `[indices]`\n# redirect points at this repo, so both dependencies resolve to the checked-in\n# recipes (pkgs/c/compat.protobuf.lua, pkgs/c/compat.abseil.lua) rather than\n# the published remote index.\n#\n# No .proto codegen anywhere in this member, by design: compat.protobuf ships\n# the RUNTIME, not protoc. The test drives the runtime through the descriptor\n# / DynamicMessage path plus the checked-in well-known types, which is exactly\n# the surface a consumer gets without a compiler.\n[package]\nname = \"protobuf-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nprotobuf = \"35.1\"\n", + "files": [ + { + "path": "tests/examples/protobuf/tests/runtime.cpp", + "text": "// Behavioral test for compat.protobuf (the libprotobuf runtime).\n//\n// Deliberately uses NO protoc-generated code: the package ships the runtime,\n// not the compiler, so this drives the surface a consumer actually gets —\n//\n// .proto text -> descriptor compiler/parser.cc, io/tokenizer.cc\n// descriptor -> message descriptor.cc, dynamic_message.cc\n// set/get fields generated_message_reflection.cc, map_field.cc\n// serialize / parse wire_format.cc, parse_context.cc, coded_stream.cc\n// TextFormat text_format.cc\n// JSON json/json.cc + json/internal/*.cc\n// well-known types timestamp.pb.cc, struct.pb.cc, util/time_util.cc\n// MessageDifferencer util/message_differencer.cc\n// UTF-8 validation third_party/utf8_range/utf8_range.c\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n#include \"google/protobuf/compiler/parser.h\"\n#include \"google/protobuf/descriptor.h\"\n#include \"google/protobuf/descriptor.pb.h\"\n#include \"google/protobuf/dynamic_message.h\"\n#include \"google/protobuf/io/tokenizer.h\"\n#include \"google/protobuf/io/zero_copy_stream_impl_lite.h\"\n#include \"google/protobuf/json/json.h\"\n#include \"google/protobuf/struct.pb.h\"\n#include \"google/protobuf/text_format.h\"\n#include \"google/protobuf/timestamp.pb.h\"\n#include \"google/protobuf/util/message_differencer.h\"\n#include \"google/protobuf/util/time_util.h\"\n\nnamespace gpb = google::protobuf;\n\nnamespace {\n\n// Collects parse errors instead of printing them, so a broken schema shows up\n// as a failed assertion rather than noise on stderr.\nclass SilentErrorCollector : public gpb::io::ErrorCollector {\npublic:\n void RecordError(int line, gpb::io::ColumnNumber col, absl::string_view msg) override {\n ++errors;\n (void)line;\n (void)col;\n (void)msg;\n }\n int errors = 0;\n};\n\nconstexpr const char* kSchema = R\"(\n syntax = \"proto3\";\n package mcpp.test;\n message Person {\n string name = 1;\n int32 id = 2;\n repeated string tags = 3;\n map scores = 4;\n }\n)\";\n\n// Parse the .proto TEXT above into a FileDescriptorProto. This is the runtime's\n// own parser (compiler/parser.cc), not protoc.\nbool build_pool(gpb::DescriptorPool& pool, const gpb::Descriptor** out) {\n gpb::io::ArrayInputStream raw(kSchema, static_cast(std::string(kSchema).size()));\n SilentErrorCollector collector;\n gpb::io::Tokenizer tokenizer(&raw, &collector);\n\n gpb::FileDescriptorProto file;\n gpb::compiler::Parser parser;\n parser.RecordErrorsTo(&collector);\n if (!parser.Parse(&tokenizer, &file) || collector.errors != 0) return false;\n file.set_name(\"mcpp_test.proto\");\n\n const gpb::FileDescriptor* fd = pool.BuildFile(file);\n if (fd == nullptr) return false;\n *out = pool.FindMessageTypeByName(\"mcpp.test.Person\");\n return *out != nullptr;\n}\n\nbool dynamic_message_roundtrip(const gpb::Descriptor* desc) {\n gpb::DynamicMessageFactory factory;\n const gpb::Message* prototype = factory.GetPrototype(desc);\n if (prototype == nullptr) return false;\n\n std::unique_ptr msg(prototype->New());\n const gpb::Reflection* ref = msg->GetReflection();\n\n ref->SetString(msg.get(), desc->FindFieldByName(\"name\"), \"Ada\");\n ref->SetInt32(msg.get(), desc->FindFieldByName(\"id\"), 1815);\n const gpb::FieldDescriptor* tags = desc->FindFieldByName(\"tags\");\n ref->AddString(msg.get(), tags, \"math\");\n ref->AddString(msg.get(), tags, \"engine\");\n\n // Wire round-trip.\n std::string wire;\n if (!msg->SerializeToString(&wire)) return false;\n std::unique_ptr back(prototype->New());\n if (!back->ParseFromString(wire)) return false;\n if (!gpb::util::MessageDifferencer::Equals(*msg, *back)) return false;\n\n // TextFormat round-trip.\n std::string text;\n if (!gpb::TextFormat::PrintToString(*back, &text)) return false;\n if (text.find(\"name: \\\"Ada\\\"\") == std::string::npos) return false;\n if (text.find(\"id: 1815\") == std::string::npos) return false;\n std::unique_ptr from_text(prototype->New());\n if (!gpb::TextFormat::ParseFromString(text, from_text.get())) return false;\n if (!gpb::util::MessageDifferencer::Equals(*msg, *from_text)) return false;\n\n // A truncated payload must be REJECTED — proves parsing is real and not a\n // no-op that accepts anything.\n std::unique_ptr truncated(prototype->New());\n if (!wire.empty() && truncated->ParseFromString(wire.substr(0, wire.size() - 1))) return false;\n\n return ref->GetRepeatedString(*back, tags, 1) == \"engine\";\n}\n\nbool json_ok(const gpb::Descriptor* desc) {\n gpb::DynamicMessageFactory factory;\n std::unique_ptr msg(factory.GetPrototype(desc)->New());\n const gpb::Reflection* ref = msg->GetReflection();\n ref->SetString(msg.get(), desc->FindFieldByName(\"name\"), \"Grace\");\n ref->SetInt32(msg.get(), desc->FindFieldByName(\"id\"), 1906);\n\n std::string json;\n if (!gpb::json::MessageToJsonString(*msg, &json).ok()) return false;\n if (json.find(\"\\\"name\\\":\\\"Grace\\\"\") == std::string::npos) return false;\n\n std::unique_ptr back(factory.GetPrototype(desc)->New());\n if (!gpb::json::JsonStringToMessage(json, back.get()).ok()) return false;\n return gpb::util::MessageDifferencer::Equals(*msg, *back);\n}\n\n// The well-known types are generated code that upstream CHECKS IN, so they are\n// compiled into this package and usable with no protoc anywhere.\nbool well_known_types_ok() {\n gpb::Timestamp ts;\n if (!gpb::util::TimeUtil::FromString(\"2026-08-04T12:34:56Z\", &ts)) return false;\n if (gpb::util::TimeUtil::ToString(ts) != \"2026-08-04T12:34:56Z\") return false;\n\n gpb::Struct s;\n (*s.mutable_fields())[\"pi\"].set_number_value(3.5);\n (*s.mutable_fields())[\"name\"].set_string_value(\"mcpp\");\n if (s.fields().size() != 2) return false;\n if (s.fields().at(\"pi\").number_value() != 3.5) return false;\n\n std::string wire;\n if (!s.SerializeToString(&wire)) return false;\n gpb::Struct back;\n return back.ParseFromString(wire) && back.fields().at(\"name\").string_value() == \"mcpp\";\n}\n\n// proto3 string fields are UTF-8 validated, which routes into\n// third_party/utf8_range/utf8_range.c — the one C TU in this package.\nbool utf8_validation_ok(const gpb::Descriptor* desc) {\n gpb::DynamicMessageFactory factory;\n std::unique_ptr msg(factory.GetPrototype(desc)->New());\n msg->GetReflection()->SetString(msg.get(), desc->FindFieldByName(\"name\"), \"héllo-世界\");\n\n std::string wire;\n if (!msg->SerializeToString(&wire)) return false;\n std::unique_ptr back(factory.GetPrototype(desc)->New());\n if (!back->ParseFromString(wire)) return false;\n return back->GetReflection()->GetString(*back, desc->FindFieldByName(\"name\")) == \"héllo-世界\";\n}\n\n} // namespace\n\nint main() {\n gpb::DescriptorPool pool;\n const gpb::Descriptor* person = nullptr;\n if (!build_pool(pool, &person)) return 1;\n\n const bool ok = dynamic_message_roundtrip(person) && json_ok(person)\n && well_known_types_ok() && utf8_validation_ok(person);\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "quickjs-ng", + "text": "# QuickJS-ng test project: evaluate JavaScript through the engine and assert\n# the values it computes, one group per compiled source file.\n#\n# compat.quickjs-ng carries a `linux` section only (see the descriptor's\n# header), so the dependency is gated and the test compiles to a no-op main()\n# elsewhere, the same shape as tests/examples/libaio and tests/examples/wamr.\n[package]\nname = \"quickjs-ng-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nquickjs-ng = \"0.17.0\"\n", + "files": [ + { + "path": "tests/examples/quickjs-ng/tests/engine.cpp", + "text": "// Behavioral test: JavaScript evaluated by the engine, checked by value.\n//\n// The package compiles four sources, and each group below needs one of them to\n// be present and built with the right flags, so a missing or mis-built TU shows\n// up as a wrong answer rather than only as a link error:\n//\n// quickjs.c evaluation, closures, exceptions, the promise job queue\n// dtoa.c shortest round-trip number formatting (0.1 + 0.2, 5e-324)\n// libregexp.c named groups, case-insensitive and sticky matching\n// libunicode.c full case mapping, NFC normalization, script properties\n//\n// The expected strings are what the ECMAScript specification requires, written\n// out by hand; none is computed by the code under test.\n//\n// The runtime is freed at the end. The package does not define NDEBUG, so\n// JS_FreeRuntime asserts that no object is still alive (quickjs.c:2699) and an\n// object this test forgot to free aborts it.\n\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n\n// The descriptor exposes quickjs.h alone. The engine's private headers sit next\n// to it in the tarball and must not reach a consumer.\n#if __has_include() || __has_include()\n#error \"compat.quickjs-ng put the engine's private headers on the include path\"\n#endif\n\nnamespace {\n\nint failures = 0;\n\nstd::string to_std_string(JSContext* ctx, JSValueConst v) {\n const char* s = JS_ToCString(ctx, v);\n std::string out = s ? s : \"\";\n JS_FreeCString(ctx, s);\n return out;\n}\n\n// Global code; the completion value of the last statement is the result.\nstd::string run_js(JSContext* ctx, const char* src) {\n JSValue v = JS_Eval(ctx, src, std::strlen(src), \"\", JS_EVAL_TYPE_GLOBAL);\n std::string out;\n if (JS_IsException(v)) {\n JSValue exc = JS_GetException(ctx);\n out = \"exception \" + to_std_string(ctx, exc);\n JS_FreeValue(ctx, exc);\n } else {\n out = to_std_string(ctx, v);\n }\n JS_FreeValue(ctx, v);\n return out;\n}\n\nvoid expect(JSContext* ctx, const char* src, const char* want) {\n std::string got = run_js(ctx, src);\n if (got != want) {\n std::fprintf(stderr, \"FAIL %s\\n want: %s\\n got: %s\\n\", src, want, got.c_str());\n ++failures;\n }\n}\n\n} // namespace\n\nint main() {\n // The library and the header it was built from agree on the version.\n char header_version[32];\n std::snprintf(header_version, sizeof header_version, \"%d.%d.%d%s\",\n QJS_VERSION_MAJOR, QJS_VERSION_MINOR, QJS_VERSION_PATCH, QJS_VERSION_SUFFIX);\n std::printf(\"QuickJS-ng %s\\n\", JS_GetVersion());\n if (std::strcmp(JS_GetVersion(), \"0.17.0\") != 0 ||\n std::strcmp(JS_GetVersion(), header_version) != 0) {\n std::fprintf(stderr, \"FAIL version: library %s, header %s\\n\", JS_GetVersion(), header_version);\n ++failures;\n }\n\n JSRuntime* rt = JS_NewRuntime();\n JSContext* ctx = rt ? JS_NewContext(rt) : nullptr;\n if (ctx == nullptr) {\n std::fprintf(stderr, \"FAIL could not create a runtime and context\\n\");\n return 1;\n }\n\n // quickjs.c\n expect(ctx, \"const sq = [1, 2, 3].map(x => x * x); sq.reduce((a, b) => a + b, 0)\", \"14\");\n expect(ctx, \"(() => { let n = 0; const inc = () => ++n; inc(); inc(); return inc(); })()\", \"3\");\n expect(ctx, \"JSON.stringify({ a: [1, { b: null }], c: 'x' })\", \"{\\\"a\\\":[1,{\\\"b\\\":null}],\\\"c\\\":\\\"x\\\"}\");\n expect(ctx, \"throw new TypeError('boom')\", \"exception TypeError: boom\");\n\n // dtoa.c\n expect(ctx, \"String(0.1 + 0.2)\", \"0.30000000000000004\");\n expect(ctx, \"String(5e-324)\", \"5e-324\");\n expect(ctx, \"String(1e21)\", \"1e+21\");\n expect(ctx, \"(1.005).toFixed(2)\", \"1.00\");\n expect(ctx, \"(255).toString(16) + ' ' + parseFloat('2.5e-3') * 4\", \"ff 0.01\");\n\n // libregexp.c\n expect(ctx, \"const m = /(?\\\\d{4})-(?\\\\d{2})/.exec('on 2026-09'); m.groups.y + '/' + m.groups.m\",\n \"2026/09\");\n expect(ctx, \"'aXbxc'.split(/x/i).join(',')\", \"a,b,c\");\n expect(ctx, \"const re = /o/y; re.lastIndex = 1; re.test('foo') + ' ' + re.lastIndex\", \"true 2\");\n\n // libunicode.c\n expect(ctx, \"'straße'.toUpperCase()\", \"STRASSE\");\n expect(ctx, \"'\\\\u0041\\\\u030A'.normalize('NFC') === '\\\\u00C5'\", \"true\");\n expect(ctx, \"/^\\\\p{Script=Greek}+$/u.test('αβγ') + ' ' + /\\\\p{Script=Greek}/u.test('abc')\", \"true false\");\n\n // quickjs.c, the job queue: a reaction runs only when the host drains it.\n expect(ctx, \"var out = 'pending'; Promise.resolve(20).then(v => { out = 'resolved ' + (v + 1); }); out\",\n \"pending\");\n JSContext* job_ctx = nullptr;\n int r;\n while ((r = JS_ExecutePendingJob(rt, &job_ctx)) > 0) {\n }\n if (r != 0) {\n std::fprintf(stderr, \"FAIL a pending job threw\\n\");\n ++failures;\n }\n expect(ctx, \"out\", \"resolved 21\");\n\n JS_FreeContext(ctx);\n JS_FreeRuntime(rt);\n\n if (failures != 0) {\n std::fprintf(stderr, \"%d check(s) failed\\n\", failures);\n return 1;\n }\n return 0;\n}\n\n#else\n\nint main() { return 0; } // compat.quickjs-ng declares linux only; nothing to assert here.\n\n#endif\n" + } + ] + }, + { + "project": "re2", + "text": "# re2 test project: consumes compat.re2 and asserts behavior under `mcpp test`.\n# Part of the mcpp-index self-referential workspace — the workspace-root\n# `[indices]` redirect points at this repo, so the dependency resolves to the\n# checked-in recipe (pkgs/c/compat.re2.lua) rather than the published index.\n[package]\nname = \"re2-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nre2 = \"2022-04-01\"\n", + "files": [ + { + "path": "tests/examples/re2/tests/match.cpp", + "text": "// Behavioral test for compat.re2.\n//\n// Exercises the parts of RE2 that gRPC's xds matchers actually use — full and\n// partial match with captures, a pre-compiled RE2 object, replacement, and the\n// RE2::Set multi-pattern API — so a package that compiled but linked nothing\n// useful cannot pass:\n//\n// RE2 ctor / FullMatch / PartialMatch -> re2/re2.cc, parse.cc, compile.cc, prog.cc\n// captures + rewriting -> re2/re2.cc, regexp.cc, simplify.cc\n// RE2::Set -> re2/set.cc, dfa.cc, nfa.cc\n// invalid pattern reporting -> re2/parse.cc, tostring.cc\n// UTF-8 handling -> util/rune.cc, unicode_groups.cc\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n#include \"re2/re2.h\"\n#include \"re2/set.h\"\n\nnamespace {\n\nbool matching_ok() {\n if (!RE2::FullMatch(\"grpc-1.83.0\", R\"(grpc-\\d+\\.\\d+\\.\\d+)\")) return false;\n // A full match must NOT accept trailing junk — proves this is FullMatch\n // semantics and not an accidental substring search.\n if (RE2::FullMatch(\"grpc-1.83.0-rc1\", R\"(grpc-\\d+\\.\\d+\\.\\d+)\")) return false;\n return RE2::PartialMatch(\"grpc-1.83.0-rc1\", R\"(grpc-\\d+\\.\\d+\\.\\d+)\");\n}\n\nbool captures_ok() {\n const RE2 re(R\"((\\w+)://([^/:]+):(\\d+))\"); // pre-compiled, the way a matcher holds it\n if (!re.ok()) return false;\n\n std::string scheme, host;\n int port = 0;\n if (!RE2::FullMatch(\"dns://localhost:50051\", re, &scheme, &host, &port)) return false;\n return scheme == \"dns\" && host == \"localhost\" && port == 50051;\n}\n\nbool rewrite_ok() {\n std::string s = \"service/Echo, service/Ping\";\n const int n = RE2::GlobalReplace(&s, R\"(service/(\\w+))\", R\"(grpc.\\1)\");\n return n == 2 && s == \"grpc.Echo, grpc.Ping\";\n}\n\nbool set_ok() {\n RE2::Set set(RE2::DefaultOptions, RE2::UNANCHORED);\n const int a = set.Add(R\"(^/helloworld\\.Greeter/)\", nullptr);\n const int b = set.Add(R\"(SayHello$)\", nullptr);\n if (a < 0 || b < 0 || !set.Compile()) return false;\n\n std::vector hits;\n if (!set.Match(\"/helloworld.Greeter/SayHello\", &hits)) return false;\n if (hits.size() != 2) return false;\n\n std::vector none;\n return !set.Match(\"/other.Service/Method\", &none);\n}\n\nbool invalid_pattern_reported() {\n // An unbalanced group must be REJECTED with an error string, not silently\n // accepted — otherwise a broken matcher config would look valid.\n // log_errors(false) keeps RE2 from also printing the (expected) parse\n // failure to stderr, so real problems stay the only noise in the log.\n RE2::Options quiet;\n quiet.set_log_errors(false);\n const RE2 bad(\"(unclosed\", quiet);\n return !bad.ok() && !bad.error().empty();\n}\n\nbool utf8_ok() {\n // '.' counts runes, not bytes, in UTF-8 mode (the default).\n return RE2::FullMatch(\"héllo\", \"h.llo\") && RE2::PartialMatch(\"世界\", \"界\");\n}\n\n} // namespace\n\nint main() {\n const bool ok = matching_ok() && captures_ok() && rewrite_ok() && set_ok()\n && invalid_pattern_reported() && utf8_ok();\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "recastnavigation-features", + "text": "# recastnavigation-features 测试工程:以三个可选 feature 全开的方式消费\n# compat.recastnavigation——`crowd`、`tilecache`、`debug-utils`——并逐个断言\n# 它们干了真实的活。mcpp-index 自引用 workspace 的一员;workspace 根的\n# [indices] 重定向让依赖解析到签入的描述符\n# (pkgs/c/compat.recastnavigation.lua)。\n#\n# 兄弟成员 tests/examples/recastnavigation 覆盖无 feature 的核心构建\n# (Recast + Detour);本成员证明三个被门控的组件——每个都只在被点名时\n# 才编进库里:\n#\n# crowd -> 一个 agent 被引导着穿过网格,而且必须真的挪了地方\n# tilecache -> 构建高度场层、压缩、缓存,再物化回一块导航网格 tile\n# debug-utils -> 用一个 duDebugDraw 实现驱动导航网格绘制,并给收到的\n# 图元计数\n#\n# `debug-utils` 会传递拉入 `tilecache`(上游的 DebugUtils 无条件链接\n# DetourTileCache),所以只点调试绘制的消费者依然能拿到可链接的库——\n# 这条 implication 也是本成员要守住的东西之一。\n[package]\nname = \"recastnavigation-features-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nrecastnavigation = { version = \"1.6.0\", features = [\"crowd\", \"debug-utils\"] }\n", + "files": [ + { + "path": "tests/examples/recastnavigation-features/tests/features.cpp", + "text": "// compat.recastnavigation——三个按 feature 门控的组件,逐个让它做出\n// 可观察的工作。\n//\n// `recastnavigation-features` 只点名 `crowd` 和 `debug-utils`。\n// `tilecache` 没被点名,这是刻意的:上游的 DebugUtils 无条件链接\n// DetourTileCache,所以描述符让 `debug-utils` imply `tilecache`。这条\n// implication 哪天失效,本成员会在链接期失败,而不是悄悄发出一个没法\n// 画调试图的包。\n//\n// 一切都通过生成的 `recastnavigation/` include 前缀行使——与上游自己的\n// CMake 安装产出一致——所以 shim 层对 feature 头也有覆盖,不只是核心头。\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nimport std;\n\nnamespace {\n\nconstexpr float kSize = 20.0f;\n\nconstexpr float kAgentHeight = 2.0f;\nconstexpr float kAgentRadius = 0.6f;\nconstexpr float kAgentClimb = 0.9f;\n\n// y=0 处一块 20x20 的平面四边形,俯视为顺时针绕序,使 rcCalcTriNormal\n// 产出朝上的法线——rcMarkWalkableTriangles 测试 `norm[1] > threshold`,\n// 绕序反了不会报错,只会得到空网格。\nconstexpr float kFloorVerts[] = { 0, 0, 0, 0, 0, kSize, kSize, 0, kSize, kSize, 0, 0 };\nconstexpr int kFloorTris[] = { 0, 1, 2, 0, 2, 3 };\n\nvoid fillConfig(rcConfig& cfg, float cs) {\n cfg = rcConfig{};\n cfg.cs = cs;\n cfg.ch = 0.2f;\n cfg.walkableSlopeAngle = 45.0f;\n cfg.walkableHeight = static_cast(std::ceil(kAgentHeight / cfg.ch));\n cfg.walkableClimb = static_cast(std::floor(kAgentClimb / cfg.ch));\n cfg.walkableRadius = static_cast(std::ceil(kAgentRadius / cfg.cs));\n cfg.maxEdgeLen = static_cast(12.0f / cfg.cs);\n cfg.maxSimplificationError = 1.3f;\n cfg.minRegionArea = static_cast(rcSqr(8.0f));\n cfg.mergeRegionArea = static_cast(rcSqr(20.0f));\n cfg.maxVertsPerPoly = 6;\n cfg.detailSampleDist = cfg.cs * 6.0f;\n cfg.detailSampleMaxError = cfg.ch * 1.0f;\n cfg.bmin[0] = 0.0f; cfg.bmin[1] = 0.0f; cfg.bmin[2] = 0.0f;\n cfg.bmax[0] = kSize; cfg.bmax[1] = 0.0f; cfg.bmax[2] = kSize;\n}\n\n// 两条流水线共用的前半段:栅格化、过滤、压缩。\nrcCompactHeightfield* buildCompact(rcContext& ctx, const rcConfig& cfg) {\n rcHeightfield* solid = rcAllocHeightfield();\n if (!solid) return nullptr;\n if (!rcCreateHeightfield(&ctx, *solid, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch)) {\n rcFreeHeightField(solid);\n return nullptr;\n }\n unsigned char triareas[2] = { 0, 0 };\n rcMarkWalkableTriangles(&ctx, cfg.walkableSlopeAngle, kFloorVerts, 4, kFloorTris, 2, triareas);\n rcRasterizeTriangles(&ctx, kFloorVerts, 4, kFloorTris, triareas, 2, *solid, cfg.walkableClimb);\n rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *solid);\n rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid);\n rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *solid);\n\n rcCompactHeightfield* chf = rcAllocCompactHeightfield();\n if (!chf || !rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid, *chf)) {\n rcFreeCompactHeightfield(chf);\n rcFreeHeightField(solid);\n return nullptr;\n }\n rcFreeHeightField(solid);\n return chf;\n}\n\n// dtTileCacheCompressor 是用户提供的接口——RecastDemo 往里接的是 fastlz。\n// 对一个只关心 tile 能完整往返的测试来说,memcpy 就是很好的压缩方案,\n// 还顺带把 fastlz 依赖挡在外面。\nstruct PassthroughCompressor final : dtTileCacheCompressor {\n int maxCompressedSize(const int bufferSize) override { return bufferSize; }\n dtStatus compress(const unsigned char* buffer, const int bufferSize,\n unsigned char* compressed, const int maxCompressedSize, int* compressedSize) override {\n if (bufferSize > maxCompressedSize) return DT_FAILURE;\n std::memcpy(compressed, buffer, static_cast(bufferSize));\n *compressedSize = bufferSize;\n return DT_SUCCESS;\n }\n dtStatus decompress(const unsigned char* compressed, const int compressedSize,\n unsigned char* buffer, const int maxBufferSize, int* bufferSize) override {\n if (compressedSize > maxBufferSize) return DT_FAILURE;\n std::memcpy(buffer, compressed, static_cast(compressedSize));\n *bufferSize = compressedSize;\n return DT_SUCCESS;\n }\n};\n\n// dtTileCache 把每块刚建好的 tile 交给 mesh process 打 flag。没有这一步,\n// 多边形的 flags 会是 0,什么都查不了——这正是「tile 存在」这条断言\n// 抓不住的失败模式。\nstruct FlagWalkable final : dtTileCacheMeshProcess {\n void process(dtNavMeshCreateParams* params, unsigned char* polyAreas, unsigned short* polyFlags) override {\n for (int i = 0; i < params->polyCount; ++i)\n polyFlags[i] = (polyAreas[i] == DT_TILECACHE_WALKABLE_AREA) ? 1 : 0;\n }\n};\n\n// 给 DebugUtils 向渲染器要的东西计数。duDebugDraw 是抽象接口,实现一个\n// 是证明 DebugUtils 既编译过又链接得上的唯一办法——只做头的 DebugUtils\n// 撑不过 begin()。\nstruct RecordingDraw final : duDebugDraw {\n int vertices = 0;\n int begins = 0;\n int triBatches = 0;\n\n void depthMask(bool) override {}\n void texture(bool) override {}\n void begin(duDebugDrawPrimitives prim, float) override {\n ++begins;\n if (prim == DU_DRAW_TRIS) ++triBatches;\n }\n void vertex(const float*, unsigned int) override { ++vertices; }\n void vertex(const float, const float, const float, unsigned int) override { ++vertices; }\n void vertex(const float*, unsigned int, const float*) override { ++vertices; }\n void vertex(const float, const float, const float, unsigned int, const float, const float) override { ++vertices; }\n void end() override {}\n};\n\n} // namespace\n\nint main() {\n bool ok = true;\n const auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n rcContext ctx;\n\n // ══ Recast:一张独立网格,crowd 和 DebugUtils 都要用它 ═════════════════\n rcConfig cfg;\n fillConfig(cfg, 0.5f);\n rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height);\n\n rcCompactHeightfield* chf = buildCompact(ctx, cfg);\n check(chf != nullptr, \"compact heightfield\");\n\n rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf);\n rcBuildDistanceField(&ctx, *chf);\n rcBuildRegions(&ctx, *chf, 0, cfg.minRegionArea, cfg.mergeRegionArea);\n\n rcContourSet* cset = rcAllocContourSet();\n rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cset);\n rcPolyMesh* pmesh = rcAllocPolyMesh();\n rcBuildPolyMesh(&ctx, *cset, cfg.maxVertsPerPoly, *pmesh);\n rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail();\n rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dmesh);\n std::println(\"solo poly mesh: {} verts, {} polys\", pmesh->nverts, pmesh->npolys);\n check(pmesh->npolys > 0, \"core Recast build produced polygons\");\n rcFreeCompactHeightfield(chf);\n rcFreeContourSet(cset);\n\n for (int i = 0; i < pmesh->npolys; ++i)\n pmesh->flags[i] = (pmesh->areas[i] == RC_WALKABLE_AREA) ? 1 : 0;\n\n dtNavMeshCreateParams params{};\n params.verts = pmesh->verts;\n params.vertCount = pmesh->nverts;\n params.polys = pmesh->polys;\n params.polyFlags = pmesh->flags;\n params.polyAreas = pmesh->areas;\n params.polyCount = pmesh->npolys;\n params.nvp = pmesh->nvp;\n params.detailMeshes = dmesh->meshes;\n params.detailVerts = dmesh->verts;\n params.detailVertsCount = dmesh->nverts;\n params.detailTris = dmesh->tris;\n params.detailTriCount = dmesh->ntris;\n params.walkableHeight = kAgentHeight;\n params.walkableRadius = kAgentRadius;\n params.walkableClimb = kAgentClimb;\n rcVcopy(params.bmin, pmesh->bmin);\n rcVcopy(params.bmax, pmesh->bmax);\n params.cs = cfg.cs;\n params.ch = cfg.ch;\n params.buildBvTree = true;\n\n unsigned char* navData = nullptr;\n int navDataSize = 0;\n check(dtCreateNavMeshData(¶ms, &navData, &navDataSize), \"dtCreateNavMeshData\");\n\n dtNavMesh* nav = dtAllocNavMesh();\n check(nav != nullptr, \"dtAllocNavMesh\");\n check(dtStatusSucceed(nav->init(navData, navDataSize, DT_TILE_FREE_DATA)), \"dtNavMesh::init\");\n dtNavMeshQuery* query = dtAllocNavMeshQuery();\n check(dtStatusSucceed(query->init(nav, 2048)), \"dtNavMeshQuery::init\");\n\n const float ext[3] = { 2.0f, 4.0f, 2.0f };\n dtQueryFilter filter;\n filter.setIncludeFlags(1);\n filter.setExcludeFlags(0);\n\n // ══ crowd ══════════════════════════════════════════════════════════════\n dtCrowd crowd;\n check(crowd.init(4, kAgentRadius, nav), \"dtCrowd::init\");\n\n dtCrowdAgentParams agentParams{};\n agentParams.radius = kAgentRadius;\n agentParams.height = kAgentHeight;\n agentParams.maxAcceleration = 8.0f;\n agentParams.maxSpeed = 3.5f;\n agentParams.collisionQueryRange = kAgentRadius * 12.0f;\n agentParams.pathOptimizationRange = kAgentRadius * 30.0f;\n agentParams.updateFlags = DT_CROWD_ANTICIPATE_TURNS | DT_CROWD_OBSTACLE_AVOIDANCE | DT_CROWD_OPTIMIZE_VIS;\n agentParams.obstacleAvoidanceType = 3;\n agentParams.separationWeight = 2.0f;\n\n const float start[3] = { 2.0f, 0.2f, 2.0f };\n const float goal[3] = { 18.0f, 0.2f, 18.0f };\n const int agent = crowd.addAgent(start, &agentParams);\n check(agent >= 0, \"crowd.addAgent\");\n\n dtPolyRef goalRef = 0;\n float goalNear[3] = { 0, 0, 0 };\n check(dtStatusSucceed(query->findNearestPoly(goal, ext, &filter, &goalRef, goalNear)), \"findNearestPoly(goal)\");\n check(crowd.requestMoveTarget(agent, goalRef, goalNear), \"crowd.requestMoveTarget\");\n\n const float distanceBefore = dtVdist(start, goal);\n for (int i = 0; i < 400; ++i) crowd.update(1.0f / 60.0f, nullptr);\n const dtCrowdAgent* moved = crowd.getAgent(agent);\n check(moved != nullptr, \"crowd agent survives 400 updates\");\n if (moved) {\n const float distanceAfter = dtVdist(moved->npos, goal);\n std::println(\"crowd: {} -> {} from goal (state {})\", distanceBefore, distanceAfter, moved->state);\n // dtCrowd 在这里约 6.7 秒的模拟时间内完成引导;一步都没有积分过的\n // agent 会仍然待在起点。\n check(distanceAfter < distanceBefore - 5.0f, \"crowd agent actually walked toward the goal\");\n }\n\n // ══ tilecache ══════════════════════════════════════════════════════════\n // rcBuildHeightfieldLayers 需要可走区域外有一圈不可通行的边界,这就是\n // 为什么这里是第二次、单独配置的栅格化,而不是复用上面的 compact\n // heightfield。\n rcConfig tcfg;\n fillConfig(tcfg, 0.5f);\n const int tileSizeVoxels = 40; // 一块 tile 盖住整个地面\n tcfg.walkableRadius = static_cast(std::ceil(kAgentRadius / tcfg.cs));\n tcfg.tileSize = tileSizeVoxels;\n tcfg.borderSize = tcfg.walkableRadius + 3;\n const float tileWorldSize = static_cast(tileSizeVoxels) * tcfg.cs;\n tcfg.bmin[0] -= tcfg.borderSize * tcfg.cs;\n tcfg.bmin[2] -= tcfg.borderSize * tcfg.cs;\n tcfg.bmax[0] += tcfg.borderSize * tcfg.cs;\n tcfg.bmax[2] += tcfg.borderSize * tcfg.cs;\n rcCalcGridSize(tcfg.bmin, tcfg.bmax, tcfg.cs, &tcfg.width, &tcfg.height);\n\n rcCompactHeightfield* tchf = buildCompact(ctx, tcfg);\n check(tchf != nullptr, \"tiled compact heightfield\");\n rcErodeWalkableArea(&ctx, tcfg.walkableRadius, *tchf);\n\n rcHeightfieldLayerSet* lset = rcAllocHeightfieldLayerSet();\n check(rcBuildHeightfieldLayers(&ctx, *tchf, tcfg.borderSize, tcfg.walkableHeight, *lset),\n \"rcBuildHeightfieldLayers\");\n std::println(\"heightfield layers: {}\", lset->nlayers);\n check(lset->nlayers > 0, \"heightfield layers were built\");\n rcFreeCompactHeightfield(tchf);\n\n dtNavMesh* tiledNav = dtAllocNavMesh();\n dtNavMeshParams navParams{};\n rcVcopy(navParams.orig, cfg.bmin);\n navParams.tileWidth = tileWorldSize;\n navParams.tileHeight = tileWorldSize;\n navParams.maxTiles = 8;\n navParams.maxPolys = 128;\n check(dtStatusSucceed(tiledNav->init(&navParams)), \"tiled navmesh init\");\n\n PassthroughCompressor compressor;\n FlagWalkable meshProcess;\n dtTileCacheAlloc tileAlloc;\n dtTileCache tileCache;\n\n dtTileCacheParams tcParams{};\n rcVcopy(tcParams.orig, cfg.bmin);\n tcParams.cs = tcfg.cs;\n tcParams.ch = tcfg.ch;\n tcParams.width = tileSizeVoxels;\n tcParams.height = tileSizeVoxels;\n tcParams.walkableHeight = kAgentHeight;\n tcParams.walkableRadius = kAgentRadius;\n tcParams.walkableClimb = kAgentClimb;\n tcParams.maxSimplificationError = tcfg.maxSimplificationError;\n tcParams.maxTiles = 8;\n tcParams.maxObstacles = 32;\n check(dtStatusSucceed(tileCache.init(&tcParams, &tileAlloc, &compressor, &meshProcess)), \"dtTileCache::init\");\n\n const rcHeightfieldLayer* rlayer = &lset->layers[0];\n dtTileCacheLayerHeader header{};\n header.magic = DT_TILECACHE_MAGIC;\n header.version = DT_TILECACHE_VERSION;\n header.tx = 0;\n header.ty = 0;\n header.tlayer = 0;\n rcVcopy(header.bmin, rlayer->bmin);\n rcVcopy(header.bmax, rlayer->bmax);\n header.width = static_cast(rlayer->width);\n header.height = static_cast(rlayer->height);\n header.minx = static_cast(rlayer->minx);\n header.maxx = static_cast(rlayer->maxx);\n header.miny = static_cast(rlayer->miny);\n header.maxy = static_cast(rlayer->maxy);\n header.hmin = static_cast(rlayer->hmin);\n header.hmax = static_cast(rlayer->hmax);\n\n // DetourTileCache 的 builder 半边,在同一层上端到端驱动一遍。\n dtTileCacheLayer layer{};\n layer.header = &header;\n layer.heights = rlayer->heights;\n layer.areas = rlayer->areas;\n layer.cons = rlayer->cons;\n layer.regs = static_cast(dtAlloc(rlayer->width * rlayer->height, DT_ALLOC_TEMP));\n std::memset(layer.regs, 0xff, static_cast(rlayer->width) * rlayer->height);\n\n check(dtStatusSucceed(dtBuildTileCacheRegions(&tileAlloc, layer, tcfg.walkableClimb)),\n \"dtBuildTileCacheRegions\");\n // dtFreeTileCacheContourSet / dtFreeTileCachePolyMesh 连容器本身带载荷\n // 一起释放,所以两者都必须用配套的 dtAlloc* 辅助函数从堆上取——\n // 释放栈上对象会直接崩。\n dtTileCacheContourSet* contours = dtAllocTileCacheContourSet(&tileAlloc);\n check(contours != nullptr, \"dtAllocTileCacheContourSet\");\n check(dtStatusSucceed(dtBuildTileCacheContours(&tileAlloc, layer, tcfg.walkableClimb,\n tcfg.maxSimplificationError, *contours)),\n \"dtBuildTileCacheContours\");\n dtTileCachePolyMesh* layerMesh = dtAllocTileCachePolyMesh(&tileAlloc);\n check(layerMesh != nullptr, \"dtAllocTileCachePolyMesh\");\n check(dtStatusSucceed(dtBuildTileCachePolyMesh(&tileAlloc, *contours, *layerMesh)), \"dtBuildTileCachePolyMesh\");\n std::println(\"tile cache poly mesh: {} polys\", layerMesh->npolys);\n check(layerMesh->npolys > 0, \"tile cache builder produced polygons\");\n dtFreeTileCacheContourSet(&tileAlloc, contours);\n dtFreeTileCachePolyMesh(&tileAlloc, layerMesh);\n dtFree(layer.regs);\n\n // 缓存半边:压缩这一层,交给 dtTileCache,再让它从中物化出一块\n // 导航网格 tile。\n unsigned char* tileData = nullptr;\n int tileDataSize = 0;\n check(dtStatusSucceed(dtBuildTileCacheLayer(&compressor, &header, rlayer->heights, rlayer->areas,\n rlayer->cons, &tileData, &tileDataSize)),\n \"dtBuildTileCacheLayer\");\n dtCompressedTileRef tileRef = 0;\n check(dtStatusSucceed(tileCache.addTile(tileData, tileDataSize, DT_COMPRESSEDTILE_FREE_DATA, &tileRef)),\n \"dtTileCache::addTile\");\n check(tileRef != 0, \"addTile returned a reference\");\n check(dtStatusSucceed(tileCache.buildNavMeshTile(tileRef, tiledNav)), \"dtTileCache::buildNavMeshTile\");\n\n const dtMeshTile* built = tiledNav->getTileAt(0, 0, 0);\n check(built != nullptr, \"the tile cache materialized a navmesh tile\");\n if (built && built->header) std::println(\"cached tile polys: {}\", built->header->polyCount);\n check(built != nullptr && built->header != nullptr && built->header->polyCount > 0,\n \"that tile holds polygons\");\n\n // ……以及它是可用的,而不只是存在:多边形 flags 为 0 的网格照样有\n // 几何,却什么查询都答不了。\n dtNavMeshQuery* tiledQuery = dtAllocNavMeshQuery();\n check(dtStatusSucceed(tiledQuery->init(tiledNav, 128)), \"tiled query init\");\n dtPolyRef tiledRef = 0;\n float tiledNear[3] = { 0, 0, 0 };\n const float middle[3] = { 10.0f, 0.2f, 10.0f };\n check(dtStatusSucceed(tiledQuery->findNearestPoly(middle, ext, &filter, &tiledRef, tiledNear)),\n \"findNearestPoly on the cached tile\");\n check(tiledRef != 0, \"the cached tile answers queries\");\n dtFreeNavMeshQuery(tiledQuery);\n\n // ══ debug-utils ════════════════════════════════════════════════════════\n RecordingDraw draw;\n duDebugDrawNavMesh(&draw, *nav, 0);\n std::println(\"debug draw: {} begins ({} triangle batches), {} vertices\",\n draw.begins, draw.triBatches, draw.vertices);\n check(draw.vertices > 0, \"DebugUtils emitted vertices for the navmesh\");\n check(draw.triBatches > 0, \"DebugUtils emitted triangle batches\");\n\n dtFreeNavMesh(tiledNav);\n dtFreeNavMeshQuery(query);\n dtFreeNavMesh(nav);\n rcFreeHeightfieldLayerSet(lset);\n rcFreePolyMesh(pmesh);\n rcFreePolyMeshDetail(dmesh);\n\n std::println(\"compat.recastnavigation feature test: {}\", ok ? \"ok\" : \"FAILED\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "recastnavigation", + "text": "# recastnavigation 测试工程:用 Recast 烘一张导航网格,用 Detour 在上面\n# 走路,并对路径做断言。mcpp-index 自引用 workspace 的一员——workspace 根\n# 的 [indices] 重定向指向本仓库,所以 compat.recastnavigation 解析到签入的\n# 描述符(pkgs/c/compat.recastnavigation.lua)。\n#\n# 地面是 20x20、中间挖穿一个 4x4 洞的正方形,因此从 (2,2) 到 (18,18) 的\n# 路线不可能是一条直线段。「包坏了但测试依然绿」的情况有三种,这里逐项\n# 防住:\n#\n# * 栅格化出空网格 -> 对 nverts/npolys 做断言\n# * 网格是一整块大四方形 -> 直线路径必须拐弯,洞若被构建器悄悄填平,\n# 就在这里失败\n# * Detour 没被真正链接 -> findPath / findStraightPath 都被调用,\n# 缺库在链接期就失败,根本到不了这里\n#\n# 本成员只测核心构建(Recast + Detour);`crowd`、`tilecache`、\n# `debug-utils` 三个 feature 由 tests/examples/recastnavigation-features\n# 覆盖。\n[package]\nname = \"recastnavigation-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nrecastnavigation = \"1.6.0\"\n", + "files": [ + { + "path": "tests/examples/recastnavigation/tests/navmesh_path.cpp", + "text": "// compat.recastnavigation——烘一张导航网格,在上面走路,对路径做断言。\n//\n// 地面是 20x20、中间挖穿一个 4x4 洞的正方形,所以从 (2,2) 到 (18,18) 的\n// 路线不可能是一条直线段。「包坏了但测试依然绿」的情况有三种,每一种\n// 都被单独防住:\n//\n// * 网格栅格化成了空集 -> 对 nverts/npolys 做断言\n// * 网格是一整块大四方形 -> 直线路径必须拐弯,洞若被构建器悄悄填平,\n// 就在这里失败\n// * Detour 没被真正链接 -> findPath / findStraightPath 都被调用,\n// 缺库在链接期就失败,根本到不了这里\n//\n// 两种 include 拼写是有意混用的:Recast 走生成的 `recastnavigation/`\n// 前缀,Detour 走裸的 ``。上游的 CMake 安装把两条路径\n// 都放到接口 include path 上;若只有一种能用,shim 层就是没被测到的。\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nimport std;\n\nnamespace {\n\nconstexpr float kSize = 20.0f; // 地面边长(x 与 z)\nconstexpr float kHoleLo = 8.0f; // 洞在两根轴上都横跨 [8,12]\nconstexpr float kHoleHi = 12.0f;\n\nconstexpr float kAgentHeight = 2.0f;\nconstexpr float kAgentRadius = 0.6f;\nconstexpr float kAgentClimb = 0.9f;\n\nstruct Quad { float ax, az, bx, bz, cx, cz, dx, dz; };\n\n// 四个四边形拼成环形。俯视(沿 -y 轴往下看)时角点顺序为顺时针——这正是\n// rcCalcTriNormal 产出朝上法线的原因;rcMarkWalkableTriangles 测试的是\n// `norm[1] > threshold`,绕序反了不会报错,只会静默得到一张空网格。\nconstexpr Quad kQuads[] = {\n { 0, 0, 0, kHoleLo, kSize, kHoleLo, kSize, 0 }, // 南侧条带\n { 0, kHoleHi, 0, kSize, kSize, kSize, kSize, kHoleHi }, // 北侧条带\n { 0, kHoleLo, 0, kHoleHi, kHoleLo, kHoleHi, kHoleLo, kHoleLo }, // 西侧条带\n { kHoleHi, kHoleLo, kHoleHi, kHoleHi, kSize, kHoleHi, kSize, kHoleLo }, // 东侧条带\n};\n\nstruct FloorMesh {\n std::vector verts;\n std::vector tris;\n\n FloorMesh() {\n for (const Quad& q : kQuads) {\n const int base = static_cast(verts.size()) / 3;\n const float corners[4][2] = {\n { q.ax, q.az }, { q.bx, q.bz }, { q.cx, q.cz }, { q.dx, q.dz }\n };\n for (const auto& c : corners) {\n verts.push_back(c[0]);\n verts.push_back(0.0f);\n verts.push_back(c[1]);\n }\n tris.insert(tris.end(), { base + 0, base + 1, base + 2 });\n tris.insert(tris.end(), { base + 0, base + 2, base + 3 });\n }\n }\n\n int vertCount() const { return static_cast(verts.size()) / 3; }\n int triCount() const { return static_cast(tris.size()) / 3; }\n};\n\n} // namespace\n\nint main() {\n bool ok = true;\n const auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n const FloorMesh floor;\n std::println(\"geometry: {} verts, {} tris\", floor.vertCount(), floor.triCount());\n\n rcContext ctx;\n\n rcConfig cfg{};\n cfg.cs = 0.3f;\n cfg.ch = 0.2f;\n cfg.walkableSlopeAngle = 45.0f;\n cfg.walkableHeight = static_cast(std::ceil(kAgentHeight / cfg.ch));\n cfg.walkableClimb = static_cast(std::floor(kAgentClimb / cfg.ch));\n cfg.walkableRadius = static_cast(std::ceil(kAgentRadius / cfg.cs));\n cfg.maxEdgeLen = static_cast(12.0f / cfg.cs);\n cfg.maxSimplificationError = 1.3f;\n cfg.minRegionArea = static_cast(rcSqr(8.0f));\n cfg.mergeRegionArea = static_cast(rcSqr(20.0f));\n cfg.maxVertsPerPoly = 6;\n cfg.detailSampleDist = cfg.cs * 6.0f;\n cfg.detailSampleMaxError = cfg.ch * 1.0f;\n cfg.bmin[0] = 0.0f; cfg.bmin[1] = 0.0f; cfg.bmin[2] = 0.0f;\n cfg.bmax[0] = kSize; cfg.bmax[1] = 0.0f; cfg.bmax[2] = kSize;\n rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height);\n std::println(\"grid: {}x{}\", cfg.width, cfg.height);\n\n // ── Recast:原始三角形 -> 多边形网格 ──────────────────────────────────\n rcHeightfield* solid = rcAllocHeightfield();\n check(solid != nullptr, \"rcAllocHeightfield\");\n check(rcCreateHeightfield(&ctx, *solid, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch),\n \"rcCreateHeightfield\");\n\n std::vector triareas(static_cast(floor.triCount()), 0);\n rcMarkWalkableTriangles(&ctx, cfg.walkableSlopeAngle, floor.verts.data(), floor.vertCount(),\n floor.tris.data(), floor.triCount(), triareas.data());\n const int walkableTris = static_cast(std::ranges::count(triareas, RC_WALKABLE_AREA));\n std::println(\"walkable triangles: {} / {}\", walkableTris, floor.triCount());\n check(walkableTris == floor.triCount(), \"every floor triangle is walkable (winding gives +y normals)\");\n\n check(rcRasterizeTriangles(&ctx, floor.verts.data(), floor.vertCount(), floor.tris.data(),\n triareas.data(), floor.triCount(), *solid, cfg.walkableClimb),\n \"rcRasterizeTriangles\");\n rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *solid);\n rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid);\n rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *solid);\n\n rcCompactHeightfield* chf = rcAllocCompactHeightfield();\n check(rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *solid, *chf),\n \"rcBuildCompactHeightfield\");\n rcFreeHeightField(solid);\n\n check(rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf), \"rcErodeWalkableArea\");\n check(rcBuildDistanceField(&ctx, *chf), \"rcBuildDistanceField\");\n check(rcBuildRegions(&ctx, *chf, 0, cfg.minRegionArea, cfg.mergeRegionArea), \"rcBuildRegions\");\n\n rcContourSet* cset = rcAllocContourSet();\n check(rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cset), \"rcBuildContours\");\n\n rcPolyMesh* pmesh = rcAllocPolyMesh();\n check(rcBuildPolyMesh(&ctx, *cset, cfg.maxVertsPerPoly, *pmesh), \"rcBuildPolyMesh\");\n std::println(\"poly mesh: {} verts, {} polys\", pmesh->nverts, pmesh->npolys);\n check(pmesh->nverts > 0, \"Recast produced vertices\");\n check(pmesh->npolys > 0, \"Recast produced polygons\");\n\n rcPolyMeshDetail* dmesh = rcAllocPolyMeshDetail();\n check(rcBuildPolyMeshDetail(&ctx, *pmesh, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dmesh),\n \"rcBuildPolyMeshDetail\");\n rcFreeCompactHeightfield(chf);\n rcFreeContourSet(cset);\n\n // ── Detour:多边形网格 -> 可查询的导航网格 ────────────────────────────\n // Detour 只会走带查询过滤器所含 flag 的多边形。\n for (int i = 0; i < pmesh->npolys; ++i)\n pmesh->flags[i] = (pmesh->areas[i] == RC_WALKABLE_AREA) ? 1 : 0;\n\n dtNavMeshCreateParams params{};\n params.verts = pmesh->verts;\n params.vertCount = pmesh->nverts;\n params.polys = pmesh->polys;\n params.polyFlags = pmesh->flags;\n params.polyAreas = pmesh->areas;\n params.polyCount = pmesh->npolys;\n params.nvp = pmesh->nvp;\n params.detailMeshes = dmesh->meshes;\n params.detailVerts = dmesh->verts;\n params.detailVertsCount = dmesh->nverts;\n params.detailTris = dmesh->tris;\n params.detailTriCount = dmesh->ntris;\n params.walkableHeight = kAgentHeight;\n params.walkableRadius = kAgentRadius;\n params.walkableClimb = kAgentClimb;\n rcVcopy(params.bmin, pmesh->bmin);\n rcVcopy(params.bmax, pmesh->bmax);\n params.cs = cfg.cs;\n params.ch = cfg.ch;\n params.buildBvTree = true;\n\n unsigned char* navData = nullptr;\n int navDataSize = 0;\n check(dtCreateNavMeshData(¶ms, &navData, &navDataSize), \"dtCreateNavMeshData\");\n\n dtNavMesh* nav = dtAllocNavMesh();\n check(nav != nullptr, \"dtAllocNavMesh\");\n check(dtStatusSucceed(nav->init(navData, navDataSize, DT_TILE_FREE_DATA)), \"dtNavMesh::init\");\n\n dtNavMeshQuery* query = dtAllocNavMeshQuery();\n check(query != nullptr, \"dtAllocNavMeshQuery\");\n check(dtStatusSucceed(query->init(nav, 2048)), \"dtNavMeshQuery::init\");\n\n dtQueryFilter filter;\n filter.setIncludeFlags(1);\n filter.setExcludeFlags(0);\n\n const float ext[3] = { 2.0f, 4.0f, 2.0f };\n const float start[3] = { 2.0f, 0.0f, 2.0f };\n const float goal[3] = { 18.0f, 0.0f, 18.0f };\n\n dtPolyRef startRef = 0, endRef = 0;\n float startNear[3] = { 0, 0, 0 }, endNear[3] = { 0, 0, 0 };\n check(dtStatusSucceed(query->findNearestPoly(start, ext, &filter, &startRef, startNear)), \"findNearestPoly(start)\");\n check(dtStatusSucceed(query->findNearestPoly(goal, ext, &filter, &endRef, endNear)), \"findNearestPoly(goal)\");\n check(startRef != 0, \"start polygon found\");\n check(endRef != 0, \"goal polygon found\");\n\n std::vector corridor(256);\n int corridorLen = 0;\n check(dtStatusSucceed(query->findPath(startRef, endRef, startNear, endNear, &filter,\n corridor.data(), &corridorLen, static_cast(corridor.size()))),\n \"findPath\");\n std::println(\"path corridor: {} polys\", corridorLen);\n check(corridorLen > 0, \"findPath returned a corridor\");\n\n std::vector straight(256 * 3);\n std::vector straightFlags(256);\n std::vector straightRefs(256);\n int straightLen = 0;\n check(dtStatusSucceed(query->findStraightPath(startNear, endNear, corridor.data(), corridorLen,\n straight.data(), straightFlags.data(), straightRefs.data(),\n &straightLen, 256)),\n \"findStraightPath\");\n std::println(\"straight path: {} points\", straightLen);\n\n check(straightLen >= 2, \"straight path has a start and an end\");\n // 洞挡住了直对角线,所以最短路径必须拐弯。只有两个点意味着查询笔直地\n // 穿过了洞——即洞没进网格,或者网格是一整块大四方形。\n check(straightLen >= 3, \"path bends around the hole (>= 3 straight points)\");\n\n if (straightLen >= 2) {\n const float* last = &straight[(straightLen - 1) * 3];\n const float gap = dtVdist(last, goal);\n std::println(\"endpoint distance to goal: {:.3f}\", gap);\n check(gap < 2.0f, \"path ends at the goal\");\n\n float walked = 0.0f;\n for (int i = 1; i < straightLen; ++i)\n walked += dtVdist(&straight[(i - 1) * 3], &straight[i * 3]);\n const float direct = dtVdist(start, goal);\n std::println(\"path length {:.3f} vs direct {:.3f}\", walked, direct);\n check(walked > direct + 0.5f, \"path is longer than the direct line (it went around)\");\n }\n\n dtFreeNavMeshQuery(query);\n dtFreeNavMesh(nav);\n rcFreePolyMesh(pmesh);\n rcFreePolyMeshDetail(dmesh);\n\n std::println(\"compat.recastnavigation smoke test: {}\", ok ? \"ok\" : \"FAILED\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "redis-plus-plus-async", + "text": "# redis-plus-plus ASYNC test project: consumes compat.redis-plus-plus with the\n# `async` feature (which pulls compat.libuv) and asserts the libuv-backed async\n# interface under `mcpp test`. Part of the mcpp-index self-referential\n# workspace — the workspace-root `[indices]` redirect points at this repo.\n#\n# The test is entirely OFFLINE: same minimal RESP server on loopback sockets as\n# the sync members, driven through sw::redis::AsyncRedis (which runs the libuv\n# event loop on a background thread). PING + SET are fired back-to-back, so the\n# server parses real RESP arrays — the async client may pipeline both commands\n# into one TCP segment.\n[package]\nname = \"redis-plus-plus-async-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nredis-plus-plus = { version = \"1.3.13\", features = [\"async\"] }\n", + "files": [ + { + "path": "tests/examples/redis-plus-plus-async/tests/redis_test.cpp", + "text": "// redis_test.cpp — offline behavioral test for the `async` feature of\n// compat.redis-plus-plus (libuv-backed AsyncRedis).\n//\n// No redis-server binary and no network access: a minimal RESP server runs\n// inside the process (raw loopback sockets, same platform abstraction as the\n// websocket member) and the sw::redis::AsyncRedis client is driven against it.\n// The async client can pipeline PING + SET into a single TCP segment, so the\n// server parses complete RESP arrays instead of scanning for keywords.\n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n// ── Platform socket abstraction (same as tests/examples/websocket) ─────────\n\n#ifdef _WIN32\n#include \n#include \nusing SockType = SOCKET;\nconstexpr SockType kInvalidSocket = INVALID_SOCKET;\nusing AddrLenType = int;\n#else\n#include \n#include \n#include \n#include \n#include \nusing SockType = int;\nconstexpr SockType kInvalidSocket = -1;\nusing AddrLenType = socklen_t;\n#endif\n\nvoid init_sockets()\n{\n#ifdef _WIN32\n WSADATA wsa;\n WSAStartup(MAKEWORD(2, 2), &wsa);\n#endif\n}\n\nvoid close_socket(SockType s)\n{\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n}\n\nvoid set_recv_timeout(SockType s, int ms)\n{\n#ifdef _WIN32\n DWORD t = static_cast(ms);\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&t), sizeof(t));\n#else\n timeval tv{};\n tv.tv_sec = ms / 1000;\n tv.tv_usec = (ms % 1000) * 1000;\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));\n#endif\n}\n\n// ── Minimal RESP server: PING -> +PONG, SET -> +OK ─────────────────────────\n// Parses complete RESP arrays so pipelined commands (async client) are served\n// one reply each instead of one reply per recv().\nclass RespServer\n{\npublic:\n ~RespServer()\n {\n if (client != kInvalidSocket) close_socket(client);\n if (listener != kInvalidSocket) close_socket(listener);\n if (thread.joinable()) thread.join();\n }\n\n bool start()\n {\n init_sockets();\n listener = socket(AF_INET, SOCK_STREAM, 0);\n if (listener == kInvalidSocket) return false;\n int one = 1;\n#ifdef _WIN32\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,\n reinterpret_cast(&one), sizeof(one));\n#else\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));\n#endif\n sockaddr_in addr{};\n addr.sin_family = AF_INET;\n addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n addr.sin_port = 0; // ephemeral\n if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0) return false;\n AddrLenType alen = sizeof(addr);\n getsockname(listener, reinterpret_cast(&addr), &alen);\n port = ntohs(addr.sin_port);\n if (listen(listener, 1) != 0) return false;\n thread = std::thread([this] { run(); });\n return true;\n }\n\n int port = 0;\n\nprivate:\n void run()\n {\n SockType s = accept(listener, nullptr, nullptr);\n if (s == kInvalidSocket) return;\n client = s;\n set_recv_timeout(client, 5000);\n\n std::string buf;\n int served = 0;\n while (served < 16) {\n char tmp[1024];\n const int n = static_cast(recv(client, tmp, sizeof(tmp), 0));\n if (n <= 0) break;\n buf.append(tmp, static_cast(n));\n\n size_t off = 0;\n while (served < 16) {\n // `*\\r\\n` then `` bulk strings `$\\r\\n\\r\\n`.\n if (buf.size() < off + 3 || buf[off] != '*') break;\n const size_t nl = buf.find(\"\\r\\n\", off);\n if (nl == std::string::npos) break;\n const int count = std::atoi(buf.substr(off + 1, nl - off - 1).c_str());\n off = nl + 2;\n std::string cmd;\n bool complete = true;\n for (int i = 0; i < count; ++i) {\n if (buf.size() < off + 1 || buf[off] != '$') { complete = false; break; }\n const size_t nl2 = buf.find(\"\\r\\n\", off);\n if (nl2 == std::string::npos) { complete = false; break; }\n const int len = std::atoi(buf.substr(off + 1, nl2 - off - 1).c_str());\n off = nl2 + 2;\n if (buf.size() < off + static_cast(len) + 2) { complete = false; break; }\n if (i == 0) cmd.assign(buf, off, static_cast(len));\n off += static_cast(len) + 2;\n }\n if (!complete) break;\n buf.erase(0, off);\n off = 0;\n\n if (cmd == \"PING\") {\n static constexpr char kPong[] = \"+PONG\\r\\n\";\n send(client, kPong, static_cast(sizeof(kPong) - 1), 0);\n } else if (cmd == \"SET\") {\n static constexpr char kOk[] = \"+OK\\r\\n\";\n send(client, kOk, static_cast(sizeof(kOk) - 1), 0);\n }\n ++served;\n }\n }\n }\n\n SockType listener = kInvalidSocket;\n SockType client = kInvalidSocket;\n std::thread thread;\n};\n\nint main()\n{\n RespServer server;\n if (!server.start()) {\n std::cerr << \"failed to start RESP server\\n\";\n return 1;\n }\n\n sw::redis::ConnectionOptions opts;\n opts.host = \"127.0.0.1\";\n opts.port = server.port;\n opts.connect_timeout = std::chrono::milliseconds(3000);\n opts.socket_timeout = std::chrono::milliseconds(3000);\n\n sw::redis::ConnectionPoolOptions pool_opts;\n pool_opts.size = 1;\n\n try {\n sw::redis::AsyncRedis redis(opts, pool_opts);\n\n // Fire both commands back-to-back; the libuv loop runs on a background\n // thread and the server may see them pipelined in one segment.\n auto ping_res = redis.ping();\n auto set_res = redis.set(\"mcpp\", \"async\");\n\n const std::string pong = ping_res.get();\n if (pong != \"PONG\") {\n std::cerr << \"unexpected async PING reply: '\" << pong << \"'\\n\";\n return 2;\n }\n if (!set_res.get()) {\n std::cerr << \"async SET did not return OK\\n\";\n return 3;\n }\n\n std::cout << \"OK: async PING -> \" << pong << \", SET -> OK\\n\";\n return 0;\n } catch (const sw::redis::Error &e) {\n std::cerr << \"redis error: \" << e.what() << \"\\n\";\n return 4;\n }\n}\n" + } + ] + }, + { + "project": "redis-plus-plus-v133", + "text": "# redis-plus-plus v1.3.3 test project — the PRE-watershed structure: 15 sync\n# TUs (no redis_uri.cpp / patterns/redlock.cpp, no hiredis_features.h, flat\n# no_tls/tls.h). This is the member that proves the single-package union holds:\n# at 1.3.3 exactly two source globs match nothing (a warning, not an error) and\n# the build must still link and pass the same offline RESP assertions as the\n# 1.3.13 member (tests/examples/redis-plus-plus).\n#\n# The `compat` index redirect is inherited from the workspace root.\n# Part of the mcpp-index self-referential workspace.\n[package]\nname = \"redis-plus-plus-v133-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nredis-plus-plus = \"1.3.3\"\n\n# 1.3.3's utils.h uses uint16_t without including (fixed upstream in\n# 1.3.6+); under the vendored gcc/libstdc++ sysroot the test TU (which includes\n# first) needs the same force-include the package uses.\n[build]\ncxxflags = [\"-include\", \"cstdint\"]\n", + "files": [ + { + "path": "tests/examples/redis-plus-plus-v133/tests/redis_test.cpp", + "text": "// redis_test.cpp — offline behavioral test for compat.redis-plus-plus.\n//\n// No redis-server binary and no network access: this test runs a minimal RESP\n// server inside the process (raw loopback sockets, same platform abstraction\n// as the websocket member) and drives the sw::redis::Redis sync client against\n// it. That exercises the full stack the package must get right:\n// * both static libs actually link (redis++ + hiredis),\n// * hiredis connects and speaks the RESP protocol (PING -> +PONG),\n// * redis++ formats commands (SET -> +OK) and parses replies back to C++.\n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n// ── Platform socket abstraction (same as tests/examples/websocket) ─────────\n\n#ifdef _WIN32\n#include \n#include \nusing SockType = SOCKET;\nconstexpr SockType kInvalidSocket = INVALID_SOCKET;\nusing AddrLenType = int;\n#else\n#include \n#include \n#include \n#include \n#include \nusing SockType = int;\nconstexpr SockType kInvalidSocket = -1;\nusing AddrLenType = socklen_t;\n#endif\n\nvoid init_sockets()\n{\n#ifdef _WIN32\n WSADATA wsa;\n WSAStartup(MAKEWORD(2, 2), &wsa);\n#endif\n}\n\nvoid close_socket(SockType s)\n{\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n}\n\n// Safety net: any read that stalls this long makes the server thread return so\n// the test fails cleanly instead of hanging the process (and, on CI, a runner).\nvoid set_recv_timeout(SockType s, int ms)\n{\n#ifdef _WIN32\n DWORD t = static_cast(ms);\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&t), sizeof(t));\n#else\n timeval tv{};\n tv.tv_sec = ms / 1000;\n tv.tv_usec = (ms % 1000) * 1000;\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));\n#endif\n}\n\n// ── Minimal RESP server: answers PING with +PONG, SET with +OK ─────────────\n\nclass RespServer\n{\npublic:\n ~RespServer()\n {\n if (client != kInvalidSocket) close_socket(client);\n if (listener != kInvalidSocket) close_socket(listener);\n if (thread.joinable()) thread.join();\n }\n\n bool start()\n {\n init_sockets();\n listener = socket(AF_INET, SOCK_STREAM, 0);\n if (listener == kInvalidSocket) return false;\n int one = 1;\n#ifdef _WIN32\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,\n reinterpret_cast(&one), sizeof(one));\n#else\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));\n#endif\n sockaddr_in addr{};\n addr.sin_family = AF_INET;\n addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n addr.sin_port = 0; // ephemeral\n if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0) return false;\n AddrLenType alen = sizeof(addr);\n getsockname(listener, reinterpret_cast(&addr), &alen);\n port = ntohs(addr.sin_port);\n if (listen(listener, 1) != 0) return false;\n thread = std::thread([this] { run(); });\n return true;\n }\n\n int port = 0;\n\nprivate:\n void run()\n {\n SockType s = accept(listener, nullptr, nullptr);\n if (s == kInvalidSocket) return;\n client = s;\n set_recv_timeout(client, 5000);\n\n char buf[1024];\n int served = 0;\n // The client keeps the connection open for the whole test; serve a\n // handful of commands, then stop so the client's next read fails\n // cleanly instead of hanging.\n while (served < 8) {\n const int n = static_cast(recv(client, buf, sizeof(buf) - 1, 0));\n if (n <= 0) break;\n const std::string_view req(buf, static_cast(n));\n if (req.find(\"PING\") != std::string_view::npos) {\n static constexpr char kPong[] = \"+PONG\\r\\n\";\n send(client, kPong, static_cast(sizeof(kPong) - 1), 0);\n ++served;\n } else if (req.find(\"SET\") != std::string_view::npos) {\n static constexpr char kOk[] = \"+OK\\r\\n\";\n send(client, kOk, static_cast(sizeof(kOk) - 1), 0);\n ++served;\n }\n }\n }\n\n SockType listener = kInvalidSocket;\n SockType client = kInvalidSocket;\n std::thread thread;\n};\n\nint main()\n{\n RespServer server;\n if (!server.start()) {\n std::cerr << \"failed to start RESP server\\n\";\n return 1;\n }\n\n sw::redis::ConnectionOptions opts;\n opts.host = \"127.0.0.1\";\n opts.port = server.port;\n opts.connect_timeout = std::chrono::milliseconds(3000);\n opts.socket_timeout = std::chrono::milliseconds(3000);\n\n try {\n sw::redis::Redis redis(opts);\n\n const std::string pong = redis.ping();\n if (pong != \"PONG\") {\n std::cerr << \"unexpected PING reply: '\" << pong << \"'\\n\";\n return 2;\n }\n\n if (!redis.set(\"mcpp\", \"redis-plus-plus\")) {\n std::cerr << \"SET did not return OK\\n\";\n return 3;\n }\n\n std::cout << \"OK: PING -> \" << pong << \", SET -> OK\\n\";\n return 0;\n } catch (const sw::redis::Error &e) {\n std::cerr << \"redis error: \" << e.what() << \"\\n\";\n return 4;\n }\n}\n" + } + ] + }, + { + "project": "redis-plus-plus", + "text": "# redis-plus-plus test project: consumes compat.redis-plus-plus (and through it\n# compat.hiredis) and asserts behavior under `mcpp test`. Part of the\n# mcpp-index self-referential workspace — the workspace-root `[indices]`\n# redirect points at this repo, so the dependency resolves to the checked-in\n# recipe (pkgs/c/compat.redis-plus-plus.lua).\n#\n# The test is entirely OFFLINE and self-contained: it runs a minimal RESP\n# PING/SET server inside the test process (raw loopback sockets, same pattern\n# as the websocket member) and drives the sw::redis::Redis sync client against\n# it — a full protocol round trip with no redis-server binary and no network\n# access.\n[package]\nname = \"redis-plus-plus-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nredis-plus-plus = \"1.3.13\"\n", + "files": [ + { + "path": "tests/examples/redis-plus-plus/tests/redis_test.cpp", + "text": "// redis_test.cpp — offline behavioral test for compat.redis-plus-plus.\n//\n// No redis-server binary and no network access: this test runs a minimal RESP\n// server inside the process (raw loopback sockets, same platform abstraction\n// as the websocket member) and drives the sw::redis::Redis sync client against\n// it. That exercises the full stack the package must get right:\n// * both static libs actually link (redis++ + hiredis),\n// * hiredis connects and speaks the RESP protocol (PING -> +PONG),\n// * redis++ formats commands (SET -> +OK) and parses replies back to C++.\n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n// ── Platform socket abstraction (same as tests/examples/websocket) ─────────\n\n#ifdef _WIN32\n#include \n#include \nusing SockType = SOCKET;\nconstexpr SockType kInvalidSocket = INVALID_SOCKET;\nusing AddrLenType = int;\n#else\n#include \n#include \n#include \n#include \n#include \nusing SockType = int;\nconstexpr SockType kInvalidSocket = -1;\nusing AddrLenType = socklen_t;\n#endif\n\nvoid init_sockets()\n{\n#ifdef _WIN32\n WSADATA wsa;\n WSAStartup(MAKEWORD(2, 2), &wsa);\n#endif\n}\n\nvoid close_socket(SockType s)\n{\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n}\n\n// Safety net: any read that stalls this long makes the server thread return so\n// the test fails cleanly instead of hanging the process (and, on CI, a runner).\nvoid set_recv_timeout(SockType s, int ms)\n{\n#ifdef _WIN32\n DWORD t = static_cast(ms);\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&t), sizeof(t));\n#else\n timeval tv{};\n tv.tv_sec = ms / 1000;\n tv.tv_usec = (ms % 1000) * 1000;\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));\n#endif\n}\n\n// ── Minimal RESP server: answers PING with +PONG, SET with +OK ─────────────\n\nclass RespServer\n{\npublic:\n ~RespServer()\n {\n if (client != kInvalidSocket) close_socket(client);\n if (listener != kInvalidSocket) close_socket(listener);\n if (thread.joinable()) thread.join();\n }\n\n bool start()\n {\n init_sockets();\n listener = socket(AF_INET, SOCK_STREAM, 0);\n if (listener == kInvalidSocket) return false;\n int one = 1;\n#ifdef _WIN32\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,\n reinterpret_cast(&one), sizeof(one));\n#else\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));\n#endif\n sockaddr_in addr{};\n addr.sin_family = AF_INET;\n addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n addr.sin_port = 0; // ephemeral\n if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0) return false;\n AddrLenType alen = sizeof(addr);\n getsockname(listener, reinterpret_cast(&addr), &alen);\n port = ntohs(addr.sin_port);\n if (listen(listener, 1) != 0) return false;\n thread = std::thread([this] { run(); });\n return true;\n }\n\n int port = 0;\n\nprivate:\n void run()\n {\n SockType s = accept(listener, nullptr, nullptr);\n if (s == kInvalidSocket) return;\n client = s;\n set_recv_timeout(client, 5000);\n\n char buf[1024];\n int served = 0;\n // The client keeps the connection open for the whole test; serve a\n // handful of commands, then stop so the client's next read fails\n // cleanly instead of hanging.\n while (served < 8) {\n const int n = static_cast(recv(client, buf, sizeof(buf) - 1, 0));\n if (n <= 0) break;\n const std::string_view req(buf, static_cast(n));\n if (req.find(\"PING\") != std::string_view::npos) {\n static constexpr char kPong[] = \"+PONG\\r\\n\";\n send(client, kPong, static_cast(sizeof(kPong) - 1), 0);\n ++served;\n } else if (req.find(\"SET\") != std::string_view::npos) {\n static constexpr char kOk[] = \"+OK\\r\\n\";\n send(client, kOk, static_cast(sizeof(kOk) - 1), 0);\n ++served;\n }\n }\n }\n\n SockType listener = kInvalidSocket;\n SockType client = kInvalidSocket;\n std::thread thread;\n};\n\nint main()\n{\n RespServer server;\n if (!server.start()) {\n std::cerr << \"failed to start RESP server\\n\";\n return 1;\n }\n\n sw::redis::ConnectionOptions opts;\n opts.host = \"127.0.0.1\";\n opts.port = server.port;\n opts.connect_timeout = std::chrono::milliseconds(3000);\n opts.socket_timeout = std::chrono::milliseconds(3000);\n\n try {\n sw::redis::Redis redis(opts);\n\n const std::string pong = redis.ping();\n if (pong != \"PONG\") {\n std::cerr << \"unexpected PING reply: '\" << pong << \"'\\n\";\n return 2;\n }\n\n if (!redis.set(\"mcpp\", \"redis-plus-plus\")) {\n std::cerr << \"SET did not return OK\\n\";\n return 3;\n }\n\n std::cout << \"OK: PING -> \" << pong << \", SET -> OK\\n\";\n return 0;\n } catch (const sw::redis::Error &e) {\n std::cerr << \"redis error: \" << e.what() << \"\\n\";\n return 4;\n }\n}\n" + } + ] + }, + { + "project": "reflectcpp", + "text": "# reflect-cpp test project: consumes compat.reflectcpp from this repo's own index\n# and asserts struct <-> JSON round trips through the bundled yyjson.\n[package]\nname = \"reflectcpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nreflectcpp = \"0.25.0\"\n", + "files": [ + { + "path": "tests/examples/reflectcpp/tests/json_roundtrip.cpp", + "text": "// Behavioral test: struct -> JSON -> struct, through the JSON backend.\n//\n// The package compiles two umbrella TUs plus the vendored yyjson. All three have\n// to be present and agree: leave out src/yyjson.c and this fails to LINK; get the\n// include roots wrong and rfl/json/save.hpp picks a different yyjson than the one\n// compiled, which fails at runtime rather than at build time. So the assertions\n// go all the way to parsed values.\n#include \n#include \n\n#include \n#include \n#include \n#include \n\nstruct Point {\n int x;\n int y;\n};\n\nstruct Document {\n std::string name;\n int version;\n bool enabled;\n std::vector points;\n std::optional note;\n};\n\nint main() {\n const Document doc{\n .name = \"mcpp\",\n .version = 23,\n .enabled = true,\n .points = {{1, 2}, {-3, 4}},\n .note = std::nullopt,\n };\n\n // --- round trip ---\n const std::string json = rfl::json::write(doc);\n assert(!json.empty());\n\n const auto parsed = rfl::json::read(json);\n assert(parsed && \"a document this library just wrote must parse\");\n const Document& back = parsed.value();\n assert(back.name == doc.name);\n assert(back.version == doc.version);\n assert(back.enabled == doc.enabled);\n assert(back.points.size() == 2);\n assert(back.points[0].x == 1 && back.points[0].y == 2);\n assert(back.points[1].x == -3 && back.points[1].y == 4);\n assert(!back.note.has_value());\n\n // --- the field-name rename processor, which is why callers reach for rfl ---\n const std::string camel = rfl::json::write(doc);\n const auto camel_back = rfl::json::read(camel);\n assert(camel_back && camel_back.value().version == doc.version);\n\n // --- a malformed document must fail, not throw or crash ---\n const auto bad = rfl::json::read(R\"({\"name\": 5})\");\n assert(!bad && \"a type mismatch must surface as an error, not a value\");\n\n // --- to_generic / from_generic, the path an RPC layer uses when the\n // concrete type is only known per method ---\n const auto generic = rfl::to_generic(doc);\n const auto from_generic = rfl::from_generic(generic);\n assert(from_generic && from_generic.value().name == doc.name);\n\n // --- the JSON Schema generator ---\n const std::string schema = rfl::json::to_schema();\n assert(schema.find(\"version\") != std::string::npos);\n\n return 0;\n}\n" + } + ] + }, + { + "project": "sdl2", + "text": "[package]\nname = \"sdl2-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nsdl2 = \"2.32.10\"\n", + "files": [ + { + "path": "tests/examples/sdl2/tests/video.cpp", + "text": "// Behavioral test — verify compat.sdl2 builds an SDL that can actually run a\n// video subsystem, create a window and pump events.\n//\n// SDL is the one backend in this family that CAN be exercised headlessly:\n// its `dummy` video driver is a real, fully functional driver that just never\n// touches a display. So unlike the OpenGL and Vulkan backends, this is not a\n// link-only smoke test — SDL_Init, SDL_CreateWindow, SDL_GetWindowSize and the\n// event pump all execute.\n//\n// The driver is selected through SDL_HINT_VIDEODRIVER rather than the\n// SDL_VIDEODRIVER environment variable so the test is self-contained and does\n// not depend on how CI invokes it.\n// SDL_MAIN_HANDLED before : on Windows SDL_main.h does\n// `#define main SDL_main` and expects the real entry point to come from\n// SDL2main (src/main/windows/SDL_windows_main.c). This package does not ship\n// that — a library consumer should not be handed an entry point — so the test\n// takes SDL's documented alternative and keeps its own main, pairing it with\n// SDL_SetMainReady() below. Without this the link fails with\n// \"LNK1561: entry point must be defined\".\n#define SDL_MAIN_HANDLED\n#include \nimport std;\n\nint main() {\n SDL_SetMainReady();\n SDL_SetHint(SDL_HINT_VIDEODRIVER, \"dummy\");\n\n if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_EVENTS) != 0) {\n std::println(\"SDL_Init failed: {}\", SDL_GetError());\n return 1;\n }\n\n // A build whose video subsystem never registered a driver fails here, which\n // is the failure mode a bad SDL_config.h produces — upstream's Linux\n // fallback config (SDL_config_minimal.h) builds exactly that.\n const char* driver = SDL_GetCurrentVideoDriver();\n if (driver == nullptr) {\n std::println(\"no video driver active: {}\", SDL_GetError());\n SDL_Quit();\n return 2;\n }\n\n SDL_Window* window = SDL_CreateWindow(\n \"compat.sdl2\", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,\n 320, 240, SDL_WINDOW_HIDDEN);\n if (window == nullptr) {\n std::println(\"SDL_CreateWindow failed: {}\", SDL_GetError());\n SDL_Quit();\n return 3;\n }\n\n int width = 0;\n int height = 0;\n SDL_GetWindowSize(window, &width, &height);\n if (width != 320 || height != 240) {\n std::println(\"unexpected window size: {}x{}\", width, height);\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 4;\n }\n\n // Pumping events exercises the event subsystem end to end; SDL_QUIT is\n // pushed and read back rather than waited for, so nothing here can hang.\n SDL_Event pushed{};\n pushed.type = SDL_QUIT;\n if (SDL_PushEvent(&pushed) < 0) {\n std::println(\"SDL_PushEvent failed: {}\", SDL_GetError());\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 5;\n }\n SDL_PumpEvents();\n\n bool sawQuit = false;\n SDL_Event event{};\n while (SDL_PollEvent(&event) != 0) {\n if (event.type == SDL_QUIT) sawQuit = true;\n }\n if (!sawQuit) {\n std::println(\"pushed SDL_QUIT never came back out of the event queue\");\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 6;\n }\n\n SDL_version linked{};\n SDL_GetVersion(&linked);\n\n std::println(\"compat.sdl2: ok (SDL {}.{}.{}, driver={}, {}x{} window, events round-tripped)\",\n linked.major, linked.minor, linked.patch, driver, width, height);\n\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 0;\n}\n" + } + ] + }, + { + "project": "sdl3", + "text": "# compat.sdl3 test project. `compat` redirect inherited from the workspace root.\n#\n# Everything asserted runs on a headless runner: the test forces SDL's DUMMY\n# video driver, so it exercises the real video subsystem — window creation,\n# surface ops, the event queue — without needing a display server on any of the\n# three platforms.\n[package]\nname = \"sdl3-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nsdl3 = \"3.4.2\"\n", + "files": [ + { + "path": "tests/examples/sdl3/tests/init.cpp", + "text": "// Behavioral test — compat.sdl3 builds a working SDL3, not just headers.\n//\n// SDL is a library where \"it compiled\" is especially weak evidence: most of it\n// is backends selected by a config header, and a wrong config produces a\n// library that links and then has no video driver at all. So the assertions\n// below are about what the BUILD CONFIG decided:\n//\n// * the runtime version matches the headers (one tarball, not two)\n// * the compiled-in video driver list contains dummy AND offscreen, which\n// are exactly what a config that fell through to SDL_build_config_minimal.h\n// would NOT have\n// * the dummy driver actually initialises, creates a window, and gives back\n// a surface of the requested size\n// * the timer subsystem advances\n//\n// No `import std;` — SDL's headers are textual C over the standard headers.\n// SDL_MAIN_HANDLED + SDL_main.h: this test brings its own main(), so SDL must\n// not rename it. SDL_SetMainReady() is declared in SDL_main.h, not SDL.h.\n#define SDL_MAIN_HANDLED\n#include \n#include \n\n#include \n#include \n\nint main() {\n // Header/runtime agreement. SDL_VERSION is the compile-time constant from\n // the headers; SDL_GetVersion() is what the linked objects report.\n const int compiled = SDL_VERSION;\n const int linked = SDL_GetVersion();\n if (compiled != linked) {\n std::printf(\"header says %d, library says %d — two different SDLs\\n\", compiled, linked);\n return 1;\n }\n if (linked != SDL_VERSIONNUM(3, 4, 2)) {\n std::printf(\"SDL version is %d, expected 3.4.2\\n\", linked);\n return 2;\n }\n\n SDL_SetMainReady();\n\n // The compiled-in driver list. A minimal config has none of these.\n const int drivers = SDL_GetNumVideoDrivers();\n if (drivers <= 0) {\n std::printf(\"no video drivers compiled in — the build config did not take\\n\");\n return 3;\n }\n bool haveDummy = false, haveOffscreen = false;\n for (int i = 0; i < drivers; ++i) {\n const char* name = SDL_GetVideoDriver(i);\n if (!name) continue;\n if (std::strcmp(name, \"dummy\") == 0) haveDummy = true;\n if (std::strcmp(name, \"offscreen\") == 0) haveOffscreen = true;\n }\n if (!haveDummy || !haveOffscreen) {\n std::printf(\"driver list is missing dummy/offscreen (%d drivers):\", drivers);\n for (int i = 0; i < drivers; ++i) std::printf(\" %s\", SDL_GetVideoDriver(i));\n std::printf(\"\\n\");\n return 4;\n }\n\n // Force dummy so this works with no display on any platform.\n SDL_SetHint(SDL_HINT_VIDEO_DRIVER, \"dummy\");\n if (!SDL_Init(SDL_INIT_VIDEO)) {\n std::printf(\"SDL_Init(VIDEO) failed: %s\\n\", SDL_GetError());\n return 5;\n }\n\n const char* current = SDL_GetCurrentVideoDriver();\n if (!current || std::strcmp(current, \"dummy\") != 0) {\n std::printf(\"current video driver is %s, expected dummy\\n\", current ? current : \"(null)\");\n SDL_Quit();\n return 6;\n }\n\n SDL_Window* window = SDL_CreateWindow(\"mcpp-index sdl3\", 320, 240, 0);\n if (!window) {\n std::printf(\"SDL_CreateWindow failed: %s\\n\", SDL_GetError());\n SDL_Quit();\n return 7;\n }\n int w = 0, h = 0;\n SDL_GetWindowSize(window, &w, &h);\n if (w != 320 || h != 240) {\n std::printf(\"window is %dx%d, asked for 320x240\\n\", w, h);\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 8;\n }\n\n // The dummy driver still backs a window with a real surface, so this\n // exercises the surface/pixel code rather than only the window bookkeeping.\n SDL_Surface* surface = SDL_GetWindowSurface(window);\n if (!surface || surface->w != 320 || surface->h != 240) {\n std::printf(\"window surface missing or wrong size: %s\\n\", SDL_GetError());\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 9;\n }\n if (!SDL_FillSurfaceRect(surface, nullptr, SDL_MapSurfaceRGB(surface, 0, 0, 0))) {\n std::printf(\"SDL_FillSurfaceRect failed: %s\\n\", SDL_GetError());\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 10;\n }\n\n // Timer subsystem: SDL_GetTicks must advance across a delay.\n const Uint64 before = SDL_GetTicks();\n SDL_Delay(20);\n const Uint64 after = SDL_GetTicks();\n if (after < before + 10) {\n std::printf(\"SDL_GetTicks did not advance (%llu -> %llu)\\n\",\n (unsigned long long)before, (unsigned long long)after);\n SDL_DestroyWindow(window);\n SDL_Quit();\n return 11;\n }\n\n SDL_DestroyWindow(window);\n SDL_Quit();\n\n std::printf(\"compat.sdl3: ok (version 3.4.2, %d video drivers, dummy window 320x240, timer advances)\\n\",\n drivers);\n return 0;\n}\n" + } + ] + }, + { + "project": "spdlog-compiled", + "text": "# spdlog test project: consumes compat.spdlog in its COMPILED mode\n# (`features = [\"compiled\"]`) — spdlog's src/*.cpp are compiled into the lib and\n# SPDLOG_COMPILED_LIB reaches this consumer's TUs as an interface define, so the\n# headers take the non-inline path and link against those objects.\n#\n# Sibling of tests/examples/spdlog/ (header-only default mode). Both modes ship\n# from one descriptor; this member is what keeps the compiled path honest.\n#\n# Needs mcpp >= 0.0.94: older versions never compiled a dependency's active\n# feature `sources` under `mcpp test`, so this member would fail to link.\n[package]\nname = \"spdlog-compiled-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nspdlog = { version = \"1.17.0\", features = [\"compiled\"] }\n", + "files": [ + { + "path": "tests/examples/spdlog-compiled/tests/compiled_test.cpp", + "text": "// Behavioral test: consume compat.spdlog in COMPILED mode. Asserts both halves\n// of what the `compiled` feature promises:\n//\n// 1. the interface define reached this TU (SPDLOG_COMPILED_LIB defined, and\n// spdlog's headers therefore did NOT flip on SPDLOG_HEADER_ONLY) — a\n// static check, so a regression is a compile error, not a silent fallback\n// to the header-only path that would still pass the runtime assertions;\n// 2. spdlog's src/*.cpp really were compiled and linked — the calls below\n// resolve to out-of-line symbols (default_logger_raw, log_msg's ctor,\n// logger::log_it_, and bundled fmt's vformat), which is exactly what fails\n// to link when the engine skips a feature's sources.\n#include \n#include \n#include \n#include \n\n#ifndef SPDLOG_COMPILED_LIB\n#error \"compiled feature did not propagate SPDLOG_COMPILED_LIB to the consumer\"\n#endif\n#ifdef SPDLOG_HEADER_ONLY\n#error \"SPDLOG_HEADER_ONLY is on in compiled mode — headers took the inline path\"\n#endif\n\nint main() {\n std::ostringstream captured;\n auto sink = std::make_shared(captured);\n spdlog::logger logger(\"test\", sink);\n logger.set_pattern(\"%v\"); // message only, no timestamp/level decoration\n\n logger.info(\"hello {}={}\", \"answer\", 42);\n logger.warn(\"{:#x}\", 255);\n logger.flush();\n\n // Exercises the global registry too — default_logger_raw() is one of the\n // out-of-line symbols that only exists once src/spdlog.cpp is compiled.\n spdlog::set_level(spdlog::level::info);\n\n const std::string out = captured.str();\n const bool ok = out.find(\"hello answer=42\") != std::string::npos\n && out.find(\"0xff\") != std::string::npos;\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "spdlog", + "text": "# spdlog test project: consumes compat.spdlog in its DEFAULT header-only mode\n# (bundled fmt, no external dependency) and asserts behavior under `mcpp test`.\n# Part of the mcpp-index self-referential workspace: the workspace-root\n# `[indices]` redirect points at this repo, so the dependency resolves to the\n# checked-in recipe\n# (pkgs/c/compat.spdlog.lua). Compiled mode is covered by the sibling member\n# tests/examples/spdlog-compiled/.\n[package]\nname = \"spdlog-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nspdlog = \"1.17.0\"\n", + "files": [ + { + "path": "tests/examples/spdlog/tests/log_test.cpp", + "text": "// Behavioral test: consume compat.spdlog in its DEFAULT header-only mode. Logs\n// through a custom logger wired to an in-memory ostream sink, then asserts the\n// formatted output — this exercises the logger, the pattern formatter, and the\n// bundled {fmt} formatting all via the header inlines. Returns non-zero on any\n// mismatch.\n#include \n#include \n#include \n#include \n\nint main() {\n std::ostringstream captured;\n auto sink = std::make_shared(captured);\n spdlog::logger logger(\"test\", sink);\n logger.set_pattern(\"%v\"); // message only, no timestamp/level decoration\n\n logger.info(\"hello {}={}\", \"answer\", 42);\n logger.warn(\"{:#x}\", 255);\n logger.flush();\n\n const std::string out = captured.str();\n const bool ok = out.find(\"hello answer=42\") != std::string::npos\n && out.find(\"0xff\") != std::string::npos;\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "spirv-headers", + "text": "[package]\nname = \"spirv-headers-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nspirv-headers = \"1.4.357.0\"\n", + "files": [ + { + "path": "tests/examples/spirv-headers/tests/decode.cpp", + "text": "// Behavioral test -- verify compat.spirv-headers delivers the Khronos include\n// layout and definitions a SPIR-V READER needs.\n//\n// The criterion is deliberately not \"the header compiles\". A header-only\n// package that resolves and exposes the wrong include root compiles nothing\n// and fails at the consumer, so this test spells the include the way a\n// consumer spells it and then decodes a module built by hand, which is the\n// only thing that can tell a real header tree from an empty one.\n#include \n\nimport std;\n\nint main() {\n // A minimal, well-formed SPIR-V module header: magic, version, generator,\n // bound, schema. Written as words rather than read from a file so the test\n // depends on nothing but the package under test.\n const std::uint32_t module_words[] = {\n spv::MagicNumber,\n spv::Version,\n 0u, // generator\n 1u, // bound\n 0u, // schema\n };\n\n if (module_words[0] != 0x07230203u) {\n std::println(\"spv::MagicNumber is {:#x}, expected 0x07230203\", module_words[0]);\n return 1;\n }\n\n // The enums a decoder switches on. Their numeric values are part of the\n // SPIR-V specification, so a header that renamed or renumbered them would\n // be a different specification rather than a newer package.\n if (static_cast(spv::OpCapability) != 17u) {\n std::println(\"spv::OpCapability is {}, expected 17\",\n static_cast(spv::OpCapability));\n return 2;\n }\n if (static_cast(spv::ExecutionModelGLCompute) != 5u) {\n std::println(\"spv::ExecutionModelGLCompute is {}, expected 5\",\n static_cast(spv::ExecutionModelGLCompute));\n return 3;\n }\n\n // The version word packs major and minor into the middle two bytes. A\n // consumer that gates on a SPIR-V version reads it exactly this way.\n const std::uint32_t major = (module_words[1] >> 16) & 0xffu;\n const std::uint32_t minor = (module_words[1] >> 8) & 0xffu;\n if (major != 1u) {\n std::println(\"spv::Version reports major {}, expected 1\", major);\n return 4;\n }\n\n std::println(\"compat.spirv-headers: SPIR-V {}.{} definitions present\", major, minor);\n return 0;\n}\n" + } + ] + }, + { + "project": "spirv-reflect", + "text": "# spirv-reflect test project: reflect a REAL compute shader (compiled with\n# glslc and embedded as a word array) and assert the descriptor bindings,\n# sets and push-constant block come back with the values the shader declared.\n[package]\nname = \"spirv-reflect-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nspirv-reflect = \"1.4.357.0\"\n", + "files": [ + { + "path": "tests/examples/spirv-reflect/tests/reflect.cpp", + "text": "// compat.spirv-reflect — reflect a REAL compute shader and assert the bindings\n// come back with the values the shader declared.\n//\n// The module below is the SPIR-V that glslc emits for this GLSL:\n//\n// #version 450\n// layout(set = 0, binding = 3, std430) buffer Data { float values[]; } data;\n// layout(set = 1, binding = 0) uniform Params { vec4 tint; } params;\n// layout(push_constant) uniform Push { mat4 mvp; } push;\n// layout(local_size_x = 64) in;\n// void main() { data.values[gl_GlobalInvocationID.x] *= params.tint.x + push.mvp[0][0]; }\n//\n// It is embedded as words rather than compiled at test time so the test needs\n// no shader compiler on the runner. Two sets, two different descriptor types, a\n// non-zero binding number and a push-constant block mean a stub that returned\n// zeroed structures could not pass.\n#include \nimport std;\n\nnamespace {\n// glslc -fshader-stage=compute, SPIR-V 1.0 / Vulkan 1.0\nconstexpr std::uint32_t kComputeSpv[] = {\n 0x07230203, 0x00010000, 0x000d000b, 0x0000002b, 0x00000000, 0x00020011,\n 0x00000001, 0x0006000b, 0x00000001, 0x4c534c47, 0x6474732e, 0x3035342e,\n 0x00000000, 0x0003000e, 0x00000000, 0x00000001, 0x0006000f, 0x00000005,\n 0x00000004, 0x6e69616d, 0x00000000, 0x00000010, 0x00060010, 0x00000004,\n 0x00000011, 0x00000040, 0x00000001, 0x00000001, 0x00030003, 0x00000002,\n 0x000001c2, 0x000a0004, 0x475f4c47, 0x4c474f4f, 0x70635f45, 0x74735f70,\n 0x5f656c79, 0x656e696c, 0x7269645f, 0x69746365, 0x00006576, 0x00080004,\n 0x475f4c47, 0x4c474f4f, 0x6e695f45, 0x64756c63, 0x69645f65, 0x74636572,\n 0x00657669, 0x00040005, 0x00000004, 0x6e69616d, 0x00000000, 0x00040005,\n 0x00000008, 0x61746144, 0x00000000, 0x00050006, 0x00000008, 0x00000000,\n 0x756c6176, 0x00007365, 0x00040005, 0x0000000a, 0x61746164, 0x00000000,\n 0x00080005, 0x00000010, 0x475f6c67, 0x61626f6c, 0x766e496c, 0x7461636f,\n 0x496e6f69, 0x00000044, 0x00040005, 0x00000016, 0x61726150, 0x0000736d,\n 0x00050006, 0x00000016, 0x00000000, 0x746e6974, 0x00000000, 0x00040005,\n 0x00000018, 0x61726170, 0x0000736d, 0x00040005, 0x0000001d, 0x68737550,\n 0x00000000, 0x00040006, 0x0000001d, 0x00000000, 0x0070766d, 0x00040005,\n 0x0000001f, 0x68737570, 0x00000000, 0x00040047, 0x00000007, 0x00000006,\n 0x00000004, 0x00050048, 0x00000008, 0x00000000, 0x00000023, 0x00000000,\n 0x00030047, 0x00000008, 0x00000003, 0x00040047, 0x0000000a, 0x00000022,\n 0x00000000, 0x00040047, 0x0000000a, 0x00000021, 0x00000003, 0x00040047,\n 0x00000010, 0x0000000b, 0x0000001c, 0x00050048, 0x00000016, 0x00000000,\n 0x00000023, 0x00000000, 0x00030047, 0x00000016, 0x00000002, 0x00040047,\n 0x00000018, 0x00000022, 0x00000001, 0x00040047, 0x00000018, 0x00000021,\n 0x00000000, 0x00040048, 0x0000001d, 0x00000000, 0x00000005, 0x00050048,\n 0x0000001d, 0x00000000, 0x00000023, 0x00000000, 0x00050048, 0x0000001d,\n 0x00000000, 0x00000007, 0x00000010, 0x00030047, 0x0000001d, 0x00000002,\n 0x00040047, 0x0000002a, 0x0000000b, 0x00000019, 0x00020013, 0x00000002,\n 0x00030021, 0x00000003, 0x00000002, 0x00030016, 0x00000006, 0x00000020,\n 0x0003001d, 0x00000007, 0x00000006, 0x0003001e, 0x00000008, 0x00000007,\n 0x00040020, 0x00000009, 0x00000002, 0x00000008, 0x0004003b, 0x00000009,\n 0x0000000a, 0x00000002, 0x00040015, 0x0000000b, 0x00000020, 0x00000001,\n 0x0004002b, 0x0000000b, 0x0000000c, 0x00000000, 0x00040015, 0x0000000d,\n 0x00000020, 0x00000000, 0x00040017, 0x0000000e, 0x0000000d, 0x00000003,\n 0x00040020, 0x0000000f, 0x00000001, 0x0000000e, 0x0004003b, 0x0000000f,\n 0x00000010, 0x00000001, 0x0004002b, 0x0000000d, 0x00000011, 0x00000000,\n 0x00040020, 0x00000012, 0x00000001, 0x0000000d, 0x00040017, 0x00000015,\n 0x00000006, 0x00000004, 0x0003001e, 0x00000016, 0x00000015, 0x00040020,\n 0x00000017, 0x00000002, 0x00000016, 0x0004003b, 0x00000017, 0x00000018,\n 0x00000002, 0x00040020, 0x00000019, 0x00000002, 0x00000006, 0x00040018,\n 0x0000001c, 0x00000015, 0x00000004, 0x0003001e, 0x0000001d, 0x0000001c,\n 0x00040020, 0x0000001e, 0x00000009, 0x0000001d, 0x0004003b, 0x0000001e,\n 0x0000001f, 0x00000009, 0x00040020, 0x00000020, 0x00000009, 0x00000006,\n 0x0004002b, 0x0000000d, 0x00000028, 0x00000040, 0x0004002b, 0x0000000d,\n 0x00000029, 0x00000001, 0x0006002c, 0x0000000e, 0x0000002a, 0x00000028,\n 0x00000029, 0x00000029, 0x00050036, 0x00000002, 0x00000004, 0x00000000,\n 0x00000003, 0x000200f8, 0x00000005, 0x00050041, 0x00000012, 0x00000013,\n 0x00000010, 0x00000011, 0x0004003d, 0x0000000d, 0x00000014, 0x00000013,\n 0x00060041, 0x00000019, 0x0000001a, 0x00000018, 0x0000000c, 0x00000011,\n 0x0004003d, 0x00000006, 0x0000001b, 0x0000001a, 0x00070041, 0x00000020,\n 0x00000021, 0x0000001f, 0x0000000c, 0x0000000c, 0x00000011, 0x0004003d,\n 0x00000006, 0x00000022, 0x00000021, 0x00050081, 0x00000006, 0x00000023,\n 0x0000001b, 0x00000022, 0x00060041, 0x00000019, 0x00000024, 0x0000000a,\n 0x0000000c, 0x00000014, 0x0004003d, 0x00000006, 0x00000025, 0x00000024,\n 0x00050085, 0x00000006, 0x00000026, 0x00000025, 0x00000023, 0x00060041,\n 0x00000019, 0x00000027, 0x0000000a, 0x0000000c, 0x00000014, 0x0003003e,\n 0x00000027, 0x00000026, 0x000100fd, 0x00010038,};\n} // namespace\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n SpvReflectShaderModule mod{};\n const auto rc = spvReflectCreateShaderModule(sizeof(kComputeSpv), kComputeSpv, &mod);\n check(rc == SPV_REFLECT_RESULT_SUCCESS, \"module parsed\");\n if (rc != SPV_REFLECT_RESULT_SUCCESS) return 1;\n\n check(mod.shader_stage == SPV_REFLECT_SHADER_STAGE_COMPUTE_BIT, \"stage is compute\");\n check(std::string_view{mod.entry_point_name} == \"main\", \"entry point is main\");\n\n // ---- descriptor bindings --------------------------------------------\n std::uint32_t count = 0;\n check(spvReflectEnumerateDescriptorBindings(&mod, &count, nullptr) == SPV_REFLECT_RESULT_SUCCESS,\n \"binding count query\");\n check(count == 2, \"two descriptor bindings\");\n\n std::vector bindings(count);\n check(spvReflectEnumerateDescriptorBindings(&mod, &count, bindings.data()) == SPV_REFLECT_RESULT_SUCCESS,\n \"binding enumeration\");\n\n bool saw_storage = false, saw_uniform = false;\n for (const auto* b : bindings) {\n if (b->descriptor_type == SPV_REFLECT_DESCRIPTOR_TYPE_STORAGE_BUFFER) {\n saw_storage = true;\n check(b->set == 0, \"storage buffer is in set 0\");\n check(b->binding == 3, \"storage buffer is at binding 3\");\n } else if (b->descriptor_type == SPV_REFLECT_DESCRIPTOR_TYPE_UNIFORM_BUFFER) {\n saw_uniform = true;\n check(b->set == 1, \"uniform buffer is in set 1\");\n check(b->binding == 0, \"uniform buffer is at binding 0\");\n }\n }\n check(saw_storage, \"the std430 buffer was reflected as a storage buffer\");\n check(saw_uniform, \"the uniform block was reflected as a uniform buffer\");\n\n // ---- descriptor sets -------------------------------------------------\n std::uint32_t sets = 0;\n check(spvReflectEnumerateDescriptorSets(&mod, &sets, nullptr) == SPV_REFLECT_RESULT_SUCCESS,\n \"set count query\");\n check(sets == 2, \"two descriptor sets\");\n\n // ---- push constants --------------------------------------------------\n std::uint32_t blocks = 0;\n check(spvReflectEnumeratePushConstantBlocks(&mod, &blocks, nullptr) == SPV_REFLECT_RESULT_SUCCESS,\n \"push constant count query\");\n check(blocks == 1, \"one push constant block\");\n\n std::vector pcs(blocks);\n if (blocks == 1) {\n check(spvReflectEnumeratePushConstantBlocks(&mod, &blocks, pcs.data()) == SPV_REFLECT_RESULT_SUCCESS,\n \"push constant enumeration\");\n check(pcs[0]->size == 64, \"the mat4 push constant block is 64 bytes\");\n check(pcs[0]->offset == 0, \"push constant block starts at offset 0\");\n }\n\n spvReflectDestroyShaderModule(&mod);\n\n // ---- negative case ---------------------------------------------------\n // Garbage must be REJECTED. Without this, a stub that always returns\n // SUCCESS would pass everything above by accident.\n const std::uint32_t junk[] = {0xdeadbeefu, 0u, 0u, 0u, 0u, 0u, 0u, 0u};\n SpvReflectShaderModule bad{};\n check(spvReflectCreateShaderModule(sizeof(junk), junk, &bad) != SPV_REFLECT_RESULT_SUCCESS,\n \"a bad magic number is rejected\");\n\n if (ok) std::println(\"SPIRV-Reflect OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "sqlite3", + "text": "# SQLite test project: depends on compat.sqlite3 (built from source via this\n# repo's own index) and asserts behavior under `mcpp test`. Part of the\n# mcpp-index self-referential workspace.\n[package]\nname = \"sqlite3-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nsqlite3 = \"3.45.3\"\n", + "files": [ + { + "path": "tests/examples/sqlite3/tests/sqlite3_test.cpp", + "text": "// Behavioral test for compat.sqlite3 (SQLite 3.45.3, C API): assert the\n// resolved library version, then open an in-memory database, create a table,\n// insert a row, and read it back through both sqlite3_exec and a prepared\n// statement.\n#include \n#include \n#include \n\nint main() {\n // The index pins 3.45.3; prove the resolved package is that version.\n assert(std::strcmp(sqlite3_libversion(), \"3.45.3\") == 0);\n\n sqlite3* db = nullptr;\n assert(sqlite3_open(\":memory:\", &db) == SQLITE_OK);\n assert(db != nullptr);\n\n // DDL + DML through the convenience API.\n char* errmsg = nullptr;\n int rc = sqlite3_exec(db,\n \"CREATE TABLE t(id INTEGER PRIMARY KEY, name TEXT NOT NULL);\"\n \"INSERT INTO t(name) VALUES ('mcpp');\",\n nullptr, nullptr, &errmsg);\n assert(rc == SQLITE_OK);\n if (errmsg != nullptr) { sqlite3_free(errmsg); errmsg = nullptr; }\n\n // Read back through a prepared statement.\n sqlite3_stmt* stmt = nullptr;\n rc = sqlite3_prepare_v2(db, \"SELECT id, name FROM t;\", -1, &stmt, nullptr);\n assert(rc == SQLITE_OK);\n assert(stmt != nullptr);\n assert(sqlite3_step(stmt) == SQLITE_ROW);\n assert(sqlite3_column_int(stmt, 0) == 1);\n assert(std::strcmp(reinterpret_cast(sqlite3_column_text(stmt, 1)), \"mcpp\") == 0);\n assert(sqlite3_step(stmt) == SQLITE_DONE);\n sqlite3_finalize(stmt);\n\n assert(sqlite3_close(db) == SQLITE_OK);\n return 0;\n}\n" + } + ] + }, + { + "project": "sqlitecpp", + "text": "# SQLiteCpp test project: consumes compat.sqlitecpp from this repo's own index.\n# compat.sqlite3 arrives transitively — the wrapper depends on it because\n# upstream's own build gets SQLite from a git submodule the tarball omits.\n[package]\nname = \"sqlitecpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nsqlitecpp = \"3.3.3\"\n", + "files": [ + { + "path": "tests/examples/sqlitecpp/tests/database.cpp", + "text": "// Behavioral test: a real database session through the RAII wrapper.\n//\n// The load-bearing question for this package is whether the dependency edge on\n// compat.sqlite3 actually replaces upstream's git submodule — and that is a LINK\n// question, so every assertion below is chosen to reach sqlite3 symbols: the\n// version string, prepared statements with bound parameters, a transaction that\n// rolls back, and the exception path (which carries an sqlite3 error code).\n#include \n\n#include \n#include \n\nint main() {\n // The wrapper reports the SQLite it was linked against, which is the\n // cheapest proof that the dependency edge resolved to a real library.\n const std::string version = SQLite::getLibVersion();\n assert(!version.empty() && version[0] == '3');\n\n SQLite::Database db(\":memory:\", SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE);\n\n db.exec(\"CREATE TABLE note (id INTEGER PRIMARY KEY, title TEXT NOT NULL, size INTEGER)\");\n assert(db.tableExists(\"note\"));\n\n // --- bound parameters through a prepared statement ---\n {\n SQLite::Statement insert(db, \"INSERT INTO note (title, size) VALUES (?, ?)\");\n for (const auto& [title, size] : {std::pair{\"alpha\", 10},\n {\"beta\", 20},\n {\"gamma\", 30}}) {\n insert.bind(1, title);\n insert.bind(2, size);\n assert(insert.exec() == 1);\n insert.reset();\n }\n }\n assert(db.execAndGet(\"SELECT COUNT(*) FROM note\").getInt() == 3);\n assert(db.getLastInsertRowid() == 3);\n\n // --- iteration, typed columns, and a named parameter ---\n {\n SQLite::Statement query(db, \"SELECT id, title, size FROM note WHERE size >= :floor ORDER BY id\");\n query.bind(\":floor\", 20);\n int rows = 0;\n int total = 0;\n while (query.executeStep()) {\n const int id = query.getColumn(0);\n const std::string title = query.getColumn(1).getString();\n total += static_cast(query.getColumn(2).getInt());\n assert(id >= 2 && !title.empty());\n ++rows;\n }\n assert(rows == 2 && total == 50);\n }\n\n // --- a transaction that is NOT committed must roll back on scope exit ---\n {\n SQLite::Transaction abandoned(db);\n db.exec(\"DELETE FROM note\");\n assert(db.execAndGet(\"SELECT COUNT(*) FROM note\").getInt() == 0);\n }\n assert(db.execAndGet(\"SELECT COUNT(*) FROM note\").getInt() == 3);\n\n // --- and one that is ---\n {\n SQLite::Transaction kept(db);\n db.exec(\"UPDATE note SET size = size * 2\");\n kept.commit();\n }\n assert(db.execAndGet(\"SELECT SUM(size) FROM note\").getInt() == 120);\n\n // --- the error path carries an sqlite3 error code, not just a message ---\n bool threw = false;\n try {\n db.exec(\"INSERT INTO note (title) VALUES (NULL)\"); // NOT NULL violation\n } catch (const SQLite::Exception& e) {\n threw = true;\n assert(e.getErrorCode() != 0);\n assert(std::string(e.what()).find(\"NOT NULL\") != std::string::npos);\n }\n assert(threw);\n\n return 0;\n}\n" + } + ] + }, + { + "project": "sycl-runtime", + "text": "# compat.sycl-runtime is a Linux-only adapter, and for the same reason\n# compat.cuda-driver is: an mcpp binary on Linux runs under mcpp's own loader,\n# so a soname that is not on its runtime search path is not found at all. macOS\n# and Windows have neither that loader arrangement nor this payload, so the\n# dependency is conditioned rather than the member being excluded -- the member\n# still builds on all three, which is what keeps the test source from rotting.\n[package]\nname = \"sycl-runtime-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nsycl-runtime = \"2026.09.10\"\n", + "files": [ + { + "path": "tests/examples/sycl-runtime/tests/farm.cpp", + "text": "// What compat.sycl-runtime is asserted to do.\n//\n// A machine with no GPU is a legitimate configuration and is what every runner\n// in this repository is, so the test cannot require a device. It asserts the\n// properties that hold on both kinds of machine.\n//\n// 1. The package resolves, builds and links. The failure this package exists\n// to prevent is a RUNTIME one -- `libsycl.so.9 not found on the search\n// path this artifact will actually use` -- so linking alone is not the\n// whole assertion.\n//\n// 2. THE FARM IS SUFFICIENT FOR ITS OWN MEMBERS. For every versioned library\n// in the farm, every SONAME in its DT_NEEDED is either in the farm too or\n// is one of the three the artifact itself has already loaded.\n//\n// 3. `libsycl.so.9` loads and carries the entry point every SYCL program\n// reaches the runtime through. Resolving a file is not the same as it\n// being the runtime.\n//\n// WHY (2) IS A DT_NEEDED WALK AND NOT `dlopen` OF EACH MEMBER, which is what\n// this test did first: dlopen measures the PROCESS, and the process has more\n// on its search path than this package put there. Measured -- with an OpenCL\n// dependency briefly declared, `compat:opencl-runtime`'s farm supplied\n// `libnvidia-ml.so.1`, so removing NVML from THIS farm changed nothing and the\n// test passed on a farm that was missing it. A package's test has to be able\n// to fail on that package alone.\n//\n// Before this walk existed, the assertion was three hand-written names --\n// `libsycl.so.9`, `libur_loader.so.0`, `libumf.so.1` -- against a farm of\n// twenty-five members, and the two members that could not load were not\n// among the three (mcpp#596). The population is enumerated now; only the\n// exceptions are named, and each one carries its reason.\n//\n// WHETHER A MEMBER'S CLOSURE IS SATISFIED IS A PACKAGING PROPERTY, NOT A\n// DEVICE PROPERTY. This machine has no Level Zero device and both Level Zero\n// adapters are complete; the two that were broken reported `cannot open shared\n// object file`, which is a statement about the farm.\n//\n// THE TWO LEGITIMATE ABSENCES:\n//\n// * A dangling farm entry. The farm links the host's NVIDIA driver through\n// `xim:libcuda-host-link`, whose symlinks are deliberately dangling on a\n// machine with no driver so that installing one later self-heals every\n// consumer. The entry is present, so the package did its part; the target\n// is the machine's answer.\n//\n// * A SONAME this package gets from a DECLARED DEPENDENCY rather than from\n// its own farm. Today that is `libOpenCL.so.1`: `compat:sycl-runtime`\n// depends on `compat:opencl`, whose shared library is deployed beside the\n// consumer's executable and is found there through `$ORIGIN`. The name is\n// listed here rather than resolved through the process, because resolving\n// through the process is what let another farm answer for\n// `libnvidia-ml.so.1` and hide the gap this test exists to catch. A short\n// list that a reader can check is the price of a criterion that cannot be\n// masked.\n//\n// Which devices exist is deliberately NOT asserted: that is the machine's\n// answer, not this package's.\n#include \n\n#ifndef __linux__\nint main() {\n std::printf(\"not applicable on this platform\\n\");\n return 0;\n}\n#else\n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\n// Loaded on the executable's own behalf before any farm member is asked for,\n// and resolved by SONAME without a search. The recipe says the same thing from\n// the other side, as the reason it does NOT farm them: a second C library in\n// one address space is the one failure worse than a missing library.\nbool provided_by_the_artifact(const std::string& soname) {\n return soname == \"libc.so.6\" || soname == \"libm.so.6\"\n || soname.rfind(\"ld-linux\", 0) == 0;\n}\n\n// Served by a declared dependency of this package, not by its farm.\n//\n// `compat:opencl` builds the Khronos ICD loader with the canonical soname and\n// mcpp deploys it beside the consumer's executable, where `$ORIGIN` finds it.\n// Naming it here keeps the farm's self-sufficiency assertion exact: everything\n// NOT on this list must be in the farm, and no other directory on the search\n// path can answer for it.\nbool served_by_a_declared_dependency(const std::string& soname) {\n return soname == \"libOpenCL.so.1\";\n}\n\n// The farm's directory, read from a library that is in it.\n//\n// Not spelled as a path: this test does not know where the package was\n// installed, and the artifact's own DT_RPATH is the only thing that does.\n// `libsycl.so.9` resolves through it and the link map reports the file that\n// was actually opened, so the directory is discovered the way the loader\n// discovers it.\nstd::string farm_dir() {\n void* h = dlopen(\"libsycl.so.9\", RTLD_LAZY | RTLD_LOCAL);\n if (!h) {\n std::printf(\"FAIL libsycl.so.9: %s\\n\", dlerror());\n return {};\n }\n struct link_map* map = nullptr;\n if (dlinfo(h, RTLD_DI_LINKMAP, &map) != 0 || !map || !map->l_name) {\n std::printf(\"FAIL libsycl.so.9: loaded, but its path is not readable\\n\");\n return {};\n }\n std::string path = map->l_name;\n auto slash = path.rfind('/');\n return slash == std::string::npos ? std::string(\".\") : path.substr(0, slash);\n}\n\nbool versioned_soname(const std::string& name) {\n // `libfoo.so.N...`, which is what the farm links and what dlopen asks for.\n // The `-gdb.py` sidecars beside the payload's libraries match a looser test.\n auto so = name.find(\".so.\");\n if (so == std::string::npos) return false;\n if (name.size() < so + 5) return false;\n if (name.size() >= 3 && name.compare(name.size() - 3, 3, \".py\") == 0)\n return false;\n return name[so + 4] >= '0' && name[so + 4] <= '9';\n}\n\n// A link whose target does not exist. The sentinel's self-heal shape.\nbool dangling(const std::string& path) {\n struct stat st {};\n struct stat lst {};\n if (lstat(path.c_str(), &lst) != 0) return false;\n return stat(path.c_str(), &st) != 0;\n}\n\n// The DT_NEEDED list of an ELF64 file.\n//\n// Read here rather than shelled out to `objdump`/`readelf`: a test that needs\n// a binutils on the runner is a test that skips. Returns false when the file\n// is not an ELF64 this reader understands, which the caller reports rather\n// than treating as an empty list -- \"could not look\" and \"nothing there\" are\n// the two readings this repository has been burned by conflating.\nbool needed_of(const std::string& path, std::vector& out) {\n FILE* f = std::fopen(path.c_str(), \"rb\");\n if (!f) return false;\n std::vector buf;\n std::fseek(f, 0, SEEK_END);\n long size = std::ftell(f);\n std::fseek(f, 0, SEEK_SET);\n if (size <= 0) { std::fclose(f); return false; }\n buf.resize(static_cast(size));\n bool ok = std::fread(buf.data(), 1, buf.size(), f) == buf.size();\n std::fclose(f);\n if (!ok || buf.size() < sizeof(Elf64_Ehdr)) return false;\n\n auto const* eh = reinterpret_cast(buf.data());\n if (std::memcmp(eh->e_ident, ELFMAG, SELFMAG) != 0) return false;\n if (eh->e_ident[EI_CLASS] != ELFCLASS64) return false;\n if (eh->e_phoff == 0 || eh->e_phentsize != sizeof(Elf64_Phdr)) return false;\n\n const Elf64_Dyn* dyn = nullptr;\n std::size_t dynCount = 0;\n for (unsigned i = 0; i < eh->e_phnum; ++i) {\n auto off = eh->e_phoff + static_cast(i) * eh->e_phentsize;\n if (off + sizeof(Elf64_Phdr) > buf.size()) return false;\n auto const* ph = reinterpret_cast(buf.data() + off);\n if (ph->p_type != PT_DYNAMIC) continue;\n if (ph->p_offset + ph->p_filesz > buf.size()) return false;\n dyn = reinterpret_cast(buf.data() + ph->p_offset);\n dynCount = ph->p_filesz / sizeof(Elf64_Dyn);\n }\n if (!dyn) return true; // statically linked: no DT_NEEDED, and that is a\n // complete answer rather than a failure to read\n\n // DT_STRTAB is a virtual address; map it back through the program headers\n // that actually cover it.\n std::uint64_t strtabVaddr = 0;\n for (std::size_t i = 0; i < dynCount && dyn[i].d_tag != DT_NULL; ++i)\n if (dyn[i].d_tag == DT_STRTAB) strtabVaddr = dyn[i].d_un.d_ptr;\n if (!strtabVaddr) return false;\n\n std::size_t strtabOff = 0;\n bool mapped = false;\n for (unsigned i = 0; i < eh->e_phnum; ++i) {\n auto off = eh->e_phoff + static_cast(i) * eh->e_phentsize;\n auto const* ph = reinterpret_cast(buf.data() + off);\n if (ph->p_type != PT_LOAD) continue;\n if (strtabVaddr < ph->p_vaddr || strtabVaddr >= ph->p_vaddr + ph->p_filesz)\n continue;\n strtabOff = ph->p_offset + (strtabVaddr - ph->p_vaddr);\n mapped = true;\n break;\n }\n if (!mapped || strtabOff >= buf.size()) return false;\n\n for (std::size_t i = 0; i < dynCount && dyn[i].d_tag != DT_NULL; ++i) {\n if (dyn[i].d_tag != DT_NEEDED) continue;\n auto at = strtabOff + dyn[i].d_un.d_val;\n if (at >= buf.size()) return false;\n out.emplace_back(reinterpret_cast(buf.data() + at));\n }\n return true;\n}\n\n} // namespace\n\nint main() {\n const std::string dir = farm_dir();\n if (dir.empty()) return 1;\n std::printf(\"farm: %s\\n\", dir.c_str());\n\n DIR* d = opendir(dir.c_str());\n if (!d) {\n std::printf(\"FAIL %s: cannot be read\\n\", dir.c_str());\n return 1;\n }\n std::vector members;\n while (dirent* e = readdir(d)) {\n std::string name = e->d_name;\n if (versioned_soname(name)) members.push_back(name);\n }\n closedir(d);\n\n auto in_farm = [&](const std::string& soname) {\n for (auto const& m : members) if (m == soname) return true;\n return false;\n };\n\n // The denominator, printed before any verdict. A farm that failed to build\n // enumerates nothing and every per-member assertion then passes.\n std::printf(\"members: %zu\\n\", members.size());\n if (members.empty()) {\n std::printf(\"FAIL: the farm is empty\\n\");\n return 1;\n }\n\n int bad = 0;\n std::size_t walked = 0;\n for (auto const& name : members) {\n const std::string path = dir + \"/\" + name;\n if (dangling(path)) {\n // A host library this machine does not have. The entry is present,\n // which is this package's half of the contract.\n std::printf(\"skip %s (dangling host link)\\n\", name.c_str());\n continue;\n }\n std::vector needed;\n if (!needed_of(path, needed)) {\n std::printf(\"FAIL %s: DT_NEEDED could not be read\\n\", name.c_str());\n ++bad;\n continue;\n }\n ++walked;\n for (auto const& soname : needed) {\n if (provided_by_the_artifact(soname)) continue;\n if (in_farm(soname)) {\n if (dangling(dir + \"/\" + soname))\n std::printf(\"note %s needs %s, which is a dangling host \"\n \"link on this machine\\n\",\n name.c_str(), soname.c_str());\n continue;\n }\n if (served_by_a_declared_dependency(soname)) {\n std::printf(\"note %s needs %s, which a declared dependency of \"\n \"this package provides\\n\",\n name.c_str(), soname.c_str());\n continue;\n }\n std::printf(\"FAIL %s needs %s, which the farm does not carry\\n\",\n name.c_str(), soname.c_str());\n ++bad;\n }\n }\n // The second denominator. Every member could be a dangling link, and the\n // walk above would then have examined nothing while reporting no failure.\n std::printf(\"walked: %zu of %zu members\\n\", walked, members.size());\n if (walked == 0) {\n std::printf(\"FAIL: no member's closure was examined\\n\");\n ++bad;\n }\n\n // The entry point every SYCL program reaches the runtime through. Loading\n // the file is not the same as it being the runtime, and a farm that linked\n // a stale or wrong-class file would satisfy everything above.\n void* h = dlopen(\"libsycl.so.9\", RTLD_LAZY | RTLD_LOCAL);\n if (!h || !dlsym(h, \"__sycl_register_lib\")) {\n std::printf(\"FAIL libsycl.so.9: __sycl_register_lib is not there\\n\");\n ++bad;\n }\n\n std::printf(\"%s\\n\", bad ? \"FAILED\" : \"PASSED\");\n return bad ? 1 : 0;\n}\n#endif\n" + } + ] + }, + { + "project": "tinyhttps", + "text": "# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `default`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n[indices]\ndefault = { path = \"../../..\" }\n\n[package]\nname = \"tinyhttps-tests\"\nversion = \"0.1.0\"\n\n[dependencies]\ntinyhttps = \"0.3.4\"\n", + "files": [ + { + "path": "tests/examples/tinyhttps/tests/api.cpp", + "text": "import std;\nimport mcpplibs.tinyhttps;\n\nint main() {\n using namespace mcpplibs::tinyhttps;\n\n const auto request = HttpRequest::post(\"https://example.invalid/data\", \"{\\\"ok\\\":true}\");\n const auto proxy = parse_proxy_url(\"http://127.0.0.1:8088/path\");\n const auto chunk = parse_chunk_size_line(\"1a\");\n const auto invalid_chunk = parse_chunk_size_line(\"1x\");\n\n // 0.3.0: a status line must be one. The rejected string is the exact input\n // from mcpplibs/tinyhttps#15, where leftover body bytes were mined for\n // digits and reported as an ordinary 999.\n const auto status = parse_status_line(\"HTTP/1.1 200 OK\");\n const auto not_a_status = parse_status_line(\"BBBB 999 XHTTP/1.1 200 OK\");\n\n // 0.3.0: adding fields to this aggregate left four-initialiser\n // brace-initialisation working, which is the whole compatibility claim.\n HttpResponse response{204, \"No Content\", {}, {}};\n\n // 0.3.4: a stop token on all three entry points. One stopped before the\n // call ends it before anything is sent, so this needs no network and no\n // listener; the result says why without anyone matching text.\n std::stop_source stopped;\n stopped.request_stop();\n HttpClient client;\n HttpRequest get;\n get.url = \"https://example.invalid/\";\n const auto cancelled = client.send(get, stopped.get_token());\n const auto cancelledDownload = client.download_to_file(\n \"https://example.invalid/file\", \"tinyhttps-unused\", nullptr, nullptr,\n stopped.get_token());\n const bool ok = request.method == Method::POST\n && request.url == \"https://example.invalid/data\"\n && request.body == \"{\\\"ok\\\":true}\"\n && request.headers.at(\"Content-Type\") == \"application/json\"\n && proxy.host == \"127.0.0.1\"\n && proxy.port == 8088\n && chunk == 26\n && !invalid_chunk.has_value()\n && status.has_value()\n && status->code == 200\n && status->text == \"OK\"\n && !not_a_status.has_value()\n && response.bodyComplete // 0.3.0, defaults to true\n && response.bodyError.empty()\n && response.ok()\n && !response.cancelled // 0.3.4, defaults to false\n && cancelled.cancelled\n && cancelled.statusCode == 0\n && !cancelled.ok()\n && cancelledDownload.cancelled\n && !cancelledDownload.ok();\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "tray", + "text": "[package]\nname = \"tray-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\ntray = \"0.0.0-8dd1358\"\n", + "files": [ + { + "path": "tests/examples/tray/tests/header.cpp", + "text": "// Smoke test — verify compat.tray header compiles (header-only lib)\n#define TRAY_IMPLEMENTATION\n#include \"tray.h\"\nimport std;\n\nint main() {\n std::println(\"compat.tray smoke test: ok\");\n return 0;\n}\n" + } + ] + }, + { + "project": "usockets", + "text": "# uSockets test project. compat.libuv arrives transitively as the event loop.\n[package]\nname = \"usockets-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nusockets = \"0.8.8\"\n\n[build]\n# libusockets.h changes the layout of us_loop_t under LIBUS_USE_LIBUV and gates\n# its SSL declarations on LIBUS_NO_SSL, so a consumer must be compiled with the\n# SAME pair the library was — an index descriptor's cflags reach only the\n# package's own TUs. Getting this wrong does not fail to build; it corrupts.\ndefines = [\"LIBUS_USE_LIBUV\", \"LIBUS_NO_SSL\"]\n", + "files": [ + { + "path": "tests/examples/usockets/tests/loop.cpp", + "text": "// Behavioral test: a real event loop iteration, driven by uSockets' own timer.\n//\n// The struct-layout hazard this package warns about is invisible to a build:\n// `us_loop_t` has a different shape under LIBUS_USE_LIBUV, and a consumer that\n// disagrees corrupts memory rather than failing to link. So the test allocates\n// loop-attached extension memory, writes to it from a timer callback, and reads\n// it back afterwards — if the layouts disagreed, that write lands somewhere else.\n#include \n\n#include \n#include \n\nnamespace {\n\nstruct LoopExt {\n int ticks;\n int magic;\n};\n\nstruct TimerExt {\n us_loop_t* loop;\n int budget;\n};\n\nconstexpr int kMagic = 0x5AFE;\n\nvoid on_wakeup(us_loop_t*) {}\nvoid on_pre(us_loop_t*) {}\nvoid on_post(us_loop_t*) {}\n\nvoid on_timer(us_timer_t* timer) {\n auto* text = static_cast(us_timer_ext(timer));\n auto* lext = static_cast(us_loop_ext(text->loop));\n\n assert(lext->magic == kMagic && \"loop extension memory moved — layout mismatch\");\n ++lext->ticks;\n\n if (--text->budget <= 0) {\n us_timer_close(timer);\n }\n}\n\n} // namespace\n\nint main() {\n us_loop_t* loop = us_create_loop(nullptr, on_wakeup, on_pre, on_post,\n static_cast(sizeof(LoopExt)));\n assert(loop != nullptr);\n\n auto* lext = static_cast(us_loop_ext(loop));\n std::memset(lext, 0, sizeof(LoopExt));\n lext->magic = kMagic;\n\n us_timer_t* timer = us_create_timer(loop, 0, static_cast(sizeof(TimerExt)));\n assert(timer != nullptr);\n auto* text = static_cast(us_timer_ext(timer));\n text->loop = loop;\n text->budget = 3;\n\n // 1 ms, repeating: the loop must actually run and re-arm.\n us_timer_set(timer, on_timer, 1, 1);\n\n us_loop_run(loop); // returns when nothing is left referencing the loop\n\n assert(lext->ticks == 3);\n assert(lext->magic == kMagic);\n\n us_loop_free(loop);\n return 0;\n}\n" + } + ] + }, + { + "project": "uwebsockets", + "text": "# uWebSockets test project. compat.usockets and compat.libuv arrive transitively.\n[package]\nname = \"uwebsockets-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nuwebsockets = \"20.79.0\"\n\n[build]\n# uWS is header-only: the consumer's TU is where it is actually compiled, so the\n# consumer is where these have to be declared. LIBUS_* must match how uSockets\n# was built (us_loop_t layout, SSL declarations); UWS_NO_ZLIB matches the\n# package's zlib-free build.\ndefines = [\"LIBUS_USE_LIBUV\", \"LIBUS_NO_SSL\", \"UWS_NO_ZLIB\"]\n", + "files": [ + { + "path": "tests/examples/uwebsockets/tests/listen.cpp", + "text": "// Behavioral test: instantiate the server templates and bind a real port.\n//\n// The risk in a header-only package layered on a C library is not \"does the\n// header exist\" — it is whether the templates instantiate over the C types as\n// the library was actually built, which only the CONSUMER's compile can answer.\n// Registering routes instantiates HttpContext/HttpResponse/HttpRequest over\n// us_socket_t, and `listen` walks all the way down to a bound socket.\n#include \n\n#include \n#include \n\nint main() {\n bool bound = false;\n int chosen_port = 0;\n\n uWS::App app;\n\n // Two route shapes and a websocket behavior: each instantiates a different\n // corner of the template stack.\n app.get(\"/ping\", [](auto* res, auto* /*req*/) {\n res->end(\"pong\");\n });\n app.post(\"/echo/:id\", [](auto* res, auto* req) {\n res->end(std::string(req->getParameter(0)));\n });\n app.ws(\"/ws\", {\n .open = [](auto* /*ws*/) {},\n .message = [](auto* ws, std::string_view msg, uWS::OpCode op) { ws->send(msg, op); },\n });\n\n // Port 0 asks the OS for an ephemeral port, so the test cannot collide with\n // anything else on the runner.\n app.listen(0, [&](us_listen_socket_t* token) {\n bound = (token != nullptr);\n if (token) {\n chosen_port = us_socket_local_port(0, reinterpret_cast(token));\n us_listen_socket_close(0, token);\n }\n });\n\n // Returns once the listen socket is closed and nothing else holds the loop.\n app.run();\n\n assert(bound && \"uWS::App failed to bind an ephemeral port\");\n assert(chosen_port > 0 && \"the OS assigned no port\");\n return 0;\n}\n" + } + ] + }, + { + "project": "vulkan-hpp-module", + "text": "# Vulkan-Hpp module test project: consumes Khronos' own C++23 module units\n# (`import vulkan;` / `import vulkan_video;`) from the `khronos.vulkan-hpp`\n# package and asserts behavior under `mcpp test`.\n#\n# Overrides the workspace-root redirect: root declares `compat`, this member\n# needs `khronos`. A member-level [indices] REPLACES the inherited table rather\n# than merging with it, which is what keeps this to ONE project index repo —\n# two repos pointing at the same tree make every lookup ambiguous.\n#\n# The consequence is deliberate and worth stating: `compat.vulkan`, which\n# `khronos.vulkan-hpp` depends on for the loader, then resolves from the\n# PUBLISHED index rather than from this checkout. That is the same arrangement\n# tests/examples/asio-ssl leans on, and it is the right one here — the package\n# under test is the module package, and its dependency is a package this index\n# has been shipping for some time.\n[indices]\nkhronos = { path = \"../../..\" }\n\n[package]\nname = \"vulkan-hpp-module-tests\"\nversion = \"0.1.0\"\n\n[dependencies.khronos]\nvulkan-hpp = \"1.4.357.1\"\n\n# No [build] section, and no per-platform gating: unlike tests/examples/vulkan,\n# which had to publish its own HAVE_VULKAN_LOADER switch, nothing here is\n# conditional. The package carries all three platforms, and every runtime call\n# the tests make is one the loader answers by itself, before any ICD is\n# involved — so the same source runs on a driverless runner on all of them.\n", + "files": [ + { + "path": "tests/examples/vulkan-hpp-module/tests/module.cpp", + "text": "// Behavioral test — consume Vulkan-Hpp through the C++23 named module\n// `vulkan`, and assert the things a module build can get wrong quietly.\n//\n// There is not a single `#include` here, on purpose. `import vulkan;` has to\n// deliver the whole surface by itself: a package that resolved, downloaded and\n// linked but never compiled its module unit fails at the FIRST line of this\n// file rather than passing with an empty build.\n//\n// The three groups below are chosen so that each of Vulkan-Hpp's four header\n// layers is answered by something only that layer provides, and none of them\n// needs a GPU, a driver, or an ICD manifest:\n//\n// * vulkan.hpp — enums, structs, the `sType` defaults\n// * vulkan_format_traits.hpp — constexpr format arithmetic\n// * vulkan_extension_inspection.hpp— the extension registry tables\n// * vulkan_raii.hpp — the RAII wrappers, asserted by TYPE\n//\n// `vk::to_string` is asserted below for a reason worth stating: it lives in\n// `vulkan_to_string.hpp`, which is NOT in the list of headers `vulkan.cppm`\n// names — reading that list as exhaustive gets this wrong. `vulkan.hpp`\n// includes it itself, guarded only by `VULKAN_HPP_NO_TO_STRING`, which nothing\n// defines here, so it rides into the module purview and is exported. The check\n// exists so that stops being a matter of reading and stays true.\n//\n// The rule the module DOES impose: `import vulkan;` and\n// `#include ` are two roads and a TU takes exactly one of\n// them — the header would arrive a second time, textually, as a different set\n// of entities from the ones the module already owns.\n//\n// Macros do not travel through a named module either, which is why the\n// extension names below are written as string literals rather than as\n// `VK_KHR_SURFACE_EXTENSION_NAME`. Nor do the C API's own names: upstream's\n// module unit exports the `vk::` surface plus the `PFN_vk*` function-pointer\n// typedefs, and nothing else from the C headers — so `VkInstance` and\n// `VkApplicationInfo` cannot be named here, only reached through\n// `vk::Instance` and friends.\nimport std;\nimport vulkan;\n\nint main() {\n // ── vulkan.hpp: enumerators and structs ──────────────────────────────\n //\n // Enumerator VALUES are the classic silent loss in a module wrapper — a\n // wrapper that exports only an enum's type name still compiles, and the\n // enumerators just are not there. These are the Vulkan specification's own\n // numbers (VK_FORMAT_R8G8B8A8_UNORM is 37), so a module that answers them\n // is carrying the real enum.\n static_assert(static_cast(vk::Format::eR8G8B8A8Unorm) == 37u);\n static_assert(static_cast(vk::ImageLayout::ePresentSrcKHR) == 1000001002u);\n\n // A struct with its generated `sType` default: this is Vulkan-Hpp's whole\n // reason to exist over the C headers, and it is default-member-initializer\n // state that has to survive the module build intact.\n static_assert(vk::ApplicationInfo{}.sType == vk::StructureType::eApplicationInfo);\n static_assert(std::is_standard_layout_v);\n\n // ── vulkan_to_string.hpp, which arrives through vulkan.hpp ──────────\n //\n // Not in `vulkan.cppm`'s include list, exported all the same. Both\n // overloads: the enum one and the bitmask one, which is a different\n // generated file section.\n if (vk::to_string(vk::Format::eR8G8B8A8Unorm) != \"R8G8B8A8Unorm\") {\n std::println(\"vk::to_string(Format) gave {}\", vk::to_string(vk::Format::eR8G8B8A8Unorm));\n return 5;\n }\n if (vk::to_string(vk::ImageUsageFlagBits::eColorAttachment) != \"ColorAttachment\") {\n std::println(\"vk::to_string(ImageUsageFlagBits) gave {}\",\n vk::to_string(vk::ImageUsageFlagBits::eColorAttachment));\n return 6;\n }\n\n // ── vulkan_format_traits.hpp: constexpr, evaluated at compile time ───\n static_assert(vk::blockSize(vk::Format::eR8G8B8A8Unorm) == 4);\n static_assert(vk::componentCount(vk::Format::eR8G8B8A8Unorm) == 4);\n static_assert(vk::blockSize(vk::Format::eBc1RgbUnormBlock) == 8);\n\n // ── vulkan_raii.hpp: asserted by type, not by construction ───────────\n //\n // Constructing a `vk::raii::Context` would dlopen the loader at RUNTIME,\n // which is a different claim than \"the RAII layer is in the module\" and is\n // not answerable on every runner (macOS links the loader statically, so\n // there is no library to open). The static members below are compile-time\n // facts of the RAII classes, so they prove the layer is present without\n // asking anything of the machine.\n static_assert(vk::raii::Instance::objectType == vk::ObjectType::eInstance);\n static_assert(vk::raii::Device::objectType == vk::ObjectType::eDevice);\n static_assert(std::is_same_v);\n\n // ── The loader, reached through the module's function layer ──────────\n //\n // Vulkan-Hpp defaults to the STATIC dispatcher (VULKAN_HPP_DISPATCH_LOADER_\n // DYNAMIC is 0 unless VK_NO_PROTOTYPES is defined), so these calls become\n // direct references to `vkEnumerateInstanceVersion` and friends. That is\n // the link-time proof that `khronos.vulkan-hpp`'s dependency on\n // `compat.vulkan` reaches a consumer: without it, this file does not link.\n //\n // Both answers come from the loader itself, before any ICD is consulted,\n // so they hold on a runner with no GPU. (The WSI surface extensions do NOT\n // — the loader advertises those only when some driver supports them, which\n // is why nothing here asserts on `VK_KHR_surface` being *reported*.)\n const std::uint32_t apiVersion = vk::enumerateInstanceVersion();\n if (vk::apiVersionMajor(apiVersion) < 1) {\n std::println(\"implausible loader api version: {}\", apiVersion);\n return 1;\n }\n\n const std::vector extensions = vk::enumerateInstanceExtensionProperties();\n\n // VK_EXT_debug_utils is implemented BY the loader, so it is present with or\n // without a driver. Reading it back through `vk::ExtensionProperties`\n // exercises the ArrayWrapper1D member that makes the C struct's\n // `char[256]` a real C++ string type.\n const bool haveDebugUtils = std::ranges::any_of(extensions, [](const vk::ExtensionProperties& e) {\n return std::string_view(e.extensionName) == \"VK_EXT_debug_utils\";\n });\n if (!haveDebugUtils) {\n std::println(\"VK_EXT_debug_utils missing from {} loader extension(s)\", extensions.size());\n for (const auto& e : extensions) std::println(\" {}\", std::string_view(e.extensionName));\n return 2;\n }\n\n // ── vulkan_extension_inspection.hpp: the registry tables ─────────────\n //\n // This is static knowledge compiled from the Vulkan XML registry, not a\n // question about this machine — `VK_KHR_swapchain` is a device extension\n // whether or not anything here implements it. A module missing this header\n // would fail to compile above; a module carrying an EMPTY table would\n // compile and answer false, which is what these check.\n if (!vk::isInstanceExtension(\"VK_KHR_surface\") || vk::isDeviceExtension(\"VK_KHR_surface\")) {\n std::println(\"extension inspection misclassified VK_KHR_surface\");\n return 3;\n }\n if (!vk::isDeviceExtension(\"VK_KHR_swapchain\")) {\n std::println(\"extension inspection does not know VK_KHR_swapchain is a device extension\");\n return 4;\n }\n\n std::println(\"khronos.vulkan-hpp: ok (import vulkan; loader api {}.{}.{}, {} instance extension(s))\",\n vk::apiVersionMajor(apiVersion), vk::apiVersionMinor(apiVersion),\n vk::apiVersionPatch(apiVersion), extensions.size());\n return 0;\n}\n" + }, + { + "path": "tests/examples/vulkan-hpp-module/tests/video.cpp", + "text": "// Behavioral test — the package's SECOND module unit, `vulkan_video`.\n//\n// It gets its own test file for a reason that is not tidiness: upstream's\n// `vulkan_video.cppm` says `import vulkan;`, so this module can only build\n// after the other one, from a source list that names them in no particular\n// order. A build system that compiled the two units independently would fail\n// here and nowhere else, and a package that shipped only the first unit would\n// fail at the `import` line below while `module.cpp` still passed.\n//\n// Everything asserted is compile-time video-codec-standard data (the H.264\n// numbers are ITU-T H.264's own), so this test needs no GPU and no video\n// hardware — only the module.\nimport std;\nimport vulkan_video;\n\nint main() {\n // Constants: `constexpr inline` variables at namespace scope inside the\n // module purview. They are the shape most likely to be lost.\n static_assert(vk::video::H264MaxNumListRef == 32);\n static_assert(vk::video::H264ScalingList8X8NumElements == 64);\n static_assert(vk::video::H264MaxChromaPlanes == 2);\n\n // Enumerators, wrapping the STD_VIDEO_* values from the vk_video/ C\n // headers — which is also proof that those headers, reached through\n // `compat.vulkan-headers`, were on the include path when the module unit\n // was compiled: the enums live behind `#if defined(VULKAN_VIDEO_CODEC_\n // H264STD_H_)`, so a build that missed them would compile an EMPTY\n // namespace rather than fail.\n static_assert(static_cast(vk::video::H264ProfileIdc::eBaseline) == 66u);\n static_assert(static_cast(vk::video::H264ProfileIdc::eHigh) == 100u);\n static_assert(static_cast(vk::video::H264ChromaFormatIdc::e420) == 1u);\n\n // A struct from the same header set, to show the type layer is there too.\n const vk::video::H264SpsFlags flags{};\n if (flags.direct_8x8_inference_flag != 0u) {\n std::println(\"vk::video::H264SpsFlags is not value-initialized\");\n return 1;\n }\n\n std::println(\"khronos.vulkan-hpp: ok (import vulkan_video; H.264 profile idc baseline={}, high={})\",\n static_cast(vk::video::H264ProfileIdc::eBaseline),\n static_cast(vk::video::H264ProfileIdc::eHigh));\n return 0;\n}\n" + } + ] + }, + { + "project": "vulkan-memory-allocator", + "text": "# VMA test project: drive the VIRTUAL allocator, which is VMA's sub-allocation\n# algorithm with the Vulkan calls removed. That makes it the one part of VMA\n# that can be asserted for real on a CI machine with no GPU and no driver --\n# and it still proves the implementation TU is compiled and linked.\n[package]\nname = \"vulkan-memory-allocator-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nvulkan-memory-allocator = \"3.4.0\"\n", + "files": [ + { + "path": "tests/examples/vulkan-memory-allocator/tests/virtual_block.cpp", + "text": "// compat.vulkan-memory-allocator — drive VMA's VIRTUAL allocator.\n//\n// Everything else in VMA needs a VkDevice, which a CI runner with no GPU and no\n// driver cannot create. The virtual allocator is the same sub-allocation\n// algorithm with the Vulkan calls stripped out: it hands out offsets inside a\n// notional block. That makes it the one part of VMA whose BEHAVIOUR can be\n// asserted here -- and since it is compiled from the same VMA_IMPLEMENTATION\n// TU as the rest, it still proves the package's implementation is built and\n// linked (these symbols do not exist in the header-only view).\n#include \nimport std;\n\nint main() {\n bool ok = true;\n auto check = [&](bool cond, std::string_view what) {\n if (!cond) {\n std::println(\"FAIL: {}\", what);\n ok = false;\n }\n };\n\n constexpr VkDeviceSize BLOCK = 1u << 20; // 1 MiB\n\n VmaVirtualBlockCreateInfo bci{};\n bci.size = BLOCK;\n\n VmaVirtualBlock block = VK_NULL_HANDLE;\n check(vmaCreateVirtualBlock(&bci, &block) == VK_SUCCESS, \"virtual block created\");\n check(block != VK_NULL_HANDLE, \"block handle is non-null\");\n if (block == VK_NULL_HANDLE) return 1;\n\n check(vmaIsVirtualBlockEmpty(block), \"a fresh block is empty\");\n\n // Allocate a set of differently-sized, differently-aligned chunks.\n struct Chunk { VmaVirtualAllocation h; VkDeviceSize off, size; };\n std::vector chunks;\n for (int i = 0; i < 16; ++i) {\n VmaVirtualAllocationCreateInfo aci{};\n aci.size = static_cast(1024u << (i % 5));\n aci.alignment = 256;\n\n VmaVirtualAllocation h{};\n VkDeviceSize off = 0;\n if (vmaVirtualAllocate(block, &aci, &h, &off) != VK_SUCCESS) {\n check(false, \"vmaVirtualAllocate succeeded\");\n break;\n }\n check(off % 256 == 0, \"returned offset honours the requested alignment\");\n check(off + aci.size <= BLOCK, \"allocation stays inside the block\");\n chunks.push_back({h, off, aci.size});\n }\n check(chunks.size() == 16, \"all 16 allocations succeeded\");\n check(!vmaIsVirtualBlockEmpty(block), \"block is not empty while allocations live\");\n\n // The whole point of an allocator: no two live allocations may overlap.\n std::ranges::sort(chunks, {}, &Chunk::off);\n for (std::size_t i = 1; i < chunks.size(); ++i) {\n check(chunks[i - 1].off + chunks[i - 1].size <= chunks[i].off,\n \"live allocations do not overlap\");\n }\n\n // Statistics must account for what we asked for.\n VkDeviceSize requested = 0;\n for (const auto& c : chunks) requested += c.size;\n\n VmaStatistics stats{};\n vmaGetVirtualBlockStatistics(block, &stats);\n check(stats.allocationCount == chunks.size(), \"stats report every allocation\");\n check(stats.allocationBytes == requested, \"stats report the requested bytes\");\n\n // Freeing must return the space: after freeing everything the block is\n // empty again and a single block-sized allocation fits.\n for (const auto& c : chunks) vmaVirtualFree(block, c.h);\n check(vmaIsVirtualBlockEmpty(block), \"block is empty again after freeing all\");\n\n VmaVirtualAllocationCreateInfo whole{};\n whole.size = BLOCK;\n VmaVirtualAllocation big{};\n VkDeviceSize big_off = 0;\n check(vmaVirtualAllocate(block, &whole, &big, &big_off) == VK_SUCCESS,\n \"the entire block is allocatable once freed\");\n check(big_off == 0, \"the block-sized allocation starts at offset 0\");\n vmaVirtualFree(block, big);\n\n // And an over-sized request must FAIL rather than silently succeed.\n VmaVirtualAllocationCreateInfo toobig{};\n toobig.size = BLOCK * 2;\n VmaVirtualAllocation none{};\n VkDeviceSize none_off = 0;\n check(vmaVirtualAllocate(block, &toobig, &none, &none_off) != VK_SUCCESS,\n \"an over-sized request is rejected\");\n\n vmaDestroyVirtualBlock(block);\n\n if (ok) std::println(\"VulkanMemoryAllocator OK\");\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "vulkan", + "text": "[package]\nname = \"vulkan-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nvulkan = \"1.4.357.3\"\n\n# All three platforms now carry compat.vulkan — linux/macOS build the loader\n# from source, windows links the import library. HAVE_VULKAN_LOADER stays as the\n# switch tests/loader.cpp keys off; it is unconditional again.\n[build]\ncxxflags = [\"-DHAVE_VULKAN_LOADER=1\"]\n", + "files": [ + { + "path": "tests/examples/vulkan/tests/loader.cpp", + "text": "// Behavioral test — verify compat.vulkan builds a working loader and that a\n// consumer can link its trampolines.\n//\n// Everything asserted here is answered by the LOADER itself, before any ICD is\n// involved, so it is meaningful on a CI runner with no GPU and no driver.\n// That constraint is sharper than it looks: the loader advertises the WSI\n// surface extensions only when some ICD supports them, so `VK_KHR_surface` is\n// legitimately ABSENT on a driverless machine and asserting on it would just be\n// testing the runner's hardware.\n//\n// HAVE_VULKAN_LOADER is set by THIS project's own [build] cxxflags. A consumer\n// that keys its source off a dependency's presence has to declare that itself.\n#if defined(HAVE_VULKAN_LOADER)\n#include \n#endif\nimport std;\n\n#if defined(HAVE_VULKAN_LOADER) && defined(_WIN32)\n// Declared rather than taken from : two functions are needed, and\n// the header's macros and min/max collide with `import std`. Both live in\n// kernel32, which every Windows program already links.\nextern \"C\" __declspec(dllimport) void* GetModuleHandleW(const wchar_t* name);\nextern \"C\" __declspec(dllimport) unsigned long GetModuleFileNameW(void* module, wchar_t* path,\n unsigned long size);\n#endif\n\n#if !defined(HAVE_VULKAN_LOADER)\nint main() {\n std::println(\"compat.vulkan: skipped (no windows build — static loader unsupported upstream)\");\n return 0;\n}\n#else\nint main() {\n // Only the loader can answer this — it reports the Vulkan version the\n // loader itself implements. A package that failed to compile its\n // trampolines fails at LINK here rather than passing quietly.\n std::uint32_t apiVersion = 0;\n if (vkEnumerateInstanceVersion(&apiVersion) != VK_SUCCESS) {\n std::println(\"vkEnumerateInstanceVersion failed\");\n return 1;\n }\n if (VK_VERSION_MAJOR(apiVersion) < 1) {\n std::println(\"implausible loader api version: {}\", apiVersion);\n return 2;\n }\n\n#if defined(_WIN32)\n // WHERE THE LOADER CAME FROM is the point of compat.vulkan 1.4.357.3 on\n // Windows: the package now ships vulkan-1.dll and mcpp places it beside the\n // executable, so a machine with no GPU driver can still start this program.\n // The loader has just answered, so the DLL is mapped; its directory must be\n // this executable's. That holds on a machine WITH a driver too (the\n // application directory precedes System32), which is what makes this a real\n // check: with an older compat.vulkan the loader came from System32 there,\n // and on a driverless machine the process never reached main.\n {\n std::vector dll(32768), exe(32768);\n void* module = GetModuleHandleW(L\"vulkan-1.dll\");\n if (module == nullptr) {\n std::println(\"vulkan-1.dll answered but is not mapped in this process\");\n return 7;\n }\n const auto dllLen = GetModuleFileNameW(module, dll.data(), 32768);\n const auto exeLen = GetModuleFileNameW(nullptr, exe.data(), 32768);\n auto dirOf = [](std::wstring_view p) {\n std::wstring d(p.substr(0, p.find_last_of(L\"\\\\/\")));\n for (auto& c : d) c = static_cast(std::towlower(c));\n return d;\n };\n auto narrow = [](std::wstring_view p) {\n std::string out;\n for (wchar_t c : p) out.push_back(c < 128 ? static_cast(c) : '?');\n return out;\n };\n const std::wstring_view dllPath(dll.data(), dllLen), exePath(exe.data(), exeLen);\n if (dirOf(dllPath) != dirOf(exePath)) {\n std::println(\"vulkan-1.dll was loaded from {}, not beside {}\",\n narrow(dllPath), narrow(exePath));\n return 8;\n }\n std::println(\"compat.vulkan: loader deployed beside the executable ({})\", narrow(dllPath));\n }\n#endif\n\n std::uint32_t extensionCount = 0;\n if (vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, nullptr) != VK_SUCCESS) {\n std::println(\"vkEnumerateInstanceExtensionProperties failed\");\n return 3;\n }\n std::vector extensions(extensionCount);\n if (extensionCount > 0 &&\n vkEnumerateInstanceExtensionProperties(nullptr, &extensionCount, extensions.data()) != VK_SUCCESS) {\n std::println(\"vkEnumerateInstanceExtensionProperties (fill) failed\");\n return 4;\n }\n\n // VK_EXT_debug_utils is implemented BY the loader, so it is present with or\n // without a driver — unlike the surface extensions. Its absence would mean\n // the loader's own extension table never made it into the lib.\n const bool haveDebugUtils = std::ranges::any_of(extensions, [](const VkExtensionProperties& e) {\n return std::string_view(e.extensionName) == VK_EXT_DEBUG_UTILS_EXTENSION_NAME;\n });\n if (!haveDebugUtils) {\n std::println(\"VK_EXT_debug_utils missing from {} loader extension(s)\", extensionCount);\n for (const auto& e : extensions) std::println(\" {}\", e.extensionName);\n return 5;\n }\n\n // Link-time proof that wsi.c is in the library. vkDestroySurfaceKHR is a\n // WSI trampoline; referencing it makes a build without that translation\n // unit fail to link, which is the check the extension list cannot give us\n // on a driverless machine.\n if (&vkDestroySurfaceKHR == nullptr) {\n std::println(\"vkDestroySurfaceKHR unexpectedly null\");\n return 6;\n }\n\n std::println(\"compat.vulkan: ok (loader api {}.{}.{}, {} loader extension(s), WSI trampolines linked)\",\n VK_VERSION_MAJOR(apiVersion), VK_VERSION_MINOR(apiVersion),\n VK_VERSION_PATCH(apiVersion), extensionCount);\n return 0;\n}\n#endif\n" + } + ] + }, + { + "project": "wamr-features", + "text": "# WAMR feature test project: both optional guest-libc layers turned on.\n#\n# Split from tests/examples/wamr because the base member asserts the opposite —\n# that `env.putchar` does NOT resolve without the feature. The two members\n# together are the feature gate's positive and negative evidence.\n[package]\nname = \"wamr-features-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nwamr = { version = \"2.4.5\", features = [\"libc-builtin\", \"libc-wasi\"] }\n", + "files": [ + { + "path": "tests/examples/wamr-features/tests/libc_features.cpp", + "text": "// Feature test for compat.wamr: assert that the two optional guest-libc layers\n// are actually linked in when requested.\n//\n// The discriminator is a CALL, not instantiation. WAMR does not reject a\n// module whose import is unresolved — the loader logs \"failed to link import\n// function\" and carries on, so instantiation succeeds either way. The refusal\n// only appears when the guest calls the import, as an exception naming it\n// \"unlinked\". tests/examples/wamr asserts exactly that for env.putchar against\n// the base package; here the same call must go through.\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\n// (module (import \"env\" \"putchar\" (func (param i32) (result i32)))\n// (func (export \"emit\") (param i32) (result i32) local.get 0 call 0))\nconst unsigned char kNeedsLibcBuiltin[] = {\n 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,\n 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f,\n 0x02, 0x0f, 0x01, 0x03, 'e', 'n', 'v',\n 0x07, 'p', 'u', 't', 'c', 'h', 'a', 'r', 0x00, 0x00,\n 0x03, 0x02, 0x01, 0x00,\n 0x07, 0x08, 0x01, 0x04, 'e', 'm', 'i', 't', 0x00, 0x01,\n 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b,\n};\n\n// (module (import \"wasi_snapshot_preview1\" \"proc_exit\" (func (param i32)))\n// (memory (export \"memory\") 1)\n// (func (export \"exit0\") (param i32) local.get 0 call 0))\n//\n// The memory is not optional decoration: WAMR refuses to load a module that\n// imports WASI apis without an exported memory (\"a module with WASI apis must\n// export memory by default\"), because the WASI wrappers address the guest's\n// linear memory to return their results.\nconst unsigned char kNeedsLibcWasi[] = {\n 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,\n 0x01, 0x05, 0x01, 0x60, 0x01, 0x7f, 0x00,\n 0x02, 0x24, 0x01,\n 0x16, 'w', 'a', 's', 'i', '_', 's', 'n', 'a', 'p', 's', 'h', 'o', 't',\n '_', 'p', 'r', 'e', 'v', 'i', 'e', 'w', '1',\n 0x09, 'p', 'r', 'o', 'c', '_', 'e', 'x', 'i', 't', 0x00, 0x00,\n 0x03, 0x02, 0x01, 0x00,\n 0x05, 0x03, 0x01, 0x00, 0x01,\n 0x07, 0x12, 0x02, 0x05, 'e', 'x', 'i', 't', '0', 0x00, 0x01,\n 0x06, 'm', 'e', 'm', 'o', 'r', 'y', 0x02, 0x00,\n 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b,\n};\n\nbool g_ok = true;\n\nvoid check(const char *what, bool cond)\n{\n std::printf(\"%-28s %s\\n\", what, cond ? \"ok\" : \"FAILED\");\n g_ok = g_ok && cond;\n}\n\n} // namespace\n\nint main()\n{\n RuntimeInitArgs init;\n std::memset(&init, 0, sizeof init);\n init.mem_alloc_type = Alloc_With_Allocator;\n init.mem_alloc_option.allocator.malloc_func = reinterpret_cast(std::malloc);\n init.mem_alloc_option.allocator.realloc_func = reinterpret_cast(std::realloc);\n init.mem_alloc_option.allocator.free_func = reinterpret_cast(std::free);\n\n if (!wasm_runtime_full_init(&init)) {\n std::puts(\"wasm_runtime_full_init failed\");\n return 1;\n }\n\n char err[192] = { 0 };\n\n // ── libc-builtin: env.putchar must link and run ────────────────────────\n {\n unsigned char image[sizeof kNeedsLibcBuiltin];\n std::memcpy(image, kNeedsLibcBuiltin, sizeof image);\n wasm_module_t mod = wasm_runtime_load(image, sizeof image, err, sizeof err);\n check(\"libc-builtin load\", mod != nullptr);\n if (mod) {\n wasm_module_inst_t inst =\n wasm_runtime_instantiate(mod, 8192, 8192, err, sizeof err);\n check(\"libc-builtin instantiate\", inst != nullptr);\n if (inst) {\n wasm_function_inst_t fn = wasm_runtime_lookup_function(inst, \"emit\");\n wasm_exec_env_t env = wasm_runtime_create_exec_env(inst, 8192);\n if (fn && env) {\n // putchar writes the byte and returns 1 (see\n // libc_builtin_wrapper.c putchar_wrapper) -- it is not the\n // C library's \"returns the character written\".\n uint32_t argv[1] = { 0x0a };\n bool called = wasm_runtime_call_wasm(env, fn, 1, argv);\n if (!called)\n std::printf(\" exception: %s\\n\",\n wasm_runtime_get_exception(inst));\n check(\"libc-builtin putchar call\", called && argv[0] == 1);\n }\n else {\n check(\"libc-builtin lookup/env\", false);\n }\n if (env)\n wasm_runtime_destroy_exec_env(env);\n wasm_runtime_deinstantiate(inst);\n }\n wasm_runtime_unload(mod);\n }\n }\n\n // ── libc-wasi: wasi_snapshot_preview1.proc_exit must link and run ──────\n // proc_exit does not end the process; the wrapper raises \"wasi proc exit\"\n // and records the code, which the embedder then reads. That round trip is\n // only possible with the feature's sources compiled in.\n {\n unsigned char image[sizeof kNeedsLibcWasi];\n std::memcpy(image, kNeedsLibcWasi, sizeof image);\n wasm_module_t mod = wasm_runtime_load(image, sizeof image, err, sizeof err);\n if (!mod)\n std::printf(\" load error: %s\\n\", err);\n check(\"libc-wasi load\", mod != nullptr);\n if (mod) {\n // Gives the module a wasi context; the wrapper dereferences it.\n wasm_runtime_set_wasi_args(mod, nullptr, 0, nullptr, 0, nullptr, 0,\n nullptr, 0);\n wasm_module_inst_t inst =\n wasm_runtime_instantiate(mod, 8192, 8192, err, sizeof err);\n check(\"libc-wasi instantiate\", inst != nullptr);\n if (inst) {\n wasm_function_inst_t fn = wasm_runtime_lookup_function(inst, \"exit0\");\n wasm_exec_env_t env = wasm_runtime_create_exec_env(inst, 8192);\n if (fn && env) {\n uint32_t argv[1] = { 3 };\n bool called = wasm_runtime_call_wasm(env, fn, 1, argv);\n const char *ex = wasm_runtime_get_exception(inst);\n std::printf(\" call=%d exception=%s exit_code=%u\\n\", called ? 1 : 0,\n ex ? ex : \"(none)\",\n wasm_runtime_get_wasi_exit_code(inst));\n // The one thing that must NOT happen is the unlinked-import\n // refusal; that is the signal the feature was not compiled in.\n bool unlinked = ex && std::strstr(ex, \"unlinked\") != nullptr;\n check(\"libc-wasi proc_exit linked\", !unlinked);\n check(\"libc-wasi exit code\", wasm_runtime_get_wasi_exit_code(inst) == 3);\n }\n else {\n check(\"libc-wasi lookup/env\", false);\n }\n if (env)\n wasm_runtime_destroy_exec_env(env);\n wasm_runtime_deinstantiate(inst);\n }\n wasm_runtime_unload(mod);\n }\n }\n\n wasm_runtime_destroy();\n return g_ok ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "wamr", + "text": "# WAMR test project: load a hand-assembled wasm module, instantiate it, call an\n# export, and have the guest call back into a host native function.\n#\n# The module bytes are written out by hand rather than produced by a wasm\n# toolchain, so the test has no build-time dependency beyond the package itself.\n#\n# compat.wamr carries a `linux` section only (see the descriptor's header), so\n# the dependency is gated and the test compiles to a no-op main() elsewhere —\n# the same shape tests/examples/libaio uses.\n[package]\nname = \"wamr-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nwamr = \"2.4.5\"\n", + "files": [ + { + "path": "tests/examples/wamr/tests/run_module.cpp", + "text": "// Behavioral test for compat.wamr: run a real wasm module through the\n// interpreter and let it call back into the host.\n//\n// Nothing here is mocked. A package that links but mis-configures the runtime\n// would pass a \"does it link\" test: without BUILD_TARGET_* the invoke-native\n// section compiles to nothing, and without the generated dispatcher there is\n// no invokeNative at all — either way the guest→host call below is what\n// notices.\n#ifdef __linux__\n\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\n// (module\n// (import \"env\" \"host_mul\" (func $host_mul (param i32 i32) (result i32)))\n// (func (export \"compute\") (param i32 i32) (result i32)\n// local.get 0 local.get 1 call $host_mul))\nconst unsigned char kCallsHost[] = {\n 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,\n 0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7f,\n 0x02, 0x10, 0x01, 0x03, 'e', 'n', 'v',\n 0x08, 'h', 'o', 's', 't', '_', 'm', 'u', 'l', 0x00, 0x00,\n 0x03, 0x02, 0x01, 0x00,\n 0x07, 0x0b, 0x01, 0x07, 'c', 'o', 'm', 'p', 'u', 't', 'e', 0x00, 0x01,\n 0x0a, 0x0a, 0x01, 0x08, 0x00, 0x20, 0x00, 0x20, 0x01, 0x10, 0x00, 0x0b,\n};\n\n// Same shape, but importing `env.putchar` — one of the wrappers the\n// `libc-builtin` feature adds. The base package must NOT resolve it; this is\n// the negative half of the feature gate, with the positive half in\n// tests/examples/wamr-features.\nconst unsigned char kNeedsLibcBuiltin[] = {\n 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,\n 0x01, 0x06, 0x01, 0x60, 0x01, 0x7f, 0x01, 0x7f,\n 0x02, 0x0f, 0x01, 0x03, 'e', 'n', 'v',\n 0x07, 'p', 'u', 't', 'c', 'h', 'a', 'r', 0x00, 0x00,\n 0x03, 0x02, 0x01, 0x00,\n 0x07, 0x08, 0x01, 0x04, 'e', 'm', 'i', 't', 0x00, 0x01,\n 0x0a, 0x08, 0x01, 0x06, 0x00, 0x20, 0x00, 0x10, 0x00, 0x0b,\n};\n\nint host_mul(wasm_exec_env_t, int a, int b) { return a * b; }\n\nNativeSymbol g_natives[] = {\n { \"host_mul\", reinterpret_cast(host_mul), \"(ii)i\", nullptr },\n};\n\n// Calls `emit` in the given module and reports whether WAMR refused because\n// the import was never linked.\n//\n// An unresolved import is NOT an instantiation error in WAMR — the loader only\n// logs \"failed to link import function\" and defers, so asking whether the\n// module instantiates says nothing about the feature gate. The refusal only\n// materialises on the call, as an exception naming the unlinked function.\nbool refused_as_unlinked(const unsigned char *bytes, unsigned size)\n{\n char err[192] = { 0 };\n // wasm_runtime_load takes a mutable buffer: it may patch the module image\n // in place, so hand it a copy rather than the const literal.\n unsigned char *buf = static_cast(std::malloc(size));\n if (!buf)\n return false;\n std::memcpy(buf, bytes, size);\n\n bool unlinked = false;\n wasm_module_t mod = wasm_runtime_load(buf, size, err, sizeof err);\n if (!mod) {\n std::printf(\" load rejected: %s\\n\", err);\n }\n else {\n wasm_module_inst_t inst =\n wasm_runtime_instantiate(mod, 8192, 8192, err, sizeof err);\n if (!inst) {\n std::printf(\" instantiate rejected: %s\\n\", err);\n }\n else {\n wasm_function_inst_t fn = wasm_runtime_lookup_function(inst, \"emit\");\n wasm_exec_env_t env = wasm_runtime_create_exec_env(inst, 8192);\n if (fn && env) {\n uint32_t argv[1] = { 65 };\n if (!wasm_runtime_call_wasm(env, fn, 1, argv)) {\n const char *ex = wasm_runtime_get_exception(inst);\n std::printf(\" exception: %s\\n\", ex ? ex : \"(none)\");\n unlinked = ex && std::strstr(ex, \"unlinked\") != nullptr;\n }\n }\n if (env)\n wasm_runtime_destroy_exec_env(env);\n wasm_runtime_deinstantiate(inst);\n }\n wasm_runtime_unload(mod);\n }\n std::free(buf);\n return unlinked;\n}\n\n} // namespace\n\nint main()\n{\n RuntimeInitArgs init;\n std::memset(&init, 0, sizeof init);\n init.mem_alloc_type = Alloc_With_Allocator;\n init.mem_alloc_option.allocator.malloc_func = reinterpret_cast(std::malloc);\n init.mem_alloc_option.allocator.realloc_func = reinterpret_cast(std::realloc);\n init.mem_alloc_option.allocator.free_func = reinterpret_cast(std::free);\n init.native_module_name = \"env\";\n init.native_symbols = g_natives;\n init.n_native_symbols = 1;\n\n bool ok = wasm_runtime_full_init(&init);\n if (!ok) {\n std::puts(\"wasm_runtime_full_init failed\");\n return 1;\n }\n\n char err[192] = { 0 };\n unsigned char image[sizeof kCallsHost];\n std::memcpy(image, kCallsHost, sizeof image);\n\n wasm_module_t mod = wasm_runtime_load(image, sizeof image, err, sizeof err);\n ok = ok && mod != nullptr;\n if (!mod)\n std::printf(\"load failed: %s\\n\", err);\n\n wasm_module_inst_t inst = nullptr;\n if (mod) {\n inst = wasm_runtime_instantiate(mod, 8192, 8192, err, sizeof err);\n ok = ok && inst != nullptr;\n if (!inst)\n std::printf(\"instantiate failed: %s\\n\", err);\n }\n\n if (inst) {\n wasm_function_inst_t fn = wasm_runtime_lookup_function(inst, \"compute\");\n ok = ok && fn != nullptr;\n if (fn) {\n wasm_exec_env_t env = wasm_runtime_create_exec_env(inst, 8192);\n ok = ok && env != nullptr;\n if (env) {\n uint32_t argv[2] = { 6, 7 };\n bool called = wasm_runtime_call_wasm(env, fn, 2, argv);\n if (!called)\n std::printf(\"call failed: %s\\n\",\n wasm_runtime_get_exception(inst));\n // 6 * 7 computed by the HOST and returned through the guest.\n ok = ok && called && argv[0] == 42u;\n std::printf(\"compute(6,7) = %u (expected 42)\\n\", argv[0]);\n wasm_runtime_destroy_exec_env(env);\n }\n }\n wasm_runtime_deinstantiate(inst);\n }\n if (mod)\n wasm_runtime_unload(mod);\n\n // Feature gate, negative direction: libc-builtin is not in the base build,\n // so a module importing env.putchar must be refused.\n std::puts(\"expecting env.putchar to be unlinked (libc-builtin is off):\");\n bool libc_absent =\n refused_as_unlinked(kNeedsLibcBuiltin, sizeof kNeedsLibcBuiltin);\n ok = ok && libc_absent;\n std::printf(\"libc-builtin gated out: %s\\n\", libc_absent ? \"yes\" : \"NO\");\n\n wasm_runtime_destroy();\n return ok ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "wayland-cursor", + "text": "# wayland-cursor test member — the client-side pointer.\n#\n# Own `[indices]` for the reason the sibling wayland members document: the\n# workspace root maps `compat` to this checkout and lookup is BY NAMESPACE.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n# ONE package named, and libwayland-client arrives with it — `wayland-cursor`\n# declares it as a PATH dependency (they are workspace members of one fork), and\n# naming it here as a version dependency too is rejected outright. Same shape as\n# tests/examples/wayland-egl; the note there explains why the diagnostic is\n# right.\n[package]\nname = \"wayland-cursor-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.freedesktop]\nwayland-cursor = \"1.26.0\"\n", + "files": [ + { + "path": "tests/examples/wayland-cursor/tests/wayland-cursor.cpp", + "text": "// freedesktop.wayland-cursor — the client-side pointer.\n//\n// WHAT CANNOT BE ASSERTED HERE, AND WHY THE FIRST VERSION OF THIS FILE CRASHED\n//\n// `wl_cursor_theme_load(name, size, shm)` is not callable without a compositor,\n// and not merely inadvisable: line 410 of upstream's wayland-cursor.c is\n//\n// theme->pool = shm_pool_create(shm, size * size * 4);\n//\n// which reaches `wl_shm_create_pool(shm, ...)` and dereferences the proxy with\n// no null check. The first version of this test passed `nullptr` on the theory\n// that only the theme PARSER would run — it segfaulted (exit 139). A `wl_shm`\n// comes from a compositor's registry, so the buffer half of this library\n// belongs in a program that has one, exactly as opening /dev/input/event*\n// belongs outside tests/examples/libinput.\n//\n// WHAT IS ASSERTED IS STILL THE PART THAT CARRIES THE PACKAGING RISK: that the\n// hand-written module wrapper exports every name, that the two PUBLIC structs\n// are usable as values rather than opaque handles, and that libwayland-client\n// is really linked in. The compiled-in cursor search path being empty — the\n// other packaging decision — is checked in the fork's CI by grepping the\n// binary, which is the only place it is visible.\n\n#ifdef __linux__\n\n#include \n#include \n\n#include \n#include \n#include \n\nimport freedesktop.wayland.cursor;\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The module carries the API ────────────────────────────────────\n // Taking the address of each: a name that vanished from the hand-written\n // module wrapper is a compile error here. That is the guarantee\n // mcpp/tools/genmod.py gives the client and server wrappers, which this\n // one is too small to justify.\n check(&wl_cursor_theme_load != nullptr, \"module exports wl_cursor_theme_load\");\n check(&wl_cursor_theme_destroy != nullptr, \"module exports wl_cursor_theme_destroy\");\n check(&wl_cursor_theme_get_cursor != nullptr, \"module exports wl_cursor_theme_get_cursor\");\n check(&wl_cursor_image_get_buffer != nullptr, \"module exports wl_cursor_image_get_buffer\");\n check(&wl_cursor_frame != nullptr, \"module exports wl_cursor_frame\");\n check(&wl_cursor_frame_and_duration != nullptr, \"module exports wl_cursor_frame_and_duration\");\n\n // ── 2. The public structs are VALUES, not opaque handles ─────────────\n // `wl_cursor` and `wl_cursor_image` have their fields in the public header\n // and a client reads them to place the hotspot every time the pointer\n // enters a surface. Exporting them as opaque would compile here and fail\n // at the caller's first `image->hotspot_x`, which is why the fields are\n // touched rather than just the types named.\n wl_cursor_image img{};\n img.width = 24; img.height = 24;\n img.hotspot_x = 4; img.hotspot_y = 4; img.delay = 50;\n check(img.width == 24 && img.hotspot_x == 4 && img.delay == 50,\n \"wl_cursor_image fields are reachable through the module\");\n\n wl_cursor cur{};\n wl_cursor_image *one = &img;\n cur.image_count = 1;\n cur.images = &one;\n cur.name = const_cast(\"left_ptr\");\n check(cur.image_count == 1 && cur.images[0]->hotspot_y == 4,\n \"wl_cursor fields are reachable, including the images array\");\n\n // ── 3. wl_cursor_frame works on a theme-less cursor ──────────────────\n // The animation helper is pure arithmetic over the images array — no shm,\n // no compositor — so it IS callable here, and it is the one piece of real\n // behaviour this test can exercise. A single-image cursor is not animated,\n // so every timestamp must select frame 0.\n check(wl_cursor_frame(&cur, 0) == 0, \"wl_cursor_frame(t=0) selects the only frame\");\n check(wl_cursor_frame(&cur, 100000) == 0, \"…and so does a far-future timestamp\");\n\n uint32_t duration = 12345; // wayland-cursor.h includes , not \n const int f = wl_cursor_frame_and_duration(&cur, 0, &duration);\n std::printf(\" frame_and_duration -> frame %d, duration %u\\n\", f, duration);\n check(f == 0, \"wl_cursor_frame_and_duration agrees on the frame\");\n\n // ── 4. It links the client for real ──────────────────────────────────\n // Unlike libwayland-egl, this library CALLS into libwayland-client — that\n // is exactly why §3's theme loader needs a live wl_shm. The two are in one\n // program by necessity rather than by convention.\n //\n // `wl_display_connect` AND NOT `wl_shm_create_pool`, which is what this\n // check said first and what the llvm leg rejected:\n //\n // ld.lld: error: undefined symbol: wl_proxy_get_version\n // >>> wayland-cursor.o:(wl_shm_create_pool(wl_shm*, int, int))\n //\n // `wl_shm_create_pool` is one of wayland-scanner's `static inline`\n // protocol wrappers (mcpp/generated/wayland-client-protocol.h:2202), so\n // taking its address FORCES this translation unit to emit a copy — and\n // that copy calls `wl_proxy_marshal_flags` and friends, which live in\n // libwayland-client. GNU ld resolves those through the transitive\n // DT_NEEDED of libwayland-cursor; lld deliberately does not, and lld is\n // right: a program that names a symbol should link the library that\n // defines it.\n //\n // `wl_display_connect` is an ordinary exported function, so referencing it\n // proves the same thing without emitting anything. Same form the sibling\n // wayland-egl test uses, which is why that one was green on both legs.\n check(&wl_display_connect != nullptr,\n \"libwayland-client is linked in (wl_display_connect)\");\n\n // ── 5. Report the environment the ecosystem is expected to fill ───────\n // Not asserted: nothing in the index or in xim ships cursor themes yet, so\n // an unset XCURSOR_PATH is the current correct state rather than a defect.\n // When a theme package exists this becomes an assertion, exactly as the\n // RMLVO check in tests/examples/libxkbcommon did once xkeyboard-config\n // landed.\n const char *xp = std::getenv(\"XCURSOR_PATH\");\n std::printf(\" XCURSOR_PATH = %s\\n\",\n xp ? xp : \"(unset — no cursor-theme package exists yet)\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "wayland-egl", + "text": "# wayland-egl test member — the client-side GPU path.\n#\n# Its own `[indices]`, for the reason the sibling wayland member documents: the\n# workspace root maps `compat` to this checkout and lookup is BY NAMESPACE, so\n# `freedesktop.*` would otherwise resolve against the published index instead of\n# the packages under test.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"wayland-egl-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# ONE package named, and libwayland-client arrives with it.\n#\n# `freedesktop.wayland-egl`'s own manifest declares `freedesktop.wayland` as a\n# PATH dependency — the two are workspace members of the same fork — and naming\n# it here as a version dependency as well is rejected outright:\n#\n# error: dependency 'freedesktop.wayland' is requested as both a version\n# dep (by 'wayland-egl-tests') and a path dep (by\n# 'freedesktop.wayland-egl@1.26.0'). Pick one.\n#\n# That is the right diagnostic: two spellings of one dependency could resolve\n# to two different trees. So the client comes in transitively, which is also\n# what a real consumer gets — `wl_egl_window_create` takes a `wl_surface *`\n# that only libwayland-client can produce, so the edge exists whether or not\n# the consumer names it.\n[target.'cfg(linux)'.dependencies.freedesktop]\nwayland-egl = \"1.26.0\"\n", + "files": [ + { + "path": "tests/examples/wayland-egl/tests/wayland-egl.cpp", + "text": "// freedesktop.wayland-egl — the step that used to be missing.\n//\n// WHAT THIS CAN AND CANNOT ASSERT, decided by what the library actually is.\n//\n// libwayland-egl is 118 lines and holds no connection: `wl_egl_window_create`\n// allocates a struct, stores the `wl_surface *` it was handed, and records a\n// size. It calls no client function and talks to no compositor — an EGL\n// implementation later reads and writes the struct through\n// `wayland-egl-backend.h`.\n//\n// So a CI runner with no compositor can exercise the whole library honestly.\n// What it cannot do is `eglCreateWindowSurface`, which needs a real\n// `wl_display` from a real compositor — asserting that here would only assert\n// that the runner has a desktop session.\n//\n// THE BACKEND HEADER IS INCLUDED ON PURPOSE. `wayland-egl-core.h` exposes four\n// functions and an opaque type, so through it alone the only observable is\n// \"create returned non-null\". `wayland-egl-backend.h` is the contract this\n// library has with an EGL implementation — Mesa includes it — and reading the\n// struct through it is what lets the test check that create and resize wrote\n// what they claim.\n//\n// The `wl_surface *` is a null pointer ON PURPOSE: upstream stores it without\n// dereferencing (wayland-egl.c:93), so this exercises the real path, and the\n// test can then assert the pointer round-tripped.\n\n#ifdef __linux__\n\n#include \n#include \n#include \n\n#include \n#include \n\nimport freedesktop.wayland.egl;\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. The module carries the API ────────────────────────────────────\n // Taking the address of each is what makes this a check rather than a\n // comment: a name that vanished from the module is a compile error here,\n // which is the guarantee genmod.py gives the client and server wrappers\n // and that this hand-written module needs by other means.\n check(&wl_egl_window_create != nullptr, \"module exports wl_egl_window_create\");\n check(&wl_egl_window_destroy != nullptr, \"module exports wl_egl_window_destroy\");\n check(&wl_egl_window_resize != nullptr, \"module exports wl_egl_window_resize\");\n check(&wl_egl_window_get_attached_size != nullptr, \"module exports wl_egl_window_get_attached_size\");\n\n // ── 2. The bridge itself ─────────────────────────────────────────────\n // This is the call that did not exist in this ecosystem before: the only\n // way to turn a wl_surface into something EGL can render into.\n wl_egl_window *w = wl_egl_window_create(nullptr, 640, 480);\n check(w != nullptr, \"wl_egl_window_create(surface, 640, 480)\");\n if (w == nullptr) {\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return 1;\n }\n\n // ── 3. It wrote what it was given ────────────────────────────────────\n // Read back through the backend header, which is how Mesa reads it.\n // `version` matters most of the three: an EGL implementation switches on\n // it, and it is the field upstream casts away constness to set.\n std::printf(\" version=%ld size=%d x %d\\n\",\n (long)w->version, w->width, w->height);\n check(w->version == WL_EGL_WINDOW_VERSION, \"…and stamped WL_EGL_WINDOW_VERSION\");\n check(w->width == 640 && w->height == 480, \"…and recorded 640 x 480\");\n check(w->surface == nullptr, \"…and round-tripped the wl_surface pointer\");\n\n // ── 4. attached size is 0 BEFORE any EGL implementation attaches ─────\n // Not an oversight and not the creation size: `attached_width/height` are\n // written by the EGL side when it attaches a buffer, and `create` calloc's\n // them to zero. The first version of this test asserted 640 x 480 here and\n // was wrong — the check now encodes the real contract, which is that a\n // window nothing has rendered into reports nothing attached.\n int aw = -1, ah = -1;\n wl_egl_window_get_attached_size(w, &aw, &ah);\n std::printf(\" attached size before any EGL attach: %d x %d\\n\", aw, ah);\n check(aw == 0 && ah == 0, \"get_attached_size reports 0 x 0 until EGL attaches\");\n\n // ── 5. Resize is what a client calls on every configure event ────────\n wl_egl_window_resize(w, 800, 600, 0, 0);\n check(w->width == 800 && w->height == 600, \"wl_egl_window_resize updated the geometry\");\n\n // A rejected resize, which is a real branch: upstream returns early on a\n // non-positive dimension rather than storing it, so the previous size must\n // survive. Same guard `create` uses.\n wl_egl_window_resize(w, 0, 600, 0, 0);\n check(w->width == 800 && w->height == 600, \"…and a width of 0 is refused, not stored\");\n\n wl_egl_window_destroy(w);\n check(true, \"wl_egl_window_destroy\");\n\n // ── 6. create rejects a degenerate size ──────────────────────────────\n check(wl_egl_window_create(nullptr, 0, 480) == nullptr,\n \"wl_egl_window_create refuses a width of 0\");\n\n // ── 7. The two halves agree on the linkage they share ────────────────\n // `wl_display_connect` comes from libwayland-client and is NOT in\n // libwayland-egl — the fork's CI asserts that separation on the library,\n // and this asserts the consumer side: both are reachable from one program,\n // which is the arrangement a real client is in.\n check(&wl_display_connect != nullptr,\n \"libwayland-client is in the same program (wl_display_connect)\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "wayland-protocols", + "text": "# wayland-protocols test project.\n#\n# THIS MEMBER DECLARES ITS OWN [indices]: an index is looked up BY NAMESPACE,\n# and the workspace root maps `compat`, so `freedesktop.*` would resolve\n# against the published index rather than the packages under test.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"wayland-protocols-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# ONLY staging is named, and stable arrives with it.\n#\n# staging depends on stable INSIDE the fork, as a path dependency — measured\n# necessity, not convention: staging references `xdg_toplevel_interface` and\n# `zwp_tablet_tool_v2_interface`, which stable defines. So naming both here is\n# an error rather than a redundancy:\n#\n# dependency freedesktop.wayland-protocols-stable is requested as both a\n# version dep (by wayland-protocols-tests) and a path dep (by\n# freedesktop.wayland-protocols-staging.49). Pick one.\n#\n# The rule that falls out: a project that wants staging names staging; one that\n# wants only stable names stable. Never both. This member takes the first form,\n# and the test then proves that protocols from BOTH tiers link into one\n# program — which is the property the three-package split exists to preserve.\n#\n# stable + unstable is the pair that CANNOT coexist (13 shared symbols). That\n# is upstream's semantics rather than a defect, and testing it would mean\n# asserting a link failure.\n[target.'cfg(linux)'.dependencies.freedesktop]\nwayland-protocols-staging = \"1.49.1\"\n", + "files": [ + { + "path": "tests/examples/wayland-protocols/tests/wayland-protocols.cpp", + "text": "// wayland-protocols, as a compositor would consume it.\n//\n// The packages ship the marshalling code wayland-scanner generates from\n// upstream's XML. Three things can be wrong and none is a missing symbol:\n//\n// 1. THE HEADERS ARE THE PUBLIC INTERFACE. A consumer writes\n// `#include `, so the generated directory\n// has to be EXPOSED rather than build-private. This file compiling is the\n// first assertion, and freedesktop.wayland had exactly this bug once —\n// masked locally by a header the SubOS happened to carry.\n//\n// 2. THE INTERFACE TABLES MUST LINK. A header-only package sails past\n// compilation; `xdg_wm_base_interface` is a `wl_interface` OBJECT in the\n// generated .c, so reading it proves the .c files were compiled.\n//\n// 3. TWO TIERS MUST COMPOSE. stable and staging are separate packages\n// precisely so they can be used together; that they do is what this\n// member is for. (stable + unstable is the pair that cannot — 13 shared\n// symbols — which is upstream's semantics rather than a defect.)\n//\n// Nothing here connects: no compositor and no display.\n\n#ifdef __linux__\n\n#include \n#include \n\n#include // stable\n#include \n#include // stable — the GBM bridge\n#include // staging\n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\nvoid report(const wl_interface &i, const char *want, const char *tier)\n{\n std::printf(\" %-8s %-28s version %d, %d method(s)\\n\",\n tier, i.name ? i.name : \"(null)\", i.version, i.method_count);\n check(i.name != nullptr && std::strcmp(i.name, want) == 0,\n (std::string(tier) + \" \" + want + \" is linked in\").c_str());\n check(i.method_count > 0, \"…and it describes its requests\");\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. Both tiers linked, in one program ─────────────────────────────\n report(xdg_wm_base_interface, \"xdg_wm_base\", \"stable\");\n report(zwp_linux_dmabuf_v1_interface, \"zwp_linux_dmabuf_v1\", \"stable\");\n report(wp_cursor_shape_manager_v1_interface,\n \"wp_cursor_shape_manager_v1\", \"staging\");\n\n // ── 2. The server side was generated too ─────────────────────────────\n // `xdg_wm_base_send_ping` is a `static inline` only the SERVER header\n // defines. Client and server headers are separate scanner outputs and a\n // packaging mistake tends to lose one of them.\n check(reinterpret_cast(&xdg_wm_base_send_ping) != nullptr,\n \"the server-side headers were generated too\");\n\n // ── 3. The library that marshals these is new enough ─────────────────\n // The generated code calls wl_proxy_marshal_flags, which arrived in\n // libwayland 1.19 — so the protocols and the client library are not from\n // different eras.\n check(reinterpret_cast(&wl_proxy_marshal_flags) != nullptr,\n \"libwayland provides wl_proxy_marshal_flags\");\n check(reinterpret_cast(&wl_display_create) != nullptr,\n \"libwayland-server is linked for the compositor side\");\n\n // ── 4. The GBM bridge protocol is the stable one ─────────────────────\n // linux-dmabuf is how a compositor hands a gbm_bo to a client, so it is\n // the protocol that ties this package to compat.libgbm. It lives in\n // stable/ upstream; the unstable spelling is one of the three this index\n // deliberately does not ship.\n check(zwp_linux_dmabuf_v1_interface.version >= 4,\n \"linux-dmabuf is the stable v4+ interface, not the unstable spelling\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n// ── 1.49.1: the enum headers upstream installs ───────────────────────────────\n//\n// `wayland-scanner enum-header` output, which this fork did not generate before\n// 1.49.1. wlroots 0.20 includes these from TEN of its PUBLIC headers, so a\n// consumer of wlroots writing the ordinary `#include ` needs them on\n// the include path — which is why they belong to THIS package rather than to\n// wlroots.\n//\n// The enum header carries only the protocol's enums: no interface symbols and\n// no libwayland dependency. Including it in a translation unit that links\n// nothing proves exactly that.\n#include \n#include \n\nstatic_assert(XDG_TOPLEVEL_STATE_MAXIMIZED == 1,\n \"xdg-shell-enum.h did not come from the scanner\");\nstatic_assert(ZWP_TABLET_TOOL_V2_TYPE_PEN == 0x140,\n \"tablet-v2-enum.h did not come from the scanner\");\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + } + ] + }, + { + "project": "wayland", + "text": "# wayland test member.\n#\n# THIS MEMBER DECLARES ITS OWN [indices], and has to. The workspace root maps\n# the `compat` namespace to this checkout, and an index is looked up BY\n# NAMESPACE — so `freedesktop.*` would resolve against the published index\n# rather than the packages under test. A member-level table REPLACES the root's\n# rather than merging, which is what keeps this to one repo per member; the\n# transitive `compat.libffi` these packages need therefore comes from the\n# published index, which is correct: it is published.\n[indices]\nfreedesktop = { path = \"../../..\" }\n\n[package]\nname = \"wayland-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n# Two packages, not one with an ldflags escape hatch. The client and the server\n# are distinct SONAMEs — Mesa's libEGL_mesa carries DT_NEEDED on both — so they\n# are two libraries with disjoint contents, and a compositor asks for the one it\n# needs by name.\n#\n# wayland-util is the third: wayland's public MACROS, which no module can\n# export, mapped to the entities they actually are.\n[target.'cfg(linux)'.dependencies.freedesktop]\nwayland = \"1.26.0\"\nwayland-server = \"1.26.0\"\nwayland-util = \"1.26.0\"\n", + "files": [ + { + "path": "tests/examples/wayland/tests/modules.cpp", + "text": "// The module wrappers, used the way a consumer would.\n//\n// The claim they make is narrow and testable: `import freedesktop.wayland.client;` gives\n// you the same entities `#include ` does, spelled the same\n// way. So this file includes NOTHING from wayland — no header — and still\n// calls the stock API. If an export were missing, this would not compile; if a\n// name had been renamed or wrapped, it would not compile either.\n//\n// tests/wayland.cpp is the header-based sibling. Both must pass: the module\n// layer is an addition, not a replacement.\n\n#ifdef __linux__\n\n#include \n#include \n#include \n\nimport freedesktop.wayland.client;\nimport freedesktop.wayland.server;\nimport freedesktop.wayland.util;\n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) ++failures;\n}\n\n// A node linked through wl_list, the shape every wayland consumer writes.\nstruct listener_node {\n int id;\n wl_list link;\n};\n\n} // namespace\n\nint main()\n{\n // ── 1. Client entities, with no #include anywhere ────────────────────\n {\n wl_display *d = wl_display_connect(\"mcpp-no-such-compositor\");\n check(d == nullptr, \"wl_display_connect through the module returns NULL\");\n\n // A protocol interface object: generated code, exported by the module.\n check(std::string(wl_registry_interface.name) == \"wl_registry\",\n \"wl_registry_interface arrives through freedesktop.wayland.client\");\n check(std::string(wl_compositor_interface.name) == \"wl_compositor\",\n \"so does wl_compositor_interface\");\n }\n\n // ── 2. Server entities ───────────────────────────────────────────────\n {\n wl_display *s = wl_display_create();\n check(s != nullptr, \"wl_display_create through freedesktop.wayland.server\");\n if (s != nullptr) {\n wl_event_loop *loop = wl_display_get_event_loop(s);\n check(loop != nullptr, \"wl_display_get_event_loop returns a loop\");\n wl_display_destroy(s);\n }\n }\n\n // ── 3. The macros, as entities ───────────────────────────────────────\n // These are the names a module cannot export as macros; freedesktop.wayland.util\n // carries them as a constant, a template and ranges. Exercised here in a\n // consumer rather than only in the package's own test.\n {\n wl_list head;\n head.prev = &head;\n head.next = &head;\n\n listener_node a{1, {}}, b{2, {}};\n for (listener_node *n : {&a, &b}) {\n wl_list *prev = head.prev;\n n->link.prev = prev; n->link.next = &head;\n prev->next = &n->link; head.prev = &n->link;\n }\n\n std::vector seen;\n for (listener_node *n : wl_list_each<&listener_node::link>(&head)) {\n seen.push_back(n->id);\n }\n check(seen.size() == 2 && seen[0] == 1 && seen[1] == 2,\n \"wl_list_each walks a consumer's own list type\");\n\n check(wl_container_of<&listener_node::link>(&b.link) == &b,\n \"wl_container_of recovers the containing object\");\n\n check(WL_MARSHAL_FLAG_DESTROY == 1u,\n \"WL_MARSHAL_FLAG_DESTROY keeps upstream's value\");\n }\n\n // ⭐ 这八个函数在 mcpp4 之前根本够不着(`static inline` 无法从模块导出),\n // 而**没有任何测试调用过它们**,所以谁都没发现。缺口是从外面被一个最小\n // wlroots 合成器问出来的。下面每一行都是\"再塌陷就编译不过\"的探针。\n {\n // wayland-util.h 的定点数 —— 协议里每个亚像素坐标的载体\n check(wl_fixed_from_int(3) == 768, \"wl_fixed_from_int\");\n check(wl_fixed_to_double(wl_fixed_from_double(1.5)) == 1.5, \"wl_fixed double 往返\");\n check(wl_fixed_from_double(-1.5) == -384, \"负数按上游 round() 舍入\");\n\n // wayland-server-core.h 的 signal —— 每次 wlroots 挂监听都要它\n wl_signal sig{};\n wl_signal_init(&sig);\n check(sig.listener_list.next == &sig.listener_list, \"wl_signal_init\");\n wl_listener l{};\n l.notify = [](wl_listener *, void *) {};\n wl_signal_add(&sig, &l);\n check(wl_signal_get(&sig, l.notify) == &l, \"wl_signal_add + wl_signal_get\");\n wl_list_remove(&l.link);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main() { return 0; }\n\n#endif\n" + }, + { + "path": "tests/examples/wayland/tests/wayland.cpp", + "text": "// compat.wayland — behavioral test, runnable with no compositor.\n//\n// Two seams, and the second is the one a compositor author depends on.\n//\n// 1. THE CLIENT LIBRARY, which the package puts on the link line itself.\n// `wl_display_connect` on a machine with no compositor must return NULL\n// cleanly -- that exercises the real library (it reads WAYLAND_DISPLAY,\n// builds a socket path and fails to connect) without needing a server.\n//\n// 2. THE LIBRARIES THE PACKAGE DELIBERATELY DOES NOT LINK. libwayland-server\n// is harvested into the farm but kept off `ldflags`, so a consumer reaches\n// it by adding `-lwayland-server` to its own build — which this member's\n// mcpp.toml does. If the farm ever stops carrying the server library, the\n// documented escape hatch silently stops working; this file is what turns\n// that into a link error here instead.\n\n#ifdef __linux__\n\n#include \n#include \n\n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-58s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. Headers resolved from the flat include root ───────────────────\n check(WL_DISPLAY_ERROR_INVALID_OBJECT == 0,\n \"wayland-client.h provides the core protocol enums\");\n\n // ── 2. Both libraries are really linked ──────────────────────────────\n for (const char *sym : {\"wl_display_connect\", \"wl_display_disconnect\",\n \"wl_proxy_marshal\", \"wl_registry_interface\"}) {\n check(::dlsym(RTLD_DEFAULT, sym) != nullptr,\n (std::string(\"libwayland-client exports \") + sym).c_str());\n }\n // This one comes from the farm via the consumer's own -lwayland-server,\n // not from anything this package puts on the link line.\n for (const char *sym : {\"wl_display_create\", \"wl_display_destroy\"}) {\n check(::dlsym(RTLD_DEFAULT, sym) != nullptr,\n (std::string(\"libwayland-server exports \") + sym).c_str());\n }\n\n // ── 3. Connecting with no compositor fails cleanly ───────────────────\n // Deliberately points at a socket that cannot exist, so the result does\n // not depend on whether the machine running the test has a session.\n ::setenv(\"WAYLAND_DISPLAY\", \"mcpp-no-such-compositor\", 1);\n ::unsetenv(\"WAYLAND_SOCKET\");\n wl_display *dpy = wl_display_connect(nullptr);\n std::printf(\" wl_display_connect (no compositor) = %p\\n\", (void *)dpy);\n check(dpy == nullptr,\n \"wl_display_connect returns NULL rather than crashing\");\n if (dpy != nullptr) {\n wl_display_disconnect(dpy);\n }\n\n // ── 4. The server library can actually build a display ───────────────\n // No socket is bound, so this needs no privileges and no session; it is\n // the cheapest proof that libwayland-server is functional and not merely\n // present.\n wl_display *server = wl_display_create();\n std::printf(\" wl_display_create = %p\\n\", (void *)server);\n check(server != nullptr, \"wl_display_create succeeds (server library live)\");\n if (server != nullptr) {\n wl_display_destroy(server);\n }\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\n\nint main()\n{\n return 0;\n}\n\n#endif\n" + } + ] + }, + { + "project": "websocket-features", + "text": "# websocket-features test project: consumes compat.websocket with the `server`\n# and `zlib` features enabled and asserts them under `mcpp test`. Part of the\n# mcpp-index self-referential workspace — the workspace-root `[indices]`\n# redirect points at this repo, so the dependency resolves to the checked-in\n# recipe (pkgs/c/compat.websocket.lua).\n#\n# The test is entirely OFFLINE: it runs an ix::WebSocketServer (the `server`\n# feature) on loopback and drives the ix::WebSocket client against it, then\n# checks that per-message-deflate (the `zlib` feature) actually compresses on\n# the wire. The sibling member tests/examples/websocket covers the zero-dep\n# client with no features; this member exists to prove the features.\n[package]\nname = \"websocket-features-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nwebsocket = { version = \"12.0.1\", features = [\"server\", \"zlib\"] }\n", + "files": [ + { + "path": "tests/examples/websocket-features/tests/ws_features_test.cpp", + "text": "// ws_features_test.cpp — asserts compat.websocket's `server` and `zlib`\n// features, enabled through the member's dependency declaration.\n//\n// * `server`: an ix::WebSocketServer runs inside the test process on\n// loopback and echoes every message; the client's round-trips go through\n// the REAL server class (the sibling member tests/examples/websocket uses\n// a hand-written raw-socket echo server precisely so the zero-dep client\n// is tested against something independent — here the point is the opposite:\n// prove the compiled-in server works).\n// * `zlib`: the client enables per-message-deflate. A highly-compressible\n// payload must come back as a message whose str is the full payload but\n// whose wireSize is the COMPRESSED frame bytes (the transport delivers\n// str = decompressed content and wireSize = raw wire payload), which is\n// how a compression round-trip is observable without sniffing the socket.\n//\n// No network access is required; everything stays on 127.0.0.1.\n\n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n// Raw sockets only for find_free_port() (a fixed test port would collide on\n// shared CI runners; port 0 is the race-free way to ask the OS).\n#ifdef _WIN32\n#include \n#include \nusing SockType = SOCKET;\nconstexpr SockType kInvalidSocket = INVALID_SOCKET;\n#else\n#include \n#include \n#include \n#include \nusing SockType = int;\nconstexpr SockType kInvalidSocket = -1;\n#endif\n\nnamespace\n{\n\nint g_failures = 0;\n\nvoid check(bool ok, const std::string& what)\n{\n if (ok)\n {\n std::cout << \" ok: \" << what << \"\\n\";\n }\n else\n {\n std::cout << \" FAIL: \" << what << \"\\n\";\n ++g_failures;\n }\n}\n\nint find_free_port()\n{\n#ifdef _WIN32\n WSADATA wsa;\n WSAStartup(MAKEWORD(2, 2), &wsa);\n#endif\n SockType s = socket(AF_INET, SOCK_STREAM, 0);\n if (s == kInvalidSocket) return -1;\n sockaddr_in addr{};\n addr.sin_family = AF_INET;\n addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n addr.sin_port = 0;\n if (bind(s, reinterpret_cast(&addr), sizeof(addr)) != 0)\n {\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n return -1;\n }\n#ifdef _WIN32\n int len = sizeof(addr);\n#else\n socklen_t len = sizeof(addr);\n#endif\n if (getsockname(s, reinterpret_cast(&addr), &len) != 0)\n {\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n return -1;\n }\n int port = ntohs(addr.sin_port);\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n return port;\n}\n\n// ── Client-side message collector ──────────────────────────────────────────\n\nstruct RecvMsg\n{\n ix::WebSocketMessageType type;\n std::string str;\n bool binary;\n size_t wireSize;\n};\n\nclass Collector\n{\npublic:\n void add(const ix::WebSocketMessagePtr& msg)\n {\n std::lock_guard lk(m_);\n msgs_.push_back(RecvMsg{msg->type, msg->str, msg->binary, msg->wireSize});\n cv_.notify_all();\n }\n\n template \n bool wait_for(Pred pred, int timeout_ms)\n {\n std::unique_lock lk(m_);\n return cv_.wait_for(lk, std::chrono::milliseconds(timeout_ms), [&] { return pred(msgs_); });\n }\n\nprivate:\n std::mutex m_;\n std::condition_variable cv_;\n std::vector msgs_;\n};\n\n} // namespace\n\nint main()\n{\n std::cout << std::unitbuf;\n\n bool netOk = ix::initNetSystem();\n check(netOk, \"ix::initNetSystem()\");\n\n int port = find_free_port();\n check(port > 0, \"found a free loopback port\");\n if (port <= 0)\n {\n ix::uninitNetSystem();\n return 1;\n }\n\n // The `server` feature: a real ix::WebSocketServer.\n ix::WebSocketServer server(port, \"127.0.0.1\");\n server.setOnClientMessageCallback(\n [](std::shared_ptr /*state*/,\n ix::WebSocket& ws,\n const ix::WebSocketMessagePtr& msg)\n {\n if (msg->type == ix::WebSocketMessageType::Message)\n {\n if (msg->binary) ws.sendBinary(msg->str);\n else ws.sendText(msg->str);\n }\n });\n check(server.listenAndStart(), \"ix::WebSocketServer listenAndStart()\");\n\n Collector collector;\n ix::WebSocket ws;\n ws.setUrl(\"ws://127.0.0.1:\" + std::to_string(port) + \"/\");\n ws.enablePerMessageDeflate();\n ws.setOnMessageCallback([&](const ix::WebSocketMessagePtr& msg) { collector.add(msg); });\n ws.start();\n\n check(collector.wait_for(\n [](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Open) return true;\n return false;\n },\n 5000),\n \"client opened against ix::WebSocketServer\");\n\n // Text round-trip through the real server.\n ws.sendText(\"hello, real server!\");\n check(collector.wait_for(\n [](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Message &&\n m.str == \"hello, real server!\" && !m.binary)\n return true;\n return false;\n },\n 5000),\n \"text message echoed by ix::WebSocketServer\");\n\n // Binary round-trip through the real server.\n std::string bin(\"\\x00\\x01\\xfe\\xff\\x80\\x01binary\", 10);\n ws.sendBinary(bin);\n check(collector.wait_for(\n [&](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Message && m.str == bin && m.binary)\n return true;\n return false;\n },\n 5000),\n \"binary message echoed by ix::WebSocketServer\");\n\n // The `zlib` feature: per-message-deflate must actually compress on the\n // wire. 64 KiB of a repeated byte compresses to a few hundred bytes; the\n // client delivers the FULL decompressed str but reports wireSize = the raw\n // (compressed) frame payload, so the ratio is observable.\n const size_t kBig = 65536;\n ws.sendText(std::string(kBig, 'a'));\n size_t compressedWire = 0;\n bool gotBig = collector.wait_for(\n [&](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Message && m.str.size() == kBig &&\n !m.binary)\n {\n compressedWire = m.wireSize;\n return true;\n }\n return false;\n },\n 5000);\n check(gotBig, \"64 KiB compressible message round-tripped\");\n if (gotBig)\n {\n check(compressedWire < 1024,\n \"per-message-deflate compressed the wire (wireSize=\" + std::to_string(compressedWire) +\n \" << 65536)\");\n }\n\n ws.stop();\n server.stop();\n ix::uninitNetSystem();\n\n if (g_failures == 0)\n {\n std::cout << \"ALL WEBSOCKET-FEATURES ASSERTIONS PASSED\\n\";\n return 0;\n }\n std::cout << g_failures << \" ASSERTION(S) FAILED\\n\";\n return 1;\n}\n" + } + ] + }, + { + "project": "websocket-tls", + "text": "# websocket-tls test project: proves compat.websocket's opt-in `tls` feature\n# makes a real certificate-validated wss:// client/server usable. The fixture\n# is entirely offline (127.0.0.1 + test-only PEM files), so CI neither reaches\n# a public WSS endpoint nor weakens IXWebSocket's normal peer/hostname checks.\n#\n# This is separate from `websocket` (strict zero-dependency ws:// client) and\n# `websocket-features` (server + zlib): mcpp resolves one feature set per\n# consuming project, so a separate member preserves those assertions.\n[package]\nname = \"websocket-tls-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(linux)'.dependencies.compat]\nwebsocket = { version = \"12.0.1\", features = [\"tls\", \"server\"] }\n\n[target.'cfg(linux)'.build]\ncxxflags = [\"-DHAVE_WEBSOCKET_TLS=1\"]\n\n[target.'cfg(macos)'.dependencies.compat]\nwebsocket = { version = \"12.0.1\", features = [\"tls\", \"server\"] }\n\n[target.'cfg(macos)'.build]\ncxxflags = [\"-DHAVE_WEBSOCKET_TLS=1\"]\n\n[target.'cfg(windows)'.dependencies.compat]\nwebsocket = { version = \"12.0.1\", features = [\"tls\", \"server\"] }\n\n[target.'cfg(windows)'.build]\ncxxflags = [\"-DHAVE_WEBSOCKET_TLS=1\"]\n", + "files": [ + { + "path": "tests/examples/websocket-tls/tests/wss_test.cpp", + "text": "// wss_test.cpp — offline end-to-end test for compat.websocket's `tls` feature.\n//\n// An ix::WebSocketServer serves TLS on loopback using test-only PEM files;\n// ix::WebSocket connects with wss://localhost and trusts that certificate via\n// caFile. Hostname validation intentionally stays enabled, so this verifies\n// OpenSSL transport, trust-chain handling, the localhost SAN, WebSocket\n// upgrade, text/binary payloads, and orderly shutdown without public network.\n#ifdef HAVE_WEBSOCKET_TLS\n\n#include \n#include \n#include \n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\n#ifdef _WIN32\n#include \n#include \nusing Socket = SOCKET;\nconstexpr Socket kInvalidSocket = INVALID_SOCKET;\n#else\n#include \n#include \n#include \n#include \nusing Socket = int;\nconstexpr Socket kInvalidSocket = -1;\n#endif\n\nnamespace\n{\n\nint g_failures = 0;\n\nvoid check(bool ok, const std::string& what)\n{\n if (ok)\n {\n std::cout << \" ok: \" << what << \"\\n\";\n }\n else\n {\n std::cout << \" FAIL: \" << what << \"\\n\";\n ++g_failures;\n }\n}\n\nstruct RecvMsg\n{\n ix::WebSocketMessageType type;\n std::string str;\n bool binary;\n};\n\nclass Collector\n{\npublic:\n void add(const ix::WebSocketMessagePtr& msg)\n {\n std::lock_guard lock(mutex_);\n messages_.push_back({msg->type, msg->str, msg->binary});\n condition_.notify_all();\n }\n\n template \n bool wait_for(Predicate predicate, int timeout_ms)\n {\n std::unique_lock lock(mutex_);\n return condition_.wait_for(lock, std::chrono::milliseconds(timeout_ms), [&] {\n return predicate(messages_);\n });\n }\n\nprivate:\n std::mutex mutex_;\n std::condition_variable condition_;\n std::vector messages_;\n};\n\nconstexpr const char* kCertificatePem = R\"pem(-----BEGIN CERTIFICATE-----\nMIIDJTCCAg2gAwIBAgIUFgJ40WV0h6eUam72FKECeBPFXqkwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgyNDEzMTQ0NVoXDTM2MDgy\nMTEzMTQ0NVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAvdaQ7B4R1b1LljeRkG91z7SXF2JRmIuStEHB34Q3N1sE\npCDBbihaP2Uzunf9/0j7ypaAsZettQTYUPIoyIGZ/qHuaAGaA14ybOl6NstyU/BZ\n5i+RYOdvChJytfi9yLJ8UAayduLfRufoSV1LXGf1YfjtcWx/FDOtMRKLNKAzAh4Y\nPUz1Jj6PmLP25jQ5LyA70wnPVv7lYVHS2ZKvTGTng0FADBYCRg3s/gpEB91vuNWW\n9g7/BQJQMRkzaq1CGQPTlwpjV8nums5jEWJ6CQTojjOn7anwvcIw4SbSqT2/2ds3\nlefSy7nLC7B4YWQw6yMvf8OvFPEbFQsZrr8nTLtoEwIDAQABo28wbTAdBgNVHQ4E\nFgQULuQu97cGXi66GfXgZ4wCqmvlZ24wHwYDVR0jBBgwFoAULuQu97cGXi66GfXg\nZ4wCqmvlZ24wDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH\nBH8AAAEwDQYJKoZIhvcNAQELBQADggEBADTtFpAfVI8NF2H69pMecu1ZL0XonQyi\n7tI8OetQH6ZdV3BHlyRRb5kkA7kDJSkeKHIVmL2ekB12ayU684jZtOy2eEcRML/y\nNJ0mN5+WfSUWsOhI7U+Py/LUW6o11pTC6iIoOyd2BbKd7jo/bX4XnkKgGTXVsZ4l\nD12f9IYb/DWY7SDgIiy4EsVsbEaY3Na45bZ0J8GCfWwaWbP1K8c5HRilA644CfYI\nItdcWRipChc4BPMOgIMW/9QE3OtE9J6Bcub8/QABi1Wat/bEYCpOHkMxBn/EE4hK\n90r/2fKkfFV9Zcc5RfBkXZ0kf8hHhtdYN0PbZU1tlaxinEFtHvL8hN4=\n-----END CERTIFICATE-----\n)pem\";\n\nconstexpr const char* kPrivateKeyPem = R\"pem(-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC91pDsHhHVvUuW\nN5GQb3XPtJcXYlGYi5K0QcHfhDc3WwSkIMFuKFo/ZTO6d/3/SPvKloCxl621BNhQ\n8ijIgZn+oe5oAZoDXjJs6Xo2y3JT8FnmL5Fg528KEnK1+L3IsnxQBrJ24t9G5+hJ\nXUtcZ/Vh+O1xbH8UM60xEos0oDMCHhg9TPUmPo+Ys/bmNDkvIDvTCc9W/uVhUdLZ\nkq9MZOeDQUAMFgJGDez+CkQH3W+41Zb2Dv8FAlAxGTNqrUIZA9OXCmNXye6azmMR\nYnoJBOiOM6ftqfC9wjDhJtKpPb/Z2zeV59LLucsLsHhhZDDrIy9/w68U8RsVCxmu\nvydMu2gTAgMBAAECggEAV6ceuycXLQ1+PRnjSEFusDy+Frn62uh3EGvcTIwLwq9v\n8Sh+p5JSOTtNKygESz8zo1LikR1rw009ZAKr+gh9RikWn0c+CZgQyGD1YR+G5mLv\n32zPP2MczhW+iW8Ukfp3k6vD80jFt0OU6Wr+ROhrUJVTbS+fbYB+002woNfnNW5M\nLf3N0fdmNGDXVwattzy31BV+f8JHr3grgRVTXPaT8x3ADxxlbkI26T5w8FaYx6gZ\nXE++jktNZCHB0FtgdTD9Cugm0D6WkDmu0ZYroNrk20FPswgZKqOm1/PluX5qSu0Z\n7+QfTj+6oWepB4gY1xXSb9sjWn5ZZDCVih5a/P1yQQKBgQDjYHtyi/DKM0llVpO1\nfBql217oKBVnd8GTSa7+1ehqTMuDnZMRwqFwrCQDMoMJ3nk06+Kvw4jRTn2HvUHG\nLLZhb7ZyeukvBCh3hVc3QPxy+BuoSYvt9al0ouufIZZ4hL7Yx4S3pGmRpkd4OBmQ\n9TJo2Rqjg2YLZGqxjNw3RO5kuwKBgQDVvFlYgfZlBYWKn/0Pi0XjyvQlGZYUsFdW\nh2N3L6SDoMVeYgVJbC28fDjN9O4IVXarB32JCY4bXcsHvQKSsU6chbKcXGAZPaWO\nUuniYRqmmjQyOqO2E7A5hbZMFgRxLmcmSPA505FB4WF3sPJd6W5X8i2RzFfCwAHR\nUXdrHL6AiQKBgAx2VU3J7cCnXvZ28FGaI7vDckg3KjUpkyqHd1fwUXTCEMV99Xmb\nuU17od2q/xOjZfFInHwVs4IFU0wFS32ZJcXhYZaUtgMlrzId1NHqdeu3PYzTux+n\nv0ntRAzMwnqIjA1FojiOglrBSlmEeaJATisA+zzLDuTA9DgXCFrfJFHRAoGAa8rd\n1IFW3oP2YX9mhRxcVxHYJ43L3wtAQOdvBoEEm03NvFf7CpiASHrtuxE3qwRPINpa\nOW6UOMEI0BJG5ex+FPpopesAnDo28JxoUD9gzX0freVdA0rSqXACDEVeYCZi5zAJ\n12AX9f3Qxih7U1mSyM/eo5VG/XUQdZx8eYy5luECgYBwUV7QZzTqyoEvoz3z6PAU\n++c/n+YQMrDeNg1nHjBMR5qFn4H6Eg6ZsFkSHkAXYw5PyjNF/k12rHkveTQV9kuB\nyvhREr3gAz2a/1cBXvVg8eNwQ0jkea83MZw9FK4Kf0Y9T0Eh6Vsg5sTitYrUV+l/\n/w96Ha5ea5ypiCL+Al6aYQ==\n-----END PRIVATE KEY-----\n)pem\";\n\nclass TempPemFiles\n{\npublic:\n TempPemFiles()\n {\n const auto directory = std::filesystem::temp_directory_path();\n const auto stem = \"mcpp-websocket-tls-\" + std::to_string(\n std::chrono::steady_clock::now().time_since_epoch().count());\n certificate_ = directory / (stem + \".crt.pem\");\n key_ = directory / (stem + \".key.pem\");\n write(certificate_, kCertificatePem);\n write(key_, kPrivateKeyPem);\n }\n\n ~TempPemFiles()\n {\n std::error_code ignored;\n std::filesystem::remove(certificate_, ignored);\n std::filesystem::remove(key_, ignored);\n }\n\n bool valid() const { return valid_; }\n const std::filesystem::path& certificate() const { return certificate_; }\n const std::filesystem::path& key() const { return key_; }\n\nprivate:\n void write(const std::filesystem::path& path, const char* contents)\n {\n std::ofstream out(path, std::ios::binary);\n out << contents;\n valid_ = valid_ && static_cast(out);\n }\n\n std::filesystem::path certificate_;\n std::filesystem::path key_;\n bool valid_ = true;\n};\n\nint reserve_loopback_port()\n{\n Socket socket_fd = socket(AF_INET, SOCK_STREAM, 0);\n if (socket_fd == kInvalidSocket) return -1;\n\n sockaddr_in address{};\n address.sin_family = AF_INET;\n address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n address.sin_port = 0;\n if (bind(socket_fd, reinterpret_cast(&address), sizeof(address)) != 0)\n {\n#ifdef _WIN32\n closesocket(socket_fd);\n#else\n close(socket_fd);\n#endif\n return -1;\n }\n#ifdef _WIN32\n int length = sizeof(address);\n#else\n socklen_t length = sizeof(address);\n#endif\n if (getsockname(socket_fd, reinterpret_cast(&address), &length) != 0)\n {\n#ifdef _WIN32\n closesocket(socket_fd);\n#else\n close(socket_fd);\n#endif\n return -1;\n }\n#ifdef _WIN32\n closesocket(socket_fd);\n#else\n close(socket_fd);\n#endif\n return ntohs(address.sin_port);\n}\n\n\nbool saw_open(const std::vector& messages)\n{\n for (const auto& msg : messages)\n if (msg.type == ix::WebSocketMessageType::Open) return true;\n return false;\n}\n\nbool saw_error(const std::vector& messages)\n{\n for (const auto& msg : messages)\n if (msg.type == ix::WebSocketMessageType::Error) return true;\n return false;\n}\n\n} // namespace\n\nint main()\n{\n std::cout << std::unitbuf;\n check(ix::initNetSystem(), \"ix::initNetSystem()\");\n\n TempPemFiles pem;\n check(pem.valid(), \"wrote test-only certificate and private key\");\n if (g_failures != 0)\n {\n ix::uninitNetSystem();\n return 1;\n }\n\n // Reserve a port with a raw loopback socket. The short close→listen gap is\n // retried by CI through the normal test invocation if an unrelated process\n // claims it; a fixed literal would collide systematically on shared runners.\n const int port = reserve_loopback_port();\n check(port > 0, \"reserved a loopback port\");\n if (g_failures != 0)\n {\n ix::uninitNetSystem();\n return 1;\n }\n ix::WebSocketServer server(port, \"127.0.0.1\");\n ix::SocketTLSOptions server_tls;\n server_tls.tls = true;\n server_tls.certFile = pem.certificate().string();\n server_tls.keyFile = pem.key().string();\n // The fixture authenticates the SERVER. Requesting a client certificate\n // would turn this into mTLS and reject the deliberately certificate-free\n // WebSocket client before the behavior under test can begin.\n server_tls.caFile = \"NONE\";\n server.setTLSOptions(server_tls);\n server.setOnClientMessageCallback(\n [](std::shared_ptr /*state*/,\n ix::WebSocket& socket,\n const ix::WebSocketMessagePtr& message) {\n if (message->type != ix::WebSocketMessageType::Message) return;\n if (message->binary) socket.sendBinary(message->str);\n else socket.sendText(message->str);\n });\n check(server.listenAndStart(), \"TLS WebSocketServer listenAndStart()\");\n if (g_failures != 0)\n {\n server.stop();\n ix::uninitNetSystem();\n return 1;\n }\n\n Collector collector;\n ix::WebSocket client;\n ix::SocketTLSOptions client_tls;\n client_tls.caFile = pem.certificate().string();\n // Do NOT use caFile = \"NONE\" or disable_hostname_validation: certificate\n // trust and localhost SAN matching are precisely what this test asserts.\n client.setTLSOptions(client_tls);\n client.setUrl(\"wss://localhost:\" + std::to_string(port) + \"/\");\n client.setOnMessageCallback([&](const ix::WebSocketMessagePtr& message) { collector.add(message); });\n client.start();\n\n check(collector.wait_for(saw_open, 8000), \"certificate-validated wss:// connection opened\");\n check(!collector.wait_for(saw_error, 50), \"client reported no TLS/upgrade error\");\n\n const std::string text = \"hello over WSS\";\n client.sendText(text);\n check(collector.wait_for(\n [&](const std::vector& messages) {\n for (const auto& message : messages)\n if (message.type == ix::WebSocketMessageType::Message &&\n !message.binary && message.str == text)\n return true;\n return false;\n },\n 5000),\n \"text message round-tripped over TLS\");\n\n const std::string binary(\"\\x00\\x01\\xfe\\xff\\x80WSS\", 8);\n client.sendBinary(binary);\n check(collector.wait_for(\n [&](const std::vector& messages) {\n for (const auto& message : messages)\n if (message.type == ix::WebSocketMessageType::Message &&\n message.binary && message.str == binary)\n return true;\n return false;\n },\n 5000),\n \"binary message round-tripped over TLS\");\n\n client.stop();\n server.stop();\n ix::uninitNetSystem();\n\n if (g_failures == 0)\n {\n std::cout << \"ALL WEBSOCKET-TLS ASSERTIONS PASSED\\n\";\n return 0;\n }\n std::cout << g_failures << \" ASSERTION(S) FAILED\\n\";\n return 1;\n}\n\n#else\n#error \"HAVE_WEBSOCKET_TLS must be enabled for every declared test platform\"\n#endif\n" + } + ] + }, + { + "project": "websocket", + "text": "# websocket test project: consumes compat.websocket and asserts client behavior\n# under `mcpp test`. Part of the mcpp-index self-referential workspace — the\n# workspace-root `[indices]` redirect points at this repo, so the dependency\n# resolves to the checked-in recipe (pkgs/c/compat.websocket.lua).\n#\n# The test is entirely OFFLINE and self-contained: it runs a minimal RFC 6455\n# echo server inside the test process (raw loopback sockets) and drives the\n# ix::WebSocket client against it, so it does not depend on the CI runner\n# having (or not having) network access or a WS server process.\n[package]\nname = \"websocket-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nwebsocket = \"12.0.1\"\n", + "files": [ + { + "path": "tests/examples/websocket/tests/ws_test.cpp", + "text": "// ws_test.cpp — self-contained WebSocket client test for compat.websocket.\n//\n// IXWebSocket is a pure client here (the descriptor builds the client TUs\n// only), so this test brings its OWN minimal RFC 6455 echo server, built on\n// raw sockets, and drives the ix::WebSocket client against 127.0.0.1:0. That\n// exercises the real wire behaviors the client must implement — the HTTP\n// upgrade handshake (the client validates Sec-WebSocket-Accept), client-side\n// masking, fragmentation (the server replies with a fragmented message and the\n// client must reassemble it), ping/pong in both directions, and the closing\n// handshake. No network access and no server process are required: everything\n// stays inside one process on the loopback interface.\n//\n// The server is intentionally independent of the library under test: it\n// computes Sec-WebSocket-Accept with its own SHA-1 + base64 and implements the\n// framing itself, so a client bug in any of those areas fails the assertions\n// below rather than being hidden by shared code.\n\n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n\nnamespace\n{\n\nint g_failures = 0;\n\nvoid check(bool ok, const std::string& what)\n{\n if (ok)\n {\n std::cout << \" ok: \" << what << \"\\n\";\n }\n else\n {\n std::cout << \" FAIL: \" << what << \"\\n\";\n ++g_failures;\n }\n}\n\n// ── SHA-1 and base64 (for Sec-WebSocket-Accept) ────────────────────────────\n// Compact reference implementations, independent of the library under test.\n\nstd::string base64_encode(const std::string& in)\n{\n static const char tbl[] =\n \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n std::string out;\n unsigned val = 0;\n int valb = -6;\n for (unsigned char c : in)\n {\n val = (val << 8) | c;\n valb += 8;\n while (valb >= 0)\n {\n out.push_back(tbl[(val >> valb) & 0x3F]);\n valb -= 6;\n }\n }\n if (valb > -6) out.push_back(tbl[((val << 8) >> (valb + 8)) & 0x3F]);\n while (out.size() % 4) out.push_back('=');\n return out;\n}\n\nstd::string sha1_binary(const std::string& msg)\n{\n uint32_t h0 = 0x67452301, h1 = 0xEFCDAB89, h2 = 0x98BADCFE, h3 = 0x10325476,\n h4 = 0xC3D2E1F0;\n uint64_t bitlen = static_cast(msg.size()) * 8;\n std::string m = msg;\n m.push_back(static_cast(0x80));\n while (m.size() % 64 != 56) m.push_back('\\0');\n for (int i = 7; i >= 0; --i)\n m.push_back(static_cast((bitlen >> (i * 8)) & 0xFF));\n\n for (size_t i = 0; i < m.size(); i += 64)\n {\n uint32_t w[80];\n for (int j = 0; j < 16; ++j)\n w[j] = (static_cast(static_cast(m[i + j * 4])) << 24) |\n (static_cast(static_cast(m[i + j * 4 + 1])) << 16) |\n (static_cast(static_cast(m[i + j * 4 + 2])) << 8) |\n (static_cast(static_cast(m[i + j * 4 + 3])));\n for (int j = 16; j < 80; ++j)\n {\n uint32_t x = w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16];\n w[j] = (x << 1) | (x >> 31);\n }\n uint32_t a = h0, b = h1, c = h2, d = h3, e = h4;\n for (int j = 0; j < 80; ++j)\n {\n uint32_t f, k;\n if (j < 20)\n {\n f = (b & c) | ((~b) & d);\n k = 0x5A827999;\n }\n else if (j < 40)\n {\n f = b ^ c ^ d;\n k = 0x6ED9EBA1;\n }\n else if (j < 60)\n {\n f = (b & c) | (b & d) | (c & d);\n k = 0x8F1BBCDC;\n }\n else\n {\n f = b ^ c ^ d;\n k = 0xCA62C1D6;\n }\n uint32_t tmp = ((a << 5) | (a >> 27)) + f + e + k + w[j];\n e = d;\n d = c;\n c = (b << 30) | (b >> 2);\n b = a;\n a = tmp;\n }\n h0 += a;\n h1 += b;\n h2 += c;\n h3 += d;\n h4 += e;\n }\n std::string out(20, '\\0');\n const uint32_t hs[5] = {h0, h1, h2, h3, h4};\n for (int i = 0; i < 5; ++i)\n for (int j = 0; j < 4; ++j)\n out[i * 4 + j] = static_cast((hs[i] >> ((3 - j) * 8)) & 0xFF);\n return out;\n}\n\n// ── Platform socket abstraction ────────────────────────────────────────────\n\n#ifdef _WIN32\n#include \n#include \nusing SockType = SOCKET;\nconstexpr SockType kInvalidSocket = INVALID_SOCKET;\nusing AddrLenType = int;\n#else\n#include \n#include \n#include \n#include \n#include \nusing SockType = int;\nconstexpr SockType kInvalidSocket = -1;\nusing AddrLenType = socklen_t;\n#endif\n\nvoid init_sockets()\n{\n#ifdef _WIN32\n WSADATA wsa;\n WSAStartup(MAKEWORD(2, 2), &wsa);\n#endif\n}\n\nvoid close_socket(SockType s)\n{\n#ifdef _WIN32\n closesocket(s);\n#else\n ::close(s);\n#endif\n}\n\n// Safety net: any read that stalls this long makes the server thread return so\n// the test fails cleanly instead of hanging the process (and, on CI, a runner).\nvoid set_recv_timeout(SockType s, int ms)\n{\n#ifdef _WIN32\n DWORD t = static_cast(ms);\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&t), sizeof(t));\n#else\n timeval tv{};\n tv.tv_sec = ms / 1000;\n tv.tv_usec = (ms % 1000) * 1000;\n setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));\n#endif\n}\n\n// ── Minimal RFC 6455 echo server ───────────────────────────────────────────\n\nclass EchoServer\n{\npublic:\n ~EchoServer()\n {\n if (client != kInvalidSocket) close_socket(client);\n if (listener != kInvalidSocket) close_socket(listener);\n if (thread.joinable()) thread.join();\n }\n\n bool start()\n {\n init_sockets();\n listener = socket(AF_INET, SOCK_STREAM, 0);\n if (listener == kInvalidSocket) return false;\n int one = 1;\n#ifdef _WIN32\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR,\n reinterpret_cast(&one), sizeof(one));\n#else\n setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));\n#endif\n sockaddr_in addr{};\n addr.sin_family = AF_INET;\n addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);\n addr.sin_port = 0; // ephemeral\n if (bind(listener, reinterpret_cast(&addr), sizeof(addr)) != 0) return false;\n AddrLenType alen = sizeof(addr);\n getsockname(listener, reinterpret_cast(&addr), &alen);\n port = ntohs(addr.sin_port);\n if (listen(listener, 1) != 0) return false;\n thread = std::thread([this] { run(); });\n return true;\n }\n\n int port = 0;\n std::atomic server_saw_masked{false};\n std::atomic server_saw_pong{false};\n std::atomic server_saw_close{false};\n\nprivate:\n SockType listener = kInvalidSocket;\n SockType client = kInvalidSocket;\n std::thread thread;\n\n // Receive exactly n bytes; false on timeout/error/disconnect.\n bool recv_n(char* buf, size_t n)\n {\n size_t got = 0;\n while (got < n)\n {\n int r = recv(client, buf + got, static_cast(n - got), 0);\n if (r <= 0) return false;\n got += static_cast(r);\n }\n return true;\n }\n\n std::string lower(std::string s)\n {\n for (char& c : s)\n if (c >= 'A' && c <= 'Z') c = static_cast(c + ('a' - 'A'));\n return s;\n }\n\n bool handshake()\n {\n std::string req;\n char tmp[1024];\n while (req.find(\"\\r\\n\\r\\n\") == std::string::npos)\n {\n int r = recv(client, tmp, sizeof(tmp), 0);\n if (r <= 0) return false;\n req.append(tmp, static_cast(r));\n if (req.size() > 65536) return false;\n }\n std::string key;\n std::string rest = req;\n for (;;)\n {\n size_t eol = rest.find(\"\\r\\n\");\n if (eol == std::string::npos) break;\n std::string line = rest.substr(0, eol);\n rest = rest.substr(eol + 2);\n size_t colon = line.find(':');\n if (colon == std::string::npos) continue;\n std::string name = lower(line.substr(0, colon));\n std::string value = line.substr(colon + 1);\n size_t b = value.find_first_not_of(\" \\t\");\n size_t e = value.find_last_not_of(\" \\t\");\n value = (b == std::string::npos) ? std::string() : value.substr(b, e - b + 1);\n if (name == \"sec-websocket-key\") key = value;\n }\n if (key.empty()) return false;\n\n const std::string guid = \"258EAFA5-E914-47DA-95CA-C5AB0DC85B11\";\n std::string accept = base64_encode(sha1_binary(key + guid));\n\n std::string resp = \"HTTP/1.1 101 Switching Protocols\\r\\n\"\n \"Upgrade: websocket\\r\\n\"\n \"Connection: Upgrade\\r\\n\"\n \"Sec-WebSocket-Accept: \" + accept + \"\\r\\n\"\n \"\\r\\n\";\n size_t off = 0;\n while (off < resp.size())\n {\n int w = send(client, resp.data() + off, static_cast(resp.size() - off), 0);\n if (w <= 0) return false;\n off += static_cast(w);\n }\n return true;\n }\n\n bool send_frame(uint8_t opcode, const std::string& payload, bool fin = true)\n {\n // Server->client frames are NOT masked (RFC 6455 §5.1).\n std::string frame;\n frame.push_back(static_cast((fin ? 0x80 : 0x00) | (opcode & 0x0F)));\n size_t n = payload.size();\n if (n < 126)\n {\n frame.push_back(static_cast(n));\n }\n else if (n <= 0xFFFF)\n {\n frame.push_back(static_cast(126));\n frame.push_back(static_cast((n >> 8) & 0xFF));\n frame.push_back(static_cast(n & 0xFF));\n }\n else\n {\n frame.push_back(static_cast(127));\n for (int i = 7; i >= 0; --i)\n frame.push_back(static_cast((n >> (i * 8)) & 0xFF));\n }\n frame += payload;\n size_t off = 0;\n while (off < frame.size())\n {\n int w = send(client, frame.data() + off, static_cast(frame.size() - off), 0);\n if (w <= 0) return false;\n off += static_cast(w);\n }\n return true;\n }\n\n bool recv_frame(uint8_t& opcode, std::string& payload)\n {\n char hdr[2];\n if (!recv_n(hdr, 2)) return false;\n bool fin = (hdr[0] & 0x80) != 0;\n (void) fin;\n opcode = static_cast(hdr[0] & 0x0F);\n bool masked = (hdr[1] & 0x80) != 0;\n if (masked) server_saw_masked = true;\n size_t len = static_cast(hdr[1] & 0x7F);\n if (len == 126)\n {\n char ext[2];\n if (!recv_n(ext, 2)) return false;\n len = (static_cast(static_cast(ext[0])) << 8) |\n static_cast(static_cast(ext[1]));\n }\n else if (len == 127)\n {\n char ext[8];\n if (!recv_n(ext, 8)) return false;\n len = 0;\n for (int i = 0; i < 8; ++i)\n len = (len << 8) | static_cast(static_cast(ext[i]));\n }\n char key[4] = {0, 0, 0, 0};\n if (masked && !recv_n(key, 4)) return false;\n payload.resize(len);\n if (len && !recv_n(&payload[0], len)) return false;\n if (masked)\n for (size_t i = 0; i < len; ++i)\n payload[i] = static_cast(payload[i] ^ key[i % 4]);\n return true;\n }\n\n void run()\n {\n sockaddr_in peer{};\n AddrLenType plen = sizeof(peer);\n client = accept(listener, reinterpret_cast(&peer), &plen);\n if (client == kInvalidSocket) return;\n set_recv_timeout(client, 10000);\n if (!handshake()) return;\n\n for (;;)\n {\n uint8_t opcode = 0;\n std::string payload;\n if (!recv_frame(opcode, payload)) return;\n\n switch (opcode)\n {\n case 0x9: // ping -> pong\n if (!send_frame(0xA, payload)) return;\n break;\n case 0xA: // pong (answer to our ping)\n server_saw_pong = true;\n break;\n case 0x8: // close -> echo close code, then shut down\n {\n server_saw_close = true;\n std::string close_payload = payload;\n if (close_payload.size() < 2) close_payload = std::string(\"\\x03\\xe8\", 2); // 1000\n if (!send_frame(0x8, close_payload)) return;\n return;\n }\n case 0x1: // text\n case 0x2: // binary\n {\n if (payload == \"FRAG\")\n {\n // Reply with a fragmented text message.\n if (!send_frame(0x1, \"fragA-\", false)) return;\n if (!send_frame(0x0, \"fragB-\", false)) return;\n if (!send_frame(0x0, \"fragC!\", true)) return;\n }\n else if (payload == \"PINGME\")\n {\n // Ask the client to answer a ping with a pong.\n if (!send_frame(0x9, \"server-ping\", true)) return;\n }\n else\n {\n if (!send_frame(opcode, payload, true)) return;\n }\n break;\n }\n default:\n // continuation frames and anything else: ignore.\n break;\n }\n }\n }\n};\n\n// ── Client-side message collector ──────────────────────────────────────────\n\nstruct RecvMsg\n{\n ix::WebSocketMessageType type;\n std::string str;\n bool binary;\n};\n\nclass Collector\n{\npublic:\n void add(const ix::WebSocketMessagePtr& msg)\n {\n std::lock_guard lk(m_);\n msgs_.push_back(RecvMsg{msg->type, msg->str, msg->binary});\n cv_.notify_all();\n }\n\n // Wait up to timeout_ms for a predicate over the collected messages.\n template \n bool wait_for(Pred pred, int timeout_ms)\n {\n std::unique_lock lk(m_);\n return cv_.wait_for(lk, std::chrono::milliseconds(timeout_ms), [&] { return pred(msgs_); });\n }\n\n bool has_type(ix::WebSocketMessageType t) const\n {\n std::lock_guard lk(m_);\n for (const auto& m : msgs_)\n if (m.type == t) return true;\n return false;\n }\n\nprivate:\n mutable std::mutex m_;\n std::condition_variable cv_;\n std::vector msgs_;\n};\n\n} // namespace\n\nint main()\n{\n // Unbuffered: `mcpp test` runs this piped, and a hang without visible\n // progress is indistinguishable from a slow pass otherwise.\n std::cout << std::unitbuf;\n\n EchoServer server;\n if (!server.start())\n {\n std::cout << \"FAIL: could not start echo server\\n\";\n return 1;\n }\n std::cout << \"server listening on 127.0.0.1:\" << server.port << \"\\n\";\n\n bool netOk = ix::initNetSystem();\n check(netOk, \"ix::initNetSystem()\");\n\n Collector collector;\n ix::WebSocket ws;\n ws.setUrl(\"ws://127.0.0.1:\" + std::to_string(server.port) + \"/\");\n ws.setOnMessageCallback([&](const ix::WebSocketMessagePtr& msg) { collector.add(msg); });\n ws.start();\n\n // 1. Handshake: the client must validate our Sec-WebSocket-Accept and open.\n check(collector.wait_for(\n [](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Open) return true;\n return false;\n },\n 5000),\n \"client opened after HTTP upgrade handshake\");\n\n // 2. Text echo — by the time the server echoes, it has seen the client's\n // masked frames (RFC 6455 §5.1 requires client->server masking).\n ws.sendText(\"hello, ws!\");\n check(collector.wait_for(\n [](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Message && m.str == \"hello, ws!\" &&\n !m.binary)\n return true;\n return false;\n },\n 5000),\n \"text message echoed back\");\n check(server.server_saw_masked.load(), \"client frames arrive masked\");\n\n // 3. Binary echo (arbitrary bytes, not valid UTF-8).\n std::string bin(\"\\x00\\x01\\x02\\xfe\\xff\\x80hello\", 9);\n ws.sendBinary(bin);\n check(collector.wait_for(\n [&](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Message && m.str == bin && m.binary)\n return true;\n return false;\n },\n 5000),\n \"binary message echoed back byte-for-byte\");\n\n // 4. Client ping -> server pong -> client surfaces the Pong message.\n ws.ping(\"ping-payload\");\n check(collector.wait_for(\n [](const std::vector& v) {\n for (const auto& m : v)\n if (m.type == ix::WebSocketMessageType::Pong && m.str == \"ping-payload\")\n return true;\n return false;\n },\n 5000),\n \"ping/pong round-trip (client-initiated)\");\n\n // 5. Server ping -> client auto-pong (enablePong default is on).\n ws.sendText(\"PINGME\");\n {\n auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);\n while (!server.server_saw_pong.load() && std::chrono::steady_clock::now() < deadline)\n std::this_thread::sleep_for(std::chrono::milliseconds(10));\n }\n check(server.server_saw_pong.load(), \"client auto-answered a server ping with a pong\");\n\n // 6. Fragmentation: server replies with a fragmented text message, the\n // client must reassemble it into one Message (Fragment frames precede it).\n ws.sendText(\"FRAG\");\n check(collector.wait_for(\n [&](const std::vector& v) {\n bool sawFragment = false;\n for (const auto& m : v)\n {\n if (m.type == ix::WebSocketMessageType::Fragment) sawFragment = true;\n if (m.type == ix::WebSocketMessageType::Message && m.str == \"fragA-fragB-fragC!\")\n return true;\n }\n (void) sawFragment;\n return false;\n },\n 5000),\n \"fragmented server message reassembled by the client\");\n check(collector.has_type(ix::WebSocketMessageType::Fragment),\n \"fragment frames surfaced before the reassembled message\");\n\n // 7. Close handshake: stop() initiates the close (via its internal\n // close()), waits for the server's close reply and for the client thread\n // to exit, and sets `_stop` so the run loop cannot fall into the\n // automatic reconnection that a CLOSED state would otherwise trigger\n // (IXWebSocket enables automatic reconnection by default). The Close\n // message is delivered to the collector before stop() returns.\n ws.stop();\n check(collector.has_type(ix::WebSocketMessageType::Close),\n \"closing handshake completed (client saw Close)\");\n check(server.server_saw_close.load(), \"server received the client's close frame\");\n\n ix::uninitNetSystem();\n\n if (g_failures == 0)\n {\n std::cout << \"ALL WEBSOCKET ASSERTIONS PASSED\\n\";\n return 0;\n }\n std::cout << g_failures << \" ASSERTION(S) FAILED\\n\";\n return 1;\n}\n" + } + ] + }, + { + "project": "wil", + "text": "# WIL test project. The library is Windows-only — not a portable library with a\n# Windows backend, but a library ABOUT Win32 — so the dependency is gated and\n# the test compiles to a no-op main() elsewhere, the same shape gui-stack uses\n# in the other direction.\n[package]\nname = \"wil-tests\"\nversion = \"0.1.0\"\n\n[target.'cfg(windows)'.dependencies.compat]\nwil = \"1.0.260126.7\"\n\n[target.'cfg(windows)'.build]\nldflags = [\"-lole32\", \"-loleaut32\"]\n", + "files": [ + { + "path": "tests/examples/wil/tests/raii.cpp", + "text": "// Behavioral test: WIL's three families, each doing real work.\n//\n// A header-only package can fail in two ways a \"does it link\" test would miss:\n// the headers might not compile under this toolchain at all (WIL is dense in\n// __declspec, SAL annotations and intrinsics, and this index builds Windows\n// with clang rather than cl.exe), and the RAII types might not actually release\n// what they own. So the assertions observe SIDE EFFECTS — a handle that really\n// closed, a COM refcount that really dropped — rather than just naming symbols.\n#ifdef _WIN32\n\n#include \n#include \n#include \n\n#include \n#include \n\n#include \n#include \n\nnamespace {\n\n// Returns whether the handle value still names a live kernel object in this\n// process. This is how \"the wrapper really closed it\" gets observed.\nbool handle_is_live(HANDLE h) {\n DWORD flags = 0;\n return ::GetHandleInformation(h, &flags) != FALSE;\n}\n\nHRESULT succeeds() { return S_OK; }\nHRESULT fails() { return E_ACCESSDENIED; }\n\nHRESULT propagates_failure() {\n RETURN_IF_FAILED(succeeds());\n RETURN_IF_FAILED(fails()); // must return here\n return S_FALSE; // unreachable\n}\n\n} // namespace\n\nint main() {\n // ── wil::unique_handle: ownership, move, and actual closure ──────────\n HANDLE raw = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);\n assert(raw != nullptr && handle_is_live(raw));\n {\n wil::unique_handle owner(raw);\n assert(owner.get() == raw);\n\n wil::unique_handle moved = std::move(owner);\n assert(!owner && moved.get() == raw); // move really transferred\n assert(handle_is_live(raw)); // and did not close on the way\n }\n assert(!handle_is_live(raw) && \"unique_handle did not close the handle\");\n\n // release() must hand ownership BACK, i.e. not close it\n HANDLE raw2 = ::CreateEventW(nullptr, TRUE, FALSE, nullptr);\n assert(raw2 != nullptr);\n {\n wil::unique_handle owner(raw2);\n HANDLE released = owner.release();\n assert(released == raw2 && !owner);\n }\n assert(handle_is_live(raw2) && \"release() must not close\");\n ::CloseHandle(raw2);\n\n // ── wil::unique_hlocal_string over a LocalAlloc'd buffer ─────────────\n {\n auto* buffer = static_cast(::LocalAlloc(LPTR, 8 * sizeof(wchar_t)));\n assert(buffer != nullptr);\n wcscpy_s(buffer, 8, L\"wil\");\n wil::unique_hlocal_string owned(buffer);\n assert(owned && wcscmp(owned.get(), L\"wil\") == 0);\n } // LocalFree runs here; nothing observable, but it must compile and not crash\n\n // ── wil::com_ptr against a real COM object ───────────────────────────\n {\n const auto uninit = wil::CoInitializeEx(COINIT_APARTMENTTHREADED);\n\n wil::com_ptr link;\n const HRESULT hr = ::CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,\n IID_PPV_ARGS(&link));\n assert(SUCCEEDED(hr) && link);\n\n // query() crosses an interface — the classic com_ptr operation.\n auto persist = link.query();\n assert(persist);\n\n // A raw AddRef/Release pair around a copy proves the smart pointer is\n // actually counting, not just holding.\n link->AddRef();\n const ULONG after_addref = link->AddRef();\n const ULONG after_release = link->Release();\n assert(after_release == after_addref - 1);\n link->Release();\n\n wil::com_ptr copy = link;\n assert(copy.get() == link.get());\n copy.reset();\n assert(!copy && link);\n }\n\n // ── HRESULT macros: the error model, not just the spelling ───────────\n assert(propagates_failure() == E_ACCESSDENIED);\n\n bool threw = false;\n try {\n THROW_IF_FAILED(fails());\n } catch (const wil::ResultException& e) {\n threw = true;\n assert(e.GetErrorCode() == E_ACCESSDENIED);\n }\n assert(threw && \"THROW_IF_FAILED did not throw on a failed HRESULT\");\n\n // …and must NOT throw on success.\n THROW_IF_FAILED(succeeds());\n\n return 0;\n}\n\n#else\n\nint main() { return 0; } // WIL is Windows-only; nothing to assert here.\n\n#endif\n" + } + ] + }, + { + "project": "wlroots", + "text": "# wlroots — the compositor library, consumed the way a compositor would.\n#\n# ⚠️ The `wlroots` index redirect is what makes this test the INDEX's rather\n# than the published package's. It has to be keyed on the namespace the\n# dependency below requests: a redirect declared under a name nothing asks for\n# is silently not registered, and the resolution falls back to the remote\n# index — at which point this checks a package that is already published\n# rather than the descriptor in this PR.\n[indices]\nwlroots = { path = \"../../..\" }\n\n[package]\nname = \"wlroots-tests\"\nversion = \"0.1.0\"\nstandard = \"c++23\"\n\n[target.'cfg(linux)'.dependencies.wlroots]\nwlroots = \"0.20.2\"\n", + "files": [ + { + "path": "tests/examples/wlroots/tests/wlroots.cpp", + "text": "// wlroots — what can be asserted without a seat, a GPU or a compositor.\n//\n// WHAT THIS TEST IS FOR\n//\n// A compositor library is mostly untestable in CI: creating a backend wants a\n// DRM master, a libseat session and an input device, and none of a runner's\n// three exist. So the temptation is to assert that it linked, which is worth\n// almost nothing — a package can link and still have compiled none of the\n// files a consumer needs (this index has measured exactly that).\n//\n// Everything below is chosen to be an INTERMEDIATE QUANTITY: a value the\n// library computes on its own, from data it carries, with no device involved.\n// Each one names a specific generator in build.mcpp, so a failure says which\n// one broke rather than \"wlroots is broken\":\n//\n// version → include/wlr/version.h\n// WLR_HAS_* → include/wlr/config.h, and the feature set behind it\n// the PNP table → pnpids.c, from the pinned hwdata\n// the box maths → util/box.c, i.e. that ordinary sources compiled\n// the module itself → 986 `using ::` declarations that must all resolve\n// `namespace_` → the C++ keyword-member rewrite\n//\n// ⚠️ NO extern \"C\" WRAPPER, and that is the point. wlroots' public headers\n// contain not one `extern \"C\"` block, and two of them are not even valid C++\n// (C99 `[static N]` array parameters). A C++ consumer cannot include them at\n// all. `import wlroots;` is not a nicety here — it is the only way in.\n\n#ifdef __linux__\n\n// ⚠️ THE MODULE CANNOT CARRY MACROS, so `WLR_HAS_*` comes from the header.\n// Macros are preprocessor entities and nothing exports them.\n//\n// ⚠️ AND ONLY A MACRO-ONLY HEADER MAY BE INCLUDED ALONGSIDE THE MODULE.\n// `` is nothing but `#define`s, so it is safe. ``\n// is not: it DECLARES wlr_version_get_major and has no `extern \"C\"` of its\n// own, so including it here gives those three names C++ linkage while the\n// module's have C linkage — two different entities, and the link fails with\n//\n// undefined reference to `wlr_version_get_major()'\n//\n// naming a function the library plainly contains. The parentheses in that\n// message are the tell: a mangled name, i.e. the wrong one. Take declarations\n// from the module and macros from the header, never the same name from both.\n#include \n\nimport wlroots;\n\n// `get_pnp_manufacturer` is INTERNAL to the DRM backend — declared in the\n// private `backend/drm/util.h`, so it is not in the module and not in any\n// installed header. Declared here because the generated table behind it is\n// the one thing in this package whose failure looks exactly like success.\n// `const char code[static 3]` and `const char *` are the same parameter after\n// array-to-pointer decay, so this declaration is ABI-identical to upstream's.\nextern \"C\" const char *get_pnp_manufacturer(const char *code);\n\n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool ok, const char *what)\n{\n std::printf(\"%-62s %s\\n\", what, ok ? \"ok\" : \"FAILED\");\n if (!ok) {\n ++failures;\n }\n}\n\n} // namespace\n\nint main()\n{\n // ── 1. version.h was generated, and says what the manifest says ───────\n std::printf(\" wlroots %d.%d.%d\\n\", wlr_version_get_major(),\n wlr_version_get_minor(), wlr_version_get_micro());\n check(wlr_version_get_major() == 0 && wlr_version_get_minor() == 20,\n \"version.h reports 0.20, the version the manifest declares\");\n\n // ── 2. config.h reports THIS build's features ────────────────────────\n // Not \"some features are on\" — the exact default set, so a feature that\n // silently stopped being compiled is a failure and not a shrug.\n std::printf(\" drm=%d libinput=%d session=%d gles2=%d gbm=%d | \"\n \"x11=%d vulkan=%d xwayland=%d\\n\",\n WLR_HAS_DRM_BACKEND, WLR_HAS_LIBINPUT_BACKEND, WLR_HAS_SESSION,\n WLR_HAS_GLES2_RENDERER, WLR_HAS_GBM_ALLOCATOR,\n WLR_HAS_X11_BACKEND, WLR_HAS_VULKAN_RENDERER, WLR_HAS_XWAYLAND);\n check(WLR_HAS_DRM_BACKEND && WLR_HAS_LIBINPUT_BACKEND && WLR_HAS_SESSION\n && WLR_HAS_GLES2_RENDERER && WLR_HAS_GBM_ALLOCATOR,\n \"the five default features are all on\");\n check(!WLR_HAS_X11_BACKEND && !WLR_HAS_VULKAN_RENDERER && !WLR_HAS_XWAYLAND,\n \"…and X11, Vulkan and Xwayland are off, as the manifest says\");\n\n // ── 3. util/box.c: ordinary sources really were compiled ─────────────\n // Pure arithmetic on two rectangles, no device. If the package had linked\n // without compiling anything this would not resolve.\n wlr_box a{.x = 0, .y = 0, .width = 100, .height = 50};\n wlr_box b{.x = 60, .y = 20, .width = 100, .height = 50};\n wlr_box out{};\n const bool overlap = wlr_box_intersection(&out, &a, &b);\n std::printf(\" intersection = %d,%d %dx%d\\n\", out.x, out.y, out.width, out.height);\n check(overlap && out.x == 60 && out.y == 20 && out.width == 40 && out.height == 30,\n \"wlr_box_intersection computes the overlap\");\n\n check(wlr_box_contains_point(&a, 10, 10) && !wlr_box_contains_point(&a, 10, 90),\n \"wlr_box_contains_point agrees with the box it was given\");\n\n // ── 4. the scene graph allocates ─────────────────────────────────────\n // wlr_scene_create needs no backend — it is the one non-trivial object a\n // test can build. It also proves types/scene/*.c compiled.\n wlr_scene *scene = wlr_scene_create();\n check(scene != nullptr, \"wlr_scene_create without a backend\");\n if (scene != nullptr) {\n const float colour[4] = {1.0f, 0.0f, 0.0f, 1.0f};\n wlr_scene_rect *rect = wlr_scene_rect_create(&scene->tree, 32, 16, colour);\n check(rect != nullptr, \"…and a rect in it\");\n // wlr_scene_rect_set_color is one of the five declarations that use\n // C99 `[static 4]`. Calling it is what proves the rewritten header\n // declares the same function the library defines.\n if (rect != nullptr) {\n const float other[4] = {0.0f, 1.0f, 0.0f, 1.0f};\n wlr_scene_rect_set_color(rect, other);\n check(true, \"…and wlr_scene_rect_set_color, whose C99 [static 4] was rewritten\");\n }\n wlr_scene_node_destroy(&scene->tree.node);\n }\n\n // ── 5. the C++ keyword-member rewrite ────────────────────────────────\n // `struct wlr_layer_surface_v1` has a member C++ cannot name. The header\n // this build compiles against renames it under `#ifdef __cplusplus`, at\n // the same offset. Reading it through a null-object offset would be UB, so\n // this asserts on a real object's storage instead.\n wlr_layer_surface_v1 ls{};\n ls.namespace_ = const_cast(\"panel\");\n check(std::strcmp(ls.namespace_, \"panel\") == 0,\n \"wlr_layer_surface_v1::namespace_ is reachable from C++\");\n\n // ── 6. the PNP table generated from the PINNED hwdata ────────────────\n // The failure this catches looks exactly like a passing build: upstream's\n // gen_pnpids.sh reads /usr/share/hwdata/pnp.ids off the BUILD MACHINE, so\n // the manufacturer a monitor reports would depend on where the package was\n // built. `get_pnp_manufacturer` is wlroots-internal rather than exported,\n // so it is reached through the DRM backend header's declaration.\n //\n // \"ACR\" is the id in the EDID of the Acer monitor freedesktop.libdisplay-\n // info tests against, which is where the expected string comes from.\n const char *acr = get_pnp_manufacturer(\"ACR\");\n const char *del = get_pnp_manufacturer(\"DEL\");\n // ⚠️ \"JJJ\", not an arbitrary byte triple. PNP_ID keeps only the LOW FIVE\n // BITS of each character, so \"\\x01\\x02\\x03\" packs to the same key as \"ABC\"\n // and returns AboCom System Inc. — measured, and it is upstream's encoding\n // rather than a bug here. An id that is genuinely absent has to be one\n // whose 15-bit key no entry occupies; \"JJJ\" (key 10570) is one of them.\n const char *nope = get_pnp_manufacturer(\"JJJ\");\n std::printf(\" ACR=%s DEL=%s JJJ=%s\\n\", acr ? acr : \"(null)\",\n del ? del : \"(null)\", nope ? nope : \"(null)\");\n check(acr != nullptr && std::strstr(acr, \"Acer\") != nullptr,\n \"the pinned pnp.ids resolved ACR to Acer\");\n check(del != nullptr && std::strstr(del, \"Dell\") != nullptr, \"…and DEL to Dell\");\n check(nope == nullptr, \"…and an unoccupied key returns NULL, not a neighbour\");\n\n std::printf(\"\\n%d check(s) failed\\n\", failures);\n return failures == 0 ? 0 : 1;\n}\n\n#else\nint main() { return 0; }\n#endif\n" + } + ] + }, + { + "project": "xxhash", + "text": "# xxHash test project: consumes compat.xxhash from this repo's own index and\n# asserts the hash values themselves, not just that the symbols link.\n[package]\nname = \"xxhash-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nxxhash = \"0.8.3\"\n", + "files": [ + { + "path": "tests/examples/xxhash/tests/digest.cpp", + "text": "// Behavioral test: the digests themselves, against upstream's own vectors.\n//\n// A hash package that links but computes a different value is worse than one\n// that does not link at all — every caller downstream is content-addressing\n// something. So the assertions here are known-answer tests taken verbatim from\n// xxHash's `tests/sanity_test_vectors.h`, together with the buffer generator\n// from `tests/sanity_test.c` that those vectors were computed over. Nothing is\n// derived from running the code under test.\n//\n// The lengths cover every size branch XXH3 switches on (0, 1-3, 4-8, 9-16,\n// 17-128, 129-240, >240) plus the multi-block path at 2048 and 4160, which is\n// where a mis-selected SIMD variant or a byte-order slip would show up.\n#include \n\n#include \n#include \n#include \n#include \n\nnamespace {\n\n// tests/sanity_test.c: fillTestBuffer(). \"Its values must not be changed.\"\nconstexpr std::uint32_t kPrime32 = 2654435761U;\nconstexpr std::uint64_t kPrime64 = 11400714785074694797ULL;\n\nstd::vector make_sanity_buffer(std::size_t len) {\n std::vector buf(len);\n std::uint64_t gen = kPrime32;\n for (std::size_t i = 0; i < len; ++i) {\n buf[i] = static_cast(gen >> 56);\n gen *= kPrime64;\n }\n return buf;\n}\n\nstruct Case32 { std::uint32_t len; std::uint32_t seed; std::uint32_t expected; };\nstruct Case64 { std::uint32_t len; std::uint64_t seed; std::uint64_t expected; };\nstruct Case128 { std::uint32_t len; std::uint64_t lo; std::uint64_t hi; };\n\n// XSUM_XXH32_testdata, seed 0.\nconstexpr Case32 kXXH32[] = {\n { 0, 0x00000000U, 0x02CC5D05U }, { 1, 0x00000000U, 0xCF65B03EU },\n { 2, 0x00000000U, 0x1151BEE4U }, { 3, 0x00000000U, 0xC23884F5U },\n { 4, 0x00000000U, 0xA9DE7CE9U }, { 8, 0x00000000U, 0xA3F6F44BU },\n { 9, 0x00000000U, 0xFFB82A24U }, { 16, 0x00000000U, 0x93BA3759U },\n { 17, 0x00000000U, 0x89FDC23EU }, { 64, 0x00000000U, 0x02E95DBBU },\n { 128, 0x00000000U, 0x0FD07B71U }, { 129, 0x00000000U, 0x68C9EC37U },\n { 222, 0x00000000U, 0x5BD11DBDU }, { 240, 0x00000000U, 0xFA6B6557U },\n { 241, 0x00000000U, 0xE5F7C54DU }, { 403, 0x00000000U, 0x6675FF5AU },\n { 512, 0x00000000U, 0xD485C30AU }, { 2048, 0x00000000U, 0x7C535464U },\n { 4160, 0x00000000U, 0xDB0D9141U },\n};\n\n// XSUM_XXH64_testdata, seed 0.\nconstexpr Case64 kXXH64[] = {\n { 0, 0ULL, 0xEF46DB3751D8E999ULL }, { 1, 0ULL, 0xE934A84ADB052768ULL },\n { 2, 0ULL, 0x5D48CD60A77E23FFULL }, { 3, 0ULL, 0xFF7E1959CB50794AULL },\n { 4, 0ULL, 0x9136A0DCA57457EEULL }, { 8, 0ULL, 0xCDBCF538E71D1348ULL },\n { 9, 0ULL, 0x554B1AE991EDA6B6ULL }, { 16, 0ULL, 0x98C90B57FDFCB55CULL },\n { 17, 0ULL, 0x0D39A2D051A30C2CULL }, { 64, 0ULL, 0xEF558F8ACAC2B5CDULL },\n { 128, 0ULL, 0x90CA021457D96DC5ULL }, { 129, 0ULL, 0x41C280132D697ABAULL },\n { 222, 0ULL, 0xB641AE8CB691C174ULL }, { 240, 0ULL, 0xB81838D483BAEE53ULL },\n { 241, 0ULL, 0x95D76C8B4D8FC4D6ULL }, { 403, 0ULL, 0xD99858FEE82283DFULL },\n { 512, 0ULL, 0x4358D2FDD62B58A7ULL }, { 2048, 0ULL, 0x5940F2752BC04387ULL },\n { 4160, 0ULL, 0xEEE6A4E2AC952A5EULL },\n};\n\n// XSUM_XXH3_testdata, seed 0 and seed PRIME64 — the seeded path is a different\n// branch in XXH3, not a reseeding of the same one.\nconstexpr Case64 kXXH3[] = {\n { 0, 0ULL, 0x2D06800538D394C2ULL }, { 1, 0ULL, 0xC44BDFF4074EECDBULL },\n { 2, 0ULL, 0x7A9978044CB8A8BBULL }, { 3, 0ULL, 0x54247382A8D6B94DULL },\n { 4, 0ULL, 0xE5DC74BC51848A51ULL }, { 8, 0ULL, 0x24CCC9ACAA9F65E4ULL },\n { 9, 0ULL, 0x14D5001C15DD3F2BULL }, { 16, 0ULL, 0x981B17D36C7498C9ULL },\n { 17, 0ULL, 0x796F5ACD3A60F862ULL }, { 64, 0ULL, 0x9CB48487720EC49DULL },\n { 128, 0ULL, 0xFCFF24126754D861ULL }, { 129, 0ULL, 0x98F1B0A679A2CA29ULL },\n { 222, 0ULL, 0xB9163B558664D356ULL }, { 240, 0ULL, 0x81C3C2B67F568CCFULL },\n { 241, 0ULL, 0xC5A639ECD2030E5EULL }, { 403, 0ULL, 0xCDEB804D65C6DEA4ULL },\n { 512, 0ULL, 0x617E49599013CB6BULL }, { 2048, 0ULL, 0xDD59E2C3A5F038E0ULL },\n { 4160, 0ULL, 0x4F323B15321E94E1ULL },\n};\n\nconstexpr Case64 kXXH3Seeded[] = {\n { 0, 0x9E3779B185EBCA8DULL, 0xA8A6B918B2F0364AULL },\n { 1, 0x9E3779B185EBCA8DULL, 0x032BE332DD766EF8ULL },\n { 16, 0x9E3779B185EBCA8DULL, 0x663F29333B4DB6B1ULL },\n { 129, 0x9E3779B185EBCA8DULL, 0x21FFFDBCA099C844ULL },\n { 241, 0x9E3779B185EBCA8DULL, 0xDDA9B0A161D4829AULL },\n { 4160, 0x9E3779B185EBCA8DULL, 0x1BF6F5FAF9EECABDULL },\n};\n\n// XSUM_XXH128_testdata, seed 0.\nconstexpr Case128 kXXH128[] = {\n { 0, 0x6001C324468D497FULL, 0x99AA06D3014798D8ULL },\n { 1, 0xC44BDFF4074EECDBULL, 0xA6CD5E9392000F6AULL },\n { 16, 0x562980258A998629ULL, 0xC68C368ECF8A9C05ULL },\n { 129, 0x86C9E3BC8F0A3B5CULL, 0x03815FC91F1B30B6ULL },\n { 241, 0xC5A639ECD2030E5EULL, 0x99A80ECF0ECFC647ULL },\n { 4160, 0x4F323B15321E94E1ULL, 0x67140711C1E3E335ULL },\n};\n\n} // namespace\n\nint main() {\n for (const auto& c : kXXH32) {\n const auto buf = make_sanity_buffer(c.len);\n assert(XXH32(buf.data(), c.len, c.seed) == c.expected);\n }\n for (const auto& c : kXXH64) {\n const auto buf = make_sanity_buffer(c.len);\n assert(XXH64(buf.data(), c.len, c.seed) == c.expected);\n }\n for (const auto& c : kXXH3) {\n const auto buf = make_sanity_buffer(c.len);\n assert(XXH3_64bits(buf.data(), c.len) == c.expected);\n }\n for (const auto& c : kXXH3Seeded) {\n const auto buf = make_sanity_buffer(c.len);\n assert(XXH3_64bits_withSeed(buf.data(), c.len, c.seed) == c.expected);\n }\n for (const auto& c : kXXH128) {\n const auto buf = make_sanity_buffer(c.len);\n const XXH128_hash_t h = XXH3_128bits(buf.data(), c.len);\n assert(h.low64 == c.lo && h.high64 == c.hi);\n }\n\n // --- streaming must agree with the one-shot form ---\n // Split at 3 bytes, which is neither the 16-byte stripe nor the 256-byte\n // block, so the internal buffering path is actually exercised.\n {\n const auto buf = make_sanity_buffer(4160);\n XXH64_state_t* st = XXH64_createState();\n assert(st != nullptr);\n assert(XXH64_reset(st, 0) == XXH_OK);\n assert(XXH64_update(st, buf.data(), 3) == XXH_OK);\n assert(XXH64_update(st, buf.data() + 3, buf.size() - 3) == XXH_OK);\n assert(XXH64_digest(st) == 0xEEE6A4E2AC952A5EULL);\n assert(XXH64_freeState(st) == XXH_OK);\n\n XXH3_state_t* s3 = XXH3_createState();\n assert(s3 != nullptr);\n assert(XXH3_64bits_reset(s3) == XXH_OK);\n assert(XXH3_64bits_update(s3, buf.data(), 3) == XXH_OK);\n assert(XXH3_64bits_update(s3, buf.data() + 3, buf.size() - 3) == XXH_OK);\n assert(XXH3_64bits_digest(s3) == 0x4F323B15321E94E1ULL);\n assert(XXH3_freeState(s3) == XXH_OK);\n }\n\n // --- the canonical (big-endian) serialization round-trips ---\n {\n const auto buf = make_sanity_buffer(512);\n const std::uint64_t h64 = XXH64(buf.data(), buf.size(), 0);\n XXH64_canonical_t c64;\n XXH64_canonicalFromHash(&c64, h64);\n assert(c64.digest[0] == static_cast(h64 >> 56)); // big-endian, always\n assert(XXH64_hashFromCanonical(&c64) == h64);\n\n const XXH128_hash_t h128 = XXH3_128bits(buf.data(), buf.size());\n XXH128_canonical_t c128;\n XXH128_canonicalFromHash(&c128, h128);\n const XXH128_hash_t back = XXH128_hashFromCanonical(&c128);\n assert(back.low64 == h128.low64 && back.high64 == h128.high64);\n }\n\n // --- a single flipped bit must change the digest ---\n {\n auto buf = make_sanity_buffer(4160);\n const std::uint64_t before = XXH3_64bits(buf.data(), buf.size());\n buf[buf.size() / 2] ^= 0x01;\n assert(XXH3_64bits(buf.data(), buf.size()) != before);\n }\n\n return 0;\n}\n" + } + ] + }, + { + "project": "yaml-cpp-v080", + "text": "# yaml-cpp 0.8.0 test project: the same assertions as tests/examples/yaml-cpp\n# (0.9.0) minus the float formatting 0.9.0 added, plus a compile-time probe\n# that 0.9.0's new header is absent, so a member silently resolving the newer\n# version fails instead of passing.\n# `compat` redirect inherited from the workspace root.\n[package]\nname = \"yaml-cpp-v080-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nyaml-cpp = \"0.8.0\"\n", + "files": [ + { + "path": "tests/examples/yaml-cpp-v080/tests/yaml.cpp", + "text": "// Behavioral test for compat.yaml-cpp 0.8.0 -- the same checks as\n// tests/examples/yaml-cpp (0.9.0) minus the float formatting 0.9.0 added.\n//\n// Each check reaches symbols defined in the package's own .cpp files, so a\n// package that compiled nothing cannot pass by being header-only by accident:\n//\n// YAML::Load, node access, conversions -> parse.cpp, node_data.cpp, convert.cpp\n// anchors and aliases -> nodebuilder.cpp, singledocparser.cpp\n// YAML::LoadAll -> parse.cpp, parser.cpp\n// YAML::Emitter, YAML::Dump -> emitter.cpp, emitterstate.cpp, emit.cpp\n// malformed input -> scanner.cpp, exceptions.cpp\n// YAML::BuildGraphOfNextDocument -> contrib/graphbuilder*.cpp\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n\n#include \n#include \n#include \n#include \n\n// The package builds objects, not a shared library, so every yaml-cpp\n// declaration this TU sees must be plain -- not dllimport. The package\n// delivers that with a shim in front of yaml-cpp/dll.h, because no descriptor\n// key carries a define to a consumer's TUs. On Linux the difference is only a\n// visibility attribute and everything links anyway; on the MSVC ABI it is a\n// link failure. Asserting it at compile time fails everywhere the moment the\n// shim stops being reached.\n#ifndef YAML_CPP_STATIC_DEFINE\n# error \"YAML_CPP_STATIC_DEFINE did not reach the consumer: the yaml-cpp/dll.h shim was not found first\"\n#endif\n\n// Proves this member resolved 0.8.0 and not the newest version: the header\n// 0.9.0 added must not exist here.\n#if __has_include()\n# error \"yaml-cpp/fptostring.h is present: this member did not resolve compat.yaml-cpp 0.8.0\"\n#endif\n\nnamespace {\n\nconst char *const kDoc = R\"(\nname: mcpp-index\nversion: 3\nratio: 0.25\nenabled: true\ntags: [yaml, cpp, \"quoted, with comma\"]\nbase: &base\n host: localhost\n port: 8080\nderived:\n <<: *base\n alias_of_base: *base\ntext: |\n line one\n line two\n)\";\n\nbool loads_and_converts() {\n YAML::Node root = YAML::Load(kDoc);\n if (!root.IsMap()) return false;\n if (root[\"name\"].as() != \"mcpp-index\") return false;\n if (root[\"version\"].as() != 3) return false;\n if (root[\"ratio\"].as() != 0.25) return false;\n if (!root[\"enabled\"].as()) return false;\n\n const YAML::Node tags = root[\"tags\"];\n if (!tags.IsSequence() || tags.size() != 3) return false;\n if (tags[2].as() != \"quoted, with comma\") return false;\n if (tags.as>().front() != \"yaml\") return false;\n\n if (root[\"text\"].as() != \"line one\\nline two\\n\") return false;\n\n // A missing key is reported as undefined and a fallback applies; an\n // explicit conversion of the wrong kind throws.\n if (root[\"absent\"].IsDefined()) return false;\n if (root[\"absent\"].as(42) != 42) return false;\n try {\n (void)root[\"name\"].as();\n return false;\n } catch (const YAML::BadConversion &) {\n }\n return true;\n}\n\nbool resolves_aliases() {\n YAML::Node root = YAML::Load(kDoc);\n const YAML::Node alias = root[\"derived\"][\"alias_of_base\"];\n if (alias[\"port\"].as() != 8080) return false;\n // An alias is the SAME node as its anchor, not a copy of it.\n return alias.is(root[\"base\"]);\n}\n\nbool loads_every_document() {\n const std::vector docs = YAML::LoadAll(\"a: 1\\n---\\nb: 2\\n---\\n- 3\\n\");\n return docs.size() == 3 && docs[0][\"a\"].as() == 1\n && docs[1][\"b\"].as() == 2 && docs[2][0].as() == 3;\n}\n\nbool emits_and_round_trips() {\n YAML::Emitter out;\n out << YAML::BeginMap;\n out << YAML::Key << \"list\" << YAML::Value << YAML::Flow\n << YAML::BeginSeq << 1 << 2 << 3 << YAML::EndSeq;\n out << YAML::Key << \"nested\" << YAML::Value << YAML::BeginMap\n << YAML::Key << \"k\" << YAML::Value << \"v\" << YAML::EndMap;\n out << YAML::EndMap;\n if (!out.good()) return false;\n if (std::string(out.c_str()) != \"list: [1, 2, 3]\\nnested:\\n k: v\") return false;\n\n YAML::Node again = YAML::Load(out.c_str());\n if (again[\"list\"][2].as() != 3) return false;\n\n // A node built in code and dumped comes back with the same content.\n YAML::Node built;\n built[\"x\"] = 7;\n built[\"y\"].push_back(\"first\");\n built[\"y\"].push_back(\"second\");\n YAML::Node back = YAML::Load(YAML::Dump(built));\n return back[\"x\"].as() == 7 && back[\"y\"].size() == 2\n && back[\"y\"][1].as() == \"second\";\n}\n\nbool rejects_malformed_input() {\n try {\n (void)YAML::Load(\"key: [unterminated\\nother: 1\\n\");\n } catch (const YAML::ParserException &e) {\n // The mark points into the document rather than being left unset.\n return e.mark.line >= 0 && !std::string(e.what()).empty();\n }\n return false;\n}\n\n} // namespace\n\n// Upstream declares GraphBuilderInterface's destructor pure virtual and\n// defines it nowhere (0.8.0 and 0.9.0 alike), so every class derived from it\n// -- upstream's own GraphBuilder included -- needs the definition from\n// its user. This is that definition, as any consumer of the contrib API must\n// write it. BuildGraphOfNextDocument below still comes only from\n// src/contrib/graphbuilder.cpp, so the check still proves contrib was built.\nYAML::GraphBuilderInterface::~GraphBuilderInterface() = default;\n\nnamespace {\n\n// Counts what the contrib GraphBuilder is told, which only the two\n// src/contrib/*.cpp translation units implement.\nstruct Counter : YAML::GraphBuilderInterface {\n int scalars = 0, sequences = 0, maps = 0;\n void *NewNull(const YAML::Mark &, void *) override { return this; }\n void *NewScalar(const YAML::Mark &, const std::string &, void *,\n const std::string &) override {\n ++scalars;\n return this;\n }\n void *NewSequence(const YAML::Mark &, const std::string &, void *) override {\n ++sequences;\n return this;\n }\n void AppendToSequence(void *, void *) override {}\n void SequenceComplete(void *) override {}\n void *NewMap(const YAML::Mark &, const std::string &, void *) override {\n ++maps;\n return this;\n }\n void AssignInMap(void *, void *, void *) override {}\n void MapComplete(void *) override {}\n void *AnchorReference(const YAML::Mark &, void *node) override { return node; }\n};\n\nbool builds_a_graph() {\n std::istringstream in(\"a: [1, 2]\\nb: {c: 3}\\n\");\n YAML::Parser parser(in);\n Counter counter;\n if (YAML::BuildGraphOfNextDocument(parser, counter) == nullptr) return false;\n // keys a, b, c and values 1, 2, 3\n return counter.scalars == 6 && counter.sequences == 1 && counter.maps == 2;\n}\n\n} // namespace\n\nint main() {\n struct Check {\n const char *name;\n bool (*run)();\n } checks[] = {\n {\"loads_and_converts\", loads_and_converts},\n {\"resolves_aliases\", resolves_aliases},\n {\"loads_every_document\", loads_every_document},\n {\"emits_and_round_trips\", emits_and_round_trips},\n {\"rejects_malformed_input\", rejects_malformed_input},\n {\"builds_a_graph\", builds_a_graph},\n };\n bool ok = true;\n for (const Check &c : checks) {\n const bool passed = c.run();\n std::printf(\"%s %s\\n\", passed ? \"ok \" : \"FAIL\", c.name);\n ok = ok && passed;\n }\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "yaml-cpp", + "text": "# yaml-cpp test project: consumes compat.yaml-cpp 0.9.0 and asserts behavior\n# under `mcpp test` -- parsing, aliases, multi-document loads, emitting, parse\n# errors, the contrib GraphBuilder and 0.9.0's shortest float formatting.\n# tests/examples/yaml-cpp-v080 covers 0.8.0. Also measured on openkal\n# (tests/openkal/members.toml).\n# `compat` redirect inherited from the workspace root.\n[package]\nname = \"yaml-cpp-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nyaml-cpp = \"0.9.0\"\n", + "files": [ + { + "path": "tests/examples/yaml-cpp/tests/yaml.cpp", + "text": "// Behavioral test for compat.yaml-cpp 0.9.0.\n//\n// Each check reaches symbols defined in the package's own .cpp files, so a\n// package that compiled nothing cannot pass by being header-only by accident:\n//\n// YAML::Load, node access, conversions -> parse.cpp, node_data.cpp, convert.cpp\n// anchors and aliases -> nodebuilder.cpp, singledocparser.cpp\n// YAML::LoadAll -> parse.cpp, parser.cpp\n// YAML::Emitter, YAML::Dump -> emitter.cpp, emitterstate.cpp, emit.cpp\n// malformed input -> scanner.cpp, exceptions.cpp\n// YAML::BuildGraphOfNextDocument -> contrib/graphbuilder*.cpp\n// YAML::FpToString -> fptostring.cpp (new in 0.9.0)\n//\n// Returns non-zero on any mismatch.\n#include \n#include \n#include \n\n#include \n#include \n#include \n#include \n\n// The package builds objects, not a shared library, so every yaml-cpp\n// declaration this TU sees must be plain -- not dllimport. The package\n// delivers that with a shim in front of yaml-cpp/dll.h, because no descriptor\n// key carries a define to a consumer's TUs. On Linux the difference is only a\n// visibility attribute and everything links anyway; on the MSVC ABI it is a\n// link failure. Asserting it at compile time fails everywhere the moment the\n// shim stops being reached.\n#ifndef YAML_CPP_STATIC_DEFINE\n# error \"YAML_CPP_STATIC_DEFINE did not reach the consumer: the yaml-cpp/dll.h shim was not found first\"\n#endif\n\nnamespace {\n\nconst char *const kDoc = R\"(\nname: mcpp-index\nversion: 3\nratio: 0.25\nenabled: true\ntags: [yaml, cpp, \"quoted, with comma\"]\nbase: &base\n host: localhost\n port: 8080\nderived:\n <<: *base\n alias_of_base: *base\ntext: |\n line one\n line two\n)\";\n\nbool loads_and_converts() {\n YAML::Node root = YAML::Load(kDoc);\n if (!root.IsMap()) return false;\n if (root[\"name\"].as() != \"mcpp-index\") return false;\n if (root[\"version\"].as() != 3) return false;\n if (root[\"ratio\"].as() != 0.25) return false;\n if (!root[\"enabled\"].as()) return false;\n\n const YAML::Node tags = root[\"tags\"];\n if (!tags.IsSequence() || tags.size() != 3) return false;\n if (tags[2].as() != \"quoted, with comma\") return false;\n if (tags.as>().front() != \"yaml\") return false;\n\n if (root[\"text\"].as() != \"line one\\nline two\\n\") return false;\n\n // A missing key is reported as undefined and a fallback applies; an\n // explicit conversion of the wrong kind throws.\n if (root[\"absent\"].IsDefined()) return false;\n if (root[\"absent\"].as(42) != 42) return false;\n try {\n (void)root[\"name\"].as();\n return false;\n } catch (const YAML::BadConversion &) {\n }\n return true;\n}\n\nbool resolves_aliases() {\n YAML::Node root = YAML::Load(kDoc);\n const YAML::Node alias = root[\"derived\"][\"alias_of_base\"];\n if (alias[\"port\"].as() != 8080) return false;\n // An alias is the SAME node as its anchor, not a copy of it.\n return alias.is(root[\"base\"]);\n}\n\nbool loads_every_document() {\n const std::vector docs = YAML::LoadAll(\"a: 1\\n---\\nb: 2\\n---\\n- 3\\n\");\n return docs.size() == 3 && docs[0][\"a\"].as() == 1\n && docs[1][\"b\"].as() == 2 && docs[2][0].as() == 3;\n}\n\nbool emits_and_round_trips() {\n YAML::Emitter out;\n out << YAML::BeginMap;\n out << YAML::Key << \"list\" << YAML::Value << YAML::Flow\n << YAML::BeginSeq << 1 << 2 << 3 << YAML::EndSeq;\n out << YAML::Key << \"nested\" << YAML::Value << YAML::BeginMap\n << YAML::Key << \"k\" << YAML::Value << \"v\" << YAML::EndMap;\n out << YAML::EndMap;\n if (!out.good()) return false;\n if (std::string(out.c_str()) != \"list: [1, 2, 3]\\nnested:\\n k: v\") return false;\n\n YAML::Node again = YAML::Load(out.c_str());\n if (again[\"list\"][2].as() != 3) return false;\n\n // A node built in code and dumped comes back with the same content.\n YAML::Node built;\n built[\"x\"] = 7;\n built[\"y\"].push_back(\"first\");\n built[\"y\"].push_back(\"second\");\n YAML::Node back = YAML::Load(YAML::Dump(built));\n return back[\"x\"].as() == 7 && back[\"y\"].size() == 2\n && back[\"y\"][1].as() == \"second\";\n}\n\nbool rejects_malformed_input() {\n try {\n (void)YAML::Load(\"key: [unterminated\\nother: 1\\n\");\n } catch (const YAML::ParserException &e) {\n // The mark points into the document rather than being left unset.\n return e.mark.line >= 0 && !std::string(e.what()).empty();\n }\n return false;\n}\n\n} // namespace\n\n// Upstream declares GraphBuilderInterface's destructor pure virtual and\n// defines it nowhere (0.8.0 and 0.9.0 alike), so every class derived from it\n// -- upstream's own GraphBuilder included -- needs the definition from\n// its user. This is that definition, as any consumer of the contrib API must\n// write it. BuildGraphOfNextDocument below still comes only from\n// src/contrib/graphbuilder.cpp, so the check still proves contrib was built.\nYAML::GraphBuilderInterface::~GraphBuilderInterface() = default;\n\nnamespace {\n\n// Counts what the contrib GraphBuilder is told, which only the two\n// src/contrib/*.cpp translation units implement.\nstruct Counter : YAML::GraphBuilderInterface {\n int scalars = 0, sequences = 0, maps = 0;\n void *NewNull(const YAML::Mark &, void *) override { return this; }\n void *NewScalar(const YAML::Mark &, const std::string &, void *,\n const std::string &) override {\n ++scalars;\n return this;\n }\n void *NewSequence(const YAML::Mark &, const std::string &, void *) override {\n ++sequences;\n return this;\n }\n void AppendToSequence(void *, void *) override {}\n void SequenceComplete(void *) override {}\n void *NewMap(const YAML::Mark &, const std::string &, void *) override {\n ++maps;\n return this;\n }\n void AssignInMap(void *, void *, void *) override {}\n void MapComplete(void *) override {}\n void *AnchorReference(const YAML::Mark &, void *node) override { return node; }\n};\n\nbool builds_a_graph() {\n std::istringstream in(\"a: [1, 2]\\nb: {c: 3}\\n\");\n YAML::Parser parser(in);\n Counter counter;\n if (YAML::BuildGraphOfNextDocument(parser, counter) == nullptr) return false;\n // keys a, b, c and values 1, 2, 3\n return counter.scalars == 6 && counter.sequences == 1 && counter.maps == 2;\n}\n\n// 0.9.0 formats floating point by the shortest representation that reads\n// back to the same value; 0.8.0 printed max_digits10 digits\n// (\"0.10000000000000001\"). The emitter uses the same routine.\nbool formats_floats_shortest() {\n if (YAML::FpToString(0.1) != \"0.1\") return false;\n if (YAML::FpToString(1.5f) != \"1.5\") return false;\n YAML::Emitter out;\n out << 0.1;\n return std::string(out.c_str()) == \"0.1\";\n}\n\n} // namespace\n\nint main() {\n struct Check {\n const char *name;\n bool (*run)();\n } checks[] = {\n {\"loads_and_converts\", loads_and_converts},\n {\"resolves_aliases\", resolves_aliases},\n {\"loads_every_document\", loads_every_document},\n {\"emits_and_round_trips\", emits_and_round_trips},\n {\"rejects_malformed_input\", rejects_malformed_input},\n {\"builds_a_graph\", builds_a_graph},\n {\"formats_floats_shortest\", formats_floats_shortest},\n };\n bool ok = true;\n for (const Check &c : checks) {\n const bool passed = c.run();\n std::printf(\"%s %s\\n\", passed ? \"ok \" : \"FAIL\", c.name);\n ok = ok && passed;\n }\n return ok ? 0 : 1;\n}\n" + } + ] + }, + { + "project": "yyjson", + "text": "[package]\nname = \"yyjson-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nyyjson = \"0.12.0\"\n", + "files": [ + { + "path": "tests/examples/yyjson/tests/parse.cpp", + "text": "// Smoke test — verify compat.yyjson builds, links, and works\n#include \"yyjson.h\"\nimport std;\n\nint main() {\n const char* json = R\"({\"name\":\"mcpp\",\"version\":\"0.12.0\"})\";\n yyjson_doc* doc = yyjson_read(json, strlen(json), 0);\n if (!doc) return 1;\n auto* root = yyjson_doc_get_root(doc);\n auto* name = yyjson_obj_get(root, \"name\");\n if (!name || std::string_view{yyjson_get_str(name)} != \"mcpp\") return 2;\n std::println(\"compat.yyjson smoke test: ok\");\n yyjson_doc_free(doc);\n return 0;\n}\n" + } + ] + }, + { + "project": "zlib", + "text": "# compat.zlib test project. It asserts behaviour (a deflate round trip and a gz\n# file written and read back) and the one agreement a configuration that differs\n# per target can break silently: the width of z_off_t as the compiled library\n# reports it and as this consumer computes it from zconf.h.\n# `compat` redirect inherited from the workspace root.\n[package]\nname = \"zlib-tests\"\nversion = \"0.1.0\"\n\n[dependencies.compat]\nzlib = \"1.3.2\"\n", + "files": [ + { + "path": "tests/examples/zlib/tests/zlib.cpp", + "text": "// compat.zlib: behaviour, and agreement between the library and its consumer.\n#include \n\n#include \n#include \n#include \n\nnamespace {\n\nint failures = 0;\n\nvoid check(bool held, const char* what) {\n if (!held) { std::printf(\"FAIL: %s\\n\", what); ++failures; }\n}\n\n// zlibCompileFlags() bits 6 and 7 state the size of z_off_t the library was\n// compiled with: 0 is 2 bytes, 1 is 4, 2 is 8, 3 is another size.\nunsigned long library_off_t_size() {\n switch ((zlibCompileFlags() >> 6) & 3u) {\n case 0: return 2;\n case 1: return 4;\n case 2: return 8;\n default: return 0;\n }\n}\n\n} // namespace\n\nint main() {\n check(library_off_t_size() == sizeof(z_off_t),\n \"the library and this consumer agree on the width of z_off_t\");\n\n const std::string text(4096, 'z');\n uLongf bound = compressBound(static_cast(text.size()));\n std::string packed(bound, '\\0');\n check(compress(reinterpret_cast(packed.data()), &bound,\n reinterpret_cast(text.data()),\n static_cast(text.size())) == Z_OK,\n \"a buffer is compressed\");\n std::string unpacked(text.size(), '\\0');\n uLongf size = static_cast(unpacked.size());\n check(uncompress(reinterpret_cast(unpacked.data()), &size,\n reinterpret_cast(packed.data()), bound) == Z_OK\n && size == text.size() && unpacked == text,\n \"and decompresses to what it was\");\n\n const char* name = \"zlib-test.gz\";\n gzFile out = gzopen(name, \"wb\");\n check(out != nullptr, \"a gz file is opened for writing\");\n if (out) {\n check(gzwrite(out, text.data(), static_cast(text.size()))\n == static_cast(text.size()), \"the gz file is written\");\n gzclose(out);\n }\n gzFile in = gzopen(name, \"rb\");\n check(in != nullptr, \"the gz file is opened for reading\");\n if (in) {\n std::string back(text.size(), '\\0');\n check(gzread(in, back.data(), static_cast(back.size()))\n == static_cast(text.size()) && back == text,\n \"and reads back what was written\");\n check(gzseek(in, 10, SEEK_SET) == 10 && gztell(in) == 10,\n \"a gz file seeks and reports its offset through z_off_t\");\n gzclose(in);\n }\n std::remove(name);\n\n if (failures == 0) std::printf(\"compat.zlib: every observation held\\n\");\n return failures == 0 ? 0 : 1;\n}\n" + } + ] + } + ] +} diff --git a/mide-plugins/vscode/docs/architecture.md b/mide-plugins/vscode/docs/architecture.md new file mode 100644 index 0000000..d2e32c5 --- /dev/null +++ b/mide-plugins/vscode/docs/architecture.md @@ -0,0 +1,133 @@ +# Architecture + +How this repository is put together, and which file is the single source of truth for what. + +The extension is a thin VS Code assembly layer (`src/extension.ts`) over a set of **pure** +modules: project discovery, TOML/build-script text analysis, cache aggregation, the mcppls +capability table and command-id tables. Nearly everything can be exercised by `node --test` +without an Extension Host, and the parts that cannot are small. + +## Module layout + +| Directory | What lives there | Touches `vscode` | +| --- | --- | --- | +| `src/extension.ts` | The only assembly point: `activate` / `deactivate` | yes | +| `src/commands/` | Command ids (`ids.ts`) and the quick-menu table (`menu.ts`) | no | +| `src/config/` | Settings registry access, validation, the settings panel, presets | `access.ts`, `panel.ts`, `migrate.ts` | +| `src/cli/` | mcpp process runner, protocol probe, tasks, toolchain parsing, cache/cleanup plans, artifacts walk, self-check text | `controller.ts` | +| `src/projects/` | `mcpp.toml` discovery, the `mcpp.inProject` context key, the manifest summary | no | +| `src/toml/` | The tolerant structure reader the project and library views read a manifest with | no | +| `src/ide/` | The session with the mcpp-ide backend (its language client: mcpp.toml's and build.mcpp's rules layer, [../../../docs/rules.md](../../../docs/rules.md)), jobs, debugging, the Testing view | `session.ts` and the views' adapters | +| `src/mcppls/` | The mcppls contract, capability probe, state normaliser, bridge | `stateSource.ts` | +| `src/views/` | Tree models as data, the shared tree provider, the three views | `treeProvider.ts`, `projectView.ts`, `cacheView.ts`, `languageServerView.ts` | +| `src/i18n/` | Runtime string resolution (`t.ts`) and the pure resolver (`translate.ts`) | `t.ts` | +| `src/util/` | Byte/count formatting, text truncation | no | +| `src/workflows/` | The build-then-refresh state machine | no | + +`test/` mirrors `src/` and the directory names match, so a failing test names its module. + +## The purity rule + +A module that does not import `vscode` runs under plain `node --test`, cannot touch the editor +by accident, and is the place to put a decision. The `vscode`-touching modules are exactly: + +`src/extension.ts`, `src/cli/controller.ts`, `src/config/{access,panel,migrate}.ts`, +`src/i18n/t.ts`, `src/mcppls/stateSource.ts`, `src/ide/*` (but `protocol.ts` and `mapping.ts`), +`src/views/{treeProvider,projectView,cacheView,languageServerView}.ts`. + +Two rules follow from that: + +- a policy (which argv, how much confirmation, which severity) belongs in a pure module — + `src/cli/clean.ts` is the model: it owns the whole cleanup danger table so a UI change cannot + quietly add a sixth level of danger; +- a `vscode`-touching module should only translate a decision into API calls, not make it. + +CI also asserts the boundary that matters most: the one language client is the session's, connected +to the mcpp-ide backend for mcpp.toml and build.mcpp, and serves no other C++ source (C++ is C++ +Modules'); it is bundled into `dist/vendor/languageclient.js`, so the package carries no +node_modules (`test/artifacts.test.ts` and `.github/workflows/ci.yml`). + +## Single sources of truth + +| Fact | Source | Read by | +| --- | --- | --- | +| Every setting | `data/config-registry.json` | `src/config/registry.ts`, `tools/check-config.mjs`, `tools/generate-settings-docs.mjs` | +| Setting labels | `package.nls.json` / `package.nls.zh-cn.json` | VS Code, at startup | +| Runtime strings (Chinese) | `data/i18n/zh-cn.json` | `src/i18n/t.ts`, `tools/generate-l10n.mjs` | +| Runtime strings VS Code reads | `l10n/bundle.l10n*.json` (generated, do not edit) | `vscode.l10n.t` under `mcpp.ui.language: auto` | +| `build.mcpp` API, `mcpp.toml` shape | the backend's `spec/buildscript-api.json`, `spec/toml-schema.json` | the backend's rules layer | +| Command ids | `src/commands/ids.ts` | the manifest is held to it by `test/artifacts.test.ts` (command list, view ids, colour ids) and `test/commands/ids.test.ts` | + +The two generated snapshots come from an mcpp checkout and are committed, because the extension +must build without that checkout. Regenerate them with `npm run gen:buildscript` and +`npm run gen:toml` (`MCPP_REPO`, default `../mcpp`). + +`package.json`'s `contributes.configuration` is **hand-written**, not generated: rewriting a +several-hundred-line manifest on every change would produce an unreviewable diff. It is held to +the registry semantically instead. + +## Language + +Two independent paths decide which language you see, and they cannot be made to agree: + +- **VS Code's own strings** — command titles, setting names and descriptions, deprecation + messages — come from `package.nls.json` / `package.nls.zh-cn.json`, which VS Code resolves + **once at startup** from its display language. Nothing this extension does at runtime can + change them. +- **Our runtime strings and our panels** — resolved by `src/i18n/t.ts`. With + `mcpp.ui.language: "auto"` (the default) it calls `vscode.l10n.t(english)`, which reads + `l10n/bundle.l10n..json` (generated from `data/i18n/zh-cn.json`) and falls back to the + English text passed in. With `"en"` or `"zh-cn"` it reads our own bundle directly, so the + escape hatch works even when the editor is in a third language. + +The convention is that **the English text is the key**: a missing translation degrades to +readable English, never to an identifier. `tools/l10n-check.mjs` fails the build when a new +`t("…")` literal has no entry in `data/i18n/zh-cn.json`, and when the two `package.nls.*` files +have different key sets. + +The deliberate consequence: a user on a Chinese VS Code who sets `mcpp.ui.language` to `en` +sees Chinese setting names and English notifications. That mixture is the documented behaviour +of the escape hatch, not a bug. Note also that the migration is **partial in this build**: many +strings in `src/cli/controller.ts` and `src/extension.ts` (task completion messages, blocked +states, toolchain prompts) are still hardcoded Chinese and never go through `t()` at all. They +do not follow `mcpp.ui.language`, and on an English VS Code they are shown as-is. + +A note on the two directory names, because they look like synonyms and are not: + +| Layer | Lives in | What it is | +| --- | --- | --- | +| Our runtime strings | `src/i18n/` (`t`, `translate`) + `data/i18n/zh-cn.json` | The `t()` lookup table, switchable at runtime by `mcpp.ui.language` — i18n in the sense that the engineering keeps every string adaptable | +| VS Code's manifest strings | `l10n/bundle.l10n.*.json` (generated from `package.nls*`) | What VS Code itself localizes — l10n proper, resolved once at startup; the `l10n/` directory name is a platform convention and cannot be renamed | + +## The gates + +| Gate | Command | Checks | +| --- | --- | --- | +| Config | `npm run check:config` (`tools/check-config.mjs`) | registry shape; that `package.json` has exactly the registry's keys with the same type, default, enum, scope, bounds and `%key%` references; that `package.nls.json` carries `.title`, `.description` and any `deprecationMessage`, matching the registry text | +| Localisation | `npm run check:l10n` (`tools/l10n-check.mjs`) | every `t("…")` literal in `src/**` has a `data/i18n/zh-cn.json` entry; `package.nls.json` and `package.nls.zh-cn.json` have identical key sets; every `%key%` in `package.json` resolves | +| Docs | `npm run gen:docs` (`tools/generate-settings-docs.mjs`) | regenerates `docs/settings.md`; the stated drift check is `npm run gen:docs` then `git diff --exit-code` | +| Tests | `npm test` | runs `npm run check`, compiles, then `node --test "dist/test/**/*.test.js"` | +| Extension Host | `npm run test:e2e` | activates the extension against the `test/e2e` fixtures | + +`npm test` runs the three checks as one step (`npm run check`), so a manifest/registry +mismatch, an untranslated runtime string or a stale snapshot fails before any test runs. + +## Where a change goes + +- A new setting: add it to `data/config-registry.json`, add the property to + `contributes.configuration`, add the three `package.nls*` entries, then + `npm run gen:docs`. `check-config` will tell you what is missing. +- A new command: add the id to `src/commands/ids.ts`, contribute it in `package.json` with a + `%command..title%` placeholder, register it, and add both `package.nls*` strings. + **Never** use an `mcppls.` id — see [compatibility.md](compatibility.md#commands-we-must-not-register). +- A new runtime string: use `t("English text", …)`, then add the Chinese entry to + `data/i18n/zh-cn.json` (or `l10n-check` fails). +- A new user-facing page: `docs/`, written so that every relative link resolves in the + repository. + +`docs/` is excluded from the VSIX by `.vscodeignore`, so these links work on GitHub and in the +repository but not inside an installed extension. + +## Graph canvas + +Graph providers retain data separately from bounded visible projections. Entry grouping, two-way traversal and local folds live in `src/graphs/model.ts`; file/build events use a cancellable refresh coordinator. The canvas caches layouts and keeps camera/selection updates independent. SVG/PNG exports use the committed visible drawing. See [Dependency graphs](dependency-graphs.md). diff --git a/mide-plugins/vscode/docs/cache.md b/mide-plugins/vscode/docs/cache.md new file mode 100644 index 0000000..5b11606 --- /dev/null +++ b/mide-plugins/vscode/docs/cache.md @@ -0,0 +1,138 @@ +# Cache and cleanup + +Two different things share the word "cache" here: + +- the **project artifacts** under `/target/`, which only this project uses; +- the **shared build cache** mcpp keeps for the whole machine (packages and standard-library + module entries), which every mcpp project reads. + +The view is `mcpp.cache` in the `mcpp` container. Its refresh is manual +(`mcpp.refreshCacheStats`); `mcpp.cache.autoRefreshSeconds` is declared but not read, so there +is no timer. All cache commands require a trusted workspace — in a restricted workspace the +view shows `the workspace is not trusted` and the commands refuse. + +## Reading the numbers + +| Command | Document | How it is parsed | +| --- | --- | --- | +| `mcpp cache list --format json` | An envelope with `kind: "mcpp.cache"`: `data.root` plus `data.entries[]` of `{accessed, bytes, complete, dir, files, key, kind, label}` | Strictly. A foreign `kind`, a non-JSON body or a bad shape is rejected outright, not partially believed | +| `mcpp cache dir` | Human text: the root, then an optional `legacy (unused, removable with \`mcpp cache clean --legacy\`): ` line | Leniently; only the last `": "` on the legacy line is treated as a path, so the backticked command is never mistaken for one | +| `/target` | The file system | `estimateArtifacts` walks it (see below) | +| `mcpp cache info ` | Human text | **Not parsed at all** — shown verbatim in a preview document | +| `mcpp cache verify` | Human text plus the exit code | Shown verbatim in a preview document | + +A broken entry is dropped rather than thrown, and an entry with no `complete: true` counts as +incomplete. Entries without a usable `accessed` timestamp are left out of the age histogram and +out of the oldest/newest figures instead of being guessed at. + +### `target/` is always an estimate + +mcpp documents the contents of `target/` as **not an interface**: the layout may change without +notice. So this extension never parses mcpp's fingerprints and never decides which artifacts are +stale. It walks `/target` with `estimateArtifacts` (`src/cli/artifacts.ts`): + +- a bounded budget of 200,000 entries and depth 6, so a hostile or huge tree cannot hang the + extension host; `truncated: "entries"` or `"depth"` is reported and the figure is then a + **lower bound**; +- symlinks are counted as zero-byte entries and never followed (a link to an ancestor would + loop); fifos, sockets and devices count as zero; +- unreadable directories are tolerated and a raced `mcpp clean` cannot throw; +- a symlinked or missing `target/` reports as absent. + +The authoritative answer to "what would be removed" is always **`mcpp clean --dry-run`**, which +the stale-artifact command runs and shows. `mcpp.views.cache.topN` (default 5) and +`mcpp.views.cache.ageBuckets` (default `["1d","7d","30d"]`) shape the tree; the buckets accept +`h`, `d`, `w` and `m` suffixes, and unparsable entries are dropped. + +## The two project levels + +`src/cli/clean.ts` owns both, as data. + +| Level | Button | argv | Semantics | +| --- | --- | --- | --- | +| **L1** Clean Project Artifacts | `mcpp.cleanProjectArtifacts` | `mcpp clean` | Deletes the whole `target/` directory; the next build starts from scratch. The shared cache is untouched | +| **L2** Clean Stale Artifacts | `mcpp.cleanStaleArtifacts` | `mcpp clean --stale --older-than d` | Removes only the build directories mcpp no longer considers current, keeping the last `N` days | + +`N` is `mcpp.cache.staleDays` (default `3`, applied on the next clean; `0` means keep none). It +is always written explicitly so the result does not depend on mcpp's own default. `mcpp clean` is +explicitly **not** given `--bmi-cache` by either level; emptying the shared cache is a separate +second button in the L1 dialogue (`withSharedCache`), which escalates the plan to level 3 with a +second acknowledgement. The classic `mcpp.clean` command (a VS Code task) is separate and never +touches the shared cache either. + +## Every action and its argv + +| Command | argv | Level | +| --- | --- | --- | +| `mcpp.refreshCacheStats` | `cache list --format json`, `cache dir` | read-only | +| `mcpp.showCachePanel` | none (refreshes, then shows a summary) | read-only | +| `mcpp.showCacheEntry` | `cache info ` | read-only | +| `mcpp.verifyGlobalCache` | `cache verify` | read-only | +| `mcpp.gcGlobalCache` | `cache gc --max-size GiB` | preview + modal | +| `mcpp.pruneGlobalCache` | `cache prune --older-than d` | preview + modal | +| `mcpp.cleanStaleArtifacts` | `clean --dry-run`, then `clean --stale --older-than d` | preview + modal | +| `mcpp.cleanLegacyCache` | `cache clean --legacy` | modal | +| `mcpp.cleanProjectArtifacts` | `clean` (optionally `clean --bmi-cache`) | modal (escalated option: modal + acknowledgement) | + +`cache gc`'s budget comes from `mcpp.cache.gc.defaultBudgetGiB` (default `0`, which means "ask"); +when it is `0` the input box suggests half the current size. Before asking, the extension runs a +**local LRU projection** (`projectGc` in `src/util/format.ts`) and shows +`About would be freed ( entries).` That is an estimate: mcpp's own LRU also weighs +entry completeness and its own bookkeeping, so the command's output is what actually happened. +`mcpp prune`'s default age is `mcpp.cache.pruneAgeDays` (default 30, never below 1). + +There is **no per-package delete**. mcpp's `cache clean` only accepts `--deps`, `--std`, `--all` +and `--legacy`, so a top-label row opens `cache info` instead of pretending such a command +exists. + +## The five confirmation levels + +`confirmPlan` in `src/views/cacheView.ts` implements the policy from `planClean`. Nothing +destructive ever runs itself; every path goes through the plan, an optional preview and a modal. + +| # | Behaviour | Used by | +| --- | --- | --- | +| 1 | No prompt | `cache list`, `cache verify` (level 0) | +| 2 | One modal, whose text names what goes and what does not | `mcpp clean` / L1 (level 1) | +| 3 | A preview first, then the modal | L2 stale, `cache gc`, `cache prune`, `cache clean --deps`, `cache clean --std` (level 2) | +| 4 | Modal only, no preview — nothing is rebuilt because nothing reads the pre-v1 cache | `cache clean --legacy` (level 2, no preview by design) | +| 5 | A preview, the modal, then a **second, explicit acknowledgement** ("I understand this affects every mcpp project on this machine") | `cache clean --all`; and the escalated `clean --bmi-cache` (level 3) | + +`planProblems()` asserts the policy has no holes (every plan requires trust; every level-2+ +plan except legacy is previewed; every level-3 plan acknowledges), and +`test/cli/clean.test.ts` pins it. `mcpp.cache.gc.confirmAboveGiB` (default `1`) is declared but +**not read** in this build, so a gc that would free more than a gigabyte gets the same +preview-then-modal treatment as any other gc, not an extra confirmation. + +## What a preview shows + +`mcpp.showCachePanel` opens the **webview panel** (`src/views/cachePanel.ts`); its own budget +simulator is a *local LRU projection*, and the panel says so — mcpp's policy decides in the end +panel — via `workspace.openTextDocument({ content, language: "markdown" })` and +`showTextDocument(…, { preview: true, preserveFocus: true })`. The content is a `# title` and a +fenced block holding the command's raw stdout (or stderr when stdout is empty), trimmed. So: + +- `mcpp clean --dry-run` — mcpp's own list of what L2 would remove; +- `mcpp cache verify` — the verification report, or `Every entry matches its manifest.` when + there is no output; +- `mcpp cache info ` — the command's output verbatim; +- `mcpp.showCachePanel` — a generated Markdown summary (`cacheSummaryText`) of the project + estimate, the shared cache totals per `kind`, the incomplete count and the largest labels. + +The summary numbers are formatted with `formatBytes` (binary units, three significant digits) +and `formatCount`; `mcpp.ui.numberFormat` is declared but not read, so decimal units are not +selectable yet. + +## Known gaps in this build + +- The **pre-v1 cache** node renders only when a byte count is known. `mcpp cache dir` is run and + its `legacyPath` is stored, but no size is measured, so the node does not appear; + `mcpp.cleanLegacyCache` is reachable from the Command Palette. `mcpp.cache.showLegacy` is + declared but not read. +- No byte total is shown per project in the status bar: `mcpp.cache.statusBar` and + `mcpp.cache.warnAboveGiB` are declared but not read. +- `mcpp.views.cache.show` is declared but the view has no `when` clause, so it is always + present. + +Full setting descriptions: [settings.md](settings.md). The mcpp side of these commands: +`.agents/docs/mcpp-integration.md`. diff --git a/mide-plugins/vscode/docs/commands.md b/mide-plugins/vscode/docs/commands.md new file mode 100644 index 0000000..c89e14d --- /dev/null +++ b/mide-plugins/vscode/docs/commands.md @@ -0,0 +1,135 @@ +# Commands + +Every command this extension contributes, what it actually runs, and its title key. + +All ids are listed in `src/commands/ids.ts`; the manifest is held to that list by +`test/artifacts.test.ts`, and `test/commands/ids.test.ts` checks the namespacing, the +uniqueness and that every 0.4.x id survived. A title key is the `%…%` placeholder in +`package.json`, resolved by VS Code from `package.nls.json` (English) or +`package.nls.zh-cn.json` at startup — so the Command Palette title follows the **VS Code display +language**, not `mcpp.ui.language`. + +Our ids always use the `mcpp.` prefix and never `mcppls.`. See +[compatibility.md](compatibility.md#commands-we-must-not-register). + +## Settings and diagnostics + +| Command id | Title key | What it does | +| --- | --- | --- | +| `mcpp.openSettings` | `command.mcpp.openSettings.title` | Opens the extension's own settings panel (`src/config/panel.ts`). One panel per window | +| `mcpp.openMcpplsSettings` | `command.mcpp.openMcpplsSettings.title` | Runs `workbench.action.openSettings @ext:sunrisepeak.mcpp-language-server`. Hidden from the Command Palette (`commandPalette` → `when: false`); reachable from the C++ Modules view's **Actions** node | +| `mcpp.selfCheck` | `command.mcpp.selfCheck.title` | Probes `mcpp --protocol-version`, reads the mcppls state and the changed settings, and writes a copyable snapshot to the `mcpp` output channel | + +## mcpp CLI and tasks + +| Command id | Title key | What it does | +| --- | --- | --- | +| `mcpp.showMenu` | `command.mcpp.showMenu.title` | The quick menu (`src/commands/menu.ts`): 20 entries in five groups. Also the status bar item's command | +| `mcpp.newProject` | `command.mcpp.newProject.title` | Name prompt, folder prompt, then `mcpp new ` and `vscode.openFolder`. Never builds. Name validation in `src/cli/newProject.ts` | +| `mcpp.build` | `command.mcpp.build.title` | VS Code task, argv `["build"]` | +| `mcpp.run` | `command.mcpp.run.title` | VS Code task, argv `["run"]` | +| `mcpp.test` | `command.mcpp.test.title` | VS Code task, argv `["test"]` | +| `mcpp.clean` | `command.mcpp.clean.title` | VS Code task, argv `["clean"]`, after a modal confirmation. It is **not** an alias of `mcpp.cleanProjectArtifacts`: same argv, different path (task vs. `runProcess`) | +| `mcpp.showToolchains` | `command.mcpp.showToolchains.title` | `mcpp toolchain list`, parsed from its human output, shown in a read-only picker | +| `mcpp.installToolchain` | `command.mcpp.installToolchain.title` | `mcpp toolchain install ` as a task, after naming what will be downloaded or detected | +| `mcpp.selectDefaultToolchain` | `command.mcpp.selectDefaultToolchain.title` | `mcpp toolchain default ` via `runProcess`, then offers "clean and build" | +| `mcpp.autoConfigureModules` | `command.mcpp.autoConfigureModules.title` | One confirmation, then `mcpp build` and a language-service refresh | + +Task details live in `src/cli/tasks.ts` (`projectTaskPlan`) and `src/cli/controller.ts` +(`executeTask`). Every task runs through a dedicated, reuse-message-free task terminal; the +presentation options are fixed (reveal always, focus, clear), so the `mcpp.task.*` +presentation settings are declared but not yet read. + +## Cache + +The policy — argv, confirmation level, whether a preview is shown — is a pure table in +`src/cli/clean.ts`; `src/views/cacheView.ts` only asks, runs and reports. See +[cache.md](cache.md) for the confirmation levels. + +| Command id | Title key | What it does | +| --- | --- | --- | +| `mcpp.refreshCacheStats` | `command.mcpp.refreshCacheStats.title` | Re-runs `mcpp cache list --format json`, `mcpp cache dir` and the `target/` estimate | +| `mcpp.showCachePanel` | `command.mcpp.showCachePanel.title` | Refreshes, then opens the **cache panel** — a webview with the composition and age bars, the largest packages and a budget simulator; every action in it goes through the same confirmed cleanup plans as the tree ([docs/cache.md](cache.md)) | +| `mcpp.showCacheEntry` | `command.mcpp.showCacheEntry.title` | `mcpp cache info `, shown verbatim in a preview document; never parsed | +| `mcpp.cleanStaleArtifacts` | `command.mcpp.cleanStaleArtifacts.title` | `mcpp clean --dry-run`, shown as a preview, then `mcpp clean --stale --older-than d` | +| `mcpp.cleanProjectArtifacts` | `command.mcpp.cleanProjectArtifacts.title` | Modal offering `mcpp clean` and, as a second button, "Also empty the shared build cache"; that button escalates to `mcpp clean --bmi-cache` and a second acknowledgement | +| `mcpp.gcGlobalCache` | `command.mcpp.gcGlobalCache.title` | Asks for a GiB budget (default `mcpp.cache.gc.defaultBudgetGiB`, else half the current size), shows a local LRU estimate, then `mcpp cache gc --max-size GiB` | +| `mcpp.pruneGlobalCache` | `command.mcpp.pruneGlobalCache.title` | `mcpp cache prune --older-than d` | +| `mcpp.verifyGlobalCache` | `command.mcpp.verifyGlobalCache.title` | `mcpp cache verify`; read-only, output in a preview document | +| `mcpp.cleanLegacyCache` | `command.mcpp.cleanLegacyCache.title` | `mcpp cache clean --legacy`. Reachable from the Command Palette; the cache view's node for it needs a byte count that this build does not measure | + +## C++ Modules + +These only forward to `sunrisepeak.mcpp-language-server`; nothing here reimplements language +behaviour. The capability table is `src/mcppls/contract.ts`, the probe is +`src/mcppls/capabilities.ts`, and a missing command becomes a warning plus the capability's own +`degradedHint` — never a failure of an mcpp command. + +| Command id | Title key | Forwards to | Confirmation | +| --- | --- | --- | --- | +| `mcpp.languageServer.refreshState` | `command.mcpp.languageServer.refreshState.title` | nothing; re-renders the view | none | +| `mcpp.languageServer.restart` | `command.mcpp.languageServer.restart.title` | `mcppls.restartServer` | none | +| `mcpp.languageServer.restartEngine` | `command.mcpp.languageServer.restartEngine.title` | `mcppls.restartClangd` | confirm | +| `mcpp.languageServer.resetWorkspaceCache` | `command.mcpp.languageServer.resetWorkspaceCache.title` | `mcppls.resetWorkspaceCache` | destructive (modal) | +| `mcpp.languageServer.selectContext` | `command.mcpp.languageServer.selectContext.title` | `mcppls.selectContext` | none | +| `mcpp.languageServer.showModuleGraph` | `command.mcpp.languageServer.showModuleGraph.title` | `mcppls.showModuleGraph` | none | +| `mcpp.languageServer.showLogs` | `command.mcpp.languageServer.showLogs.title` | `mcppls.showLogs` | none | +| `mcpp.languageServer.collectReport` | `command.mcpp.languageServer.collectReport.title` | `mcppls.collectReport` | none | +| `mcpp.languageServer.exportDiagnosticBundle` | `command.mcpp.languageServer.exportDiagnosticBundle.title` | `mcppls.exportDiagnosticBundle` | none | +| `mcpp.languageServer.runBuildToolInTerminal` | `command.mcpp.languageServer.runBuildToolInTerminal.title` | `mcppls.runBuildToolInTerminal` | confirm | +| `mcpp.languageServer.manageConflicts` | `command.mcpp.languageServer.manageConflicts.title` | `mcppls.turnOffOtherCppFeatures` (it asks where: this workspace or user settings) | none | +| `mcpp.languageServer.restoreConflicts` | `command.mcpp.languageServer.restoreConflicts.title` | `mcppls.restoreOtherCppFeatures` | none | +| `mcpp.languageServer.toggleInWorkspace` | `command.mcpp.languageServer.toggleInWorkspace.title` | `mcppls.turnOffInWorkspace` / `mcppls.turnOnInWorkspace`, called with `!mcppls.enable` | confirm | +| `mcpp.languageServer.installTools` | `command.mcpp.languageServer.installTools.title` | `mcppls.installCommandLineTools` | confirm | +| `mcpp.languageServer.reviewChanges` | `command.mcpp.languageServer.reviewChanges.title` | `mcppls.review.run` / `mcppls.review.clear`, called with `false` | none | + +**Project → Basics → Language service** carries one row for the other C++ language services +(C/C++ IntelliSense, clangd, ccls). While one is active it shows a warning and their names, and +clicking it runs `mcpp.languageServer.manageConflicts`; with none it is a dimmed check mark, +"Only C++ Modules is serving C++", with nothing to click. When mcppls remembers settings it +turned off, the row's context menu offers `mcpp.languageServer.restoreConflicts`. The list comes +from mcppls itself (`otherCppFeatures()` on its extension API, 0.0.16+) and, from an older +mcppls, from a read-only look at the same three extensions and their settings. This extension +never writes those settings: mcppls's command does, at their own keys. + +`refresh` for the build path uses a candidate chain +(`mcppls.reloadBuildDescription`, then `mcppls.restartServer`) and is single-flight, so two +builds cannot restart the language server twice. `mcpp.languageService.refreshAfterBuild` is +declared in the registry but is not read yet: the refresh always happens after a build that was +not cancelled. + +## Aliases kept from 0.4.x + +Ids do not change once shipped. A rename keeps the old id and points it at the new behaviour, +so an existing keybinding keeps working. Five of the six are marked `[Deprecated]` in their +title. + +| Old id | Title key | Now does | +| --- | --- | --- | +| `mcpp.configureLanguageServer` | `command.mcpp.configureLanguageServer.title` | same as `mcpp.languageServer.selectContext` | +| `mcpp.configureClangd` | `command.mcpp.configureClangd.title` | same as `mcpp.languageServer.selectContext` | +| `mcpp.checkModuleSupport` | `command.mcpp.checkModuleSupport.title` | same as `mcpp.languageServer.restart` | +| `mcpp.showModuleGraph` | `command.mcpp.showModuleGraph.title` | same as `mcpp.languageServer.showModuleGraph` | +| `mcpp.showLanguageServerLogs` | `command.mcpp.showLanguageServerLogs.title` | same as `mcpp.languageServer.showLogs` | +| `mcpp.refreshCompilationDatabase` | `command.mcpp.refreshCompilationDatabase.title` | runs the `build` task. Its title is **not** marked deprecated | + +## Menu and keybinding notes + +- **Editor title**: `mcpp.run` (`navigation@1`) and `mcpp.test` (`navigation@2`), both gated on + `mcpp.inProject`. +- **View title**: on `view == mcpp.cache`, `mcpp.refreshCacheStats` (`navigation@2`) and + `mcpp.showCachePanel` (`navigation@3`); on `view == mcpp.languageServer`, + `mcpp.languageServer.refreshState` (`navigation@2`) and `mcpp.languageServer.restart` + (`navigation@3`). +- **Command Palette**: everything is visible except `mcpp.openMcpplsSettings` + (`when: false`). +- **No keybindings are contributed.** `contributes.keybindings` is absent from `package.json`; + bind your own if you want one. +- The status bar item (`$(tools) mcpp`, command `mcpp.showMenu`) is created in + `src/cli/controller.ts` and is shown only while a project is found. +- `mcpp.internal.refreshProjectView` is registered by the project view but is **not** + contributed, so it never appears in the palette. + +## Project graph navigation + +The project package/module graph canvas supports independent selection and focus, importer depth, entry groups and SVG/PNG export. See [Dependency graphs](dependency-graphs.md). Existing forwarded mcppls graph commands remain separate. diff --git a/mide-plugins/vscode/docs/compatibility.md b/mide-plugins/vscode/docs/compatibility.md new file mode 100644 index 0000000..75ac0a2 --- /dev/null +++ b/mide-plugins/vscode/docs/compatibility.md @@ -0,0 +1,136 @@ +# Compatibility + +This extension has a hard dependency on `sunrisepeak.mcpp-language-server` (display name +**C++ Modules Language Server**, "mcppls" below), and it never reimplements that extension's +behaviour. This page records the platform matrix, the dependency mechanics, the commands that +were actually checked, and how the capability probe degrades. + +## Platform matrix + +| Platform | mcppls VSIX | This extension | +| --- | --- | --- | +| `linux-x64` | yes | activates | +| `linux-arm64` | yes | activates | +| `darwin-arm64` | yes | activates | +| `win32-x64` | yes | activates | +| `darwin-x64` | **no package** | does not activate | +| `win32-arm64` | **no package** | does not activate | + +The matrix comes from mcppls's `packaging/release.manifest.json` and is cross-checked on the +mcppls side in four places (its devtools, `payload.lock.json`, `editors/vscode/src/payload.ts` +`SUPPORTED_PLATFORMS`, and its CI matrix). mcppls validates its own payload at load time: +`manifest.platform` must equal `${process.platform}-${process.arch}`. + +## What `extensionDependencies` does and does not give us + +```json +"extensionDependencies": ["sunrisepeak.mcpp-language-server"] +``` + +- **Install**: on a supported platform VS Code resolves and installs the matching mcppls + package automatically — the user does nothing. +- **Upgrade**: mcppls upgrades independently; this extension immediately uses whatever is + installed, with no reinstall. +- **No version range is possible.** The field accepts an extension id and nothing else. There is + no lower bound, no upper bound, and **no install-time compatibility check**. That is why + capability probing exists instead of a version gate. +- The dependency is **one-way**: mcppls declares no `extensionDependencies` on this extension. +- mcppls's diagnostic report reads this extension's `packageJSON.version` as environment + information, and mcppls excludes it from its "conflicting C++ extension" candidates. That is + mcppls's one-way knowledge of us, not a compatibility guarantee. + +`src/mcppls/contract.ts` also exports `VERIFIED_MCPPLS_RANGE = ">=0.0.4"`, the range this build +was written against. In this build the constant is **not read by any production code** — no +comparison, no notice. The judge is the capability probe, not a version number: a version +cannot predict a rename. + +## Unsupported platforms + +`extensionDependencies` is resolved by VS Code when the extension is installed and again when it +is activated. On `darwin-x64` or `win32-arm64` there is no mcppls package to resolve, so **this +extension is not activated at all** — it does not activate and then report `unavailable`. VS +Code surfaces the unresolved dependency itself. + +Caveat, from `.agents/docs/mcppls-integration.md`: this behaviour should be re-confirmed with a +clean `extensions/` directory, including an offline install, before a release, and the result +recorded there. It is a stated expectation, not a tested one in this build. + +## The commands that were checked + +Four mcppls commands are the original coupling surface, referenced by +`src/mcppls/contract.ts` and exercised by the tests: + +| mcppls command | Used for | +| --- | --- | +| `mcppls.selectContext` | `mcpp.configureLanguageServer`, `mcpp.languageServer.selectContext` | +| `mcppls.restartServer` | `mcpp.checkModuleSupport`, `mcpp.languageServer.restart`, the post-build refresh fallback | +| `mcppls.showModuleGraph` | `mcpp.showModuleGraph`, `mcpp.languageServer.showModuleGraph` | +| `mcppls.showLogs` | `mcpp.showLanguageServerLogs`, `mcpp.languageServer.showLogs` | + +**These ids are not a documented cross-extension API.** They are mcppls's own UI commands and +may change between versions. The only guard is this repository's tests. + +A second batch of commands (restart clangd, reset the workspace cache, collect a report, export a +diagnostic bundle, run the build tool in a terminal, conflict handling, per-workspace +enable/disable, install command-line tools, review changes) is forwarded with the same mechanics +and the same caveat — see [commands.md](commands.md#c-modules) for the full table and the +confirmation each one requires. + +## Commands we must not register + +mcppls's S3 specification (S3-5.6-3) says a client **MUST NOT** register a command id the server +advertises, because `vscode-languageclient` registers one VS Code command per advertised id and +a duplicate stops the language client from starting. So: + +- every id this extension contributes starts with `mcpp.`, never `mcppls.`; +- we do not "fill in" a missing refresh command such as `mcppls.reloadBuildDescription`: it is + already in the server's advertised list. + +The server's advertised ids include `mcppls.review.run`, `mcppls.review.clear`, +`mcppls.reloadBuildDescription`, `mcppls.describeOnline`, `mcppls.restartEngine`, +`mcppls.exportBundle` and `mcppls.resetCache`. Some have a paired extension-side command with a +different name (`mcppls.restartClangd`, `mcppls.exportDiagnosticBundle`, +`mcppls.resetWorkspaceCache`). + +## How the probe degrades instead of breaking + +`src/mcppls/capabilities.ts` probes on two levels, deliberately: + +- **Static** — `getExtension(id)?.packageJSON.contributes.commands`. Zero side effects; it never + calls anything, because most of these commands open a picker or a panel. It is re-read on + `vscode.extensions.onDidChange`. A command the static read cannot confirm is **greyed out, not + hidden**, so a wrong read cannot remove a feature. `commands.getCommands()` is not used: + whether it lists a contributed-but-unactivated command is not guaranteed. +- **Runtime** — the first real use calls the command once and classifies the error + (`command '…' not found`-style messages become `missing`). Only then is the capability hidden, + and the degraded hint is written once. A failure that is not "not found" is a failure of that + one call and removes nothing. + +`readState` is separate: it does not use a command at all, but the object mcppls's `activate()` +returned, guarded by a shape check and `try/catch`. Anything unexpected becomes +`available: false` with a reason — the view simply shows less. + +Invariants the tests assert: + +1. **A failing mcppls never turns a successful `mcpp build` into a failure.** The refresh result + is reported separately; a build's success is decided by the task's exit code alone. +2. **A missing capability never disables an mcpp feature.** Every entry in the capability table + has `required: false`, and `capabilityProblems()` fails if one is marked required. +3. **A broken `readState` leaves the view usable** — it keeps its status line, its identity and + all forwarding actions. +4. `deactivate()` leaves no dangling promise (VS Code disposes what `activate()` registered). + +## What this extension reads and writes on the mcppls side + +- Reads `mcppls.enable` (default `true`) to show `· disabled here`. **Read-only.** +- Writes **no** `mcppls.*` setting, ever. Two commands change mcppls state + (`mcppls.turnOnInWorkspace` / `turnOffInWorkspace`), but they are mcppls's own and only run + when the user triggers them. +- Does not read or parse mcppls's cache directories, model files or logs — that layout is an + internal implementation detail. +- Does not create an LSP client. There is no `vscode-languageclient` dependency; CI asserts the + packaged extension contains none. + +Background and the reasoning for each integration decision: +`.agents/docs/mcppls-integration.md`. What mcpp itself promises and does not promise: +`.agents/docs/mcpp-integration.md`. diff --git a/mide-plugins/vscode/docs/dependency-graphs.md b/mide-plugins/vscode/docs/dependency-graphs.md new file mode 100644 index 0000000..7411410 --- /dev/null +++ b/mide-plugins/vscode/docs/dependency-graphs.md @@ -0,0 +1,25 @@ +# Dependency graphs + +Open **Project → Basics → C++ Modules Deps** for imports, or use the graph action on the **Dependencies** row for package declarations. C++ parsing and resolution come from mcppls; package relationships come from manifests, with lock/index metadata supplementing them. + +The toolbar keeps entry selection and search visible. **View options** contains the two depth controls, **Locate focus** and **Back to project**. Right-click a node for focus, locate, branch expansion/collapse, importer actions and source opening. Keyboard users can press Shift+F10 or the menu key, navigate with arrows, and press Escape to return to the node. Double-click and the node plus control remain shortcuts. **Export** combines SVG, PNG and Mermaid copy in one menu. Menus close after an action, on outside click or with Escape. + +Modules opens an active entry with three dependency levels and two importer levels. Main and Library entries follow explicit target/main and lib paths or the package defaults. Multiple tests are grouped under Tests using `[test].discover` (default `tests/**/*.cpp`); entry groups show twenty entries per page. Other keeps disconnected components accessible. External package modules stop after one hop. The importer control follows arrows backwards; dependency depth follows imports forwards. + +- Click a node to select it; double-click or press Enter to open its read-only source beside the graph. Space selects without opening. +- **Focus** uses the selected node as the center of a new subgraph. **Locate selected** and **Locate focus** move the camera without changing the graph. C and F work while the canvas has keyboard focus. +- **Expand next level** advances the selected branch repeatedly. Importers have separate expand/collapse controls. Collapsing a shared branch preserves nodes still reachable from another branch. Changing a depth control resets that direction's local overrides in the current view. +- Search selects and locates a visible match. A hidden match offers **Focus this result**. **Back** restores prior navigation; **Back to project** shows entry groups. +- Scroll to zoom, drag to pan and use **Fit** to show the complete visible drawing. Changing selection, locating and zooming reuse the current layout. + +The normal view is bounded to 60 nodes and 80 edges before layout. A plus count indicates omitted outgoing neighbors; the view notice distinguishes a layout limit from incomplete provider data. When a branch reaches the limit, focus that node to continue exploring. “All” removes the depth limit while retaining the drawing budget. + +Refresh merges nearby file/build events and keeps at most one query active for a project/provider. Hidden graphs defer rendering. Unchanged snapshots reuse layouts; an incomplete snapshot gets bounded background retries while visible, stopping when results stop changing. If the provider remains incomplete, its notice stays visible and Refresh is available. First-time mcppls indexing can still take several seconds; this extension does not run another C++ indexer. + +Project Dependencies remains visible without a lock file: declarations are enough to list direct dependencies. A project with none shows a single information row. Transient manifest read failures preserve the last usable rows and mark them pending; without usable data a retry row appears. Successful manifest/lock changes update the row automatically. Building is useful for generating resolved metadata, but is not required to show declarations. + +**Export → Export SVG** and **Export → Export PNG** save the complete visible drawing, independent of pan and zoom. They preserve current labels, colors and folding, and include a partial-view description when appropriate. Exports do not expand the full project or query mcppls. PNG starts at 2× and reduces resolution to stay within 16 megapixels and 8192 pixels per side; SVG remains scalable. Both use the standard save dialog. + +For contributors, `npm run test:graphs-ui` runs the shipped canvas and Mermaid in Chromium, covering interaction, unchanged-layout reuse and offline SVG/PNG export. Install the browser once with `npx playwright install chromium`. The normal CI also runs this test and retains fixture screenshots and exports as artifacts. + +Refresh and Export use theme colors and a shared control height. Both depth selectors have equal widths. While the project sidebar is visible, a two-file metadata stamp check supplements editor save events and file watchers; unchanged stamps trigger no graph reads or layout. diff --git a/mide-plugins/vscode/docs/libraries.md b/mide-plugins/vscode/docs/libraries.md new file mode 100644 index 0000000..ab79979 --- /dev/null +++ b/mide-plugins/vscode/docs/libraries.md @@ -0,0 +1,88 @@ +# Libraries + +Open **MC++ → Libraries**, search for a package, and click it to open its detail page. +The default catalog is the official mcpp-index already installed on this machine. +The footer distinguishes rereading local files from updating the index with mcpp. + +The detail header shows usage, C++ standard and license tags. Choose the target +project in the upper right, then select a version available for the current +environment. This platform describes the extension host, not a cross-compilation target. + +Use the arrow beside the add button to choose the dependency type: + +| Type | Behavior | +| --- | --- | +| Project dependency | Runs `mcpp add @` | +| Development dependency | Runs `mcpp add @ --dev` | +| Build dependency | Shows a copyable `[build-dependencies]` entry and opens `mcpp.toml` for editing | + +Current mcpp does not provide a flag for adding build dependencies. Moving an +existing declaration between project and development groups also opens the +manifest with an appropriate snippet; the extension does not silently duplicate +or migrate it. Additions use exact versions. Existing declarations are preserved +by default, and lockfile resolutions are shown separately from direct declarations. + +Usage shows the index's real test examples, preferring the package's own named +test project. Local checkout tests take priority. Official packaged indexes often +omit tests, so the extension includes a pinned upstream test snapshot for offline +use, with its commit shown on the page. Open the complete source in a read-only +editor; this also applies to local checkout sources. Copy and source actions sit +beside the example heading. Custom indexes only use their own tests. Packages without matching tests +keep an explicit empty state. Platform versions, build information and source +diagnostics can be expanded without leaving the page. + +In **Project → Dependencies**, use the graph icon to open a Mermaid view, +initially expanded to three dependency levels. The palette command is **mcpp: Show Project +Dependency Graph**. Relations come from project/local-path manifests and local +index descriptors; lockfiles supply versions rather than parent edges. This is a +declaration graph, so conditional or descriptor-level relations may differ from a +resolved build. Missing local metadata is marked with a dashed node. The page +supports zoom, refresh, opening the manifest and copying Mermaid source. The +package provider retains its 120-node/240-edge data limit; the shared canvas also +bounds every layout before rendering. + +The dependency list shows direct `namespace.package@version` declarations and +distinguishes index, path and git sources. Click a row to open the graph focused +on that dependency. Graph edges keep the full package coordinate together; +colored nodes show minimal tags and dependency counts. The primary canvas omits +absolute paths and diagnostic paragraphs; the legend and source are folded. + +**Search other registries** is optional and off by default. Results from these +registries appear in a separate group and open limited previews. They cannot be +added directly without a complete local descriptor. + +If the official catalog is missing, update the mcpp index or configure +`mcpp.library.indexPath` to an index directory containing `pkgs/`. A custom path +is identified as a custom source; global index updates do not update an arbitrary +checkout. The legacy official path is supported, while the xlings tool catalog +is not automatically substituted for mcpp-index. + +The dependency graph supports cursor-centered wheel zoom and click-and-drag panning. Use Fit to restore the complete graph; keyboard `+`, `-`, and `0` provide zoom and reset. + +The project sidebar keeps **C++ Modules Deps** inside **Basics**, followed by a +separate, initially collapsed **Dependencies** section containing direct packages. +The graph panel switches between **Packages** and **C++ Modules**, preserving each +view's selection and viewport. + +Module relationships come from the installed C++ Modules language server's +`mcppls query module --graph --format json` output. No C++ source parser is added. +Internal relationships can be explored at any depth; external modules, including +local path packages, stop after one outgoing hop from the project. Entry cards +identify explicit target main files, `[lib].path`, or existing default entry files. +Click a module to open its provider source read-only beside the graph. + +The initial depth is 3; choose 5 or All, or activate a card's `+N` control to +expand one branch. Collapse keeps shared nodes reached by other branches. Search +also finds hidden nodes and opens a focused neighborhood with direct importers; +Back to project restores the overview. Each Mermaid layout is limited to 150 +nodes and 300 edges, computed before rendering. All respects these budgets. +The mcppls CLI currently limits its full graph response to 500 module records; +truncated or still-indexing results carry a compact incomplete-data notice. +Use Refresh after source edits or indexing completes. Precise declaration lines +and per-import re-export flags are not supplied by this graph endpoint, so this +view opens the source file and does not invent those details. + +Package details use the available editor width, including fullscreen and ultrawide +windows. Side gutters stay within 16–40px; the metadata column stays within +240–320px and additional width goes to the example code. Below 900px the page +uses a single column for split editors and narrow windows. diff --git a/mide-plugins/vscode/docs/settings.md b/mide-plugins/vscode/docs/settings.md new file mode 100644 index 0000000..9291c46 --- /dev/null +++ b/mide-plugins/vscode/docs/settings.md @@ -0,0 +1,1154 @@ +# Settings + + + +78 settings, in 10 groups. All of them are read by the +**mcpp** extension only. `mcppls.*` belongs to the C++ Modules extension and is never +written by this one; the configuration panel says so on every screen. + +| | | +|---|---| +| Panel | **mcpp: Open Settings Panel** (`mcpp.openSettings`) | +| Native UI | Settings → Extensions → mcpp, or `@ext:mcpp-community.mcpp-vscode` | +| Defaults | Every default is deliberately quiet: nothing here changes behaviour until you ask | + +## Project + +### `mcpp.path` + +Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process. + +供扩展执行全部 mcpp CLI 命令使用的 mcpp 路径。留空时从 VS Code 进程的 PATH 中查找 mcpp。 + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.1.0 | + +### `mcpp.project.discoveryBoundary` — advanced + +How far the extension walks to find mcpp.toml. "filesystem" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees. + +扩展为查找 mcpp.toml 而向上搜索的范围。设为 “filesystem” 时还会搜索工作区文件夹之外,磁盘开销更大,且可能触及无关目录树。 + +| | | +|---|---| +| Type | `string`: `workspaceFolder` \| `filesystem` | +| Default | `"workspaceFolder"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Tasks + +### `mcpp.task.clearTerminal` — advanced + +Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's. + +在 mcpp 任务开始前清空终端,避免把上一次构建的输出误当成这一次的。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.buildArgs` + +Extra arguments appended to the mcpp build task. Applied on the next build. + +追加到 mcpp build 任务的额外参数。下次构建时生效。 + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.runArgs` + +Extra arguments appended to the mcpp run task. Applied on the next run. + +追加到 mcpp run 任务的额外参数。下次运行时生效。 + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.testArgs` + +Extra arguments appended to the mcpp test task. Applied on the next test run. + +追加到 mcpp test 任务的额外参数。下次测试时生效。 + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next build/run/test | +| Since | 0.5.0 | + +### `mcpp.task.cleanArgs` + +Extra arguments appended to the mcpp clean task. Applied on the next clean. + +追加到 mcpp clean 任务的额外参数。下次清理时生效。 + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect on the next clean | +| Since | 0.5.0 | + +### `mcpp.task.confirmClean` + +Ask for confirmation before a clean task deletes the target directory of the project. + +清理任务删除工程 target 目录前先请求确认。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.revealTerminal` — advanced + +When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never. + +mcpp 任务何时把终端面板带到前台:始终显示、仅在失败时显示,或从不显示。 + +| | | +|---|---| +| Type | `string`: `always` \| `onFailure` \| `never` | +| Default | `"always"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.focusTerminal` + +Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away. + +任务运行时把键盘焦点移到终端。默认关闭,以免抢走编辑器焦点。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.problemMatcher` + +Parse compiler output of the mcpp build and test tasks into the Problems panel. + +把 mcpp build 与 test 任务的编译器输出解析到「问题」面板。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.task.editorTitleButtons` + +Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project. + +当文件属于 mcpp 工程时,在编辑器标题栏显示运行与测试按钮。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.build.onSave` + +Start a build of the file's project when a source or mcpp.toml is saved. A save while a build waits replaces it; a running build is cancelled and started again. + +保存源文件或 mcpp.toml 时构建其所在工程。构建在等待时再次保存会替换它;正在运行的构建会被取消并重新开始。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.0.0 | + +### `mcpp.build.profile` + +The profile build, run, test and debug use: dev (unoptimised, debug information) or release. + +构建、运行、测试与调试使用的配置:dev(不优化,带调试信息)或 release。 + +| | | +|---|---| +| Type | `string`: `dev` \| `release` | +| Default | `"dev"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.0.0 | + +### `mcpp.build.target` + +The target build, run, test, debug and pack use, as mcpp names it (x86_64-linux-musl, x86_64-openkal). Empty: this machine. The Targets panel lists the targets and the platform that configures each. + +构建、运行、测试、调试与打包使用的目标,按 mcpp 的命名(x86_64-linux-musl、x86_64-openkal)。留空:本机。目标面板列出各目标及配置它的平台。 + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.0.0 | + +### `mcpp.debug.engine` + +The debug adapter a debug session uses. auto: CodeLLDB when it is installed, then the C/C++ extension's gdb or lldb, then lldb-dap. + +调试会话使用的调试适配器。auto:已安装 CodeLLDB 时用它,其次用 C/C++ 扩展的 gdb 或 lldb,再次用 lldb-dap。 + +| | | +|---|---| +| Type | `string`: `auto` \| `cppdbg` \| `lldb` \| `lldb-dap` \| `gdb` | +| Default | `"auto"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.0.0 | + +### `mcpp.codeLens.enable` + +A CodeLens above the main of a program or a test: Run and Debug. Where C++ Modules serves the file its position is the language service's; otherwise the line is read from the text. + +在程序或测试的 main 上方显示 CodeLens:运行、调试。C++ Modules 服务该文件时位置取自语言服务,否则从文本读出行号。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.1.0 | + +### `mcpp.test.macros` — advanced + +Macros that write a test framework's cases, each as framework:MACRO (gtest:MY_TEST). A test program's cases are listed only when its source calls one of its framework's macros. + +书写测试框架用例的宏,每项写作 框架:宏名(gtest:MY_TEST)。测试程序的源文件调用了其框架的宏时才列举它的用例。 + +| | | +|---|---| +| Type | `array` | +| Default | `[]` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 1.1.0 | + +## C++ Modules language service + +### `mcpp.languageService.menuItems` — advanced + +Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands. + +在 mcpp 快捷菜单中保留 C++ Modules 操作。关闭后菜单只留 mcpp 自己的命令。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.confirmResetCache` — advanced + +Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens. + +在执行 C++ Modules 的重置前询问——该操作会丢弃本工作区的模型缓存并重新准备。关闭后这一步不再有提示。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.refreshAfterBuild` + +How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing. + +mcpp-vscode 以何种方式把构建结果告知 C++ Modules 扩展:auto、轻量重载、完整重启,或不做处理。 + +| | | +|---|---| +| Type | `string`: `auto` \| `reload` \| `restart` \| `off` | +| Default | `"auto"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.notifyOnDegraded` — advanced + +Show a notification when the C++ Modules language service reports a degraded state. + +C++ Modules 语言服务报告降级状态时显示通知。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.readState` — advanced + +Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself. + +通过 C++ Modules 扩展对外暴露的测试 API 读取其状态。关闭只会隐藏状态,不会改动 mcppls 本身。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.languageService.stateRefreshSeconds` — advanced + +Re-read the language service state every this many seconds. 0 disables the timer. + +每隔多少秒重新读取一次语言服务状态。0 表示关闭定时刷新。 + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## mcpp.toml editing + +### `mcpp.toml.completion` + +Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias. + +编辑 mcpp.toml 时提供段、键与值补全。旧键 mcpp.tomlCompletion 仍作为别名生效。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Old name | `mcpp.tomlCompletion` (still read) | + +### `mcpp.toml.hover` + +Show documentation for sections and keys when hovering in mcpp.toml. + +在 mcpp.toml 中悬停时显示段与键的说明。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.navigation` + +Enable go-to-definition for workspace, path and feature references in mcpp.toml. + +为 mcpp.toml 中的 workspace、path 与 features 引用启用跳转定义。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.enabled` + +Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting. + +统一开关 mcpp.toml 的校验。各条规则仍保留各自的严重度设置。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.diagnostics.syntax` — advanced + +Severity used for mcpp.toml syntax errors. + +mcpp.toml 语法错误的严重度。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"error"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Deprecated | mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect. | + +### `mcpp.toml.diagnostics.unknownSection` + +Severity used for sections that mcpp does not recognise. + +mcpp 无法识别的段的严重度。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Deprecated | mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect. | + +### `mcpp.toml.diagnostics.unknownKey` + +Severity used for keys that mcpp does not recognise. + +mcpp 无法识别的键的严重度。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Deprecated | mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect. | + +### `mcpp.toml.diagnostics.planeSeparation` — advanced + +Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane. + +在 mcpp.toml 中把键写错层面(例如把构建层面的键写进库层面)时的严重度。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Deprecated | mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect. | + +### `mcpp.toml.diagnostics.legacyKeys` — advanced + +Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated. + +0.5 之前仍可用、但建议迁移的 mcpp.toml 旧键的严重度。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `off` | +| Default | `"info"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | +| Deprecated | mcpp reports mcpp.toml problems itself, with their location; this setting no longer has an effect. | + +### `mcpp.toml.indexCompletion` — advanced + +Query the package index to complete dependency versions. Off by default because it needs network access. + +查询包索引以补全依赖版本。默认关闭,因为需要网络访问。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.toml.indexCompletionTimeoutSeconds` — advanced + +How long one package index query may run before it is abandoned and completion falls back to local data, in seconds. + +单次包索引查询的最长等待秒数;超时后回退到本地数据补全。 + +| | | +|---|---| +| Type | `number` | +| Default | `20` | +| Range | 1 … 600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## build.mcpp editing + +### `mcpp.buildScript.intelligence` + +Turn completion and hover support for build.mcpp on or off. + +统一开关 build.mcpp 的补全与悬停支持。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.diagnostics` + +Validate build.mcpp against the build API mcpp actually ships. + +按 mcpp 实际提供的构建 API 校验 build.mcpp。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.diagnostics.severity` — advanced + +Severity used for build.mcpp diagnostics. + +build.mcpp 诊断使用的严重度。 + +| | | +|---|---| +| Type | `string`: `warning` \| `info` \| `off` | +| Default | `"warning"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.imports.knownModules` — advanced + +Offer mcpp's own modules (std, std.compat, mcpp.*) in import completion. Hover still recognises them, and no import is ever reported missing, whichever way this is set. + +在 import 补全中列出 mcpp 自带的模块(std、std.compat、mcpp.*)。无论此设置如何,悬停仍会识别它们,也不会把任何 import 报为缺失。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.buildScript.snippets` — advanced + +Offer snippet completions for the mcpp build script API. + +为 mcpp 构建脚本 API 提供片段补全。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Cache + +### `mcpp.cache.statusBar` + +Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet. + +在状态栏显示 mcpp 全局缓存的大小。默认关闭,以保持状态栏清爽。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.warnAboveGiB` + +Warn when the mcpp global cache grows past this many GiB. 0 disables the warning. + +mcpp 全局缓存超过该 GiB 数时发出警告。0 表示关闭警告。 + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.staleDays` + +Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule. + +超过该天数未被访问的缓存条目视为过期。0 表示关闭该时间规则。 + +| | | +|---|---| +| Type | `number` | +| Default | `3` | +| Range | 0 … 365 | +| Scope | per folder | +| Applies | Takes effect on the next clean | +| Since | 0.5.0 | + +### `mcpp.cache.autoRefreshSeconds` — advanced + +Re-read the cache statistics every this many seconds. 0 disables automatic refreshing. + +每隔多少秒重新读取一次缓存统计。0 表示关闭自动刷新。 + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … 3600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.estimateProjectBytes` — advanced + +Measure the target directory of the current project so the cache view can show its size. + +统计当前工程 target 目录的体积,供缓存视图显示。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.showLegacy` — advanced + +Include cache entries written by older mcpp versions in the cache view and in cleaning. + +在缓存视图与清理中包含旧版 mcpp 写入的缓存条目。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.gc.defaultBudgetGiB` + +Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run. + +缓存清理的默认体积预算(GiB)。0 表示每次清理都询问预算。 + +| | | +|---|---| +| Type | `number` | +| Default | `0` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.gc.confirmAboveGiB` — advanced + +Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup. + +单次清理预计释放超过该 GiB 数时追加一次确认。0 表示每次清理都确认。 + +| | | +|---|---| +| Type | `number` | +| Default | `1` | +| Range | 0 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.cache.pruneAgeDays` — advanced + +Default age in days used when pruning cache entries older than a threshold. + +清理缓存条目时默认使用的天数阈值。 + +| | | +|---|---| +| Type | `number` | +| Default | `30` | +| Range | 1 … ∞ | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Views + +### `mcpp.views.project.show` + +Show the mcpp project view in the activity bar. Takes effect after the view container reloads. + +Show the mcpp project view in the activity bar. Takes effect after the view container reloads. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.cache.show` + +Show the mcpp cache view in the activity bar. Takes effect after the view container reloads. + +Show the mcpp cache view in the activity bar. Takes effect after the view container reloads. + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.enabled` + +Turns every mcpp view off at once. The view contents disappear, so the container shows nothing; whether VS Code also removes the mcpp icon from the activity bar is up to VS Code itself, because an extension cannot mark its container as hidden-when-empty. Right-click the icon to hide it there. + +一次性关闭全部 mcpp 视图。视图内容会消失,容器变成空的;mcpp 图标是否也从活动栏移除由 VS Code 决定——扩展无法把自己的容器标记为「空则隐藏」。要移除图标可在图标上右键。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.6.0 | + +### `mcpp.views.library.show` + +Show the view that browses the package index mcpp has already refreshed locally. + +显示用于浏览本地已刷新包索引的视图。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.6.0 | + +### `mcpp.library.indexPath` — advanced + +Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source. + +留空则读取 mcpp 主目录下 indices/mcpplibs 中的官方 mcpp-index,兼容旧位置 registry/data/mcpplibs。优先使用 MCPP_HOME,否则通过 mcpp self env 获取主目录。可指定带 pkgs/ 的目录或索引集合目录来浏览自定义来源。 + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.6.0 | + +### `mcpp.library.networkSearch` + +Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly. + +默认关闭。开启后额外用 mcpp search 搜索其它 registry;搜索结果仅提供有限预览,不能直接添加。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.6.0 | + +### `mcpp.views.languageServer.show` + +The C++ Modules state is folded into the project view's basic information. The collapsed row always shows a status icon, so a problem is visible without expanding it. + +C++ Modules 的状态折进工程视图的「基本信息」。折叠行始终带状态图标,因此不展开也能看出异常。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect after the window reloads | +| Since | 0.5.0 | + +### `mcpp.views.cache.topN` — advanced + +How many entries the cache view lists per group. + +How many entries the cache view lists per group. + +| | | +|---|---| +| Type | `number` | +| Default | `5` | +| Range | 1 … 50 | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.views.cache.ageBuckets` — advanced + +Age buckets used to group cache entries in the cache view, written as "1d", "7d" and so on. + +Age buckets used to group cache entries in the cache view, written as "1d", "7d" and so on. + +| | | +|---|---| +| Type | `array` | +| Default | `["1d","7d","30d"]` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Interface + +### `mcpp.ui.confirmDestructiveOnly` — advanced + +Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less. + +只在无法撤销的操作上使用模态对话框。关闭后只会问得更多,不会更少。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.language` + +Language for extension messages and panels. "auto" follows VS Code; command titles and settings labels always follow the VS Code display language. + +扩展提示与面板使用的语言。“auto” 跟随 VS Code;命令面板标题与设置项名称始终跟随 VS Code 显示语言。 + +| | | +|---|---| +| Type | `string`: `auto` \| `en` \| `zh-cn` | +| Default | `"auto"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.statusBar.show` — advanced + +Show the main mcpp status bar item. + +显示 mcpp 的主状态栏项。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.statusBar.showLanguageServer` — advanced + +Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item. + +在 mcpp 状态栏项中显示 C++ Modules 语言服务状态。默认关闭,因为 mcppls 已有自己的状态项。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.statusBar.background` — advanced + +VS Code accepts only two background colours for a status bar entry, "statusBarItem.warningBackground" and "statusBarItem.errorBackground", and it overrides the foreground so the text stays readable; any other colour is dropped. The mcpp item is plain by default; "warning" gives it the theme's amber chip and "error" its red one. + +VS Code 只允许状态栏项使用两种背景色——`statusBarItem.warningBackground` 与 `statusBarItem.errorBackground`——并会自动换上对比度足够的前景色,其他颜色会被丢弃。mcpp 状态项默认朴素无色;"warning" 给它主题的琥珀色块,"error" 给红色。 + +| | | +|---|---| +| Type | `string`: `none` \| `warning` \| `error` | +| Default | `"none"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.6.0 | + +### `mcpp.ui.notifications.success` + +How successful mcpp operations are reported: in the status bar only, as a toast, or not at all. + +mcpp 操作成功时的提示方式:只在状态栏显示、弹出 toast,或完全静默。 + +| | | +|---|---| +| Type | `string`: `silent` \| `statusBar` \| `toast` | +| Default | `"statusBar"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.notifications.dedupeMinutes` — advanced + +Suppress a repeated notification for this many minutes. 0 shows every notification. + +同一条通知在该分钟数内只显示一次。0 表示每次都显示。 + +| | | +|---|---| +| Type | `number` | +| Default | `5` | +| Range | 0 … 1440 | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.ui.numberFormat` + +Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB). + +字节大小使用二进制单位(KiB、MiB)还是十进制单位(KB、MB)。 + +| | | +|---|---| +| Type | `string`: `binary` \| `decimal` | +| Default | `"binary"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Diagnostics and logging + +### `mcpp.log.level` + +Amount of detail written to the mcpp output channel. Debug output is verbose. + +写入 mcpp 输出频道的详细程度。debug 输出较为冗长。 + +| | | +|---|---| +| Type | `string`: `error` \| `warn` \| `info` \| `debug` | +| Default | `"info"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.diagnostics.selfCheckOnStartup` — advanced + +Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp. + +激活时运行环境自检,并把发现的问题写入输出频道。默认关闭,因为会启动 mcpp。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `false` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +## Advanced + +### `mcpp.runtime.timeoutSeconds` — advanced + +How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout. + +单条 mcpp CLI 命令的最长运行秒数,超时后终止。0 表示不限时。 + +| | | +|---|---| +| Type | `number` | +| Default | `30` | +| Range | 0 … 3600 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.runtime.maxOutputMiB` — advanced + +Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept. + +单条 mcpp 命令最多捕获的输出(MiB)。输出被截断时保留尾部。 + +| | | +|---|---| +| Type | `number` | +| Default | `16` | +| Range | 1 … 1024 | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.runtime.concurrency` — advanced + +Whether the one-command-at-a-time rule applies to each project separately or to the whole window. + +「同一时刻只运行一条命令」的限制作用于单个工程,还是整个窗口。 + +| | | +|---|---| +| Type | `string`: `perProject` \| `global` | +| Default | `"perProject"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.5.0 | + +### `mcpp.clangd.path` — **deprecated**, advanced + +Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。 + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.modulesSupport` — **deprecated**, advanced + +Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。 + +| | | +|---|---| +| Type | `string`: `auto` \| `on` \| `off` | +| Default | `"auto"` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.configureCppTools` — **deprecated**, advanced + +Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. + +已弃用:mcpp-vscode 不再读取此设置;语言服务冲突现由 sunrisepeak.mcpp-language-server 管理。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.4.0 | +| Deprecated | Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting. | + +### `mcpp.tomlCompletion` — **deprecated**, advanced + +Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead. + +已弃用:mcpp-vscode 不再读取此设置;请改用 mcpp.toml.completion。 + +| | | +|---|---| +| Type | `boolean` | +| Default | `true` | +| Scope | per folder | +| Applies | Takes effect immediately | +| Since | 0.2.6 | +| Deprecated | mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead. | + +### `mcpp.ide.backendPath` — advanced + +The mcpp-ide program the extension starts. Empty: the backend shipped with the extension, then the one xlings installed, then PATH. + +扩展启动的 mcpp-ide 程序。留空:先用扩展自带的后端,再用 xlings 安装的,最后用 PATH 中的。 + +| | | +|---|---| +| Type | `string` | +| Default | `""` | +| Scope | machine | +| Applies | restart | +| Since | 1.0.0 | + +### `mcpp.trust.denyEffects` — advanced + +What mcpp may not do while the workspace is not trusted (mcpp --deny): a run that needs one of them stops before it and says so. Trusting the workspace lifts the list. + +工作区未受信任时 mcpp 不得做的事(mcpp --deny):需要其中之一的运行会在发生前停止并说明原因。信任工作区后解除。 + +| | | +|---|---| +| Type | `array` | +| Default | `["exec-build-script","network"]` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 1.0.0 | + +### `mcpp.backend.logLevel` — advanced + +What the mcpp-ide backend writes to the editor's log: errors only, warnings, what it does (the default), or every run of mcpp and mcppls with its arguments. + +mcpp-ide 后端写入编辑器日志的内容:只写错误、写警告、写它所做的事(缺省),或写每一次运行 mcpp 与 mcppls 及其参数。 + +| | | +|---|---| +| Type | `string`: `error` \| `warning` \| `info` \| `debug` | +| Default | `"info"` | +| Scope | per window | +| Applies | Takes effect immediately | +| Since | 1.1.0 | + +## Language + +`mcpp.ui.language` overrides the language of **runtime messages and this extension's +panels**. The command palette and the Settings UI always follow the editor's own +language: VS Code resolves `package.nls.*` once at startup, so a manual override +cannot reach them. `auto` (the default) follows the editor everywhere. diff --git a/mide-plugins/vscode/docs/troubleshooting.md b/mide-plugins/vscode/docs/troubleshooting.md new file mode 100644 index 0000000..73fb883 --- /dev/null +++ b/mide-plugins/vscode/docs/troubleshooting.md @@ -0,0 +1,134 @@ +# Troubleshooting + +Start with the **mcpp** output channel (`View → Output → mcpp`) and then run +**mcpp: Environment Self-check** (`mcpp.selfCheck`). The self-check writes one copyable block to +that channel: extension/VS Code/platform, workspace trust and roots, the project root, +`mcpp.path` plus the `--protocol-version` probe (version, envelope, advertised `kinds`), the +mcppls version plus every capability's state (`available` / `unconfirmed` / `missing`), the +mcppls state summary, the last language-service refresh, and every setting whose effective value +differs from its default. Its `Cache` section reports the shared cache's size, entry count and +incomplete-entry count, read with the same bounded query the Cache view uses. + +## The C++ Modules view says the dependency is missing + + + +The view's first node reads `C++ Modules is not installed`. It appears when +`vscode.extensions.getExtension("sunrisepeak.mcpp-language-server")` is `undefined`, and its +command opens that extension's settings page (the view also uses it as an install entry point). + +Because `extensionDependencies` is a **hard** dependency, on a supported platform the extension +should not activate at all without mcppls — see the platform matrix in +[compatibility.md](compatibility.md). So this node is normally only reachable if mcppls was +disabled after activation, or on a platform where no package exists. + +1. Confirm it is installed and enabled: + `code --list-extensions --show-versions | grep mcpp-language-server`. +2. Confirm the platform has a package ([compatibility.md](compatibility.md#platform-matrix)). +3. If the tree says `· disabled here`, `mcppls.enable` is `false` for this workspace. That + value is read-only for this extension; change it with mcppls's own command + (`mcpp.languageServer.toggleInWorkspace`) or its Settings page. + +## The language service stays on "no status yet" + + + +The Status node shows the state's `reason` instead of a state. Reading mcppls's state goes +through the object its `activate()` returned (its internal `TestApi`), which is a +**best-effort channel, not a contract**. `readStateFromExports` returns `available: false`, +with a reason, whenever the exports are not an object, `lastStatus` is not a function, the call +throws, or the returned `state` is not one of `starting`, `loading`, `preparing`, `ready`, +`degraded`, `error`. + +- The reason string is written to the `mcpp` output channel; read it there. +- `mcpp: Environment Self-check` prints the same state plus the capability table. +- `mcpp.languageService.readState` (default `true`) and + `mcpp.languageService.stateRefreshSeconds` (default `0`) are declared but **not read** in this + build: turning the first off does not stop the read, and setting the second does not add a + timer. There is no polling either — the view is refreshed by + `mcpp.languageServer.refreshState`, by `vscode.extensions.onDidChange`, and by a configuration + change to `mcppls.enable` or `mcpp.views.languageServer`. +- The view stays usable: when the state is unavailable it still lists every action that + forwards to mcppls. + +## A command reports it is not offered + + + +The message is `…: the installed C++ Modules does not offer this action.`, followed by the +capability's own `degradedHint`. It means the probe classified the capability as `missing`: +either the id was absent from mcppls's declared `contributes.commands` (static read), or the +first real call failed with a `command '…' not found`-style error. The candidate chain is tried +once, then the result is remembered for the session. + +1. `mcpp: Environment Self-check` lists every capability as `available`, `unconfirmed` or + `missing` — that is the fastest way to see what the installed version actually offers. +2. Compare the mcppls version in the same block with the one this build was written against + (`>=0.0.4`). The version only produces a notice; the real judge is the capability probe. +3. Update mcppls. Nothing you can set in `mcpp.*` brings a missing command back. + +Note that `mcppls.review.run` and `mcppls.review.clear` are **server-advertised** commands: they +exist only while the language server is running, so they can be reported as unoffered before +the server starts. That is expected. + +## mcpp cannot be found + + + +When `mcpp.path` is empty the extension runs the literal executable name `mcpp`, resolved on the +`PATH` of the **VS Code process**. On macOS a VS Code launched from the Dock or Finder often has +a different `PATH` than a terminal. + +- Set `mcpp.path` to the absolute executable, for example `/opt/homebrew/bin/mcpp`, then + re-run the command. The setting is per-folder (scope `resource`). +- The `mcpp` output channel shows the exact `$ ` line, the captured stdout/stderr + and `[exit N]` for every short command; a task's argv and exit code are logged too. +- `mcpp.path` affects **this extension's CLI calls only**. mcppls finds mcpp by itself, in the + order `PATH` → `$HOME/.mcpp/bin/mcpp` → `$HOME/.xlings/subos/current/bin/mcpp`, behind its own + non-user-visible `mcppls.mcpp` setting. If the two disagree, both output channels will show + it. +- On an untrusted workspace every mcpp command refuses with a warning. Trust the folder first + (`Workspaces: Manage Workspace Trust`). The `mcpp.toml` and `build.mcpp` text features are + pure text analysis and keep working regardless. + +## A build finished but the language service did not refresh + + + +After a `build` task that was not cancelled, this extension always asks mcppls to re-read the +build description, using the candidate chain `mcppls.reloadBuildDescription` → +`mcppls.restartServer`, single-flight so two builds cannot restart the server twice. `run`, +`test` and `clean` deliberately do not touch it. + +1. Read the `[C++ Modules] …` line the channel just got. If the refresh was degraded, the + channel says so and offers no retry. +2. Manual paths, in increasing weight: **mcpp: Refresh the Module Build Description** + (`mcpp.refreshCompilationDatabase`, which runs the `build` task and then refreshes), + **C++ Modules: Restart the Language Server** (`mcpp.languageServer.restart`), then + **C++ Modules: Reset This Workspace's Cache** (destructive, modal). +3. `mcpp.languageService.refreshAfterBuild` is declared but **not read** in this build, so + choosing `off` there does not stop the refresh. +4. A failed build still triggers a refresh on purpose, so mcppls can show the last usable + description as `degraded`. If the build itself failed, fix that first. + +## The cache figures are stale + + + +There is no automatic refresh timer in this build: `mcpp.cache.autoRefreshSeconds` is declared +but not read. The view re-reads when you run **mcpp: Refresh Cache Statistics**, when it is +painted for the first time, and when `mcpp.cache.*` or `mcpp.views.cache.*` changes. +`target/` sizes are a bounded file-system estimate, never mcpp's own accounting, and the +pre-v1 cache node does not render because no size is measured for it. Details and the +authoritative "what is stale" source: [cache.md](cache.md). + +## Nothing above helped + +- **mcpp** output channel — the full transcript, including the argv and exit code of every + command. +- **mcpp: Environment Self-check** — the snapshot to paste into an issue. +- The exit-code contract (`0` success, `1` failed, `2` usage, `4` environment not ready, `70` + internal, `101` `mcpp run` build failure, `127` unknown command) is implemented in + `src/cli/errors.ts`, but in this build the UI does not branch on it: you get the raw exit code + in the channel rather than a per-code explanation. +- Report at with the self-check block. diff --git a/mide-plugins/vscode/l10n/bundle.l10n.json b/mide-plugins/vscode/l10n/bundle.l10n.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/mide-plugins/vscode/l10n/bundle.l10n.json @@ -0,0 +1 @@ +{} diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json new file mode 100644 index 0000000..9a0491c --- /dev/null +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -0,0 +1,639 @@ +{ + "$(cloud-download) Install a toolchain…": "$(cloud-download) 安装工具链…", + "$(edit) Enter another compatible toolchain spec…": "$(edit) 输入其他兼容工具链 spec…", + "(size unknown)": "(大小未知)", + "About {0} would be freed ({1} entries).": "预计释放约 {0}({1} 个条目)。", + "Accepts family, family@version, namespace, partial versions and mcpp's compatible legacy spellings": "支持 family、family@version、namespace、部分版本和 mcpp 兼容旧拼写", + "Actions": "操作", + "Add to mcpp.toml": "添加到 mcpp.toml", + "Added": "已添加", + "Added {0} to mcpp.toml.": "已把 {0} 加入 mcpp.toml。", + "Adding {0}…": "正在添加 {0}…", + "Age distribution": "按最近使用时间分布", + "All": "全部", + "Already added": "已添加", + "Already within {0} GiB.": "当前已在 {0} GiB 以内。", + "Also empty the shared build cache": "同时清空共享构建缓存", + "An mcpp operation is already running.": "已有 mcpp 操作正在运行。", + "An mcpp operation is already running; not starting {0} now.": "已有 mcpp 操作正在运行,暂不启动 {0}。", + "An mcpp operation is already running; wait for it to finish and try again.": "已有 mcpp 操作正在运行,请等待完成后再试。", + "Array shorthand: implies the feature only": "数组简写:仅隐含 feature", + "Back to the toolchain installation flow": "回到工具链安装流程", + "Basics": "基本信息", + "Budget simulator": "预算模拟器", + "Build": "构建", + "Build and refresh the language service": "构建并刷新语言服务", + "Build cache": "构建缓存", + "Build configuration": "构建配置", + "Build environment (supplied by xlings)": "构建环境(xlings 供给)", + "Build profiles": "构建档案", + "Build shape": "构建形态", + "Build targets": "构建目标", + "Build the current mcpp project and refresh the C++ Modules language service?": "是否构建当前 mcpp 工程并刷新 C++ 模块语言服务?", + "Build-time dependencies (pulled at build time only, invisible at run time)": "构建期依赖(仅构建期拉取,运行时不可见)", + "Bytes and entries by how long ago they were last used.": "按最近使用时间统计的字节数与条目数。", + "C++ Modules (provided by the mcpp language server extension): {0}": "C++ Modules(由 mcpp 语言服务扩展提供):{0}", + "C++ Modules command failed: {0}": "C++ Modules 命令失败:{0}", + "C++ Modules is not installed": "未安装 C++ Modules", + "C++ Modules language service": "C++ Modules 语言服务", + "C++ Modules: capture the logs (report + bundle)": "C++ Modules:抓取日志(报告 + 压缩包)", + "C++ Modules: open the log": "C++ Modules:打开日志", + "C++ Modules: open the log folder": "C++ Modules:打开日志目录", + "C++ Modules: reset this workspace's cache": "C++ Modules:重置本工作区缓存", + "C++ Modules: restart the language server": "C++ Modules:重启语言服务器", + "C++ Modules: select the analysis context": "C++ Modules:选择分析上下文", + "C++ Modules: show the diagnostic report": "C++ Modules:查看诊断报告", + "C++ Modules: show the last captured bundle": "C++ Modules:定位最近抓取的压缩包", + "C++ Modules: show the module graph": "C++ Modules:查看模块图", + "C++ Modules: {0}": "C++ Modules:{0}", + "Cache actions": "缓存操作", + "Cache entry {0}": "缓存条目 {0}", + "Cache statistics": "缓存统计", + "Cache verification": "缓存校验", + "Cache warning": "缓存告警", + "Cancelled": "已取消", + "Capability binding (equivalent to --cap)": "capability 绑定(等价于 --cap)", + "Capability bindings (provider selection)": "capability 绑定(provider 选择)", + "Capture logs now": "现在抓取", + "Caret constraint (^) by default; ~, = and range combinations such as \">=1.0, <2.0\" are also supported.": "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", + "Changed from the default": "已偏离默认值", + "China mirror": "国内镜像", + "Choose a global default": "选择全局默认", + "Choose a project, toolchain, cache or C++ Modules action": "选择工程、工具链、缓存或 C++ Modules 操作", + "Choose the mcpp global default toolchain": "选择 mcpp 全局默认工具链", + "Choose the project location (2/2)": "选择项目位置(2/2)", + "Choosing here only changes the mcpp global configuration; it does not build the project": "选择后只修改 mcpp 全局配置,不会自动构建工程", + "Clean": "清理", + "Clean and build": "清理并构建", + "Clean old artifacts": "清理旧产物", + "Clean project artifacts": "清理项目产物", + "Clean stale artifacts": "清理过期产物", + "Clean target": "清理 target", + "Click a version to aim the command above at it.": "点一个版本,上面的命令就会指向它。", + "Collect a diagnostic report": "收集诊断报告", + "Collect the global cache to a budget": "将全局缓存收敛到预算内", + "Collect to this budget": "收敛到此预算", + "Command": "命令", + "Common commands": "常用命令", + "Compiler toolchain shorthand": "编译器工具链简写", + "Completing dependency versions runs `mcpp search`, which may use the network.": "补全依赖版本会调用 `mcpp search`,可能使用网络。", + "Composition by kind": "按类型的构成", + "Configuration per target triple": "按目标三元组的配置", + "Configuring the C++ Modules language service failed.": "C++ 模块语言服务配置失败。", + "Confirm a cleanup this large": "确认如此规模的清理", + "Confirm once more": "请再次确认", + "Confirm one-click setup": "确认一键配置", + "Create and open": "创建并打开", + "Create the project here": "在此创建项目", + "Current effective toolchain": "当前有效工具链", + "Current mcpp project": "当前 mcpp 工程", + "Currently effective item": "当前有效项", + "Data source": "数据来源", + "Declare dependency versions centrally; members inherit them with workspace = true": "集中声明依赖版本,成员用 workspace = true 继承", + "Declared in mcpp.toml; the resolved version comes from mcpp.lock.": "声明来自 mcpp.toml,解析值来自 mcpp.lock。", + "Default": "默认", + "Degraded": "降级", + "Delete the target/ directory of this project: {0}/target": "将删除当前工程的 target 目录:{0}/target", + "Dependencies": "依赖", + "Dependencies ({0})": "依赖 ({0})", + "Deprecated": "已弃用", + "Descriptor not readable": "描述符无法读取", + "Details": "详情", + "Detect": "检测", + "Development/test dependencies": "开发/测试依赖", + "Direction: disable.": "方向:停用。", + "Direction: enable.": "方向:启用。", + "Drop entries unused for a while": "按时间丢弃长期未用的条目", + "Effective target: host (mcpp shows no explicit target)": "有效 target:host(mcpp 未显示显式 target)", + "Effective target: {0}": "有效 target:{0}", + "Effective toolchain of this project": "当前工程有效工具链", + "Enter a family, family@version, family version, namespace or an mcpp-compatible spec": "请输入 family、family@version、family version、namespace 或 mcpp 兼容 spec", + "Enter a project name; a folder of that name is created at the location you pick": "输入项目名,将在所选位置创建同名项目文件夹", + "Enter a toolchain spec": "输入工具链 spec", + "Enter a whole number of GiB": "请输入整数 GiB", + "Entries": "条目", + "Environment self-check": "环境自检", + "Estimated size": "估算大小", + "Every entry matches its manifest.": "每个条目都与它的清单一致。", + "Every figure here is read from mcpp cache list, mcpp cache dir and a bounded walk of target/. Sizes are estimates; cleanup runs only after confirmation and never automatically.": "这里的数字来自 mcpp cache list、mcpp cache dir 以及对 target/ 的有界遍历。体积是估算值;清理只在确认后执行,绝不自动进行。", + "Example code": "示例代码", + "Example project: {0} — built and run by the index's CI, not written for this page.": "示例工程:{0} —— 由索引的 CI 实际构建并运行过,不是为这个页面写的。", + "Exit code {0}": "退出码 {0}", + "Explain this code with: {0}": "解释此退出码:{0}", + "Export a diagnostic bundle": "导出诊断包", + "Failed with exit code {0}": "失败,退出码 {0}", + "Feature definitions": "feature 定义", + "Filters": "筛选", + "Fine-tuning of the vendored filtering policy": "vendored 过滤策略微调", + "For example gcc, llvm@20.1.7, xim:gcc@16, msvc, mingw; mcpp normalises compatible spellings": "例如 gcc、llvm@20.1.7、xim:gcc@16、msvc、mingw;兼容写法由 mcpp 规范化", + "From the global default": "来自全局默认", + "From this project's mcpp.toml, overriding the global default": "来自当前项目 mcpp.toml,覆盖全局默认", + "Generated file (relative path → content, part of the fingerprint)": "生成文件(相对路径 → 内容,进指纹)", + "Generated files (path → content)": "生成文件(路径 → 内容)", + "GiB": "GiB", + "Git dependency (one of tag / branch / rev)": "Git 依赖(tag / branch / rev 三选一)", + "Global build cache": "全局构建缓存", + "Global default: ": "全局默认:", + "Global default: {0}": "全局默认:{0}", + "Host runtime capabilities": "主机运行时能力", + "Host tool binary overrides": "host 工具二进制覆盖", + "Host tools produced by the dependency (must be a bin target of that package)": "依赖产出的 host 工具(须为该包的 bin target)", + "I understand this affects every mcpp project on this machine": "我明白这会影响本机所有 mcpp 工程", + "I understand this removes entries other mcpp projects may use": "我明白这会删除其它 mcpp 工程可能正在使用的条目", + "Include directories": "包含目录", + "Incomplete entries": "不完整条目", + "Index versions mcpp aggregates by family; no host payload is promised": "mcpp 按 family 聚合的索引版本;未承诺 host payload", + "Inspect the toolchain": "查看工具链", + "Install": "安装", + "Install C++ Modules": "安装 C++ Modules", + "Install an mcpp toolchain": "安装 mcpp 工具链", + "Install extension": "安装扩展", + "Install toolchain": "安装工具链", + "Installable: {0}": "可安装:{0}", + "Installed": "已安装", + "Invalid": "无效", + "Keep the shared build cache under": "将共享构建缓存保留在", + "Keep the shared build cache under how many GiB?": "共享构建缓存保留在多少 GiB 以内?", + "Label": "包", + "Language service": "语言服务", + "Largest packages": "占用最多的包", + "Largest packages (top {0})": "占用最多的包(前 {0})", + "Last use": "最近使用", + "Legacy compatibility field; new projects should use [package].standard": "旧版兼容字段;新项目请用 [package].standard", + "Library": "库", + "Library package {0}": "库包 {0}", + "Library root-module convention": "库根模块约定", + "License": "许可", + "Long dep spec: request a feature of that dependency": "长式 dep spec:请求该依赖的 feature", + "MSVC detection finished. Choose a global default from the latest list?": "MSVC 检测完成。是否从最新列表中选择全局默认?", + "Manifest reference": "清单参考", + "Metadata and assets compiled into the product (PE targets only)": "编译进产物的元数据与资产(仅 PE 目标)", + "Module graph": "模块图", + "More ({0})…": "其它 {0} 项…", + "Move them to my user settings": "移动到我的用户设置", + "Move them to this workspace": "移动到当前工作区", + "New mcpp project (1/2)": "新建 mcpp 工程(1/2)", + "New mcpp project…": "新建 mcpp 工程…", + "New project": "新建工程", + "Newest use: {0}": "最近使用:{0}", + "No cache entries were found.": "没有找到缓存条目。", + "No cache label was read.": "没有读到缓存标签。", + "No descriptor was read.": "没有读到任何描述符。", + "No diagnostic bundle has been written yet. Capturing the logs writes one zip with the report, the environment and the recent logs.": "还没有写过诊断压缩包。抓取日志会把报告、环境和最近的日志写成一个 zip。", + "No entry has a recorded last use.": "没有条目记录最近使用时间。", + "No further packages were found in the other registries.": "其它 registry 中没有找到更多包。", + "No installed toolchain is usable for the host target, so a global default cannot be set here. For target-specific toolchains use the mcpp CLI; on Windows install Visual Studio first.": "没有可用于 host target 的已安装工具链,不能在此处设置全局默认。target 专用工具链请使用 mcpp CLI;Windows MSVC 请先安装 Visual Studio。", + "No local index was read.": "没有读取本地索引。", + "No mcpp index was found. Run mcpp: Refresh the mcpp Package Index once, or set mcpp.library.indexPath to an index checkout.": "没有找到 mcpp 索引。先执行一次 mcpp: 刷新 mcpp 包索引,或用 mcpp.library.indexPath 指向一个索引检出。", + "No mcpp project in this workspace": "当前工作区没有 mcpp 工程", + "No mcpp.toml was found in this workspace. Run this command inside an mcpp project.": "当前工作区没有找到 mcpp.toml。请在 mcpp 工程中执行此命令。", + "No package matches this search.": "没有匹配的包。", + "No settings match the search": "没有匹配的设置", + "No target is selected; for a target-specific install use the mcpp CLI": "不选择 target;target 专用安装请使用 mcpp CLI", + "No target/ directory": "没有 target/ 目录", + "No workspace folder is open": "未打开工作区文件夹", + "Not measured yet": "尚未测量", + "Off by default: this tier runs mcpp search, which may use the network and is read from human output on a best-effort basis. Failures fall back to the local index.": "默认关闭:这一档会调用 mcpp search,可能联网,且只能从人类输出尽力解析。失败时退回本地索引。", + "Oldest use": "最早使用", + "Oldest use: {0}": "最早使用:{0}", + "One value per line": "每行一个值", + "Only modified": "仅显示已修改", + "Open in the Settings editor": "在设置编辑器中打开", + "Open logs": "打开日志", + "Open on the index site": "在索引站打开", + "Open the C++ Modules settings": "打开 C++ Modules 设置", + "Open the detail page for {0}": "打开 {0} 的详情页", + "Open the mcpp project and toolchain quick menu": "打开 mcpp 项目和工具链快捷菜单", + "Open the repository": "打开仓库", + "Opened {0} in your browser.": "已在浏览器中打开 {0}。", + "Opening {0}…": "正在打开 {0}…", + "Optional dependencies pulled in by a feature": "由 feature 拉取的可选依赖", + "Override a host tool with an existing binary (skips the build)": "用已有二进制覆盖 host 工具(跳过构建)", + "POSIX environment": "POSIX 环境", + "Package metadata": "包元数据", + "Path dependency (local development)": "路径依赖(本地开发)", + "Path: {0}": "路径:{0}", + "Pin the xlings tool version": "固定 xlings 工具版本", + "Pin tool versions": "固定工具版本", + "Pre-v1 cache": "pre-v1 遗留缓存", + "Presets": "预设", + "Project artifacts": "工程产物", + "Project artifacts are not available.": "工程产物不可用。", + "Project artifacts could not be measured.": "无法测量工程产物。", + "Project cache": "项目缓存", + "Project-level index redirection": "项目级索引重定向", + "Provided by": "提供方", + "Read offline from {0} in the {1} index ({2} index folder(s) found).": "已从 {1} 索引离线读取 {0}(共找到 {2} 个索引目录)。", + "Read-only view of what mcpp currently resolves": "只读查看 mcpp 当前解析结果", + "Reading the index…": "正在读取索引…", + "Reading {0}…": "正在读取 {0}…", + "Ready": "就绪", + "Refresh": "刷新", + "Refresh cache statistics": "刷新缓存统计", + "Refresh the mcpp Package Index": "刷新 mcpp 包索引", + "Refresh this view": "刷新此视图", + "Refreshing the mcpp Package Index…": "正在刷新 mcpp 包索引…", + "Registry": "Registry", + "Reload the window to see this": "重载窗口后生效", + "Remove the pre-v1 cache": "清理 pre-v1 遗留缓存", + "Repository": "仓库", + "Reset": "重置", + "Reset the invalid value to the default": "将无效值重置为默认值", + "Reset this workspace's cache": "重置本工作区缓存", + "Resolved {0}": "已解析 {0}", + "Resolved —": "已解析 —", + "Restart language service": "重启语言服务", + "Restart the semantic engine": "重启语义引擎", + "Result: {0}": "结果:{0}", + "Root: {0}": "根目录:{0}", + "Run": "运行", + "Run the build tool in a terminal": "在终端运行构建工具", + "Run “mcpp new {0}” in {1}, create the project folder {2} and open it.": "将在 {1} 执行 “mcpp new {0}”,创建项目文件夹 {2} 并打开它。", + "Runtime dependencies": "运行时依赖", + "Save settings to": "保存设置到", + "Search all registries": "在全部 registry 中搜索", + "Search and add a dependency": "搜索并添加依赖", + "Search and add a dependency…": "搜索并添加依赖…", + "Search packages": "搜索包", + "Search settings": "搜索设置", + "Searching all registries failed (exit {0}); showing the local index only.": "搜索全部 registry 失败(退出码 {0});只显示本地索引。", + "Searching all registries…": "正在搜索全部 registry…", + "Select analysis context": "重选分析上下文", + "Select the global default toolchain": "选择全局默认工具链", + "SemVer version dependency": "SemVer 版本依赖", + "Set as global default": "设为全局默认", + "Set the global default toolchain": "设置全局默认工具链", + "Setting the global default toolchain failed (exit code {0}). See the mcpp output channel.": "设置全局默认工具链失败(退出码 {0})。请查看 mcpp 输出频道。", + "Settings": "设置", + "Settings and diagnostics": "设置与诊断", + "Share of the total cache size, by entry kind.": "按条目类型占缓存总大小的比例。", + "Shared build cache": "共享构建缓存", + "Show advanced settings": "显示高级设置", + "Show cache entry details for {0}": "查看缓存条目 {0} 的详情", + "Show running tasks": "显示正在运行的任务", + "Show toolchains": "查看工具链", + "Simulates mcpp cache gc --max-size: the estimate is a local projection, so mcpp's own policy decides in the end.": "模拟 mcpp cache gc --max-size:这里的估算只是本地推算,最终仍以 mcpp 自身的策略为准。", + "Size": "大小", + "Stale artifacts: about {0}": "其中过期产物约 {0}", + "Standard": "标准", + "Switch to {0}": "切换到 {0}", + "System toolchain (mcpp only detects it)": "系统工具链(mcpp 只检测)", + "System: system toolchain, detection only": "System:系统工具链,仅检测", + "Table form: contributes the package's own macros when activated": "表形式:激活时贡献包自有宏", + "Table form: feature-gated source globs": "表形式:feature 门控的源 glob", + "Table form: requires a capability": "表形式:需要 capability", + "Takes effect on the next build": "下次构建时生效", + "Takes effect on the next clean": "下次清理时生效", + "Target": "目标", + "Targets": "目标", + "Task arguments: {0}": "任务参数:{0}", + "Test": "测试", + "The C++ Modules extension ({0}) is not installed or is disabled.": "C++ Modules 扩展({0})未安装或已禁用。", + "The build was cancelled; the C++ Modules language service was not refreshed.": "构建已取消;C++ 模块语言服务未刷新。", + "The compatible spec {0} carrying target semantics is handed to mcpp to install.": "将把携带 target 语义的兼容 spec {0} 交给 mcpp 安装。", + "The compatible spec {0}, which may carry target semantics, is handed to mcpp to install, and mcpp validates it in the end.": "将把可能携带 target 语义的兼容 spec {0} 交给 mcpp 安装,最终由 mcpp 校验。", + "The current mcpp toolchain list output is not recognised; the raw output is kept in the mcpp output channel, so check the mcpp version.": "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", + "The destination already exists: {0}. Choose another project name or location.": "目标路径已存在:{0}。请更换项目名或位置。", + "The global default pair is set to {0} + host target. The mcpp.toml/target configuration of the current project can still override it.": "将把全局默认对设为 {0} + host target。当前项目的 mcpp.toml/target 配置仍可能覆盖它。", + "The index folders contain no descriptors.": "索引目录里没有描述符。", + "The installation may download a large toolchain package and modifies the mcpp global cache.": "安装可能下载较大的工具链包,并修改 mcpp 全局缓存。", + "The mcpp build failed (exit code {0}); the C++ Modules language service re-read the existing build description.": "mcpp 构建失败(退出码 {0});C++ 模块语言服务已重新读取现有构建描述。", + "The mcpp build finished; the C++ Modules language service has been refreshed.": "mcpp 构建完成,C++ 模块语言服务已刷新。", + "The mcpp global default is now {0} + host target. Cleaning the old toolchain artifacts and rebuilding is recommended.": "mcpp 全局默认已更新为 {0} + host target。建议清理旧工具链产物后重新构建。", + "The program did not build; see the task terminal for the compiler output.": "程序未构建成功;编译器输出见任务终端。", + "The project currently overrides the global default; this only changes the current mcpp global configuration": "项目当前覆盖全局默认;这里只修改 mcpp 当前全局配置", + "The project name must not be . or ..": "项目名不能是 . 或 ..", + "The project name must not be a Windows reserved device name.": "项目名不能是 Windows 保留设备名", + "The project name must not be empty.": "项目名不能为空", + "The project name must not contain PROJECT: it triggers a defect in the current mcpp template substitution.": "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷", + "The project name must not contain a path separator.": "项目名不能包含路径分隔符", + "The project name must not contain control characters.": "项目名不能包含控制字符", + "The project name must not end with . (unsupported on Windows).": "项目名不能以 . 结尾(Windows 不支持)", + "The project name must not start with -, or mcpp would read it as a command-line option.": "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项", + "The project root sits below the VS Code folder; the real build resolution is whatever mcpp reports": "当前工程根位于 VS Code 文件夹子目录;实际构建解析以 mcpp 为准", + "The same figures are listed as text next to each bar.": "每个条形图旁边以文字列出了同样的数字。", + "The setting could not be written; the toggle was not saved.": "设置写入失败;开关没有保存。", + "The shared build cache could not be read.": "无法读取共享构建缓存。", + "The shared build cache is not available.": "共享构建缓存不可用。", + "The shared build cache is {0}, at or above the {1} warning threshold.": "共享构建缓存为 {0},已达到或超过 {1} 的告警阈值。", + "The shared build cache is {0}. Raise or clear mcpp.cache.warnAboveGiB to change when this appears.": "共享构建缓存为 {0}。调高或清空 mcpp.cache.warnAboveGiB 可改变告警时机。", + "The target axis is shown read-only; to select a target use the mcpp CLI": "target 轴只读展示;选择 target 请使用 mcpp CLI", + "The version is required: mcpp accepts an exact version only.": "必须指定版本:mcpp 只接受精确版本。", + "The {0} largest cache labels, by bytes.": "按字节数排名的前 {0} 个缓存标签。", + "There is no pre-v1 cache to remove.": "没有可清理的 pre-v1 缓存。", + "This descriptor declares no dependencies.": "此描述符没有声明依赖。", + "This does not clean the global BMI cache.": "此操作不会清理全局 BMI 缓存。", + "This index publishes no version for any platform.": "此索引没有为任何平台发布版本。", + "This index publishes no version for this platform, so there is nothing to add.": "此索引没有为当前平台发布版本,因此无法添加。", + "This is the current-directory view; overrides from the project or a parent configuration are decided by the actual build": "当前目录视图;项目或父级配置的覆盖以实际构建为准", + "This is the current-directory view; the value mcpp build resolves is authoritative": "当前目录视图;实际生效值以 mcpp build 解析为准", + "This mcpp build does not recognise that command; update mcpp or check the spelling.": "此 mcpp 版本不认识该命令;请升级 mcpp 或检查拼写。", + "This package has no test project in the index, so there is no example to show.": "此包在索引里没有示例工程,因此没有示例可显示。", + "This package is not in the local index any more.": "此包已不在本地索引中。", + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.": "本面板只修改 mcpp-vscode 的设置;C++ Modules 扩展({0})自行管理其 mcppls.* 设置,本面板不会写入它们。", + "This project may not be using this value": "当前项目可能没有使用此值", + "This runs mcpp build and has the C++ Modules extension re-read the build description; it does not change the toolchain, mcpp.toml or any language service setting.": "将执行 mcpp build,并由 C++ Modules 扩展重新读取构建描述;不会修改工具链、mcpp.toml 或任何语言服务设置。", + "This workspace has no mcpp.toml.": "当前工作区没有找到 mcpp.toml。", + "This workspace is not trusted, so mcpp will not run and the C++ Modules language service will not refresh. Trust the workspace first.": "当前工作区未受信任,不会执行 mcpp 或刷新 C++ 模块语言服务。请先信任工作区。", + "This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.": "当前工作区未受信任。mcpp 命令可能执行工作区设置指定的外部程序,请先信任工作区。", + "This workspace is not trusted. mcpp commands that write are disabled until you trust it.": "当前工作区未受信任。会写盘的 mcpp 命令在信任工作区之前不会执行。", + "This {0} GiB budget frees entries that every mcpp project on this machine shares. The cleanup can take a while, and any dropped entry is rebuilt on next use.": "{0} GiB 的预算会释放本机所有 mcpp 工程共享的条目。清理可能耗时,被丢弃的条目会在下次使用时重建。", + "Toggle this section": "折叠或展开本节", + "Toolchain": "工具链", + "Toolchain managed by mcpp": "mcpp 管理的工具链", + "Toolchain {0} installed. Choose a global default from the latest list?": "工具链 {0} 安装完成。是否从最新列表中选择全局默认?", + "Unavailable": "暂无元数据", + "Unexpected error: {0}": "发生未预期错误:{0}", + "Unknown": "未知", + "Use": "用法", + "User": "用户", + "User settings": "用户设置", + "VS Code could not open {0}. The link is on your clipboard.": "VS Code 无法打开 {0},链接已复制到剪贴板。", + "Values shown for {0}": "以下值适用于 {0}", + "Verify the cache": "校验缓存", + "Verify the shared cache": "校验共享缓存", + "Versions": "版本", + "Versions ({0})": "版本({0})", + "Versions mcpp aggregates by family; this operation installs for the host target": "mcpp 按 family 聚合的可用版本;本操作按 host target 安装", + "What the descriptor declares. The resolved version lives in a project's mcpp.lock, not here.": "这里显示描述符声明的依赖。解析后的版本在工程的 mcpp.lock 里,不在这里。", + "Working directory: {0}": "工作目录:{0}", + "Workspace": "工作区", + "Workspace folder": "工作区文件夹", + "Workspace member declarations": "工作空间成员声明", + "Workspace settings": "工作区设置", + "added": "已添加", + "convention {0}": "约定 {0}", + "dev dependency": "dev 依赖", + "git · {0}": "git · {0}", + "host default": "host 默认", + "latest {0}": "最新 {0}", + "mcpp CLI operation failed: {0}": "mcpp CLI 操作失败:{0}", + "mcpp Package Index refreshed: {0} package(s) from {1} index folder(s).": "mcpp 包索引已刷新:{0} 个包,来自 {1} 个索引目录。", + "mcpp add failed with exit code {0}.": "mcpp add 失败,退出码 {0}。", + "mcpp add failed with exit code {0}: {1}": "mcpp add 失败,退出码 {0}:{1}", + "mcpp add needs an exact version: mcpp add {0}@.": "mcpp add 需要精确版本:mcpp add {0}@。", + "mcpp add {0}@{1}": "mcpp add {0}@{1}", + "mcpp add {0}@{1} --dev": "mcpp add {0}@{1} --dev", + "mcpp also clears the global default_target; the configuration file location is decided by the current mcpp installation and MCPP_HOME.": "mcpp 会同时清空全局 default_target;配置文件位置由当前 mcpp 安装及 MCPP_HOME 决定。", + "mcpp build was not started.": "未启动 mcpp 构建。", + "mcpp cache list did not return the documented document": "mcpp cache list 未返回约定的文档", + "mcpp cache list failed (exit {0})": "mcpp cache list 失败(退出码 {0})", + "mcpp cache verify failed with exit code {0}": "mcpp cache verify 失败,退出码 {0}", + "mcpp clean --dry-run": "mcpp clean --dry-run", + "mcpp decides whether the compiler prefix is a valid triple; when it is, it may download a larger toolchain package for that target.": "mcpp 会判断编译器前缀是否为有效 triple;有效时可能下载对应 target 的较大工具链包。", + "mcpp does not download or install MSVC; it detects Visual Studio and points to the official installation guide when it is missing.": "mcpp 不会下载或安装 MSVC;它会检测 Visual Studio,并在缺失时给出官方安装指引。", + "mcpp finished {0}. This first release does not change the target default; to set one, use the mcpp CLI with --target.": "mcpp 已完成 {0}。首版插件不修改 target 默认;如需设为默认,请使用带 --target 的 mcpp CLI。", + "mcpp global configuration": "mcpp 全局配置", + "mcpp has no global default toolchain yet": "mcpp 尚未设置全局默认工具链", + "mcpp index update failed with exit code {0}": "mcpp index update 失败,退出码 {0}", + "mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing.": "mcpp 已安装但环境尚未就绪;运行 `mcpp self doctor` 查看缺少什么。", + "mcpp library ecosystem": "mcpp 库生态", + "mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace": "mcpp list 当前目录有效项;实际构建解析可能受父级 mcpp 工作区影响", + "mcpp list toolchain for this directory": "mcpp list 当前目录工具链", + "mcpp listed no usable toolchain; see the raw result in the mcpp output channel.": "mcpp 没有列出可用工具链;请查看 mcpp 输出频道中的原始结果。", + "mcpp new {0} failed (exit code {1}). See the mcpp output channel.": "mcpp new {0} 失败(退出码 {1})。请查看 mcpp 输出频道。", + "mcpp normalises the compatible spelling and may download a larger toolchain package for that target.": "mcpp 会规范化兼容写法,并可能下载对应 target 的较大工具链包。", + "mcpp pack packaging configuration": "mcpp pack 打包配置", + "mcpp rejected the command line; check the arguments and run it from the project root.": "mcpp 拒绝了命令行;请检查参数,并在工程根目录运行。", + "mcpp reported an internal error; re-run with the mcpp output channel open and report it.": "mcpp 报告了内部错误;请打开 mcpp 输出频道重跑并反馈。", + "mcpp settings": "mcpp 设置", + "mcpp toolchain list failed (exit code {0}). See the mcpp output channel.": "mcpp toolchain list 失败(退出码 {0})。请查看 mcpp 输出频道。", + "mcpp toolchain management": "mcpp 工具链管理", + "mcpp toolchain {0} is installed (no target specified, the host target is used).": "将安装 mcpp 工具链 {0}(不指定 target,使用 host target)。", + "mcpp toolchains and targets": "mcpp 工具链与 target", + "mcpp will detect the system MSVC ({0}).": "将调用 mcpp 检测系统 MSVC({0})。", + "mcpp xpkg parse could not read this descriptor; the versions below come from its text, which is less authoritative.": "mcpp xpkg parse 无法读取此描述符;下面的版本来自文本解析,权威性较低。", + "mcpp {0} failed with exit code {1}": "mcpp {0} 失败,退出码 {1}", + "mcpp.toml could not be read": "无法读取 mcpp.toml", + "mcpp: Build": "mcpp: 构建", + "mcpp: Clean": "mcpp: 清理", + "mcpp: Run": "mcpp: 运行", + "mcpp: Test": "mcpp: 测试", + "mcpp: detect system MSVC ({0})": "mcpp: 检测系统 MSVC({0})", + "mcpp: install toolchain {0}": "mcpp: 安装工具链 {0}", + "mcpp: moved {0} setting(s).": "mcpp:已移动 {0} 个设置。", + "mcpp: quick menu": "mcpp: 快捷菜单", + "mcpp: {0}": "mcpp:{0}", + "mcppls {0}": "mcppls {0}", + "more than {0} day(s)": "超过 {0} 天", + "more than {0} day(s) ago": "超过 {0} 天前", + "not read yet": "尚未读取", + "openkal-compat": "openkal-compat", + "openkal-ecosystem": "openkal-ecosystem", + "other registry": "其它 registry", + "path · {0}": "路径 · {0}", + "resolved {0}": "已解析 {0}", + "the workspace is not trusted": "工作区未受信任", + "this platform": "当前平台", + "under {0} day(s)": "少于 {0} 天", + "unknown": "未知", + "unknown error": "未知错误", + "upstream mcpp.toml": "上游 mcpp.toml", + "uses platform interfaces": "使用平台接口", + "{0}": "{0}", + "{0} cache entries are incomplete; run mcpp cache verify to see which ones.": "有 {0} 个缓存条目不完整;运行 mcpp cache verify 可查看具体是哪些。", + "{0} descriptor(s) from {1} local index folder(s): {2}. Read offline.": "从 {1} 个本地索引目录读到 {0} 个描述符:{2}。离线读取。", + "{0} dev": "{0} 个 dev", + "{0} entries": "{0} 个条目", + "{0} entries have no recorded last use and are not counted in this bar.": "{0} 个条目没有记录最近使用时间,未计入此条形图。", + "{0} failed (exit code {1}). See the task terminal.": "{0}失败(退出码 {1})。请查看任务终端。", + "{0} failed: {1}": "{0} 失败:{1}", + "{0} file(s) · {1} group(s)": "{0} 个文件 · {1} 个分组", + "{0} finished.": "{0} 已完成。", + "{0} further package(s) came from the other registries.": "另有 {0} 个包来自其它 registry。", + "{0} in the shared cache": "共享缓存中 {0}", + "{0} of {1} packages": "{0} / {1} 个包", + "{0} problem(s)": "{0} 个问题", + "{0} source file(s)": "{0} 个源文件", + "{0} was cancelled.": "{0}已取消。", + "{0} {1}": "{0} {1}", + "{0} {1} is older than the verified range ({2}); the capability probe will hide what it cannot do.": "{0} {1} 低于已验证范围({2});能力探测会隐藏它做不到的部分。", + "{0} · at or above {1}": "{0} · 已达到或超过 {1}", + "{0} · line {1}": "{0} · 第 {1} 行", + "{0} · version {1}": "{0} · 版本 {1}", + "{0} · {1}": "{0} · {1}", + "{0} · {1} file(s)": "{0} · {1} 个文件", + "{0} · {1} · {2}": "{0} · {1} · {2}", + "{0}: done.": "{0}:完成。", + "{0}: the installed C++ Modules does not offer this action.": "{0}:已安装的 C++ Modules 不提供该操作。", + "{0}{1}": "{0}{1}", + "{0}–{1} day(s)": "{0}–{1} 天", + "{0}–{1} day(s) ago": "{0}–{1} 天前", + "✓ Has examples": "✓ 有示例", + "Bring it into your code": "在代码里引入", + "Copy": "复制", + "Copied to the clipboard.": "已复制到剪贴板。", + "Copying…": "复制中…", + "Could not write the clipboard.": "无法写入剪贴板。", + "The workspace is not trusted; mcpp itself was not probed.": "工作区未受信任;未探测 mcpp 本身。", + "This workspace is not trusted, so mcpp is not run; the versions below come from the descriptor's text.": "工作区未受信任,因此不运行 mcpp;下面的版本来自描述符文本。", + "The workspace is not trusted; the other registries were not searched.": "工作区未受信任;未搜索其他 registry。", + "Only C++ Modules is serving C++": "仅 C++ Modules 在提供 C++ 语言服务", + "No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice.": "没有其他 C++ 语言服务(C/C++、clangd、ccls)在工作,结果不会重复出现。", + "Other C++ language services": "其他 C++ 语言服务", + "They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere.": "它们也在处理同一批 C++ 文件,结果会出现两次。点击即可关闭它们的 C++ 语言功能;C++ Modules 会询问只对本工作区还是对所有地方生效。", + "Libraries": "库", + "Source details": "来源详情", + "Other registries": "其它 registry", + "Set index path": "设置索引路径", + "Update mcpp index": "更新 mcpp 索引", + "Show output": "查看输出", + "Clear search": "清空搜索", + "Search other registries": "搜索其它 registry", + "Declared": "已声明", + "Reread locally": "重新读取本地索引", + "Could not read the index: {0}": "无法读取索引:{0}", + "Searching other registries…": "正在搜索其它 registry…", + "Other registry": "其它 registry", + "Official index": "官方索引", + "Custom index": "自定义索引", + "Browse libraries; select an mcpp project in the detail page to add dependencies.": "可以浏览库;请在详情页选择 mcpp 项目后添加依赖。", + "No local mcpp-index found": "未找到本地 mcpp-index", + "{0} · {1} packages · local": "{0} · {1} 个包 · 本地", + "Target project": "目标项目", + "Choose project": "选择项目", + "Environment platform": "当前环境平台", + "Version": "版本", + "Other platforms (read only)": "其它平台(只读)", + "Resolved in lockfile": "锁定解析", + "Source dependency": "源码依赖", + "No verified import or header path is available locally. See the repository or index site for usage.": "本地没有可验证的模块名或头文件路径,请查看仓库或索引站的用法说明。", + "From the index's example source": "来自索引示例源码", + "Source and diagnostics": "来源与诊断", + "Reading package details…": "正在读取包详情…", + "Adding dependency…": "正在添加依赖…", + "Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration.": "此处暂不支持迁移依赖分组,请编辑 mcpp.toml 移动声明。", + "Retry reading details": "重新读取详情", + "Open mcpp.toml": "打开 mcpp.toml", + "Open source file": "打开源文件", + "Search preview: only the fields returned by mcpp search are available.": "搜索预览:仅展示 mcpp search 返回的字段。", + "Copied": "已复制", + "Copy failed": "复制失败", + "Opened in your browser.": "已在浏览器中打开。", + "Could not open the browser. Link copied to clipboard.": "无法打开浏览器,链接已复制到剪贴板。", + "Could not open the browser or copy the link: {0}": "无法打开浏览器或复制链接:{0}", + "Could not add the dependency. See output for details.": "无法添加依赖,请查看输出了解详情。", + "Could not read package details. See output for details.": "无法读取包详情,请查看输出了解详情。", + "This is a search preview. Set an index path containing this package to read its complete details.": "这是搜索预览。将索引路径设置为包含此包的目录后,可读取完整详情。", + "Search previews cannot be added. Open a complete local descriptor first.": "搜索预览无法直接添加,请先打开完整的本地包描述。", + "The package descriptor is missing. Reread the local index.": "包描述不存在,请重新读取本地索引。", + "Trust this workspace to run mcpp and add dependencies.": "信任此工作区后,才能运行 mcpp 和添加依赖。", + "Select an mcpp project before adding this library.": "请先选择 mcpp 项目,再添加此库。", + "This dependency name is ambiguous. Use a qualified package name in mcpp.toml.": "此依赖名称有歧义,请在 mcpp.toml 中使用完整包名。", + "This project uses a source dependency. Edit its manifest to change the source.": "项目已声明源码依赖,请编辑清单修改来源。", + "Version availability could not be confirmed. Retry reading this descriptor.": "暂时无法确认版本可用性,请重新读取包描述。", + "No version is published for the current environment platform.": "当前环境平台没有可用的发布版本。", + "Already declared": "已声明", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.": "这里展示包描述中的依赖,所选版本的依赖可能不同;解析版本来自当前项目的锁文件。", + "mcpp add failed (exit {0}). See output for details.": "mcpp add 失败(退出码 {0}),请查看输出了解详情。", + "Choose the target mcpp project": "选择目标 mcpp 项目", + "Dependencies will be added to this project's mcpp.toml": "依赖将添加到此项目的 mcpp.toml", + "Updating the global mcpp index; a configured custom checkout is only reread.": "正在更新全局 mcpp 索引;已配置的自定义检出目录仅会被重新读取。", + "The current environment platform is not recognized by this index.": "索引无法识别当前环境平台。", + "Versions are from descriptor text; mcpp will confirm availability when adding.": "版本来自描述符文本,添加时将由 mcpp 确认可用性。", + "Platforms and versions": "平台与版本", + "Add dependency": "添加依赖", + "Usage": "用法", + "Dependency type": "依赖类型", + "Project dependency": "项目依赖", + "Development dependency": "开发依赖", + "Build dependency": "构建依赖", + "Add development dependency": "添加开发依赖", + "Build dependencies are used by the build script. Copy this entry into mcpp.toml; mcpp add does not support this group yet.": "构建依赖供构建脚本使用;当前 mcpp add 尚不支持此分组,请复制此项并编辑 mcpp.toml。", + "Changing this declaration requires a manifest edit. Copy the entry and update its dependency group in mcpp.toml.": "请复制声明并在 mcpp.toml 中调整依赖分组;已有声明不会自动迁移。", + "The dependency declaration changed. Review mcpp.toml before adding it.": "依赖声明已变化,请先检查 mcpp.toml 再添加。", + "Updating mcpp index…": "正在更新 mcpp 索引…", + "Index update failed; showing the last available local snapshot.": "索引更新失败,继续显示上次可用的本地快照。", + "Index updated; {0} local packages.": "索引已更新,本地共 {0} 个包。", + "Bundled official index tests · commit {0}": "插件内置官方索引测试 · 提交 {0}", + "Project dependency graph": "项目依赖图", + "Copy Mermaid": "复制 Mermaid", + "Up to 3 dependency levels · {0} packages · {1} edges": "最多 3 层依赖 · {0} 个包 · {1} 条关系", + "Edges come from manifests and local index descriptors; lockfile versions are shown when available. This is a declaration graph, not a resolved build graph. Dashed nodes have no local dependency metadata; conditional edges may not apply to this environment.": "关系来自项目清单和本地索引描述符;有锁定版本时显示锁定版本。这是声明关系图,不代表实际构建解析结果。虚线节点缺少本地依赖元数据,条件关系可能不适用于当前环境。", + "Large graph truncated at 120 nodes or 240 edges.": "大型依赖图已截断(最多 120 个节点或 240 条关系)。", + "Fit": "适应窗口", + "Mermaid source": "Mermaid 源码", + "Could not render the graph. Copy Mermaid source or refresh to retry.": "依赖图渲染失败,可复制 Mermaid 源码或刷新重试。", + "Could not read project dependencies: {0}": "无法读取项目依赖:{0}", + "Open an mcpp project first.": "请先打开一个 mcpp 项目。", + "The project manifest is missing or unreadable.": "项目清单不存在或无法读取。", + "Reading project dependencies…": "正在读取项目依赖…", + "Source": "源码", + "Open read-only source": "打开只读源码", + "Dependency graph": "依赖图", + "Maximum dependency depth: 3": "最多展示 3 层依赖", + "Zoom out": "缩小", + "Zoom in": "放大", + "Legend and source": "图例与源码", + "Project": "项目", + "Declaration graph · dashed edges are conditional · lockfile supplies versions": "声明关系图 · 虚线为条件依赖 · 锁文件提供版本", + "index": "索引", + "build": "构建", + "conditional": "条件", + "Scroll to zoom · drag to pan": "滚轮缩放 · 按住拖动画布", + "C++ Modules Deps": "C++ Modules 依赖图", + "No module relationships were found for this project.": "此项目尚无模块关系。", + "View limit reached. Focus a node to explore its dependencies.": "已达到当前视图上限,可聚焦节点继续查看。", + "The source graph is incomplete. Refresh after indexing finishes.": "数据尚不完整,可在索引完成后刷新。", + "Expand next level": "展开下一层", + "Selected node": "已选节点", + "Graph view": "图视图", + "Packages": "包依赖", + "Depth": "深度", + "Find a node…": "查找节点…", + "Back to project": "返回项目", + "Select a node to explore its relationships.": "选择节点查看关联。", + "Collapse": "收起", + "Focus": "聚焦", + "Packages show declarations; modules show mcppls imports. External modules stop after one hop.": "包图显示声明关系;模块图来自 mcppls,外部模块只显示一层。", + "Source is unavailable for this module.": "此模块的源码不可用。", + "Choose a module source file": "选择模块源码文件", + "The source graph is incomplete. Some modules may be omitted.": "源图数据不完整,部分模块可能尚未包含。", + "Could not export this graph. Try SVG or a smaller view.": "无法导出此图,请尝试 SVG 或更小的视图。", + "Entry group": "入口分组", + "Selected": "选中", + "Export SVG": "导出 SVG", + "Export PNG": "导出 PNG", + "Dependencies depth": "依赖深度", + "Importers depth": "父节点深度", + "Previous entries": "上一批入口", + "Next entries": "下一批入口", + "Back": "后退", + "Locate focus": "定位焦点", + "Locate selected": "定位选中", + "Open source": "打开源码", + "Expand importers": "展开父节点", + "Collapse importers": "折叠父节点", + "Focus this result": "以此结果为焦点", + "Dependencies unavailable — click to retry": "暂时无法读取依赖,点击重试", + "Reading dependencies…": "正在读取依赖…", + "This project declares no dependencies": "此项目未声明依赖", + "Refresh pending": "等待刷新", + "Main": "主程序", + "Tests": "测试", + "Other": "其他", + "Export": "导出", + "View options": "视图", + "Node actions": "节点操作", + "build, run, test and debug use it": "构建、运行、测试与调试都使用它", + "every architecture of the platform": "平台的全部架构", + "Install CodeLLDB": "安装 CodeLLDB", + "mcpp: building for the debugger…": "mcpp:正在为调试器构建…", + "mcpp: Pack": "mcpp:打包", + "mcpp: Pack (1/3): the package format": "mcpp:打包(1/3):包格式", + "mcpp: Pack (2/3): what the machine that runs it provides": "mcpp:打包(2/3):运行它的机器提供什么", + "mcpp: Pack (3/3): the libraries inside the program or beside it": "mcpp:打包(3/3):库在程序里还是在程序旁边", + "mcpp: reading the package formats…": "mcpp:正在读取包格式…", + "mcpp: reading the targets…": "mcpp:正在读取目标…", + "No debugger extension is installed: install CodeLLDB, the C/C++ extension or lldb-dap.": "没有安装调试器扩展:请安装 CodeLLDB、C/C++ 扩展或 lldb-dap。", + "No toolchain is installed for {0}.": "{0} 尚未安装工具链。", + "Open Settings": "打开设置", + "Restart": "重新启动", + "Select the build target": "选择构建目标", + "The mcpp-ide backend ended.": "mcpp-ide 后端已结束。", + "the mcpp-ide backend is not running": "mcpp-ide 后端未运行", + "The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath.": "未找到 mcpp-ide 后端。请用 `xlings install mcpp-ide` 安装,或设置 mcpp.ide.backendPath。", + "the project's default": "工程默认值", + "The targets mcpp builds for": "mcpp 能构建的目标", + "this machine": "本机" +} diff --git a/mide-plugins/vscode/media/cache.css b/mide-plugins/vscode/media/cache.css new file mode 100644 index 0000000..f62e1f5 --- /dev/null +++ b/mide-plugins/vscode/media/cache.css @@ -0,0 +1,570 @@ +/* + * The cache view's stylesheet (§8.1). + * + * Four rules hold this together, and all four come from the plan (§8.1, §3.5): + * + * 1. **Theme tokens only.** Every colour is a `var(--vscode-*)` reference, so the + * view follows light, dark and high-contrast themes without a second + * stylesheet. `currentColor` and `transparent` are the only other values, and + * `transparent` is used as the *fallback* of a theme token that a theme may + * not define. There is not a single literal colour value in this file. + * 2. **Never colour alone.** Each bar segment is also a labelled item in the + * legend beneath it — caption and share, as text — so a theme that collapses + * every hue into one still reads. Colour is decoration on top. + * 3. **No inline styles.** The document sets `data-*` attributes and this file + * turns them into fill and border, so the strict CSP holds. + * 4. **One primary bar, and whitespace instead of borders.** §8.1 asks for a + * 6 px bar in the project block, a 14 px composition bar and a 6 px age bar, + * the legend compressed into a single wrapped line separated by `·`, and + * sections separated by space rather than by nested boxes. The only + * hairline left is the one between table rows. + * + * `--vscode-mcpp-cacheOkForeground` and `--vscode-mcpp-cacheStaleForeground` + * are the two colour ids this extension contributes; the plan's chart tokens + * are the fallbacks, so an older rendering host still gets a sensible hue. + * + * Sizes are the plan's own scale (26 / 12 / 11 px) rather than + * `var(--vscode-font-size)`: the sidebar has to stay compact at 170–520 px, and + * the review is by eye against §8.1. Nothing here depends on a colour, so a + * theme change cannot make a control unusable. + */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + color: var(--vscode-foreground); + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); + font-family: var(--vscode-font-family); + font-size: 12px; + line-height: 1.45; + /* Tabular figures: the summary, the metric and the table line up by digit. */ + font-variant-numeric: tabular-nums; + /* A long cache path or package label wraps instead of widening the sidebar. */ + overflow-wrap: anywhere; +} + +[hidden] { + display: none !important; +} + +/* The view title is the real heading; this is its accessible twin. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +main { + padding: 8px 10px 14px; +} + +/* ----------------------------------------------------------------- blocks */ + +.block { + margin: 0 0 14px; +} + +.block-heading { + margin: 0; + font-size: 12px; + font-weight: 600; +} + +.block-summary, +.viz-summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; + cursor: pointer; + list-style: none; +} + +.block-summary::-webkit-details-marker, +.viz-summary::-webkit-details-marker { + display: none; +} + +.block-summary::before, +.viz-summary::before { + content: "▸"; + color: var(--vscode-icon-foreground, currentColor); +} + +details[open] > .block-summary::before, +details[open] > .viz-summary::before { + content: "▾"; +} + +.block-summary:focus-visible, +.viz-summary:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.summary-title { + font-weight: 600; + overflow-wrap: anywhere; +} + +.summary-value { + color: var(--vscode-descriptionForeground); + font-size: 11px; + overflow-wrap: anywhere; +} + +.block-body { + padding-top: 8px; +} + +/* ----------------------------------------------------------------- figures */ + +.metric { + display: flex; + align-items: baseline; + gap: 4px; + margin: 4px 0 0; +} + +.metric-value { + font-size: 26px; + line-height: 1.1; + font-weight: 600; + /* The figure is bounded to four characters by `formatBytes`, so keeping it on + one line cannot overflow a 170 px sidebar. */ + white-space: nowrap; +} + +.metric-unit { + font-size: 12px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.metric-sub, +.metric-note, +.viz-note { + margin: 4px 0 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; +} + +/* --------------------------------------------------------------------- viz */ + +.viz { + margin: 12px 0 0; +} + +.viz-heading { + margin: 0 0 4px; + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); +} + +.viz-details > .viz-summary { + font-size: 11px; + font-weight: 600; + color: var(--vscode-descriptionForeground); +} + +/* -------------------------------------------------------------------- bars */ + +.viz-bar { + display: block; + width: 100%; + height: 14px; + background-color: var(--vscode-input-background); +} + +/* §8.1: exactly one 6 px bar in the project block and one for the age spread. */ +.viz-bar-thin { + height: 6px; +} + +.project-bar { + margin-top: 8px; +} + +/* The outline is what keeps a segment visible in a high-contrast theme, where + `--vscode-contrastBorder` exists; everywhere else it stays transparent, so the + bars are separated by the track rather than by a heavy border. */ +.viz-bar rect { + stroke: var(--vscode-contrastBorder, transparent); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +/* + * `fill` is set on the **group**, never on the ``. + * + * The renderer stamps `data-kind` / `data-segment` / `data-bucket` on the `` + * that wraps each segment, and SVG inherits `fill` from the group down into the + * rectangle. A rule written as `rect[data-kind]` therefore matches nothing — + * the attribute lives one level up — and every bar falls back to the SVG + * default fill, which is *black in every theme*. That is what shipped: the + * legend swatches were coloured, the bars were solid black, and the data was + * plainly there. `test/views/cachePanelHtml.test.ts` now checks the document and + * this stylesheet against each other so it cannot happen again. + */ +[data-viz="composition"] g[data-kind] { + fill: var(--vscode-charts-blue, currentColor); +} + +[data-viz="composition"] g[data-kind="pkg"] { + fill: var(--vscode-mcpp-cacheOkForeground, var(--vscode-charts-green, currentColor)); +} + +[data-viz="composition"] g[data-kind="std"] { + fill: var(--vscode-charts-purple, currentColor); +} + +[data-viz="composition"] g[data-kind="unknown"] { + fill: var(--vscode-descriptionForeground, currentColor); +} + +[data-viz="composition"] g[data-segment="legacy"], +.project-bar g[data-segment="stale"] { + fill: var(--vscode-mcpp-cacheStaleForeground, var(--vscode-charts-yellow, currentColor)); +} + +.project-bar g[data-segment="current"] { + fill: var(--vscode-mcpp-cacheOkForeground, var(--vscode-charts-green, currentColor)); +} + +/* + * The age bar is a **ramp**, not one colour per group. + * + * Every bucket used to be painted `mcpp.cacheOkForeground` except the overflow + * one, so four segments of 8% / 85% / 6% / 1% read as a single green block and + * the chart added nothing to the legend beneath it. The renderer now stamps + * `data-age-step` (0 = newest … 3 = oldest, `ageRampStep()` in + * `src/views/cachePanelHtml.ts`) and the four stops are cold → hot. The four + * `charts.*` tokens below are solid in every theme, unlike `charts.orange`, + * which resolves to a find-match highlight at 33% alpha. + */ +[data-viz="age"] g[data-age-step="0"], +.swatch[data-age-step="0"] { + fill: var(--vscode-charts-blue, currentColor); + background-color: var(--vscode-charts-blue, currentColor); +} + +[data-viz="age"] g[data-age-step="1"], +.swatch[data-age-step="1"] { + fill: var(--vscode-charts-green, currentColor); + background-color: var(--vscode-charts-green, currentColor); +} + +[data-viz="age"] g[data-age-step="2"], +.swatch[data-age-step="2"] { + fill: var(--vscode-charts-yellow, currentColor); + background-color: var(--vscode-charts-yellow, currentColor); +} + +[data-viz="age"] g[data-age-step="3"], +.swatch[data-age-step="3"] { + fill: var(--vscode-charts-red, currentColor); + background-color: var(--vscode-charts-red, currentColor); +} + +/* ------------------------------------------------------------------ legend */ + +/* §8.1: the legend is one wrapped line separated by `·`, not one row per item. */ +.legend { + margin: 4px 0 0; + padding: 0; + list-style: none; +} + +.legend-inline { + display: block; + font-size: 11px; +} + +.legend-inline .legend-item { + display: inline; + /* No `nowrap`: a long translated caption has to wrap rather than widen the + sidebar. Items are short enough that they only break at 200 px. */ +} + +.legend-inline .legend-item + .legend-item::before { + content: " · "; + color: var(--vscode-descriptionForeground); +} + +.legend-label { + margin-right: 4px; +} + +.legend-value { + color: var(--vscode-descriptionForeground); +} + +.swatch { + display: inline-block; + width: 8px; + height: 8px; + margin-right: 4px; + vertical-align: middle; + border: 1px solid var(--vscode-contrastBorder, transparent); + background-color: var(--vscode-charts-blue, currentColor); +} + +/* The age swatches are painted by `data-age-step` above; `data-bucket` only + survives as the marker for the overflow bucket, so it must not colour here. */ +.swatch[data-kind="pkg"], +.swatch[data-segment="current"] { + background-color: var(--vscode-mcpp-cacheOkForeground, var(--vscode-charts-green, currentColor)); +} + +.swatch[data-kind="std"] { + background-color: var(--vscode-charts-purple, currentColor); +} + +.swatch[data-kind="unknown"] { + background-color: var(--vscode-descriptionForeground, currentColor); +} + +.swatch[data-kind="legacy"], +.swatch[data-segment="stale"] { + background-color: var(--vscode-mcpp-cacheStaleForeground, var(--vscode-charts-yellow, currentColor)); +} + +/* ------------------------------------------------------------- top packages */ + +.top-table { + width: 100%; + margin-top: 4px; + border-collapse: collapse; + table-layout: fixed; +} + +.top-table th, +.top-table td { + padding: 2px 4px; + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; + font-size: 11px; +} + +.top-table th { + color: var(--vscode-descriptionForeground); + font-weight: 600; +} + +.top-table tbody tr + tr td { + border-top: 1px solid var(--vscode-panel-border, transparent); +} + +.top-table th:nth-child(1) { + width: 40%; +} + +.top-table th:nth-child(2) { + width: 14%; +} + +.top-table th:nth-child(3) { + width: 18%; +} + +.top-table th:nth-child(4) { + width: 28%; +} + +.top-table time, +.top-table .unknown { + color: var(--vscode-descriptionForeground); +} + +/* A package label is itself the drill-down button. */ +.link { + padding: 0; + border: 0; + border-radius: 0; + color: var(--vscode-textLink-foreground); + background-color: transparent; + font: inherit; + text-align: left; + overflow-wrap: anywhere; +} + +/* `:not([disabled])` so this keeps beating the generic `button:hover` rule. */ +.link:hover:not([disabled]) { + text-decoration: underline; + background-color: transparent; +} + +/* ----------------------------------------------------------------- controls */ + +button, +input { + font-family: inherit; + font-size: inherit; + border-radius: 2px; + padding: 3px 8px; +} + +button { + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background-color: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + border: 1px solid var(--vscode-button-border, transparent); + cursor: pointer; +} + +button:hover:not([disabled]) { + background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground)); +} + +input { + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, var(--vscode-panel-border, transparent)); +} + +button[disabled], +input[disabled] { + /* Dashed plus dimmed: "closed" is visible without relying on colour. */ + border-style: dashed; + opacity: 0.7; + cursor: default; +} + +button:focus-visible, +input:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 10px 0 0; +} + +.budget { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + min-width: 0; +} + +.budget input { + width: 5em; + min-width: 3em; + max-width: 100%; +} + +.budget-unit, +.budget-estimate { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.budget-estimate { + flex-basis: 100%; +} + +/* ---------------------------------------------------------------- messages */ + +.warnings { + margin: 0 0 10px; +} + +.warning { + margin: 0 0 6px; + padding: 6px 8px; + border: 1px solid var(--vscode-inputValidation-warningBorder, transparent); + background-color: var(--vscode-inputValidation-warningBackground, transparent); + font-size: 11px; + overflow-wrap: anywhere; +} + +.marker { + color: var(--vscode-editorWarning-foreground, currentColor); + font-weight: 600; +} + +.empty { + margin: 6px 0 0; + padding: 6px 8px; + border: 1px dashed var(--vscode-panel-border, transparent); + color: var(--vscode-descriptionForeground); + font-size: 11px; + overflow-wrap: anywhere; +} + +.boundary { + margin: 14px 0 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; +} + +/* -------------------------------------------------------------- narrow view */ + +/* + * The sidebar is 170–520 px. At 200 px nothing may overflow sideways: the + * buttons and the budget group already wrap, the legend already wraps between + * items, the metric keeps its 26 px figure on one line, and the table stops + * being a table — each cell is prefixed with the column name it carries in + * `data-head`, so no figure is lost and no column is clipped. + */ +@media (max-width: 360px) { + main { + padding: 6px 8px 12px; + } + + .actions { + gap: 5px; + } +} + +@media (max-width: 260px) { + .top-table thead { + display: none; + } + + .top-table, + .top-table tbody, + .top-table tr, + .top-table td { + display: block; + width: auto; + } + + .top-table tr + tr { + border-top: 1px solid var(--vscode-panel-border, transparent); + } + + .top-table td { + padding: 0; + border: 0; + } + + .top-table td[data-head]::before { + content: attr(data-head) " "; + color: var(--vscode-descriptionForeground); + } + + .top-table .cell-label { + font-weight: 600; + } +} diff --git a/mide-plugins/vscode/media/dependencyGraph.css b/mide-plugins/vscode/media/dependencyGraph.css new file mode 100644 index 0000000..70f5b65 --- /dev/null +++ b/mide-plugins/vscode/media/dependencyGraph.css @@ -0,0 +1,72 @@ +body { font-family: var(--vscode-font-family); color: var(--vscode-foreground); padding: 20px 24px; margin: 0; } +.graph-header,.graph-heading,nav,.zoom,.graph-legend { display:flex;align-items:center;gap:10px; } +.graph-header { justify-content:space-between; gap:16px; padding-bottom:16px; } +.graph-heading { min-width:0; flex-wrap:wrap; } h1 { font-size:17px;font-weight:600;margin:0; } +.project-name { color:var(--vscode-descriptionForeground); font-size:12px; } .depth-pill { font:11px var(--vscode-editor-font-family,monospace); padding:2px 6px;border-radius:4px;background:var(--vscode-badge-background);color:var(--vscode-badge-foreground); } +button { display:inline-flex;align-items:center;justify-content:center;color:var(--vscode-descriptionForeground);background:transparent;border:1px solid transparent;border-radius:5px;padding:5px 8px;cursor:pointer;font:inherit;font-size:12px;white-space:nowrap; } +button:hover { background:var(--vscode-toolbar-hoverBackground);color:var(--vscode-foreground); } button:focus-visible { outline:1px solid var(--vscode-focusBorder);outline-offset:2px; } +.graph-stage { position:relative; border:1px solid var(--vscode-panel-border);border-radius:8px;background:var(--vscode-editor-background); } +#graph { overflow:hidden; position:relative; cursor:grab; user-select:none; touch-action:none; min-height:260px; height:calc(100vh - 185px);box-sizing:border-box; } +#graph svg { display:block;position:absolute;left:0;top:0;transform-origin:0 0;height:auto; } +#graph.is-grabbing,#graph.is-grabbing .node { cursor:grabbing; } .zoom { position:absolute;right:12px;bottom:12px;gap:2px;background:var(--vscode-editorWidget-background);border:1px solid var(--vscode-panel-border);border-radius:6px;padding:2px; } +.zoom button { min-width:28px; } #feedback { color:var(--vscode-descriptionForeground);font-size:11px;white-space:nowrap; } +.graph-help { margin-top:12px;color:var(--vscode-descriptionForeground);font-size:11px; } summary { cursor:pointer; } pre { overflow:auto;max-height:240px;line-height:1.6; } .graph-legend { margin:12px 0; } .graph-legend span { border-radius:4px;padding:3px 7px; } +#graph svg .node rect { filter:none !important;stroke-width:1.25px; } +#graph .node.project rect { fill:color-mix(in srgb,var(--vscode-charts-blue) 13%,var(--vscode-editor-background));stroke:var(--vscode-charts-blue) !important; } +#graph .node.manifest rect { fill:color-mix(in srgb,var(--vscode-charts-purple) 10%,var(--vscode-editor-background));stroke:var(--vscode-charts-purple) !important; } +#graph .node.index rect { fill:color-mix(in srgb,var(--vscode-charts-green) 10%,var(--vscode-editor-background));stroke:var(--vscode-charts-green) !important; } +#graph .node.unknown rect { fill:color-mix(in srgb,var(--vscode-charts-orange) 8%,var(--vscode-editor-background));stroke:var(--vscode-charts-orange) !important;stroke-dasharray:4 3; } +#graph .node text { fill:var(--vscode-foreground);font-size:13px; } #graph .node text>tspan:first-child { font-weight:600; } +#graph .node text>tspan:nth-child(n+2) { fill:var(--vscode-descriptionForeground);font-size:11px; } +#graph .node { cursor:pointer; } #graph .node.is-focused rect,#graph .node:focus-visible rect { stroke:var(--vscode-focusBorder) !important;stroke-width:3px !important;filter:drop-shadow(0 0 5px var(--vscode-focusBorder)) !important; } +#graph .edgeLabel { color:var(--vscode-descriptionForeground);font-size:11px; } #graph .edgeLabel text { fill:var(--vscode-descriptionForeground); } #graph .edgeLabel .labelBkg { background:var(--vscode-editor-background);fill:var(--vscode-editor-background); } +#graph .edgePath path { stroke:var(--vscode-descriptionForeground); } .legend-project { color:var(--vscode-charts-blue); } .legend-path { color:var(--vscode-charts-purple); } .legend-index { color:var(--vscode-charts-green); } .legend-unknown { color:var(--vscode-charts-orange); } +.vscode-high-contrast button,.vscode-high-contrast-light button { border-color:var(--vscode-contrastBorder); } +@media(max-width:600px) { body { padding:16px 12px; } .graph-header { flex-wrap:wrap; } } + +#graph .edgeLabel rect.background { fill:var(--vscode-editor-background) !important;opacity:1 !important; } +#graph .node text>tspan:first-child .text-inner-tspan { font-weight:600 !important; } +#graph .node text>tspan:nth-child(n+2) { font-size:11px !important; } + +.graph-tools { display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px;font-size:12px; } +.graph-tabs { display:flex;gap:2px;padding:2px;border:1px solid var(--vscode-panel-border);border-radius:6px; } +.graph-tabs [aria-pressed=true] { color:var(--vscode-foreground);background:var(--vscode-toolbar-activeBackground); } +.graph-tools input,.graph-tools select { font:inherit;background:var(--vscode-input-background);color:var(--vscode-input-foreground);border:1px solid var(--vscode-input-border,transparent);padding:4px 7px;border-radius:4px; } +.graph-tools input { width:180px;min-width:100px; } .graph-tools input:focus,.graph-tools select:focus { outline:1px solid var(--vscode-focusBorder); } +.graph-selection { min-height:30px;display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:8px;color:var(--vscode-descriptionForeground);font-size:12px; } +#selected-node { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; } button:disabled { opacity:.4;cursor:default; } +#graph { height:calc(100vh - 245px); } +#graph.is-loading { visibility:hidden; } #graph-state { position:absolute;top:50%;left:24px;right:24px;transform:translateY(-50%);text-align:center;color:var(--vscode-descriptionForeground);font-size:13px;white-space:pre-wrap; } +#graph-state[hidden], [hidden] { display:none !important; } #graph-notice { color:var(--vscode-descriptionForeground);font-size:11px;margin:8px 0; } +#graph .fold-control,#graph .fold-control tspan { fill:var(--vscode-textLink-foreground);cursor:pointer;font-weight:600; } +#graph .fold-control:hover { text-decoration:underline; } #graph .fold-control:focus-visible { outline:1px solid var(--vscode-focusBorder); } +@media(max-width:600px) { .graph-selection { flex-wrap:wrap; } .graph-tools { gap:8px; } #graph { height:calc(100vh - 290px); } } +#focus-node { color:var(--vscode-charts-purple);white-space:nowrap; } +#graph .node.is-graph-focus rect { stroke-dasharray:5 2;stroke-width:2px; } +.graph-selection { flex-wrap:wrap; } .graph-selection>div { display:flex;flex-wrap:wrap; } +#graph-entries { max-width:170px; } #entry-page { white-space:nowrap; } +.graph-menu { position:relative;display:inline-block;flex:none;color:var(--vscode-descriptionForeground);font-size:12px; } +.graph-menu>summary { display:flex;align-items:center;gap:7px;list-style:none;border:1px solid transparent;border-radius:5px;padding:5px 8px;white-space:nowrap; } +.graph-menu>summary::-webkit-details-marker { display:none; } +.graph-menu>summary::after { content:'⌄';font-size:11px; } +.graph-menu>summary:hover,.graph-menu[open]>summary { background:var(--vscode-toolbar-hoverBackground);color:var(--vscode-foreground); } +.graph-menu>summary:focus-visible { outline:1px solid var(--vscode-focusBorder);outline-offset:2px; } +.graph-menu>summary[aria-disabled=true] { opacity:.4;cursor:default; } +.graph-menu-items { position:absolute;right:0;top:calc(100% + 4px);z-index:20;min-width:190px;padding:5px;display:flex;flex-direction:column;align-items:stretch;gap:2px;border:1px solid var(--vscode-menu-border,var(--vscode-panel-border));border-radius:6px;background:var(--vscode-menu-background,var(--vscode-editorWidget-background));box-shadow:0 4px 14px #0004; } +.graph-menu-items>button { justify-content:flex-start;padding:7px 10px;color:var(--vscode-menu-foreground,var(--vscode-foreground)); } +.graph-menu-items>label { display:flex;align-items:center;justify-content:space-between;gap:16px;padding:7px 10px;white-space:nowrap; } +.graph-tools { gap:8px; } .graph-tools input { flex:1;max-width:260px; } +.graph-context { display:flex;gap:14px;align-items:center;min-width:0;overflow:hidden; } +.graph-context>span { overflow:hidden;text-overflow:ellipsis; } +.graph-selection>.graph-node-actions { display:flex;flex-wrap:nowrap;align-items:center;gap:2px; } +.graph-header nav { flex:none; } #graph-view-menu .graph-menu-items { left:0;right:auto; } +@media(max-width:700px) { .graph-context { flex:1 1 100%; } .graph-menu-items { max-width:calc(100vw - 60px); } } +/* Shared control geometry keeps labels and chevrons on the same baseline. */ +.graph-header button,.graph-menu>summary,.graph-tools input,.graph-tools select { box-sizing:border-box;height:28px; } +.graph-menu>summary::after { content:'';width:5px;height:5px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:translateY(-2px) rotate(45deg);flex:none;margin-left:2px; } +.graph-header [data-action=refresh] { color:var(--vscode-charts-blue,var(--vscode-textLink-foreground));min-width:28px;font-size:18px;padding:0 7px; } +#graph-export>summary { color:var(--vscode-charts-green,var(--vscode-textLink-foreground)); } +#graph-view-menu label { display:grid;grid-template-columns:1fr 72px; } +#graph-view-menu label select { width:72px;min-width:72px; } +.graph-context-menu { position:fixed;right:auto;top:0;z-index:50;max-height:calc(100vh - 8px);overflow-y:auto;box-sizing:border-box; } +.graph-context-menu button:focus { outline:1px solid var(--vscode-focusBorder);background:var(--vscode-menu-selectionBackground,var(--vscode-toolbar-hoverBackground));color:var(--vscode-menu-selectionForeground,var(--vscode-foreground)); } diff --git a/mide-plugins/vscode/media/graph.js b/mide-plugins/vscode/media/graph.js new file mode 100644 index 0000000..11f6405 --- /dev/null +++ b/mide-plugins/vscode/media/graph.js @@ -0,0 +1,262 @@ +/* Bounded SVG canvas: data, selection and camera have separate update paths. */ +(() => { + const vscode = acquireVsCodeApi(), labels = JSON.parse(document.getElementById('graph-labels').textContent); + const graph = document.getElementById('graph'), status = document.getElementById('graph-state'); + const views = new Map(), snapshots = new Map(), layouts = new Map(); + let data, currentKey, selected, zoom = 1, panX = 0, panY = 0, baseWidth = 0, baseHeight = 0; + let drag, suppressClick = false, pending, drawing = false, renderId = 0, busy = true, committed, committedBinding; + const send = (action, extra = {}) => vscode.postMessage({ type: 'action', action, viewKey: currentKey, ...extra }); + const cameraKey = next => next && `${next.viewKey}:${next.focus || next.group || 'overview'}:${next.offset || 0}`; + const snapshot = () => snapshots.get(currentKey) || { search: [], groups: [] }; + function trim(map, max = 8) { while (map.size > max) map.delete(map.keys().next().value); } + function applyView() { const svg = graph.querySelector('svg'); if (svg) svg.style.transform = `translate(${panX}px,${panY}px) scale(${zoom})`; } + function saveView() { if (data && baseWidth) { views.delete(cameraKey(data)); views.set(cameraKey(data), { zoom, panX, panY }); trim(views, 40); } } + function zoomAt(next, x = graph.clientWidth / 2, y = graph.clientHeight / 2) { + next = Math.max(.02, Math.min(4, next)); const ratio = next / zoom; + panX = x - (x - panX) * ratio; panY = y - (y - panY) * ratio; zoom = next; applyView(); saveView(); + } + function fitGraph() { + if (!baseWidth || !baseHeight) return; + zoom = Math.max(.001, Math.min(1, (graph.clientWidth - 48) / baseWidth, (graph.clientHeight - 48) / baseHeight)); + panX = (graph.clientWidth - baseWidth * zoom) / 2; panY = (graph.clientHeight - baseHeight * zoom) / 2; applyView(); saveView(); + } + function locate(id) { + const node = [...graph.querySelectorAll('.node')].find(node => node.dataset.nodeKey === id); + if (!node || busy) return; + if (zoom < .85) zoomAt(.85); + const viewport = graph.getBoundingClientRect(), box = node.getBoundingClientRect(); + panX += viewport.left + viewport.width / 2 - box.left - box.width / 2; + panY += viewport.top + viewport.height / 2 - box.top - box.height / 2; applyView(); saveView(); + } + const menus = [...document.querySelectorAll('.graph-menu')]; + function closeMenus(except) { for (const menu of menus) if (menu !== except) menu.open = false; } + for (const menu of menus) menu.addEventListener('toggle', () => { if (menu.open) closeMenus(menu); }); + document.addEventListener('click', event => { if (!event.target.closest('.graph-menu')) closeMenus(); }); + document.addEventListener('keydown', event => { if (event.key === 'Escape') { const active = menus.find(menu => menu.open); closeMenus(); active?.querySelector('summary').focus(); } }); + const nodeMenu = document.getElementById('graph-node-context'); + let menuOrigin, menuTarget; + function closeNodeMenu(restore = false) { + nodeMenu.hidden = true; const origin = menuOrigin; menuOrigin = menuTarget = undefined; + if (restore && origin?.isConnected) origin.focus(); + } + function openNodeMenu(event, node, item, keyboard = false) { + if (busy) return; + event.preventDefault(); event.stopPropagation(); closeMenus(); choose(item.id); + menuOrigin = node; menuTarget = item.id; + for (const button of nodeMenu.querySelectorAll('button')) button.disabled = !!item.virtual && ['open','collapse','expandParents','collapseParents'].includes(button.dataset.contextAction); + nodeMenu.hidden = false; + const box = node.getBoundingClientRect(); + nodeMenu.style.left = Math.max(4, Math.min(keyboard ? box.left : event.clientX, innerWidth - nodeMenu.offsetWidth - 4)) + 'px'; + nodeMenu.style.top = Math.max(4, Math.min(keyboard ? box.bottom : event.clientY, innerHeight - nodeMenu.offsetHeight - 4)) + 'px'; + nodeMenu.querySelector('button:not(:disabled)').focus(); + } + document.addEventListener('pointerdown', event => { if (!nodeMenu.contains(event.target)) closeNodeMenu(); }); + nodeMenu.addEventListener('keydown', event => { + const items = [...nodeMenu.querySelectorAll('button:not(:disabled)')], index = items.indexOf(document.activeElement); + if (event.key === 'Escape' || event.key === 'Tab') { closeNodeMenu(true); if (event.key === 'Escape') event.preventDefault(); return; } + const next = event.key === 'ArrowDown' ? (index + 1) % items.length : event.key === 'ArrowUp' ? (index + items.length - 1) % items.length : event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : undefined; + if (next !== undefined) { event.preventDefault(); items[next].focus(); } + }); + nodeMenu.querySelectorAll('button').forEach(button => button.addEventListener('click', () => { + const id = menuTarget, action = button.dataset.contextAction; closeNodeMenu(true); + if (id && action === 'locate') locate(id); else if (id) send(action, { id }); + })); + function selectNode(id, center = false) { + selected = id; + graph.querySelectorAll('.node').forEach(node => { + const active = node.dataset.nodeKey === id; + node.classList.toggle('is-focused', active); node.setAttribute('aria-pressed', String(active)); + node.classList.toggle('is-graph-focus', node.dataset.nodeKey === data?.focus); + }); + const item = data?.nodes.find(node => node.id === id); + document.getElementById('selected-node').textContent = item ? `${labels.selectedPrefix}: ${item.label}` : labels.selected; + document.getElementById('selected-node').hidden = !!data?.focus && id === data.focus; + document.getElementById('focus-node').hidden = !data?.focus; + document.getElementById('focus-node').textContent = `${labels.focus}: ${snapshot().search.find(node => node.id === data?.focus)?.label || labels.group}`; + for (const button of document.querySelectorAll('.graph-selection button')) button.disabled = !id || busy || (item?.virtual && ['open','collapse','expandParents','collapseParents'].includes(button.dataset.action)); + if (center) locate(id); + } + function choose(id) { selectNode(id); send('select', { id }); } + function setStatus(text, block = false) { status.textContent = text; status.hidden = !text; graph.classList.toggle('is-loading', block); } + document.querySelectorAll('[data-action]').forEach(button => button.addEventListener('click', () => { + closeMenus(); + const action = button.dataset.action; + if (action === 'locate') return locate(selected); + if (action === 'locateFocus') return locate(data?.focus); + send(action, { id: selected }); + })); + document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => send('mode', { mode: button.dataset.mode }))); + document.getElementById('graph-depth').addEventListener('change', event => { closeMenus(); send('depth', { depth: event.target.value }); }); + document.getElementById('graph-upstream').addEventListener('change', event => { closeMenus(); send('upstreamDepth', { depth: event.target.value }); }); + document.getElementById('graph-entries').addEventListener('change', event => send('group', { group: event.target.value })); + document.getElementById('entry-previous').addEventListener('click', () => send('page', { next: false })); + document.getElementById('entry-next').addEventListener('click', () => send('page', { next: true })); + const search = document.getElementById('graph-search'); + function options() { + const list = document.getElementById('graph-options'); list.replaceChildren(); + for (const node of snapshot().search.filter(node => node.label.toLowerCase().includes(search.value.toLowerCase())).slice(0, 50)) { + const option = document.createElement('option'); option.value = node.label; list.append(option); + } + } + let hiddenResult; + document.getElementById('graph-search-focus').addEventListener('click', () => { if (hiddenResult) send('search', { id: hiddenResult }); }); + function find() { + const node = snapshot().search.find(node => node.id === search.value || node.label === search.value); + if (!node) return; + if (data?.nodes.some(item => item.id === node.id)) { choose(node.id); locate(node.id); } + else { hiddenResult = node.id; document.getElementById('graph-search-focus').hidden = false; } + } + search.addEventListener('input', () => { hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; options(); }); + search.addEventListener('change', find); search.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); find(); } }); + document.querySelectorAll('[data-zoom]').forEach(button => button.addEventListener('click', () => button.dataset.zoom === 'fit' ? fitGraph() : zoomAt(zoom * (button.dataset.zoom === '+' ? 1.2 : 1 / 1.2)))); + graph.addEventListener('wheel', event => { + event.preventDefault(); if (busy) return; + const rect = graph.getBoundingClientRect(), delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? graph.clientHeight : 1); + zoomAt(zoom * Math.exp(-delta * .0015), event.clientX - rect.left, event.clientY - rect.top); + }, { passive: false }); + graph.addEventListener('pointerdown', event => { if (!busy && event.button === 0) drag = { id: event.pointerId, x: event.clientX, y: event.clientY, panX, panY, moved: false }; }); + graph.addEventListener('pointermove', event => { + if (!drag || event.pointerId !== drag.id) return; + const dx = event.clientX - drag.x, dy = event.clientY - drag.y; + if (!drag.moved && Math.hypot(dx, dy) > 4) { drag.moved = true; graph.setPointerCapture(event.pointerId); graph.classList.add('is-grabbing'); } + if (drag.moved) { event.preventDefault(); panX = drag.panX + dx; panY = drag.panY + dy; applyView(); saveView(); } + }); + function endDrag(event) { + if (!drag || event.pointerId !== drag.id) return; + suppressClick = drag.moved; if (graph.hasPointerCapture(event.pointerId)) graph.releasePointerCapture(event.pointerId); + graph.classList.remove('is-grabbing'); drag = undefined; setTimeout(() => suppressClick = false, 300); + } + graph.addEventListener('pointerup', endDrag); graph.addEventListener('pointercancel', endDrag); + for (const type of ['click', 'dblclick']) graph.addEventListener(type, event => { if (suppressClick || busy) { event.preventDefault(); event.stopPropagation(); } }, true); + graph.addEventListener('keydown', event => { + if (busy) return; + if (['+', '=', '-'].includes(event.key)) { event.preventDefault(); zoomAt(zoom * (event.key === '-' ? 1 / 1.2 : 1.2)); } + if (event.key === '0') { event.preventDefault(); fitGraph(); } + if (event.key.toLowerCase() === 'c') { event.preventDefault(); locate(selected); } + if (event.key.toLowerCase() === 'f' && selected) { event.preventDefault(); send('focus', { id: selected }); } + }); + function controls(next) { + document.getElementById('project-name').textContent = next.name; + document.getElementById('graph-depth').value = next.depth; + document.getElementById('graph-upstream').value = next.upstreamDepth || '0'; + document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === next.mode))); + document.querySelector('[data-action=overview]').hidden = !next.focus; + document.querySelector('[data-action=back]').disabled = !next.back; + document.querySelector('[data-action=locateFocus]').disabled = !next.focus; + if (next.nodes.some(node => node.id === hiddenResult)) { hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; } + const entries = document.getElementById('graph-entries'); entries.replaceChildren(); + const placeholder = document.createElement('option'); placeholder.textContent = labels.group; placeholder.value = ''; entries.append(placeholder); + for (const group of snapshot().groups) { const option = document.createElement('option'); option.value = group.id; option.textContent = `${group.label} (${group.nodes.length})`; entries.append(option); } + entries.value = next.group || ''; entries.hidden = !snapshot().groups.length; + const group = snapshot().groups.find(group => group.id === next.group); + for (const id of ['entry-previous', 'entry-page', 'entry-next']) document.getElementById(id).hidden = !group; + document.getElementById('entry-previous').disabled = !next.offset; + document.getElementById('entry-next').disabled = !group || (next.offset || 0) + 20 >= group.nodes.length; + document.getElementById('entry-page').textContent = group ? `${(next.offset || 0) + 1}–${Math.min((next.offset || 0) + 20, group.nodes.length)} / ${group.nodes.length}` : ''; + document.getElementById('graph-source').textContent = next.source; + const notice = document.getElementById('graph-notice'); notice.textContent = [next.limited ? labels.limited : '', next.truncated ? labels.partial : ''].filter(Boolean).join(' '); notice.hidden = !notice.textContent; + } + async function draw() { + if (drawing || document.hidden) return; + drawing = true; + while (pending && !document.hidden) { + const next = pending; pending = undefined; + const viewChanged = currentKey !== next.viewKey; + closeNodeMenu(); saveView(); currentKey = next.viewKey; data = next; controls(next); + if (viewChanged) { search.value = ''; hiddenResult = undefined; document.getElementById('graph-search-focus').hidden = true; options(); } + const dark = document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast'); + const signature = `${dark}:${next.source}`, binding = JSON.stringify([next.viewKey, next.nodes.map(node => node.id)]); + if (committed === signature && committedBinding === binding && graph.querySelector('svg')) { + busy = false; setStatus(''); if (next.fit) fitGraph(); selectNode(next.selected, next.center); continue; + } + if (!next.nodes.length) { graph.replaceChildren(); committed = undefined; setStatus(labels.empty); busy = false; selectNode(undefined); continue; } + busy = true; + mermaid.initialize({ startOnLoad: false, htmlLabels: false, securityLevel: 'strict', suppressErrorRendering: true, theme: dark ? 'dark' : 'default', fontFamily: 'system-ui', markdownAutoWrap: false, flowchart: { htmlLabels: false, useMaxWidth: true, wrappingWidth: 1000, nodeSpacing: 30, rankSpacing: 90, padding: 18 }, maxEdges: 310 }); + try { + const layoutStarted = performance.now(), cached = layouts.has(signature); + const markup = layouts.get(signature) || (await mermaid.render(`dependency-svg-${++renderId}`, next.source)).svg; + if (pending || data !== next) continue; + if (next.debug) console.debug('mcpp graph', { ...next.debug, cacheHit: cached, layoutMs: performance.now() - layoutStarted }); + layouts.delete(signature); layouts.set(signature, markup); trim(layouts); + graph.innerHTML = markup; committed = signature; committedBinding = binding; const svg = graph.querySelector('svg'); + baseWidth = svg.viewBox.baseVal.width; baseHeight = svg.viewBox.baseVal.height; svg.style.width = baseWidth + 'px'; svg.style.maxWidth = 'none'; + const saved = views.get(cameraKey(next)); + if (!saved || next.fit) fitGraph(); else { ({ zoom, panX, panY } = saved); applyView(); } + graph.querySelectorAll('.node').forEach(node => { + const match = /flowchart-(n[0-9]+)-/.exec(node.id); if (!match) return; + const item = next.nodes[Number(match[1].slice(1))]; if (!item) return; + node.dataset.nodeKey = item.id; node.setAttribute('tabindex', '0'); node.setAttribute('role', 'button'); node.setAttribute('aria-label', node.textContent); + node.querySelectorAll('rect').forEach(rect => { rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); }); + node.setAttribute('aria-haspopup', 'menu'); + node.addEventListener('contextmenu', event => openNodeMenu(event, node, item)); + node.addEventListener('click', () => choose(item.id)); + node.addEventListener('dblclick', () => { choose(item.id); send(item.virtual ? 'focus' : 'open', { id: item.id }); }); + node.addEventListener('keydown', event => { + if (event.target !== node) return; + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { openNodeMenu(event, node, item, true); return; } + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(item.id); if (event.key === 'Enter') send(item.virtual ? 'focus' : 'open', { id: item.id }); } + }); + if (item.hidden) { + const line = node.querySelector('text')?.lastElementChild; + if (line) { + line.classList.add('fold-control'); line.setAttribute('role', 'button'); line.setAttribute('tabindex', '0'); line.setAttribute('aria-label', labels.expand + ': ' + item.hidden); + const expand = event => { event.preventDefault(); event.stopPropagation(); choose(item.id); send('expand', { id: item.id }); }; + line.addEventListener('click', expand); line.addEventListener('dblclick', event => event.stopPropagation()); + line.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') expand(event); }); + } + } + }); + busy = false; setStatus(''); selectNode(next.selected, next.center); saveView(); + } catch { busy = false; committed = undefined; graph.replaceChildren(); setStatus(labels.error); } + } + drawing = false; + } + async function exportGraph(format) { + if (busy || !committed) return; + const original = graph.querySelector('svg'); if (!original) return; + const fixedData = data, clone = original.cloneNode(true), sourceNodes = [original, ...original.querySelectorAll('*')], targetNodes = [clone, ...clone.querySelectorAll('*')]; + const properties = ['fill','stroke','stroke-width','stroke-dasharray','opacity','font-family','font-size','font-weight','text-anchor','dominant-baseline','marker-end','marker-start','paint-order']; + sourceNodes.forEach((node, index) => { + const style = getComputedStyle(node), target = targetNodes[index]; target.removeAttribute('style'); target.removeAttribute('class'); + for (const property of properties) { + const value = style.getPropertyValue(property).replace(/url\(["']?[^)#]*#([^)'" ]+)["']?\)/g, 'url(#$1)'); + if (value) target.style.setProperty(property, value); + } + for (const attribute of [...target.attributes]) if (/^on|^data-|^tabindex$|^role$|^aria-/.test(attribute.name)) target.removeAttribute(attribute.name); + }); + clone.querySelectorAll('style,script,foreignObject').forEach(node => node.remove()); + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); clone.setAttribute('width', String(baseWidth)); clone.setAttribute('height', String(baseHeight)); + const background = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + const box = original.viewBox.baseVal; background.setAttribute('x', box.x); background.setAttribute('y', box.y); background.setAttribute('width', box.width); background.setAttribute('height', box.height); background.setAttribute('fill', getComputedStyle(document.body).backgroundColor || '#fff'); clone.insertBefore(background, clone.firstChild); + const description = document.createElementNS('http://www.w3.org/2000/svg', 'desc'); description.textContent = [fixedData.name, fixedData.limited ? labels.limited : '', fixedData.truncated ? labels.partial : ''].filter(Boolean).join(' · '); clone.prepend(description); + const svgText = new XMLSerializer().serializeToString(clone); + const deliver = payload => vscode.postMessage({ type: 'action', action: 'export', viewKey: fixedData.viewKey, format, data: payload }); + if (format === 'svg') return deliver(svgText); + let url; + try { + const ratio = Math.min(2, 8192 / baseWidth, 8192 / baseHeight, Math.sqrt(16000000 / (baseWidth * baseHeight))); + const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.floor(baseWidth * ratio)); canvas.height = Math.max(1, Math.floor(baseHeight * ratio)); + url = URL.createObjectURL(new Blob([svgText], { type: 'image/svg+xml' })); + const img = new Image(); img.src = url; await img.decode(); canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height); + const png = canvas.toDataURL('image/png').split(',')[1]; canvas.width = canvas.height = 0; + deliver(png); + } catch { setStatus(labels.exportError); } finally { if (url) URL.revokeObjectURL(url); } + } + document.querySelectorAll('[data-export]').forEach(button => button.addEventListener('click', () => { closeMenus(); void exportGraph(button.dataset.export); })); + window.addEventListener('message', event => { + const message = event.data; + if (message.type === 'snapshot') { snapshots.delete(message.viewKey); snapshots.set(message.viewKey, message); trim(snapshots); if (message.viewKey === currentKey) options(); } + if (message.type === 'graph') { pending = message; void draw(); } + if (message.type === 'loading') { + closeNodeMenu(); saveView(); currentKey = message.viewKey; committed = undefined; baseWidth = 0; data = undefined; pending = undefined; busy = true; + document.getElementById('project-name').textContent = message.name; + search.value = ''; selectNode(undefined); setStatus(labels.loading, true); + } + if (message.type === 'error') { busy = false; setStatus(message.error, !data); } + if (message.type === 'copied') { const feedback = document.getElementById('feedback'); feedback.textContent = labels.copied; setTimeout(() => feedback.textContent = '', 1800); } + }); + new MutationObserver(() => { if (data) { pending = data; void draw(); } }).observe(document.body, { attributes: true, attributeFilter: ['class'] }); + document.addEventListener('visibilitychange', () => { if (!document.hidden) void draw(); }); + window.addEventListener('resize', applyView); + selectNode(undefined); setStatus(labels.loading, true); vscode.postMessage({ type: 'ready' }); +})(); diff --git a/mide-plugins/vscode/media/library.css b/mide-plugins/vscode/media/library.css new file mode 100644 index 0000000..be2d241 --- /dev/null +++ b/mide-plugins/vscode/media/library.css @@ -0,0 +1,745 @@ +/* + * The library sidebar's stylesheet (§9.3, §10.2, §12). + * + * The rules the other webviews are held to apply here too: + * + * 1. **Theme tokens only.** Every colour is a `var(--vscode-*)` reference, so + * light, dark and high-contrast themes all work from one file. The only other + * values are `currentColor` and `transparent`, and `transparent` is always a + * fallback of a token a theme may not define. There is no literal colour in + * this file. + * 2. **Never colour alone.** The "already declared" state is a labelled badge + * (``) and a `data-added` attribute; the + * surface, the badges and the version are all text. Colour is decoration. + * 3. **No inline styles.** The document sets `data-*` attributes and this file + * turns them into borders and fills, so the strict CSP holds. + * 4. **Built for 170–520 px.** Two-line clamped descriptions, the search box + * row, `tabular-nums` so versions line up, and no fixed widths. + * + * The `.detail-*` rules at the end belong to the editor-area detail page + * (`src/library/detailHtml.ts`), which shares this stylesheet so the two halves + * of the library feature cannot drift apart visually. + */ + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + color: var(--vscode-foreground); + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); + font-family: var(--vscode-font-family); + font-size: 12px; + line-height: 1.45; + font-variant-numeric: tabular-nums; + overflow-wrap: anywhere; +} + +[hidden] { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +main { + padding: 4px 0 12px; +} + +/* ───────────────────────────────────────────────────────────── header ── */ + +.panel-header { + padding: 8px 10px 0; +} + +.panel-title { + margin: 0; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--vscode-descriptionForeground); +} + +.count { + margin: 2px 0 0; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +/* ──────────────────────────────────────────────────────────── toolbar ── */ + +.toolbar { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px; + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); + border-bottom: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); +} + +.search { + flex: 1 1 100%; + min-width: 0; + padding: 3px 6px; + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid var(--vscode-input-border, transparent); + border-radius: 2px; +} + +.search::placeholder { + color: var(--vscode-input-placeholderForeground, var(--vscode-descriptionForeground)); +} + +.search:focus-visible, +.row:focus-visible, +.link-button:focus-visible, +.network-toggle input:focus-visible, +.detail-actions button:focus-visible, +.detail-toggle input:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.network-toggle { + display: flex; + align-items: center; + gap: 5px; + flex: 1 1 100%; + font-size: 11px; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +.network-toggle input { + margin: 0; + accent-color: var(--vscode-checkbox-selectBackground, var(--vscode-focusBorder)); +} + +/* ─────────────────────────────────────────────────────────────── rows ── */ + +.rows { + margin: 0; + padding: 0; + list-style: none; +} + +.row-item + .row-item { + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); +} + +.row { + display: block; + width: 100%; + padding: 7px 10px; + font: inherit; + color: inherit; + text-align: start; + background: none; + border: 0; + cursor: pointer; +} + +.row:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.row-head { + display: flex; + align-items: baseline; + gap: 6px; + justify-content: space-between; +} + +.row-name { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.row-version { + flex: 0 0 auto; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.row-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px; + margin-top: 2px; +} + +.row-surface { + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +/* + * A badge is *metadata*, not a control: no box, no fill, no outline — a quiet + * label separated from its neighbours by `·`. The outline was the whole problem: + * a bounded rectangle in a sidebar reads as something you can press. + * + * The two states that must not be missed (`Added`, `Descriptor not readable`) + * keep their colour and gain weight, so they still stand out without a border — + * and the text beside them says the same thing, which is the house rule. + */ +.badge { + padding: 0; + font-size: 10px; + color: var(--vscode-descriptionForeground); + border: 0; + white-space: nowrap; +} + +.badge + .badge::before, +.row-surface + .badge::before { + content: "·"; + margin-right: 4px; + color: var(--vscode-descriptionForeground); +} + +.badge-added { + color: var(--vscode-charts-green, var(--vscode-foreground)); + font-weight: 600; +} + +.badge-warn { + color: var(--vscode-charts-yellow, var(--vscode-foreground)); + font-weight: 600; +} + +/* Two lines, then an ellipsis: the row stays self-sufficient without a wall of text. */ +.row-description { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + margin-top: 3px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.empty { + margin: 12px 10px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +/* ────────────────────────────────────────────────────────────── footer ── */ + +.footer { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding: 8px 10px 14px; + color: var(--vscode-descriptionForeground); + font-size: 10px; + border-top: 1px solid var(--vscode-sideBarSectionHeader-border, transparent); +} + +.source { + flex: 1 1 auto; +} + +.link-button { + flex: 0 0 auto; + padding: 0; + font: inherit; + color: var(--vscode-textLink-foreground); + background: none; + border: 0; + cursor: pointer; + text-decoration: underline; +} + +.link-button:hover { + color: var(--vscode-textLink-activeForeground, var(--vscode-textLink-foreground)); +} + +/* ─────────────────────────────────────────── the detail page (editor) ── */ + +body.detail { + background-color: var(--vscode-editor-background); + width: 100%; + min-width: 0; + padding: 0 clamp(16px, 2vw, 40px) 40px; +} + +.detail-header { + padding: 16px 0 8px; + border-bottom: 1px solid var(--vscode-panel-border, transparent); +} + +.detail-title { + margin: 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 18px; + font-weight: 600; +} + +.detail-description { + margin: 6px 0 0; + color: var(--vscode-descriptionForeground); +} + +.detail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 10px; + margin-top: 8px; + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.detail-meta a { + color: var(--vscode-textLink-foreground); +} + +/* + * The detail page is a *document*, so its sections are separated by a hairline + * and a heading that reads as a heading rather than as a micro-label: the + * uppercase/letterspaced 12 px version made four sections look like four + * toolbars. The page opens with the thing it is for (`detail-primary`), then + * the facts, then the prose. + */ +.detail-section { + margin-top: 4px; + padding-top: 14px; + border-top: 1px solid var(--vscode-panel-border, transparent); +} + +.detail-section > h2 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.detail-primary { + margin-top: 14px; +} + +.detail-primary .detail-actions { + margin-top: 0; +} + +/* The version matrix is the page's selector: one row per platform, one button + per version, the aim of the command shown at the top of the page. */ +.detail-versions { + margin: 0; + padding: 0; + list-style: none; + font-size: 11px; +} + +.detail-version-group { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 4px 8px; + padding: 3px 0; +} + +.detail-version-group + .detail-version-group { + border-top: 1px solid var(--vscode-panel-border, transparent); +} + +.detail-version-group[data-current] .detail-platform { + color: var(--vscode-foreground); + font-weight: 600; +} + +.detail-platform { + flex: 0 0 auto; + min-width: 7em; + color: var(--vscode-descriptionForeground); +} + +.detail-version-list { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.detail-version { + padding: 1px 7px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 11px; + color: var(--vscode-descriptionForeground); + background-color: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} + +.detail-version:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground, transparent); +} + +/* The version the project already asks for, marked by a word as well as a hue. */ +.detail-installed { + padding: 0 5px; + font-size: 10px; + font-weight: 600; + color: var(--vscode-charts-green, var(--vscode-foreground)); +} + +.detail-version[data-selected] { + color: var(--vscode-inputOption-activeForeground, var(--vscode-foreground)); + background-color: var(--vscode-inputOption-activeBackground, var(--vscode-toolbar-hoverBackground, transparent)); + outline: 1px solid var(--vscode-inputOption-activeBorder, transparent); + outline-offset: -1px; + font-weight: 600; +} + +.detail-version-empty { + color: var(--vscode-descriptionForeground); +} + +.detail-current { + color: var(--vscode-descriptionForeground); + font-size: 10px; + font-style: italic; +} + +.detail-hint { + margin: 4px 0 0; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.detail-deps, +.detail-licenses { + margin: 0; + padding-left: 18px; + font-size: 11px; +} + +.detail-code { + margin: 0; + padding: 8px 10px; + overflow-x: auto; + max-height: min(52vh, 440px); + overflow-y: auto; + background-color: var(--vscode-textCodeBlock-background, transparent); + border: 1px solid var(--vscode-panel-border, transparent); + border-radius: 3px; +} + +.detail-code + .detail-current { + color: var(--vscode-descriptionForeground); + font-size: 10px; + font-style: italic; +} + +.detail-hint { + margin-top: 4px; +} + +.detail-code-file { + margin-top: 8px; + color: var(--vscode-descriptionForeground); + font-family: var(--vscode-editor-font-family, monospace); + font-size: 10px; +} + +pre.detail-code code { + display: block; + background: transparent; + color: inherit; + padding: 0; + border-radius: 0; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + line-height: 1.5; + white-space: pre; +} + +.tok-comment { + color: var(--vscode-editorCodeLens-foreground, var(--vscode-descriptionForeground)); + font-style: italic; +} + +.tok-string { + color: var(--vscode-charts-orange, currentColor); +} + +.tok-keyword { + color: var(--vscode-charts-blue, var(--vscode-symbolIcon-keywordForeground, currentColor)); +} + +.tok-preprocessor { + color: var(--vscode-charts-purple, var(--vscode-symbolIcon-keywordForeground, currentColor)); +} + +.tok-punctuation { + color: var(--vscode-symbolIcon-operatorForeground, currentColor); +} + +.detail-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.detail-actions button { + padding: 4px 12px; + font: inherit; + color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background); + border: 1px solid var(--vscode-contrastBorder, transparent); + border-radius: 2px; + cursor: pointer; +} + +.detail-actions button:hover { + background-color: var(--vscode-button-hoverBackground); +} + +.detail-actions button[data-secondary] { + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background-color: var(--vscode-button-secondaryBackground, transparent); +} + +/* "Already added" is success, not a call to action: the theme's pass colour, + thinned until it reads inert — disabled stays disabled, the hue tells the + reader the outcome is the good one (§22.5). */ +.detail-actions button.detail-add[data-installed], +.detail-actions button.detail-add[data-installed]:hover { + color: var(--vscode-button-foreground); + background-color: color-mix( + in srgb, + var(--vscode-testing-iconPassed, var(--vscode-button-background)) 55%, + transparent + ); + border-color: color-mix( + in srgb, + var(--vscode-testing-iconPassed, var(--vscode-contrastBorder, transparent)) 70%, + transparent + ); +} + +.detail-toggle { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + cursor: pointer; +} + +/* The command and each usage line are text a reader copies elsewhere, so they + read as code blocks — the same recipe the example snippets use — with the + copy affordance riding beside them (§22.5). */ +.detail-command-row { + display: flex; + align-items: stretch; + gap: 8px; + margin-top: 6px; +} + +.detail-command { + flex: 1; + margin: 0; + padding: 8px 10px; + overflow-x: auto; + background-color: var(--vscode-textCodeBlock-background, transparent); + border: 1px solid var(--vscode-panel-border, transparent); + border-radius: 3px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + white-space: pre; + color: var(--vscode-editorForeground, var(--vscode-foreground)); +} + +/* The small affordance beside the command and each usage line: text a reader + pastes elsewhere, so a click takes it to the clipboard. */ +.detail-copy { + flex: none; + align-self: center; + padding: 3px 10px; + font-size: 11px; + color: var(--vscode-button-secondaryForeground, var(--vscode-foreground)); + background-color: var(--vscode-button-secondaryBackground, transparent); + border: 1px solid var(--vscode-contrastBorder, transparent); + border-radius: 2px; + cursor: pointer; +} + +.detail-copy:hover { + background-color: var(--vscode-button-hoverBackground, transparent); +} + +.detail-usage-line { + display: flex; + align-items: stretch; + gap: 8px; + margin: 4px 0 0; +} + +.detail-usage-line code { + flex: 1; + display: block; + padding: 6px 10px; + overflow-x: auto; + background-color: var(--vscode-textCodeBlock-background, transparent); + border: 1px solid var(--vscode-panel-border, transparent); + border-radius: 3px; + font-family: var(--vscode-editor-font-family, monospace); + font-size: 12px; + white-space: pre; + color: var(--vscode-editorForeground, var(--vscode-foreground)); +} + +.detail-result { + margin: 6px 0 0; + font-size: 11px; +} + +.detail-result[data-state="error"] { + color: var(--vscode-errorForeground); +} + +/* The client's own line while the host is being asked, so a click is never silent. */ +.detail-result[data-state="pending"] { + color: var(--vscode-descriptionForeground); +} + +.detail-footer { + margin-top: 22px; + padding-top: 8px; + color: var(--vscode-descriptionForeground); + font-size: 10px; + border-top: 1px solid var(--vscode-panel-border, transparent); +} + +/* Libraries: keep the search control stable while replacing results in place. */ +.toolbar { position: sticky; } +.search { padding-right: 24px; } +.clear-search { position: absolute; right: 18px; top: 10px; font-size: 16px; } +.row[aria-current="true"] { background: var(--vscode-list-inactiveSelectionBackground); box-shadow: inset 2px 0 var(--vscode-focusBorder); } +.group-title { margin: 12px 10px 4px; font-size: 11px; color: var(--vscode-descriptionForeground); } +.search-status { margin: 8px 10px; color: var(--vscode-descriptionForeground); } +.footer details { margin-bottom: 6px; } +.footer summary, .detail-footer summary, .detail-aside summary { cursor: pointer; } +.footer-actions { display: flex; flex-wrap: wrap; gap: 6px 12px; } +.source { white-space: pre-wrap; } +.project-notice { margin-top: 2px; } + +/* Use the editor width; reserve a compact metadata column instead of scaling it with the code. */ +.detail-title { overflow-wrap: anywhere; } +.detail-primary { margin: 0; padding: 8px 0 10px; } +.project-context, .detail-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 8px 0; } +.project-context { color: var(--vscode-descriptionForeground); } +.project-context strong { font-weight: 500; overflow-wrap: anywhere; } +.detail-controls label { display: flex; align-items: center; gap: 6px; } +.detail-controls select { padding: 3px 8px; font: inherit; color: var(--vscode-dropdown-foreground); background: var(--vscode-dropdown-background); border: 1px solid var(--vscode-dropdown-border, var(--vscode-input-border, transparent)); } +.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) clamp(240px, 24vw, 320px); gap: 28px; } +.detail-layout > *, .detail-command-row > *, .detail-usage-line > * { min-width: 0; } +.detail-aside { min-width: 0; } +.detail-aside .detail-version-group { display: block; } +.detail-aside .detail-platform { display: block; margin-bottom: 4px; } +.detail-version-list { display: flex; flex-wrap: wrap; gap: 4px; } +span.detail-version { cursor: default; color: var(--vscode-descriptionForeground); background: var(--vscode-textCodeBlock-background); border-color: transparent; } +span.detail-version[data-installed] { color: var(--vscode-testing-iconPassed, var(--vscode-foreground)); } +.detail-usage > h2:not(:first-child) { margin-top: 20px; } +.detail-command { white-space: pre; } +.detail-command-row { min-width: 0; } +.detail-command-row .detail-command { flex: 1; } +.detail-command-row .detail-copy { flex-shrink: 0; } +.example-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 20px 0 8px; } +.example-heading h2 { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.snippet-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; } +.snippet-action { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 4px 7px; border: 1px solid transparent; border-radius: 4px; color: var(--vscode-descriptionForeground); background: transparent; font: inherit; font-size: 11px; cursor: pointer; } +.snippet-action:hover { background: var(--vscode-toolbar-hoverBackground); color: var(--vscode-foreground); } +.snippet-action:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 1px; } +.vscode-high-contrast .snippet-action, .vscode-high-contrast-light .snippet-action { border-color: var(--vscode-contrastBorder); } +.result-actions { display: flex; flex-wrap: wrap; gap: 12px; } +button:disabled, select:disabled { cursor: default; opacity: 0.65; } +button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: 2px; } +.detail-footer { overflow-wrap: anywhere; } +@media (max-width: 899px) { + .detail-layout { display: block; } + body.detail { padding-left: 16px; padding-right: 16px; } +} +@media (max-width: 520px) { + .detail-actions { gap: 8px; } + .detail-actions button { padding: 5px 8px; } + .detail-command-row { flex-wrap: wrap; } + .detail-command-row .detail-command { flex-basis: 100%; } +} + +.toolbar .count { margin: 0 0 0 auto; align-self: center; } +.detail-actions .link-button { color: var(--vscode-textLink-foreground); background: transparent; border-color: transparent; padding: 4px 0; } +.detail-actions .link-button:hover { background: transparent; text-decoration: underline; } + +/* A single, compact context row; empty feedback reserves no vertical space. */ +.project-context { margin: 0 0 8px; gap: 8px; font-size: 11px; } +.project-picker { display: inline-flex; align-items: center; gap: 6px; min-width: 0; max-width: 100%; margin: 0; padding: 2px 0; font: inherit; color: var(--vscode-descriptionForeground); background: transparent; border: 0; cursor: pointer; } +.project-picker strong { color: var(--vscode-foreground); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.project-picker:hover strong { color: var(--vscode-textLink-foreground); } +.context-platform::before { content: "·"; margin-right: 8px; } +.detail-controls { margin: 4px 0 10px; gap: 8px 14px; } +.detail-command-row { margin-top: 8px; } +.detail-command { margin: 0; } +.detail-result { margin: 8px 0 0; } +.detail-layout { gap: 24px; } +.detail-aside .detail-section { padding: 10px 0; margin: 0; } +.detail-aside summary { font-size: 12px; font-weight: 500; } +.detail-aside details[open] > summary { margin-bottom: 10px; } +.detail-usage { padding-top: 12px; } +.detail-footer { margin-top: 14px; } + +/* Package identity and context share one header; tags remain readable in every theme. */ +.detail-heading-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 10px 18px; } +.detail-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; } +.detail-heading .detail-title { max-width: 100%; } +.detail-heading-row .project-context { justify-content: flex-end; max-width: 220px; margin: 0; padding-top: 2px; } +.detail-tags { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; } +.detail-tag { display: inline-block; padding: 1px 6px; border: 1px solid currentColor; border-radius: 4px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; line-height: 1.6; font-weight: 500; } +.detail-tag[data-tag="surface"] { color: var(--vscode-symbolIcon-interfaceForeground, var(--vscode-textLink-foreground)); background: color-mix(in srgb, var(--vscode-symbolIcon-interfaceForeground, var(--vscode-textLink-foreground)) 12%, transparent); } +.detail-tag[data-tag="standard"] { color: var(--vscode-charts-purple, var(--vscode-textLink-foreground)); background: color-mix(in srgb, var(--vscode-charts-purple, var(--vscode-textLink-foreground)) 12%, transparent); } +.detail-tag[data-tag="license"] { color: var(--vscode-charts-green, var(--vscode-foreground)); background: color-mix(in srgb, var(--vscode-charts-green, var(--vscode-foreground)) 12%, transparent); } +.vscode-high-contrast .detail-tag, .vscode-high-contrast-light .detail-tag { background: transparent; color: var(--vscode-foreground); } +.dependency-kind-label { color: var(--vscode-descriptionForeground); font-family: var(--vscode-editor-font-family); font-size: 11px; } +.detail-add-group { position: relative; display: inline-flex; align-items: stretch; } +.detail-add-group .detail-add { border-radius: 2px 0 0 2px; } +.detail-add-group .detail-kind-toggle { width: 28px; min-width: 28px; padding: 4px; border-radius: 0 2px 2px 0; border-left-color: var(--vscode-button-separator, var(--vscode-contrastBorder, transparent)); } +.detail-kind-menu { position: absolute; z-index: 10; top: calc(100% + 4px); left: 0; width: 270px; max-width: calc(100vw - 36px); padding: 4px; border: 1px solid var(--vscode-widget-border, var(--vscode-contrastBorder, transparent)); border-radius: 4px; background: var(--vscode-menu-background, var(--vscode-editorWidget-background)); box-shadow: 0 4px 12px var(--vscode-widget-shadow, transparent); } +.detail-actions .detail-kind-menu button { position: relative; display: grid; width: 100%; gap: 2px; padding: 7px 10px 7px 26px; text-align: left; color: var(--vscode-menu-foreground, var(--vscode-foreground)); background: transparent; border: 0; border-radius: 2px; } +.detail-kind-menu button[aria-checked="true"]::before { content: "✓"; position: absolute; left: 7px; top: 7px; } +.detail-kind-menu code { font-size: 10px; color: var(--vscode-descriptionForeground); } +.detail-actions .detail-kind-menu button:hover, .detail-actions .detail-kind-menu button:focus { color: var(--vscode-menu-selectionForeground, var(--vscode-list-activeSelectionForeground)); background: var(--vscode-menu-selectionBackground, var(--vscode-list-activeSelectionBackground)); } +@media (max-width: 520px) { + .detail-heading-row { display: flex; flex-direction: column; gap: 4px; } + .detail-heading-row .project-context { align-self: flex-end; } +} + +.detail-kind-menu code { background: transparent; padding: 0; } +.detail-kind-menu button:hover code, .detail-kind-menu button:focus code { color: inherit; } diff --git a/mide-plugins/vscode/media/quick-menu/archive--purple--dark.svg b/mide-plugins/vscode/media/quick-menu/archive--purple--dark.svg new file mode 100644 index 0000000..f4c1f5d --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/archive--purple--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/archive--purple--light.svg b/mide-plugins/vscode/media/quick-menu/archive--purple--light.svg new file mode 100644 index 0000000..6749ab1 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/archive--purple--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/beaker--purple--dark.svg b/mide-plugins/vscode/media/quick-menu/beaker--purple--dark.svg new file mode 100644 index 0000000..3b1726f --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/beaker--purple--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/beaker--purple--light.svg b/mide-plugins/vscode/media/quick-menu/beaker--purple--light.svg new file mode 100644 index 0000000..346027b --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/beaker--purple--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/chip--yellow--dark.svg b/mide-plugins/vscode/media/quick-menu/chip--yellow--dark.svg new file mode 100644 index 0000000..3ec84e1 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/chip--yellow--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/chip--yellow--light.svg b/mide-plugins/vscode/media/quick-menu/chip--yellow--light.svg new file mode 100644 index 0000000..274f032 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/chip--yellow--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/cloud-download--blue--dark.svg b/mide-plugins/vscode/media/quick-menu/cloud-download--blue--dark.svg new file mode 100644 index 0000000..29a71ae --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/cloud-download--blue--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/cloud-download--blue--light.svg b/mide-plugins/vscode/media/quick-menu/cloud-download--blue--light.svg new file mode 100644 index 0000000..845e06e --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/cloud-download--blue--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--dark.svg b/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--dark.svg new file mode 100644 index 0000000..678432d --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--light.svg b/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--light.svg new file mode 100644 index 0000000..71280b2 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/cloud-download--yellow--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--dark.svg new file mode 100644 index 0000000..282d81b --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--light.svg new file mode 100644 index 0000000..ff44f44 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/debug-restart--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/file-zip--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/file-zip--neutral--dark.svg new file mode 100644 index 0000000..25c9af3 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/file-zip--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/file-zip--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/file-zip--neutral--light.svg new file mode 100644 index 0000000..01bfc76 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/file-zip--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/folder--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/folder--neutral--dark.svg new file mode 100644 index 0000000..2e213e2 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/folder--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/folder--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/folder--neutral--light.svg new file mode 100644 index 0000000..8db6e26 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/folder--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--dark.svg new file mode 100644 index 0000000..7dc3353 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--light.svg new file mode 100644 index 0000000..d130fd7 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/folder-opened--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/heart--green--dark.svg b/mide-plugins/vscode/media/quick-menu/heart--green--dark.svg new file mode 100644 index 0000000..29d07bc --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/heart--green--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/heart--green--light.svg b/mide-plugins/vscode/media/quick-menu/heart--green--light.svg new file mode 100644 index 0000000..02ff5ae --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/heart--green--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/library--blue--dark.svg b/mide-plugins/vscode/media/quick-menu/library--blue--dark.svg new file mode 100644 index 0000000..fa6a502 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/library--blue--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/library--blue--light.svg b/mide-plugins/vscode/media/quick-menu/library--blue--light.svg new file mode 100644 index 0000000..4624932 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/library--blue--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/output--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/output--neutral--dark.svg new file mode 100644 index 0000000..249e5c7 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/output--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/output--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/output--neutral--light.svg new file mode 100644 index 0000000..648bad2 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/output--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/play--green--dark.svg b/mide-plugins/vscode/media/quick-menu/play--green--dark.svg new file mode 100644 index 0000000..257bfec --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/play--green--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/play--green--light.svg b/mide-plugins/vscode/media/quick-menu/play--green--light.svg new file mode 100644 index 0000000..8a7fb22 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/play--green--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/refresh--blue--dark.svg b/mide-plugins/vscode/media/quick-menu/refresh--blue--dark.svg new file mode 100644 index 0000000..3317094 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/refresh--blue--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/refresh--blue--light.svg b/mide-plugins/vscode/media/quick-menu/refresh--blue--light.svg new file mode 100644 index 0000000..091b2e1 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/refresh--blue--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/refresh--green--dark.svg b/mide-plugins/vscode/media/quick-menu/refresh--green--dark.svg new file mode 100644 index 0000000..088c57a --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/refresh--green--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/refresh--green--light.svg b/mide-plugins/vscode/media/quick-menu/refresh--green--light.svg new file mode 100644 index 0000000..55651d5 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/refresh--green--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/report--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/report--neutral--dark.svg new file mode 100644 index 0000000..8b9fcc0 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/report--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/report--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/report--neutral--light.svg new file mode 100644 index 0000000..b6d5314 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/report--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--dark.svg new file mode 100644 index 0000000..a9323c0 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--light.svg new file mode 100644 index 0000000..d9af7db --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/settings-gear--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/star-full--yellow--dark.svg b/mide-plugins/vscode/media/quick-menu/star-full--yellow--dark.svg new file mode 100644 index 0000000..27e0825 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/star-full--yellow--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/star-full--yellow--light.svg b/mide-plugins/vscode/media/quick-menu/star-full--yellow--light.svg new file mode 100644 index 0000000..13acc5f --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/star-full--yellow--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--dark.svg new file mode 100644 index 0000000..1beaa79 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--light.svg new file mode 100644 index 0000000..7113300 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/symbol-interface--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/tools--blue--dark.svg b/mide-plugins/vscode/media/quick-menu/tools--blue--dark.svg new file mode 100644 index 0000000..789f38a --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/tools--blue--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/tools--blue--light.svg b/mide-plugins/vscode/media/quick-menu/tools--blue--light.svg new file mode 100644 index 0000000..e4b8fca --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/tools--blue--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/trash--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/trash--neutral--dark.svg new file mode 100644 index 0000000..c177465 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/trash--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/trash--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/trash--neutral--light.svg new file mode 100644 index 0000000..6cf1bb4 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/trash--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/trash--red--dark.svg b/mide-plugins/vscode/media/quick-menu/trash--red--dark.svg new file mode 100644 index 0000000..1397e55 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/trash--red--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/trash--red--light.svg b/mide-plugins/vscode/media/quick-menu/trash--red--light.svg new file mode 100644 index 0000000..0f6c369 --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/trash--red--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--dark.svg b/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--dark.svg new file mode 100644 index 0000000..75bf76d --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--light.svg b/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--light.svg new file mode 100644 index 0000000..417c5ec --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/type-hierarchy--neutral--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/verified--green--dark.svg b/mide-plugins/vscode/media/quick-menu/verified--green--dark.svg new file mode 100644 index 0000000..088b27c --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/verified--green--dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/quick-menu/verified--green--light.svg b/mide-plugins/vscode/media/quick-menu/verified--green--light.svg new file mode 100644 index 0000000..4e5c7cd --- /dev/null +++ b/mide-plugins/vscode/media/quick-menu/verified--green--light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/mide-plugins/vscode/media/settings.css b/mide-plugins/vscode/media/settings.css new file mode 100644 index 0000000..967cbab --- /dev/null +++ b/mide-plugins/vscode/media/settings.css @@ -0,0 +1,318 @@ +/* + * The configuration panel's stylesheet. + * + * Two rules hold this together, and both come from the plan (§3.5, §4.3): + * + * 1. **Theme tokens only.** Every colour is a `var(--vscode-*)` reference, so + * the panel follows light, dark and high-contrast themes without a second + * stylesheet. `currentColor` is used for borders that should inherit the + * text colour; there is not a single literal colour value in this file. + * 2. **Never colour alone.** State (default / user / workspace / folder / + * invalid, deprecated, modified) is carried by the badges' *text* and by the + * shape of the row's left border — solid, dashed, or double. Colour is + * decoration on top, which is what keeps the panel readable when a theme + * collapses every hue into one. + * + * The client only toggles `hidden` and `data-*` attributes, so all behaviour is + * expressed here as attribute selectors rather than inline styles. + */ + +body { + margin: 0; + padding: 0; + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + line-height: 1.5; +} + +[hidden] { + display: none !important; +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + color: var(--vscode-input-foreground); + background-color: var(--vscode-input-background); + border: 1px solid; + border-color: var(--vscode-input-border, currentColor); + border-radius: 2px; + padding: 2px 6px; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +input[type="checkbox"] { + padding: 0; + background: none; + border: 0; + accent-color: var(--vscode-checkbox-background, var(--vscode-focusBorder)); +} + +/* ------------------------------------------------------------------ header */ + +.panel-header { + padding: 12px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.panel-title { + margin: 0 0 6px; + font-size: 1.3em; + font-weight: 600; +} + +.boundary { + margin: 0; + padding: 8px 10px; + border: 1px solid; + border-color: var(--vscode-inputValidation-infoBorder, currentColor); + background-color: var(--vscode-inputValidation-infoBackground); +} + +.resource-label { + margin: 6px 0 0; + color: var(--vscode-descriptionForeground); +} + +/* ----------------------------------------------------------------- toolbar */ + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + padding: 8px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.search { + flex: 1 1 220px; + min-width: 160px; +} + +.toggle, +.target { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +/* ----------------------------------------------------------------- presets */ + +.presets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 8px 16px; + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.presets-title { + font-weight: 600; +} + +.preset-buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.preset { + color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground)); + background-color: var(--vscode-button-secondaryBackground, var(--vscode-button-background)); + border: 1px solid; + border-color: var(--vscode-contrastBorder, currentColor); + padding: 3px 10px; +} + +.preset:hover { + background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground)); +} + +.link-button { + color: var(--vscode-textLink-foreground); + background: none; + border: 1px solid; + border-color: currentColor; + padding: 2px 8px; +} + +.link-button:hover { + color: var(--vscode-textLink-activeForeground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* ---------------------------------------------------------------- sections */ + +.section { + border-bottom: 1px solid var(--vscode-panel-border, currentColor); +} + +.section-head { + margin: 0; + font-size: 1em; +} + +.section-toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 16px; + text-align: left; + font-weight: 600; + color: var(--vscode-sideBarSectionHeader-foreground, var(--vscode-foreground)); + background-color: var(--vscode-sideBarSectionHeader-background); + border: 0; + border-radius: 0; +} + +/* The caret is a text glyph so the collapsed state is visible without colour. */ +.section-toggle::before { + content: "\25BE"; +} + +.section[data-collapsed="true"] .section-toggle::before { + content: "\25B8"; +} + +.section[data-collapsed="true"] .rows { + display: none; +} + +/* -------------------------------------------------------------------- rows */ + +.row { + display: grid; + gap: 6px; + padding: 10px 16px; + border-left: 3px solid; + border-left-color: var(--vscode-panel-border, currentColor); +} + +.row[data-deprecated="true"] { + border-left-style: dashed; + border-left-color: var(--vscode-editorWarning-foreground); +} + +.row[data-source="invalid"] { + border-left-style: double; + border-left-width: 5px; + border-left-color: var(--vscode-errorForeground); +} + +.row-head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; +} + +.row-title { + font-weight: 600; +} + +.row-description { + margin: 0; + color: var(--vscode-descriptionForeground); +} + +.row-deprecation { + margin: 0; + color: var(--vscode-editorWarning-foreground); +} + +.row-applies { + margin: 0; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.row-control { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.row-control input[type="text"], +.row-control input[type="number"], +.row-control select { + min-width: 180px; +} + +.row-control textarea { + width: 100%; + min-height: 3em; + resize: vertical; +} + +.control-hint { + margin: 0; + color: var(--vscode-descriptionForeground); + font-size: 0.9em; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.empty { + margin: 0; + padding: 16px; + color: var(--vscode-descriptionForeground); +} + +/* ------------------------------------------------------------------ badges */ + +.badge { + border: 1px solid; + border-color: currentColor; + border-radius: 999px; + padding: 0 6px; + font-size: 0.85em; + white-space: nowrap; +} + +.badge-modified { + color: var(--vscode-charts-blue, var(--vscode-foreground)); +} + +.badge-deprecated { + color: var(--vscode-editorWarning-foreground, var(--vscode-foreground)); +} + +.badge-invalid { + color: var(--vscode-errorForeground, var(--vscode-foreground)); +} + +.badge-source { + color: var(--vscode-descriptionForeground); +} + +/* A hand-edited value the registry rejects: the control is visible but closed, + and the dashed border says "not editable here" without relying on colour. */ +[data-control][disabled] { + border-style: dashed; + opacity: 0.85; +} diff --git a/mide-plugins/vscode/package-lock.json b/mide-plugins/vscode/package-lock.json index 19d5e66..8209f00 100644 --- a/mide-plugins/vscode/package-lock.json +++ b/mide-plugins/vscode/package-lock.json @@ -1,88 +1,34 @@ { "name": "mcpp-vscode", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "1.0.1", + "version": "1.1.0", "license": "Apache-2.0", "devDependencies": { - "@types/jsdom": "^27.0.0", + "@mermaid-js/tiny": "12.2.0", + "@noble/hashes": "^1.8.0", + "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", - "@types/vscode": "~1.91.0", + "@types/vscode": "^1.91.0", + "@vscode/codicons": "0.0.46-24", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", - "jsdom": "^27.0.0", - "typescript": "^5.9.0" + "buffer": "^6.0.3", + "esbuild": "^0.25.12", + "mocha": "^11.8.0", + "ovsx": "^1.2.0", + "playwright": "1.64.1", + "typescript": "^5.9.0", + "vscode-languageclient": "9.0.1" }, "engines": { "vscode": "^1.91.0" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.32", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz", - "integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", - "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^3.0.0", - "@csstools/css-color-parser": "^4.0.1", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -129,9 +75,9 @@ } }, "node_modules/@azure/core-client": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", - "integrity": "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.0.tgz", + "integrity": "sha512-JjQWO6akOck45PH/XBrxzsQGAiKrfFl4m5iggJ0ItMIz5omRufOXWpqCPpdjKN3vKDzlSUvFjaMb7Zwf0gvAdA==", "dev": true, "license": "MIT", "dependencies": { @@ -147,16 +93,6 @@ "node": ">=22.0.0" } }, - "node_modules/@azure/core-process": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", - "integrity": "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/@azure/core-rest-pipeline": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", @@ -205,27 +141,26 @@ } }, "node_modules/@azure/identity": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.14.0.tgz", - "integrity": "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", + "integrity": "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw==", "dev": true, "license": "MIT", "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", - "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", - "@azure/msal-node": "^6.0.0", - "open": "^11.0.0", + "@azure/msal-node": "^5.1.0", + "open": "^10.1.0", "tslib": "^2.2.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/logger": { @@ -243,22 +178,22 @@ } }, "node_modules/@azure/msal-browser": { - "version": "5.26.0", - "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.26.0.tgz", - "integrity": "sha512-GYKRquzDZmZggCdStWy2bNJKnPui1upssSuznJgP+/h6Mb0ldkUkaGvaY6uSGGmZOm0CGiBQ3b5BiGy43BpXSg==", + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.17.3.tgz", + "integrity": "sha512-qMabD7Xrm/UgRhs+/IVyCTZRjUl8Qb+uGsRrCkaKNWsiTwRaeyStJbChE7/ySNTNYtiWo7khfF7vUDd0wGMnLw==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.14.3" + "@azure/msal-common": "16.11.3" }, "engines": { "node": ">=0.8.0" } }, "node_modules/@azure/msal-common": { - "version": "16.14.3", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.3.tgz", - "integrity": "sha512-tz3GvMrR9V/plb2lDdW8qaO8CPpfZevdExBKjltzzbAtyhK0eB2UIvji1aU+gkyHj3fxQ2ie+LNHyMXdmo8wOg==", + "version": "16.11.3", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.11.3.tgz", + "integrity": "sha512-VeXOW+t3Rdd9XGX6lVyIg3DhtjMR1JD8ARKcsnGbJFUWwAmF3sHL7GwZc/ZjEUfHESResAonETRYCuG06OBT7A==", "dev": true, "license": "MIT", "engines": { @@ -266,29 +201,19 @@ } }, "node_modules/@azure/msal-node": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", - "integrity": "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ==", + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-5.4.3.tgz", + "integrity": "sha512-tumCMmzrRhKmTbYQg/7OlfbrIKcKaf8Ed0Fw3suUpRT3owFYljznVgxcfHe8RycQXY9uyROGiLD1GjhpF45AwA==", "dev": true, "license": "MIT", "dependencies": { - "@azure/msal-common": "16.14.1", + "@azure/msal-common": "16.11.3", "jsonwebtoken": "^9.0.0" }, "engines": { "node": ">=20" } }, - "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { - "version": "16.14.1", - "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", - "integrity": "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -314,162 +239,1468 @@ "node": ">=6.9.0" } }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", - "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "aix" ], - "license": "MIT-0", "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, - "node_modules/@csstools/css-calc": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", - "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.2", - "@csstools/css-tokenizer": "^4.0.2" + "node": ">=18" } }, - "node_modules/@csstools/css-color-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", - "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.2", - "@csstools/css-calc": "^3.4.3" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.2", - "@csstools/css-tokenizer": "^4.0.2" + "node": ">=18" } }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", - "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.2" + "node": ">=18" } }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", - "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "freebsd" ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@inquirer/core/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@mermaid-js/tiny": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/tiny/-/tiny-12.2.0.tgz", + "integrity": "sha512-9bEE+R974NTh1YJlfc6ztgwhy6+9sE+wAFiFrqvSdtlJHdxGfLytQmHYHNpFKmj94+Z+uMicYrQeGXnTTjILAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-rs/crc32": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32/-/crc32-1.10.8.tgz", + "integrity": "sha512-gOYKFwkojSdWrSmreCcGocRcAXSpRkNZYxv6nscmtddSWBKVGypdMbfLxu5qgmymdaCNApajuwZblYEfs7HswA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/crc32-android-arm-eabi": "1.10.8", + "@node-rs/crc32-android-arm64": "1.10.8", + "@node-rs/crc32-darwin-arm64": "1.10.8", + "@node-rs/crc32-darwin-x64": "1.10.8", + "@node-rs/crc32-freebsd-x64": "1.10.8", + "@node-rs/crc32-linux-arm-gnueabihf": "1.10.8", + "@node-rs/crc32-linux-arm64-gnu": "1.10.8", + "@node-rs/crc32-linux-arm64-musl": "1.10.8", + "@node-rs/crc32-linux-x64-gnu": "1.10.8", + "@node-rs/crc32-linux-x64-musl": "1.10.8", + "@node-rs/crc32-win32-arm64-msvc": "1.10.8", + "@node-rs/crc32-win32-ia32-msvc": "1.10.8", + "@node-rs/crc32-win32-x64-msvc": "1.10.8" + } + }, + "node_modules/@node-rs/crc32-android-arm-eabi": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm-eabi/-/crc32-android-arm-eabi-1.10.8.tgz", + "integrity": "sha512-Ed2P9uQAOlOSveuPKSlmCctcZ73+9VIBTD84F8MjfKU26q33Y0hYlcl9vlzfc9UQX7LLMGEZhOuU1xxYtx49tg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-android-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-android-arm64/-/crc32-android-arm64-1.10.8.tgz", + "integrity": "sha512-gvEOdU8tkSwK2McdlMeXDoyqtvJFPYShNopJtGaGMNoltN5EzFOsM/7XKF3rk9JLQ9GnZHQrCxur0Cksonow2A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-arm64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-arm64/-/crc32-darwin-arm64-1.10.8.tgz", + "integrity": "sha512-ylDspj/s3i+FCdTGoLZtOBsbR2PvAsI5taJDE5Pl7kDgeKX7IywdAYQtkh2orvTs0RqCjcnIBxrCDI/O/r+z1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-darwin-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-darwin-x64/-/crc32-darwin-x64-1.10.8.tgz", + "integrity": "sha512-DVTORLWomcx7F0CxQxlfBszAcMh8IkNGx/hgYHQ4iDnuf/uVjhs7l1hoVBghPymdFxP129+I/+b1WoQBbTJzaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-freebsd-x64": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-freebsd-x64/-/crc32-freebsd-x64-1.10.8.tgz", + "integrity": "sha512-ZzuW7RS9VFy7zD64YUFI2dK6tJrlAFfe1NBuNx/ouKZ6SK+NHW5F8hxZNrZCkv2XB7QyZk9wZ53oJ20PKylQTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm-gnueabihf": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm-gnueabihf/-/crc32-linux-arm-gnueabihf-1.10.8.tgz", + "integrity": "sha512-5/2kW7V4KMV3o37ApvcnF4Pk11luzLnd38kQZowVCw5D/xGlCByEQOODfJHX9pv0C+4hp+8Dosul06RODjPEUw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-gnu/-/crc32-linux-arm64-gnu-1.10.8.tgz", + "integrity": "sha512-Vr1P3+WF+RsUVI8CNLiunl1vuzdpgTR1sf9PxgQFr3o//hh2KdqJeU4zZsFVTn+GbGZtqvRoba0wi8nKMzRLeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-arm64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-arm64-musl/-/crc32-linux-arm64-musl-1.10.8.tgz", + "integrity": "sha512-eS8YXANJLBqdOzf5jCNVEXiDI4wjXVqQybaJextJJINvHhnhhOXPar8402GMagewOxGxYsy4C69G8rBIzqL4cg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-gnu": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-gnu/-/crc32-linux-x64-gnu-1.10.8.tgz", + "integrity": "sha512-DFd0eV47MAykz4PQDcBSlEvY3CtXqPt2gkBpvPngCpOUoIJgEk9tKtPjEgUwLatMkvsy4fEvAg7gckGickOyEw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-linux-x64-musl": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-linux-x64-musl/-/crc32-linux-x64-musl-1.10.8.tgz", + "integrity": "sha512-U7fP8FjDQuOb61YoT8EoWDIJ9x40ZrCzM6OcAH1wqYiu9Z94U14S+i8KwTC7YorotGtICdYZ/APn1eI8vNne5w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" } }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", - "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "node_modules/@node-rs/crc32-win32-arm64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-arm64-msvc/-/crc32-win32-arm64-msvc-1.10.8.tgz", + "integrity": "sha512-VJezyT9OWnMPnArDR5ok0ARLemiL83LLNYh4MC++oHoLegqzyBllqNlO8fVp2PmczGkn+rfKkZbtG4w3MQ6BbQ==", + "cpu": [ + "arm64" + ], "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/crc32-win32-ia32-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-ia32-msvc/-/crc32-win32-ia32-msvc-1.10.8.tgz", + "integrity": "sha512-01HooQK7WiG07/vvp0XzRnqSC5cvXvQ1MsQwlH9wwMWZ9qqHRO1j08cSeSt7DM4eALNisfX83bjqqrHzSKI9Iw==", + "cpu": [ + "ia32" + ], + "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=20.19.0" + "node": ">= 10" } }, - "node_modules/@exodus/bytes": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", - "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "node_modules/@node-rs/crc32-win32-x64-msvc": { + "version": "1.10.8", + "resolved": "https://registry.npmjs.org/@node-rs/crc32-win32-x64-msvc/-/crc32-win32-x64-msvc-1.10.8.tgz", + "integrity": "sha512-4O6G7yme7s98LUGwiqW7q2QqYmWD2IRQvV+q7+S4vjkUWiXUMbP8QEPYP9cF2WEQSdXCkyDEqDW10zeUdniPRA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } + "node": ">= 10" } }, "node_modules/@nodelib/fs.scandir": { @@ -510,6 +1741,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@secretlint/config-creator": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/@secretlint/config-creator/-/config-creator-10.2.2.tgz", @@ -698,35 +1940,33 @@ } }, "node_modules/@textlint/ast-node-types": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.8.0.tgz", - "integrity": "sha512-5CiH9COYmovWmExQgs7763DzX6Gy9zjkjJ7JxCC95wyTcjwQn/8poNF6fv3qzRlmx8CRRde8DHr9FcgAAiPzgw==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.8.0.tgz", - "integrity": "sha512-+oU3A235NATv6Lzi4xa4kJ65PuNJlIxesaO4AvDhDWA9FWm7y4XKWaoQCW1esgaQQ6dwnUiFKArQ8TcJ86mC4w==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.8.0", - "@textlint/resolver": "15.8.0", - "@textlint/types": "15.8.0", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", + "chalk": "^4.1.2", "debug": "^4.4.3", - "js-yaml": "^4.3.0", + "js-yaml": "^4.1.1", "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "table": "^6.9.0", "text-table": "^0.2.0" - }, - "engines": { - "node": ">=20.18.0" } }, "node_modules/@textlint/linter-formatter/node_modules/ansi-regex": { @@ -760,49 +2000,44 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.8.0.tgz", - "integrity": "sha512-rt+OR1WYGoLOY8HkA/aBPrqufF6yUUEsKEAh7XohTsT3lp9IyZFT6zOIbjul9P4FAzsmSPkcrYjVx3Bz/IUfkg==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.8.0.tgz", - "integrity": "sha512-E88tzfX3K8Jykk+38aJ9cy8RquD8ABVOPTO2rFEESq0wcg8x6/ypdAS8ZgR7OKiGqlRF0hkO/m5PbQwVfKM3VA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.8.0", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.8.0.tgz", - "integrity": "sha512-Anhc6y5736YIsvqae0U6k0YmB2M/QVHkEeOv2aydAn/WIkdI69dCOiDbe3/+RagS3qstFTSFWJzNRA2lUjv19w==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.8.0" + "@textlint/ast-node-types": "15.7.1" } }, - "node_modules/@types/jsdom": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-27.0.0.tgz", - "integrity": "sha512-NZyFl/PViwKzdEkQg96gtnB8wm+1ljhdDay9ahn4hgb+SfVtPCbm3TlmDUFXTA+MGN3CijicnMhG18SI5H3rFw==", + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/tough-cookie": "*", - "parse5": "^7.0.0" - } + "license": "MIT" }, "node_modules/@types/node": { - "version": "24.19.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", - "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~7.18.0" } }, "node_modules/@types/normalize-package-data": { @@ -819,24 +2054,17 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/tough-cookie": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", - "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/vscode": { - "version": "1.91.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.91.0.tgz", - "integrity": "sha512-PgPr+bUODjG3y+ozWUCyzttqR9EHny9sPAfJagddQjDwdtf66y2sDKJMnFZRuzBA2YtBGASqJGPil8VDUPvO6A==", + "version": "1.125.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.125.0.tgz", + "integrity": "sha512-0icm/ZQAaism87P0ekHqi4/Ju9du+Tm0RUW+y7vqRsxY2cY0FNRX1nAnaW7nT6npPt2tfHiheZ55Zm9UhqonFA==", "dev": true, "license": "MIT" }, "node_modules/@typespec/ts-http-runtime": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", - "integrity": "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.8.tgz", + "integrity": "sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==", "dev": true, "license": "MIT", "dependencies": { @@ -848,6 +2076,13 @@ "node": ">=22.0.0" } }, + "node_modules/@vscode/codicons": { + "version": "0.0.46-24", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-24.tgz", + "integrity": "sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==", + "dev": true, + "license": "CC-BY-4.0" + }, "node_modules/@vscode/test-electron": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-3.1.0.tgz", @@ -913,9 +2148,9 @@ } }, "node_modules/@vscode/vsce-sign": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.1.0.tgz", - "integrity": "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@vscode/vsce-sign/-/vsce-sign-2.0.9.tgz", + "integrity": "sha512-8IvaRvtFyzUnGGl3f5+1Cnor3LqaUWvhaUjAYO8Y39OUYlOf3cRd+dowuQYLpZcP3uwSG+mURwjEBOSq4SOJ0g==", "dev": true, "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", @@ -1101,9 +2336,9 @@ } }, "node_modules/ansi-regex": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", - "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, "license": "MIT", "engines": { @@ -1193,18 +2428,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "optional": true - }, - "node_modules/bidi-js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", - "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } + "license": "MIT" }, "node_modules/binaryextensions": { "version": "6.11.0", @@ -1235,20 +2459,30 @@ "readable-stream": "^3.4.0" } }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", "optional": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" } }, "node_modules/boolbase": { @@ -1266,9 +2500,9 @@ "license": "BSD-2-Clause" }, "node_modules/brace-expansion": { - "version": "5.0.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -1291,10 +2525,17 @@ "node": ">=8" } }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dev": true, "funding": [ { @@ -1311,10 +2552,9 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-crc32": { @@ -1335,9 +2575,9 @@ "license": "BSD-3-Clause" }, "node_modules/bundle-name": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", - "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -1381,6 +2621,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1398,6 +2651,13 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, "node_modules/cheerio": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", @@ -1442,6 +2702,22 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -1450,6 +2726,13 @@ "license": "ISC", "optional": true }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1479,6 +2762,54 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -1539,6 +2870,41 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-keychain": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cross-keychain/-/cross-keychain-1.1.0.tgz", + "integrity": "sha512-244DWNdGepLKD5vEn3reZqwzZFiE/LD4U+XV9IaXQbtIXKvQkf0VkRaOj/9vPYauPdR12PSGB3U0cE7jJi3WTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.8.6", + "meow": "^14.0.0" + }, + "bin": { + "cross-keychain": "dist/cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@napi-rs/keyring": "^1.2.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1556,20 +2922,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -1583,56 +2935,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/data-urls": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", - "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^15.1.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls/node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1651,12 +2953,18 @@ } } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/decompress-response": { "version": "6.0.0", @@ -1687,9 +2995,9 @@ } }, "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1713,7 +3021,25 @@ "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/define-lazy-prop": { @@ -1729,6 +3055,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1750,6 +3094,16 @@ "node": ">=8" } }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1824,6 +3178,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -1958,6 +3319,71 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expand-template": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", @@ -1994,9 +3420,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", - "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", "dev": true, "funding": [ { @@ -2011,9 +3437,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "dev": true, "license": "ISC", "dependencies": { @@ -2033,6 +3459,71 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz", + "integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -2059,9 +3550,9 @@ "optional": true }, "node_modules/fs-extra": { - "version": "11.4.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", - "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -2083,10 +3574,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", - "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -2174,6 +3675,23 @@ "node": ">= 6" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/globby": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz", @@ -2225,6 +3743,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2267,6 +3798,16 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -2280,19 +3821,6 @@ "node": ">=10" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -2386,13 +3914,12 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { - "version": "7.0.14", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.14.tgz", - "integrity": "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -2434,6 +3961,19 @@ "license": "ISC", "optional": true }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -2483,19 +4023,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -2528,6 +4055,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-it-type": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/is-it-type/-/is-it-type-5.1.3.tgz", + "integrity": "sha512-AX2uU0HW+TxagTgQXOJY7+2fbFHemC7YFBwN1XqD8qQMKdtfbOC8OC3fUb4s5NU59a3662Dzwto8tWDdZYRXxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globalthis": "^1.0.2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -2538,21 +4078,34 @@ "node": ">=0.12.0" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/is-unicode-supported": { + "node_modules/is-plain-obj": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2581,6 +4134,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/istextorbinary": { "version": "9.5.0", "resolved": "https://registry.npmjs.org/istextorbinary/-/istextorbinary-9.5.0.tgz", @@ -2599,6 +4159,22 @@ "url": "https://bevry.me/fund" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2607,9 +4183,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, "funding": [ { @@ -2629,72 +4205,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdom": { - "version": "27.4.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", - "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.28", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.6.0", - "cssstyle": "^5.3.4", - "data-urls": "^6.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.0", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^15.1.0", - "ws": "^8.18.3", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/entities": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -2758,17 +4268,50 @@ "npm": ">=6" } }, - "node_modules/jszip": { - "version": "3.10.2", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", - "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "(MIT OR GPL-3.0-or-later)", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" + "safe-buffer": "~5.1.0" } }, "node_modules/jwa": { @@ -2847,6 +4390,22 @@ "uc.micro": "^2.0.0" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2911,43 +4470,17 @@ "license": "MIT" }, "node_modules/log-symbols": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", - "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^5.3.0", - "is-unicode-supported": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-symbols/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/log-symbols/node_modules/is-unicode-supported": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", - "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -2967,9 +4500,9 @@ } }, "node_modules/markdown-it": { - "version": "14.3.3", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", - "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", "dev": true, "funding": [ { @@ -3004,13 +4537,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/mdurl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", @@ -3018,6 +4544,19 @@ "dev": true, "license": "MIT" }, + "node_modules/meow": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-14.1.0.tgz", + "integrity": "sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3150,6 +4689,151 @@ "license": "MIT", "optional": true }, + "node_modules/mocha": { + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.8.0.tgz", + "integrity": "sha512-VyCeUdGN3A9lmCTTgG4yuvY9ixxaDk+xt2R/7/+1AP6EqNG+G9OKkzBwhVtVYoNX8YsxNSgAl8mOv3IAeOpFbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mocha/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3173,9 +4857,9 @@ "optional": true }, "node_modules/node-abi": { - "version": "3.101.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.101.0.tgz", - "integrity": "sha512-bkb/RkuWCE64eWu1PCh3Fu2pGc9mKRs+kOE+FU2bGHRYlQa86/D321DSATV54AsCQMKz3/xSt9sRgraZTpqdhQ==", + "version": "3.94.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", + "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", "dev": true, "license": "MIT", "optional": true, @@ -3269,6 +4953,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3290,99 +4984,207 @@ "mimic-function": "^5.0.0" }, "engines": { - "node": ">=18" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/open": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", - "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", "dependencies": { - "default-browser": "^5.5.1", - "define-lazy-prop": "^3.0.0", - "is-in-ssh": "^1.0.0", - "is-inside-container": "^1.0.0", - "powershell-utils": "^0.2.1", - "wsl-utils": "^1.0.0" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", - "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "node_modules/ovsx": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ovsx/-/ovsx-1.2.0.tgz", + "integrity": "sha512-12mauBqsLehlJ0cRiroQz4uKYVjblVS5OobgSJ278CdCtkDfRQD+SI3NQ00GNalDqzp5PDN11yx84miFUojeMw==", "dev": true, - "license": "MIT", + "license": "EPL-2.0", "dependencies": { - "chalk": "^5.3.0", - "cli-cursor": "^5.0.0", - "cli-spinners": "^2.9.2", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.0.0", - "log-symbols": "^6.0.0", - "stdin-discarder": "^0.2.2", - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0" + "@inquirer/prompts": "^7.10.1", + "@vscode/vsce": "^3.7.1", + "commander": "^6.2.1", + "cross-keychain": "^1.1.0", + "follow-redirects": "^1.16.0", + "is-ci": "^2.0.0", + "leven": "^3.1.0", + "semver": "^7.6.0", + "tmp": "^0.2.3", + "yauzl-promise": "^4.0.0" }, - "engines": { - "node": ">=18" + "bin": { + "ovsx": "bin/ovsx" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=22.0.0" } }, - "node_modules/ora/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/ovsx/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, "license": "MIT", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "node": ">= 6" } }, - "node_modules/ora/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/ora/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" + "p-limit": "^3.0.2" }, "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-map": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", - "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.6.tgz", + "integrity": "sha512-I4Prw6ivkd6p8PiYR1tXASOAOBzIJwu0TB7fqaX0c/8c3QAehNYmX57EijyGGGBt3c/BIowGwV03RVBtXvHEVg==", "dev": true, "license": "MIT", "engines": { @@ -3392,6 +5194,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -3490,6 +5299,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -3508,9 +5337,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.5.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", - "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -3557,6 +5386,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -3567,19 +5425,6 @@ "node": ">=4" } }, - "node_modules/powershell-utils": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", - "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -3628,16 +5473,6 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -3649,9 +5484,9 @@ } }, "node_modules/qs": { - "version": "6.17.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.0.tgz", - "integrity": "sha512-5LK5BZ2XDHMvcUJaf0CQ5HL3aVgKbpklvByC7qLSxYkfE8COEEbJg5xCHWCTqzqi0Gs4Jn5Mv5v3R38W6edscA==", + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3686,6 +5521,16 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -3763,27 +5608,44 @@ } }, "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" } }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, "node_modules/require-from-string": { "version": "2.0.2", @@ -3898,19 +5760,6 @@ "node": ">=11.0.0" } }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, "node_modules/secretlint": { "version": "10.2.2", "resolved": "https://registry.npmjs.org/secretlint/-/secretlint-10.2.2.tgz", @@ -3946,6 +5795,16 @@ "node": ">=10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -3953,6 +5812,29 @@ "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -4091,6 +5973,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/simple-invariant": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/simple-invariant/-/simple-invariant-2.0.1.tgz", + "integrity": "sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -4122,16 +6014,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/source-map-js": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", - "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/spdx-correct": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", @@ -4162,9 +6044,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.24", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", - "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", "dev": true, "license": "CC0-1.0" }, @@ -4182,23 +6064,33 @@ } }, "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "safe-buffer": "~5.1.0" + "safe-buffer": "~5.2.0" } }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } }, - "node_modules/string-width": { + "node_modules/string-width-cjs": { + "name": "string-width", "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", @@ -4213,6 +6105,29 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string-width/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4252,6 +6167,30 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -4303,13 +6242,6 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, "node_modules/table": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", @@ -4382,22 +6314,6 @@ "node": ">=6" } }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -4438,26 +6354,6 @@ "url": "https://bevry.me/fund" } }, - "node_modules/tldts": { - "version": "7.4.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", - "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.19" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.19.tgz", - "integrity": "sha512-goNXV6Ho9TIOfpJZanTd6CKG5DyYgYAL+k+22T0SLh8l6+KHIfBUPFuePuSZv05UttIaaLmI9tb7TmGIdK5AkQ==", - "dev": true, - "license": "MIT" - }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -4481,32 +6377,6 @@ "node": ">=8.0" } }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4592,9 +6462,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.30.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", - "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -4602,9 +6472,9 @@ } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, @@ -4669,29 +6539,79 @@ "url": "https://bevry.me/fund" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", "dev": true, "license": "MIT", "dependencies": { - "xml-name-validator": "^5.0.0" + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" }, "engines": { - "node": ">=18" + "vscode": "^1.82.0" } }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "node_modules/vscode-languageclient/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT" + }, + "node_modules/vscode-languageclient/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/vscode-languageclient/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { - "node": ">=20" + "node": ">=10" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" } }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -4716,75 +6636,134 @@ "node": ">=18" } }, - "node_modules/whatwg-url": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", - "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.0" + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">=20" + "node": ">= 8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", "dev": true, - "license": "ISC", - "optional": true + "license": "Apache-2.0" }, - "node_modules/ws": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", - "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=10.0.0" + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" + "engines": { + "node": ">=10" }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wsl-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", - "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, "license": "MIT", "dependencies": { - "is-wsl": "^3.1.0", - "powershell-utils": "^0.2.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=20" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/xml2js": { @@ -4811,12 +6790,15 @@ "node": ">=4.0" } }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { "version": "4.0.0", @@ -4825,6 +6807,51 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", @@ -4838,6 +6865,21 @@ "node": ">=12" } }, + "node_modules/yauzl-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yauzl-promise/-/yauzl-promise-4.0.0.tgz", + "integrity": "sha512-/HCXpyHXJQQHvFq9noqrjfa/WpQC2XYs3vI7tBiAi4QiIU1knvYhZGaO1QPjwIVMdqflxbmwgMXtYeaRiAE0CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@node-rs/crc32": "^1.7.0", + "is-it-type": "^5.1.2", + "simple-invariant": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/yazl": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/yazl/-/yazl-2.5.1.tgz", @@ -4847,6 +6889,32 @@ "dependencies": { "buffer-crc32": "~0.2.3" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/mide-plugins/vscode/package.json b/mide-plugins/vscode/package.json index 58297ee..34fd4d0 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.0.1", + "version": "1.1.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", @@ -10,47 +10,1206 @@ "type": "git", "url": "https://github.com/mcpp-community/mcpp-ide.git" }, - "homepage": "https://github.com/mcpp-community/mcpp-ide#readme", + "homepage": "https://github.com/mcpp-community/mcpp-ide/tree/main/mide-plugins/vscode#readme", "bugs": { "url": "https://github.com/mcpp-community/mcpp-ide/issues" }, "engines": { "vscode": "^1.91.0" }, - "extensionPack": [ + "extensionDependencies": [ "sunrisepeak.mcpp-language-server" ], + "activationEvents": [ + "workspaceContains:mcpp.toml", + "onLanguage:mcpp-toml", + "onDebugResolve:mcpp", + "onDebugDynamicConfigurations:mcpp" + ], "categories": [ "Programming Languages", "Debuggers", "Testing", "Other" ], - "keywords": [ - "mcpp", - "C++", - "C++23", - "modules", - "build" - ], - "activationEvents": [ - "workspaceContains:**/mcpp.toml", - "onLanguage:mcpp-toml", - "onDebugResolve:mcpp", - "onDebugDynamicConfigurations:mcpp" - ], - "main": "./out/src/extension.js", "capabilities": { "untrustedWorkspaces": { "supported": "limited", "description": "%untrustedWorkspaces.description%", "restrictedConfigurations": [ "mcpp.path", - "mcpp.ide.backendPath" + "mcpp.clangd.path" ] } }, + "main": "./dist/src/extension.js", "contributes": { + "menus": { + "editor/context": [ + { + "command": "mcpp.build", + "group": "mcpp@3", + "when": "mcpp.inProject && editorLangId == cpp" + }, + { + "command": "mcpp.run", + "group": "mcpp@1", + "when": "mcpp.inProject && editorLangId == cpp" + }, + { + "command": "mcpp.debug", + "group": "mcpp@2", + "when": "mcpp.inProject && editorLangId == cpp" + }, + { + "command": "mcpp.test", + "group": "mcpp@4", + "when": "mcpp.inProject && editorLangId == cpp" + } + ], + "editor/title": [ + { + "command": "mcpp.editor.build", + "group": "navigation@0", + "when": "mcpp.inProject && mcpp.editorTitleButtons" + }, + { + "command": "mcpp.run", + "group": "navigation@1", + "when": "mcpp.inProject && mcpp.editorTitleButtons" + }, + { + "command": "mcpp.debug", + "group": "navigation@3", + "when": "mcpp.inProject && mcpp.editorTitleButtons" + }, + { + "command": "mcpp.test", + "group": "navigation@2", + "when": "mcpp.inProject && mcpp.editorTitleButtons" + } + ], + "view/title": [ + { + "command": "mcpp.refreshCacheStats", + "group": "navigation@2", + "when": "view == mcpp.cache" + }, + { + "command": "mcpp.library.updateIndex", + "group": "navigation@2", + "when": "view == mcpp.library" + } + ], + "view/item/context": [ + { + "command": "mcpp.project.showDependencyGraph", + "group": "inline@1", + "when": "view == mcpp.project && viewItem == mcppProjectDependencies" + }, + { + "command": "mcpp.languageServer.restoreConflicts", + "when": "view == mcpp.project && viewItem =~ /^mcppOtherCppFeatures.*Restorable$/" + } + ], + "commandPalette": [ + { + "command": "mcpp.editor.build", + "when": "false" + }, + { + "command": "mcpp.debug", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.pack", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.cancelJob", + "when": "mcpp.jobRunning" + }, + { + "command": "mcpp.openMcpplsSettings", + "when": "false" + } + ] + }, + "commands": [ + { + "command": "mcpp.build", + "title": "%command.mcpp.build.title%", + "category": "%category%" + }, + { + "command": "mcpp.editor.build", + "title": "%command.mcpp.editor.build.title%", + "category": "%category%", + "icon": "$(tools)" + }, + { + "command": "mcpp.run", + "title": "%command.mcpp.run.title%", + "category": "%category%", + "icon": "$(play)" + }, + { + "command": "mcpp.debug", + "title": "%command.mcpp.debug.title%", + "category": "%category%", + "icon": "$(debug-alt)" + }, + { + "command": "mcpp.test", + "title": "%command.mcpp.test.title%", + "category": "%category%", + "icon": "$(beaker)" + }, + { + "command": "mcpp.clean", + "title": "%command.mcpp.clean.title%", + "category": "%category%" + }, + { + "command": "mcpp.pack", + "title": "%command.mcpp.pack.title%", + "category": "%category%", + "icon": "$(package)" + }, + { + "command": "mcpp.cancelJob", + "title": "%command.mcpp.cancelJob.title%", + "category": "%category%", + "icon": "$(debug-stop)" + }, + { + "command": "mcpp.selectTarget", + "title": "%command.mcpp.selectTarget.title%", + "category": "%category%", + "icon": "$(target)" + }, + { + "command": "mcpp.showTargets", + "title": "%command.mcpp.showTargets.title%", + "category": "%category%", + "icon": "$(server-environment)" + }, + { + "command": "mcpp.showToolchains", + "title": "%command.mcpp.showToolchains.title%", + "category": "%category%" + }, + { + "command": "mcpp.installToolchain", + "title": "%command.mcpp.installToolchain.title%", + "category": "%category%" + }, + { + "command": "mcpp.selectDefaultToolchain", + "title": "%command.mcpp.selectDefaultToolchain.title%", + "category": "%category%" + }, + { + "command": "mcpp.newProject", + "title": "%command.mcpp.newProject.title%", + "category": "%category%" + }, + { + "command": "mcpp.showMenu", + "title": "%command.mcpp.showMenu.title%", + "category": "%category%" + }, + { + "command": "mcpp.openSettings", + "title": "%command.mcpp.openSettings.title%", + "category": "%category%" + }, + { + "command": "mcpp.selfCheck", + "title": "%command.mcpp.selfCheck.title%", + "category": "%category%" + }, + { + "command": "mcpp.exportReport", + "title": "%command.mcpp.exportReport.title%", + "category": "%category%" + }, + { + "command": "mcpp.autoConfigureModules", + "title": "%command.mcpp.autoConfigureModules.title%", + "category": "%category%" + }, + { + "command": "mcpp.refreshCompilationDatabase", + "title": "%command.mcpp.refreshCompilationDatabase.title%", + "category": "%category%" + }, + { + "command": "mcpp.refreshCacheStats", + "title": "%command.mcpp.refreshCacheStats.title%", + "category": "%category%" + }, + { + "command": "mcpp.showCachePanel", + "title": "%command.mcpp.showCachePanel.title%", + "category": "%category%" + }, + { + "command": "mcpp.showCacheEntry", + "title": "%command.mcpp.showCacheEntry.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanStaleArtifacts", + "title": "%command.mcpp.cleanStaleArtifacts.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanProjectArtifacts", + "title": "%command.mcpp.cleanProjectArtifacts.title%", + "category": "%category%" + }, + { + "command": "mcpp.gcGlobalCache", + "title": "%command.mcpp.gcGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.pruneGlobalCache", + "title": "%command.mcpp.pruneGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.verifyGlobalCache", + "title": "%command.mcpp.verifyGlobalCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.cleanLegacyCache", + "title": "%command.mcpp.cleanLegacyCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.library.search", + "title": "%command.mcpp.library.search.title%", + "category": "%category%", + "icon": "$(cloud-download)" + }, + { + "command": "mcpp.library.openDetail", + "title": "%command.mcpp.library.openDetail.title%", + "category": "%category%" + }, + { + "command": "mcpp.library.updateIndex", + "title": "%command.mcpp.library.updateIndex.title%", + "category": "%category%", + "icon": "$(cloud-download)" + }, + { + "command": "mcpp.project.showDependencyGraph", + "title": "%command.mcpp.project.showDependencyGraph.title%", + "category": "%category%", + "icon": "$(type-hierarchy)" + }, + { + "command": "mcpp.project.showModuleDependencyGraph", + "title": "%command.mcpp.project.showModuleDependencyGraph.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.refreshState", + "title": "%command.mcpp.languageServer.refreshState.title%", + "category": "%category%" + }, + { + "command": "mcpp.openMcpplsSettings", + "title": "%command.mcpp.openMcpplsSettings.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.restart", + "title": "%command.mcpp.languageServer.restart.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.restartEngine", + "title": "%command.mcpp.languageServer.restartEngine.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.resetWorkspaceCache", + "title": "%command.mcpp.languageServer.resetWorkspaceCache.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.selectContext", + "title": "%command.mcpp.languageServer.selectContext.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.showModuleGraph", + "title": "%command.mcpp.languageServer.showModuleGraph.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.showLogs", + "title": "%command.mcpp.languageServer.showLogs.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.collectReport", + "title": "%command.mcpp.languageServer.collectReport.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.exportDiagnosticBundle", + "title": "%command.mcpp.languageServer.exportDiagnosticBundle.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.revealBundle", + "title": "%command.mcpp.languageServer.revealBundle.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.openLogFolder", + "title": "%command.mcpp.languageServer.openLogFolder.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.runBuildToolInTerminal", + "title": "%command.mcpp.languageServer.runBuildToolInTerminal.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.manageConflicts", + "title": "%command.mcpp.languageServer.manageConflicts.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.restoreConflicts", + "title": "%command.mcpp.languageServer.restoreConflicts.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.toggleInWorkspace", + "title": "%command.mcpp.languageServer.toggleInWorkspace.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.installTools", + "title": "%command.mcpp.languageServer.installTools.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.installKit", + "title": "%command.mcpp.languageServer.installKit.title%", + "category": "%category%" + }, + { + "command": "mcpp.languageServer.reviewChanges", + "title": "%command.mcpp.languageServer.reviewChanges.title%", + "category": "%category%" + }, + { + "command": "mcpp.configureLanguageServer", + "title": "%command.mcpp.configureLanguageServer.title%", + "category": "%category%" + }, + { + "command": "mcpp.configureClangd", + "title": "%command.mcpp.configureClangd.title%", + "category": "%category%" + }, + { + "command": "mcpp.checkModuleSupport", + "title": "%command.mcpp.checkModuleSupport.title%", + "category": "%category%" + }, + { + "command": "mcpp.showModuleGraph", + "title": "%command.mcpp.showModuleGraph.title%", + "category": "%category%" + }, + { + "command": "mcpp.showLanguageServerLogs", + "title": "%command.mcpp.showLanguageServerLogs.title%", + "category": "%category%" + } + ], + "configuration": { + "title": "%mcpp.configuration.title%", + "properties": { + "mcpp.path": { + "type": "string", + "default": "", + "scope": "resource", + "order": 10010, + "description": "%mcpp.path.title%", + "markdownDescription": "%mcpp.path.description%" + }, + "mcpp.project.discoveryBoundary": { + "type": "string", + "default": "workspaceFolder", + "enum": [ + "workspaceFolder", + "filesystem" + ], + "scope": "resource", + "order": 10020, + "description": "%mcpp.project.discoveryBoundary.title%", + "markdownDescription": "%mcpp.project.discoveryBoundary.description%" + }, + "mcpp.task.clearTerminal": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20001, + "description": "%mcpp.task.clearTerminal.title%", + "markdownDescription": "%mcpp.task.clearTerminal.description%" + }, + "mcpp.task.buildArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20010, + "description": "%mcpp.task.buildArgs.title%", + "markdownDescription": "%mcpp.task.buildArgs.description%" + }, + "mcpp.task.runArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20020, + "description": "%mcpp.task.runArgs.title%", + "markdownDescription": "%mcpp.task.runArgs.description%" + }, + "mcpp.task.testArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20030, + "description": "%mcpp.task.testArgs.title%", + "markdownDescription": "%mcpp.task.testArgs.description%" + }, + "mcpp.task.cleanArgs": { + "type": "array", + "default": [], + "items": { + "type": "string" + }, + "scope": "resource", + "order": 20040, + "description": "%mcpp.task.cleanArgs.title%", + "markdownDescription": "%mcpp.task.cleanArgs.description%" + }, + "mcpp.task.confirmClean": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20050, + "description": "%mcpp.task.confirmClean.title%", + "markdownDescription": "%mcpp.task.confirmClean.description%" + }, + "mcpp.task.revealTerminal": { + "type": "string", + "default": "always", + "enum": [ + "always", + "onFailure", + "never" + ], + "scope": "resource", + "order": 20060, + "description": "%mcpp.task.revealTerminal.title%", + "markdownDescription": "%mcpp.task.revealTerminal.description%" + }, + "mcpp.task.focusTerminal": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 20070, + "description": "%mcpp.task.focusTerminal.title%", + "markdownDescription": "%mcpp.task.focusTerminal.description%" + }, + "mcpp.task.problemMatcher": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20080, + "description": "%mcpp.task.problemMatcher.title%", + "markdownDescription": "%mcpp.task.problemMatcher.description%" + }, + "mcpp.task.editorTitleButtons": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20090, + "description": "%mcpp.task.editorTitleButtons.title%", + "markdownDescription": "%mcpp.task.editorTitleButtons.description%" + }, + "mcpp.build.onSave": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 20100, + "description": "%mcpp.build.onSave.title%", + "markdownDescription": "%mcpp.build.onSave.description%" + }, + "mcpp.build.profile": { + "type": "string", + "default": "dev", + "enum": [ + "dev", + "release" + ], + "scope": "resource", + "order": 20110, + "description": "%mcpp.build.profile.title%", + "markdownDescription": "%mcpp.build.profile.description%" + }, + "mcpp.build.target": { + "type": "string", + "default": "", + "scope": "resource", + "order": 20120, + "description": "%mcpp.build.target.title%", + "markdownDescription": "%mcpp.build.target.description%" + }, + "mcpp.debug.engine": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "cppdbg", + "lldb", + "lldb-dap", + "gdb" + ], + "scope": "resource", + "order": 20130, + "description": "%mcpp.debug.engine.title%", + "markdownDescription": "%mcpp.debug.engine.description%" + }, + "mcpp.codeLens.enable": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 20140, + "description": "%mcpp.codeLens.enable.title%", + "markdownDescription": "%mcpp.codeLens.enable.description%" + }, + "mcpp.test.macros": { + "type": "array", + "default": [], + "scope": "resource", + "order": 20150, + "description": "%mcpp.test.macros.title%", + "markdownDescription": "%mcpp.test.macros.description%" + }, + "mcpp.languageService.menuItems": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30001, + "description": "%mcpp.languageService.menuItems.title%", + "markdownDescription": "%mcpp.languageService.menuItems.description%" + }, + "mcpp.languageService.confirmResetCache": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30002, + "description": "%mcpp.languageService.confirmResetCache.title%", + "markdownDescription": "%mcpp.languageService.confirmResetCache.description%" + }, + "mcpp.languageService.refreshAfterBuild": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "reload", + "restart", + "off" + ], + "scope": "resource", + "order": 30010, + "description": "%mcpp.languageService.refreshAfterBuild.title%", + "markdownDescription": "%mcpp.languageService.refreshAfterBuild.description%" + }, + "mcpp.languageService.notifyOnDegraded": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30020, + "description": "%mcpp.languageService.notifyOnDegraded.title%", + "markdownDescription": "%mcpp.languageService.notifyOnDegraded.description%" + }, + "mcpp.languageService.readState": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 30030, + "description": "%mcpp.languageService.readState.title%", + "markdownDescription": "%mcpp.languageService.readState.description%" + }, + "mcpp.languageService.stateRefreshSeconds": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 30040, + "description": "%mcpp.languageService.stateRefreshSeconds.title%", + "markdownDescription": "%mcpp.languageService.stateRefreshSeconds.description%" + }, + "mcpp.toml.completion": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40010, + "description": "%mcpp.toml.completion.title%", + "markdownDescription": "%mcpp.toml.completion.description%" + }, + "mcpp.toml.hover": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40020, + "description": "%mcpp.toml.hover.title%", + "markdownDescription": "%mcpp.toml.hover.description%" + }, + "mcpp.toml.navigation": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40030, + "description": "%mcpp.toml.navigation.title%", + "markdownDescription": "%mcpp.toml.navigation.description%" + }, + "mcpp.toml.diagnostics.enabled": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 40040, + "description": "%mcpp.toml.diagnostics.enabled.title%", + "markdownDescription": "%mcpp.toml.diagnostics.enabled.description%" + }, + "mcpp.toml.diagnostics.syntax": { + "type": "string", + "default": "error", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40050, + "description": "%mcpp.toml.diagnostics.syntax.title%", + "markdownDescription": "%mcpp.toml.diagnostics.syntax.description%" + }, + "mcpp.toml.diagnostics.unknownSection": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40060, + "description": "%mcpp.toml.diagnostics.unknownSection.title%", + "markdownDescription": "%mcpp.toml.diagnostics.unknownSection.description%" + }, + "mcpp.toml.diagnostics.unknownKey": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40070, + "description": "%mcpp.toml.diagnostics.unknownKey.title%", + "markdownDescription": "%mcpp.toml.diagnostics.unknownKey.description%" + }, + "mcpp.toml.diagnostics.planeSeparation": { + "type": "string", + "default": "warning", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40080, + "description": "%mcpp.toml.diagnostics.planeSeparation.title%", + "markdownDescription": "%mcpp.toml.diagnostics.planeSeparation.description%" + }, + "mcpp.toml.diagnostics.legacyKeys": { + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 40090, + "description": "%mcpp.toml.diagnostics.legacyKeys.title%", + "markdownDescription": "%mcpp.toml.diagnostics.legacyKeys.description%" + }, + "mcpp.toml.indexCompletion": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 40100, + "description": "%mcpp.toml.indexCompletion.title%", + "markdownDescription": "%mcpp.toml.indexCompletion.description%" + }, + "mcpp.toml.indexCompletionTimeoutSeconds": { + "type": "number", + "default": 20, + "minimum": 1, + "maximum": 600, + "scope": "resource", + "order": 40110, + "description": "%mcpp.toml.indexCompletionTimeoutSeconds.title%", + "markdownDescription": "%mcpp.toml.indexCompletionTimeoutSeconds.description%" + }, + "mcpp.buildScript.intelligence": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50010, + "description": "%mcpp.buildScript.intelligence.title%", + "markdownDescription": "%mcpp.buildScript.intelligence.description%" + }, + "mcpp.buildScript.diagnostics": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50020, + "description": "%mcpp.buildScript.diagnostics.title%", + "markdownDescription": "%mcpp.buildScript.diagnostics.description%" + }, + "mcpp.buildScript.diagnostics.severity": { + "type": "string", + "default": "warning", + "enum": [ + "warning", + "info", + "off" + ], + "scope": "resource", + "order": 50030, + "description": "%mcpp.buildScript.diagnostics.severity.title%", + "markdownDescription": "%mcpp.buildScript.diagnostics.severity.description%" + }, + "mcpp.buildScript.imports.knownModules": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50040, + "description": "%mcpp.buildScript.imports.knownModules.title%", + "markdownDescription": "%mcpp.buildScript.imports.knownModules.description%" + }, + "mcpp.buildScript.snippets": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 50050, + "description": "%mcpp.buildScript.snippets.title%", + "markdownDescription": "%mcpp.buildScript.snippets.description%" + }, + "mcpp.cache.statusBar": { + "type": "boolean", + "default": false, + "scope": "resource", + "order": 60010, + "description": "%mcpp.cache.statusBar.title%", + "markdownDescription": "%mcpp.cache.statusBar.description%" + }, + "mcpp.cache.warnAboveGiB": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 60020, + "description": "%mcpp.cache.warnAboveGiB.title%", + "markdownDescription": "%mcpp.cache.warnAboveGiB.description%" + }, + "mcpp.cache.staleDays": { + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", + "order": 60030, + "description": "%mcpp.cache.staleDays.title%", + "markdownDescription": "%mcpp.cache.staleDays.description%" + }, + "mcpp.cache.autoRefreshSeconds": { + "type": "number", + "default": 0, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "order": 60040, + "description": "%mcpp.cache.autoRefreshSeconds.title%", + "markdownDescription": "%mcpp.cache.autoRefreshSeconds.description%" + }, + "mcpp.cache.estimateProjectBytes": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 60050, + "description": "%mcpp.cache.estimateProjectBytes.title%", + "markdownDescription": "%mcpp.cache.estimateProjectBytes.description%" + }, + "mcpp.cache.showLegacy": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 60060, + "description": "%mcpp.cache.showLegacy.title%", + "markdownDescription": "%mcpp.cache.showLegacy.description%" + }, + "mcpp.cache.gc.defaultBudgetGiB": { + "type": "number", + "default": 0, + "minimum": 0, + "scope": "resource", + "order": 60070, + "description": "%mcpp.cache.gc.defaultBudgetGiB.title%", + "markdownDescription": "%mcpp.cache.gc.defaultBudgetGiB.description%" + }, + "mcpp.cache.gc.confirmAboveGiB": { + "type": "number", + "default": 1, + "minimum": 0, + "scope": "resource", + "order": 60080, + "description": "%mcpp.cache.gc.confirmAboveGiB.title%", + "markdownDescription": "%mcpp.cache.gc.confirmAboveGiB.description%" + }, + "mcpp.cache.pruneAgeDays": { + "type": "number", + "default": 30, + "minimum": 1, + "scope": "resource", + "order": 60090, + "description": "%mcpp.cache.pruneAgeDays.title%", + "markdownDescription": "%mcpp.cache.pruneAgeDays.description%" + }, + "mcpp.views.project.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70010, + "description": "%mcpp.views.project.show.title%", + "markdownDescription": "%mcpp.views.project.show.description%" + }, + "mcpp.views.cache.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70020, + "description": "%mcpp.views.cache.show.title%", + "markdownDescription": "%mcpp.views.cache.show.description%" + }, + "mcpp.views.enabled": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70030, + "description": "%mcpp.views.enabled.title%", + "markdownDescription": "%mcpp.views.enabled.description%" + }, + "mcpp.views.library.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70040, + "description": "%mcpp.views.library.show.title%", + "markdownDescription": "%mcpp.views.library.show.description%" + }, + "mcpp.library.indexPath": { + "type": "string", + "default": "", + "scope": "window", + "order": 70050, + "description": "%mcpp.library.indexPath.title%", + "markdownDescription": "%mcpp.library.indexPath.description%" + }, + "mcpp.library.networkSearch": { + "type": "boolean", + "default": false, + "scope": "window", + "order": 70060, + "description": "%mcpp.library.networkSearch.title%", + "markdownDescription": "%mcpp.library.networkSearch.description%" + }, + "mcpp.views.languageServer.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 70070, + "description": "%mcpp.views.languageServer.show.title%", + "markdownDescription": "%mcpp.views.languageServer.show.description%" + }, + "mcpp.views.cache.topN": { + "type": "number", + "default": 5, + "minimum": 1, + "maximum": 50, + "scope": "window", + "order": 70080, + "description": "%mcpp.views.cache.topN.title%", + "markdownDescription": "%mcpp.views.cache.topN.description%" + }, + "mcpp.views.cache.ageBuckets": { + "type": "array", + "default": [ + "1d", + "7d", + "30d" + ], + "scope": "window", + "order": 70090, + "description": "%mcpp.views.cache.ageBuckets.title%", + "markdownDescription": "%mcpp.views.cache.ageBuckets.description%" + }, + "mcpp.ui.confirmDestructiveOnly": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 80001, + "description": "%mcpp.ui.confirmDestructiveOnly.title%", + "markdownDescription": "%mcpp.ui.confirmDestructiveOnly.description%" + }, + "mcpp.ui.language": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "en", + "zh-cn" + ], + "scope": "window", + "order": 80010, + "description": "%mcpp.ui.language.title%", + "markdownDescription": "%mcpp.ui.language.description%" + }, + "mcpp.ui.statusBar.show": { + "type": "boolean", + "default": true, + "scope": "window", + "order": 80020, + "description": "%mcpp.ui.statusBar.show.title%", + "markdownDescription": "%mcpp.ui.statusBar.show.description%" + }, + "mcpp.ui.statusBar.showLanguageServer": { + "type": "boolean", + "default": false, + "scope": "window", + "order": 80030, + "description": "%mcpp.ui.statusBar.showLanguageServer.title%", + "markdownDescription": "%mcpp.ui.statusBar.showLanguageServer.description%" + }, + "mcpp.ui.statusBar.background": { + "type": "string", + "default": "none", + "enum": [ + "none", + "warning", + "error" + ], + "scope": "window", + "order": 80040, + "description": "%mcpp.ui.statusBar.background.title%", + "markdownDescription": "%mcpp.ui.statusBar.background.description%" + }, + "mcpp.ui.notifications.success": { + "type": "string", + "default": "statusBar", + "enum": [ + "silent", + "statusBar", + "toast" + ], + "scope": "window", + "order": 80050, + "description": "%mcpp.ui.notifications.success.title%", + "markdownDescription": "%mcpp.ui.notifications.success.description%" + }, + "mcpp.ui.notifications.dedupeMinutes": { + "type": "number", + "default": 5, + "minimum": 0, + "maximum": 1440, + "scope": "window", + "order": 80060, + "description": "%mcpp.ui.notifications.dedupeMinutes.title%", + "markdownDescription": "%mcpp.ui.notifications.dedupeMinutes.description%" + }, + "mcpp.ui.numberFormat": { + "type": "string", + "default": "binary", + "enum": [ + "binary", + "decimal" + ], + "scope": "window", + "order": 80070, + "description": "%mcpp.ui.numberFormat.title%", + "markdownDescription": "%mcpp.ui.numberFormat.description%" + }, + "mcpp.log.level": { + "type": "string", + "default": "info", + "enum": [ + "error", + "warn", + "info", + "debug" + ], + "scope": "window", + "order": 90010, + "description": "%mcpp.log.level.title%", + "markdownDescription": "%mcpp.log.level.description%" + }, + "mcpp.diagnostics.selfCheckOnStartup": { + "type": "boolean", + "default": false, + "scope": "window", + "order": 90020, + "description": "%mcpp.diagnostics.selfCheckOnStartup.title%", + "markdownDescription": "%mcpp.diagnostics.selfCheckOnStartup.description%" + }, + "mcpp.runtime.timeoutSeconds": { + "type": "number", + "default": 30, + "minimum": 0, + "maximum": 3600, + "scope": "resource", + "order": 100010, + "description": "%mcpp.runtime.timeoutSeconds.title%", + "markdownDescription": "%mcpp.runtime.timeoutSeconds.description%" + }, + "mcpp.runtime.maxOutputMiB": { + "type": "number", + "default": 16, + "minimum": 1, + "maximum": 1024, + "scope": "resource", + "order": 100020, + "description": "%mcpp.runtime.maxOutputMiB.title%", + "markdownDescription": "%mcpp.runtime.maxOutputMiB.description%" + }, + "mcpp.runtime.concurrency": { + "type": "string", + "default": "perProject", + "enum": [ + "perProject", + "global" + ], + "scope": "resource", + "order": 100030, + "description": "%mcpp.runtime.concurrency.title%", + "markdownDescription": "%mcpp.runtime.concurrency.description%" + }, + "mcpp.clangd.path": { + "type": "string", + "default": "", + "scope": "resource", + "order": 100040, + "description": "%mcpp.clangd.path.title%", + "markdownDescription": "%mcpp.clangd.path.description%", + "deprecationMessage": "%mcpp.clangd.path.deprecationMessage%" + }, + "mcpp.modulesSupport": { + "type": "string", + "default": "auto", + "enum": [ + "auto", + "on", + "off" + ], + "scope": "resource", + "order": 100050, + "description": "%mcpp.modulesSupport.title%", + "markdownDescription": "%mcpp.modulesSupport.description%", + "deprecationMessage": "%mcpp.modulesSupport.deprecationMessage%" + }, + "mcpp.configureCppTools": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 100060, + "description": "%mcpp.configureCppTools.title%", + "markdownDescription": "%mcpp.configureCppTools.description%", + "deprecationMessage": "%mcpp.configureCppTools.deprecationMessage%" + }, + "mcpp.tomlCompletion": { + "type": "boolean", + "default": true, + "scope": "resource", + "order": 100070, + "description": "%mcpp.tomlCompletion.title%", + "markdownDescription": "%mcpp.tomlCompletion.description%", + "deprecationMessage": "%mcpp.tomlCompletion.deprecationMessage%" + }, + "mcpp.ide.backendPath": { + "type": "string", + "default": "", + "scope": "machine", + "order": 100080, + "description": "%mcpp.ide.backendPath.title%", + "markdownDescription": "%mcpp.ide.backendPath.description%" + }, + "mcpp.trust.denyEffects": { + "type": "array", + "default": [ + "exec-build-script", + "network" + ], + "scope": "window", + "order": 100090, + "description": "%mcpp.trust.denyEffects.title%", + "markdownDescription": "%mcpp.trust.denyEffects.description%" + }, + "mcpp.backend.logLevel": { + "type": "string", + "default": "info", + "enum": [ + "error", + "warning", + "info", + "debug" + ], + "scope": "window", + "order": 100100, + "description": "%mcpp.backend.logLevel.title%", + "markdownDescription": "%mcpp.backend.logLevel.description%" + } + } + }, "languages": [ { "id": "cpp", @@ -69,6 +1228,14 @@ ] } ], + "configurationDefaults": { + "files.associations": { + "*.ccm": "cpp", + "*.cppm": "cpp", + "*.ixx": "cpp", + "*.mpp": "cpp" + } + }, "grammars": [ { "language": "mcpp-toml", @@ -120,49 +1287,78 @@ } ] }, - "taskDefinitions": [ + "colors": [ { - "type": "mcpp", - "required": [ - "command" + "id": "mcpp.cacheOkForeground", + "description": "%colors.mcpp.cacheOkForeground%", + "defaults": { + "dark": "#89d185", + "light": "#388a34", + "highContrast": "#00ff00" + } + }, + { + "id": "mcpp.cacheStaleForeground", + "description": "%colors.mcpp.cacheStaleForeground%", + "defaults": { + "dark": "#e2c08d", + "light": "#bf8803", + "highContrast": "#ffff00" + } + } + ], + "keybindings": [ + { + "command": "mcpp.build", + "key": "ctrl+alt+b", + "mac": "cmd+alt+b", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.run", + "key": "ctrl+alt+r", + "mac": "cmd+alt+r", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.test", + "key": "ctrl+alt+t", + "mac": "cmd+alt+t", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.showMenu", + "key": "ctrl+alt+m", + "mac": "cmd+alt+m", + "when": "mcpp.inProject" + }, + { + "command": "mcpp.cleanProjectArtifacts", + "key": "ctrl+alt+l", + "mac": "cmd+alt+l", + "when": "mcpp.inProject" + } + ], + "problemMatchers": [ + { + "name": "mcpp", + "owner": "mcpp", + "source": "mcpp", + "fileLocation": [ + "relative", + "${workspaceFolder}" ], - "properties": { - "command": { - "type": "string", - "enum": [ - "build", - "test", - "run", - "clean" - ], - "description": "%taskDefinition.mcpp.command%" - }, - "target": { - "type": "string", - "description": "%taskDefinition.mcpp.target%" - }, - "profile": { - "type": "string", - "enum": [ - "dev", - "release" - ], - "description": "%taskDefinition.mcpp.profile%" - }, - "args": { - "type": "array", - "items": { - "type": "string" - }, - "description": "%taskDefinition.mcpp.args%" - }, - "root": { - "type": "string", - "description": "%taskDefinition.mcpp.root%" - } + "pattern": { + "regexp": "^(.+?):(\\\\d+):(\\\\d+):\\\\s+(error|warning|note):\\\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 } } ], + "viewsWelcome": [], "debuggers": [ { "type": "mcpp", @@ -225,77 +1421,48 @@ } ] } - ], - "colors": [ - { - "id": "mcpp.cacheOkForeground", - "description": "%colors.mcpp.cacheOkForeground%", - "defaults": { - "dark": "#89d185", - "light": "#388a34", - "highContrast": "#00ff00" - } - }, - { - "id": "mcpp.cacheStaleForeground", - "description": "%colors.mcpp.cacheStaleForeground%", - "defaults": { - "dark": "#e2c08d", - "light": "#bf8803", - "highContrast": "#ffff00" - } - } - ], - "configurationDefaults": { - "files.associations": { - "*.ccm": "cpp", - "*.cppm": "cpp", - "*.ixx": "cpp", - "*.mpp": "cpp" - } - }, - "menus": { - "view/item/context": [ - { - "command": "mcpp.test", - "when": "view == mcpp.project && viewItem == mcppTest", - "group": "inline@1" - }, - { - "command": "mcpp.run", - "when": "view == mcpp.project && viewItem == mcppExecutable", - "group": "inline@1" - } - ], - "view/title": [ - { - "command": "mcpp.build", - "when": "view == mcpp.project", - "group": "navigation@1" - }, - { - "command": "mcpp.selectTarget", - "when": "view == mcpp.project", - "group": "navigation@3" - } - ] - } + ] }, "scripts": { - "compile": "tsc -p tsconfig.json", - "gen": "cd ../.. && mcpp run mcpp-ide-dev -- gen vscode --out mide-plugins/vscode/dist/extension --stage", - "test": "npm run compile && node --test \"out/test/unit/**/*.test.js\" && node --test \"../../webui/tests/*.test.js\"", - "test:e2e": "npm run compile && npm run gen && node out/test/e2e/runTest.js && MCPP_E2E_BUNDLED=1 node out/test/e2e/runTest.js", - "package": "npm run compile && npm run gen && cd dist/extension && vsce package --no-dependencies -o ../mcpp-vscode-$npm_package_version.vsix", - "check:webui": "tsc -p ../../webui/tsconfig.json" + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "compile": "tsc -p tsconfig.json && node tools/copy-webview-assets.mjs && node tools/bundle-languageclient.mjs", + "test": "npm run check && npm run clean && npm run compile && node --test \"dist/test/**/*.test.js\"", + "package": "npm run clean && npm run compile && npm run stage:backend && vsce package", + "test:e2e": "npm run compile && node dist/test/e2e/runTest.js", + "test:all": "npm test && npm run test:e2e", + "gen:l10n": "node tools/generate-l10n.mjs", + "gen:docs": "node tools/generate-settings-docs.mjs", + "check:config": "node tools/check-config.mjs", + "check:l10n": "node tools/l10n-check.mjs", + "check": "npm run check:config && npm run check:l10n && npm run check:icon", + "gen:icon": "node tools/generate-activitybar-icon.mjs", + "gen:menuicons": "node tools/generate-quick-menu-icons.mjs", + "check:icon": "node tools/generate-activitybar-icon.mjs --check && node tools/generate-quick-menu-icons.mjs --check", + "test:e2e:one": "node dist/test/e2e/runTest.js", + "gen:examples": "node tools/generate-library-examples.mjs", + "test:graphs-ui": "npm run compile && node tools/test-graphs-ui.mjs", + "gen": "cd ../.. && mcpp run mcpp-ide-dev -- gen vscode", + "stage:backend": "node tools/stage-backend.mjs", + "test:parity": "npm run compile && node --test \"dist/test/parity/*.test.js\"", + "bundle:webui": "node tools/bundle-webui.mjs", + "check:webui": "tsc -p ../../webui/tsconfig.json", + "test:webui": "node tools/bundle-webui.mjs --out .webui-test && node --test ../../webui/tests/graph.test.mjs" }, "devDependencies": { + "@mermaid-js/tiny": "12.2.0", + "@noble/hashes": "^1.8.0", + "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", - "@types/vscode": "~1.91.0", + "@types/vscode": "^1.91.0", + "@vscode/codicons": "0.0.46-24", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", - "jsdom": "^27.0.0", - "@types/jsdom": "^27.0.0", + "vscode-languageclient": "9.0.1", + "buffer": "^6.0.3", + "esbuild": "^0.25.12", + "mocha": "^11.8.0", + "ovsx": "^1.2.0", + "playwright": "1.64.1", "typescript": "^5.9.0" } } diff --git a/mide-plugins/vscode/package.nls.json b/mide-plugins/vscode/package.nls.json new file mode 100644 index 0000000..7503047 --- /dev/null +++ b/mide-plugins/vscode/package.nls.json @@ -0,0 +1,277 @@ +{ + "category": "mcpp", + "colors.mcpp.cacheOkForeground": "Colour of a cache figure that needs no attention.", + "colors.mcpp.cacheStaleForeground": "Colour of artifacts that can be cleaned.", + "command.mcpp.autoConfigureModules.title": "Build and Refresh the Language Service", + "command.mcpp.build.title": "Build", + "command.mcpp.editor.build.title": "Build", + "command.mcpp.checkModuleSupport.title": "[Deprecated] C++ Modules: Restart the Language Server", + "command.mcpp.clean.title": "Clean target", + "command.mcpp.cleanLegacyCache.title": "Remove the Pre-v1 Cache", + "command.mcpp.cleanProjectArtifacts.title": "Clean Project Artifacts", + "command.mcpp.cleanStaleArtifacts.title": "Clean Stale Artifacts", + "command.mcpp.configureClangd.title": "[Deprecated] Configure the C++ Module Language Service", + "command.mcpp.configureLanguageServer.title": "[Deprecated] C++ Modules: Select the Analysis Context", + "command.mcpp.gcGlobalCache.title": "Collect the Shared Cache to a Budget", + "command.mcpp.installToolchain.title": "Install Toolchain", + "command.mcpp.languageServer.collectReport.title": "C++ Modules: Collect a Diagnostic Report", + "command.mcpp.languageServer.exportDiagnosticBundle.title": "C++ Modules: Export a Diagnostic Bundle", + "command.mcpp.languageServer.installTools.title": "C++ Modules: Install Command Line Tools", + "command.mcpp.languageServer.installKit.title": "C++ Modules: Install the Semantic Kit", + "command.mcpp.languageServer.manageConflicts.title": "C++ Modules: Turn Off Other C++ Language Services", + "command.mcpp.languageServer.openLogFolder.title": "Open the C++ Modules log folder", + "command.mcpp.languageServer.refreshState.title": "C++ Modules: Refresh This View", + "command.mcpp.languageServer.resetWorkspaceCache.title": "C++ Modules: Reset This Workspace's Cache", + "command.mcpp.languageServer.restart.title": "C++ Modules: Restart the Language Server", + "command.mcpp.languageServer.restartEngine.title": "C++ Modules: Restart the Semantic Engine", + "command.mcpp.languageServer.restoreConflicts.title": "C++ Modules: Restore Other C++ Language Services", + "command.mcpp.languageServer.revealBundle.title": "Show the last diagnostic bundle", + "command.mcpp.languageServer.reviewChanges.title": "C++ Modules: Review Workspace Changes", + "command.mcpp.languageServer.runBuildToolInTerminal.title": "C++ Modules: Run the Build Tool in a Terminal", + "command.mcpp.languageServer.selectContext.title": "C++ Modules: Select the Analysis Context", + "command.mcpp.languageServer.showLogs.title": "C++ Modules: Open the Log", + "command.mcpp.languageServer.showModuleGraph.title": "C++ Modules: Show the Module Graph", + "command.mcpp.languageServer.toggleInWorkspace.title": "C++ Modules: Enable or Disable in This Workspace", + "command.mcpp.library.openDetail.title": "Open the Package Page", + "command.mcpp.library.search.title": "Search and Add a Dependency", + "command.mcpp.library.updateIndex.title": "Refresh the mcpp Package Index", + "command.mcpp.newProject.title": "New Project", + "command.mcpp.openMcpplsSettings.title": "Open the C++ Modules Settings", + "command.mcpp.openSettings.title": "Open Settings Panel", + "command.mcpp.pruneGlobalCache.title": "Prune the Shared Cache by Age", + "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", + "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", + "command.mcpp.run.title": "Run", + "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", + "command.mcpp.selfCheck.title": "Environment Self-check", + "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", + "command.mcpp.showCachePanel.title": "Cache Statistics", + "command.mcpp.showLanguageServerLogs.title": "[Deprecated] C++ Modules: Open the Log", + "command.mcpp.showMenu.title": "Open the Quick Menu", + "command.mcpp.showModuleGraph.title": "[Deprecated] C++ Modules: Show the Module Graph", + "command.mcpp.showToolchains.title": "Show Toolchains", + "command.mcpp.test.title": "Test", + "command.mcpp.verifyGlobalCache.title": "Verify the Shared Cache", + "description": "Build, run, test and manage mcpp C++23 module projects, with mcpp.toml editing and build-script support.", + "displayName": "mcpp", + "mcpp.buildScript.diagnostics.description": "Validate build.mcpp against the build API mcpp actually ships.", + "mcpp.buildScript.diagnostics.severity.description": "Severity used for build.mcpp diagnostics.", + "mcpp.buildScript.diagnostics.severity.title": "Set build.mcpp diagnostic severity", + "mcpp.buildScript.diagnostics.title": "Enable build.mcpp diagnostics", + "mcpp.buildScript.imports.knownModules.description": "Offer mcpp's own modules (std, std.compat, mcpp.*) in import completion. Hover still recognises them, and no import is ever reported missing, whichever way this is set.", + "mcpp.buildScript.imports.knownModules.title": "Check imported modules", + "mcpp.buildScript.intelligence.description": "Turn completion and hover support for build.mcpp on or off.", + "mcpp.buildScript.intelligence.title": "Enable build.mcpp intelligence", + "mcpp.buildScript.snippets.description": "Offer snippet completions for the mcpp build script API.", + "mcpp.buildScript.snippets.title": "Offer build.mcpp snippets", + "mcpp.cache.autoRefreshSeconds.description": "Re-read the cache statistics every this many seconds. 0 disables automatic refreshing.", + "mcpp.cache.autoRefreshSeconds.title": "Refresh the cache view automatically", + "mcpp.cache.estimateProjectBytes.description": "Measure the target directory of the current project so the cache view can show its size.", + "mcpp.cache.estimateProjectBytes.title": "Estimate project artifact size", + "mcpp.cache.gc.confirmAboveGiB.description": "Require an extra confirmation when a cleanup would free more than this many GiB. 0 confirms every cleanup.", + "mcpp.cache.gc.confirmAboveGiB.title": "Confirm large cache cleanup", + "mcpp.cache.gc.defaultBudgetGiB.description": "Default size budget for cache cleanup, in GiB. 0 asks for a budget on every run.", + "mcpp.cache.gc.defaultBudgetGiB.title": "Set the default cleanup budget", + "mcpp.cache.pruneAgeDays.description": "Default age in days used when pruning cache entries older than a threshold.", + "mcpp.cache.pruneAgeDays.title": "Set the pruning age", + "mcpp.cache.showLegacy.description": "Include cache entries written by older mcpp versions in the cache view and in cleaning.", + "mcpp.cache.showLegacy.title": "Show legacy cache entries", + "mcpp.cache.staleDays.description": "Treat cache entries that were not accessed for this many days as stale. 0 disables the age rule.", + "mcpp.cache.staleDays.title": "Mark stale cache entries", + "mcpp.cache.statusBar.description": "Show the size of the mcpp global cache in the status bar. Off by default to keep the status bar quiet.", + "mcpp.cache.statusBar.title": "Show cache size in status bar", + "mcpp.cache.warnAboveGiB.description": "Warn when the mcpp global cache grows past this many GiB. 0 disables the warning.", + "mcpp.cache.warnAboveGiB.title": "Warn above a cache size", + "mcpp.clangd.path.deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.clangd.path.description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.clangd.path.title": "Deprecated clangd path", + "mcpp.configuration.title": "mcpp settings", + "mcpp.configureCppTools.deprecationMessage": "Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.configureCppTools.description": "Deprecated: Language service conflicts are managed by sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.configureCppTools.title": "Deprecated C++ tool configuration", + "mcpp.diagnostics.selfCheckOnStartup.description": "Run the environment self check on activation and write problems to the output channel. Off by default because it starts mcpp.", + "mcpp.diagnostics.selfCheckOnStartup.title": "Run a self check at startup", + "mcpp.languageService.confirmResetCache.description": "Ask before running the C++ Modules reset, which discards this workspace's model cache and prepares it again. Turning this off removes the only warning before that happens.", + "mcpp.languageService.confirmResetCache.title": "Confirm resetting the workspace cache", + "mcpp.languageService.menuItems.description": "Keep the C++ Modules actions in the mcpp quick menu. Turn off to leave the menu to mcpp's own commands.", + "mcpp.languageService.menuItems.title": "Show language-service entries in the menu", + "mcpp.languageService.notifyOnDegraded.description": "Show a notification when the C++ Modules language service reports a degraded state.", + "mcpp.languageService.notifyOnDegraded.title": "Notify when the language service degrades", + "mcpp.languageService.readState.description": "Read status from the C++ Modules extension through the API it exposes for tests. Turning this off only hides status; it never changes mcppls itself.", + "mcpp.languageService.readState.title": "Read language service state", + "mcpp.languageService.refreshAfterBuild.description": "How mcpp-vscode tells the C++ Modules extension about a finished build: auto, a lightweight reload, a full restart, or nothing.", + "mcpp.languageService.refreshAfterBuild.title": "Refresh language service after build", + "mcpp.languageService.stateRefreshSeconds.description": "Re-read the language service state every this many seconds. 0 disables the timer.", + "mcpp.languageService.stateRefreshSeconds.title": "Set the state refresh interval", + "mcpp.library.indexPath.description": "Leave empty to browse the official mcpp-index at /indices/mcpplibs (legacy registry/data/mcpplibs is supported). MCPP_HOME takes precedence; otherwise the extension asks mcpp self env. Set a directory containing pkgs/ or a collection of indexes to browse a custom source.", + "mcpp.library.indexPath.title": "Path to a mcpp package index checkout", + "mcpp.library.networkSearch.description": "Off by default. Also searches other registries with mcpp search; remote results have limited previews and cannot be added directly.", + "mcpp.library.networkSearch.title": "Search every configured registry over the network", + "mcpp.log.level.description": "Amount of detail written to the mcpp output channel. Debug output is verbose.", + "mcpp.log.level.title": "Set the log level", + "mcpp.modulesSupport.deprecationMessage": "The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.modulesSupport.description": "Deprecated: The C++ module language service moved to sunrisepeak.mcpp-language-server; mcpp-vscode no longer reads this setting.", + "mcpp.modulesSupport.title": "Deprecated module support mode", + "mcpp.path.description": "Path to the mcpp CLI used for every mcpp command. Leave it empty to look up mcpp on the PATH of the VS Code process.", + "mcpp.path.title": "Set the mcpp executable path", + "mcpp.project.discoveryBoundary.description": "How far the extension walks to find mcpp.toml. \"filesystem\" also searches outside the workspace folder; it costs more I/O and can reach unrelated trees.", + "mcpp.project.discoveryBoundary.title": "Limit project discovery scope", + "mcpp.runtime.concurrency.description": "Whether the one-command-at-a-time rule applies to each project separately or to the whole window.", + "mcpp.runtime.concurrency.title": "Limit concurrent mcpp commands", + "mcpp.runtime.maxOutputMiB.description": "Maximum output captured for one mcpp command, in MiB. When output is truncated the tail is kept.", + "mcpp.runtime.maxOutputMiB.title": "Cap captured command output", + "mcpp.runtime.timeoutSeconds.description": "How long one mcpp CLI command may run before it is terminated, in seconds. 0 disables the timeout.", + "mcpp.runtime.timeoutSeconds.title": "Set the command timeout", + "mcpp.task.buildArgs.description": "Extra arguments appended to the mcpp build task. Applied on the next build.", + "mcpp.task.buildArgs.title": "Add arguments to mcpp build", + "mcpp.task.cleanArgs.description": "Extra arguments appended to the mcpp clean task. Applied on the next clean.", + "mcpp.task.cleanArgs.title": "Add arguments to mcpp clean", + "mcpp.task.clearTerminal.description": "Clear the terminal before an mcpp task starts, so the last build's output is not mistaken for this one's.", + "mcpp.task.clearTerminal.title": "Clear the task terminal", + "mcpp.task.confirmClean.description": "Ask for confirmation before a clean task deletes the target directory of the project.", + "mcpp.task.confirmClean.title": "Confirm before cleaning", + "mcpp.task.editorTitleButtons.description": "Show the Run and Test buttons in the editor title bar while the file belongs to an mcpp project.", + "mcpp.task.editorTitleButtons.title": "Show editor title buttons", + "mcpp.task.focusTerminal.description": "Move keyboard focus to the terminal while an mcpp task runs. Off by default so editor focus is not taken away.", + "mcpp.task.focusTerminal.title": "Focus the task terminal", + "mcpp.task.problemMatcher.description": "Parse compiler output of the mcpp build and test tasks into the Problems panel.", + "mcpp.task.problemMatcher.title": "Parse task problems", + "mcpp.task.revealTerminal.description": "When an mcpp task brings the terminal panel to the front: always, only when the task fails, or never.", + "mcpp.task.revealTerminal.title": "Reveal the task terminal", + "mcpp.task.runArgs.description": "Extra arguments appended to the mcpp run task. Applied on the next run.", + "mcpp.task.runArgs.title": "Add arguments to mcpp run", + "mcpp.task.testArgs.description": "Extra arguments appended to the mcpp test task. Applied on the next test run.", + "mcpp.task.testArgs.title": "Add arguments to mcpp test", + "mcpp.toml.completion.description": "Offer section, key and value completion while editing mcpp.toml. The old mcpp.tomlCompletion key still works as an alias.", + "mcpp.toml.completion.title": "Complete mcpp.toml keys", + "mcpp.toml.diagnostics.enabled.description": "Turn all mcpp.toml validation on or off. Every rule keeps its own severity setting.", + "mcpp.toml.diagnostics.enabled.title": "Enable mcpp.toml diagnostics", + "mcpp.toml.diagnostics.legacyKeys.description": "Severity used for pre-0.5 mcpp.toml keys that still work but should be migrated.", + "mcpp.toml.diagnostics.legacyKeys.title": "Set legacy key severity", + "mcpp.toml.diagnostics.planeSeparation.description": "Severity used when a key is written in the wrong mcpp.toml plane, such as a build key in the library plane.", + "mcpp.toml.diagnostics.planeSeparation.title": "Set plane separation severity", + "mcpp.toml.diagnostics.syntax.description": "Severity used for mcpp.toml syntax errors.", + "mcpp.toml.diagnostics.syntax.title": "Set syntax diagnostic severity", + "mcpp.toml.diagnostics.unknownKey.description": "Severity used for keys that mcpp does not recognise.", + "mcpp.toml.diagnostics.unknownKey.title": "Set unknown key severity", + "mcpp.toml.diagnostics.unknownSection.description": "Severity used for sections that mcpp does not recognise.", + "mcpp.toml.diagnostics.unknownSection.title": "Set unknown section severity", + "mcpp.toml.hover.description": "Show documentation for sections and keys when hovering in mcpp.toml.", + "mcpp.toml.hover.title": "Show mcpp.toml hovers", + "mcpp.toml.indexCompletion.description": "Query the package index to complete dependency versions. Off by default because it needs network access.", + "mcpp.toml.indexCompletion.title": "Complete dependency versions", + "mcpp.toml.indexCompletionTimeoutSeconds.description": "How long one package index query may run before it is abandoned and completion falls back to local data, in seconds.", + "mcpp.toml.indexCompletionTimeoutSeconds.title": "Set the index query timeout", + "mcpp.toml.navigation.description": "Enable go-to-definition for workspace, path and feature references in mcpp.toml.", + "mcpp.toml.navigation.title": "Navigate inside mcpp.toml", + "mcpp.tomlCompletion.deprecationMessage": "mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "mcpp.tomlCompletion.description": "Deprecated: mcpp-vscode no longer reads this setting; use mcpp.toml.completion instead.", + "mcpp.tomlCompletion.title": "Deprecated mcpp.toml completion", + "mcpp.ui.confirmDestructiveOnly.description": "Use a modal dialogue only for actions that cannot be undone. Turning this off asks for confirmation more often, never less.", + "mcpp.ui.confirmDestructiveOnly.title": "Confirm only destructive actions", + "mcpp.ui.language.description": "Language for extension messages and panels. \"auto\" follows VS Code; command titles and settings labels always follow the VS Code display language.", + "mcpp.ui.language.title": "Set the extension language", + "mcpp.ui.notifications.dedupeMinutes.description": "Suppress a repeated notification for this many minutes. 0 shows every notification.", + "mcpp.ui.notifications.dedupeMinutes.title": "Deduplicate notifications", + "mcpp.ui.notifications.success.description": "How successful mcpp operations are reported: in the status bar only, as a toast, or not at all.", + "mcpp.ui.notifications.success.title": "Report successful operations", + "mcpp.ui.numberFormat.description": "Format byte sizes with binary units (KiB, MiB) or decimal units (KB, MB).", + "mcpp.ui.numberFormat.title": "Choose number formatting", + "mcpp.ui.statusBar.background.description": "VS Code accepts only two background colours for a status bar entry, \"statusBarItem.warningBackground\" and \"statusBarItem.errorBackground\", and it overrides the foreground so the text stays readable; any other colour is dropped. The mcpp item is plain by default; \"warning\" gives it the theme's amber chip and \"error\" its red one.", + "mcpp.ui.statusBar.background.title": "Highlight the mcpp status bar item", + "mcpp.ui.statusBar.show.description": "Show the main mcpp status bar item.", + "mcpp.ui.statusBar.show.title": "Show the mcpp status bar item", + "mcpp.ui.statusBar.showLanguageServer.description": "Show the C++ Modules language service state in the mcpp status bar item. Off by default because mcppls already has its own status item.", + "mcpp.ui.statusBar.showLanguageServer.title": "Show language service status", + "mcpp.views.cache.ageBuckets.description": "Age buckets used to group cache entries in the cache view, written as \"1d\", \"7d\" and so on.", + "mcpp.views.cache.ageBuckets.title": "Set cache age buckets", + "mcpp.views.cache.show.description": "Show the mcpp cache view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.cache.show.title": "Show the cache view", + "mcpp.views.cache.topN.description": "How many entries the cache view lists per group.", + "mcpp.views.cache.topN.title": "Set cache list length", + "mcpp.views.enabled.description": "Turns every mcpp view off at once. The view contents disappear, so the container shows nothing; whether VS Code also removes the mcpp icon from the activity bar is up to VS Code itself, because an extension cannot mark its container as hidden-when-empty. Right-click the icon to hide it there.", + "mcpp.views.enabled.title": "Hide the mcpp view contents", + "mcpp.views.languageServer.show.description": "The C++ Modules state is folded into the project view's basic information. The collapsed row always shows a status icon, so a problem is visible without expanding it.", + "mcpp.views.languageServer.show.title": "Show the language-service block in the project view", + "mcpp.views.library.show.description": "Show the view that browses the package index mcpp has already refreshed locally.", + "mcpp.views.library.show.title": "Show the mcpp library ecosystem view", + "mcpp.views.project.show.description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.project.show.title": "Show the project view", + "untrustedWorkspaces.description": "In an untrusted workspace this extension only provides module syntax highlighting and text-only mcpp.toml completion; it runs no mcpp command and changes no language-service configuration.", + "view.mcpp.cache.description": "Project and shared build-cache sizes, read with mcpp", + "view.mcpp.cache.name": "Cache", + "view.mcpp.languageServer.description": "Provided by sunrisepeak.mcpp-language-server; this view only displays and forwards.", + "view.mcpp.languageServer.name": "C++ Modules", + "view.mcpp.library.description": "Browse the mcpp package index that is already on this machine. Offline; nothing is fetched.", + "view.mcpp.library.name": "Libraries", + "view.mcpp.project.name": "Project", + "viewsContainer.mcpp.title": "mcpp", + "viewsWelcome.library": "No mcpp package index was found on this machine.\n\nRun `mcpp index update` once, or set `mcpp.library.indexPath` to an index checkout.\n[Run mcpp index update](command:mcpp.library.updateIndex)", + "command.mcpp.project.showDependencyGraph.title": "Show Project Dependency Graph", + "command.mcpp.project.showModuleDependencyGraph.title": "Show C++ Modules Dependency Graph", + "command.mcpp.debug.title": "Debug", + "command.mcpp.pack.title": "Pack", + "command.mcpp.cancelJob.title": "Cancel the Running Job", + "command.mcpp.problems.title": "Show the Problems in the Quickfix List", + "command.mcpp.selectTarget.title": "Select the Build Target", + "command.mcpp.showTargets.title": "Targets and Platforms", + "group.project": "Project", + "group.cache": "Cache", + "group.library": "Libraries", + "group.toolchain": "Targets and Toolchains", + "group.languageService": "C++ Modules Language Service", + "group.tools": "Tools", + "view.mcpp.targets.name": "Targets", + "debugger.mcpp.label": "mcpp", + "taskDefinition.mcpp.command": "The mcpp command: build, test, run or clean.", + "taskDefinition.mcpp.target": "The target to build for; empty for this machine.", + "taskDefinition.mcpp.profile": "The build profile: dev or release.", + "taskDefinition.mcpp.args": "Extra arguments passed to mcpp.", + "taskDefinition.mcpp.root": "The project's directory; empty for the current project.", + "debug.mcpp.target": "The target to debug; empty for this machine.", + "debug.mcpp.profile": "The build profile: dev or release.", + "debug.mcpp.engine": "The debugger: auto, cppdbg, lldb, lldb-dap or gdb.", + "debug.mcpp.args": "Arguments passed to the program.", + "mcpp.ide.backendPath.title": "Set the mcpp-ide backend path", + "mcpp.ide.backendPath.description": "The mcpp-ide program the extension starts. Empty: the backend shipped with the extension, then the one xlings installed, then PATH.", + "mcpp.build.onSave.title": "Build when a file is saved", + "mcpp.build.onSave.description": "Start a build of the file's project when a source or mcpp.toml is saved. A save while a build waits replaces it; a running build is cancelled and started again.", + "mcpp.build.profile.title": "Choose the build profile", + "mcpp.build.profile.description": "The profile build, run, test and debug use: dev (unoptimised, debug information) or release.", + "mcpp.build.target.title": "Choose the build target", + "mcpp.build.target.description": "The target build, run, test, debug and pack use, as mcpp names it (x86_64-linux-musl, x86_64-openkal). Empty: this machine. The Targets panel lists the targets and the platform that configures each.", + "mcpp.trust.denyEffects.title": "Effects denied in an untrusted workspace", + "mcpp.trust.denyEffects.description": "What mcpp may not do while the workspace is not trusted (mcpp --deny): a run that needs one of them stops before it and says so. Trusting the workspace lifts the list.", + "mcpp.debug.engine.title": "Choose the debugger", + "mcpp.debug.engine.description": "The debug adapter a debug session uses. auto: CodeLLDB when it is installed, then the C/C++ extension's gdb or lldb, then lldb-dap.", + "settings.group.project": "Project", + "settings.group.tasks": "Tasks", + "settings.group.languageService": "C++ Modules language service", + "settings.group.toml": "mcpp.toml editing", + "settings.group.buildScript": "build.mcpp editing", + "settings.group.cache": "Cache", + "settings.group.views": "Views", + "settings.group.ui": "Interface", + "settings.group.diagnostics": "Diagnostics and logging", + "settings.group.advanced": "Advanced", + "settings.preset.defaults.title": "Defaults", + "settings.preset.defaults.description": "Put every mcpp setting back to its shipped default.", + "settings.preset.quiet.title": "Quiet", + "settings.preset.quiet.description": "No status-bar counters, no automatic refresh, no editor diagnostics from this extension.", + "settings.preset.everything.title": "Everything on", + "settings.preset.everything.description": "All observation and editing help enabled, including the ones that run mcpp a little more often.", + "settings.preset.diagnose.title": "Diagnose", + "settings.preset.diagnose.description": "Turn the logging up and read the C++ Modules state, for when something is wrong.", + "mcpp.toml.diagnostics.syntax.deprecationMessage": "mcpp.toml's problems come from mcpp itself now, located where mcpp reports them; this check no longer exists. mcpp.toml.diagnostics.enabled turns them all off.", + "mcpp.toml.diagnostics.unknownSection.deprecationMessage": "mcpp.toml's problems come from mcpp itself now, located where mcpp reports them; this check no longer exists. mcpp.toml.diagnostics.enabled turns them all off.", + "mcpp.toml.diagnostics.unknownKey.deprecationMessage": "mcpp.toml's problems come from mcpp itself now, located where mcpp reports them; this check no longer exists. mcpp.toml.diagnostics.enabled turns them all off.", + "mcpp.toml.diagnostics.planeSeparation.deprecationMessage": "mcpp.toml's problems come from mcpp itself now, located where mcpp reports them; this check no longer exists. mcpp.toml.diagnostics.enabled turns them all off.", + "mcpp.toml.diagnostics.legacyKeys.deprecationMessage": "mcpp.toml's problems come from mcpp itself now, located where mcpp reports them; this check no longer exists. mcpp.toml.diagnostics.enabled turns them all off.", + "mcpp.backend.logLevel.title": "Set the backend's log level", + "mcpp.backend.logLevel.description": "What the mcpp-ide backend writes to the editor's log: errors only, warnings, what it does (the default), or every run of mcpp and mcppls with its arguments.", + "command.mcpp.exportReport.title": "Export IDE Report", + "mcpp.codeLens.enable.title": "Show Run and Debug above main", + "mcpp.codeLens.enable.description": "A CodeLens above the main of a program or a test: Run and Debug. Where C++ Modules serves the file its position is the language service's; otherwise the line is read from the text.", + "mcpp.test.macros.title": "Add a test framework's macros", + "mcpp.test.macros.description": "Macros that write a test framework's cases, each as framework:MACRO (gtest:MY_TEST). A test program's cases are listed only when its source calls one of its framework's macros." +} diff --git a/mide-plugins/vscode/package.nls.zh-cn.json b/mide-plugins/vscode/package.nls.zh-cn.json new file mode 100644 index 0000000..7ede0f2 --- /dev/null +++ b/mide-plugins/vscode/package.nls.zh-cn.json @@ -0,0 +1,277 @@ +{ + "category": "mcpp", + "colors.mcpp.cacheOkForeground": "无需关注的缓存数值的颜色。", + "colors.mcpp.cacheStaleForeground": "可清理产物的颜色。", + "command.mcpp.autoConfigureModules.title": "一键构建并刷新模块语言服务", + "command.mcpp.build.title": "构建", + "command.mcpp.editor.build.title": "构建", + "command.mcpp.checkModuleSupport.title": "[已弃用] C++ Modules: 重启语言服务", + "command.mcpp.clean.title": "清理 target", + "command.mcpp.cleanLegacyCache.title": "清理遗留缓存", + "command.mcpp.cleanProjectArtifacts.title": "清理项目产物", + "command.mcpp.cleanStaleArtifacts.title": "清理过期产物", + "command.mcpp.configureClangd.title": "[已弃用] 配置 C++ 模块语言服务", + "command.mcpp.configureLanguageServer.title": "[已弃用] C++ Modules: 选择分析上下文", + "command.mcpp.gcGlobalCache.title": "收敛全局缓存到预算", + "command.mcpp.installToolchain.title": "安装工具链", + "command.mcpp.languageServer.collectReport.title": "C++ Modules: 收集诊断报告", + "command.mcpp.languageServer.exportDiagnosticBundle.title": "C++ Modules: 导出诊断包", + "command.mcpp.languageServer.installTools.title": "C++ Modules: 安装命令行工具", + "command.mcpp.languageServer.installKit.title": "C++ Modules: 安装语义套件", + "command.mcpp.languageServer.manageConflicts.title": "C++ Modules: 关闭其他 C++ 语言服务", + "command.mcpp.languageServer.openLogFolder.title": "打开 C++ Modules 日志目录", + "command.mcpp.languageServer.refreshState.title": "C++ Modules: 刷新此视图", + "command.mcpp.languageServer.resetWorkspaceCache.title": "C++ Modules: 重置本工作区缓存", + "command.mcpp.languageServer.restart.title": "C++ Modules: 重启语言服务", + "command.mcpp.languageServer.restartEngine.title": "C++ Modules: 重启语义引擎", + "command.mcpp.languageServer.restoreConflicts.title": "C++ Modules: 恢复其他 C++ 语言服务", + "command.mcpp.languageServer.revealBundle.title": "显示最近一次诊断包", + "command.mcpp.languageServer.reviewChanges.title": "C++ Modules: 审查工作区改动", + "command.mcpp.languageServer.runBuildToolInTerminal.title": "C++ Modules: 在终端运行构建工具", + "command.mcpp.languageServer.selectContext.title": "C++ Modules: 选择分析上下文", + "command.mcpp.languageServer.showLogs.title": "C++ Modules: 打开日志", + "command.mcpp.languageServer.showModuleGraph.title": "C++ Modules: 查看模块图", + "command.mcpp.languageServer.toggleInWorkspace.title": "C++ Modules: 在本工作区启用或停用", + "command.mcpp.library.openDetail.title": "打开包详情页", + "command.mcpp.library.search.title": "搜索并添加依赖", + "command.mcpp.library.updateIndex.title": "刷新 mcpp 包索引", + "command.mcpp.newProject.title": "新建工程", + "command.mcpp.openMcpplsSettings.title": "打开 C++ Modules 设置", + "command.mcpp.openSettings.title": "打开设置面板", + "command.mcpp.pruneGlobalCache.title": "按时间清理全局缓存", + "command.mcpp.refreshCacheStats.title": "刷新缓存统计", + "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", + "command.mcpp.run.title": "运行", + "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", + "command.mcpp.selfCheck.title": "环境自检", + "command.mcpp.showCacheEntry.title": "查看缓存条目详情", + "command.mcpp.showCachePanel.title": "缓存统计", + "command.mcpp.showLanguageServerLogs.title": "[已弃用] C++ Modules: 打开日志", + "command.mcpp.showMenu.title": "打开快捷菜单", + "command.mcpp.showModuleGraph.title": "[已弃用] C++ Modules: 查看模块图", + "command.mcpp.showToolchains.title": "查看工具链", + "command.mcpp.test.title": "测试", + "command.mcpp.verifyGlobalCache.title": "校验全局缓存", + "description": "构建、运行、测试并管理 mcpp C++23 模块工程,提供 mcpp.toml 编辑与构建脚本支持。", + "displayName": "mcpp", + "mcpp.buildScript.diagnostics.description": "按 mcpp 实际提供的构建 API 校验 build.mcpp。", + "mcpp.buildScript.diagnostics.severity.description": "build.mcpp 诊断使用的严重度。", + "mcpp.buildScript.diagnostics.severity.title": "设置 build.mcpp 诊断严重度", + "mcpp.buildScript.diagnostics.title": "启用 build.mcpp 诊断", + "mcpp.buildScript.imports.knownModules.description": "在 import 补全中列出 mcpp 自带的模块(std、std.compat、mcpp.*)。无论此设置如何,悬停仍会识别它们,也不会把任何 import 报为缺失。", + "mcpp.buildScript.imports.knownModules.title": "检查导入的模块", + "mcpp.buildScript.intelligence.description": "统一开关 build.mcpp 的补全与悬停支持。", + "mcpp.buildScript.intelligence.title": "启用 build.mcpp 智能", + "mcpp.buildScript.snippets.description": "为 mcpp 构建脚本 API 提供片段补全。", + "mcpp.buildScript.snippets.title": "提供 build.mcpp 片段", + "mcpp.cache.autoRefreshSeconds.description": "每隔多少秒重新读取一次缓存统计。0 表示关闭自动刷新。", + "mcpp.cache.autoRefreshSeconds.title": "自动刷新缓存视图", + "mcpp.cache.estimateProjectBytes.description": "统计当前工程 target 目录的体积,供缓存视图显示。", + "mcpp.cache.estimateProjectBytes.title": "估算工程产物体积", + "mcpp.cache.gc.confirmAboveGiB.description": "单次清理预计释放超过该 GiB 数时追加一次确认。0 表示每次清理都确认。", + "mcpp.cache.gc.confirmAboveGiB.title": "大额缓存清理前确认", + "mcpp.cache.gc.defaultBudgetGiB.description": "缓存清理的默认体积预算(GiB)。0 表示每次清理都询问预算。", + "mcpp.cache.gc.defaultBudgetGiB.title": "设置默认清理预算", + "mcpp.cache.pruneAgeDays.description": "清理缓存条目时默认使用的天数阈值。", + "mcpp.cache.pruneAgeDays.title": "设置清理保留天数", + "mcpp.cache.showLegacy.description": "在缓存视图与清理中包含旧版 mcpp 写入的缓存条目。", + "mcpp.cache.showLegacy.title": "显示旧版缓存条目", + "mcpp.cache.staleDays.description": "超过该天数未被访问的缓存条目视为过期。0 表示关闭该时间规则。", + "mcpp.cache.staleDays.title": "标记过期缓存条目", + "mcpp.cache.statusBar.description": "在状态栏显示 mcpp 全局缓存的大小。默认关闭,以保持状态栏清爽。", + "mcpp.cache.statusBar.title": "在状态栏显示缓存大小", + "mcpp.cache.warnAboveGiB.description": "mcpp 全局缓存超过该 GiB 数时发出警告。0 表示关闭警告。", + "mcpp.cache.warnAboveGiB.title": "缓存超过阈值时警告", + "mcpp.clangd.path.deprecationMessage": "mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.clangd.path.description": "已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.clangd.path.title": "已弃用的 clangd 路径", + "mcpp.configuration.title": "mcpp 设置", + "mcpp.configureCppTools.deprecationMessage": "mcpp-vscode 不再读取此设置;语言服务冲突现由 sunrisepeak.mcpp-language-server 管理。", + "mcpp.configureCppTools.description": "已弃用:mcpp-vscode 不再读取此设置;语言服务冲突现由 sunrisepeak.mcpp-language-server 管理。", + "mcpp.configureCppTools.title": "已弃用的 C++ 工具配置", + "mcpp.diagnostics.selfCheckOnStartup.description": "激活时运行环境自检,并把发现的问题写入输出频道。默认关闭,因为会启动 mcpp。", + "mcpp.diagnostics.selfCheckOnStartup.title": "启动时运行环境自检", + "mcpp.languageService.confirmResetCache.description": "在执行 C++ Modules 的重置前询问——该操作会丢弃本工作区的模型缓存并重新准备。关闭后这一步不再有提示。", + "mcpp.languageService.confirmResetCache.title": "重置工作区缓存前确认", + "mcpp.languageService.menuItems.description": "在 mcpp 快捷菜单中保留 C++ Modules 操作。关闭后菜单只留 mcpp 自己的命令。", + "mcpp.languageService.menuItems.title": "在快捷菜单中显示语言服务项", + "mcpp.languageService.notifyOnDegraded.description": "C++ Modules 语言服务报告降级状态时显示通知。", + "mcpp.languageService.notifyOnDegraded.title": "语言服务降级时提示", + "mcpp.languageService.readState.description": "通过 C++ Modules 扩展对外暴露的测试 API 读取其状态。关闭只会隐藏状态,不会改动 mcppls 本身。", + "mcpp.languageService.readState.title": "读取语言服务状态", + "mcpp.languageService.refreshAfterBuild.description": "mcpp-vscode 以何种方式把构建结果告知 C++ Modules 扩展:auto、轻量重载、完整重启,或不做处理。", + "mcpp.languageService.refreshAfterBuild.title": "构建后刷新语言服务", + "mcpp.languageService.stateRefreshSeconds.description": "每隔多少秒重新读取一次语言服务状态。0 表示关闭定时刷新。", + "mcpp.languageService.stateRefreshSeconds.title": "设置状态刷新间隔", + "mcpp.library.indexPath.description": "留空则读取 mcpp 主目录下 indices/mcpplibs 中的官方 mcpp-index,兼容旧位置 registry/data/mcpplibs。优先使用 MCPP_HOME,否则通过 mcpp self env 获取主目录。可指定带 pkgs/ 的目录或索引集合目录来浏览自定义来源。", + "mcpp.library.indexPath.title": "mcpp 包索引检出的路径", + "mcpp.library.networkSearch.description": "默认关闭。开启后额外用 mcpp search 搜索其它 registry;搜索结果仅提供有限预览,不能直接添加。", + "mcpp.library.networkSearch.title": "在全部 registry 中联网搜索", + "mcpp.log.level.description": "写入 mcpp 输出频道的详细程度。debug 输出较为冗长。", + "mcpp.log.level.title": "设置日志级别", + "mcpp.modulesSupport.deprecationMessage": "mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.modulesSupport.description": "已弃用:mcpp-vscode 不再读取此设置;C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server。", + "mcpp.modulesSupport.title": "已弃用的模块支持模式", + "mcpp.path.description": "供扩展执行全部 mcpp CLI 命令使用的 mcpp 路径。留空时从 VS Code 进程的 PATH 中查找 mcpp。", + "mcpp.path.title": "设置 mcpp 可执行文件路径", + "mcpp.project.discoveryBoundary.description": "扩展为查找 mcpp.toml 而向上搜索的范围。设为 “filesystem” 时还会搜索工作区文件夹之外,磁盘开销更大,且可能触及无关目录树。", + "mcpp.project.discoveryBoundary.title": "限制工程发现范围", + "mcpp.runtime.concurrency.description": "「同一时刻只运行一条命令」的限制作用于单个工程,还是整个窗口。", + "mcpp.runtime.concurrency.title": "限制并发 mcpp 命令", + "mcpp.runtime.maxOutputMiB.description": "单条 mcpp 命令最多捕获的输出(MiB)。输出被截断时保留尾部。", + "mcpp.runtime.maxOutputMiB.title": "限制命令输出大小", + "mcpp.runtime.timeoutSeconds.description": "单条 mcpp CLI 命令的最长运行秒数,超时后终止。0 表示不限时。", + "mcpp.runtime.timeoutSeconds.title": "设置命令超时", + "mcpp.task.buildArgs.description": "追加到 mcpp build 任务的额外参数。下次构建时生效。", + "mcpp.task.buildArgs.title": "为 mcpp build 追加参数", + "mcpp.task.cleanArgs.description": "追加到 mcpp clean 任务的额外参数。下次清理时生效。", + "mcpp.task.cleanArgs.title": "为 mcpp clean 追加参数", + "mcpp.task.clearTerminal.description": "在 mcpp 任务开始前清空终端,避免把上一次构建的输出误当成这一次的。", + "mcpp.task.clearTerminal.title": "清空任务终端", + "mcpp.task.confirmClean.description": "清理任务删除工程 target 目录前先请求确认。", + "mcpp.task.confirmClean.title": "清理前确认", + "mcpp.task.editorTitleButtons.description": "当文件属于 mcpp 工程时,在编辑器标题栏显示运行与测试按钮。", + "mcpp.task.editorTitleButtons.title": "显示编辑器标题按钮", + "mcpp.task.focusTerminal.description": "任务运行时把键盘焦点移到终端。默认关闭,以免抢走编辑器焦点。", + "mcpp.task.focusTerminal.title": "聚焦任务终端", + "mcpp.task.problemMatcher.description": "把 mcpp build 与 test 任务的编译器输出解析到「问题」面板。", + "mcpp.task.problemMatcher.title": "解析任务问题", + "mcpp.task.revealTerminal.description": "mcpp 任务何时把终端面板带到前台:始终显示、仅在失败时显示,或从不显示。", + "mcpp.task.revealTerminal.title": "显示任务终端", + "mcpp.task.runArgs.description": "追加到 mcpp run 任务的额外参数。下次运行时生效。", + "mcpp.task.runArgs.title": "为 mcpp run 追加参数", + "mcpp.task.testArgs.description": "追加到 mcpp test 任务的额外参数。下次测试时生效。", + "mcpp.task.testArgs.title": "为 mcpp test 追加参数", + "mcpp.toml.completion.description": "编辑 mcpp.toml 时提供段、键与值补全。旧键 mcpp.tomlCompletion 仍作为别名生效。", + "mcpp.toml.completion.title": "补全 mcpp.toml 键", + "mcpp.toml.diagnostics.enabled.description": "统一开关 mcpp.toml 的校验。各条规则仍保留各自的严重度设置。", + "mcpp.toml.diagnostics.enabled.title": "启用 mcpp.toml 诊断", + "mcpp.toml.diagnostics.legacyKeys.description": "0.5 之前仍可用、但建议迁移的 mcpp.toml 旧键的严重度。", + "mcpp.toml.diagnostics.legacyKeys.title": "设置旧键严重度", + "mcpp.toml.diagnostics.planeSeparation.description": "在 mcpp.toml 中把键写错层面(例如把构建层面的键写进库层面)时的严重度。", + "mcpp.toml.diagnostics.planeSeparation.title": "设置层面混用严重度", + "mcpp.toml.diagnostics.syntax.description": "mcpp.toml 语法错误的严重度。", + "mcpp.toml.diagnostics.syntax.title": "设置语法诊断严重度", + "mcpp.toml.diagnostics.unknownKey.description": "mcpp 无法识别的键的严重度。", + "mcpp.toml.diagnostics.unknownKey.title": "设置未知键严重度", + "mcpp.toml.diagnostics.unknownSection.description": "mcpp 无法识别的段的严重度。", + "mcpp.toml.diagnostics.unknownSection.title": "设置未知段严重度", + "mcpp.toml.hover.description": "在 mcpp.toml 中悬停时显示段与键的说明。", + "mcpp.toml.hover.title": "显示 mcpp.toml 悬停", + "mcpp.toml.indexCompletion.description": "查询包索引以补全依赖版本。默认关闭,因为需要网络访问。", + "mcpp.toml.indexCompletion.title": "补全依赖版本", + "mcpp.toml.indexCompletionTimeoutSeconds.description": "单次包索引查询的最长等待秒数;超时后回退到本地数据补全。", + "mcpp.toml.indexCompletionTimeoutSeconds.title": "设置索引查询超时", + "mcpp.toml.navigation.description": "为 mcpp.toml 中的 workspace、path 与 features 引用启用跳转定义。", + "mcpp.toml.navigation.title": "在 mcpp.toml 内跳转", + "mcpp.tomlCompletion.deprecationMessage": "mcpp-vscode 不再读取此设置;请改用 mcpp.toml.completion。", + "mcpp.tomlCompletion.description": "已弃用:mcpp-vscode 不再读取此设置;请改用 mcpp.toml.completion。", + "mcpp.tomlCompletion.title": "已弃用的 mcpp.toml 补全", + "mcpp.ui.confirmDestructiveOnly.description": "只在无法撤销的操作上使用模态对话框。关闭后只会问得更多,不会更少。", + "mcpp.ui.confirmDestructiveOnly.title": "仅对不可逆操作确认", + "mcpp.ui.language.description": "扩展提示与面板使用的语言。“auto” 跟随 VS Code;命令面板标题与设置项名称始终跟随 VS Code 显示语言。", + "mcpp.ui.language.title": "设置扩展语言", + "mcpp.ui.notifications.dedupeMinutes.description": "同一条通知在该分钟数内只显示一次。0 表示每次都显示。", + "mcpp.ui.notifications.dedupeMinutes.title": "通知去重", + "mcpp.ui.notifications.success.description": "mcpp 操作成功时的提示方式:只在状态栏显示、弹出 toast,或完全静默。", + "mcpp.ui.notifications.success.title": "报告成功操作", + "mcpp.ui.numberFormat.description": "字节大小使用二进制单位(KiB、MiB)还是十进制单位(KB、MB)。", + "mcpp.ui.numberFormat.title": "选择数字格式", + "mcpp.ui.statusBar.background.description": "VS Code 只允许状态栏项使用两种背景色——`statusBarItem.warningBackground` 与 `statusBarItem.errorBackground`——并会自动换上对比度足够的前景色,其他颜色会被丢弃。mcpp 状态项默认朴素无色;\"warning\" 给它主题的琥珀色块,\"error\" 给红色。", + "mcpp.ui.statusBar.background.title": "高亮 mcpp 状态栏项", + "mcpp.ui.statusBar.show.description": "显示 mcpp 的主状态栏项。", + "mcpp.ui.statusBar.show.title": "显示 mcpp 状态栏项", + "mcpp.ui.statusBar.showLanguageServer.description": "在 mcpp 状态栏项中显示 C++ Modules 语言服务状态。默认关闭,因为 mcppls 已有自己的状态项。", + "mcpp.ui.statusBar.showLanguageServer.title": "显示语言服务状态", + "mcpp.views.cache.ageBuckets.description": "Age buckets used to group cache entries in the cache view, written as \"1d\", \"7d\" and so on.", + "mcpp.views.cache.ageBuckets.title": "Set cache age buckets", + "mcpp.views.cache.show.description": "Show the mcpp cache view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.cache.show.title": "Show the cache view", + "mcpp.views.cache.topN.description": "How many entries the cache view lists per group.", + "mcpp.views.cache.topN.title": "Set cache list length", + "mcpp.views.enabled.description": "一次性关闭全部 mcpp 视图。视图内容会消失,容器变成空的;mcpp 图标是否也从活动栏移除由 VS Code 决定——扩展无法把自己的容器标记为「空则隐藏」。要移除图标可在图标上右键。", + "mcpp.views.enabled.title": "隐藏 mcpp 视图内容", + "mcpp.views.languageServer.show.description": "C++ Modules 的状态折进工程视图的「基本信息」。折叠行始终带状态图标,因此不展开也能看出异常。", + "mcpp.views.languageServer.show.title": "在工程视图里显示语言服务块", + "mcpp.views.library.show.description": "显示用于浏览本地已刷新包索引的视图。", + "mcpp.views.library.show.title": "显示 mcpp 库生态视图", + "mcpp.views.project.show.description": "Show the mcpp project view in the activity bar. Takes effect after the view container reloads.", + "mcpp.views.project.show.title": "Show the project view", + "untrustedWorkspaces.description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。", + "view.mcpp.cache.description": "用 mcpp 只读查询工程与共享构建缓存的大小", + "view.mcpp.cache.name": "缓存", + "view.mcpp.languageServer.description": "由 sunrisepeak.mcpp-language-server 提供;此视图只做展示与转发。", + "view.mcpp.languageServer.name": "C++ Modules", + "view.mcpp.library.description": "浏览本机已有的 mcpp 包索引。离线,不联网抓取。", + "view.mcpp.library.name": "库生态", + "view.mcpp.project.name": "工程", + "viewsContainer.mcpp.title": "mcpp", + "viewsWelcome.library": "本机没有找到 mcpp 包索引。\n\n先运行一次 `mcpp index update`,或用 `mcpp.library.indexPath` 指向一个索引检出。\n[运行 mcpp index update](command:mcpp.library.updateIndex)", + "command.mcpp.project.showDependencyGraph.title": "显示项目依赖图", + "command.mcpp.project.showModuleDependencyGraph.title": "显示 C++ Modules 依赖图", + "command.mcpp.debug.title": "调试", + "command.mcpp.pack.title": "打包", + "command.mcpp.cancelJob.title": "取消正在运行的任务", + "command.mcpp.problems.title": "在 quickfix 列表中显示问题", + "command.mcpp.selectTarget.title": "选择构建目标", + "command.mcpp.showTargets.title": "目标与平台", + "group.project": "工程", + "group.cache": "缓存", + "group.library": "库", + "group.toolchain": "目标与工具链", + "group.languageService": "C++ 模块语言服务", + "group.tools": "工具", + "view.mcpp.targets.name": "目标", + "debugger.mcpp.label": "mcpp", + "taskDefinition.mcpp.command": "mcpp 命令:build、test、run 或 clean。", + "taskDefinition.mcpp.target": "构建目标;留空表示本机。", + "taskDefinition.mcpp.profile": "构建配置:dev 或 release。", + "taskDefinition.mcpp.args": "传给 mcpp 的额外参数。", + "taskDefinition.mcpp.root": "工程目录;留空表示当前工程。", + "debug.mcpp.target": "要调试的目标;留空表示本机。", + "debug.mcpp.profile": "构建配置:dev 或 release。", + "debug.mcpp.engine": "调试器:auto、cppdbg、lldb、lldb-dap 或 gdb。", + "debug.mcpp.args": "传给程序的参数。", + "mcpp.ide.backendPath.title": "设置 mcpp-ide 后端路径", + "mcpp.ide.backendPath.description": "扩展启动的 mcpp-ide 程序。留空:先用扩展自带的后端,再用 xlings 安装的,最后用 PATH 中的。", + "mcpp.build.onSave.title": "保存时构建", + "mcpp.build.onSave.description": "保存源文件或 mcpp.toml 时构建其所在工程。构建在等待时再次保存会替换它;正在运行的构建会被取消并重新开始。", + "mcpp.build.profile.title": "选择构建配置", + "mcpp.build.profile.description": "构建、运行、测试与调试使用的配置:dev(不优化,带调试信息)或 release。", + "mcpp.build.target.title": "选择构建目标", + "mcpp.build.target.description": "构建、运行、测试、调试与打包使用的目标,按 mcpp 的命名(x86_64-linux-musl、x86_64-openkal)。留空:本机。目标面板列出各目标及配置它的平台。", + "mcpp.trust.denyEffects.title": "未受信任工作区禁止的副作用", + "mcpp.trust.denyEffects.description": "工作区未受信任时 mcpp 不得做的事(mcpp --deny):需要其中之一的运行会在发生前停止并说明原因。信任工作区后解除。", + "mcpp.debug.engine.title": "选择调试器", + "mcpp.debug.engine.description": "调试会话使用的调试适配器。auto:已安装 CodeLLDB 时用它,其次用 C/C++ 扩展的 gdb 或 lldb,再次用 lldb-dap。", + "settings.group.project": "工程", + "settings.group.tasks": "任务", + "settings.group.languageService": "C++ 模块语言服务", + "settings.group.toml": "mcpp.toml 编辑", + "settings.group.buildScript": "build.mcpp 编辑", + "settings.group.cache": "缓存", + "settings.group.views": "视图", + "settings.group.ui": "界面", + "settings.group.diagnostics": "诊断与日志", + "settings.group.advanced": "高级", + "settings.preset.defaults.title": "默认", + "settings.preset.defaults.description": "把所有 mcpp 设置恢复为发布时的默认值。", + "settings.preset.quiet.title": "安静", + "settings.preset.quiet.description": "不显示状态栏计数、不自动刷新、本扩展不在编辑器中报告诊断。", + "settings.preset.everything.title": "全部开启", + "settings.preset.everything.description": "启用全部观察与编辑辅助,包括会稍多运行 mcpp 的功能。", + "settings.preset.diagnose.title": "诊断", + "settings.preset.diagnose.description": "提高日志级别并读取 C++ Modules 的状态,用于排查问题。", + "mcpp.toml.diagnostics.syntax.deprecationMessage": "mcpp.toml 的问题现在由 mcpp 自己报告并定位;这项检查已不存在。mcpp.toml.diagnostics.enabled 可整体关闭。", + "mcpp.toml.diagnostics.unknownSection.deprecationMessage": "mcpp.toml 的问题现在由 mcpp 自己报告并定位;这项检查已不存在。mcpp.toml.diagnostics.enabled 可整体关闭。", + "mcpp.toml.diagnostics.unknownKey.deprecationMessage": "mcpp.toml 的问题现在由 mcpp 自己报告并定位;这项检查已不存在。mcpp.toml.diagnostics.enabled 可整体关闭。", + "mcpp.toml.diagnostics.planeSeparation.deprecationMessage": "mcpp.toml 的问题现在由 mcpp 自己报告并定位;这项检查已不存在。mcpp.toml.diagnostics.enabled 可整体关闭。", + "mcpp.toml.diagnostics.legacyKeys.deprecationMessage": "mcpp.toml 的问题现在由 mcpp 自己报告并定位;这项检查已不存在。mcpp.toml.diagnostics.enabled 可整体关闭。", + "mcpp.backend.logLevel.title": "设置后端的日志级别", + "mcpp.backend.logLevel.description": "mcpp-ide 后端写入编辑器日志的内容:只写错误、写警告、写它所做的事(缺省),或写每一次运行 mcpp 与 mcppls 及其参数。", + "command.mcpp.exportReport.title": "导出 IDE 报告", + "mcpp.codeLens.enable.title": "在 main 上方显示运行与调试", + "mcpp.codeLens.enable.description": "在程序或测试的 main 上方显示 CodeLens:运行、调试。C++ Modules 服务该文件时位置取自语言服务,否则从文本读出行号。", + "mcpp.test.macros.title": "追加测试框架的宏", + "mcpp.test.macros.description": "书写测试框架用例的宏,每项写作 框架:宏名(gtest:MY_TEST)。测试程序的源文件调用了其框架的宏时才列举它的用例。" +} diff --git a/mide-plugins/vscode/src/backend/client.ts b/mide-plugins/vscode/src/backend/client.ts deleted file mode 100644 index daec36f..0000000 --- a/mide-plugins/vscode/src/backend/client.ts +++ /dev/null @@ -1,164 +0,0 @@ -// The backend's wire, as the adapter speaks it: `mcpp-ide serve` started as a child process, -// JSON-RPC 2.0 framed as the LSP base protocol on its standard streams. No editor API here, so the -// unit tests and the panel tests drive a real backend through the same code. -import { spawn, type ChildProcess } from "node:child_process"; -import { EventEmitter } from "node:events"; - -export interface RpcError { - code: number; - message: string; - data?: unknown; -} - -export class BackendError extends Error { - constructor(public readonly method: string, public readonly error: RpcError) { - super(error.message); - } -} - -type Pending = { resolve: (v: unknown) => void; reject: (e: unknown) => void; method: string }; - -/** Splits a byte stream into the bodies of Content-Length frames. */ -export class FrameReader { - private buffer = Buffer.alloc(0); - - feed(chunk: Buffer): string[] { - this.buffer = Buffer.concat([this.buffer, chunk]); - const out: string[] = []; - for (;;) { - const headerEnd = this.buffer.indexOf("\r\n\r\n"); - if (headerEnd < 0) break; - const headers = this.buffer.subarray(0, headerEnd).toString("ascii"); - const match = /content-length:\s*(\d+)/i.exec(headers); - if (!match) { - this.buffer = this.buffer.subarray(headerEnd + 4); - continue; - } - const length = Number(match[1]); - if (this.buffer.length < headerEnd + 4 + length) break; - out.push(this.buffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString("utf8")); - this.buffer = this.buffer.subarray(headerEnd + 4 + length); - } - return out; - } -} - -export function encode(message: unknown): Buffer { - const body = Buffer.from(JSON.stringify(message), "utf8"); - return Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]); -} - -export interface BackendOptions { - program: string; - args?: string[]; - cwd?: string; - env?: NodeJS.ProcessEnv; - /** Lines the backend writes to its standard error (its log). */ - log?: (line: string) => void; -} - -/** - * One backend process. `request` sends a method and resolves with its result; `notifications` - * emits each notification by method name (and "*" with the method first). `exited` resolves when - * the process ends. - */ -export class Backend { - readonly notifications = new EventEmitter(); - readonly exited: Promise; - private readonly child: ChildProcess; - private readonly pending = new Map(); - private readonly reader = new FrameReader(); - private nextId = 1; - private alive = true; - - constructor(options: BackendOptions) { - this.notifications.setMaxListeners(0); - this.child = spawn(options.program, options.args ?? ["serve"], { - cwd: options.cwd, - env: options.env ?? process.env, - stdio: ["pipe", "pipe", "pipe"], - windowsHide: true, - }); - this.child.stdout!.on("data", (chunk: Buffer) => { - for (const body of this.reader.feed(chunk)) this.dispatch(body); - }); - let errText = ""; - this.child.stderr!.on("data", (chunk: Buffer) => { - errText += chunk.toString("utf8"); - let nl: number; - while ((nl = errText.indexOf("\n")) >= 0) { - options.log?.(errText.slice(0, nl)); - errText = errText.slice(nl + 1); - } - }); - this.exited = new Promise((resolve) => { - const done = (code: number | null) => { - if (!this.alive) return; - this.alive = false; - for (const p of this.pending.values()) p.reject(new BackendError(p.method, { code: -32099, message: "the mcpp-ide backend ended" })); - this.pending.clear(); - resolve(code); - }; - this.child.on("exit", (code) => done(code)); - this.child.on("error", (e) => { - options.log?.(`mcpp-ide could not start: ${e.message}`); - done(null); - }); - }); - } - - get running(): boolean { - return this.alive; - } - - request(method: string, params: unknown = {}): Promise { - if (!this.alive) return Promise.reject(new BackendError(method, { code: -32099, message: "the mcpp-ide backend is not running" })); - const id = this.nextId++; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, method }); - this.child.stdin!.write(encode({ jsonrpc: "2.0", id, method, params })); - }); - } - - notify(method: string, params: unknown = {}): void { - if (this.alive) this.child.stdin!.write(encode({ jsonrpc: "2.0", method, params })); - } - - cancel(id: number): void { - this.notify("$/cancelRequest", { id }); - } - - async stop(): Promise { - if (!this.alive) return; - try { - await Promise.race([this.request("shutdown"), new Promise((r) => setTimeout(r, 2000))]); - } catch { - // ended already - } - this.notify("exit"); - this.child.stdin!.end(); - const ended = await Promise.race([this.exited.then(() => true), new Promise((r) => setTimeout(() => r(false), 3000))]); - if (!ended) this.child.kill(); - } - - private dispatch(body: string): void { - let message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: RpcError }; - try { - message = JSON.parse(body); - } catch { - return; - } - if (message.id !== undefined && message.id !== null && (message.result !== undefined || message.error !== undefined)) { - const p = this.pending.get(message.id); - if (!p) return; - this.pending.delete(message.id); - if (message.error) p.reject(new BackendError(p.method, message.error)); - else p.resolve(message.result); - return; - } - if (message.method) { - this.notifications.emit(message.method, message.params); - this.notifications.emit("*", message.method, message.params); - } - } -} diff --git a/mide-plugins/vscode/src/cache/cachePanel.ts b/mide-plugins/vscode/src/cache/cachePanel.ts new file mode 100644 index 0000000..d718c2a --- /dev/null +++ b/mide-plugins/vscode/src/cache/cachePanel.ts @@ -0,0 +1,393 @@ +/** + * The VS Code half of the cache view: the sidebar **WebviewView** (`mcpp.cache`, + * plan §8.1). + * + * The panel is a **reading aid** for what `mcpp cache list`, `mcpp cache dir` + * and a bounded walk of `target/` already report: composition, age, the largest + * labels, an LRU projection for a budget, and the cleanup actions the cache view + * exposes. `src/cache/cachePanelHtml.ts` owns the document; this module owns the + * data, the messages and the webview's lifetime. + * + * Deliberate boundaries: + * + * - **Nothing runs on its own.** Every figure comes from the injected `refresh` + * + `read` pair; every write goes through the injected `run()`, which is the + * same routine the cache commands use, so a confirmation cannot be bypassed by + * opening the view. + * - **The document is re-rendered after every action**, so there is exactly one + * renderer (the pure one) and no client-side model application to keep in + * sync. `renderCachePanelHtml` is idempotent, which is what a `WebviewView` + * needs: it has no `retainContextWhenHidden`, so hiding the sidebar destroys + * the document and showing it renders a new one. An unchanged document is + * not re-assigned (`WebviewDocument.paint()`), so a refresh that changes + * nothing keeps the scroll position and the budget input as they are. + * - **A failure still renders.** When the refresh/read pair rejects, the view + * draws an unavailable state with the reason instead of going blank. A global + * block that is unavailable is rendered *open*, because a reason hidden behind + * a collapsed disclosure is worse than no disclosure at all. + * - **One webview per window.** The provider is registered once; VS Code hands + * it the view whenever the sidebar shows it. + * + * The confirmation logic does not live here: `run()` is the caller's, and every + * destructive path still goes through `src/cli/clean.ts`'s plans and the graded + * modals in `src/cache/cacheView.ts`. + */ + +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { formatBytes, formatCount, type NumberFormat } from "../util/format"; +import { WebviewDocument } from "../webview/document"; +import { + CACHE_PANEL_UI, + decodeCachePanelMessage, + renderCachePanelHtml, + type CachePanelMessage, + type CachePanelModel, +} from "./cachePanelHtml"; + +/** The sidebar view this module provides; `package.json` spells the same id. */ +export const CACHE_VIEW_ID = "mcpp.cache"; + +/** + * VS Code registers `.focus` for every contributed view, so bringing the + * cache view on screen needs no command of our own. Used by the status item. + */ +export const CACHE_VIEW_FOCUS_COMMAND = `${CACHE_VIEW_ID}.focus`; + +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "cache.css"; + +export interface CachePanelData { + project: CachePanelModel["project"]; + shared: CachePanelModel["shared"]; + legacy?: CachePanelModel["legacy"]; + /** + * The caller's LRU projection for the budget simulator. Optional on purpose: + * when the caller has no estimate the panel omits the line rather than + * inventing a number. + */ + estimate?: string; +} + +/** The six messages the panel may hand to `run()`; `refresh` and `showEntry` do not clean anything. */ +export type CachePanelAction = Extract< + CachePanelMessage, + { type: "cleanStale" | "cleanProject" | "collect" | "prune" | "verify" | "cleanLegacy" } +>; + +export interface CachePanelDeps { + /** Brings the caller's figures up to date; may run mcpp. */ + refresh: () => Promise; + /** The figures as they stand, without running anything. */ + read: () => CachePanelData; + /** Runs one of the cleanup commands; the caller owns confirmation. */ + run: (message: CachePanelAction) => Promise; + showEntry: (label: string) => Promise; + /** The webview view became visible or hidden; the caller owns the refresh timer. */ + onVisibilityChanged?: (visible: boolean) => void; +} + +/** What `cacheView.ts` needs back from the registration. */ +export interface CachePanelProvider extends vscode.WebviewViewProvider { + /** True while the sidebar view is on screen. The caller's timer checks it. */ + readonly visible: boolean; + /** Redraws from the caller's current figures. A no-op while the view is not resolved. */ + refresh: () => void; +} + +/** + * Register the provider for the `mcpp.cache` sidebar view. + * + * The provider is returned so the caller can ask "is the view on screen?" (the + * auto-refresh timer) and "redraw" (after a settings change — the caller already + * listens for those, and keeps the status item and the timer in step in the same + * handler). It is registered with `retainContextWhenHidden: false`, the only + * value a `WebviewView` supports, which is why the document is re-rendered on + * every resolve. + */ +export function registerCachePanel(context: vscode.ExtensionContext, deps: CachePanelDeps): CachePanelProvider { + const provider = new CacheWebviewViewProvider(context, deps); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(CACHE_VIEW_ID, provider, { + webviewOptions: { retainContextWhenHidden: false }, + }), + ); + return provider; +} + +class CacheWebviewViewProvider implements CachePanelProvider { + private view: vscode.WebviewView | undefined; + private busy = false; + /** A redraw was asked for while one was running: do exactly one more pass. */ + private again = false; + /** + * The document on screen and the one CSP nonce it may be built with — the + * same kit the library view uses: a nonce per render would make every render + * a different document, and an assignment reloads the view (which is what + * loses the scroll position and the budget input). + */ + private readonly webviewDocument = new WebviewDocument(STYLESHEET); + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly deps: CachePanelDeps, + ) {} + + get visible(): boolean { + return this.view?.visible === true; + } + + /** + * The `WebviewViewProvider` contract: + * `resolveWebviewView(view: WebviewView, context: WebviewViewResolveContext, token: CancellationToken)`. + * The two trailing arguments are unused on purpose: the document is a pure + * function of the caller's figures, so a resolve needs neither the previous + * state nor a cancellation token, and the render it starts checks `this.view` + * before it touches the DOM. + * + * Called by VS Code every time the sidebar shows the view — and because a + * `WebviewView` cannot retain its context while hidden, that is also the point + * at which the document is drawn from scratch. + */ + resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + // A resolved view is a fresh, empty webview, so the document comparison + // starts from nothing again. + this.webviewDocument.invalidate(); + view.webview.options = { + enableScripts: true, + localResourceRoots: [mediaRoot(this.context)], + }; + view.onDidDispose(() => { + if (this.view === view) { + this.view = undefined; + this.webviewDocument.invalidate(); + } + }); + view.onDidChangeVisibility(() => { + this.deps.onVisibilityChanged?.(view.visible); + }); + view.webview.onDidReceiveMessage((raw: unknown) => { + void this.handle(raw); + }); + this.deps.onVisibilityChanged?.(view.visible); + this.refresh(); + } + + refresh(): void { + if (this.busy) { + this.again = true; + return; + } + void this.run(); + } + + /** One pass at a time; a request arriving mid-pass earns exactly one more. */ + private async run(): Promise { + this.busy = true; + try { + do { + this.again = false; + await this.render(); + } while (this.again && this.view !== undefined); + } finally { + this.busy = false; + } + } + + private async handle(raw: unknown): Promise { + const message = decodeCachePanelMessage(raw); + if (message === undefined) { + return; + } + try { + if (message.type === "refresh") { + // Nothing to run: the render below re-reads everything. + } else if (message.type === "showEntry") { + await this.deps.showEntry(message.label); + } else { + await this.deps.run(message); + } + } catch { + // The caller owns confirmation and error reporting. The view still + // re-renders, so the user sees the state that actually resulted. + } + this.refresh(); + } + + /** + * Resolve the model and put it on the webview. A rejected refresh/read pair + * becomes an unavailable model carrying the reason, because a blank view tells + * the user nothing and a view that is still usable can be redrawn. + */ + private async render(): Promise { + const view = this.view; + if (view === undefined) { + return; + } + let model: CachePanelModel; + try { + await this.deps.refresh(); + model = buildModel(this.deps.read()); + } catch (error) { + model = unavailableModel(error instanceof Error ? error.message : String(error)); + } + if (this.view !== view) { + return; + } + const html = renderCachePanelHtml(model, this.webviewDocument.assets(mediaRoot(this.context), view.webview)); + this.webviewDocument.paint(view.webview, html); + } +} + +function buildModel(data: CachePanelData): CachePanelModel { + const limits: CachePanelModel["limits"] = { + topN: Math.max(0, Math.floor(readNumber("mcpp.views.cache.topN", 0))), + warnAboveGiB: Math.max(0, readNumber("mcpp.cache.warnAboveGiB", 0)), + }; + const budget = suggestedBudgetGiB(data); + if (budget !== undefined) { + limits.budgetGiB = budget; + } + const model: CachePanelModel = { + ui: panelLabels(), + project: data.project, + shared: data.shared, + format: { bytes: (value) => formatBytes(value, numberFormat()), count: formatCount }, + limits, + }; + if (data.legacy !== undefined) { + model.legacy = data.legacy; + } + if (data.estimate !== undefined) { + model.estimate = data.estimate; + } + return model; +} + +/** Both halves unavailable, with one reason. Used when the read rejects. */ +function unavailableModel(message: string): CachePanelModel { + return { + ui: panelLabels(), + project: { available: false, note: message, totalBytes: 0, files: 0, groups: 0 }, + shared: { + available: false, + note: message, + totalBytes: 0, + totalEntries: 0, + byKind: [], + buckets: [], + top: [], + incomplete: 0, + }, + format: { bytes: (value) => formatBytes(value, numberFormat()), count: formatCount }, + limits: { topN: 0, warnAboveGiB: 0 }, + }; +} + +/** + * What the budget input starts from: `mcpp.cache.gc.defaultBudgetGiB` when the + * user set one, otherwise half of what the cache currently holds. `undefined` + * leaves the input empty rather than offering a number the user did not ask for. + */ +function suggestedBudgetGiB(data: CachePanelData): number | undefined { + const configured = readNumber("mcpp.cache.gc.defaultBudgetGiB", 0); + if (configured > 0) { + return Math.floor(configured); + } + const total = data.shared.totalBytes; + if (!data.shared.available || !Number.isFinite(total) || total <= 0) { + return undefined; + } + return Math.max(1, Math.round(total / 1024 ** 3 / 2)); +} + +function readNumber(key: string, fallback: number): number { + const value = read(key); + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +/** `binary` unless `mcpp.ui.numberFormat` says otherwise (plan §3.5). */ +function numberFormat(): NumberFormat { + return read("mcpp.ui.numberFormat") === "decimal" ? "decimal" : "binary"; +} + +/** + * Every visible string, resolved once per model. These are the literals + * `tools/l10n-check.mjs` holds to `data/i18n/zh-cn.json`. + * + * A key the §8.1 layout no longer prints (`refresh`, `details`, `colActions`, + * the four section hints, `barsHint`) is gone rather than left as a dictionary + * entry: the constant and the renderer have to keep meaning the same thing. + */ +function panelLabels(): Record { + return { + [CACHE_PANEL_UI.htmlLang]: htmlLanguage(), + [CACHE_PANEL_UI.title]: t("Cache statistics"), + [CACHE_PANEL_UI.boundary]: t( + "Every figure here is read from mcpp cache list, mcpp cache dir and a bounded walk of target/. Sizes are estimates; cleanup runs only after confirmation and never automatically.", + ), + [CACHE_PANEL_UI.projectTitle]: t("Project cache"), + [CACHE_PANEL_UI.projectFiles]: t("{0} file(s) · {1} group(s)"), + [CACHE_PANEL_UI.projectStale]: t("Stale artifacts: about {0}"), + [CACHE_PANEL_UI.sharedTitle]: t("Global build cache"), + [CACHE_PANEL_UI.sharedEntries]: t("{0} entries"), + [CACHE_PANEL_UI.sharedRoot]: t("Root: {0}"), + [CACHE_PANEL_UI.legacyTitle]: t("Pre-v1 cache"), + [CACHE_PANEL_UI.legacyPath]: t("Path: {0}"), + [CACHE_PANEL_UI.unknown]: t("unknown"), + [CACHE_PANEL_UI.projectUnavailable]: t("Project artifacts could not be measured."), + [CACHE_PANEL_UI.sharedUnavailable]: t("The shared build cache could not be read."), + [CACHE_PANEL_UI.actions]: t("Cache actions"), + [CACHE_PANEL_UI.cleanStale]: t("Clean stale artifacts"), + [CACHE_PANEL_UI.cleanProject]: t("Clean project artifacts"), + [CACHE_PANEL_UI.prune]: t("Drop entries unused for a while"), + [CACHE_PANEL_UI.verify]: t("Verify the cache"), + [CACHE_PANEL_UI.cleanLegacy]: t("Remove the pre-v1 cache"), + [CACHE_PANEL_UI.collect]: t("Collect to this budget"), + [CACHE_PANEL_UI.detailsFor]: t("Show cache entry details for {0}"), + [CACHE_PANEL_UI.reasonShared]: t("The shared build cache is not available."), + [CACHE_PANEL_UI.reasonProject]: t("Project artifacts are not available."), + [CACHE_PANEL_UI.reasonLegacy]: t("There is no pre-v1 cache to remove."), + [CACHE_PANEL_UI.composition]: t("Composition by kind"), + [CACHE_PANEL_UI.compositionEmpty]: t("No cache entries were found."), + [CACHE_PANEL_UI.age]: t("Last use"), + [CACHE_PANEL_UI.ageEmpty]: t("No entry has a recorded last use."), + [CACHE_PANEL_UI.ageUnder]: t("under {0} day(s)"), + [CACHE_PANEL_UI.ageRange]: t("{0}–{1} day(s)"), + [CACHE_PANEL_UI.ageOverflow]: t("more than {0} day(s)"), + [CACHE_PANEL_UI.ageUnknown]: t("{0} entries have no recorded last use and are not counted in this bar."), + [CACHE_PANEL_UI.top]: t("Largest packages (top {0})"), + [CACHE_PANEL_UI.topEmpty]: t("No cache label was read."), + [CACHE_PANEL_UI.colLabel]: t("Label"), + [CACHE_PANEL_UI.colEntries]: t("Entries"), + [CACHE_PANEL_UI.colBytes]: t("Size"), + [CACHE_PANEL_UI.colOldest]: t("Oldest use"), + [CACHE_PANEL_UI.budgetLabel]: t("Keep the shared build cache under"), + [CACHE_PANEL_UI.budgetHint]: t( + "Simulates mcpp cache gc --max-size: the estimate is a local projection, so mcpp's own policy decides in the end.", + ), + [CACHE_PANEL_UI.budgetUnit]: t("GiB"), + [CACHE_PANEL_UI.incompleteWarning]: t("{0} cache entries are incomplete; run mcpp cache verify to see which ones."), + [CACHE_PANEL_UI.sizeWarning]: t("The shared build cache is {0}, at or above the {1} warning threshold."), + }; +} + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} + +function mediaRoot(context: vscode.ExtensionContext): vscode.Uri { + return vscode.Uri.joinPath(context.extensionUri, MEDIA_DIRECTORY); +} diff --git a/mide-plugins/vscode/src/cache/cachePanelHtml.ts b/mide-plugins/vscode/src/cache/cachePanelHtml.ts new file mode 100644 index 0000000..2d7fedf --- /dev/null +++ b/mide-plugins/vscode/src/cache/cachePanelHtml.ts @@ -0,0 +1,949 @@ +/** + * The cache view's document, as a pure function (plan §8.1). + * + * `src/cache/cachePanel.ts` resolves the cache inventory into a + * `CachePanelModel`; this module turns that into one self-contained HTML + * document. No `vscode`, no file system, no network — which is what makes the + * interesting parts (escaping, the strict CSP, the proportional bars, the + * collapsed global block, the empty state) unit-testable. + * + * House rules, the same ones `src/config/panelHtml.ts` pins down: + * + * - **Strict CSP.** `default-src 'none'` plus exactly three sources: the + * stylesheet, the nonced inline script, and the webview's own image source. + * There are no external resources and no inline `style=` attributes. + * - **No hard-coded copy.** Every user-visible label is looked up in + * `CachePanelModel.ui`, which the caller has already localized; a key the + * caller forgot degrades to the key itself rather than to a blank control. + * - **No unescaped interpolation.** Every value that reaches the markup goes + * through `escapeHtml`. + * - **State is text plus shape, not colour.** Every bar is drawn twice: once as + * inline SVG (shape) and once as the text of the row beside it (the legend, or + * the figure line under the project bar). Colour is decoration, so a + * high-contrast theme and a screen reader both get the whole story. + * + * §8.1 puts one primary bar in the document (the project block's, 6 px) and + * compresses the legend into a single wrapped line separated by `·`; the type + * scale is 26 / 12 / 11 px and everything is separated by whitespace rather than + * nested borders. The global block is a real `
` that renders + * **collapsed**: the markup carries no `open` attribute. + * + * The visualisation is CSS plus inline SVG on purpose. A `` is not + * an inline style, so the bars can be proportional without ever breaking the + * CSP. Bar *heights* are class-driven (`media/cache.css`), never inline. + * + * Numbers: every size and count is rendered through `model.format`, so this + * module never chooses binary vs decimal units and never applies a locale. Two + * figures cannot come from the caller's formatters and are produced here with a + * canonical, locale-free representation: the *percentage* of a segment (a + * ratio, not a measurement) and the *age* of a top package (rendered as a UTC + * ISO-8601 date, with the raw Unix seconds kept in `data-oldest`). + */ + +import { format } from "../i18n/translate"; + +/** One row of the "largest packages" table. */ +export interface CachePanelRow { + label: string; + entries: number; + bytes: number; + /** Unix seconds; the renderer shows it as a locale-free UTC date. */ + oldestAccessed?: number; +} + +/** One age bucket: `[fromDays, toDays)`, the last one being the overflow. */ +export interface CachePanelBucket { + fromDays: number; + toDays?: number; + entries: number; + bytes: number; +} + +export interface CachePanelModel { + /** Everything is already localized by the caller. */ + ui: Record; + project: { + available: boolean; + note?: string; + totalBytes: number; + files: number; + groups: number; + /** + * Bytes mcpp would drop with `mcpp clean --stale`. Optional on purpose: + * nothing in this extension can know that figure today (mcpp owns the + * staleness rule), so the block renders the line and the split bar only + * when a caller supplies one. + */ + staleBytes?: number; + truncated?: string; + }; + shared: { + available: boolean; + note?: string; + root?: string; + totalBytes: number; + totalEntries: number; + byKind: Array<{ kind: string; entries: number; bytes: number }>; + buckets: CachePanelBucket[]; + top: CachePanelRow[]; + incomplete: number; + oldestAccessed?: string; + newestAccessed?: string; + }; + legacy?: { bytes: number; path?: string }; + /** Numbers the caller has already formatted, so the renderer needs no locale. */ + format: { bytes: (value: number) => string; count: (value: number) => string }; + limits: { + topN: number; + warnAboveGiB: number; + /** The budget the simulator's input starts from; omitted means "empty". */ + budgetGiB?: number; + }; + /** + * The caller's LRU projection for the budget control. Optional: when the + * caller has no estimate the renderer omits the line rather than inventing a + * number. + */ + estimate?: string; +} + +export interface CachePanelAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** + * The `ui` keys the panel uses, so the caller and the renderer cannot drift + * apart on a string. `cachePanel.ts` fills all of them through `t()`. + */ +export const CACHE_PANEL_UI = { + htmlLang: "cache.htmlLang", + title: "cache.title", + boundary: "cache.boundary", + projectTitle: "cache.project.title", + projectFiles: "cache.project.files", + projectStale: "cache.project.stale", + sharedTitle: "cache.shared.title", + sharedEntries: "cache.shared.entries", + sharedRoot: "cache.shared.root", + legacyTitle: "cache.legacy.title", + legacyPath: "cache.legacy.path", + unknown: "cache.unknown", + projectUnavailable: "cache.project.unavailable", + sharedUnavailable: "cache.shared.unavailable", + actions: "cache.actions", + cleanStale: "cache.action.cleanStale", + cleanProject: "cache.action.cleanProject", + prune: "cache.action.prune", + verify: "cache.action.verify", + cleanLegacy: "cache.action.cleanLegacy", + collect: "cache.action.collect", + detailsFor: "cache.action.detailsFor", + reasonShared: "cache.reason.shared", + reasonProject: "cache.reason.project", + reasonLegacy: "cache.reason.legacy", + composition: "cache.composition.title", + compositionEmpty: "cache.composition.empty", + age: "cache.age.title", + ageEmpty: "cache.age.empty", + ageUnder: "cache.age.under", + ageRange: "cache.age.range", + ageOverflow: "cache.age.overflow", + ageUnknown: "cache.age.unknown", + top: "cache.top.title", + topEmpty: "cache.top.empty", + colLabel: "cache.col.label", + colEntries: "cache.col.entries", + colBytes: "cache.col.bytes", + colOldest: "cache.col.oldest", + budgetLabel: "cache.budget.label", + budgetHint: "cache.budget.hint", + budgetUnit: "cache.budget.unit", + incompleteWarning: "cache.warn.incomplete", + sizeWarning: "cache.warn.size", +} as const; + +export type CachePanelMessage = + | { type: "refresh" } + | { type: "cleanStale" } + | { type: "cleanProject" } + | { type: "collect"; budgetGiB: number } + | { type: "prune" } + | { type: "verify" } + | { type: "cleanLegacy" } + | { type: "showEntry"; label: string }; + +type UiLabel = (key: string) => string; + +/** The width of the SVG bars, in viewBox units. Independent of the CSS width. */ +const BAR_UNITS = 1000; +const BAR_HEIGHT = 22; + +/** Anything not a finite number is treated as `0`, so `NaN` never reaches the DOM. */ +function finite(value: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * The CSP is an attribute value, but its single quotes are syntax: escaping + * them would make the document state a different policy than it means. Only the + * characters that could end the attribute or start a tag are escaped. + */ +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** ` name="value"`, or nothing when the value is absent. */ +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +/** ` name`, or nothing. Boolean HTML attributes only. */ +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly unknown[] = []): string { + return format(label(key), args); +} + +/** Percent of `total`, rounded to one decimal, in a locale-free representation. */ +function percent(part: number, total: number): number { + const value = finite(part); + const whole = finite(total); + if (whole <= 0) { + return 0; + } + return Math.round((value / whole) * 1000) / 10; +} + +/** `60` / `62.5` — canonical, so the tests can assert on it exactly. */ +function percentText(value: number): string { + return Number.isInteger(value) ? String(value) : value.toFixed(1); +} + +/** A cumulative ratio as viewBox units, rounded so the segments tile exactly. */ +function units(ratio: number): number { + return Math.round(clamp01(ratio) * BAR_UNITS * 1000) / 1000; +} + +/** A Unix timestamp as a locale-free absolute time, or `undefined`. */ +function isoSeconds(seconds: number | undefined): string | undefined { + if (seconds === undefined) { + return undefined; + } + const value = finite(seconds); + return value <= 0 ? undefined : new Date(value * 1000).toISOString(); +} + +/** + * `"12.4 MiB"` -> the 26 px value and the small unit beside it. The split is + * pure text: the renderer never re-formats a number, so binary and decimal read + * the same way they do everywhere else in the extension. A figure with no space + * (`"1000B"`) is rendered as one value with no unit element. + */ +function splitMetric(text: string): { value: string; unit?: string } { + const at = text.lastIndexOf(" "); + if (at <= 0 || at === text.length - 1) { + return { value: text }; + } + return { value: text.slice(0, at), unit: text.slice(at + 1) }; +} + +/** The primary figure: 26 px value, small unit, tabular figures (plan §8.1). */ +function renderMetric(text: string): string { + const parts = splitMetric(text); + const unit = parts.unit === undefined ? "" : `${escapeHtml(parts.unit)}`; + return `

${escapeHtml(parts.value)}${unit}

`; +} + +/** One segment of a bar: pre-rendered attributes, a caption and a size. */ +interface BarPart { + /** Already-escaped `data-*` attributes, including a leading space. */ + attributes: string; + label: string; + bytes: number; +} + +/** + * Inline SVG: one `` per part, widths proportional to bytes. `className` + * chooses the height (6 px or 14 px) — the renderer never sets a style. + * + * Without an `ariaLabel` the graphic is decoration for the labelled text beside + * it; with one it becomes the shape whose name carries the same figure. + */ +function renderBar(parts: readonly BarPart[], total: number, className: string, ariaLabel?: string): string { + let cumulative = 0; + const groups = parts + .map((part) => { + const start = units(cumulative / total); + cumulative += Math.max(0, finite(part.bytes)); + const end = units(cumulative / total); + const pct = percent(part.bytes, total); + return [ + ``, + ` `, + ` ${escapeHtml(`${part.label}: ${percentText(pct)}%`)}`, + ``, + ].join("\n"); + }) + .join("\n"); + const semantics = + ariaLabel === undefined + ? ` aria-hidden="true" focusable="false"` + : ` role="img" aria-label="${escapeHtml(ariaLabel)}" focusable="false"`; + return [ + ``, + groups, + ``, + ].join("\n"); +} + +/** + * The legend, compressed into **one line** (§8.1): every item is inline, the + * `·` separators are generated by the stylesheet, and all items share a single + * `
    ` so the line wraps as text rather than as rows. + */ +function renderLegend(id: string, items: readonly string[]): string { + return `
      ${items.join("")}
    `; +} + +/** One legend item: swatch (shape), caption (text) and share (text). */ +function legendItem(attributes: string, caption: string, pct: number, detail: string): string { + return ( + `
  • ` + + `` + + `${escapeHtml(caption)}` + + `${escapeHtml(`${percentText(pct)}%`)}` + + `
  • ` + ); +} + +/** `Reason` plus the caller's explanation, when there is one. */ +function reasonText(label: UiLabel, key: string, note?: string): string { + const base = label(key); + return note === undefined || note.length === 0 ? base : `${base} ${note}`; +} + +function button(action: string, text: string, disabled: boolean, reason: string): string { + return ( + `` + ); +} + +// ─────────────────────────────────────────────────────────────── warnings + +function renderWarnings(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const warnings: string[] = []; + if (model.shared.available && finite(model.shared.incomplete) > 0) { + warnings.push( + `

    ${escapeHtml( + fill(label, CACHE_PANEL_UI.incompleteWarning, [formatters.count(finite(model.shared.incomplete))]), + )}

    `, + ); + } + const threshold = finite(model.limits.warnAboveGiB); + const gib = finite(model.shared.totalBytes) / 1024 ** 3; + if (threshold > 0 && gib >= threshold) { + warnings.push( + `

    ${escapeHtml( + fill(label, CACHE_PANEL_UI.sizeWarning, [ + formatters.bytes(finite(model.shared.totalBytes)), + formatters.bytes(threshold * 1024 ** 3), + ]), + )}

    `, + ); + } + return warnings.length === 0 ? "" : `
    \n${warnings.join("\n")}\n
    `; +} + +// ────────────────────────────────────────────────────────── project cache + +/** + * The project block, always on screen (§8.1): the primary figure, one line of + * secondary figures, one 6 px bar, the stale line when a caller can name it, and + * the two project actions. + */ +function renderProjectBlock(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const available = model.project.available; + const total = Math.max(0, finite(model.project.totalBytes)); + const value = available ? formatters.bytes(total) : label(CACHE_PANEL_UI.unknown); + + const parts: string[] = [ + `
    `, + `

    ${escapeHtml(label(CACHE_PANEL_UI.projectTitle))}

    `, + ` ${renderMetric(value)}`, + ]; + + if (available) { + parts.push( + `

    ${escapeHtml( + fill(label, CACHE_PANEL_UI.projectFiles, [ + formatters.count(finite(model.project.files)), + formatters.count(finite(model.project.groups)), + ]), + )}

    `, + ); + } else { + parts.push( + `

    ${escapeHtml(model.project.note ?? label(CACHE_PANEL_UI.projectUnavailable))}

    `, + ); + } + + const stale = available ? Math.max(0, finite(model.project.staleBytes ?? 0)) : 0; + if (available && stale > 0 && total > 0) { + const bar = renderBar( + [ + { + attributes: ` data-segment="stale"`, + label: fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + bytes: stale, + }, + { + attributes: ` data-segment="current"`, + label: label(CACHE_PANEL_UI.projectTitle), + bytes: Math.max(0, total - stale), + }, + ], + total, + "viz-bar viz-bar-thin project-bar", + fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + ); + parts.push(` ${bar}`); + parts.push( + `

    ${escapeHtml( + fill(label, CACHE_PANEL_UI.projectStale, [formatters.bytes(stale)]), + )}

    `, + ); + } + + if (available && model.project.truncated !== undefined) { + parts.push(`

    ${escapeHtml(model.project.truncated)}

    `); + } + if (available && model.project.note !== undefined) { + parts.push(`

    ${escapeHtml(model.project.note)}

    `); + } + + const reason = reasonText(label, CACHE_PANEL_UI.reasonProject, model.project.note); + parts.push( + ` `, + ); + parts.push(`
    `); + return parts.join("\n"); +} + +// ─────────────────────────────────────────────────────────── global cache + +/** One source segment of the composition bar, before percentages are known. */ +interface CompositionSource { + kind: string; + segment: "kind" | "legacy"; + caption: string; + bytes: number; + entries?: number; +} + +/** The composition bar: one segment per kind, plus the pre-v1 legacy cache. */ +function renderComposition(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const unknown = label(CACHE_PANEL_UI.unknown); + const sources: CompositionSource[] = []; + const legacyBytes = finite(model.legacy?.bytes ?? 0); + + if (model.shared.available) { + for (const entry of model.shared.byKind) { + sources.push({ + kind: entry.kind, + segment: "kind", + caption: entry.kind.length === 0 ? unknown : entry.kind, + bytes: entry.bytes, + entries: entry.entries, + }); + } + } + if (legacyBytes > 0) { + sources.push({ + kind: "legacy", + segment: "legacy", + caption: label(CACHE_PANEL_UI.legacyTitle), + bytes: legacyBytes, + }); + } + + const total = sources.reduce((sum, source) => sum + Math.max(0, finite(source.bytes)), 0); + const aria = label(CACHE_PANEL_UI.composition); + if (sources.length === 0 || total <= 0) { + return [ + `
    `, + `

    ${escapeHtml(label(CACHE_PANEL_UI.compositionEmpty))}

    `, + `
    `, + ].join("\n"); + } + + const parts: BarPart[] = []; + const items: string[] = []; + for (const source of sources) { + // `data-kind` stays the raw mcpp value: it is the hook the stylesheet and + // the tests select on, so a translated caption must not change it. + const attributes = ` data-kind="${escapeHtml(source.kind)}" data-segment="${source.segment}"`; + parts.push({ attributes, label: source.caption, bytes: source.bytes }); + const pct = percent(source.bytes, total); + const detail = [ + source.caption, + `${percentText(pct)}%`, + formatters.bytes(finite(source.bytes)), + ...(source.entries === undefined ? [] : [formatters.count(finite(source.entries))]), + ].join(" · "); + items.push(legendItem(attributes, source.caption, pct, detail)); + } + + return [ + `
    `, + ` ${renderBar(parts, total, "viz-bar")}`, + ` ${renderLegend("composition", items)}`, + `
    `, + ].join("\n"); +} + +/** One age bucket, with its caption already resolved. */ +interface AgeSource { + caption: string; + bytes: number; + entries: number; + oldest: boolean; + index: number; +} + +/** How many colours the age ramp has: cold/fresh through hot/old. */ +export const AGE_RAMP_STEPS = 4; + +/** + * Where one age bucket sits on the ramp, `0` (newest) to `3` (oldest). + * + * The bar used to paint every bucket but the last in one colour, so four + * segments read as a single block and the chart said nothing that the legend + * under it did not already say. The step is computed here, in the renderer, + * rather than in CSS, because the bucket count follows `mcpp.cache.staleDays` + * and the stylesheet cannot count. A two-bucket machine gets the two ends, a + * four-bucket one gets all four, and anything longer is dealt onto the same four + * stops — the ramp is a shape, not a promise about how many buckets exist. + */ +export function ageRampStep(index: number, count: number): number { + if (!Number.isFinite(index) || !Number.isFinite(count) || count <= 1) { + return 0; + } + const clamped = Math.min(Math.max(index, 0), count - 1); + return Math.round((clamped / (count - 1)) * (AGE_RAMP_STEPS - 1)); +} + +/** The age bar: one segment per bucket, `<1d` through the overflow. */ +function renderAge(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const buckets = model.shared.available ? model.shared.buckets : []; + const sources: AgeSource[] = []; + let accounted = 0; + + buckets.forEach((bucket, index) => { + const from = finite(bucket.fromDays); + const to = bucket.toDays === undefined ? undefined : finite(bucket.toDays); + const caption = + to === undefined + ? fill(label, CACHE_PANEL_UI.ageOverflow, [formatters.count(from)]) + : from <= 0 + ? fill(label, CACHE_PANEL_UI.ageUnder, [formatters.count(to)]) + : fill(label, CACHE_PANEL_UI.ageRange, [formatters.count(from), formatters.count(to)]); + sources.push({ + caption, + bytes: bucket.bytes, + entries: bucket.entries, + oldest: index === buckets.length - 1, + index, + }); + accounted += finite(bucket.entries); + }); + + const ageTotal = sources.reduce((sum, source) => sum + Math.max(0, finite(source.bytes)), 0); + const heading = `

    ${escapeHtml(label(CACHE_PANEL_UI.age))}

    `; + const notes: string[] = []; + const unaccounted = finite(model.shared.totalEntries) - accounted; + if (model.shared.available && unaccounted > 0) { + notes.push( + `

    ${escapeHtml( + fill(label, CACHE_PANEL_UI.ageUnknown, [formatters.count(unaccounted)]), + )}

    `, + ); + } + + if (sources.length === 0 || ageTotal <= 0) { + return [ + `
    `, + ` ${heading}`, + ...notes.map((note) => ` ${note}`), + `

    ${escapeHtml(label(CACHE_PANEL_UI.ageEmpty))}

    `, + `
    `, + ].join("\n"); + } + + const parts: BarPart[] = []; + const items: string[] = []; + for (const source of sources) { + const step = ageRampStep(source.index, sources.length); + const attributes = + ` data-bucket="${source.oldest ? "oldest" : "recent"}"` + + ` data-bucket-index="${source.index}" data-age-step="${step}"`; + parts.push({ attributes, label: source.caption, bytes: source.bytes }); + const pct = percent(source.bytes, ageTotal); + const detail = [ + source.caption, + `${percentText(pct)}%`, + formatters.bytes(finite(source.bytes)), + formatters.count(finite(source.entries)), + ].join(" · "); + items.push(legendItem(attributes, source.caption, pct, detail)); + } + + return [ + `
    `, + ` ${heading}`, + ...notes.map((note) => ` ${note}`), + ` ${renderBar(parts, ageTotal, "viz-bar viz-bar-thin")}`, + ` ${renderLegend("age", items)}`, + `
    `, + ].join("\n"); +} + +/** + * The largest packages, inside a second collapsed `
    ` (§8.1). Each label + * is itself the `showEntry` button, so dropping the old "Actions" column does + * not drop the drill-down. + */ +function renderTop(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + if (model.shared.top.length === 0) { + return [ + `
    `, + `

    ${escapeHtml(label(CACHE_PANEL_UI.topEmpty))}

    `, + `
    `, + ].join("\n"); + } + + const head: Array<[string, string]> = [ + [CACHE_PANEL_UI.colLabel, label(CACHE_PANEL_UI.colLabel)], + [CACHE_PANEL_UI.colEntries, label(CACHE_PANEL_UI.colEntries)], + [CACHE_PANEL_UI.colBytes, label(CACHE_PANEL_UI.colBytes)], + [CACHE_PANEL_UI.colOldest, label(CACHE_PANEL_UI.colOldest)], + ]; + const header = `${head.map(([, text]) => `${escapeHtml(text)}`).join("")}`; + + const rows = model.shared.top.map((row) => { + const iso = isoSeconds(row.oldestAccessed); + const oldest = + iso === undefined + ? `${escapeHtml(label(CACHE_PANEL_UI.unknown))}` + : ``; + const detailsFor = fill(label, CACHE_PANEL_UI.detailsFor, [row.label]); + return [ + ``, + // `data-head` carries the column name onto the cell, so the stylesheet can + // stack the table into labelled rows at 200 px without a second renderer. + ` ` + + ``, + ` ${escapeHtml(formatters.count(finite(row.entries)))}`, + ` ${escapeHtml(formatters.bytes(finite(row.bytes)))}`, + ` ${oldest}`, + ``, + ].join("\n"); + }); + + return [ + `
    `, + ` ${escapeHtml( + fill(label, CACHE_PANEL_UI.top, [formatters.count(finite(model.limits.topN))]), + )}`, + ` `, + ` ${header}`, + ` `, + rows.join("\n"), + ` `, + `
    `, + `
    `, + ].join("\n"); +} + +/** + * The ones that must be asked for (plan §8.1): the budget input stays next to + * the button that uses it — `mcpp cache gc --max-size` has no implicit default, + * so the figure has to come from somewhere the user can see. + */ +function renderSharedActions(model: CachePanelModel, label: UiLabel): string { + const sharedAvailable = model.shared.available; + const legacyBytes = finite(model.legacy?.bytes ?? 0); + const sharedReason = reasonText(label, CACHE_PANEL_UI.reasonShared, model.shared.note); + const budget = model.limits.budgetGiB; + const value = budget === undefined || !Number.isFinite(budget) ? undefined : Math.max(0, Math.floor(budget)); + const budgetHint = label(CACHE_PANEL_UI.budgetHint); + const estimate = + model.estimate === undefined || model.estimate.length === 0 + ? "" + : `${escapeHtml(model.estimate)}`; + return [ + ``, + ].filter((line) => line.length > 0).join("\n"); +} + +/** + * The global block. Expanded it is the composition bar, the age bar, the nested + * largest-packages list and the shared actions; collapsed it is one summary line + * (`7.20 GiB · 657 entries`). The markup carries **no `open` attribute**, which + * is what makes the first render collapsed. + */ +function renderSharedBlock(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const notes: string[] = []; + if (model.shared.root !== undefined) { + notes.push( + `

    ${escapeHtml(fill(label, CACHE_PANEL_UI.sharedRoot, [model.shared.root]))}

    `, + ); + } + if (model.legacy !== undefined && model.legacy.path !== undefined) { + notes.push( + `

    ${escapeHtml(fill(label, CACHE_PANEL_UI.legacyPath, [model.legacy.path]))}

    `, + ); + } + + const body = [ + renderComposition(model, label), + renderAge(model, label), + renderTop(model, label), + renderSharedActions(model, label), + ...notes, + ].join("\n"); + + if (!model.shared.available) { + // A failure must not be hidden behind a collapsed disclosure: render the + // block open, with the reason where the summary would have been. + return [ + `
    `, + `

    ${escapeHtml(label(CACHE_PANEL_UI.sharedTitle))}

    `, + `

    ${escapeHtml(model.shared.note ?? label(CACHE_PANEL_UI.sharedUnavailable))}

    `, + renderSharedActions(model, label), + `
    `, + ].join("\n"); + } + + const summary = `${formatters.bytes(finite(model.shared.totalBytes))} · ${fill( + label, + CACHE_PANEL_UI.sharedEntries, + [formatters.count(finite(model.shared.totalEntries))], + )}`; + return [ + `
    `, + ` `, + ` ${escapeHtml(label(CACHE_PANEL_UI.sharedTitle))}`, + ` ${escapeHtml(summary)}`, + ` `, + `
    `, + body, + `
    `, + `
    `, + ].join("\n"); +} + +// ───────────────────────────────────────────────────────────────── client + +/** + * The client. Dependency-free, no template literals of its own, and it only + * posts messages: the host re-renders the whole document after every action, so + * there is exactly one renderer instead of two. + * + * The open/closed state of the two `
    ` is the one thing the client + * remembers (`setState`), so re-rendering after an action does not re-collapse + * the block the user just opened. The document itself still ships collapsed. + */ +function clientScript(): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var saved = {}; + if (api && typeof api.getState === "function") { + try { saved = api.getState() || {}; } catch (error) { saved = {}; } + } + + function post(message) { + if (api) { api.postMessage(message); } + } + + function detailsList() { + return document.querySelectorAll("details[data-details]"); + } + + function restoreOpen() { + var open = saved && typeof saved.open === "object" && saved.open !== null ? saved.open : {}; + var all = detailsList(); + for (var index = 0; index < all.length; index += 1) { + var id = all[index].getAttribute("data-details"); + if (id && open[id] === true) { all[index].open = true; } + } + } + + function rememberOpen() { + var open = {}; + var all = detailsList(); + for (var index = 0; index < all.length; index += 1) { + var id = all[index].getAttribute("data-details"); + if (id) { open[id] = all[index].open === true; } + } + saved.open = open; + if (api && typeof api.setState === "function") { api.setState(saved); } + } + + function budgetGiB() { + var input = document.getElementById("cache-budget"); + if (!input) { return undefined; } + var text = String(input.value).trim(); + if (text.length === 0) { return undefined; } + var value = Number(text); + if (!isFinite(value) || value < 0) { return undefined; } + return value; + } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var button = target.closest("button"); + if (!button || button.disabled) { return; } + var entry = button.getAttribute("data-show-entry"); + if (entry) { post({ type: "showEntry", label: entry }); return; } + var action = button.getAttribute("data-action"); + if (!action) { return; } + if (action === "collect") { + var value = budgetGiB(); + if (value === undefined) { return; } + post({ type: "collect", budgetGiB: value }); + return; + } + post({ type: action }); + }); + + // A toggle event does not bubble, so the listener captures it on the way down. + document.addEventListener("toggle", function (event) { + var target = event.target; + if (target && typeof target.getAttribute === "function" && target.hasAttribute("data-details")) { + rememberOpen(); + } + }, true); + + restoreOpen(); +})();`; +} + +// ───────────────────────────────────────────────────────────── the document + +/** The whole document. */ +export function renderCachePanelHtml(model: CachePanelModel, assets: CachePanelAssets): string { + const label: UiLabel = (key) => model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const body = [ + renderWarnings(model, label), + renderProjectBlock(model, label), + renderSharedBlock(model, label), + `

    ${escapeHtml(label(CACHE_PANEL_UI.boundary))}

    `, + ] + .filter((part) => part.length > 0) + .join("\n"); + return ` + + + + + + +${escapeHtml(label(CACHE_PANEL_UI.title))} + + +

    ${escapeHtml(label(CACHE_PANEL_UI.title))}

    +
    +${body} +
    + + + +`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not exactly one of the eight shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + * + * `collect` needs a finite, non-negative `budgetGiB`; a payload without one is + * not a request this panel can honour. + */ +export function decodeCachePanelMessage(raw: unknown): CachePanelMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "refresh": + case "cleanStale": + case "cleanProject": + case "prune": + case "verify": + case "cleanLegacy": + return { type: raw.type }; + case "collect": { + const budget = raw.budgetGiB; + if (typeof budget !== "number" || !Number.isFinite(budget) || budget < 0) { + return undefined; + } + return { type: "collect", budgetGiB: budget }; + } + case "showEntry": + return nonEmptyString(raw.label) ? { type: "showEntry", label: raw.label } : undefined; + default: + return undefined; + } +} diff --git a/mide-plugins/vscode/src/cache/cacheState.ts b/mide-plugins/vscode/src/cache/cacheState.ts new file mode 100644 index 0000000..f90b9e9 --- /dev/null +++ b/mide-plugins/vscode/src/cache/cacheState.ts @@ -0,0 +1,188 @@ +/** + * The cache view's decisions, as **data**. + * + * Everything here is deliberately free of `vscode` and of the file system, so the + * settings that shape the cache view can be tested directly: + * + * - whether the pre-v1 cache node is offered (`mcpp.cache.showLegacy`, §8 G6); + * - what the panel is allowed to say about that cache; + * - the warning node for `mcpp.cache.warnAboveGiB`; + * - the extra confirmation `mcpp.cache.gc.confirmAboveGiB` adds; + * - the snapshot the environment self-check reads (`showSelfCheck`, §8 G9). + * + * `src/cache/cacheView.ts` reads the settings and hands the values in; the + * `vscode` layer therefore stays thin enough to read in one sitting. + */ + +import { formatBytes } from "../util/format"; + +/** 1 GiB, binary — the unit both cache size settings are declared in. */ +export const BYTES_PER_GIB = 1024 ** 3; + +/** + * A node built here rather than in `./models.ts`, so the tree model needs no + * edit: the shape is structurally the tree's `TreeNode`. The cache view itself is + * a sidebar `WebviewView` since §8.1 and no longer builds a tree, so this node is + * kept as the policy (§8 G6) that the warning threshold is decided in one pure + * place — the view states the same threshold in + * `src/cache/cachePanelHtml.ts`'s warning banner. + */ +export interface CacheTreeNode { + id: string; + label: { key: string; args?: readonly (string | number)[] }; + description?: { key: string; args?: readonly (string | number)[] }; + tooltip?: { key: string; args?: readonly (string | number)[] }; + icon?: string; + contextValue?: string; + command?: { command: string; title: { key: string; args?: readonly (string | number)[] } }; + children?: readonly CacheTreeNode[]; +} + +/** `mcpp.cache.showLegacy`, as the view reads it. */ +export interface LegacyGate { + enabled: boolean; + bytes?: number; + files?: number; + truncated?: boolean; +} + +export type LegacyDisplay = "shown" | "off" | "empty" | "unknown"; + +/** Why the pre-v1 node is or is not in the tree. Pure, so the policy is testable. */ +export function legacyDisplay(gate: LegacyGate): LegacyDisplay { + if (!gate.enabled) { + return "off"; + } + if (gate.bytes === undefined) { + return "unknown"; + } + return gate.bytes > 0 ? "shown" : "empty"; +} + +/** + * The figure the tree needs, or `undefined` when no node should exist. Kept next + * to {@link legacyDisplay} so "the node renders only when the setting is on and + * the directory is non-empty" is stated once. + */ +export function legacyBytesForTree(gate: LegacyGate): number | undefined { + return legacyDisplay(gate) === "shown" ? gate.bytes : undefined; +} + +/** What the panel may show: the same rule, plus the path it can offer to clean. */ +export function legacyForPanel( + gate: LegacyGate, + path: string | undefined, +): { bytes: number; path?: string } | undefined { + if (legacyDisplay(gate) !== "shown" || path === undefined) { + return undefined; + } + return { bytes: gate.bytes ?? 0, path }; +} + +/** + * The warning the tree shows when the shared cache reached `mcpp.cache.warnAboveGiB`. + * + * `0` — and a non-positive or unreadable threshold — disables the warning + * entirely, which is what the registry's description promises. `bytes` must come + * from the same read the rest of the view uses; a missing inventory is not a + * warning (nothing was measured, so nothing is known to be large). + */ +export function buildCacheWarningNode(bytes: number, thresholdGiB: number): CacheTreeNode | undefined { + if (!Number.isFinite(thresholdGiB) || thresholdGiB <= 0) { + return undefined; + } + if (!Number.isFinite(bytes) || bytes < thresholdGiB * BYTES_PER_GIB) { + return undefined; + } + const thresholdBytes = thresholdGiB * BYTES_PER_GIB; + return { + id: "cache.warning", + label: { key: "Cache warning" }, + description: { key: "{0} · at or above {1}", args: [formatBytes(bytes), formatBytes(thresholdBytes)] }, + tooltip: { + key: "The shared build cache is {0}. Raise or clear mcpp.cache.warnAboveGiB to change when this appears.", + args: [formatBytes(bytes)], + }, + icon: "warning", + contextValue: "mcppCacheWarning", + // The cache view is a sidebar `WebviewView` now, so the node points at the + // view's own focus command (VS Code registers `.focus` for every + // contributed view) rather than at the removed `mcpp.showCachePanel`. + command: { command: "mcpp.cache.focus", title: { key: "Cache statistics" } }, + }; +} + +/** `mcpp.cache.gc.confirmAboveGiB`: does this budget add a confirmation level? */ +export function gcBudgetNeedsExtraConfirm(budgetGiB: number, confirmAboveGiB: number): boolean { + if (!Number.isFinite(budgetGiB) || budgetGiB < 0) { + return false; + } + if (!Number.isFinite(confirmAboveGiB) || confirmAboveGiB < 0) { + return false; + } + // 0 means "confirm every cleanup", including the one with no budget at all. + return confirmAboveGiB === 0 || budgetGiB >= confirmAboveGiB; +} + +export interface GcBudgetDialog { + /** Substituted into the caller's `detail` key: the budget in GiB. */ + args: readonly (string | number)[]; +} + +/** + * The data the extra level's dialogue needs. + * + * It deliberately carries no sentences: the caller writes the translation + * literals (as `src/cli/clean.ts` does with its `titleKey`/`detailKey`), which is + * what makes `tools/l10n-check.mjs` see them. This module only decides *when* the + * dialogue happens and what number it names. + */ +export function gcBudgetDialog(budgetGiB: number, confirmAboveGiB: number): GcBudgetDialog { + // A budget of 0 means "no budget given": the threshold is the figure worth + // naming in that case. + const named = budgetGiB > 0 ? budgetGiB : confirmAboveGiB; + return { args: named > 0 ? [named] : [] }; +} + +/** What the environment self-check puts under `Cache` (`cli/selfCheck.ts` §8 G9). */ +export interface CacheSnapshot { + totalBytes: number; + entries: number; + incomplete: number; +} + +/** + * The snapshot from an inventory the caller already read. No query is repeated + * here; `readCacheSnapshot()` in `src/cache/cacheView.ts` is the one that runs + * them, and it reuses the cache view's own read path. + */ +export function cacheSnapshotFrom( + inventory: { totalBytes: number; totalEntries: number; incomplete: number } | undefined, +): CacheSnapshot | undefined { + if (inventory === undefined) { + return undefined; + } + return { + totalBytes: Number.isFinite(inventory.totalBytes) ? inventory.totalBytes : 0, + entries: Number.isFinite(inventory.totalEntries) ? inventory.totalEntries : 0, + incomplete: Number.isFinite(inventory.incomplete) ? inventory.incomplete : 0, + }; +} + +export interface TimerDecision { + active: boolean; + seconds: number; +} + +/** + * Whether one of the two view timers should be running. + * + * Every condition that turns a timer off is stated once, in one place: + * `0` (or anything that is not a positive finite number) disables it, and so does + * a view that is not visible. The two settings differ only in what feeds + * `viewVisible`. + */ +export function refreshTimerDecision(seconds: number, viewVisible: boolean): TimerDecision { + const usable = Number.isFinite(seconds) && seconds > 0; + return { active: usable && viewVisible, seconds: usable ? seconds : 0 }; +} diff --git a/mide-plugins/vscode/src/cache/cacheView.ts b/mide-plugins/vscode/src/cache/cacheView.ts new file mode 100644 index 0000000..40ef2cc --- /dev/null +++ b/mide-plugins/vscode/src/cache/cacheView.ts @@ -0,0 +1,762 @@ +/** + * The cache view and every cleanup command. + * + * The view is a sidebar **WebviewView** (`mcpp.cache`, plan §8.1): the numbers + * and the bars live in one document whose host is `src/cache/cachePanel.ts`, and + * whose structure is the pure `src/cache/cachePanelHtml.ts`. This file owns the + * data behind it, the status item, the auto-refresh timer and the commands. + * + * The *policy* — which argv, how much confirmation, whether a preview is shown — + * lives in `src/cli/clean.ts` and is unit-tested there. This file only asks the + * user, runs the command and reports the result, so the safety rules cannot be + * edited by accident in a UI change. + * + * Nothing here removes anything by itself: every destructive path goes through + * `planClean`, a preview when the plan asks for one, and a modal. + * + * Four settings shape this file: + * + * - `mcpp.cache.showLegacy` decides whether the pre-v1 cache is measured and + * offered at all (`src/cache/cacheState.ts`, §8 G6); + * - `mcpp.cache.warnAboveGiB` adds a warning when the shared cache is large; + * - `mcpp.cache.autoRefreshSeconds` re-reads while the view is on screen; + * - `mcpp.cache.gc.confirmAboveGiB` adds one confirmation level to a large `gc`. + */ + +import * as vscode from "vscode"; + +import { estimateArtifacts, measureDirectory, type ArtifactEstimate } from "../cli/artifacts"; +import { parseCacheDir, parseCacheList, summarizeCache, type CacheInventory, type CacheEntry } from "../cli/cache"; +import { planClean, withSharedCache, type CleanPlan } from "../cli/clean"; +import { runMcpp, type ProcessResult } from "../cli/process"; +import { CACHE_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { format as formatMessage, t } from "../i18n/t"; +import { PollTimer } from "../mcppls/timers"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import { formatBytes, projectGc } from "../util/format"; +import { clampOutput } from "../util/text"; +import { + cacheSnapshotFrom, + gcBudgetDialog, + gcBudgetNeedsExtraConfirm, + legacyForPanel, + refreshTimerDecision, + type CacheSnapshot, +} from "./cacheState"; +import { + CACHE_VIEW_ID, + CACHE_VIEW_FOCUS_COMMAND, + registerCachePanel, + type CachePanelData, + type CachePanelProvider, +} from "./cachePanel"; + +export { CACHE_VIEW_ID, CACHE_VIEW_FOCUS_COMMAND }; + +/** The tick granularity; `mcpp.cache.autoRefreshSeconds` is in seconds. */ +const REFRESH_TICK_MS = 100; + +/** The pre-v1 walk is a courtesy figure, so it gets a smaller budget than `target/`. */ +const LEGACY_MAX_ENTRIES = 20_000; + +/** `mcpp.runtime.timeoutSeconds`, or the built-in default when it is 0. */ +function queryTimeoutMs(): number | undefined { + const seconds = read("mcpp.runtime.timeoutSeconds"); + return seconds > 0 ? seconds * 1000 : 60_000; +} +const CLEAN_TIMEOUT_MS = 300_000; + +export interface CacheViewDeps { + output: vscode.OutputChannel; + currentProject: () => McppProjectDiscovery | undefined; + mcppExecutable: (project: McppProjectDiscovery | undefined) => string; + isTrusted: () => boolean; +} + +/** What `mcpp cache dir` reported about the pre-v1 cache, measured or not. */ +interface LegacyCache { + path?: string; + bytes?: number; + files?: number; + truncated?: boolean; +} + +interface CacheState { + entries: CacheEntry[]; + inventory?: CacheInventory; + artifacts?: ArtifactEstimate; + legacy?: LegacyCache; + error?: string; +} + +function workingDirectory(project: McppProjectDiscovery | undefined): string | undefined { + return project?.root; +} + +/** Unix seconds -> an ISO instant the panel can print; the panel does not do dates. */ +function timestamp(seconds: number | undefined): string | undefined { + return seconds === undefined || !Number.isFinite(seconds) ? undefined : new Date(seconds * 1000).toISOString(); +} + +function numberFormat(): "binary" | "decimal" { + return read("mcpp.ui.numberFormat") === "decimal" ? "decimal" : "binary"; +} + +/** + * `mcpp.cache.showLegacy`: the whole rule in one object, so the panel and the + * self-check read the same answer. + */ +function legacyGate(settings: { showLegacy: boolean }, legacy: LegacyCache | undefined): { + enabled: boolean; + bytes?: number; + files?: number; + truncated?: boolean; +} { + return { + enabled: settings.showLegacy, + bytes: legacy?.bytes, + files: legacy?.files, + truncated: legacy?.truncated, + }; +} + +function viewSettings(): { + topN: number; + ageBoundaries: string[]; + showLegacy: boolean; + warnAboveGiB: number; + autoRefreshSeconds: number; +} { + return { + topN: Math.max(1, read("mcpp.views.cache.topN")), + ageBoundaries: read("mcpp.views.cache.ageBuckets"), + showLegacy: read("mcpp.cache.showLegacy") !== false, + warnAboveGiB: read("mcpp.cache.warnAboveGiB"), + autoRefreshSeconds: read("mcpp.cache.autoRefreshSeconds"), + }; +} + +async function appendResult( + deps: CacheViewDeps, + title: string, + executable: string, + argv: readonly string[], + cwd: string | undefined, + result: ProcessResult, +): Promise { + try { + deps.output.appendLine(`\n[${new Date().toISOString()}] ${title}`); + deps.output.appendLine(`$ ${[executable, ...argv].join(" ")}`); + const stdout = clampOutput(result.stdout); + const stderr = clampOutput(result.stderr); + if (stdout.text.trim().length > 0) deps.output.appendLine(stdout.text.trimEnd()); + if (stderr.text.trim().length > 0) deps.output.appendLine(stderr.text.trimEnd()); + deps.output.appendLine(`[exit ${result.exitCode}]`); + } catch { + // The channel can already be gone during shutdown. + } +} + +async function run( + deps: CacheViewDeps, + project: McppProjectDiscovery | undefined, + argv: readonly string[], + options: { timeoutMs?: number; quiet?: boolean } = {}, +): Promise { + const executable = deps.mcppExecutable(project); + const cwd = workingDirectory(project); + const result = options.quiet === true + ? await runMcpp(deps.isTrusted(), executable, [...argv], cwd, { timeoutMs: options.timeoutMs, maxBufferMiB: read("mcpp.runtime.maxOutputMiB") }) + : await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: `mcpp ${argv[0]}` }, + () => runMcpp(deps.isTrusted(), executable, [...argv], cwd, { timeoutMs: options.timeoutMs }), + ); + if (result === undefined) { + // The seam refuses in an untrusted workspace; the callers gate with their + // own message, so this is the defensive shape of the same refusal. + return { exitCode: 1, stdout: "", stderr: t("the workspace is not trusted") }; + } + if (result.exitCode !== 0 || options.quiet !== true) { + await appendResult(deps, `mcpp ${argv.join(" ")}`, executable, argv, cwd, result); + } + return result; +} + +async function preview(title: string, content: string): Promise { + const document = await vscode.workspace.openTextDocument({ + content: `# ${title}\n\n\`\`\`\n${content.trimEnd()}\n\`\`\`\n`, + language: "markdown", + }); + await vscode.window.showTextDocument(document, { preview: true, preserveFocus: true }); +} + +/** + * Ask for confirmation, honouring the plan's level. Level 3 asks twice: once as + * a modal and once with an explicit acknowledgement, because that is the one + * that reaches every project on the machine. + */ +async function confirmPlan(plan: CleanPlan, detail: string, figures: string): Promise { + const body = `${t(plan.detailKey, figures)}\n\n${detail}`; + if (plan.level === 0) { + return true; + } + const runLabel = t("Run"); + const choice = await vscode.window.showWarningMessage( + t(plan.titleKey), + { modal: true, detail: body }, + runLabel, + ); + if (choice !== runLabel) { + return false; + } + if (!plan.acknowledge) { + return true; + } + const acknowledge = t("I understand this affects every mcpp project on this machine"); + const second = await vscode.window.showWarningMessage( + t("Confirm once more"), + { modal: true, detail: body }, + acknowledge, + ); + return second === acknowledge; +} + +/** + * The extra level `mcpp.cache.gc.confirmAboveGiB` adds. + * + * It is asked *before* the plan's own modal, so a refused acknowledgement never + * reaches the dialogue that would have run the command. It never replaces a + * level: a plan that already demands an acknowledgement is not asked a third + * time, and the plan's own modal still runs afterwards. + * + * The sentences live here rather than in `cacheState.ts` so + * `tools/l10n-check.mjs` sees them as translation call literals; the decision and + * the figure come from the pure module. + */ +export function extraGcConfirmation( + plan: CleanPlan, + budgetGiB: number, + confirmAboveGiB: number, +): { title: string; detail: string; acknowledge: string; args: readonly (string | number)[] } | undefined { + if (plan.action !== "cacheGc" || plan.acknowledge) { + return undefined; + } + if (!gcBudgetNeedsExtraConfirm(budgetGiB, confirmAboveGiB)) { + return undefined; + } + const dialog = gcBudgetDialog(budgetGiB, confirmAboveGiB); + return { + title: t("Confirm a cleanup this large"), + detail: t( + "This {0} GiB budget frees entries that every mcpp project on this machine shares. The cleanup can take a while, and any dropped entry is rebuilt on next use.", + ), + acknowledge: t("I understand this removes entries other mcpp projects may use"), + args: dialog.args, + }; +} + +/** + * The cache view and its commands. + * + * Returns the sidebar provider it registered, so a caller that would rather own + * the `registerWebviewViewProvider` call can register this value itself instead + * (see `registerCachePanel`). Ignoring the return value is the normal case: the + * view is registered here, and `extension.ts` needs no extra line. + */ +export function registerCacheView(context: vscode.ExtensionContext, deps: CacheViewDeps): CachePanelProvider { + let state: CacheState = { entries: [] }; + /** Guards the interval: one refresh at a time, and none after a failure. */ + let refreshInFlight: Promise | undefined; + /** + * The sidebar view. Assigned after the timer below is declared, because the + * timer asks the provider whether the view is on screen; everything that reads + * it does so long after `registerCacheView` has returned. + */ + let provider: CachePanelProvider | undefined; + const viewVisible = (): boolean => provider?.visible === true; + + // An optional, second status item. Off by default: the C++ Modules extension + // already owns a status item, and the mcpp quick menu owns ours. + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 39); + // `mcpp.cache.focus` is registered by VS Code for the contributed view, so the + // status item can bring the sidebar view on screen without a command of ours. + status.command = CACHE_VIEW_FOCUS_COMMAND; + context.subscriptions.push(status); + + const updateStatus = (): void => { + if (!read("mcpp.cache.statusBar")) { + status.hide(); + return; + } + const total = state.inventory?.totalBytes; + status.text = total === undefined ? "$(database) mcpp" : `$(database) ${formatBytes(total, numberFormat())}`; + status.tooltip = t("Shared build cache"); + status.show(); + }; + + const applyState = (next: CacheState): void => { + state = next; + setLastCacheSnapshot( + next.inventory === undefined + ? undefined + : cacheSnapshotFrom({ + totalBytes: next.inventory.totalBytes, + totalEntries: next.inventory.totalEntries, + incomplete: next.inventory.incomplete.length, + }), + ); + }; + + /** + * Re-read everything the view shows. It deliberately does **not** redraw the + * webview: the draw path (`CachePanelDeps.read` is `read` + `panelData`) calls + * this first, and a redraw from inside here would ask itself for the same + * figures again. + */ + const refreshNow = async (): Promise => { + const project = deps.currentProject(); + const settings = viewSettings(); + const next: CacheState = { entries: [] }; + + if (!deps.isTrusted()) { + next.error = t("the workspace is not trusted"); + applyState(next); + return; + } + + const listed = await run(deps, project, ["cache", "list", "--format", "json"], { + timeoutMs: queryTimeoutMs(), + quiet: true, + }); + if (listed.exitCode === 0) { + const parsed = parseCacheList(listed.stdout); + if (parsed === undefined) { + next.error = t("mcpp cache list did not return the documented document"); + } else { + next.entries = parsed.entries; + next.inventory = summarizeCache(parsed.root, parsed.entries, { + topN: settings.topN, + ageBoundaries: settings.ageBoundaries, + }); + } + } else { + next.error = t("mcpp cache list failed (exit {0})", listed.exitCode); + } + + const dir = await run(deps, project, ["cache", "dir"], { timeoutMs: queryTimeoutMs(), quiet: true }); + if (dir.exitCode === 0) { + const parsed = parseCacheDir(dir.stdout); + // The pre-v1 directory is measured only when the setting offers it: a + // user who turned `mcpp.cache.showLegacy` off pays for no walk at all. + const measured = + settings.showLegacy && parsed.legacyPath !== undefined + ? measureDirectory(parsed.legacyPath, { maxEntries: LEGACY_MAX_ENTRIES }) + : undefined; + next.legacy = { + path: parsed.legacyPath, + bytes: measured?.totalBytes, + files: measured?.files, + truncated: measured?.truncated !== undefined, + }; + } + + if (project !== undefined && read("mcpp.cache.estimateProjectBytes")) { + next.artifacts = estimateArtifacts(project.root); + } + + applyState(next); + updateStatus(); + }; + + /** Single-flight: an interval tick during a read must not start a second one. */ + const refresh = (): Promise => { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = refreshNow().finally(() => { + refreshInFlight = undefined; + }); + return refreshInFlight; + }; + + /** The figures a confirmation dialogue needs, without running anything new. */ + const figures = (): string => { + const total = state.inventory?.totalBytes; + return total === undefined ? t("(size unknown)") : t("{0} in the shared cache", formatBytes(total)); + }; + + const runPlan = async (project: McppProjectDiscovery | undefined, plan: CleanPlan, extra = ""): Promise => { + const detail = `${t("Command")}: mcpp ${plan.argv.join(" ")}${extra}`; + if (!(await confirmPlan(plan, detail, figures()))) { + return; + } + const result = await run(deps, project, plan.argv, { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + await refresh(); + return; + } + void vscode.window.showErrorMessage(t("mcpp {0} failed with exit code {1}", plan.argv.join(" "), result.exitCode)); + }; + + /** `runPlan` plus the one extra level `mcpp.cache.gc.confirmAboveGiB` adds. */ + const runGcPlan = async ( + project: McppProjectDiscovery | undefined, + budgetGiB: number, + extra: string, + ): Promise => { + const plan = planClean("cacheGc", { budgetGiB }); + const dialog = extraGcConfirmation(plan, budgetGiB, read("mcpp.cache.gc.confirmAboveGiB")); + // The extra level is asked *before* the plan's own modal, so a refused + // acknowledgement never reaches the one that would have run the command. + // `extraGcConfirmation` already resolved the strings through `t()`. + if (dialog !== undefined) { + const choice = await vscode.window.showWarningMessage( + dialog.title, + { modal: true, detail: formatMessage(dialog.detail, dialog.args) }, + dialog.acknowledge, + ); + if (choice !== dialog.acknowledge) { + return; + } + } + await runPlan(project, plan, extra); + }; + + const requireTrusted = (): boolean => { + if (deps.isTrusted()) { + return true; + } + void vscode.window.showWarningMessage( + t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."), + ); + return false; + }; + + const register = (id: string, handler: (...args: unknown[]) => Promise): void => { + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]) => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.output.appendLine(`mcpp: ${message}`); + void vscode.window.showErrorMessage(t("mcpp: {0}", message)); + } + }), + ); + }; + + /** What the view shows, from the same state the commands use. Runs nothing. */ + const panelData = (): CachePanelData => { + const inventory = state.inventory; + const artifacts = state.artifacts; + return { + project: { + available: artifacts !== undefined && artifacts.exists, + note: + artifacts === undefined + ? t("Project artifacts could not be measured.") + : artifacts.exists + ? undefined + : t("No target/ directory"), + totalBytes: artifacts?.totalBytes ?? 0, + files: artifacts?.files ?? 0, + groups: artifacts?.byTopLevel.length ?? 0, + truncated: artifacts?.truncated, + }, + shared: { + available: inventory !== undefined, + note: inventory === undefined ? state.error ?? t("The shared build cache could not be read.") : undefined, + root: inventory?.root, + totalBytes: inventory?.totalBytes ?? 0, + totalEntries: inventory?.totalEntries ?? 0, + byKind: inventory?.byKind ?? [], + buckets: inventory?.ageBuckets ?? [], + top: (inventory?.topLabels ?? []).map((entry) => ({ + label: entry.label, + entries: entry.entries, + bytes: entry.bytes, + oldestAccessed: entry.oldestAccessed, + })), + incomplete: inventory?.incomplete.length ?? 0, + oldestAccessed: timestamp(inventory?.oldestAccessed), + newestAccessed: timestamp(inventory?.newestAccessed), + }, + legacy: legacyForPanel(legacyGate(viewSettings(), state.legacy), state.legacy?.path), + }; + }; + + // `mcpp.cache.autoRefreshSeconds`: re-read while the view is on screen, never + // in an untrusted workspace, and never on top of a read that is still running. + // The tick only redraws: the provider's own read path performs the refresh, so + // one tick costs one round of queries. + const timer = new PollTimer({ + periodMs: 0, + tick: (): void => { + if (viewVisible() && deps.isTrusted()) { + provider?.refresh(); + } + }, + }); + context.subscriptions.push(timer); + const applyTimer = (): void => { + const seconds = viewSettings().autoRefreshSeconds; + const usable = deps.isTrusted() && Number.isFinite(seconds) && seconds > 0; + const decision = refreshTimerDecision(usable ? seconds : 0, viewVisible()); + timer.start(decision.active ? decision.seconds * 1000 : 0); + }; + applyTimer(); + + // The sidebar webview view: `mcpp.cache` is a `"type": "webview"` view, so + // this provider (not a tree) is what draws it. Every destructive action still + // goes through `runPlan` above. + provider = registerCachePanel(context, { + refresh, + read: panelData, + run: async (message) => { + const project = deps.currentProject(); + switch (message.type) { + case "cleanStale": + await runPlan(project, planClean("stale", { staleDays: read("mcpp.cache.staleDays") })); + return; + case "cleanProject": + await runPlan(project, planClean("project")); + return; + case "collect": + await runGcPlan(project, message.budgetGiB, ""); + return; + case "prune": + await runPlan(project, planClean("cachePrune", { pruneAgeDays: read("mcpp.cache.pruneAgeDays") })); + return; + case "cleanLegacy": + await runPlan(project, planClean("cacheLegacy")); + return; + default: + await run(deps, project, ["cache", "verify"], { timeoutMs: CLEAN_TIMEOUT_MS }); + return; + } + }, + showEntry: async (label) => { + await vscode.commands.executeCommand(CACHE_COMMANDS.showEntry, label); + }, + onVisibilityChanged: () => applyTimer(), + }); + + register(CACHE_COMMANDS.refreshStats, async () => { + if (!requireTrusted()) return; + await refresh(); + provider?.refresh(); + }); + + register(CACHE_COMMANDS.showEntry, async (label) => { + if (!requireTrusted()) return; + if (typeof label !== "string" || label.length === 0) { + return; + } + const project = deps.currentProject(); + const result = await run(deps, project, ["cache", "info", label], { timeoutMs: queryTimeoutMs(), quiet: true }); + await preview(t("Cache entry {0}", label), result.stdout.length > 0 ? result.stdout : result.stderr); + }); + + register(CACHE_COMMANDS.cleanStale, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); + return; + } + const plan = planClean("stale", { staleDays: read("mcpp.cache.staleDays") }); + const dry = await run(deps, project, ["clean", "--dry-run"], { timeoutMs: CLEAN_TIMEOUT_MS, quiet: true }); + await preview(t("mcpp clean --dry-run"), dry.stdout.length > 0 ? dry.stdout : dry.stderr); + await runPlan(project, plan); + }); + + register(CACHE_COMMANDS.cleanProject, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); + return; + } + const base = planClean("project"); + const alsoCache = t("Also empty the shared build cache"); + // `mcpp.task.confirmClean` answers the same question here as it does on the + // task path. Turning it off skips only *this* modal: choosing to also empty + // the shared cache still escalates through the plan's own level 3. + const choice = read("mcpp.task.confirmClean") + ? await vscode.window.showWarningMessage( + t(base.titleKey), + { modal: true, detail: t(base.detailKey) }, + t("Run"), + alsoCache, + ) + : undefined; + if (read("mcpp.task.confirmClean") && choice === undefined) { + return; + } + const plan = choice === alsoCache ? withSharedCache(base) : base; + if (plan.acknowledge && choice === alsoCache) { + const acknowledge = t("I understand this affects every mcpp project on this machine"); + const second = await vscode.window.showWarningMessage( + t("Confirm once more"), + { modal: true, detail: t(plan.detailKey) }, + acknowledge, + ); + if (second !== acknowledge) { + return; + } + } + const result = await run(deps, project, plan.argv, { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + await refresh(); + } else { + void vscode.window.showErrorMessage(t("mcpp {0} failed with exit code {1}", plan.argv.join(" "), result.exitCode)); + } + }); + + register(CACHE_COMMANDS.collect, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await refresh(); + const total = state.inventory?.totalBytes ?? 0; + const suggested = read("mcpp.cache.gc.defaultBudgetGiB") || Math.max(1, Math.round(total / 1024 ** 3 / 2)); + const input = await vscode.window.showInputBox({ + title: t("Keep the shared build cache under how many GiB?"), + value: String(suggested), + validateInput: (value) => (/^\d+$/.test(value.trim()) ? undefined : t("Enter a whole number of GiB")), + }); + if (input === undefined) { + return; + } + const budgetGiB = Number.parseInt(input.trim(), 10); + // Local LRU projection: an estimate, offered so the budget is chosen with + // numbers rather than guessed. mcpp's own policy decides in the end. + const projection = projectGc(state.entries, budgetGiB * 1024 ** 3); + const estimate = + projection.removed.length === 0 + ? t("Already within {0} GiB.", budgetGiB) + : t("About {0} would be freed ({1} entries).", formatBytes(projection.freedBytes), projection.removed.length); + await runGcPlan(project, budgetGiB, `\n${estimate}`); + }); + + register(CACHE_COMMANDS.prune, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await runPlan(project, planClean("cachePrune", { pruneAgeDays: read("mcpp.cache.pruneAgeDays") })); + }); + + register(CACHE_COMMANDS.verify, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + const result = await run(deps, project, ["cache", "verify"], { timeoutMs: CLEAN_TIMEOUT_MS }); + if (result.exitCode === 0) { + const text = `${result.stdout}\n${result.stderr}`.trim(); + await preview(t("Cache verification"), text.length === 0 ? t("Every entry matches its manifest.") : text); + } else { + void vscode.window.showErrorMessage(t("mcpp cache verify failed with exit code {0}", result.exitCode)); + } + }); + + register(CACHE_COMMANDS.cleanLegacy, async () => { + if (!requireTrusted()) return; + const project = deps.currentProject(); + await runPlan(project, planClean("cacheLegacy")); + }); + + // First paint: show what the settings allow without running anything heavy. + if (deps.currentProject() !== undefined && read("mcpp.cache.estimateProjectBytes")) { + const project = deps.currentProject(); + if (project !== undefined) { + state.artifacts = estimateArtifacts(project.root); + } + } + provider.refresh(); + updateStatus(); + + // Keep the view, the timer and the status item in step with the settings. + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.views.cache") || event.affectsConfiguration("mcpp.cache") || event.affectsConfiguration("mcpp.ui.numberFormat")) { + provider?.refresh(); + updateStatus(); + applyTimer(); + } + }), + ); + + return provider; +} + +// ── the self-check's cache snapshot (§8 G9) ────────────────────────────────── + +/** A reading older than this is re-taken rather than reported as current. */ +const SNAPSHOT_FRESH_MS = 60_000; + +let lastSnapshot: { snapshot: CacheSnapshot; at: number } | undefined; + +function setLastCacheSnapshot(snapshot: CacheSnapshot | undefined): void { + lastSnapshot = snapshot === undefined ? undefined : { snapshot, at: Date.now() }; +} + +/** The configured `mcpp.path`, or `mcpp` — the same rule the CLI controller uses. */ +function snapshotExecutable(): string { + const configured = vscode.workspace.getConfiguration("mcpp").get("path", ""); + return configured.trim().length === 0 ? "mcpp" : configured.trim(); +} + +/** + * `showSelfCheck` calls this to fill `buildSelfCheckText`'s `cache` field. + * + * It runs the same read-only `mcpp cache list --format json` the cache view runs + * and reuses that view's own aggregation, so the self-check and the view can + * never disagree. A reading the view took less than a minute ago is reused, so + * opening the self-check does not re-run a query for a number the user just saw. + * + * An untrusted workspace reuses the last reading and never runs `mcpp`; a + * missing `mcpp`, a failed query or an unreadable document all return + * `undefined`, which `buildSelfCheckText` renders as "not read". + * + * The one-line caller change is in the report: + * `cache: await readCacheSnapshot(),` inside the `buildSelfCheckText` call. + */ +export async function readCacheSnapshot(): Promise { + const cached = lastSnapshot; + if (cached !== undefined && Date.now() - cached.at < SNAPSHOT_FRESH_MS) { + return cached.snapshot; + } + if (!vscode.workspace.isTrusted) { + return cached?.snapshot; + } + return readCacheSnapshotNow(); +} + +/** + * Read regardless of what the view last showed: this is the query itself, kept + * separate so a caller with an explicit reason to re-measure can ask for one. + * Read-only, bounded, and never throws. + */ +export async function readCacheSnapshotNow(): Promise { + try { + const result = await runMcpp(vscode.workspace.isTrusted, snapshotExecutable(), ["cache", "list", "--format", "json"], undefined, { + timeoutMs: queryTimeoutMs(), + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }); + if (result === undefined || result.exitCode !== 0) { + return undefined; + } + const parsed = parseCacheList(result.stdout); + if (parsed === undefined) { + return undefined; + } + const inventory = summarizeCache(parsed.root, parsed.entries, { topN: 0, ageBoundaries: [] }); + return cacheSnapshotFrom({ + totalBytes: inventory.totalBytes, + totalEntries: inventory.totalEntries, + incomplete: inventory.incomplete.length, + }); + } catch { + return undefined; + } +} + diff --git a/mide-plugins/vscode/src/cli/artifacts.ts b/mide-plugins/vscode/src/cli/artifacts.ts new file mode 100644 index 0000000..eef7073 --- /dev/null +++ b/mide-plugins/vscode/src/cli/artifacts.ts @@ -0,0 +1,197 @@ +/** + * Size estimate for a project's `target/` directory (plan §3.4.1). + * + * mcpp states that the contents of `target/` are **not an interface**, so this is + * an estimate for the cache view only: it never parses mcpp's fingerprints and + * never guesses which artifacts are stale. The authoritative "what would be + * removed" answer is `mcpp clean --dry-run`. + * + * Pure `node:fs`, never throws, and bounded: a hostile or huge `target/` cannot + * hang the extension host. Symlinks are counted as zero-byte entries and are + * never followed (a link back to an ancestor would otherwise loop forever). + */ + +import { lstatSync, readdirSync } from "node:fs"; +import path from "node:path"; + +import { formatBytes } from "../util/format"; + +export interface ArtifactGroup { + name: string; + bytes: number; + files: number; +} + +export interface ArtifactEstimate { + /** Absolute path of `target/`, whether or not it exists. */ + path: string; + exists: boolean; + totalBytes: number; + files: number; + /** One entry per directory directly under `/target`. */ + byTopLevel: ArtifactGroup[]; + /** Set when the walk stopped early; the numbers are then a floor. */ + truncated?: "entries" | "depth"; +} + +const DEFAULT_MAX_ENTRIES = 200_000; +const DEFAULT_MAX_DEPTH = 6; + +function budget(value: number | undefined, fallback: number): number { + if (value === undefined || !Number.isFinite(value) || value < 0) { + return fallback; + } + return Math.floor(value); +} + +/** + * Walk `/target` with a bounded budget. Never throws. + * + * Returns `exists: false` with zeros when `target/` is missing, is not a + * directory, or is itself a symlink. + */ +export function estimateArtifacts( + projectRoot: string, + options: { maxEntries?: number; maxDepth?: number } = {}, +): ArtifactEstimate { + return measureDirectory(path.join(projectRoot, "target"), options); +} + +/** + * The same bounded walk, for a directory that is already known by path. + * + * The cache view needs it for the pre-v1 cache path `mcpp cache dir` reports + * (plan §3.4 / §8 G6): that directory lives outside the workspace, so it cannot + * be reached through {@link estimateArtifacts}. The same rules apply — bounded, + * never followed through symlinks, and it reports a floor instead of throwing + * when the walk stops early. + */ +export function measureDirectory( + root: string, + options: { maxEntries?: number; maxDepth?: number } = {}, +): ArtifactEstimate { + const maxEntries = budget(options.maxEntries, DEFAULT_MAX_ENTRIES); + const maxDepth = budget(options.maxDepth, DEFAULT_MAX_DEPTH); + + const missing: ArtifactEstimate = { + path: root, + exists: false, + totalBytes: 0, + files: 0, + byTopLevel: [], + }; + let targetStat; + try { + targetStat = lstatSync(root); + } catch { + return missing; + } + // A symlinked `target/` is not followed either, so it reports as absent. + if (!targetStat.isDirectory()) { + return missing; + } + + const byTopLevel: ArtifactGroup[] = []; + let totalBytes = 0; + let files = 0; + let visited = 0; + let truncated: "entries" | "depth" | undefined; + + const add = (group: ArtifactGroup | undefined, bytes: number, count: number): void => { + totalBytes += bytes; + files += count; + if (group !== undefined) { + group.bytes += bytes; + group.files += count; + } + }; + + const hasEntries = (directory: string): boolean => { + try { + return readdirSync(directory, { withFileTypes: true }).length > 0; + } catch { + return false; + } + }; + + const walk = (directory: string, depth: number, group: ArtifactGroup | undefined): void => { + if (truncated === "entries") { + return; + } + if (depth > maxDepth) { + // Only report depth truncation when something was actually left behind. + if (hasEntries(directory)) { + truncated = truncated ?? "depth"; + } + return; + } + let dirents; + try { + dirents = readdirSync(directory, { withFileTypes: true }); + } catch { + // Unreadable directory: tolerate it, the estimate is a floor anyway. + return; + } + for (const dirent of dirents) { + if (visited >= maxEntries) { + truncated = "entries"; + return; + } + visited += 1; + + const full = path.join(directory, dirent.name); + let stat; + try { + stat = lstatSync(full); + } catch { + // Raced with a concurrent `mcpp clean`, or a permission error: skip it. + continue; + } + if (stat.isSymbolicLink()) { + // Never followed; counted as an entry of zero bytes. + add(group, 0, 1); + continue; + } + if (stat.isDirectory()) { + let child = group; + if (depth === 0) { + child = { name: dirent.name, bytes: 0, files: 0 }; + byTopLevel.push(child); + } + walk(full, depth + 1, child); + continue; + } + // Regular files carry a size; fifos/sockets/devices are counted at 0. + add(group, stat.isFile() ? stat.size : 0, 1); + } + }; + + walk(root, 0, undefined); + + byTopLevel.sort( + (left, right) => + right.bytes - left.bytes || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0), + ); + + const estimate: ArtifactEstimate = { + path: root, + exists: true, + totalBytes, + files, + byTopLevel, + }; + if (truncated !== undefined) { + estimate.truncated = truncated; + } + return estimate; +} + +/** One human line for the cache view / status bar, e.g. `"1.43 GiB in 3 groups"`. */ +export function formatArtifactEstimate(estimate: ArtifactEstimate): string { + if (!estimate.exists) { + return "no target/ directory"; + } + const groups = estimate.byTopLevel.length; + const base = `${formatBytes(estimate.totalBytes)} in ${groups} group${groups === 1 ? "" : "s"}`; + return estimate.truncated === undefined ? base : `${base} (truncated: ${estimate.truncated})`; +} diff --git a/mide-plugins/vscode/src/cli/cache.ts b/mide-plugins/vscode/src/cli/cache.ts new file mode 100644 index 0000000..addae80 --- /dev/null +++ b/mide-plugins/vscode/src/cli/cache.ts @@ -0,0 +1,293 @@ +/** + * `mcpp cache …` reading and aggregation (plan §3.4). + * + * Pure functions over captured stdout plus the view model the cache TreeView and + * the statistics webview render. No `vscode` and no process spawning: the caller + * runs `mcpp` (`src/cli/process.ts`) and hands the text in. + * + * Two documents are involved: + * - `mcpp cache list --format json` — a real envelope (`kind: "mcpp.cache"`), + * parsed strictly; a foreign or unknown document is rejected outright; + * - `mcpp cache dir` — human text, so it is parsed leniently and partially. + * + * Everything is defensive: a broken entry is dropped, never thrown. A cache view + * that fails to render is worse than one that renders fewer rows. + */ + +import { ageInDays, bucketIndex, fromUnixSeconds, parseAgeBuckets } from "../util/format"; + +/** Envelope `kind` of `mcpp cache list --format json`. */ +const CACHE_LIST_KIND = "mcpp.cache"; + +/** Fallback `kind` for an entry whose own `kind` is missing or not a string. */ +const UNKNOWN_KIND = "unknown"; + +/** Default number of labels reported by {@link summarizeCache}. */ +const DEFAULT_TOP_N = 5; + +export interface CacheEntry { + accessed?: number; + bytes: number; + complete: boolean; + dir: string; + files?: number; + key: string; + kind: string; + label: string; +} + +export interface CacheInventory { + root: string; + entries: CacheEntry[]; + totalBytes: number; + totalEntries: number; + /** Per-kind totals, largest first. */ + byKind: Array<{ kind: string; entries: number; bytes: number }>; + /** Per-label totals, largest first, capped by `topN`. */ + topLabels: Array<{ label: string; entries: number; bytes: number; oldestAccessed?: number }>; + /** Entries whose `complete` is false. */ + incomplete: CacheEntry[]; + oldestAccessed?: number; + newestAccessed?: number; + /** Age histogram: index i is `[boundaries[i-1], boundaries[i])` days, last bucket is the overflow. */ + ageBuckets: Array<{ fromDays: number; toDays?: number; entries: number; bytes: number }>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** Deterministic, locale-independent string ordering. */ +function compareStrings(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function parseCacheEntry(raw: unknown): CacheEntry | undefined { + if (!isRecord(raw)) { + return undefined; + } + const dir = nonEmptyString(raw.dir); + const key = nonEmptyString(raw.key); + const label = nonEmptyString(raw.label); + const bytes = finiteNumber(raw.bytes); + if (dir === undefined || key === undefined || label === undefined || bytes === undefined) { + return undefined; + } + const entry: CacheEntry = { + bytes: Math.max(0, bytes), + // A missing `complete` counts as incomplete: mcpp always emits it, so an + // absent field means a truncated record, which is exactly what + // "incomplete" should surface to the user (`mcpp cache verify`). + complete: raw.complete === true, + dir, + key, + kind: typeof raw.kind === "string" ? raw.kind : UNKNOWN_KIND, + label, + }; + const accessed = finiteNumber(raw.accessed); + if (accessed !== undefined) { + entry.accessed = accessed; + } + const files = finiteNumber(raw.files); + if (files !== undefined) { + entry.files = files; + } + return entry; +} + +/** + * Parse `mcpp cache list --format json` stdout. + * `undefined` when it is not that document (non-JSON, foreign `kind`, bad shape). + */ +export function parseCacheList(stdout: string): { root: string; entries: CacheEntry[] } | undefined { + let document: unknown; + try { + document = JSON.parse(stdout); + } catch { + return undefined; + } + if (!isRecord(document) || document.kind !== CACHE_LIST_KIND) { + return undefined; + } + const data = document.data; + if (!isRecord(data) || !Array.isArray(data.entries)) { + return undefined; + } + const entries: CacheEntry[] = []; + for (const raw of data.entries) { + const entry = parseCacheEntry(raw); + if (entry !== undefined) { + entries.push(entry); + } + } + return { root: typeof data.root === "string" ? data.root : "", entries }; +} + +/** + * Parse `mcpp cache dir` stdout: + * + * ``` + * /home/u/.mcpp/build-cache/v1 + * legacy (unused, removable with `mcpp cache clean --legacy`): /home/u/.mcpp/bmi + * ``` + * + * The legacy line is absent when there is no pre-v1 cache. The legacy path is + * whatever follows the **last** `": "` on that line — the backticked command name + * in the parenthetical must never be mistaken for a path. + */ +export function parseCacheDir(stdout: string): { root?: string; legacyPath?: string } { + let root: string | undefined; + let legacyPath: string | undefined; + for (const line of stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + continue; + } + if (/^legacy\b/i.test(trimmed)) { + const separator = trimmed.lastIndexOf(": "); + const candidate = separator >= 0 ? trimmed.slice(separator + 2).trim() : ""; + if (candidate.length > 0) { + legacyPath = candidate; + } + continue; + } + if (root === undefined) { + root = trimmed; + } + } + const result: { root?: string; legacyPath?: string } = {}; + if (root !== undefined) { + result.root = root; + } + if (legacyPath !== undefined) { + result.legacyPath = legacyPath; + } + return result; +} + +/** + * Aggregate a cache listing into what the views need. + * + * `ageBoundaries` come from `mcpp.views.cache.ageBuckets` as strings (`"1d"`, + * `"7d"`, `"30d"`); see `parseAgeBuckets`. Entries without a usable `accessed` + * timestamp are excluded from the histogram and from oldest/newest, because + * their age is unknown — guessing would promise reclaimable space mcpp may keep. + */ +export function summarizeCache( + root: string, + entries: readonly CacheEntry[], + options: { topN?: number; ageBoundaries?: readonly string[] } = {}, +): CacheInventory { + const topN = options.topN === undefined ? DEFAULT_TOP_N : Math.max(0, Math.floor(options.topN)); + const boundaries = parseAgeBuckets(options.ageBoundaries); + const now = new Date(); + + const byKindTotals = new Map(); + const labelTotals = new Map< + string, + { entries: number; bytes: number; oldestAccessed?: number } + >(); + const incomplete: CacheEntry[] = []; + + const ageBuckets: CacheInventory["ageBuckets"] = []; + for (let index = 0; index <= boundaries.length; index += 1) { + const bucket: CacheInventory["ageBuckets"][number] = { + fromDays: index === 0 ? 0 : boundaries[index - 1], + entries: 0, + bytes: 0, + }; + if (index < boundaries.length) { + bucket.toDays = boundaries[index]; + } + ageBuckets.push(bucket); + } + + let totalBytes = 0; + let oldestAccessed: number | undefined; + let newestAccessed: number | undefined; + + for (const entry of entries) { + const bytes = Number.isFinite(entry.bytes) ? Math.max(0, entry.bytes) : 0; + totalBytes += bytes; + + const kindTotals = byKindTotals.get(entry.kind) ?? { entries: 0, bytes: 0 }; + kindTotals.entries += 1; + kindTotals.bytes += bytes; + byKindTotals.set(entry.kind, kindTotals); + + const labelEntry = labelTotals.get(entry.label) ?? { entries: 0, bytes: 0 }; + labelEntry.entries += 1; + labelEntry.bytes += bytes; + if (entry.accessed !== undefined && Number.isFinite(entry.accessed)) { + if (labelEntry.oldestAccessed === undefined || entry.accessed < labelEntry.oldestAccessed) { + labelEntry.oldestAccessed = entry.accessed; + } + if (oldestAccessed === undefined || entry.accessed < oldestAccessed) { + oldestAccessed = entry.accessed; + } + if (newestAccessed === undefined || entry.accessed > newestAccessed) { + newestAccessed = entry.accessed; + } + const at = fromUnixSeconds(entry.accessed); + if (at !== undefined) { + const bucket = ageBuckets[bucketIndex(ageInDays(at, now), boundaries)]; + bucket.entries += 1; + bucket.bytes += bytes; + } + } + labelTotals.set(entry.label, labelEntry); + + if (!entry.complete) { + incomplete.push(entry); + } + } + + const byKind = [...byKindTotals.entries()] + .map(([kind, totals]) => ({ kind, entries: totals.entries, bytes: totals.bytes })) + .sort( + (left, right) => + right.bytes - left.bytes || + right.entries - left.entries || + compareStrings(left.kind, right.kind), + ); + + const rankedLabels = [...labelTotals.entries()] + .map(([label, totals]) => ({ + label, + entries: totals.entries, + bytes: totals.bytes, + ...(totals.oldestAccessed === undefined ? {} : { oldestAccessed: totals.oldestAccessed }), + })) + .sort( + (left, right) => + right.bytes - left.bytes || + right.entries - left.entries || + compareStrings(left.label, right.label), + ); + + const inventory: CacheInventory = { + root, + entries: [...entries], + totalBytes, + totalEntries: entries.length, + byKind, + topLabels: rankedLabels.slice(0, topN), + incomplete, + ageBuckets, + }; + if (oldestAccessed !== undefined) { + inventory.oldestAccessed = oldestAccessed; + } + if (newestAccessed !== undefined) { + inventory.newestAccessed = newestAccessed; + } + return inventory; +} diff --git a/mide-plugins/vscode/src/cli/clean.ts b/mide-plugins/vscode/src/cli/clean.ts new file mode 100644 index 0000000..f2be9a6 --- /dev/null +++ b/mide-plugins/vscode/src/cli/clean.ts @@ -0,0 +1,242 @@ +/** + * Cleanup, as plans rather than as calls. + * + * Every destructive action this extension can run is described here: the exact + * argv, how much confirmation it needs, and whether a preview must be shown + * first. Keeping it in one pure table means the UI cannot invent a sixth level + * of danger, and the tests can assert the policy instead of the wording. + * + * The policy (from the plan, §3.4.3): + * + * | level | what | confirmation | + * |---|---|---| + * | 0 | read-only (`cache list`/`dir`/`info`/`verify`) | none | + * | 1 | project body (`mcpp clean`) | modal, with the measured size | + * | 2 | budget/targeted (`cache gc`, `cache prune`, `cache clean --deps/--std`) | preview, then modal | + * | 3 | everything (`cache clean --all`) | preview, modal, then a second acknowledgement | + * + * The user asked for `cache clean --all` to be reachable — so it is — but it is + * the only level-3 action, and its text names every project on the machine. + */ + +export type CleanAction = + | "project" + | "stale" + | "cacheGc" + | "cachePrune" + | "cacheDeps" + | "cacheStd" + | "cacheAll" + | "cacheLegacy" + | "cacheVerify" + | "cacheList"; + +export interface CleanOptions { + /** `mcpp clean --stale --older-than d`; 0 means "keep none". */ + staleDays?: number; + /** `mcpp cache gc --max-size GiB`; 0 means "the user types a budget". */ + budgetGiB?: number; + /** `mcpp cache prune --older-than d`. */ + pruneAgeDays?: number; +} + +export interface CleanPlan { + action: CleanAction; + /** The mcpp subcommand, for logging: `clean` or `cache`. */ + group: "clean" | "cache"; + argv: readonly string[]; + /** 0 read-only, 1 modal, 2 preview+modal, 3 preview+modal+acknowledge. */ + level: 0 | 1 | 2 | 3; + /** A `--dry-run`-style pass the UI must show before asking. */ + preview: boolean; + /** A second, explicit acknowledgement (a checkbox) is required. */ + acknowledge: boolean; + /** English key for the dialogue title. */ + titleKey: string; + /** English key for the body; the caller adds the measured figures. */ + detailKey: string; + /** This action is refused when the workspace is untrusted. */ + requiresTrust: boolean; +} + +function staleDaysOf(options: CleanOptions): number { + const days = options.staleDays ?? 3; + return Number.isFinite(days) ? Math.max(0, Math.round(days)) : 3; +} + +export function planClean(action: CleanAction, options: CleanOptions = {}): CleanPlan { + switch (action) { + case "project": + return { + action, + group: "clean", + argv: ["clean"], + level: 1, + preview: false, + acknowledge: false, + titleKey: "Remove the whole target/ directory?", + detailKey: "This deletes every build directory of this project. The next build starts from scratch. The shared build cache is not touched.", + requiresTrust: true, + }; + case "stale": + return { + action, + group: "clean", + argv: ["clean", "--stale", "--older-than", `${staleDaysOf(options)}d`], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Remove build directories mcpp no longer considers current?", + detailKey: "Directories written in the last {0} day(s) are kept. mcpp decides which ones are stale; the preview above is its own list.", + requiresTrust: true, + }; + case "cacheGc": { + const budget = options.budgetGiB ?? 0; + const argv = budget > 0 ? ["cache", "gc", "--max-size", `${budget}GiB`] : ["cache", "gc"]; + return { + action, + group: "cache", + argv, + level: 2, + preview: true, + acknowledge: false, + titleKey: "Collect the shared build cache to a budget?", + detailKey: "mcpp drops least-recently-used entries until the cache fits. Projects that used a dropped entry rebuild it.", + requiresTrust: true, + }; + } + case "cachePrune": { + const days = options.pruneAgeDays ?? 30; + return { + action, + group: "cache", + argv: ["cache", "prune", "--older-than", `${Number.isFinite(days) ? Math.max(1, Math.round(days)) : 30}d`], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop cache entries that have not been used for a while?", + detailKey: "Entries unused for longer than {0} day(s) are removed. Projects that used one rebuild it.", + requiresTrust: true, + }; + } + case "cacheDeps": + return { + action, + group: "cache", + argv: ["cache", "clean", "--deps"], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop every package entry from the shared cache?", + detailKey: "Every mcpp project on this machine rebuilds its dependencies afterwards. Standard library module entries are kept.", + requiresTrust: true, + }; + case "cacheStd": + return { + action, + group: "cache", + argv: ["cache", "clean", "--std"], + level: 2, + preview: true, + acknowledge: false, + titleKey: "Drop every standard library module entry from the shared cache?", + detailKey: "Every mcpp project on this machine re-prepares the standard library module afterwards. Package entries are kept.", + requiresTrust: true, + }; + case "cacheAll": + return { + action, + group: "cache", + argv: ["cache", "clean", "--all"], + level: 3, + preview: true, + acknowledge: true, + titleKey: "Drop the entire shared build cache?", + detailKey: "Package and standard library entries go. Every mcpp project on this machine rebuilds from scratch afterwards.", + requiresTrust: true, + }; + case "cacheLegacy": + return { + action, + group: "cache", + argv: ["cache", "clean", "--legacy"], + level: 2, + preview: false, + acknowledge: false, + titleKey: "Remove the unused pre-v1 cache?", + detailKey: "mcpp no longer reads $MCPP_HOME/bmi. Nothing is rebuilt because nothing uses it.", + requiresTrust: true, + }; + case "cacheVerify": + return { + action, + group: "cache", + argv: ["cache", "verify"], + level: 0, + preview: false, + acknowledge: false, + titleKey: "Verify the shared build cache", + detailKey: "Every entry's manifest is checked against the files on disk.", + requiresTrust: true, + }; + default: + return { + action: "cacheList", + group: "cache", + argv: ["cache", "list", "--format", "json"], + level: 0, + preview: false, + acknowledge: false, + titleKey: "Read the shared build cache", + detailKey: "Nothing is removed.", + requiresTrust: true, + }; + } +} + +/** True when `mcpp clean`'s global cache also goes — the option the UI keeps un-ticked. */ +export function withSharedCache(plan: CleanPlan): CleanPlan { + if (plan.action !== "project") { + return plan; + } + return { + ...plan, + argv: [...plan.argv, "--bmi-cache"], + level: 3, + acknowledge: true, + detailKey: "This also empties the shared build cache, so every mcpp project on this machine rebuilds from scratch.", + }; +} + +/** The single destructive action that may be shortened to one confirmation. */ +export function planProblems(): string[] { + const problems: string[] = []; + const actions: CleanAction[] = [ + "project", + "stale", + "cacheGc", + "cachePrune", + "cacheDeps", + "cacheStd", + "cacheAll", + "cacheLegacy", + "cacheVerify", + "cacheList", + ]; + for (const action of actions) { + const plan = planClean(action); + if (plan.requiresTrust === false) { + problems.push(`${action} does not require a trusted workspace`); + } + if (plan.level >= 2 && !plan.preview && plan.action !== "cacheLegacy") { + problems.push(`${action} must be previewed before it runs`); + } + if (plan.level === 3 && !plan.acknowledge) { + problems.push(`${action} must require a second acknowledgement`); + } + if (plan.argv.some((part) => part.includes(" "))) { + problems.push(`${action} passes an argument containing a space`); + } + } + return problems; +} diff --git a/mide-plugins/vscode/src/cli/controller.ts b/mide-plugins/vscode/src/cli/controller.ts new file mode 100644 index 0000000..ef3e991 --- /dev/null +++ b/mide-plugins/vscode/src/cli/controller.ts @@ -0,0 +1,1178 @@ +import type { ProjectMcpp } from "../ide/protocol"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import process from "node:process"; + +import * as vscode from "vscode"; + +import { + hostDefaultToolchains, + mcppCommandArguments, + normalizeToolchainSpec, + parseToolchainList, + toolchainInstallKind, + toolchainSpecTargetHint, + type ToolchainInventory, + type ToolchainItem, +} from "./toolchain"; +import { type McppProjectDiscovery } from "../projects/discovery"; +import { updateEditorTitleButtonsContext } from "../projects/context"; +import { createLogger, type Logger } from "../util/log"; +import { runMcppCommand } from "./process"; +import { + McppOperationRegistry, + classifyTaskExit, + projectTaskPlan, + TASK_ARGUMENT_SETTINGS, + shouldRefreshLanguageServerAfterTask, + type ProjectTaskKind, + type TaskCompletion, +} from "./tasks"; +import { classifyExit, explainHint, type McppFailureKind, type McppOutcome } from "./errors"; +import { statusBarBackgroundColour } from "./statusBar"; +import { CLI_COMMANDS } from "../commands/ids"; +import { QUICK_MENU_GROUPS, quickMenuIconAsset, quickMenuItems, quickMenuStatusText } from "../commands/menu"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { controllerLabels } from "./labels"; +import { runNewProjectFlow, validateNewProjectName } from "./newProject"; + +export interface McppCliControllerOptions { + output: vscode.OutputChannel; + /** + * The extension's own directory, for the assets that cannot be a `ThemeIcon` — + * today only the coloured quick menu icons, which VS Code paints as an image + * because it drops a `ThemeIcon`'s colour in a quick pick. + */ + extensionUri: vscode.Uri; + currentProject: () => McppProjectDiscovery | undefined; + afterProjectTask: ( + project: McppProjectDiscovery, + kind: ProjectTaskKind, + completion: TaskCompletion, + ) => Promise; + isTrusted: () => boolean; + /** + * `mcpp.ui.statusBar.showLanguageServer`: one short line describing the C++ + * Modules state, or `undefined` when there is nothing to show. A callback + * instead of an import so this controller never depends on `src/mcppls/**`; + * the extension layer owns the bridge and passes it in. + */ + languageServerSummary?: () => string | undefined; + /** + * The project's mcpp as the backend asked it in the project (design 2026-10-30 part 2 §4.4, §6.1): + * the status item names its version, and its tooltip the program and whether `mcpp.path` chose it. + */ + projectMcpp?: () => ProjectMcpp | undefined; +} + +interface ToolchainPickItem extends vscode.QuickPickItem { + spec?: string; + toolchain?: ToolchainItem; + customInput?: boolean; +} + +type OperationToken = object; + +/** Where the generated quick menu icons live, under the extension root. */ +const QUICK_MENU_ICON_DIRECTORY = "media/quick-menu"; + +export interface ProjectTaskRunOptions { + /** Set to false when the caller performs its own post-task action. */ + notify?: boolean; + /** The arguments after the subcommand, for a pack the caller assembled. */ + args?: readonly string[]; +} + +function taskScope(root: string): vscode.WorkspaceFolder | vscode.TaskScope { + return vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)) + ?? vscode.TaskScope.Workspace; +} + +function workingDirectory(project: McppProjectDiscovery | undefined): string { + if (project !== undefined) { + return project.root; + } + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(); +} + +function commandLine(executable: string, args: string[]): string { + return [executable, ...args].join(" "); +} + +// ─── pure policy ──────────────────────────────────────────────────────────── +// +// Every decision a setting makes is computed by a pure function in this block, +// so `test/cli/controller.*.test.ts` can assert the decision without an editor. +// The class below only executes what these functions decide. + +export type TaskRevealSetting = "always" | "onFailure" | "never"; + +export interface TaskTerminalUi { + /** `mcpp.task.revealTerminal`. */ + reveal: TaskRevealSetting; + /** `mcpp.task.focusTerminal`: hand keyboard focus to the task terminal. */ + focus: boolean; + /** `mcpp.task.clearTerminal`: clear the panel before the command runs. */ + clearBeforeRun: boolean; +} + +export function taskTerminalUi( + revealTerminal: unknown, + focusTerminal: unknown, + clearTerminal: unknown, +): TaskTerminalUi { + return { + reveal: + revealTerminal === "never" || revealTerminal === "onFailure" ? revealTerminal : "always", + focus: focusTerminal === true, + clearBeforeRun: clearTerminal !== false, + }; +} + +/** `workbench.action.terminal.*` commands to run once the task has finished. */ +export function terminalCommandsAfterTask(ui: TaskTerminalUi, failed: boolean): readonly string[] { + // VS Code has no "reveal without focusing" command, so the one command below + // is both the reveal and the focus for the failure-only setting. + return failed && ui.reveal === "onFailure" ? ["workbench.action.terminal.focus"] : []; +} + +/** The problem matcher `package.json` must contribute under `contributes.problemMatchers`. */ +export const PROBLEM_MATCHER_NAME = "$mcpp"; + +export function problemMatchersFor(enabled: unknown): readonly string[] { + return enabled === false ? [] : [PROBLEM_MATCHER_NAME]; +} + +/** + * `mcpp.task.confirmClean`: this decides only the controller's own prompt for + * `mcpp.clean`. The cleanup plan (`src/cli/clean.ts`) keeps its own level-2/3 + * confirmations, which this setting can never remove. + */ +export function cleanConfirmationRequired(confirmClean: unknown): boolean { + return confirmClean !== false; +} + +export type ConfirmationStrength = "notice" | "modal"; + +export interface ConfirmationPolicy { + installToolchain: ConfirmationStrength; + globalDefault: ConfirmationStrength; +} + +/** + * `mcpp.ui.confirmDestructiveOnly` (default true) reserves a modal for actions + * that cannot be undone. Turning it off escalates these two undoable prompts + * from a dismissible notification to a modal — the prompt, its text and its + * buttons are identical in both modes, so the setting can only ask *more* + * insistently, never less often. + */ +export function confirmationPolicy(confirmDestructiveOnly: unknown): ConfirmationPolicy { + const strength: ConfirmationStrength = confirmDestructiveOnly === false ? "modal" : "notice"; + return { installToolchain: strength, globalDefault: strength }; +} + +/** `mcpp.ui.statusBar.showLanguageServer`: the suffix the status item gains. */ +export function languageServerStatusSuffix(show: unknown, summary: string | undefined): string | undefined { + if (show !== true) { + return undefined; + } + const text = summary?.trim(); + return text === undefined || text.length === 0 ? undefined : text; +} + +export type SuccessReport = "silent" | "statusBar" | "toast"; + +/** `mcpp.ui.notifications.success`; only a *successful* result consults this. */ +export function successReportOf(value: unknown): SuccessReport { + return value === "silent" || value === "toast" ? value : "statusBar"; +} + +/** + * `mcpp.ui.notifications.dedupeMinutes`: true when the same message may be + * shown again. `0` (or an unusable value) shows every notification. + */ +export function dedupeAllows(lastAt: number | undefined, now: number, dedupeMinutes: unknown): boolean { + if (typeof dedupeMinutes !== "number" || !Number.isFinite(dedupeMinutes) || dedupeMinutes <= 0) { + return true; + } + if (typeof lastAt !== "number" || !Number.isFinite(lastAt)) { + return true; + } + return now - lastAt >= dedupeMinutes * 60_000; +} + +export const STATUS_BAR_SUCCESS_MAX = 60; + +/** A status bar item is a label, not a paragraph. */ +export function statusBarSuccessText(message: string): string { + const single = message.replace(/\s+/g, " ").trim(); + return single.length <= STATUS_BAR_SUCCESS_MAX + ? single + : `${single.slice(0, STATUS_BAR_SUCCESS_MAX - 1)}…`; +} + +export interface FailureAdvice { + kind: McppFailureKind; + exitCode: number; + /** `mcpp self explain `, when the output or code names a diagnostic. */ + hint?: string; +} + +/** + * `src/cli/errors.ts` (SPEC-003) applied to a failed run: the failure kind that + * picks the guidance, plus the `mcpp self explain` follow-up when there is a + * diagnostic code to explain. + */ +export function failureAdvice(args: readonly string[], exitCode: number, output: string): FailureAdvice { + const outcome: McppOutcome = { ...classifyExit(exitCode, args), detail: output }; + return { kind: outcome.kind, exitCode: outcome.exitCode, hint: explainHint(outcome) }; +} + +/** The exit-code-specific next step, or `undefined` to keep the caller's message. */ +function failureGuidanceText(kind: McppFailureKind): string | undefined { + switch (kind) { + case "usage": + return t("mcpp rejected the command line; check the arguments and run it from the project root."); + case "environment": + return t("mcpp is installed but the environment is not ready; run `mcpp self doctor` to see what is missing."); + case "internal": + return t("mcpp reported an internal error; re-run with the mcpp output channel open and report it."); + case "unknown-command": + return t("This mcpp build does not recognise that command; update mcpp or check the spelling."); + case "build-failed": + return t("The program did not build; see the task terminal for the compiler output."); + default: + return undefined; + } +} + +/** + * The `vscode`-side read of `mcpp.project.discoveryBoundary`. `extension.ts` + * passes the result into `findNearestMcppProject`, whose walk stays pure. The + * resource is the workspace folder, because the setting is resource-scoped. + */ +export { discoveryBoundaryFromSettings } from "../projects/current"; + +const SUCCESS_STATUS_MS = 6000; + +export class McppCliController { + private readonly status: vscode.StatusBarItem; + + private readonly operations = new McppOperationRegistry(); + + /** `mcpp.log.level` decides what reaches the `mcpp` channel; see `src/util/log.ts`. */ + private readonly logger: Logger; + + /** `mcpp.ui.notifications.dedupeMinutes`: message signature -> last shown (ms). */ + private readonly lastNotified = new Map(); + + /** Reverts the transient success text in the status item. */ + private statusRevert: ReturnType | undefined; + + public constructor(private readonly options: McppCliControllerOptions) { + this.status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 40); + this.status.command = CLI_COMMANDS.showMenu; + this.logger = createLogger(this.options.output, () => read("mcpp.log.level")); + this.applyStatusBar(); + } + + public register(): vscode.Disposable[] { + this.applyStatusBar(); + const applyEditorTitleButtons = (): void => { + void updateEditorTitleButtonsContext({ + enabled: () => read("mcpp.task.editorTitleButtons"), + setContextValue: (key, value) => vscode.commands.executeCommand("setContext", key, value), + }).catch(() => undefined); + }; + applyEditorTitleButtons(); + const disposables: vscode.Disposable[] = [ + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.ui.statusBar")) { + this.applyStatusBar(); + } + if (event.affectsConfiguration("mcpp.task.editorTitleButtons")) { + applyEditorTitleButtons(); + } + }), + this.status, + vscode.commands.registerCommand(CLI_COMMANDS.showMenu, this.guarded(() => this.showMenu())), + vscode.commands.registerCommand(CLI_COMMANDS.newProject, this.guarded(() => this.newProject())), + vscode.commands.registerCommand(CLI_COMMANDS.build, this.guarded(() => this.runProjectTask("build"))), + vscode.commands.registerCommand(CLI_COMMANDS.editorBuild, this.guarded(() => this.runProjectTask("build"))), + vscode.commands.registerCommand(CLI_COMMANDS.run, this.guarded(() => this.runProjectTask("run"))), + vscode.commands.registerCommand(CLI_COMMANDS.test, this.guarded(() => this.runProjectTask("test"))), + vscode.commands.registerCommand(CLI_COMMANDS.clean, this.guarded(() => this.runProjectTask("clean"))), + vscode.commands.registerCommand(CLI_COMMANDS.showToolchains, this.guarded(() => this.showToolchains())), + vscode.commands.registerCommand(CLI_COMMANDS.installToolchain, this.guarded(() => this.installToolchain())), + vscode.commands.registerCommand(CLI_COMMANDS.selectDefaultToolchain, this.guarded(() => this.selectDefaultToolchain())), + vscode.window.onDidChangeActiveTextEditor(() => this.refreshStatus()), + vscode.workspace.onDidChangeWorkspaceFolders(() => this.refreshStatus()), + ]; + this.refreshStatus(); + return disposables; + } + + public refreshStatus(): void { + if (this.options.currentProject() === undefined) { + this.status.hide(); + return; + } + this.applyStatusBar(); + } + + public isBusy(): boolean { + return this.operations.hasActive(); + } + + public async runProjectTask( + kind: ProjectTaskKind, + options: ProjectTaskRunOptions = {}, + ): Promise { + const project = this.requireProject(); + if (project === undefined || !this.requireTrusted()) { + return undefined; + } + + // `mcpp.task.confirmClean`: one prompt for this entry point; the cleanup + // plan's own level-2/3 confirmations are untouched by it. + if (kind === "clean" && cleanConfirmationRequired(read("mcpp.task.confirmClean", vscode.Uri.file(project.root)))) { + const labels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("Delete the target/ directory of this project: {0}/target", project.root), + { modal: true, detail: t("This does not clean the global BMI cache.") }, + labels.confirmClean, + ); + if (choice !== labels.confirmClean) { + return undefined; + } + } + + // `mcpp.runtime.concurrency` picks the mutual-exclusion scope: one task at a + // time per project (the default), or one task at a time for the whole window. + const globalScope = read("mcpp.runtime.concurrency") === "global"; + const token: OperationToken = {}; + const active = globalScope + ? this.operations.beginGlobal(token) + : this.operations.beginProject(project.root, token); + if (active !== undefined) { + const labels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("An mcpp operation is already running; not starting {0} now.", kind), + labels.showTasks, + ); + if (choice === labels.showTasks) { + await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); + } + return undefined; + } + + let completion: TaskCompletion | undefined; + try { + const plan = kind === "pack" + ? projectTaskPlan(kind, options.args ?? []) + : projectTaskPlan(kind, read(TASK_ARGUMENT_SETTINGS[kind]), { + target: read("mcpp.build.target", vscode.Uri.file(project.root)), + profile: read("mcpp.build.profile", vscode.Uri.file(project.root)), + }); + completion = await this.executeTask( + project.root, + this.mcppExecutable(project), + plan.title, + plan.args, + ); + this.appendTaskCompletion(project.root, plan.title, plan.args, completion); + } finally { + if (globalScope) { + this.operations.finishGlobal(token); + } else { + this.operations.finishProject(project.root, token); + } + } + + if (options.notify !== false + && completion !== undefined + && shouldRefreshLanguageServerAfterTask(kind, completion)) { + await this.options.afterProjectTask(project, kind, completion); + } + return completion; + } + + public async showToolchains(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + if (inventory === undefined) { + return; + } + + const items = this.inventoryItems(inventory, false, project); + if (items.length === 0) { + await vscode.window.showInformationMessage( + t("mcpp listed no usable toolchain; see the raw result in the mcpp output channel."), + ); + return; + } + await vscode.window.showQuickPick(items, { + title: t("mcpp toolchains and targets"), + placeHolder: t("Read-only view of what mcpp currently resolves"), + matchOnDescription: true, + matchOnDetail: true, + }); + } + + public async installToolchain(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + const spec = await this.pickInstallSpec(inventory); + if (spec === undefined) { + return; + } + const installKind = toolchainInstallKind(spec); + const targetHint = toolchainSpecTargetHint(spec); + const labels = controllerLabels(); + const confirmLabel = installKind === "system-detect" ? labels.confirmDetect : labels.confirmInstall; + const confirmation = installKind === "system-detect" + ? t("mcpp will detect the system MSVC ({0}).", spec) + : targetHint === "target" + ? t("The compatible spec {0}, which may carry target semantics, is handed to mcpp to install, and mcpp validates it in the end.", spec) + : installKind === "managed-target" + ? t("The compatible spec {0} carrying target semantics is handed to mcpp to install.", spec) + : t("mcpp toolchain {0} is installed (no target specified, the host target is used).", spec); + const detail = installKind === "system-detect" + ? t("mcpp does not download or install MSVC; it detects Visual Studio and points to the official installation guide when it is missing.") + : targetHint === "target" + ? t("mcpp decides whether the compiler prefix is a valid triple; when it is, it may download a larger toolchain package for that target.") + : installKind === "managed-target" + ? t("mcpp normalises the compatible spelling and may download a larger toolchain package for that target.") + : t("The installation may download a large toolchain package and modifies the mcpp global cache."); + + const policy = confirmationPolicy(read("mcpp.ui.confirmDestructiveOnly")); + const choice = await vscode.window.showWarningMessage( + confirmation, + this.warningOptions(policy.installToolchain, detail), + confirmLabel, + ); + if (choice !== confirmLabel) { + return; + } + + const token: OperationToken = {}; + const active = this.operations.beginGlobal(token); + if (active !== undefined) { + const duplicateChoice = await vscode.window.showWarningMessage( + t("An mcpp operation is already running."), + labels.showTasks, + ); + if (duplicateChoice === labels.showTasks) { + await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); + } + return; + } + + const taskTitle = installKind === "system-detect" + ? t("mcpp: detect system MSVC ({0})", spec) + : t("mcpp: install toolchain {0}", spec); + try { + const installArgs = mcppCommandArguments("toolchain", "install", spec); + const completion = await this.executeTask( + workingDirectory(project), + this.mcppExecutable(project), + taskTitle, + installArgs, + ); + this.appendTaskCompletion( + workingDirectory(project), + taskTitle, + installArgs, + completion, + ); + if (completion.state !== "succeeded") { + return; + } + } finally { + this.operations.finishGlobal(token); + } + + if (installKind === "managed-target") { + this.reportSuccess(t("mcpp finished {0}. This first release does not change the target default; to set one, use the mcpp CLI with --target.", spec)); + return; + } + + const refreshed = await this.readToolchainInventory(project); + const defaultChoice = await vscode.window.showInformationMessage( + installKind === "system-detect" + ? t("MSVC detection finished. Choose a global default from the latest list?") + : t("Toolchain {0} installed. Choose a global default from the latest list?", spec), + labels.chooseGlobalDefault, + ); + if (defaultChoice === labels.chooseGlobalDefault && refreshed !== undefined) { + await this.selectDefaultToolchainFromInventory(project, refreshed); + } + } + + public async selectDefaultToolchain(): Promise { + if (!this.requireTrusted()) { + return; + } + const project = this.options.currentProject(); + const inventory = await this.readToolchainInventory(project); + if (inventory !== undefined) { + await this.selectDefaultToolchainFromInventory(project, inventory); + } + } + + private async selectDefaultToolchainFromInventory( + project: McppProjectDiscovery | undefined, + inventory: ToolchainInventory, + ): Promise { + const installed = hostDefaultToolchains(inventory); + if (installed.length === 0) { + await vscode.window.showWarningMessage( + t("No installed toolchain is usable for the host target, so a global default cannot be set here. For target-specific toolchains use the mcpp CLI; on Windows install Visual Studio first."), + ); + return; + } + + const items: ToolchainPickItem[] = installed.map((toolchain) => ({ + label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, + description: toolchain.source === "system" ? t("System toolchain (mcpp only detects it)") : t("Toolchain managed by mcpp"), + detail: toolchain.effective ? t("Effective toolchain of this project") : undefined, + spec: toolchain.spec, + toolchain, + })); + const picked = await vscode.window.showQuickPick(items, { + title: t("Choose the mcpp global default toolchain"), + placeHolder: this.isNestedWorkspaceProject(project) + ? t("The project root sits below the VS Code folder; the real build resolution is whatever mcpp reports") + : inventory.projectOverridesGlobal + ? t("The project currently overrides the global default; this only changes the current mcpp global configuration") + : t("Choosing here only changes the mcpp global configuration; it does not build the project"), + matchOnDescription: true, + matchOnDetail: true, + }); + if (picked?.spec === undefined) { + return; + } + + const defaultPolicy = confirmationPolicy(read("mcpp.ui.confirmDestructiveOnly")); + const warningLabels = controllerLabels(); + const choice = await vscode.window.showWarningMessage( + t("The global default pair is set to {0} + host target. The mcpp.toml/target configuration of the current project can still override it.", picked.spec), + this.warningOptions( + defaultPolicy.globalDefault, + t("mcpp also clears the global default_target; the configuration file location is decided by the current mcpp installation and MCPP_HOME."), + ), + warningLabels.confirmDefault, + ); + if (choice !== warningLabels.confirmDefault) { + return; + } + + const token: OperationToken = {}; + const active = this.operations.beginGlobal(token); + if (active !== undefined) { + await vscode.window.showWarningMessage(t("An mcpp operation is already running."), warningLabels.showTasks); + return; + } + + try { + const args = mcppCommandArguments("toolchain", "default", picked.spec); + const result = await runMcppCommand( + this.mcppExecutable(project), + args, + workingDirectory(project), + ); + this.appendShortCommand(t("Set the global default toolchain"), this.mcppExecutable(project), args, result); + if (result.exitCode !== 0) { + this.reportCommandFailure( + args, + result.exitCode, + `${result.stdout}\n${result.stderr}`, + t("Setting the global default toolchain failed (exit code {0}). See the mcpp output channel.", result.exitCode), + ); + return; + } + + } finally { + this.operations.finishGlobal(token); + } + + const buildChoice = await vscode.window.showInformationMessage( + t("The mcpp global default is now {0} + host target. Cleaning the old toolchain artifacts and rebuilding is recommended.", picked.spec), + ...(project === undefined ? [] : [warningLabels.cleanAndBuild]), + ); + if (buildChoice === warningLabels.cleanAndBuild && project !== undefined) { + const cleanArgs = mcppCommandArguments("clean"); + const cleanResult = await runMcppCommand(this.mcppExecutable(project), cleanArgs, project.root); + this.appendShortCommand(t("Clean old artifacts"), this.mcppExecutable(project), cleanArgs, cleanResult); + await this.runProjectTask("build"); + } + } + + private async pickInstallSpec(inventory: ToolchainInventory | undefined): Promise { + const labels = controllerLabels(); + const items: ToolchainPickItem[] = []; + for (const toolchain of inventory?.available ?? []) { + items.push({ + label: toolchain.spec, + description: t("Versions mcpp aggregates by family; this operation installs for the host target"), + spec: toolchain.spec, + }); + } + items.push({ + label: labels.installCustom, + detail: t("Accepts family, family@version, namespace, partial versions and mcpp's compatible legacy spellings"), + customInput: true, + }); + + const picked = await vscode.window.showQuickPick(items, { + title: t("Install an mcpp toolchain"), + placeHolder: t("No target is selected; for a target-specific install use the mcpp CLI"), + matchOnDescription: true, + matchOnDetail: true, + }); + if (picked === undefined) { + return undefined; + } + if (!picked.customInput) { + return picked.spec; + } + + const input = await vscode.window.showInputBox({ + title: t("Enter a toolchain spec"), + prompt: t("For example gcc, llvm@20.1.7, xim:gcc@16, msvc, mingw; mcpp normalises compatible spellings"), + placeHolder: "gcc@16", + validateInput: (value) => { + const normalized = normalizeToolchainSpec(value); + if (normalized === undefined) { + return t("Enter a family, family@version, family version, namespace or an mcpp-compatible spec"); + } + return undefined; + }, + }); + return input === undefined ? undefined : normalizeToolchainSpec(input); + } + + private inventoryItems( + inventory: ToolchainInventory, + includeActions: boolean, + project: McppProjectDiscovery | undefined, + ): ToolchainPickItem[] { + const items: ToolchainPickItem[] = []; + const nestedView = this.isNestedWorkspaceProject(project); + if (inventory.effective !== undefined) { + items.push({ + label: `$(check) ${nestedView ? t("mcpp list toolchain for this directory") : t("Current effective toolchain")}: ${inventory.effective.spec}`, + description: nestedView + ? t("This is the current-directory view; the value mcpp build resolves is authoritative") + : inventory.projectOverridesGlobal ? t("From this project's mcpp.toml, overriding the global default") : t("From the global default"), + detail: inventory.effectiveTarget === undefined + ? t("Effective target: host (mcpp shows no explicit target)") + : t("Effective target: {0}", inventory.effectiveTarget), + }); + } + if (inventory.globalDefaultSpec !== undefined) { + items.push({ + label: t("Global default: {0}", inventory.globalDefaultSpec), + description: nestedView + ? t("This is the current-directory view; overrides from the project or a parent configuration are decided by the actual build") + : inventory.projectOverridesGlobal ? t("This project may not be using this value") : t("mcpp global configuration"), + }); + } else if (inventory.recognized) { + items.push({ + label: t("Global default: "), + description: t("mcpp has no global default toolchain yet"), + }); + } + for (const toolchain of inventory.installed) { + items.push({ + label: `${toolchain.effective ? "$(check) " : ""}${toolchain.spec}`, + description: toolchain.source === "system" ? t("System: system toolchain, detection only") : t("Installed"), + detail: toolchain.effective + ? nestedView ? t("mcpp list item effective for this directory; the actual build resolution may be affected by a parent mcpp workspace") : t("Currently effective item") + : undefined, + spec: toolchain.spec, + toolchain, + }); + } + for (const target of inventory.targets) { + items.push({ + label: `${target.effective ? "$(check) " : ""}target ${target.target}`, + description: `${target.status}${target.toolchainSpec === undefined ? "" : ` · ${t("convention {0}", target.toolchainSpec)}`}`, + detail: target.note.length === 0 ? t("The target axis is shown read-only; to select a target use the mcpp CLI") : target.note, + }); + } + for (const toolchain of inventory.available) { + items.push({ + label: t("Installable: {0}", toolchain.spec), + description: t("Index versions mcpp aggregates by family; no host payload is promised"), + spec: toolchain.spec, + }); + } + if (includeActions) { + items.push({ + label: t("$(cloud-download) Install a toolchain…"), + detail: t("Back to the toolchain installation flow"), + customInput: true, + }); + } + return items; + } + + /** + * The status bar menu: one section per group, one row per command. + * + * The section titles come from `QUICK_MENU_GROUPS`, so an entry can never be + * filed under a heading that does not exist, and the headers are only emitted + * where the entries actually change group — a group whose commands are all + * filtered out (C++ Modules, when `mcpp.languageService.menuItems` is off) + * takes its heading with it. + * + * The icons are the generated ones under `media/quick-menu/`, handed over as a + * `{ light, dark }` pair so the row is legible in both themes. They cannot be + * `ThemeIcon`s: VS Code drops a `ThemeIcon`'s colour in a quick pick and paints + * a `Uri` as an image, so a coloured row needs a coloured file. See the note at + * the top of `src/commands/menu.ts`. + * + * Typing filters by command name only. The group names are separator rows, and + * VS Code hides separators as soon as a query is present, so they are not + * matchable — the headings are for reading the list, not for searching it. + */ + private async showMenu(): Promise { + const groupLabel = new Map(QUICK_MENU_GROUPS.map((group) => [group.id, t(group.labelKey)])); + const showLanguageServer = read("mcpp.languageService.menuItems"); + const items: Array = []; + let section: string | undefined; + for (const item of quickMenuItems) { + if (!showLanguageServer && item.group === "languageServer") { + continue; + } + if (item.group !== section) { + section = item.group; + items.push({ + label: groupLabel.get(item.group) ?? item.group, + kind: vscode.QuickPickItemKind.Separator, + }); + } + items.push({ + label: t(item.labelKey), + iconPath: { + light: vscode.Uri.joinPath(this.options.extensionUri, QUICK_MENU_ICON_DIRECTORY, quickMenuIconAsset(item, "light")), + dark: vscode.Uri.joinPath(this.options.extensionUri, QUICK_MENU_ICON_DIRECTORY, quickMenuIconAsset(item, "dark")), + }, + command: item.command, + }); + } + const picked = await vscode.window.showQuickPick(items, { + title: t("mcpp: quick menu"), + placeHolder: t("Choose a project, toolchain, cache or C++ Modules action"), + }); + if (picked?.command !== undefined) { + await vscode.commands.executeCommand(picked.command); + } + } + + public async newProject(): Promise { + if (!this.requireTrusted()) { + return; + } + + const input = await vscode.window.showInputBox({ + title: t("New mcpp project (1/2)"), + prompt: t("Enter a project name; a folder of that name is created at the location you pick"), + placeHolder: "hello-mcpp", + validateInput: validateNewProjectName, + }); + if (input === undefined) { + return; + } + const projectName = input.trim(); + + const picked = await vscode.window.showOpenDialog({ + title: t("Choose the project location (2/2)"), + // Start where the reader already is: `mcpp new` creates a folder *inside* + // the chosen one, so the workspace folder is the useful default — the + // project itself cannot be created in it, because `mcpp new` refuses a + // destination that already exists. + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: t("Create the project here"), + }); + const location = picked?.[0]; + if (location === undefined) { + return; + } + + const projectRoot = join(location.fsPath, projectName); + // Shown *and* compared: one definition keeps the two in step. + const confirmCreate = t("Create and open"); + await runNewProjectFlow(projectName, location.fsPath, projectRoot, { + exists: existsSync, + confirm: async (message) => + (await vscode.window.showWarningMessage(message, { modal: true }, confirmCreate)) + === confirmCreate, + run: async (name, cwd) => { + const executable = this.mcppExecutable(undefined); + const args = mcppCommandArguments("new", name); + const result = await runMcppCommand(executable, args, cwd); + this.appendShortCommand(t("New project"), executable, args, result); + return result.exitCode; + }, + openFolder: async (path) => { + await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.file(path)); + }, + showError: async (message) => { + await vscode.window.showErrorMessage(message); + }, + }); + } + + private guarded(operation: () => Promise): () => Promise { + return async () => { + try { + await operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.error(t("mcpp CLI operation failed: {0}", message)); + await vscode.window.showErrorMessage(t("mcpp: {0}", message)); + } + }; + } + + private requireProject(): McppProjectDiscovery | undefined { + const project = this.options.currentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); + } + return project; + } + + private requireTrusted(): boolean { + if (this.options.isTrusted()) { + return true; + } + void vscode.window.showWarningMessage( + t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first."), + ); + return false; + } + + public mcppExecutable(project: McppProjectDiscovery | undefined): string { + const uri = project === undefined + ? vscode.workspace.workspaceFolders?.[0]?.uri + : vscode.Uri.file(project.root); + const configured = vscode.workspace.getConfiguration("mcpp", uri).get("path", "").trim(); + return configured.length === 0 ? "mcpp" : configured; + } + + private isNestedWorkspaceProject(project: McppProjectDiscovery | undefined): boolean { + if (project === undefined) { + return false; + } + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(project.root)); + return folder !== undefined && folder.uri.fsPath !== project.root; + } + + public async readToolchainInventory( + project: McppProjectDiscovery | undefined = this.options.currentProject(), + ): Promise { + const executable = this.mcppExecutable(project); + const args = mcppCommandArguments("toolchain", "list"); + const result = await runMcppCommand(executable, args, workingDirectory(project)); + this.appendShortCommand(t("Inspect the toolchain"), executable, args, result); + if (result.exitCode !== 0) { + this.reportCommandFailure( + args, + result.exitCode, + `${result.stdout}\n${result.stderr}`, + t("mcpp toolchain list failed (exit code {0}). See the mcpp output channel.", result.exitCode), + ); + return undefined; + } + + const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); + if (!inventory.recognized) { + await vscode.window.showErrorMessage( + t("The current mcpp toolchain list output is not recognised; the raw output is kept in the mcpp output channel, so check the mcpp version."), + ); + return undefined; + } + return inventory; + } + + private async executeTask( + root: string, + executable: string, + title: string, + args: string[], + ): Promise { + // `mcpp.task.revealTerminal` / `focusTerminal` / `clearTerminal` and + // `mcpp.task.problemMatcher` are read here, where the task object is + // assembled; the completion path re-reads them for the failure-only reveal. + const ui = this.terminalUi(root); + const task = new vscode.Task( + { type: "mcpp", command: args[0] ?? "mcpp", projectRoot: root }, + taskScope(root), + title, + "mcpp", + new vscode.ProcessExecution(executable, args, { cwd: root }), + [...problemMatchersFor(read("mcpp.task.problemMatcher", vscode.Uri.file(root)))], + ); + task.presentationOptions = { + reveal: ui.reveal === "always" + ? vscode.TaskRevealKind.Always + : ui.reveal === "never" + ? vscode.TaskRevealKind.Never + : vscode.TaskRevealKind.Silent, + panel: vscode.TaskPanelKind.Dedicated, + focus: ui.focus, + clear: ui.clearBeforeRun, + showReuseMessage: false, + }; + + let execution: vscode.TaskExecution | undefined; + let earlyCompletion: TaskCompletion | undefined; + let settled = false; + let processEndSubscription: vscode.Disposable | undefined; + let taskEndSubscription: vscode.Disposable | undefined; + const disposeListeners = (): void => { + processEndSubscription?.dispose(); + taskEndSubscription?.dispose(); + }; + const finish = (completion: TaskCompletion): void => { + if (settled) { + return; + } + settled = true; + disposeListeners(); + resolveCompletion?.(completion); + }; + let resolveCompletion: ((completion: TaskCompletion) => void) | undefined; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; + processEndSubscription = vscode.tasks.onDidEndTaskProcess((event) => { + if (event.execution.task !== task) { + return; + } + const classified = classifyTaskExit(event.exitCode); + if (execution === undefined) { + earlyCompletion ??= classified; + return; + } + finish(classified); + }); + taskEndSubscription = vscode.tasks.onDidEndTask((event) => { + if (event.execution.task !== task) { + return; + } + const classified = classifyTaskExit(undefined); + if (execution === undefined) { + earlyCompletion ??= classified; + return; + } + finish(classified); + }); + }); + + try { + execution = await vscode.tasks.executeTask(task); + } catch (error) { + disposeListeners(); + throw error; + } + if (earlyCompletion !== undefined) { + finish(earlyCompletion); + } + return completion; + } + + private appendTaskCompletion( + root: string, + title: string, + args: string[], + completion: TaskCompletion, + ): void { + const suffix = completion.state === "succeeded" + ? t("Exit code {0}", completion.exitCode ?? 0) + : completion.state === "cancelled" + ? t("Cancelled") + : t("Failed with exit code {0}", completion.exitCode ?? t("unknown")); + // `mcpp.log.level` gates the verbose lines; the failed result is an error + // and is therefore never suppressed. + this.logger.info(`\n[${new Date().toISOString()}] ${title}`); + this.logger.debug(t("Working directory: {0}", root)); + this.logger.debug(t("Task arguments: {0}", args.join(" "))); + const resultLine = t("Result: {0}", suffix); + if (completion.state === "failed") { + this.logger.error(resultLine); + } else if (completion.state === "cancelled") { + this.logger.warn(resultLine); + } else { + this.logger.info(resultLine); + } + + for (const command of terminalCommandsAfterTask(this.terminalUi(root), completion.state === "failed")) { + void vscode.commands.executeCommand(command); + } + + if (completion.state === "failed") { + this.reportCommandFailure( + args, + completion.exitCode ?? 1, + "", + t("{0} failed (exit code {1}). See the task terminal.", title, completion.exitCode ?? t("unknown")), + ); + } else if (completion.state === "cancelled") { + void vscode.window.showWarningMessage(t("{0} was cancelled.", title)); + } else { + this.reportSuccess(t("{0} finished.", title)); + } + } + + /** The task-terminal settings, read together so every site agrees. */ + private terminalUi(root: string): TaskTerminalUi { + const resource = vscode.Uri.file(root); + return taskTerminalUi( + read("mcpp.task.revealTerminal", resource), + read("mcpp.task.focusTerminal", resource), + read("mcpp.task.clearTerminal", resource), + ); + } + + /** + * A *successful* result only; failures and cancellations never consult + * `mcpp.ui.notifications.success`. `mcpp.ui.notifications.dedupeMinutes` + * suppresses a repeated identical message inside its window. + */ + private reportSuccess(message: string): void { + const mode = successReportOf(read("mcpp.ui.notifications.success")); + if (mode === "silent") { + return; + } + const now = Date.now(); + if (!dedupeAllows(this.lastNotified.get(message), now, read("mcpp.ui.notifications.dedupeMinutes"))) { + return; + } + this.lastNotified.set(message, now); + if (mode === "toast") { + void vscode.window.showInformationMessage(message); + return; + } + this.showStatusSuccess(message); + } + + /** `mcpp.ui.notifications.success = statusBar`: a transient, non-blocking label. */ + private showStatusSuccess(message: string): void { + if (this.options.currentProject() === undefined || !read("mcpp.ui.statusBar.show")) { + return; + } + this.status.text = `$(check) ${statusBarSuccessText(message)}`; + this.status.show(); + if (this.statusRevert !== undefined) { + clearTimeout(this.statusRevert); + } + this.statusRevert = setTimeout(() => { + this.statusRevert = undefined; + try { + this.applyStatusBar(); + } catch { + // The status item can already be disposed during a window reload. + } + }, SUCCESS_STATUS_MS); + } + + /** + * A failed `mcpp` run: the exit-code-specific next step from + * `src/cli/errors.ts`, the caller's own message as the fallback, and the + * `mcpp self explain` hint whenever the output names a diagnostic code. + */ + private reportCommandFailure( + args: readonly string[], + exitCode: number, + output: string, + fallback: string, + ): void { + const advice = failureAdvice(args, exitCode, output); + const parts: string[] = [failureGuidanceText(advice.kind) ?? fallback]; + if (advice.hint !== undefined) { + parts.push(t("Explain this code with: {0}", advice.hint)); + } + void vscode.window.showErrorMessage(parts.join(" ")); + } + + private warningOptions(strength: ConfirmationStrength, detail: string): vscode.MessageOptions { + return strength === "modal" ? { modal: true, detail } : { detail }; + } + + /** + * `mcpp.ui.statusBar.show` plus `mcpp.ui.statusBar.showLanguageServer`; the + * C++ Modules line comes from the host callback, so this controller never + * imports the mcppls bridge. + */ + private applyStatusBar(): void { + if (this.statusRevert !== undefined) { + clearTimeout(this.statusRevert); + this.statusRevert = undefined; + } + const mcpp = this.options.projectMcpp?.(); + const mcppLine = mcpp === undefined ? undefined + : mcpp.state === "pinned-missing" ? t("The project's mcpp is not installed: {0}", mcpp.install ?? mcpp.message ?? "") + : mcpp.state === "failed" ? t("The project's mcpp did not answer: {0}", mcpp.message ?? "") + : mcpp.fromSetting ? t("mcpp {0} at {1}, set by mcpp.path (the project's own version is not chosen)", mcpp.version, mcpp.program) + : mcpp.state === "not-probed" ? t("mcpp {0} (not asked in the project: the workspace is not trusted)", mcpp.version) + : t("mcpp {0} at {1}, the project's", mcpp.version, mcpp.program); + const base = mcpp !== undefined && mcpp.version !== "" && (mcpp.state === "ok" || mcpp.state === "not-probed") + ? `${quickMenuStatusText} ${mcpp.version}` : quickMenuStatusText; + const tooltip = mcppLine === undefined ? t("Open the mcpp project and toolchain quick menu") + : `${mcppLine}\n${t("Open the mcpp project and toolchain quick menu")}`; + const languageServer = languageServerStatusSuffix( + read("mcpp.ui.statusBar.showLanguageServer"), + this.options.languageServerSummary?.(), + ); + this.status.text = languageServer === undefined + ? base + : `${base} · ${t("C++ Modules: {0}", languageServer)}`; + this.status.tooltip = languageServer === undefined + ? tooltip + : `${tooltip}\n${t("C++ Modules (provided by the mcpp language server extension): {0}", languageServer)}`; + // `mcpp.ui.statusBar.background`: VS Code allows a status bar entry exactly + // two backgrounds and picks the matching foreground itself; see + // `src/cli/statusBar.ts` for the whitelist this reads. + const background = statusBarBackgroundColour(read("mcpp.ui.statusBar.background")); + this.status.backgroundColor = background === undefined ? undefined : new vscode.ThemeColor(background); + if (read("mcpp.ui.statusBar.show")) { + this.status.show(); + } else { + this.status.hide(); + } + } + + private appendShortCommand( + title: string, + executable: string, + args: string[], + result: { exitCode: number; stdout: string; stderr: string }, + ): void { + // The command echo and the output of a successful command are the verbose + // lines `mcpp.log.level` gates. A failed command's raw stdout/stderr is + // written at `error`, which no configured level may suppress. + const failed = result.exitCode !== 0; + this.logger.info(`\n[${new Date().toISOString()}] ${title}`); + this.logger.debug(`$ ${commandLine(executable, args)}`); + if (result.stdout.length > 0) { + (failed ? this.logger.error : this.logger.info)(result.stdout.trimEnd()); + } + if (result.stderr.length > 0) { + (failed ? this.logger.error : this.logger.info)(result.stderr.trimEnd()); + } + (failed ? this.logger.error : this.logger.info)(`[exit ${result.exitCode}]`); + } +} diff --git a/mide-plugins/vscode/src/cli/errors.ts b/mide-plugins/vscode/src/cli/errors.ts new file mode 100644 index 0000000..a24fc35 --- /dev/null +++ b/mide-plugins/vscode/src/cli/errors.ts @@ -0,0 +1,156 @@ +/** + * mcpp's exit-code contract (SPEC-003) turned into something a UI can branch on. + * + * | code | meaning | + * |------|--------------------------------------------| + * | 0 | success | + * | 1 | ran and failed | + * | 2 | usage error | + * | 4 | environment not ready | + * | 70 | internal error | + * | 101 | build failure — **only for `mcpp run`** | + * | 127 | unknown command | + * + * Two documented quirks are respected by the callers of this module: + * `mcpp build --configure-only` returns **2** for a *planning* failure, and + * `mcpp emit build-database` returns **1** together with a usable envelope — + * see {@link hasUsableOutput}. Nothing here parses narration for meaning; the + * only text it understands is the `MCPP_*` diagnostic code that + * `mcpp self explain` accepts. + * + * Pure string/number functions: no child process, no `vscode`. + */ + +export type McppFailureKind = + | "none" + | "failed" + | "usage" + | "environment" + | "internal" + | "build-failed" + | "unknown-command" + | "cancelled"; + +export interface McppOutcome { + kind: McppFailureKind; + /** Raw exit code, or -1 when the process could not be started at all. */ + exitCode: number; + /** The single stderr line that best explains it, when there is one. */ + detail?: string; + /** A `MCPP_*` diagnostic code found in the output, when there is one. */ + diagnosticCode?: string; +} + +const USAGE = 2; +const ENVIRONMENT = 4; +const INTERNAL = 70; +const BUILD_FAILED = 101; +const UNKNOWN_COMMAND = 127; + +/** `MCPP_OFFLINE_DOWNLOAD_REQUIRED`, never a lowercase word like `mcpp_offline`. */ +const DIAGNOSTIC_CODE = /\bMCPP_[A-Z][A-Z0-9_]*/; + +/** + * Map an exit code (plus the argv that produced it, which is the only way to + * tell `mcpp run`'s build failure apart) to a failure kind. + * + * `undefined` means "the process never produced an exit code" — a spawn failure + * or a cancellation — and yields `{ kind: "cancelled", exitCode: -1 }`. Signal + * terminations reported as a negative code are treated the same way. + * + * Codes that are not in the contract (for example a program's own exit code + * relayed by `mcpp run`) fall back to `"failed"`. + */ +export function classifyExit(exitCode: number | undefined, command?: readonly string[]): McppOutcome { + if (typeof exitCode !== "number" || !Number.isFinite(exitCode) || exitCode < 0) { + return { kind: "cancelled", exitCode: -1 }; + } + if (exitCode === 0) { + return { kind: "none", exitCode }; + } + if (exitCode === UNKNOWN_COMMAND) { + return { kind: "unknown-command", exitCode }; + } + if (exitCode === BUILD_FAILED) { + // 101 is `mcpp run`'s "your program failed to build"; for anything else it + // is an ordinary runtime failure. + return { kind: command?.[0] === "run" ? "build-failed" : "failed", exitCode }; + } + if (exitCode === USAGE) { + return { kind: "usage", exitCode }; + } + if (exitCode === ENVIRONMENT) { + return { kind: "environment", exitCode }; + } + if (exitCode === INTERNAL) { + return { kind: "internal", exitCode }; + } + return { kind: "failed", exitCode }; +} + +/** The first `MCPP_*` diagnostic code in `output`, when there is one. */ +export function diagnosticCodeIn(output: string): string | undefined { + if (typeof output !== "string") { + return undefined; + } + return DIAGNOSTIC_CODE.exec(output)?.[0]; +} + +/** Minimal JSON extraction for salvage checks: the trimmed text, then its outermost `{…}` / `[…]`. */ +function jsonDocumentIn(stdout: string): unknown { + if (typeof stdout !== "string") { + return undefined; + } + const text = stdout.trim(); + if (text.length === 0) { + return undefined; + } + const attempts = [text]; + const objectStart = text.indexOf("{"); + const objectEnd = text.lastIndexOf("}"); + if (objectStart >= 0 && objectEnd > objectStart) { + attempts.push(text.slice(objectStart, objectEnd + 1)); + } + const arrayStart = text.indexOf("["); + const arrayEnd = text.lastIndexOf("]"); + if (arrayStart >= 0 && arrayEnd > arrayStart) { + attempts.push(text.slice(arrayStart, arrayEnd + 1)); + } + for (const attempt of attempts) { + try { + const parsed: unknown = JSON.parse(attempt); + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } catch { + // Not this candidate; try the next one. + } + } + return undefined; +} + +/** + * True when stdout carried something usable **even though the exit code was + * non-zero** — the `mcpp emit build-database` partial-failure case, where the + * envelope and its `diagnostics` are still worth reading. + * + * False when the command succeeded (there is nothing to salvage) and false when + * stdout has no parseable JSON document. A bare `--json` document counts just + * like an envelope. + */ +export function hasUsableOutput(outcome: McppOutcome, stdout: string): boolean { + if (outcome.exitCode === 0) { + return false; + } + return jsonDocumentIn(stdout) !== undefined; +} + +/** + * The follow-up command that explains a failure, e.g. + * `mcpp self explain MCPP_OFFLINE_DOWNLOAD_REQUIRED`. `undefined` when there is + * no diagnostic code to explain. + */ +export function explainHint(outcome: McppOutcome): string | undefined { + const code = outcome.diagnosticCode ?? diagnosticCodeIn(outcome.detail ?? ""); + return code === undefined ? undefined : `mcpp self explain ${code}`; +} diff --git a/mide-plugins/vscode/src/cli/labels.ts b/mide-plugins/vscode/src/cli/labels.ts new file mode 100644 index 0000000..96a4757 --- /dev/null +++ b/mide-plugins/vscode/src/cli/labels.ts @@ -0,0 +1,43 @@ +/** + * The controller's own buttons. + * + * `showWarningMessage` / `showInformationMessage` reply with the label the user + * pressed, so a button that is both **displayed** and **compared** must be + * defined exactly once: two literals would silently desynchronise, and the + * comparison would never match a translated label. + * + * Kept out of `controller.ts` so it stays importable without an editor (`t()` + * loads the editor API lazily) and `test/cli/labels.test.ts` can hold the + * identity rule. + */ + +import { t } from "../i18n/t"; + +export interface ControllerLabels { + installCustom: string; + confirmInstall: string; + confirmDetect: string; + confirmDefault: string; + confirmClean: string; + chooseGlobalDefault: string; + cleanAndBuild: string; + showTasks: string; +} + +/** + * Resolved on every call, so `mcpp.ui.language` applies without a reload, and + * read once per dialogue into a local `labels` value that serves both the + * prompt and the `===` comparison. + */ +export function controllerLabels(): ControllerLabels { + return { + installCustom: t("$(edit) Enter another compatible toolchain spec…"), + confirmInstall: t("Install"), + confirmDetect: t("Detect"), + confirmDefault: t("Set as global default"), + confirmClean: t("Clean target"), + chooseGlobalDefault: t("Choose a global default"), + cleanAndBuild: t("Clean and build"), + showTasks: t("Show running tasks"), + }; +} diff --git a/mide-plugins/vscode/src/cli/moduleGraph.ts b/mide-plugins/vscode/src/cli/moduleGraph.ts new file mode 100644 index 0000000..c7c60d6 --- /dev/null +++ b/mide-plugins/vscode/src/cli/moduleGraph.ts @@ -0,0 +1,10 @@ +import { runProcess, type ProcessRunner } from "./process"; +import { parseModuleGraph, type ModuleGraphJson } from "../projects/moduleGraph"; + +/** Use mcppls's resolved graph; never launch a second C++ parser in this extension. */ +export async function queryModuleGraph(trusted: boolean, executable: string, root: string, runner: ProcessRunner = runProcess, signal?: AbortSignal): Promise { + if (!trusted) { throw new Error("Trust this workspace before reading its module graph."); } + const result = await runner(executable, ["query", "module", "--graph", "--root", root, "--timeout", "20", "--format", "json"], root, { timeoutMs: 30_000, maxBufferMiB: 8, ...(signal ? { signal } : {}) }); + if (result.exitCode !== 0) { throw new Error(result.stderr.trim() || "mcppls module graph query failed"); } + return parseModuleGraph(result.stdout); +} diff --git a/mide-plugins/vscode/src/cli/newProject.ts b/mide-plugins/vscode/src/cli/newProject.ts new file mode 100644 index 0000000..90547e6 --- /dev/null +++ b/mide-plugins/vscode/src/cli/newProject.ts @@ -0,0 +1,92 @@ +import { t } from "../i18n/t"; + +export interface NewProjectActions { + exists(path: string): boolean; + confirm(message: string): Promise; + run(name: string, cwd: string): Promise; + openFolder(path: string): Promise; + showError(message: string): Promise | void; +} + +export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; + +/** + * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— + * 打开后的完整构建交给用户手动触发;新窗口按自身信任状态与 CDB 状态决定是否运行 + * `mcpp build --configure-only`,避免创建流程跨窗口重复启动配置。 + */ +export async function runNewProjectFlow( + projectName: string, + location: string, + projectRoot: string, + actions: NewProjectActions, +): Promise { + if (actions.exists(projectRoot)) { + await actions.showError(t("The destination already exists: {0}. Choose another project name or location.", projectRoot)); + return "exists"; + } + const confirmed = await actions.confirm( + t("Run “mcpp new {0}” in {1}, create the project folder {2} and open it.", projectName, location, projectRoot), + ); + if (!confirmed) { + return "declined"; + } + const exitCode = await actions.run(projectName, location); + if (exitCode !== 0) { + await actions.showError( + t("mcpp new {0} failed (exit code {1}). See the mcpp output channel.", projectName, exitCode), + ); + return "failed"; + } + await actions.openFolder(projectRoot); + return "opened"; +} + +const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; +const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/; +const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; +const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT"; + +/** + * 新建工程的项目名校验。返回错误提示;undefined 表示合法。 + * + * 项目名随后作为 `mcpp new ` 的 argv 传入:参数数组只能防 shell 注入, + * 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝 + * `-` 前缀以及 `.`、`..`。 + * + * mcpp 模板把项目名直接写进 mcpp.toml 的 `name = "{}"` 和 main.cpp,不做 + * TOML/C++ 转义,所以拒绝双引号和控制字符;Windows 保留字符、保留设备名和 + * 尾随点一并按跨平台策略拒绝。根本修复应在 mcpp CLI 自身完成。 + */ +export function validateNewProjectName(input: string): string | undefined { + const name = input.trim(); + if (name.length === 0) { + return t("The project name must not be empty."); + } + if (/[\\/]/.test(name)) { + return t("The project name must not contain a path separator."); + } + if (name.startsWith("-")) { + return t("The project name must not start with -, or mcpp would read it as a command-line option."); + } + if (name === "." || name === "..") { + return t("The project name must not be . or .."); + } + // mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。 + if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) { + return t("The project name must not contain PROJECT: it triggers a defect in the current mcpp template substitution."); + } + if (CONTROL_CHARS.test(name)) { + return t("The project name must not contain control characters."); + } + if (WINDOWS_RESERVED_CHARS.test(name)) { + return t('The project name must not contain reserved characters such as <>:"|?*.'); + } + if (name.endsWith(".")) { + return t("The project name must not end with . (unsupported on Windows)."); + } + if (WINDOWS_DEVICE_NAMES.test(name)) { + return t("The project name must not be a Windows reserved device name."); + } + return undefined; +} diff --git a/mide-plugins/vscode/src/cli/process.ts b/mide-plugins/vscode/src/cli/process.ts new file mode 100644 index 0000000..a51a845 --- /dev/null +++ b/mide-plugins/vscode/src/cli/process.ts @@ -0,0 +1,160 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface ProcessResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface ProcessRunOptions { + signal?: AbortSignal; + timeoutMs?: number; + /** `mcpp.runtime.maxOutputMiB`; the default matches what the extension shipped before it was configurable. */ + maxBufferMiB?: number; +} + +export type ProcessRunner = ( + executable: string, + args: string[], + cwd?: string, + options?: ProcessRunOptions, +) => Promise; + +/** + * Windows cannot `execFile` a `.cmd`/`.bat` shim directly — Node ≥ 20.12 rejects + * it with EINVAL outright, and older hosts still mis-handle shebang scripts. A + * `mcpp.path` that points at such a wrapper (npm-style shim, the e2e fake mcpp) + * therefore has to go through the shell. Everywhere else the direct spawn is + * both safer and faster, so only these two suffixes opt in. + */ +export function spawnNeedsShell(executable: string, platform: NodeJS.Platform = process.platform): boolean { + return platform === "win32" && /\.(cmd|bat)$/i.test(executable); +} + +/** + * One argument, quoted for the shell path (external review P1-2, 2026-10-03). + * + * Node joins `file` and `args` with plain spaces when `shell: true`, without + * quoting anything, so a path with a space (`C:\Users\John Doe\…`, exactly what + * `xpkg parse` receives) splits in two and a search term like `foo & calc` + * becomes two commands. The rule is the one `CommandLineToArgvW` applies when + * the target re-parses the line: double backslashes that precede a quote or end + * the argument, escape the quotes themselves, and wrap in double quotes — + * inside which cmd treats `& | < > ^` as literal. `%VAR%` expansion inside + * quotes is a cmd limitation every shell-spawner shares and stays documented + * here rather than half-fixed. + */ +export function quoteWindowsArgument(argument: string): string { + let quoted = argument.replace(/(\\*)"/g, '$1$1\\"'); + quoted = quoted.replace(/(\\*)$/, '$1$1'); + return `"${quoted}"`; +} + +/** + * Who runs mcpp and mcppls. With a backend installed (from 1.1.0 the mcpp-ide session), every mcpp + * command below is one `mcpp/exec` of the backend, which runs mcpp as it runs every command — the + * mcpp it located, the session's client identity, the effects an untrusted workspace denies — and + * every mcppls command one `languageService/exec`, which runs the mcppls the backend found (a + * payload's runner and image, or a program). The output reaches the same parsers it reached when + * the extension ran the programs itself. Any other program still runs here (`runProcess`). + */ +// An executor answers `undefined` where the backend does not run (the declared degraded mode, +// design 2026-10-30 part 2 §8 F15): the program then runs here, as it did before the backend. +type McppExecutor = (executable: string, args: string[], cwd?: string, options?: ProcessRunOptions) => Promise; +type McpplsExecutor = (args: string[], cwd?: string, options?: ProcessRunOptions) => Promise; +let mcppExecutor: McppExecutor | undefined; +let mcpplsExecutor: McpplsExecutor | undefined; + +export function useMcppExecutor(executor: McppExecutor | undefined): void { + mcppExecutor = executor; +} + +export function useMcpplsExecutor(executor: McpplsExecutor | undefined): void { + mcpplsExecutor = executor; +} + +/** Runs mcpp: through the backend when one is installed and runs, else as `executable`. No trust check. */ +export async function runMcppCommand( + executable: string, + args: string[], + cwd?: string, + options: ProcessRunOptions = {}, +): Promise { + return (await mcppExecutor?.(executable, args, cwd, options)) ?? runProcess(executable, args, cwd, options); +} + +/** Runs mcppls: through the backend when one is installed and runs, else `mcppls` from PATH. */ +export async function runMcppls(args: string[], cwd?: string, options: ProcessRunOptions = {}): Promise { + return (await mcpplsExecutor?.(args, cwd, options)) ?? runProcess("mcppls", args, cwd, options); +} + +export async function runProcess( + executable: string, + args: string[], + cwd?: string, + options: ProcessRunOptions = {}, +): Promise { + try { + const needsShell = spawnNeedsShell(executable); + const result = await execFileAsync( + needsShell ? quoteWindowsArgument(executable) : executable, + needsShell ? args.map(quoteWindowsArgument) : args, + { + cwd, + encoding: "utf8", + maxBuffer: Math.max(1, options.maxBufferMiB ?? 16) * 1024 * 1024, + timeout: options.timeoutMs, + signal: options.signal, + ...(needsShell ? { shell: true } : {}), + }, + ); + return { + exitCode: 0, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (error) { + const processError = error as NodeJS.ErrnoException & { + stdout?: string; + stderr?: string; + code?: number | string; + }; + return { + exitCode: typeof processError.code === "number" ? processError.code : 1, + stdout: processError.stdout ?? "", + stderr: processError.stderr ?? (typeof processError.message === "string" ? processError.message : ""), + }; + } +} + +/** + * The one way the extension body runs mcpp: `runProcess` plus the + * workspace-trust gate (external review P0, 2026-10-03). + * + * `mcpp.path` is a `resource`-scoped setting, so an untrusted workspace can + * name any program there; every caller outside `src/cli/` therefore goes + * through this seam and receives `undefined` — a refusal, not an error — when + * the workspace is not trusted. The callers degrade (the detail page falls + * back to the descriptor's own text, the cross-registry search keeps its + * local results, the self-check reports the probe as unknown) instead of + * running the command. + * + * `src/cli/controller.ts` wraps its own `requireTrusted()` prompts around + * whole commands, which is why `src/cli/` may still call `runProcess` + * directly; the architecture test holds everyone else to this seam. + */ +export async function runMcpp( + trusted: boolean, + executable: string, + args: readonly string[], + cwd?: string, + options: ProcessRunOptions = {}, +): Promise { + if (!trusted) { + return undefined; + } + return runMcppCommand(executable, [...args], cwd, options); +} diff --git a/mide-plugins/vscode/src/cli/protocol.ts b/mide-plugins/vscode/src/cli/protocol.ts new file mode 100644 index 0000000..a84841b --- /dev/null +++ b/mide-plugins/vscode/src/cli/protocol.ts @@ -0,0 +1,259 @@ +/** + * mcpp's machine-output protocol (`docs/50-machine-output.md`). + * + * **Detection rule**: parse stdout and require `schemaVersion` **and** `kind`. + * The exit code is never the signal — an older mcpp answers + * `mcpp --protocol-version` with human text on stdout and exit 1, and a + * *failing* command can still emit a perfectly good envelope + * (`mcpp emit build-database` on partial failure). Everything here is a pure + * string function: no child process, no `vscode`. + * + * `mcpp --protocol-version` prints an envelope with a `mcpp.protocol` kind: + * + * ```jsonc + * { "schemaVersion": 1, "kind": "mcpp.protocol", + * "envelope": { "min": 1, "max": 1 }, + * "kinds": { "mcpp.env": 1, "mcpp.toolchain.list": 1 }, + * "commands": { "toolchain list": { "effects": ["read"] } }, + * "mcpp": { "version": "2026.9.30.2", "protocol": { "min": 1, "max": 1 } } } + * ``` + * + * `kinds` is the answer to "which `--format json` commands are safe to use" — + * see {@link supportsKind}. + */ + +export interface ProtocolEnvelope { + schemaVersion: number; + kind: string; + kindVersion?: number; + data?: T; + diagnostics?: ProtocolDiagnostic[]; + effects?: string[]; + mcpp?: { version?: string; protocol?: { min?: number; max?: number } }; +} + +export interface ProtocolDiagnostic { + code: string; + severity: "error" | "warning" | "note"; + source?: string; + message: string; + path?: string; + range?: unknown; +} + +export interface ProtocolInfo { + mcppVersion?: string; + envelopeMax?: number; + kinds: Record; + /** command name -> the effects it may have, from `mcpp --protocol-version`. */ + effects: Record; +} + +const PROTOCOL_KIND = "mcpp.protocol"; + +/** + * Upper bound on the number of substrings we are willing to try when a document + * is not pure JSON. Only reached for malformed input, and it keeps the scan + * linear-ish on a 16 MiB capture. + */ +const MAX_JSON_CANDIDATES = 64; + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return undefined; + } + return value as Record; +} + +function readNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +/** + * Candidate object substrings for a text that is **not** pure JSON: every `{…}` + * span between the brace positions. Narration before or after the document (a + * `warning:` line, a blank line, an old-mcpp banner) therefore cannot hide it, + * and a stray `}` in trailing narration does not either. + */ +function jsonCandidates(text: string): string[] { + const candidates: string[] = []; + + const starts: number[] = []; + for (let index = text.indexOf("{"); index >= 0; index = text.indexOf("{", index + 1)) { + starts.push(index); + if (starts.length >= MAX_JSON_CANDIDATES) { + break; + } + } + const ends: number[] = []; + for (let index = text.lastIndexOf("}"); index >= 0; index = text.lastIndexOf("}", index - 1)) { + ends.push(index); + if (ends.length >= MAX_JSON_CANDIDATES) { + break; + } + } + + for (const start of starts) { + // `ends` is descending: the first entry is the outermost closing brace. + for (const end of ends) { + if (end <= start) { + break; + } + candidates.push(text.slice(start, end + 1)); + if (candidates.length >= MAX_JSON_CANDIDATES) { + return candidates; + } + } + } + return candidates; +} + +/** The first JSON object found in `stdout`; `undefined` for anything else. */ +function parseJsonObject(stdout: string): Record | undefined { + const text = stdout.trim(); + if (text.length === 0) { + return undefined; + } + try { + // The trimmed text is one complete JSON value, so it *is* the document. + // An array or a scalar is not an envelope, and we must not dig an object + // out of it. + return asRecord(JSON.parse(text) as unknown); + } catch { + // Narration around the document: fall through to the outermost object. + } + for (const candidate of jsonCandidates(text)) { + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + continue; + } + const record = asRecord(parsed); + if (record !== undefined) { + return record; + } + } + return undefined; +} + +/** Parse stdout as one enveloped document; `undefined` when it is not one. */ +export function parseEnvelope(stdout: string): ProtocolEnvelope | undefined { + if (typeof stdout !== "string") { + return undefined; + } + const record = parseJsonObject(stdout); + if (record === undefined) { + return undefined; + } + // Presence *and* type: a string `schemaVersion` is not a document we can trust. + if (typeof record.schemaVersion !== "number") { + return undefined; + } + if (typeof record.kind !== "string" || record.kind.length === 0) { + return undefined; + } + return record as unknown as ProtocolEnvelope; +} + +function kindVersionOf(value: unknown): number | undefined { + const direct = readNumber(value); + if (direct !== undefined) { + return direct; + } + const record = asRecord(value); + if (record === undefined) { + return undefined; + } + return readNumber(record.version) ?? readNumber(record.max); +} + +function readKindVersions(source: Record | undefined): Record { + const versions: Record = {}; + if (source === undefined) { + return versions; + } + for (const [kind, value] of Object.entries(source)) { + const version = kindVersionOf(value); + if (version !== undefined) { + versions[kind] = version; + } + } + return versions; +} + +function readCommandEffects(source: Record | undefined): Record { + const effects: Record = {}; + if (source === undefined) { + return effects; + } + for (const [command, value] of Object.entries(source)) { + const entry = asRecord(value); + if (entry === undefined || !Array.isArray(entry.effects)) { + continue; + } + effects[command] = entry.effects.filter((item): item is string => typeof item === "string"); + } + return effects; +} + +/** + * Parse `mcpp --protocol-version`. Returns `undefined` for older mcpp (human + * text on stdout, exit 1) and for any document that is not the protocol + * envelope, so the caller can fall back to the legacy text path. + * + * The fields sit at the envelope's top level in the real output; `data` is + * accepted as well so a future nesting cannot silently disable the probe. + */ +export function parseProtocolInfo(stdout: string): ProtocolInfo | undefined { + const envelope = parseEnvelope(stdout) as + | (ProtocolEnvelope & Record) + | undefined; + if (envelope === undefined || envelope.kind !== PROTOCOL_KIND) { + return undefined; + } + + const data = asRecord(envelope.data); + const mcpp = asRecord(envelope.mcpp) ?? asRecord(data?.mcpp); + const envelopeRange = asRecord(envelope.envelope) ?? asRecord(data?.envelope); + const protocolRange = asRecord(mcpp?.protocol); + + const info: ProtocolInfo = { + kinds: readKindVersions(asRecord(envelope.kinds) ?? asRecord(data?.kinds)), + effects: readCommandEffects(asRecord(envelope.commands) ?? asRecord(data?.commands)), + }; + const mcppVersion = readString(mcpp?.version); + if (mcppVersion !== undefined) { + info.mcppVersion = mcppVersion; + } + const envelopeMax = readNumber(envelopeRange?.max) ?? readNumber(protocolRange?.max); + if (envelopeMax !== undefined) { + info.envelopeMax = envelopeMax; + } + return info; +} + +/** The kinds mcpp advertised, i.e. which `--format json` commands are safe to use. */ +export function supportsKind(info: ProtocolInfo | undefined, kind: string): boolean { + if (info === undefined || typeof kind !== "string") { + return false; + } + const kinds = info.kinds; + if (typeof kinds !== "object" || kinds === null) { + return false; + } + return Object.prototype.hasOwnProperty.call(kinds, kind); +} + +/** `parseEnvelope` and `kind` check in one step, returning `data` or `undefined`. */ +export function readData(stdout: string, kind: string): T | undefined { + const envelope = parseEnvelope(stdout); + if (envelope === undefined || envelope.kind !== kind) { + return undefined; + } + return envelope.data; +} diff --git a/mide-plugins/vscode/src/cli/selfCheck.ts b/mide-plugins/vscode/src/cli/selfCheck.ts new file mode 100644 index 0000000..4f5f517 --- /dev/null +++ b/mide-plugins/vscode/src/cli/selfCheck.ts @@ -0,0 +1,98 @@ +/** + * `mcpp: Environment Self-check` — one copyable snapshot. + * + * The point is to answer the questions a support thread always asks, in one + * place: which versions, which protocol, what mcppls can do, what the state is, + * which settings were changed. Pure, so the layout is testable and the extension + * host only has to gather the input. + */ + +import { formatBytes, formatCount } from "../util/format"; + +export interface SelfCheckInput { + extensionVersion: string; + vscodeVersion: string; + platform: string; + languagePreference: string; + trusted: boolean; + workspaceRoots: readonly string[]; + projectRoot?: string; + mcppPath: string; + mcppProbe?: { + version?: string; + envelopeMax?: number; + kinds: readonly string[]; + effects?: Record; + }; + mcppls: { + installed: boolean; + version?: string; + enabled: boolean; + state: string; + capabilities: ReadonlyArray<{ key: string; state: string; command?: string }>; + }; + cache?: { totalBytes: number; entries: number; incomplete?: number }; + changedSettings: ReadonlyArray<{ key: string; value: unknown; source: string }>; + lastRefresh?: { at: string; state: string; command?: string }; +} + +function line(label: string, value: string): string { + return ` ${label.padEnd(14)}${value}`; +} + +/** + * The snapshot. It says "unknown" where something could not be read — an empty + * field reads as "nothing is wrong", which is the opposite of the truth. + */ +export function buildSelfCheckText(input: SelfCheckInput): string { + const lines: string[] = []; + lines.push(`mcpp-vscode ${input.extensionVersion} · VS Code ${input.vscodeVersion} · ${input.platform}`); + lines.push(line("Language", input.languagePreference)); + lines.push(line("Workspace", `${input.trusted ? "trusted" : "NOT trusted"} · ${input.workspaceRoots.length} root(s)`)); + for (const root of input.workspaceRoots) { + lines.push(line(" folder", root)); + } + lines.push(line("Project", input.projectRoot ?? "no mcpp.toml found")); + + lines.push(""); + lines.push("mcpp"); + lines.push(line("path", input.mcppPath)); + const probe = input.mcppProbe; + if (probe === undefined) { + lines.push(line("version", "unknown (mcpp --protocol-version did not answer)")); + } else { + lines.push(line("version", probe.version ?? "unknown")); + lines.push(line("envelope", probe.envelopeMax === undefined ? "unknown" : String(probe.envelopeMax))); + lines.push(line("kinds", probe.kinds.length === 0 ? "none advertised" : probe.kinds.join(", "))); + } + + lines.push(""); + lines.push("C++ Modules (mcppls)"); + lines.push(line("installed", input.mcppls.installed ? "yes" : "no")); + lines.push(line("version", input.mcppls.version ?? "unknown")); + lines.push(line("enabled", input.mcppls.enabled ? "yes" : "disabled in this workspace")); + lines.push(line("state", input.mcppls.state)); + for (const capability of input.mcppls.capabilities) { + lines.push(line(` ${capability.key}`, `${capability.state}${capability.command === undefined ? "" : ` → ${capability.command}`}`)); + } + lines.push(line("last refresh", input.lastRefresh === undefined ? "never" : `${input.lastRefresh.state} at ${input.lastRefresh.at}${input.lastRefresh.command === undefined ? "" : ` (${input.lastRefresh.command})`}`)); + + lines.push(""); + lines.push("Cache"); + if (input.cache === undefined) { + lines.push(line("shared", "not read")); + } else { + lines.push(line("shared", `${formatBytes(input.cache.totalBytes)} · ${formatCount(input.cache.entries)} entries${input.cache.incomplete === undefined ? "" : ` · ${input.cache.incomplete} incomplete`}`)); + } + + lines.push(""); + lines.push(`Settings changed from their default (${input.changedSettings.length})`); + if (input.changedSettings.length === 0) { + lines.push(line("", "none")); + } else { + for (const entry of input.changedSettings) { + lines.push(line(` ${entry.source}`, `${entry.key} = ${JSON.stringify(entry.value)}`)); + } + } + return lines.join("\n"); +} diff --git a/mide-plugins/vscode/src/cli/statusBar.ts b/mide-plugins/vscode/src/cli/statusBar.ts new file mode 100644 index 0000000..e7fc67c --- /dev/null +++ b/mide-plugins/vscode/src/cli/statusBar.ts @@ -0,0 +1,45 @@ +/** + * The one thing the status bar item can be painted with. + * + * `StatusBarItem.backgroundColor` looks like it takes any `ThemeColor`, and the + * API reference says so — but the extension host enforces a whitelist, and it is + * the *host* that also swaps the foreground so the text stays readable: + * + * ```js + * // extensionHostProcess.js, ExtHostStatusBarEntry + * static ALLOWED_BACKGROUND_COLORS = new Map([ + * ["statusBarItem.errorBackground", new ThemeColor("statusBarItem.errorForeground")], + * ["statusBarItem.warningBackground", new ThemeColor("statusBarItem.warningForeground")], + * ]); + * set backgroundColor(t) { t && !ALLOWED_BACKGROUND_COLORS.has(t.id) && (t = void 0); … } + * ``` + * + * So a contributed `mcpp.statusBarBackground` would be silently dropped, and a + * `color` set alongside one of the two allowed backgrounds would be overridden. + * This module is the whole answer to "can the mcpp item have a background?" and + * it is pure, so the answer is testable without an editor host. + */ + +/** The value of `mcpp.ui.statusBar.background`. */ +export type StatusBarBackground = "none" | "warning" | "error"; + +/** Every value `mcpp.ui.statusBar.background` accepts, in menu order. */ +export const STATUS_BAR_BACKGROUNDS: readonly StatusBarBackground[] = ["warning", "error", "none"]; + +/** + * The `ThemeColor` id for a setting value, or `undefined` for "no background". + * + * An unknown value is treated as `none` rather than as an error: a settings file + * with a stale value should leave the status bar plain, not throw during + * activation. + */ +export function statusBarBackgroundColour(value: unknown): string | undefined { + switch (value) { + case "warning": + return "statusBarItem.warningBackground"; + case "error": + return "statusBarItem.errorBackground"; + default: + return undefined; + } +} diff --git a/mide-plugins/vscode/src/cli/tasks.ts b/mide-plugins/vscode/src/cli/tasks.ts new file mode 100644 index 0000000..a51deb6 --- /dev/null +++ b/mide-plugins/vscode/src/cli/tasks.ts @@ -0,0 +1,139 @@ +import { t } from "../i18n/t"; + +export type ProjectTaskKind = "build" | "run" | "test" | "clean" | "pack"; +export type TaskState = "succeeded" | "failed" | "cancelled"; + +export interface ProjectTaskPlan { + kind: ProjectTaskKind; + title: string; + args: string[]; +} + +export interface TaskCompletion { + state: TaskState; + exitCode?: number; +} + +/** + * The task's display name. Resolved per call, not at import time, so a change to + * `mcpp.ui.language` applies without reloading the window. + */ +function taskTitle(kind: ProjectTaskKind): string { + switch (kind) { + case "build": + return t("mcpp: Build"); + case "run": + return t("mcpp: Run"); + case "test": + return t("mcpp: Test"); + case "clean": + return t("mcpp: Clean"); + case "pack": + return t("mcpp: Pack"); + } +} + +/** `mcpp.task.Args`, so a user can pass `-j 4` or `--quiet` without a wrapper. */ +export const TASK_ARGUMENT_SETTINGS: Readonly, string>> = { + build: "mcpp.task.buildArgs", + run: "mcpp.task.runArgs", + test: "mcpp.task.testArgs", + clean: "mcpp.task.cleanArgs", +}; + +/** + * What the build, run and test tasks build for: `mcpp.build.target` (a triple, or a target set such + * as `openkal`; empty is this machine) and `mcpp.build.profile` (`dev`, the default, or `release`). + * Clean and pack take neither: pack chooses its own target and profile. + */ +export interface BuildSelection { + target?: string; + profile?: string; +} + +export function selectionArgs(kind: ProjectTaskKind, selection: BuildSelection = {}): string[] { + if (kind !== "build" && kind !== "run" && kind !== "test") { + return []; + } + const out: string[] = []; + const target = typeof selection.target === "string" ? selection.target.trim() : ""; + if (target !== "") { + out.push("--target", target); + } + if (selection.profile === "release") { + out.push("--release"); + } + return out; +} + +export function projectTaskPlan( + kind: ProjectTaskKind, + extraArgs: readonly string[] = [], + selection: BuildSelection = {}, +): ProjectTaskPlan { + return { + kind, + title: taskTitle(kind), + args: [kind, ...selectionArgs(kind, selection), ...extraArgs], + }; +} + +export function shouldRefreshLanguageServerAfterTask( + kind: ProjectTaskKind, + completion?: TaskCompletion, +): boolean { + return kind === "build" && completion !== undefined && completion.state !== "cancelled"; +} + +export function classifyTaskExit(exitCode: number | undefined): TaskCompletion { + if (exitCode === undefined) { + return { state: "cancelled" }; + } + if (exitCode === 0) { + return { state: "succeeded", exitCode }; + } + return { state: "failed", exitCode }; +} + +export class McppOperationRegistry { + private readonly projectTokens = new Map(); + + private globalToken: T | undefined; + + beginProject(projectRoot: string, token: T): T | undefined { + const active = this.projectTokens.get(projectRoot) ?? this.globalToken; + if (active !== undefined) { + return active; + } + this.projectTokens.set(projectRoot, token); + return undefined; + } + + finishProject(projectRoot: string, token: T): void { + if (this.projectTokens.get(projectRoot) === token) { + this.projectTokens.delete(projectRoot); + } + } + + beginGlobal(token: T): T | undefined { + const activeProject = this.projectTokens.values().next().value as T | undefined; + if (this.globalToken !== undefined) { + return this.globalToken; + } + if (activeProject !== undefined) { + return activeProject; + } + this.globalToken = token; + return undefined; + } + + finishGlobal(token: T): void { + if (this.globalToken === token) { + this.globalToken = undefined; + } + } + + public hasActive(): boolean { + return this.projectTokens.size > 0 || this.globalToken !== undefined; + } +} diff --git a/mide-plugins/vscode/src/cli/toolchain.ts b/mide-plugins/vscode/src/cli/toolchain.ts new file mode 100644 index 0000000..fe43a18 --- /dev/null +++ b/mide-plugins/vscode/src/cli/toolchain.ts @@ -0,0 +1,422 @@ +export type ToolchainSource = "managed" | "system"; +export type ToolchainStatus = "installed" | "available" | "planned"; + +export interface ToolchainItem { + family: string; + version: string; + spec: string; + source: ToolchainSource; + effective: boolean; +} + +export interface TargetItem { + target: string; + note: string; + toolchainSpec: string | undefined; + status: ToolchainStatus; + effective: boolean; +} + +export interface ToolchainInventory { + installed: ToolchainItem[]; + available: ToolchainItem[]; + targets: TargetItem[]; + effective: ToolchainItem | undefined; + effectiveTarget: string | undefined; + globalDefaultSpec: string | undefined; + projectOverridesGlobal: boolean; + recognized: boolean; + rawOutput: string; +} + +type Section = "managed" | "system" | "available" | "targets" | undefined; + +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; +const NAME_PATTERN = "[A-Za-z][A-Za-z0-9+_.-]*"; +const FAMILY_PATTERN = `(?:${NAME_PATTERN}:)?${NAME_PATTERN}`; +const VERSION_PATTERN = "[0-9][A-Za-z0-9+_.:-]*"; +const SPEC_VERSION_PATTERN = `(?:${VERSION_PATTERN}|system)`; +const INPUT_VERSION_PATTERN = "[A-Za-z0-9][A-Za-z0-9+_.:-]*"; +const AUXILIARY_WORDS = new Set([ + "available", + "default", + "global", + "host", + "installed", + "no", + "none", + "run", + "system", + "target", + "targets", + "toolchain", + "toolchains", +]); + +export function mcppCommandArguments(...args: string[]): string[] { + return [...args]; +} + +function unqualifiedFamily(value: string): string { + const colon = value.indexOf(":"); + return (colon === -1 ? value : value.slice(colon + 1)).toLowerCase(); +} + +export function normalizeToolchainSpec(input: string): string | undefined { + let value = input.trim(); + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === "'" && last === "'") || (first === '"' && last === '"')) { + value = value.slice(1, -1).trim(); + } + } + + if (/^msvc$/i.test(value)) { + return "msvc"; + } + + const atMatch = value.match( + new RegExp(`^(${FAMILY_PATTERN})@(${INPUT_VERSION_PATTERN})$`), + ); + if (atMatch !== null) { + const family = atMatch[1].toLowerCase(); + const version = atMatch[2]; + return `${family}@${version}`; + } + + const spacedMatch = value.match( + new RegExp(`^(${FAMILY_PATTERN})\\s+(${INPUT_VERSION_PATTERN})$`), + ); + if (spacedMatch !== null) { + const family = spacedMatch[1].toLowerCase(); + const version = spacedMatch[2]; + return `${family}@${version}`; + } + + const bareMatch = value.match(new RegExp(`^(${FAMILY_PATTERN})$`)); + if (bareMatch !== null) { + return bareMatch[1].toLowerCase(); + } + + return undefined; +} + +export function isMsvcToolchainSpec(input: string): boolean { + const normalized = normalizeToolchainSpec(input); + const compiler = normalized?.split("@", 1)[0]; + return compiler !== undefined && unqualifiedFamily(compiler) === "msvc"; +} + +export type ToolchainInstallKind = "managed-host" | "managed-target" | "system-detect"; + +/** + * 返回兼容拼写可能隐含的 target 提示;undefined 表示没有对应提示。 + * 结果只用于 UI 分流,输入和 triple 的合法性最终由 mcpp CLI 判断。 + */ +export type ToolchainSpecTargetHint = "musl" | "windows-gnu" | "target"; + +export function toolchainSpecTargetHint(input: string): ToolchainSpecTargetHint | undefined { + const normalized = normalizeToolchainSpec(input)?.toLowerCase(); + if (normalized === undefined) { + return undefined; + } + const qualifiedCompiler = normalized.split("@", 1)[0]; + const compiler = unqualifiedFamily(qualifiedCompiler); + if ( + compiler === "musl-gcc" + || (compiler === "gcc" && normalized.endsWith("-musl")) + ) { + return "musl"; + } + if (compiler.includes("mingw")) { + return "windows-gnu"; + } + // mcpp 还接受带 triple 的旧编译器写法,例如 + // `aarch64-linux-musl-gcc@16`。这里只识别其外形,triple 是否有效由 mcpp 判断。 + if (compiler.endsWith("-gcc")) { + return compiler.includes("-musl-") ? "musl" : "target"; + } + return undefined; +} + +export function toolchainInstallKind(input: string): ToolchainInstallKind { + if (isMsvcToolchainSpec(input)) { + return "system-detect"; + } + return toolchainSpecTargetHint(input) === undefined ? "managed-host" : "managed-target"; +} + +export function hostDefaultToolchains(inventory: ToolchainInventory): ToolchainItem[] { + const hostSpecs = new Set( + inventory.targets + .filter((target) => target.status === "installed" && /\bhost\b/i.test(target.note)) + .map((target) => target.toolchainSpec) + .filter((spec): spec is string => spec !== undefined), + ); + const windowsHost = inventory.targets.some((target) => + target.status !== "planned" + && target.target.endsWith("-windows-msvc") + && /\bhost\b/i.test(target.note), + ); + const windowsGccSpecs = new Set( + inventory.targets + .filter((target) => target.status === "installed" && target.target.endsWith("-windows-gnu")) + .map((target) => target.toolchainSpec) + .filter((spec): spec is string => spec !== undefined), + ); + if (inventory.targets.length === 0) { + return inventory.installed; + } + return inventory.installed.filter((toolchain) => + toolchain.source === "system" + || hostSpecs.has(toolchain.spec) + // mcpp 在 Windows 上将省略 target 的 GCC 映射到 MinGW payload。 + || (windowsHost && toolchain.family === "gcc" && windowsGccSpecs.has(toolchain.spec))); +} + +function sectionForHeader(line: string): Section | "header" | false { + if (/^\s*Toolchains\s*:\s*$/i.test(line)) { + return "managed"; + } + if (/^\s*System(?:\s+toolchains?)?\s*:\s*$/i.test(line)) { + return "system"; + } + if (/^\s*Targets\s*:\s*$/i.test(line)) { + return "targets"; + } + if (/^\s*Available\s+toolchains?\b.*:\s*$/i.test(line)) { + return "available"; + } + if (/^\s*[A-Za-z][A-Za-z0-9 _-]*\s*:\s*$/i.test(line)) { + return "header"; + } + return false; +} + +function parseToolchainRow( + line: string, + source: ToolchainSource, +): Array<{ family: string; version: string; effective: boolean; spec: string }> | undefined { + let value = line.trim(); + let effective = false; + if (value.startsWith("*")) { + effective = true; + value = value.slice(1).trimStart(); + } + if (value.startsWith("-")) { + value = value.slice(1).trimStart(); + } + if (value.startsWith("(") || value.length === 0) { + return undefined; + } + + const tokens = value.split(/\s+/); + const firstToken = tokens[0]; + if (firstToken === undefined || AUXILIARY_WORDS.has(firstToken.toLowerCase())) { + return undefined; + } + + let family: string; + let versions: string[]; + if (firstToken.includes("@")) { + const spec = normalizeToolchainSpec(firstToken); + if (spec === undefined) { + return undefined; + } + const atIndex = firstToken.indexOf("@"); + family = firstToken.slice(0, atIndex).toLowerCase(); + versions = [firstToken.slice(atIndex + 1)]; + } else { + const secondToken = tokens[1]; + if (secondToken === undefined || !new RegExp(`^${VERSION_PATTERN}$`).test(secondToken)) { + return undefined; + } + family = firstToken.toLowerCase(); + versions = [secondToken]; + for (let index = 2; index + 1 < tokens.length; index += 2) { + if (tokens[index] !== "/" || !new RegExp(`^${VERSION_PATTERN}$`).test(tokens[index + 1])) { + break; + } + versions.push(tokens[index + 1]); + } + } + + return versions.flatMap((version) => { + const spec = source === "system" && family === "msvc" + ? "msvc" + : normalizeToolchainSpec(`${family} ${version}`); + return spec === undefined ? [] : [{ family, version, spec, effective }]; + }); +} + +function parseTargetRow(line: string): TargetItem | undefined { + let value = line.trim(); + let effective = false; + if (value.startsWith("*")) { + effective = true; + value = value.slice(1).trimStart(); + } + if (value.startsWith("-")) { + value = value.slice(1).trimStart(); + } + + const tokens = value.split(/\s+/); + if (tokens.length < 3 || tokens[0]?.toUpperCase() === "TARGET") { + return undefined; + } + const statusToken = tokens.at(-1)?.toLowerCase(); + if (statusToken !== "installed" && statusToken !== "available" && statusToken !== "planned") { + return undefined; + } + + const target = tokens[0]; + const columns = tokens.slice(1, -1); + if (target === undefined || columns.length === 0) { + return undefined; + } + + const lastColumn = columns.at(-1); + let noteColumns: string[]; + let toolchainSpec: string | undefined; + if (lastColumn === "\u2014" || lastColumn === "-") { + noteColumns = columns.slice(0, -1); + } else if (columns.length >= 2) { + const family = columns.at(-2); + const version = columns.at(-1); + if (family === undefined || version === undefined) { + return undefined; + } + toolchainSpec = normalizeToolchainSpec(family + " " + version); + if (toolchainSpec === undefined) { + return undefined; + } + noteColumns = columns.slice(0, -2); + } else { + return undefined; + } + + return { + target, + note: noteColumns.join(" "), + toolchainSpec, + status: statusToken, + effective, + }; +} + +interface ParsedGlobalDefault { + value: string | undefined; +} + +function parseGlobalDefault(line: string): ParsedGlobalDefault | undefined { + const match = line.match(/\bglobal\s+default\s+is\s+['"]([^'"]+)['"]/i); + if (match !== null) { + return { value: normalizeToolchainSpec(match[1]) }; + } + const none = line.match(/\bglobal\s+default\s+is\s+['"]?['"]?/i); + if (none !== null) { + return { value: undefined }; + } + const unquoted = line.match( + new RegExp(`\\bglobal\\s+default\\s+is\\s+(${FAMILY_PATTERN}(?:@${SPEC_VERSION_PATTERN}|\\s+${VERSION_PATTERN}|))\\b`, "i"), + ); + return unquoted === null ? undefined : { value: normalizeToolchainSpec(unquoted[1]) }; +} + +export function parseToolchainList(output: string): ToolchainInventory { + const installed: ToolchainItem[] = []; + const available: ToolchainItem[] = []; + const targets: TargetItem[] = []; + let effective: ToolchainItem | undefined; + let effectiveTarget: string | undefined; + let section: Section; + let recognized = false; + let projectMarker = false; + let explicitGlobalDefault: string | undefined; + let explicitGlobalDefaultSeen = false; + + for (const rawLine of output.split(/\r?\n/)) { + const line = rawLine.replace(ANSI_ESCAPE, ""); + const lowerLine = line.toLowerCase(); + if (lowerLine.includes("no toolchains installed")) { + recognized = true; + } + if (/effective\s+toolchain\s+from\s+project\s+mcpp\.toml\s+\[toolchain\]/i.test(line)) { + projectMarker = true; + } + const parsedGlobalDefault = parseGlobalDefault(line); + if (parsedGlobalDefault !== undefined) { + explicitGlobalDefaultSeen = true; + explicitGlobalDefault = parsedGlobalDefault.value; + } + + const header = sectionForHeader(line); + if (header !== false) { + if (header === "header") { + section = undefined; + } else { + recognized = true; + section = header; + } + continue; + } + + if (section === undefined) { + continue; + } + if (section === "targets") { + const target = parseTargetRow(line); + if (target === undefined) { + continue; + } + targets.push(target); + if (target.effective && effectiveTarget === undefined) { + effectiveTarget = target.target; + } + continue; + } + const parsedRows = parseToolchainRow(line, section === "system" ? "system" : "managed"); + if (parsedRows === undefined) { + continue; + } + for (const parsed of parsedRows) { + const item: ToolchainItem = { + family: parsed.family, + version: parsed.version, + spec: parsed.spec, + source: section === "system" ? "system" : "managed", + effective: parsed.effective, + }; + if (section === "available") { + available.push(item); + } else { + installed.push(item); + if (item.effective && effective === undefined) { + effective = item; + } + } + } + } + + const globalDefaultSpec = recognized + ? explicitGlobalDefaultSeen ? explicitGlobalDefault : effective?.spec + : undefined; + const projectOverridesGlobal = recognized + && (projectMarker + || (explicitGlobalDefaultSeen + && effective !== undefined + && explicitGlobalDefault !== effective.spec)); + + return { + installed, + available, + targets, + effective, + effectiveTarget, + globalDefaultSpec, + projectOverridesGlobal, + recognized, + rawOutput: output, + }; +} diff --git a/mide-plugins/vscode/src/commands.ts b/mide-plugins/vscode/src/commands.ts deleted file mode 100644 index 066d165..0000000 --- a/mide-plugins/vscode/src/commands.ts +++ /dev/null @@ -1,211 +0,0 @@ -// The commands: every id the extension declares (generated from spec/commands.json) is registered -// at activation and does what its row's `action` says. The adapter maps an action to VS Code's UI; -// which argv runs, what a cleanup costs and what a menu offers are the backend's answers. -import * as vscode from "vscode"; - -import type { Debugging } from "./debug"; -import type { Jobs } from "./jobs"; -import type { Panels } from "./panels"; -import type { Projects } from "./projects"; -import type { CleanPlan, CommandEntry, LanguageServiceStatus, TargetRow } from "./protocol"; -import { runProgram } from "./run"; -import type { Session } from "./session"; -import type { Tasks } from "./tasks"; - -export interface CommandContext { - session: Session; - projects: Projects; - jobs: Jobs; - tasks: Tasks; - panels: Panels; - debugging: Debugging; -} - -/** plan -> the editor's confirmation at the plan's level -> apply. */ -export async function cleanup(c: CommandContext, action: string): Promise { - const root = c.projects.project?.root; - const t = (k: string, ...a: unknown[]) => c.session.t(k, ...a); - const plan = await c.session.request("cache/plan", { action, root }); - if (plan.refusedUntrusted) { void vscode.window.showWarningMessage(t("this action needs a trusted workspace")); return; } - const confirmation: Record = {}; - if (plan.level > 0) { - let preview = ""; - if (typeof plan.preview === "object" && plan.preview.source === "mcpp") preview = plan.preview.text ?? ""; - else if (typeof plan.preview === "object") preview = t("About {0} would be freed ({1} entries).", plan.preview.bytesText, plan.preview.count); - const yes = t("Continue"); - const choice = await vscode.window.showWarningMessage(plan.title, { modal: true, detail: [plan.detail, preview].filter(Boolean).join("\n\n") }, yes); - if (choice !== yes) return; - confirmation.confirmed = true; - confirmation.previewShown = !!plan.preview; - if (plan.acknowledge) { - const ack = t("I understand this affects every mcpp project on this machine"); - if ((await vscode.window.showWarningMessage(plan.title, { modal: true, detail: ack }, ack)) !== ack) return; - confirmation.acknowledged = true; - } - } - await c.jobs.start({ action, root, confirmation }, { - output: (text) => c.session.output.append(text), - finished: (p) => { - if (p.status === "ok") void vscode.window.showInformationMessage(t("mcpp: {0}", plan.title.replace(/\?$/, ""))); - else void vscode.window.showErrorMessage(t("mcpp {0}: {1}", plan.argv.join(" "), p.status)); - }, - }, "cache/apply"); -} - -async function selectTarget(c: CommandContext): Promise { - const t = (k: string, ...a: unknown[]) => c.session.t(k, ...a); - const r = await c.session.request<{ host: string; rows: TargetRow[] }>("targets/list", { root: c.projects.project?.root }); - const pick = await vscode.window.showQuickPick( - r.rows.map((row) => ({ - label: `${row.selected ? "$(check) " : ""}${row.target}`, - description: [row.host ? t("this machine") : "", row.platformText].filter(Boolean).join(" · "), - detail: row.toolchains.map((tc) => `${tc.spec || "—"} (${tc.status})`).join(", ") || undefined, - row, - })), - { title: t("Select the build target"), placeHolder: t("build, run, test, debug and pack use it") }); - if (!pick) return; - await vscode.workspace.getConfiguration().update("mcpp.build.target", pick.row.host ? "" : pick.row.target, vscode.ConfigurationTarget.Workspace); - if (!pick.row.installed && pick.row.pin) { - const install = t("Install"); - if ((await vscode.window.showInformationMessage(t("No toolchain is installed for {0}.", pick.row.target), install)) === install) - await c.jobs.start({ spec: pick.row.pin, target: pick.row.target }, { output: (x) => c.session.output.append(x) }, "toolchains/install"); - } -} - -async function installToolchain(c: CommandContext): Promise { - const spec = await vscode.window.showInputBox({ title: c.session.t("Install an mcpp toolchain"), placeHolder: "gcc@16.1.0, llvm@23.1.3" }); - if (!spec) return; - c.session.output.show(true); - await c.jobs.start({ spec }, { output: (x) => c.session.output.append(x) }, "toolchains/install"); -} - -async function selectDefaultToolchain(c: CommandContext): Promise { - const r = await c.session.request<{ rows: { target: string; spec: string; status: string; default: boolean }[]; host: string }>("toolchains/list", {}); - const rows = r.rows.filter((x) => x.target === r.host && x.spec); - const pick = await vscode.window.showQuickPick(rows.map((x) => ({ label: `${x.default ? "$(check) " : ""}${x.spec}`, description: x.status, spec: x.spec })), - { title: c.session.t("Choose the mcpp global default toolchain") }); - if (!pick) return; - await c.session.request("toolchains/setDefault", { spec: pick.spec }); -} - -async function newProject(c: CommandContext): Promise { - const t = (k: string, ...a: unknown[]) => c.session.t(k, ...a); - const folder = await vscode.window.showOpenDialog({ canSelectFolders: true, canSelectFiles: false, openLabel: t("Create the project here") }); - if (!folder?.[0]) return; - const name = await vscode.window.showInputBox({ title: t("New mcpp project (1/2)"), prompt: t("The project's name, which is also its directory") }); - if (!name) return; - const handle = await c.jobs.start({ parent: folder[0].fsPath, name }, { output: (x) => c.session.output.append(x) }, "project/new"); - const done = await handle.done; - if (done.status !== "ok") { void vscode.window.showErrorMessage(t("mcpp new {0}: {1}", name, done.status)); return; } - const open = t("Open"); - if ((await vscode.window.showInformationMessage(t("Created {0}.", name), open)) === open) - await vscode.commands.executeCommand("vscode.openFolder", vscode.Uri.joinPath(folder[0], name), { forceNewWindow: false }); -} - -async function languageService(c: CommandContext): Promise { - const s = await c.session.request("languageService/status", { candidates: c.session.mcpplsCandidates() }); - const show = s.state === "ok" ? vscode.window.showInformationMessage : vscode.window.showWarningMessage; - const choice = await show(s.message, ...s.actions.map((a) => a.title)); - const action = s.actions.find((a) => a.title === choice); - if (action) await act(action.id); -} - -async function act(id: string): Promise { - if (id === "install") await vscode.commands.executeCommand("workbench.extensions.installExtension", "sunrisepeak.mcpp-language-server"); - else await vscode.commands.executeCommand("workbench.extensions.search", "@id:sunrisepeak.mcpp-language-server"); -} - -async function selfCheck(c: CommandContext): Promise { - const r = await c.session.request<{ backend: { version: string }; checks: { status: string; message: string }[] }>("selfCheck", {}); - const out = c.session.output; - out.appendLine(""); - out.appendLine(`===== mcpp: ${c.session.t("Environment Self-check")} =====`); - out.appendLine(`mcpp-vscode ${c.session.context.extension.packageJSON.version} · VS Code ${vscode.version} · ${process.platform}-${process.arch}`); - out.appendLine(`mcpp-ide ${r.backend.version}`); - for (const check of r.checks) out.appendLine(`${check.status.padEnd(8)} ${check.message}`); - out.appendLine("========================================"); - out.show(true); -} - -async function quickMenu(c: CommandContext): Promise { - const r = await c.session.request<{ items: { command: string; title: string; group: string; enabled: boolean; reason?: string; detail?: string }[] }>( - "menu/quick", { root: c.projects.project?.root, editor: "vscode" }); - const pick = await vscode.window.showQuickPick(r.items.map((i) => ({ - label: i.title, description: i.enabled ? i.group : i.reason, detail: i.detail, command: i.command, enabled: i.enabled, - })), { title: c.session.t("Open the mcpp project and toolchain quick menu") }); - if (pick?.enabled) await vscode.commands.executeCommand(pick.command); -} - -async function forward(c: CommandContext, commands: string[], args: unknown[]): Promise { - const known = await vscode.commands.getCommands(true); - const command = commands.find((x) => known.includes(x)); - if (!command) { - await languageService(c); - return; - } - await vscode.commands.executeCommand(command, ...args); -} - -export async function dispatch(c: CommandContext, entry: CommandEntry, args: unknown[]): Promise { - const a = entry.action; - const root = async () => (await c.projects.require())?.root; - const options = (args[0] && typeof args[0] === "object" ? args[0] : {}) as { filter?: string; bin?: string; root?: string; args?: { filter?: string } }; - switch (a.kind) { - case "job": { - const r = options.root ?? (await root()); - if (!r) return; - const filter = options.filter ?? options.args?.filter; - const exec = await c.tasks.execute(a.job === "test" ? "test" : "build", r, filter ? { args: [filter] } : {}); - if (a.then === "languageService") { - const sub = vscode.tasks.onDidEndTaskProcess((e) => { if (e.execution === exec) { sub.dispose(); void languageService(c); } }); - } - return; - } - case "run": { const r = options.root ?? (await root()); if (r) await runProgram(c.session, r, { bin: options.bin }); return; } - case "debug": await c.debugging.start(); return; - case "cache": await cleanup(c, a.plan!); return; - case "panel": c.panels.show(a.panel === "library" ? "libraries" : a.panel!, { root: c.projects.project?.root, graph: a.graph, id: typeof args[0] === "string" ? args[0] : undefined }); return; - case "cancel": { - const ids = c.jobs.runningIds; - for (const id of ids) await c.session.request("job/cancel", { jobId: id }); - return; - } - case "selectTarget": await selectTarget(c); return; - case "installToolchain": await installToolchain(c); return; - case "selectDefaultToolchain": await selectDefaultToolchain(c); return; - case "newProject": await newProject(c); return; - case "quickMenu": await quickMenu(c); return; - case "selfCheck": await selfCheck(c); return; - case "languageService": await languageService(c); return; - case "forward": await forward(c, a.commands ?? [], args); return; - case "editor": await vscode.commands.executeCommand(a.vscode!, ...(a.args ?? [])); return; - case "rpc": { - const handle = await c.jobs.start({ root: c.projects.project?.root }, { output: (x) => c.session.output.append(x) }, a.method!); - const done = await handle.done; - if (done.status !== "ok") void vscode.window.showErrorMessage(c.session.t("mcpp: {0}", done.status)); - return; - } - } -} - -/** Registers every declared command; the backend's table says what each one does. */ -export function registerCommands(context: vscode.ExtensionContext, c: CommandContext): void { - const declared = (context.extension.packageJSON?.contributes?.commands ?? []) as { command: string }[]; - for (const { command } of declared) { - context.subscriptions.push(vscode.commands.registerCommand(command, async (...args: unknown[]) => { - try { - if (!c.session.commands.length) await c.session.start(); - const entry = c.session.commands.find((x) => x.id === command); - if (!entry) throw new Error(c.session.t("unknown command {0}", command)); - await dispatch(c, entry, args); - } catch (e) { - void vscode.window.showErrorMessage(c.session.t("mcpp: {0}", e instanceof Error ? e.message : String(e))); - } - })); - } - // Commands the views use and the palette does not list. - context.subscriptions.push( - vscode.commands.registerCommand("mcpp.languageServer.act", (id: string) => act(id)), - vscode.commands.registerCommand("mcpp.openProjectPanel", () => c.panels.show("project", { root: c.projects.project?.root })), - ); -} diff --git a/mide-plugins/vscode/src/commands/ids.ts b/mide-plugins/vscode/src/commands/ids.ts new file mode 100644 index 0000000..523d9f8 --- /dev/null +++ b/mide-plugins/vscode/src/commands/ids.ts @@ -0,0 +1,129 @@ +/** + * Every command id this extension contributes, in one place. + * + * Two rules live here: + * + * 1. **`mcpp.` only.** Our ids must never collide with a command the C++ Modules + * server advertises (`mcppls.*`): `vscode-languageclient` registers those + * itself, and a duplicate id stops the language client from starting + * (mcppls's S3-5.6-3). `test/mcppls/contract.test.ts` asserts this. + * 2. **Ids do not change once shipped.** A rename keeps the old id and points it + * at the new implementation, so a user's keybinding keeps working — see + * `DEPRECATED_COMMANDS` below. + */ + +/** mcpp CLI commands that existed in 0.4.x. */ +export const CLI_COMMANDS = { + showMenu: "mcpp.showMenu", + newProject: "mcpp.newProject", + build: "mcpp.build", + run: "mcpp.run", + test: "mcpp.test", + clean: "mcpp.clean", + showToolchains: "mcpp.showToolchains", + installToolchain: "mcpp.installToolchain", + selectDefaultToolchain: "mcpp.selectDefaultToolchain", + autoConfigureModules: "mcpp.autoConfigureModules", + // From 1.1.0, over the mcpp-ide backend. + debug: "mcpp.debug", + pack: "mcpp.pack", + cancelJob: "mcpp.cancelJob", + selectTarget: "mcpp.selectTarget", + showTargets: "mcpp.showTargets", + 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", +} as const; + +/** The language-service entries that existed in 0.4.x (kept as aliases, see below). */ +export const LEGACY_LANGUAGE_SERVER_COMMANDS = { + configureLanguageServer: "mcpp.configureLanguageServer", + refreshCompilationDatabase: "mcpp.refreshCompilationDatabase", + checkModuleSupport: "mcpp.checkModuleSupport", + showModuleGraph: "mcpp.showModuleGraph", + showLanguageServerLogs: "mcpp.showLanguageServerLogs", +} as const; + +/** The C++ Modules view and everything it forwards. */ +export const LANGUAGE_SERVER_COMMANDS = { + refreshState: "mcpp.languageServer.refreshState", + restart: "mcpp.languageServer.restart", + restartEngine: "mcpp.languageServer.restartEngine", + resetWorkspaceCache: "mcpp.languageServer.resetWorkspaceCache", + selectContext: "mcpp.languageServer.selectContext", + showModuleGraph: "mcpp.languageServer.showModuleGraph", + showLogs: "mcpp.languageServer.showLogs", + collectReport: "mcpp.languageServer.collectReport", + exportDiagnosticBundle: "mcpp.languageServer.exportDiagnosticBundle", + revealBundle: "mcpp.languageServer.revealBundle", + openLogFolder: "mcpp.languageServer.openLogFolder", + runBuildToolInTerminal: "mcpp.languageServer.runBuildToolInTerminal", + /** Turns the other C++ language services off (the id is from 0.6.0). */ + manageConflicts: "mcpp.languageServer.manageConflicts", + restoreConflicts: "mcpp.languageServer.restoreConflicts", + toggleInWorkspace: "mcpp.languageServer.toggleInWorkspace", + installTools: "mcpp.languageServer.installTools", + installKit: "mcpp.languageServer.installKit", + reviewChanges: "mcpp.languageServer.reviewChanges", +} as const; + +/** Cache statistics and the cleanup table. */ +export const CACHE_COMMANDS = { + refreshStats: "mcpp.refreshCacheStats", + showEntry: "mcpp.showCacheEntry", + cleanStale: "mcpp.cleanStaleArtifacts", + cleanProject: "mcpp.cleanProjectArtifacts", + collect: "mcpp.gcGlobalCache", + prune: "mcpp.pruneGlobalCache", + verify: "mcpp.verifyGlobalCache", + cleanLegacy: "mcpp.cleanLegacyCache", +} as const; + +/** + * The library-ecosystem view. The list and the detail page talk to their + * webviews over messages, so these are the only ids a menu can name. + */ +export const LIBRARY_COMMANDS = { + search: "mcpp.library.search", + openDetail: "mcpp.library.openDetail", + updateIndex: "mcpp.library.updateIndex", +} as const; + +/** Settings, diagnostics and the mcpp CLI entry points added in 0.5.0. */ +export const TOOL_COMMANDS = { + dependencyGraph: "mcpp.project.showDependencyGraph", + moduleDependencyGraph: "mcpp.project.showModuleDependencyGraph", + openSettings: "mcpp.openSettings", + openLanguageServerSettings: "mcpp.openMcpplsSettings", + selfCheck: "mcpp.selfCheck", + exportReport: "mcpp.exportReport", +} as const; + +/** + * Ids kept for compatibility. A keybinding or a script that names one of these + * still works; the menu shows the new title. + */ +export const DEPRECATED_COMMANDS = { + configureClangd: "mcpp.configureClangd", +} as const; + +export type CommandId = + | (typeof CLI_COMMANDS)[keyof typeof CLI_COMMANDS] + | (typeof LEGACY_LANGUAGE_SERVER_COMMANDS)[keyof typeof LEGACY_LANGUAGE_SERVER_COMMANDS] + | (typeof LANGUAGE_SERVER_COMMANDS)[keyof typeof LANGUAGE_SERVER_COMMANDS] + | (typeof CACHE_COMMANDS)[keyof typeof CACHE_COMMANDS] + | (typeof LIBRARY_COMMANDS)[keyof typeof LIBRARY_COMMANDS] + | (typeof TOOL_COMMANDS)[keyof typeof TOOL_COMMANDS]; + +/** Every id the manifest contributes, in the order the palette shows them. */ +export function contributedCommandIds(): string[] { + return [ + ...Object.values(TOOL_COMMANDS), + ...Object.values(CLI_COMMANDS), + ...Object.values(CACHE_COMMANDS), + ...Object.values(LIBRARY_COMMANDS), + ...Object.values(LANGUAGE_SERVER_COMMANDS), + ...Object.values(LEGACY_LANGUAGE_SERVER_COMMANDS), + ...Object.values(DEPRECATED_COMMANDS), + ]; +} diff --git a/mide-plugins/vscode/src/commands/menu.ts b/mide-plugins/vscode/src/commands/menu.ts new file mode 100644 index 0000000..e3b3035 --- /dev/null +++ b/mide-plugins/vscode/src/commands/menu.ts @@ -0,0 +1,104 @@ +import { CACHE_COMMANDS, CLI_COMMANDS, LANGUAGE_SERVER_COMMANDS, LIBRARY_COMMANDS, TOOL_COMMANDS } from "./ids"; + +/** + * The status-bar menu, as **data**: entries are keyed by command id, the titles + * are resolved through `src/i18n/t.ts` when the menu is built, and the icon is a + * codicon id without the `$()` (the same convention `TreeNode.icon` uses). + * + * The groups are not decoration. They are what the menu is built from — the + * quick pick renders one separator per group — so an entry and its section can + * never disagree, and a section that ends up empty simply produces no separator. + * + * ## Why the icons are files + * + * `vscode.QuickPickItem.iconPath` accepts a `ThemeIcon`, but VS Code 1.132 + * discards its colour: `MainThreadQuickOpen.expandIconPath` turns the icon into a + * bare codicon class (`o.iconClass = asClassName(e)`), and the list renderer only + * ever paints that class in the row's ordinary foreground. A `Uri` is different — + * it becomes the row's `background-image`, painted as-is — so the only way to + * colour a quick pick icon is to *ship a coloured image*. + * + * That is what `iconColor` is for. It is a **palette word** (`blue`, `green`, …), + * not a theme-colour id like `TreeNode.iconColor`, because the colour is baked + * into the file and cannot follow the theme the way a `ThemeIcon` does. Each word + * resolves to the default light and dark value of the matching `charts.*` token, + * so the menu and the project tree agree; `media/quick-menu/---- + * .svg` is generated from `@vscode/codicons` by + * `tools/generate-quick-menu-icons.mjs`, and `test/commands/menu.test.ts` walks + * this table and stats every file so the two cannot drift apart. + * + * The palette groups rows by **what they do**, the same way the tree does: blue + * builds or adds, green runs or verifies, purple tests or collects, yellow + * manages the toolchain, red deletes, and `neutral` is for rows whose only job is + * to configure or report. + */ +export interface QuickMenuItem { + labelKey: string; + command: string; + /** A codicon id, e.g. `"tools"`. */ + icon: string; + /** A palette word; see the note above. */ + iconColor: QuickMenuColour; + group: "project" | "library" | "toolchain" | "cache" | "languageServer" | "settings"; +} + +/** The palette words `iconColor` may use; the generator owns their values. */ +export const QUICK_MENU_COLOURS = ["blue", "green", "purple", "yellow", "red", "neutral"] as const; + +export type QuickMenuColour = (typeof QUICK_MENU_COLOURS)[number]; + +/** The groups, in the order the menu shows them. */ +export const QUICK_MENU_GROUPS: ReadonlyArray<{ id: QuickMenuItem["group"]; labelKey: string }> = [ + { id: "project", labelKey: "Current mcpp project" }, + { id: "library", labelKey: "mcpp library ecosystem" }, + { id: "toolchain", labelKey: "mcpp toolchain management" }, + { id: "cache", labelKey: "Build cache" }, + { id: "languageServer", labelKey: "C++ Modules language service" }, + { id: "settings", labelKey: "Settings and diagnostics" }, +]; + +/** + * The asset name for one row's icon in one theme. + * + * The generator writes exactly these names; the test checks that every one of + * them exists, which is what keeps this convention and the files in step. + */ +export function quickMenuIconAsset(item: QuickMenuItem, theme: "dark" | "light"): string { + return `${item.icon}--${item.iconColor}--${theme}.svg`; +} + +export const quickMenuItems: readonly QuickMenuItem[] = [ + { labelKey: "Build", command: CLI_COMMANDS.build, icon: "tools", iconColor: "blue", group: "project" }, + { labelKey: "Run", command: CLI_COMMANDS.run, icon: "play", iconColor: "green", group: "project" }, + { labelKey: "Test", command: CLI_COMMANDS.test, icon: "beaker", iconColor: "purple", group: "project" }, + { labelKey: "Clean project artifacts", command: CACHE_COMMANDS.cleanProject, icon: "trash", iconColor: "red", group: "project" }, + + { labelKey: "Search and add a dependency", command: LIBRARY_COMMANDS.search, icon: "library", iconColor: "blue", group: "library" }, + { labelKey: "Refresh the mcpp Package Index", command: LIBRARY_COMMANDS.updateIndex, icon: "cloud-download", iconColor: "blue", group: "library" }, + + { labelKey: "Show toolchains", command: CLI_COMMANDS.showToolchains, icon: "chip", iconColor: "yellow", group: "toolchain" }, + { labelKey: "Install toolchain", command: CLI_COMMANDS.installToolchain, icon: "cloud-download", iconColor: "yellow", group: "toolchain" }, + { labelKey: "Select the global default toolchain", command: CLI_COMMANDS.selectDefaultToolchain, icon: "star-full", iconColor: "yellow", group: "toolchain" }, + + { labelKey: "Refresh cache statistics", command: CACHE_COMMANDS.refreshStats, icon: "refresh", iconColor: "green", group: "cache" }, + { labelKey: "Clean stale artifacts", command: CACHE_COMMANDS.cleanStale, icon: "trash", iconColor: "red", group: "cache" }, + { labelKey: "Collect the global cache to a budget", command: CACHE_COMMANDS.collect, icon: "archive", iconColor: "purple", group: "cache" }, + { labelKey: "Verify the shared cache", command: CACHE_COMMANDS.verify, icon: "verified", iconColor: "green", group: "cache" }, + + { labelKey: "C++ Modules: restart the language server", command: LANGUAGE_SERVER_COMMANDS.restart, icon: "debug-restart", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: select the analysis context", command: LANGUAGE_SERVER_COMMANDS.selectContext, icon: "symbol-interface", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the module graph", command: LANGUAGE_SERVER_COMMANDS.showModuleGraph, icon: "type-hierarchy", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: open the log", command: LANGUAGE_SERVER_COMMANDS.showLogs, icon: "output", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: capture the logs (report + bundle)", command: LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, icon: "file-zip", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the diagnostic report", command: LANGUAGE_SERVER_COMMANDS.collectReport, icon: "report", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: open the log folder", command: LANGUAGE_SERVER_COMMANDS.openLogFolder, icon: "folder-opened", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: show the last captured bundle", command: LANGUAGE_SERVER_COMMANDS.revealBundle, icon: "folder", iconColor: "neutral", group: "languageServer" }, + { labelKey: "C++ Modules: reset this workspace's cache", command: LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, icon: "trash", iconColor: "neutral", group: "languageServer" }, + + { labelKey: "Build and refresh the language service", command: CLI_COMMANDS.autoConfigureModules, icon: "refresh", iconColor: "blue", group: "settings" }, + { labelKey: "Environment self-check", command: TOOL_COMMANDS.selfCheck, icon: "heart", iconColor: "green", group: "settings" }, + { labelKey: "Export IDE Report", command: TOOL_COMMANDS.exportReport, icon: "folder", iconColor: "neutral", group: "settings" }, + { labelKey: "mcpp settings", command: TOOL_COMMANDS.openSettings, icon: "settings-gear", iconColor: "neutral", group: "settings" }, +]; + +export const quickMenuStatusText = "$(tools) mcpp"; diff --git a/mide-plugins/vscode/src/config/access.ts b/mide-plugins/vscode/src/config/access.ts new file mode 100644 index 0000000..862e465 --- /dev/null +++ b/mide-plugins/vscode/src/config/access.ts @@ -0,0 +1,133 @@ +/** + * Typed reads and writes for `mcpp.*` settings. + * + * Every read goes through the registry, so a value that does not match its + * declaration (a hand-edited `settings.json`) is replaced by the declared + * default and reported once — never silently passed on to the rest of the code. + * + * Writes always name a target. The configuration panel and the commands choose + * it deliberately; nothing here writes `mcppls.*` or any other section. + */ + +import * as vscode from "vscode"; + +import { defaultValue, expectation, validateValue } from "./validate"; +import { SECTION, SETTINGS, setting, subKey, type SettingEntry } from "./registry"; + +export type ValueSource = "default" | "user" | "workspace" | "workspaceFolder" | "language" | "invalid"; + +export interface EffectiveValue { + value: T; + source: ValueSource; +} + +const warned = new Set(); + +function configuration(resource?: vscode.Uri): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration(SECTION, resource ?? null); +} + +/** + * Where the effective value comes from, for the panel's "current value" column + * and for the environment self-check. + */ +export function effective(key: string, resource?: vscode.Uri): EffectiveValue { + const entry = setting(key); + if (entry === undefined) { + return { value: undefined as unknown as T, source: "invalid" }; + } + const inspected = configuration(resource).inspect(entry.key.slice(SECTION.length + 1)); + const candidates: Array<[ValueSource, unknown]> = [ + ["workspaceFolder", inspected?.workspaceFolderValue], + ["workspace", inspected?.workspaceValue], + ["user", inspected?.globalValue], + ]; + for (const [source, raw] of candidates) { + if (raw === undefined) { + continue; + } + const result = validateValue(entry, raw); + if (result.ok) { + return { value: result.value as T, source }; + } + warnOnce(entry, source, raw); + return { value: defaultValue(entry), source: "invalid" }; + } + return { value: defaultValue(entry), source: "default" }; +} + +/** Shorthand for callers that only want the value. */ +export function read(key: string, resource?: vscode.Uri): T { + return effective(key, resource).value; +} + +function warnOnce(entry: SettingEntry, source: ValueSource, raw: unknown): void { + const signature = `${entry.key}@${source}`; + if (warned.has(signature)) { + return; + } + warned.add(signature); + void vscode.window.showWarningMessage( + `${entry.key}: ${JSON.stringify(raw)} is not a valid value (expected ${expectation(entry)}); using the default.`, + ); +} + +/** Test seam: forget which invalid values have been reported. */ +export function resetInvalidWarnings(): void { + warned.clear(); +} + +export type WriteTarget = "user" | "workspace" | "workspaceFolder"; + +function configurationTarget(target: WriteTarget): vscode.ConfigurationTarget { + switch (target) { + case "workspace": + return vscode.ConfigurationTarget.Workspace; + case "workspaceFolder": + return vscode.ConfigurationTarget.WorkspaceFolder; + default: + return vscode.ConfigurationTarget.Global; + } +} + +/** + * Write one setting. A `resource`-scoped setting must name the folder it + * applies to, otherwise VS Code would write it to the wrong place. + */ +export async function write( + key: string, + value: unknown, + target: WriteTarget, + resource?: vscode.Uri, +): Promise { + const entry = setting(key); + if (entry === undefined) { + throw new Error(`unknown mcpp setting: ${key}`); + } + if (entry.scope === "resource" && target !== "user" && resource === undefined) { + throw new Error(`${key} is resource-scoped; a folder URI is required`); + } + await configuration(resource).update(subKey(entry.key), value, configurationTarget(target)); +} + +/** Every setting whose effective value differs from its declared default. */ +export function changedSettings(resource?: vscode.Uri): Array<{ key: string; value: unknown; source: ValueSource }> { + const changed: Array<{ key: string; value: unknown; source: ValueSource }> = []; + for (const entry of SETTINGS) { + const current = effective(entry.key, resource); + if (current.source === "default" || current.source === "language") { + continue; + } + changed.push({ key: entry.key, value: current.value, source: current.source }); + } + return changed; +} + +/** Fires for any `mcpp.*` change; the panel and the views subscribe once. */ +export function onDidChange(listener: () => void): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration(SECTION)) { + listener(); + } + }); +} diff --git a/mide-plugins/vscode/src/config/migrate.ts b/mide-plugins/vscode/src/config/migrate.ts new file mode 100644 index 0000000..053fd34 --- /dev/null +++ b/mide-plugins/vscode/src/config/migrate.ts @@ -0,0 +1,66 @@ +/** + * Renamed settings: the old name keeps working, and the user is offered the + * move once. + * + * This mirrors what the C++ Modules extension does for its own renames: read + * the alias, write nothing until the user agrees, and never remove the old + * value on their behalf. + */ + +import * as vscode from "vscode"; + +import { effective, write } from "./access"; +import { renamedKeys, subKey, SECTION } from "./registry"; + +export interface RenamedSetting { + from: string; + to: string; + value: unknown; +} + +/** Renames whose old name still holds a value the new name does not. */ +export function pendingRenames(resource?: vscode.Uri): RenamedSetting[] { + const pending: RenamedSetting[] = []; + const configuration = vscode.workspace.getConfiguration(SECTION, resource ?? null); + for (const { from, to } of renamedKeys()) { + const oldValue = configuration.get(subKey(from)); + if (oldValue === undefined) { + continue; + } + const current = effective(to, resource); + if (current.source !== "default") { + continue; + } + pending.push({ from, to, value: oldValue }); + } + return pending; +} + +/** + * Perform the move for every pending rename. The old key is left in place — + * removing a user's setting is their decision, and a stale alias is harmless + * because nothing reads it. + */ +export async function applyRenames( + pending: readonly RenamedSetting[], + target: "user" | "workspace", + resource?: vscode.Uri, +): Promise { + let moved = 0; + for (const rename of pending) { + try { + await write(rename.to, rename.value, target, resource); + moved += 1; + } catch { + // A rejected write (read-only settings, no folder) is not fatal: the user + // can still set the new key themselves. + } + } + return moved; +} + +/** Notification text, kept here so the panel and the activation path agree. */ +export function renamePrompt(pending: readonly RenamedSetting[]): string { + const names = pending.map((rename) => `${rename.from} → ${rename.to}`).join(", "); + return `mcpp: ${pending.length} setting(s) were renamed (${names}). Move them to the new names?`; +} diff --git a/mide-plugins/vscode/src/config/panel.ts b/mide-plugins/vscode/src/config/panel.ts new file mode 100644 index 0000000..86eeb9e --- /dev/null +++ b/mide-plugins/vscode/src/config/panel.ts @@ -0,0 +1,403 @@ +/** + * The VS Code half of the configuration panel: `mcpp: Open the settings panel`. + * + * The panel is deliberately narrow. It is a **reading aid** for the 60+ settings + * in `data/config-registry.json`: it groups them, shows each one's effective + * value and where that value comes from, and explains when a change takes + * effect. It does not replace the Settings editor — every row has a link that + * opens the native one — and it names the boundary out loud: it writes + * `mcpp.*` only, never `mcppls.*`, which belongs to the C++ Modules extension. + * + * Safety rules this file is responsible for: + * + * - a key is written only if the registry declares it (so `mcpp.*` only); + * - a value is validated against the registry before it is written, so the + * panel cannot be the thing that puts a bad value in `settings.json`; + * - a `resource`-scoped setting is never written to a workspace level without a + * folder URI (VS Code has nowhere to put it); + * - a rejected write (a read-only level, a folder that went away) is swallowed: + * the panel re-sends the effective model, and the user sees the real state. + * + * `src/config/panelHtml.ts` owns the document; this module owns the data and the + * messages. The webview answers every action with `{ type: "model", model }`. + */ + +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { TOOL_COMMANDS } from "../commands/ids"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { MCPPLS_EXTENSION_ID } from "../mcppls/contract"; +import { WebviewDocument } from "../webview/document"; +import { effective, onDidChange, write, type WriteTarget } from "./access"; +import { + PANEL_UI, + decodePanelMessage, + renderPanelHtml, + type PanelModel, + type PanelPreset, + type PanelRow, + type PanelSection, +} from "./panelHtml"; +import { PRESETS, presetValues } from "./presets"; +import { GROUPS, SECTION, SETTINGS, setting, settingsInGroup, subKey, type SettingEntry } from "./registry"; +import { validateValue } from "./validate"; + +/** `@ext:` queries need `publisher.name`, which is not in any registry. */ +const EXTENSION_ID = "mcpp-community.mcpp-vscode"; +const PANEL_VIEW_TYPE = "mcpp.settingsPanel"; +const OPEN_SETTINGS = "workbench.action.openSettings"; +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "settings.css"; + +/** + * The slice of `vscode.ExtensionContext` this module needs. `extensionUri` is + * optional so the declared shape stays callable from tests and from hosts that + * do not have one; when it is missing the media directory is derived from + * `__dirname` (`dist/src/config/` → the extension root). + */ +export interface PanelHostContext { + subscriptions: { push(...items: unknown[]): unknown }; + extensionUri?: vscode.Uri; +} + +interface PanelSession { + panel: vscode.WebviewPanel; + resource?: vscode.Uri; + host: PanelHostContext; + /** + * The document and its per-panel nonce. The panel's html is assigned exactly + * once per open — every later update travels by `postMessage` — but the kit + * is still used so every webview host in this extension holds its document + * the same way (one nonce minting point, one assignment site). + */ + webviewDocument: WebviewDocument; +} + +/** One panel per window: reopening reveals and refreshes the existing one. */ +let session: PanelSession | undefined; + +/** + * Register `mcpp.openSettings`. The command takes no arguments; the panel's + * resource (the folder whose values it shows) follows the active editor when it + * is inside a workspace folder, otherwise the first folder. + */ +export function registerSettingsPanel(host: PanelHostContext): void { + host.subscriptions.push( + vscode.commands.registerCommand(TOOL_COMMANDS.openSettings, () => { + open(host); + }), + onDidChange(() => { + if (session !== undefined) { + postModel(session); + } + }), + { + dispose: () => { + session?.panel.dispose(); + session = undefined; + }, + }, + ); +} + +function open(host: PanelHostContext): void { + if (session !== undefined) { + session.panel.reveal(); + postModel(session); + return; + } + const resource = resourceUri(); + const panel = vscode.window.createWebviewPanel(PANEL_VIEW_TYPE, t("mcpp settings"), vscode.ViewColumn.Active, { + enableScripts: true, + localResourceRoots: [mediaRoot(host)], + retainContextWhenHidden: false, + }); + const active: PanelSession = { panel, resource, host, webviewDocument: new WebviewDocument(STYLESHEET) }; + session = active; + const model = buildModel(resource); + const html = renderPanelHtml(model, { + ...active.webviewDocument.assets(mediaRoot(host), panel.webview), + // The client script is inline and nonced; the CSP names no external script + // source, so this stays empty on purpose. + scriptUri: "", + }); + active.webviewDocument.paint(panel.webview, html); + panel.webview.onDidReceiveMessage((raw: unknown) => { + void handle(active, raw); + }); + panel.onDidDispose(() => { + if (session === active) { + session = undefined; + } + }); +} + +async function handle(active: PanelSession, raw: unknown): Promise { + const message = decodePanelMessage(raw); + if (message === undefined) { + return; + } + try { + switch (message.type) { + case "update": + await applyUpdate(active, message.key, message.value, message.target); + break; + case "reset": + await resetEverywhere(message.key, active.resource); + break; + case "preset": + await applyPreset(message.id, active.resource); + break; + case "openNative": + await vscode.commands.executeCommand(OPEN_SETTINGS, `@ext:${EXTENSION_ID} ${message.key}`); + break; + case "openMcpplsSettings": + await vscode.commands.executeCommand(OPEN_SETTINGS, `@ext:${MCPPLS_EXTENSION_ID}`); + break; + } + } catch { + // A write can fail for reasons that are the user's business, not the + // panel's: a read-only settings level, a folder that disappeared. The model + // sent below shows the value that is actually in effect. + } + postModel(active); +} + +async function applyUpdate( + active: PanelSession, + key: string, + value: unknown, + target: "user" | "workspace", +): Promise { + const entry = setting(key); + if (entry === undefined) { + return; + } + const validated = validateValue(entry, value); + if (!validated.ok || !writable(entry, target, active.resource)) { + return; + } + await write(entry.key, validated.value, target, active.resource); +} + +/** A resource-scoped setting needs a folder URI for any workspace-level write. */ +function writable(entry: SettingEntry, target: WriteTarget, resource?: vscode.Uri): boolean { + return !(entry.scope === "resource" && target !== "user" && resource === undefined); +} + +function configurationTarget(target: WriteTarget): vscode.ConfigurationTarget { + switch (target) { + case "workspace": + return vscode.ConfigurationTarget.Workspace; + case "workspaceFolder": + return vscode.ConfigurationTarget.WorkspaceFolder; + default: + return vscode.ConfigurationTarget.Global; + } +} + +/** + * Remove one level's override. `configuration.update(key, undefined, target)` is + * the only way to delete a value; leaving the level alone is not enough, because + * a workspace value would then keep shadowing the default. + */ +async function resetKey(key: string, target: WriteTarget, resource?: vscode.Uri): Promise { + const entry = setting(key); + if (entry === undefined || !writable(entry, target, resource)) { + return; + } + await vscode.workspace + .getConfiguration(SECTION, resource ?? null) + .update(subKey(key), undefined, configurationTarget(target)); +} + +/** "Reset" means "back to the default", so every level that could hold it is cleared. */ +async function resetEverywhere(key: string, resource?: vscode.Uri): Promise { + const targets: WriteTarget[] = + resource === undefined ? ["user", "workspace"] : ["user", "workspace", "workspaceFolder"]; + for (const target of targets) { + try { + await resetKey(key, target, resource); + } catch { + // An unwritable level is skipped; the others still get cleared. + } + } +} + +async function applyPreset(id: string, resource?: vscode.Uri): Promise { + // `defaults` carries no values by design; it is the one preset that resets. + if (id === "defaults") { + for (const entry of SETTINGS) { + await resetEverywhere(entry.key, resource); + } + return; + } + const target: WriteTarget = resource === undefined ? "user" : "workspace"; + for (const { key, value } of presetValues(id)) { + const entry = setting(key); + if (entry === undefined || !writable(entry, target, resource)) { + continue; + } + const validated = validateValue(entry, value); + if (!validated.ok) { + continue; + } + try { + await write(entry.key, validated.value, target, resource); + } catch { + // Keep applying the rest of the preset rather than stopping halfway. + } + } +} + +/** + * The whole model. Labels go through `t()`, so the panel follows the editor's + * language exactly like the runtime messages do; registry titles and + * descriptions are the same English strings `package.nls.*` carries, and fall + * back to English until their translations land in `data/i18n`. + */ +function buildModel(resource?: vscode.Uri): PanelModel { + const sections: PanelSection[] = GROUPS.map((group) => ({ + id: group.id, + title: t(group.title), + rows: settingsInGroup(group.id).map((entry) => buildRow(entry, resource)), + })).filter((section) => section.rows.length > 0); + const presets: PanelPreset[] = PRESETS.map((preset) => ({ + id: preset.id, + title: t(preset.title), + description: t(preset.description), + })); + const model: PanelModel = { sections, presets, ui: panelLabels() }; + const label = resource === undefined ? undefined : folderLabel(resource); + if (label !== undefined) { + model.resourceLabel = label; + } + return model; +} + +function buildRow(entry: SettingEntry, resource?: vscode.Uri): PanelRow { + const current = effective(entry.key, resource); + const row: PanelRow = { + key: entry.key, + title: t(entry.title), + description: t(entry.description), + type: entry.type, + value: current.value, + scope: entry.scope, + applies: entry.applies, + // `effective()` also reports `language`, which is a level, not a source the + // panel can show a button for; it reads as a plain user-level value. + source: current.source === "language" ? "default" : current.source, + tier: entry.tier, + since: entry.since, + }; + if (entry.enum !== undefined) { + // Enum values are identifiers (`auto`, `workspaceFolder`, `warning`); the + // registry has no separate label for them and inventing copy here would + // give the panel words the Settings editor does not use. + row.options = entry.enum.map((value) => ({ value, label: value })); + } + if (entry.minimum !== undefined) { + row.minimum = entry.minimum; + } + if (entry.maximum !== undefined) { + row.maximum = entry.maximum; + } + if (entry.deprecated === true) { + row.deprecated = true; + if (entry.deprecationMessage !== undefined) { + row.deprecationMessage = t(entry.deprecationMessage); + } + } + return row; +} + +/** + * Every visible string, resolved once per model. These are the literals + * `tools/l10n-check.mjs` holds to `data/i18n/zh-cn.json`. + */ +function panelLabels(): Record { + return { + [PANEL_UI.htmlLang]: htmlLanguage(), + [PANEL_UI.title]: t("mcpp settings"), + [PANEL_UI.boundary]: t( + "This panel changes mcpp-vscode settings only; the C++ Modules extension ({0}) keeps its own mcppls.* settings and this panel never writes them.", + MCPPLS_EXTENSION_ID, + ), + [PANEL_UI.openMcpplsSettings]: t("Open the C++ Modules settings"), + [PANEL_UI.resourceLabel]: t("Values shown for {0}"), + [PANEL_UI.search]: t("Search settings"), + [PANEL_UI.onlyModified]: t("Only modified"), + [PANEL_UI.showAdvanced]: t("Show advanced settings"), + [PANEL_UI.target]: t("Save settings to"), + [PANEL_UI.targetUser]: t("User settings"), + [PANEL_UI.targetWorkspace]: t("Workspace settings"), + [PANEL_UI.targetWorkspaceUnavailable]: t("No workspace folder is open"), + [PANEL_UI.presets]: t("Presets"), + [PANEL_UI.modified]: t("Changed from the default"), + [PANEL_UI.deprecated]: t("Deprecated"), + [PANEL_UI.invalid]: t("Invalid"), + [PANEL_UI.appliesNextBuild]: t("Takes effect on the next build"), + [PANEL_UI.appliesNextClean]: t("Takes effect on the next clean"), + [PANEL_UI.appliesViewReload]: t("Reload the window to see this"), + [PANEL_UI.sourceDefault]: t("Default"), + [PANEL_UI.sourceUser]: t("User"), + [PANEL_UI.sourceWorkspace]: t("Workspace"), + [PANEL_UI.sourceWorkspaceFolder]: t("Workspace folder"), + [PANEL_UI.sourceInvalid]: t("Invalid"), + [PANEL_UI.reset]: t("Reset"), + [PANEL_UI.resetInvalid]: t("Reset the invalid value to the default"), + [PANEL_UI.openNative]: t("Open in the Settings editor"), + [PANEL_UI.arrayHint]: t("One value per line"), + [PANEL_UI.noMatches]: t("No settings match the search"), + [PANEL_UI.toggleSection]: t("Toggle this section"), + }; +} + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} + +function postModel(active: PanelSession): void { + void active.panel.webview.postMessage({ type: "model", model: buildModel(active.resource) }); +} + +/** + * The folder whose values the panel shows. An active editor inside a workspace + * folder wins (that is the project the user is looking at); otherwise the first + * folder. `undefined` means "no resource", and then only user-level writes are + * offered. + */ +function resourceUri(): vscode.Uri | undefined { + const active = vscode.window.activeTextEditor?.document.uri; + if (active !== undefined) { + const folder = vscode.workspace.getWorkspaceFolder(active); + if (folder !== undefined) { + return folder.uri; + } + } + return vscode.workspace.workspaceFolders?.[0]?.uri; +} + +function folderLabel(resource: vscode.Uri): string { + return vscode.workspace.getWorkspaceFolder(resource)?.name ?? path.basename(resource.fsPath); +} + +function mediaRoot(host: PanelHostContext): vscode.Uri { + const extensionUri = host.extensionUri ?? vscode.extensions.getExtension(EXTENSION_ID)?.extensionUri; + if (extensionUri !== undefined) { + return vscode.Uri.joinPath(extensionUri, MEDIA_DIRECTORY); + } + // `dist/src/config/` -> the extension root, where `media/` ships. + return vscode.Uri.file(path.join(__dirname, "..", "..", "..", MEDIA_DIRECTORY)); +} diff --git a/mide-plugins/vscode/src/config/panelHtml.ts b/mide-plugins/vscode/src/config/panelHtml.ts new file mode 100644 index 0000000..7af7456 --- /dev/null +++ b/mide-plugins/vscode/src/config/panelHtml.ts @@ -0,0 +1,612 @@ +/** + * The configuration panel's document, as a pure function. + * + * Everything the panel shows is data: `src/config/panel.ts` resolves the + * registry and the current values into a `PanelModel`, and this module turns + * that into one self-contained HTML document. No `vscode`, no file system, no + * network — which is what makes the interesting parts (escaping, the strict + * CSP, the advanced/deprecated/invalid marking) unit-testable. + * + * House rules the tests pin down: + * + * - **Strict CSP.** `default-src 'none'` plus exactly three sources: the + * stylesheet, the nonced inline script, and the webview's own image source. + * There are no external resources and no inline `style=` attributes, so a + * theme change cannot be the only thing that makes a control usable. + * - **No hard-coded copy.** Every user-visible label is looked up in + * `PanelModel.ui`, which the caller has already localized; the renderer never + * invents English. A key the caller forgot degrades to the key itself rather + * than to a blank control. + * - **No unescaped interpolation.** Every value from the registry or from + * `settings.json` goes through `escapeHtml`; the client script only ever + * assigns through `textContent`, never `innerHTML`. + * - **State is text plus shape, not colour.** Source, deprecation and invalid + * state are rendered as labelled badges and hidden attributes; the stylesheet + * adds borders around them. Colour is decoration only, so high-contrast + * themes stay readable. + * + * The webview talks back with JSON `postMessage`s (see `decodePanelMessage`) + * and the host answers with `{ type: "model", model }`, which the client script + * applies in place. + */ + +import { format } from "../i18n/translate"; + +/** One choice of an enum setting. */ +export interface PanelOption { + value: string; + label: string; +} + +/** One setting, ready to render. Values and copy are already resolved. */ +export interface PanelRow { + key: string; + title: string; + description: string; + type: "boolean" | "string" | "number" | "array"; + value: boolean | string | number | string[]; + options?: PanelOption[]; + minimum?: number; + maximum?: number; + scope: "resource" | "window"; + applies: "immediate" | "next-build" | "next-clean" | "view-reload"; + source: "default" | "user" | "workspace" | "workspaceFolder" | "invalid"; + tier: "public" | "advanced"; + deprecated?: boolean; + deprecationMessage?: string; + since: string; +} + +/** One registry group, with its rows in registry order. */ +export interface PanelSection { + id: string; + title: string; + rows: PanelRow[]; +} + +/** One preset button. */ +export interface PanelPreset { + id: string; + title: string; + description: string; +} + +export interface PanelModel { + sections: PanelSection[]; + presets: PanelPreset[]; + /** Every label the panel shows, already localized by the caller. */ + ui: Record; + /** The workspace folder the values belong to, when the panel was opened from one. */ + resourceLabel?: string; +} + +export interface PanelAssets { + cspSource: string; + nonce: string; + styleUri: string; + scriptUri: string; +} + +/** + * The `ui` keys the renderer reads, so the caller and the renderer cannot drift + * apart on a string. `panel.ts` fills all of them through `t()`. + */ +export const PANEL_UI = { + htmlLang: "panel.htmlLang", + title: "panel.title", + boundary: "panel.boundary", + openMcpplsSettings: "panel.openMcpplsSettings", + resourceLabel: "panel.resourceLabel", + search: "panel.search", + onlyModified: "panel.onlyModified", + showAdvanced: "panel.showAdvanced", + target: "panel.target", + targetUser: "panel.target.user", + targetWorkspace: "panel.target.workspace", + targetWorkspaceUnavailable: "panel.target.workspaceUnavailable", + presets: "panel.presets", + modified: "panel.modified", + deprecated: "panel.deprecated", + invalid: "panel.invalid", + appliesNextBuild: "panel.applies.nextBuild", + appliesNextClean: "panel.applies.nextClean", + appliesViewReload: "panel.applies.viewReload", + sourceDefault: "panel.source.default", + sourceUser: "panel.source.user", + sourceWorkspace: "panel.source.workspace", + sourceWorkspaceFolder: "panel.source.workspaceFolder", + sourceInvalid: "panel.source.invalid", + reset: "panel.reset", + resetInvalid: "panel.resetInvalid", + openNative: "panel.openNative", + arrayHint: "panel.arrayHint", + noMatches: "panel.noMatches", + toggleSection: "panel.toggleSection", +} as const; + +const SOURCE_UI: Record = { + default: PANEL_UI.sourceDefault, + user: PANEL_UI.sourceUser, + workspace: PANEL_UI.sourceWorkspace, + workspaceFolder: PANEL_UI.sourceWorkspaceFolder, + invalid: PANEL_UI.sourceInvalid, +}; + +const APPLIES_UI: Partial> = { + "next-build": PANEL_UI.appliesNextBuild, + "next-clean": PANEL_UI.appliesNextClean, + "view-reload": PANEL_UI.appliesViewReload, +}; + +export type PanelUiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * The CSP is an attribute value, but its single quotes are syntax: escaping + * them would turn `'none'` into `'none'` and make the document state a + * different policy than it means. Only the characters that could end the + * attribute or start a tag are escaped; `cspSource` and the nonce come from + * VS Code and from `crypto`, not from the registry. + */ +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** ` name="value"`, or nothing when the value is absent. */ +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +/** ` name`, or nothing. Boolean HTML attributes only. */ +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** The text of the `applies` hint, or `""` when the setting is immediate. */ +function appliesHint(row: PanelRow, label: PanelUiLabel): string { + const key = APPLIES_UI[row.applies]; + return key === undefined ? "" : label(key); +} + +function renderControl(row: PanelRow, label: PanelUiLabel): string { + const invalid = row.source === "invalid"; + const key = escapeHtml(row.key); + const aria = escapeHtml(row.title); + if (row.type === "boolean") { + return ( + `` + ); + } + if (row.type === "number") { + return ( + `` + ); + } + if (row.type === "array") { + const lines = Array.isArray(row.value) ? row.value.join("\n") : String(row.value); + return ( + `` + + `

    ${escapeHtml(label(PANEL_UI.arrayHint))}

    ` + ); + } + if (row.options !== undefined && row.options.length > 0) { + const options = row.options + .map( + (option) => + ``, + ) + .join(""); + return ( + `` + ); + } + return ( + `` + ); +} + +function renderRow(row: PanelRow, label: PanelUiLabel): string { + const invalid = row.source === "invalid"; + const modified = row.source !== "default"; + const deprecated = row.deprecated === true; + const deprecation = row.deprecationMessage ?? ""; + const hint = appliesHint(row, label); + const advanced = row.tier === "advanced"; + const resetTitle = invalid ? label(PANEL_UI.resetInvalid) : label(PANEL_UI.reset); + return [ + `
    `, + `
    `, + ` ${escapeHtml(row.title)}`, + ` ${escapeHtml(label(PANEL_UI.modified))}`, + ` ${escapeHtml(label(PANEL_UI.deprecated))}`, + ` ${escapeHtml(label(PANEL_UI.invalid))}`, + ` ${escapeHtml(label(SOURCE_UI[row.source]))}`, + `
    `, + `

    ${escapeHtml(row.description)}

    `, + `

    ${escapeHtml(deprecation)}

    `, + `

    ${escapeHtml(hint)}

    `, + `
    ${renderControl(row, label)}
    `, + `
    `, + ` `, + ` `, + `
    `, + `
    `, + ].join("\n"); +} + +function renderSection(section: PanelSection, label: PanelUiLabel): string { + const rows = section.rows.map((row) => renderRow(row, label)).join("\n"); + return [ + `
    `, + `

    `, + ` `, + `

    `, + `
    `, + rows, + `
    `, + `
    `, + ].join("\n"); +} + +function renderPresets(presets: readonly PanelPreset[], label: PanelUiLabel): string { + if (presets.length === 0) { + return ""; + } + const buttons = presets + .map( + (preset) => + ``, + ) + .join("\n "); + return [ + ``, + ].join("\n"); +} + +function renderToolbar(label: PanelUiLabel, hasResource: boolean): string { + return [ + `
    `, + ` `, + ` `, + ` `, + ` `, + `
    `, + ].join("\n"); +} + +/** + * A model embedded in the page's own script. `<` is escaped so a registry + * string (or a value from `settings.json`) can never close the script element. + */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ 0; }); + } + return element.value; + } + + function writeControl(element, row) { + var kind = element.getAttribute("data-kind"); + if (kind === "boolean") { element.checked = row.value === true; } + else if (kind === "array") { element.value = (row.value || []).join("\\n"); } + else { element.value = row.value === undefined || row.value === null ? "" : String(row.value); } + element.disabled = row.source === "invalid"; + } + + function applyRow(row) { + var element = rowElements[row.key]; + if (!element) { return; } + element.setAttribute("data-source", row.source); + element.setAttribute("data-modified", row.source === "default" ? "false" : "true"); + setText(element.querySelector('[data-badge="source"]'), ui[SOURCE_KEYS[row.source]] || row.source); + setHidden(element.querySelector('[data-badge="modified"]'), row.source === "default"); + setHidden(element.querySelector('[data-badge="deprecated"]'), row.deprecated !== true); + setHidden(element.querySelector('[data-badge="invalid"]'), row.source !== "invalid"); + var deprecation = element.querySelector(".row-deprecation"); + var message = typeof row.deprecationMessage === "string" ? row.deprecationMessage : ""; + setText(deprecation, message); + setHidden(deprecation, message.length === 0); + var applies = element.querySelector(".row-applies"); + var appliesKey = APPLIES_KEYS[row.applies]; + var hint = appliesKey ? (ui[appliesKey] || "") : ""; + setText(applies, hint); + setHidden(applies, hint.length === 0); + var input = element.querySelector("[data-control]"); + if (input) { writeControl(input, row); } + } + + function rowVisible(key, element, query, onlyChanged, advanced) { + if (!advanced && element.getAttribute("data-tier") === "advanced") { return false; } + if (onlyChanged && element.getAttribute("data-modified") !== "true") { return false; } + if (query.length === 0) { return true; } + var title = element.querySelector(".row-title"); + var description = element.querySelector(".row-description"); + var haystack = key + " " + (title ? title.textContent : "") + " " + (description ? description.textContent : ""); + return haystack.toLowerCase().indexOf(query) >= 0; + } + + function filter() { + var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; + var onlyChanged = onlyModifiedInput ? onlyModifiedInput.checked : false; + var advanced = showAdvancedInput ? showAdvancedInput.checked : false; + var anySection = false; + for (var index = 0; index < sectionElements.length; index += 1) { + var section = sectionElements[index]; + var rows = section.querySelectorAll("[data-row]"); + var anyRow = false; + for (var inner = 0; inner < rows.length; inner += 1) { + var element = rows[inner]; + var shown = rowVisible(element.getAttribute("data-row"), element, query, onlyChanged, advanced); + element.hidden = !shown; + if (shown) { anyRow = true; } + } + section.hidden = !anyRow; + if (anyRow) { anySection = true; } + } + if (emptyState) { emptyState.hidden = anySection; } + } + + function applyModel(next) { + if (!next) { return; } + state = next; + ui = next.ui || {}; + var sections = next.sections || []; + for (var index = 0; index < sections.length; index += 1) { + var rows = sections[index].rows || []; + for (var inner = 0; inner < rows.length; inner += 1) { applyRow(rows[inner]); } + } + var presets = next.presets || []; + for (var presetIndex = 0; presetIndex < presets.length; presetIndex += 1) { + var button = presetElements[presets[presetIndex].id]; + if (button) { + setText(button, presets[presetIndex].title); + if (typeof presets[presetIndex].description === "string") { button.title = presets[presetIndex].description; } + } + } + filter(); + } + + if (searchInput) { searchInput.addEventListener("input", filter); } + if (onlyModifiedInput) { onlyModifiedInput.addEventListener("change", filter); } + if (showAdvancedInput) { showAdvancedInput.addEventListener("change", filter); } + + document.addEventListener("change", function (event) { + var element = event.target; + if (!element || typeof element.getAttribute !== "function") { return; } + if (!element.hasAttribute("data-control")) { return; } + var row = element.closest("[data-row]"); + if (!row) { return; } + var value = readControl(element); + if (value === undefined) { return; } + post({ type: "update", key: row.getAttribute("data-row"), value: value, target: targetSelect ? targetSelect.value : "user" }); + }); + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var button = target.closest("button"); + if (!button) { return; } + var preset = button.getAttribute("data-preset"); + if (preset) { post({ type: "preset", id: preset }); return; } + if (button.hasAttribute("data-section-toggle")) { + var section = button.closest("[data-section]"); + if (section) { + var collapsed = section.getAttribute("data-collapsed") === "true"; + section.setAttribute("data-collapsed", collapsed ? "false" : "true"); + button.setAttribute("aria-expanded", collapsed ? "true" : "false"); + } + return; + } + var action = button.getAttribute("data-action"); + if (action === "reset" || action === "openNative") { + var row = button.closest("[data-row]"); + if (row) { + if (action === "reset") { post({ type: "reset", key: row.getAttribute("data-row") }); } + else { post({ type: "openNative", key: row.getAttribute("data-row") }); } + } + return; + } + if (button.id === "panel-open-mcppls") { post({ type: "openMcpplsSettings" }); } + }); + + window.addEventListener("message", function (event) { + var data = event.data; + if (data && data.type === "model" && data.model) { applyModel(data.model); } + }); + + filter(); + post({ type: "ready" }); +})();`; +} + +/** + * The whole document. `assets.scriptUri` is accepted for the contract but not + * used: the client script is inline and nonced, and the CSP names no external + * script source, so nothing else can be loaded. + */ +export function renderPanelHtml(model: PanelModel, assets: PanelAssets): string { + const label: PanelUiLabel = (key) => model.ui[key] ?? key; + const hasResource = model.resourceLabel !== undefined; + const sections = model.sections.map((section) => renderSection(section, label)).join("\n"); + const lang = model.ui[PANEL_UI.htmlLang] ?? ""; + const resource = + model.resourceLabel === undefined + ? "" + : `

    ${escapeHtml(format(label(PANEL_UI.resourceLabel), [model.resourceLabel]))}

    `; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + return ` + + + + + + +${escapeHtml(label(PANEL_UI.title))} + + +
    +

    ${escapeHtml(label(PANEL_UI.title))}

    +

    ${escapeHtml(label(PANEL_UI.boundary))}

    + ${resource} +
    +${renderToolbar(label, hasResource)} +${renderPresets(model.presets, label)} +
    +${sections} +
    + + + + +`; +} + +/** Values posted back by the webview, decoded and validated. */ +export type PanelMessage = + | { type: "update"; key: string; value: unknown; target: "user" | "workspace" } + | { type: "reset"; key: string } + | { type: "preset"; id: string } + | { type: "openNative"; key: string } + | { type: "openMcpplsSettings" } + | { type: "ready" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that + * is not exactly one of the six shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + * + * A settings key is always required and always non-empty; `update` additionally + * needs a write target and a `value` field (the value itself may be anything — + * `validate.ts` checks it against the registry before it is written). + */ +export function decodePanelMessage(raw: unknown): PanelMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "ready": + return { type: "ready" }; + case "openMcpplsSettings": + return { type: "openMcpplsSettings" }; + case "update": { + if (!nonEmptyString(raw.key) || !("value" in raw)) { + return undefined; + } + if (raw.target !== "user" && raw.target !== "workspace") { + return undefined; + } + return { type: "update", key: raw.key, value: raw.value, target: raw.target }; + } + case "reset": + return nonEmptyString(raw.key) ? { type: "reset", key: raw.key } : undefined; + case "preset": + return nonEmptyString(raw.id) ? { type: "preset", id: raw.id } : undefined; + case "openNative": + return nonEmptyString(raw.key) ? { type: "openNative", key: raw.key } : undefined; + default: + return undefined; + } +} diff --git a/mide-plugins/vscode/src/config/presets.ts b/mide-plugins/vscode/src/config/presets.ts new file mode 100644 index 0000000..21d3b2e --- /dev/null +++ b/mide-plugins/vscode/src/config/presets.ts @@ -0,0 +1,110 @@ +/** + * Named bundles of setting values for the configuration panel. + * + * Pure: a preset is data, so the panel, the tests and the docs all read the same + * table. A preset only ever names keys that exist in the registry (enforced by + * `presetProblems()` and the unit tests) — a preset that silently does nothing is + * worse than no preset. + */ + +import { setting } from "./registry"; + +export interface Preset { + id: string; + /** English; the panel localizes this through `src/i18n/t.ts`. */ + title: string; + description: string; + /** Key -> value, applied in order. */ + values: Readonly>; +} + +export const PRESETS: readonly Preset[] = [ + { + id: "defaults", + title: "Defaults", + description: "Put every mcpp setting back to its shipped default.", + values: {}, + }, + { + id: "quiet", + title: "Quiet", + description: "No status-bar counters, no automatic refresh, no editor diagnostics from this extension.", + values: { + "mcpp.cache.statusBar": false, + "mcpp.cache.autoRefreshSeconds": 0, + "mcpp.languageService.stateRefreshSeconds": 0, + "mcpp.ui.notifications.success": "silent", + "mcpp.task.focusTerminal": false, + "mcpp.toml.diagnostics.unknownKey": "off", + "mcpp.toml.diagnostics.legacyKeys": "off", + "mcpp.buildScript.diagnostics": false, + }, + }, + { + id: "everything", + title: "Everything on", + description: "All observation and editing help enabled, including the ones that run mcpp a little more often.", + values: { + "mcpp.cache.statusBar": true, + "mcpp.cache.autoRefreshSeconds": 60, + "mcpp.cache.warnAboveGiB": 10, + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 30, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.toml.hover": true, + "mcpp.toml.navigation": true, + "mcpp.toml.diagnostics.enabled": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + "mcpp.toml.diagnostics.planeSeparation": "warning", + "mcpp.toml.diagnostics.legacyKeys": "info", + "mcpp.buildScript.intelligence": true, + "mcpp.buildScript.diagnostics": true, + "mcpp.buildScript.snippets": true, + "mcpp.buildScript.imports.knownModules": true, + "mcpp.views.cache.topN": 10, + "mcpp.ui.notifications.success": "statusBar", + "mcpp.log.level": "info", + }, + }, + { + id: "diagnose", + title: "Diagnose", + description: "Turn the logging up and read the C++ Modules state, for when something is wrong.", + values: { + "mcpp.log.level": "debug", + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 15, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.diagnostics.selfCheckOnStartup": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + }, + }, +]; + +export function preset(id: string): Preset | undefined { + return PRESETS.find((entry) => entry.id === id); +} + +/** The key/value pairs a preset would write. `defaults` writes nothing: see `resetKeys()`. */ +export function presetValues(id: string): Array<{ key: string; value: unknown }> { + const found = preset(id); + if (found === undefined) { + return []; + } + return Object.entries(found.values).map(([key, value]) => ({ key, value })); +} + +/** Registry keys a preset mentions that do not exist (must be empty). */ +export function presetProblems(): string[] { + const problems: string[] = []; + for (const entry of PRESETS) { + for (const key of Object.keys(entry.values)) { + if (setting(key) === undefined) { + problems.push(`preset ${entry.id} names unknown setting ${key}`); + } + } + } + return problems; +} diff --git a/mide-plugins/vscode/src/config/registry.ts b/mide-plugins/vscode/src/config/registry.ts new file mode 100644 index 0000000..2f73423 --- /dev/null +++ b/mide-plugins/vscode/src/config/registry.ts @@ -0,0 +1,184 @@ +/** + * The settings registry: **the single source of truth** for everything this + * extension makes configurable. + * + * `data/config-registry.json` holds the declaration; `package.json`'s + * `contributes.configuration` is hand-written but held to it by + * `tools/check-config.mjs`, and `docs/settings.md` is generated from it. The + * configuration panel (`src/config/panel.ts`) renders it, and + * `mcpp: environment self-check` dumps the entries the user has changed. + * + * Shape rules are enforced by `tools/check-config.mjs` and by the unit tests: + * unique keys, every `group` present in `groups`, ascending `order` within a + * group, and a `default` that `validate.ts` accepts. + */ + +import registryJson from "../../data/config-registry.json"; + +export type SettingType = "boolean" | "string" | "number" | "array"; +export type SettingScope = "resource" | "window"; +export type SettingTier = "public" | "advanced"; +export type SettingApplies = "immediate" | "next-build" | "next-clean" | "view-reload"; + +export interface SettingEntry { + key: string; + type: SettingType; + default: boolean | string | number | string[]; + enum?: string[]; + minimum?: number; + maximum?: number; + scope: SettingScope; + group: string; + order: number; + tier: SettingTier; + applies: SettingApplies; + since: string; + deprecated?: boolean; + aliases?: string[]; + title: string; + description: string; + deprecationMessage?: string; +} + +export interface SettingGroup { + id: string; + order: number; + title: string; +} + +interface RegistryFile { + version: number; + groups: SettingGroup[]; + settings: SettingEntry[]; +} + +const registry = registryJson as unknown as RegistryFile; + +export const SETTINGS: readonly SettingEntry[] = registry.settings; +export const GROUPS: readonly SettingGroup[] = [...registry.groups].sort((a, b) => a.order - b.order); + +const BY_KEY = new Map(SETTINGS.map((entry) => [entry.key, entry])); +const GROUP_BY_ID = new Map(GROUPS.map((group) => [group.id, group])); + +/** The section every key lives under; VS Code's `getConfiguration` needs it. */ +export const SECTION = "mcpp"; + +/** `"mcpp.cache.staleDays"` -> `"cache.staleDays"`. */ +export function subKey(key: string): string { + return key.startsWith(`${SECTION}.`) ? key.slice(SECTION.length + 1) : key; +} + +export function setting(key: string): SettingEntry | undefined { + return BY_KEY.get(key); +} + +export function requireSetting(key: string): SettingEntry { + const entry = BY_KEY.get(key); + if (entry === undefined) { + throw new Error(`unknown mcpp setting: ${key}`); + } + return entry; +} + +export function groupOf(key: string): SettingGroup | undefined { + const entry = BY_KEY.get(key); + return entry === undefined ? undefined : GROUP_BY_ID.get(entry.group); +} + +/** Entries of one group, in their declared order. */ +export function settingsInGroup(groupId: string): SettingEntry[] { + return SETTINGS.filter((entry) => entry.group === groupId) + .slice() + .sort((a, b) => a.order - b.order); +} + +export function settingsByTier(tier: SettingTier): SettingEntry[] { + return SETTINGS.filter((entry) => entry.tier === tier); +} + +/** Keys this build mentions in the manifest, in registry order. */ +export function manifestKeys(): string[] { + return SETTINGS.map((entry) => entry.key); +} + +/** The key an old name now resolves to, or `undefined` when it is unknown. */ +export function aliasedKey(oldKey: string): string | undefined { + for (const entry of SETTINGS) { + if (entry.aliases?.includes(oldKey) === true) { + return entry.key; + } + } + return undefined; +} + +/** Old name -> current name, for the one-time migration prompt. */ +export function renamedKeys(): Array<{ from: string; to: string }> { + const pairs: Array<{ from: string; to: string }> = []; + for (const entry of SETTINGS) { + for (const alias of entry.aliases ?? []) { + pairs.push({ from: alias, to: entry.key }); + } + } + return pairs; +} + +/** + * The registry's own invariants. Returns a list of problems (empty when sound). + * + * The same rules live in `tools/check-config.mjs`, but having them here means a + * malformed `data/config-registry.json` is caught by the unit tests as well — + * and lets `activate()` log a single line instead of failing feature by feature. + */ +export function registryShapeProblems(): string[] { + const problems: string[] = []; + const groupIds = new Set(); + for (const group of GROUPS) { + if (groupIds.has(group.id)) { + problems.push(`duplicate group ${group.id}`); + } + groupIds.add(group.id); + if (typeof group.title !== "string" || group.title.length === 0) { + problems.push(`group ${group.id} has no title`); + } + } + const seen = new Set(); + const lastOrder = new Map(); + for (const entry of SETTINGS) { + if (!entry.key.startsWith(`${SECTION}.`)) { + problems.push(`${entry.key} is not a ${SECTION}.* key`); + } + if (seen.has(entry.key)) { + problems.push(`duplicate key ${entry.key}`); + } + seen.add(entry.key); + if (!groupIds.has(entry.group)) { + problems.push(`${entry.key} names undeclared group ${entry.group}`); + } + const previous = lastOrder.get(entry.group); + if (previous !== undefined && !(entry.order > previous)) { + problems.push(`${entry.group} order is not ascending at ${entry.key}`); + } + lastOrder.set(entry.group, entry.order); + if (entry.title.length === 0 || entry.description.length === 0) { + problems.push(`${entry.key} has an empty title or description`); + } + if (entry.enum !== undefined && !entry.enum.includes(entry.default as string)) { + problems.push(`${entry.key} default is not in its enum`); + } + if (entry.deprecated === true && entry.deprecationMessage === undefined) { + problems.push(`${entry.key} is deprecated without a deprecationMessage`); + } + } + return problems; +} + +/** Every setting key, ordered by group then by the group's own order. */ +export function groupedKeys(): string[] { + const keys: string[] = []; + for (const group of GROUPS) { + for (const entry of settingsInGroup(group.id)) { + keys.push(entry.key); + } + } + return keys; +} diff --git a/mide-plugins/vscode/src/config/validate.ts b/mide-plugins/vscode/src/config/validate.ts new file mode 100644 index 0000000..91ec337 --- /dev/null +++ b/mide-plugins/vscode/src/config/validate.ts @@ -0,0 +1,93 @@ +/** + * Value validation for the settings registry. Pure: no `vscode`. + * + * Used in two places: `access.ts` when a value arrives from the editor (a + * hand-edited `settings.json` can hold anything), and the configuration panel + * before it writes. An invalid value never reaches the rest of the extension – + * the declared default is used and the user is told once. + */ + +import type { SettingEntry } from "./registry"; + +export type ValidationFailure = + | "type" + | "enum" + | "minimum" + | "maximum" + | "items"; + +export interface ValidationResult { + ok: boolean; + /** Present when `ok`: the coerced value. */ + value?: boolean | string | number | string[]; + /** Present when `!ok`: why it was rejected. */ + reason?: ValidationFailure; +} + +function typeOf(entry: SettingEntry): string { + return entry.type === "array" ? "array of strings" : entry.type; +} + +export function validateValue(entry: SettingEntry, raw: unknown): ValidationResult { + if (raw === undefined || raw === null) { + return { ok: false, reason: "type" }; + } + switch (entry.type) { + case "boolean": { + if (typeof raw !== "boolean") { + return { ok: false, reason: "type" }; + } + return { ok: true, value: raw }; + } + case "number": { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return { ok: false, reason: "type" }; + } + if (entry.minimum !== undefined && raw < entry.minimum) { + return { ok: false, reason: "minimum" }; + } + if (entry.maximum !== undefined && raw > entry.maximum) { + return { ok: false, reason: "maximum" }; + } + return { ok: true, value: raw }; + } + case "string": { + if (typeof raw !== "string") { + return { ok: false, reason: "type" }; + } + if (entry.enum !== undefined && !entry.enum.includes(raw)) { + return { ok: false, reason: "enum" }; + } + return { ok: true, value: raw }; + } + case "array": { + if (!Array.isArray(raw)) { + return { ok: false, reason: "type" }; + } + if (raw.some((item) => typeof item !== "string")) { + return { ok: false, reason: "items" }; + } + return { ok: true, value: [...raw] as string[] }; + } + default: + return { ok: false, reason: "type" }; + } +} + +/** The declared default, as a fresh value (arrays are copied). */ +export function defaultValue(entry: SettingEntry): T { + return (Array.isArray(entry.default) ? [...entry.default] : entry.default) as T; +} + +/** Human-readable expectation, for the "value rejected" notice. */ +export function expectation(entry: SettingEntry): string { + if (entry.enum !== undefined) { + return entry.enum.join(" | "); + } + if (entry.type === "number" && (entry.minimum !== undefined || entry.maximum !== undefined)) { + const low = entry.minimum ?? Number.NEGATIVE_INFINITY; + const high = entry.maximum ?? Number.POSITIVE_INFINITY; + return `${low} … ${high}`; + } + return typeOf(entry); +} diff --git a/mide-plugins/vscode/src/diagnostics.ts b/mide-plugins/vscode/src/diagnostics.ts deleted file mode 100644 index d2ecd2d..0000000 --- a/mide-plugins/vscode/src/diagnostics.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Problems from mcpp, never from a regular expression over a terminal: each `job/diagnostic` of a -// build goes into the Problems panel located where mcpp says, a job's start clears its project's, -// and the manifest's own problems (`manifest/diagnostics`, `manifest/check`) sit on mcpp.toml. -import * as path from "node:path"; -import * as vscode from "vscode"; - -import { severityOf, zeroBased } from "./mapping"; -import type { Diagnostic } from "./protocol"; -import type { Session } from "./session"; - -const SEVERITY: Record = { - error: vscode.DiagnosticSeverity.Error, - warning: vscode.DiagnosticSeverity.Warning, - information: vscode.DiagnosticSeverity.Information, - hint: vscode.DiagnosticSeverity.Hint, -}; - -export function toVscode(d: Diagnostic): vscode.Diagnostic { - const start = zeroBased(d.range?.start); - const end = zeroBased(d.range?.end); - // A point range is widened to the rest of the line's word by VS Code itself when it is empty. - const range = new vscode.Range(start.line, start.character, end.line, Math.max(end.character, start.character + 1)); - const out = new vscode.Diagnostic(range, d.message, SEVERITY[severityOf(d)]); - out.source = d.source || "mcpp"; - if (d.code) out.code = d.code; - return out; -} - -export class Problems implements vscode.Disposable { - private readonly build = vscode.languages.createDiagnosticCollection("mcpp"); - private readonly manifest = vscode.languages.createDiagnosticCollection("mcpp.toml"); - private readonly byRoot = new Map>(); - private readonly disposables: vscode.Disposable[] = [this.build, this.manifest]; - - constructor(session: Session) { - this.disposables.push( - session.on("job/started", (p: { root: string; kind: string }) => { if (p.kind === "build" || p.kind === "test") this.clear(p.root); }), - session.on("job/diagnostic", (p: Diagnostic & { root: string }) => this.add(p.root, p)), - session.on("job/finished", (p: { root?: string; kind?: string; diagnostics?: Diagnostic[] }) => { - // The envelope's diagnostics that the stream did not already carry (a manifest error ends a - // build before any event). - if (!p.root) return; - for (const d of p.diagnostics ?? []) if (d.path && !this.has(p.root, d)) this.add(p.root, d); - }), - session.on("manifest/diagnostics", (p: { root: string; diagnostics: Diagnostic[] }) => this.setManifest(p.root, p.diagnostics)), - vscode.workspace.onDidSaveTextDocument(async (doc) => { - if (path.basename(doc.fileName) !== "mcpp.toml") return; - try { - const r = await session.request<{ root: string; diagnostics: Diagnostic[] }>("manifest/check", { root: path.dirname(doc.fileName) }); - this.setManifest(r.root, r.diagnostics); - } catch { - // mcpp unavailable: the self-check says why. - } - }), - ); - } - - private has(root: string, d: Diagnostic): boolean { - const list = this.byRoot.get(root)?.get(d.path!) ?? []; - return list.some((x) => x.message === d.message); - } - - private add(root: string, d: Diagnostic): void { - if (!d.path) return; - if (!this.byRoot.has(root)) this.byRoot.set(root, new Map()); - const files = this.byRoot.get(root)!; - const file = path.isAbsolute(d.path) ? d.path : path.join(root, d.path); - const list = files.get(file) ?? []; - list.push(toVscode(d)); - files.set(file, list); - this.build.set(vscode.Uri.file(file), list); - } - - private clear(root: string): void { - for (const file of this.byRoot.get(root)?.keys() ?? []) this.build.delete(vscode.Uri.file(file)); - this.byRoot.delete(root); - } - - private setManifest(root: string, diagnostics: Diagnostic[]): void { - const file = vscode.Uri.file(path.join(root, "mcpp.toml")); - const enabled = vscode.workspace.getConfiguration().get("mcpp.toml.diagnostics.enabled") ?? true; - this.manifest.set(file, enabled ? diagnostics.map(toVscode) : []); - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts index be06921..161b6d1 100644 --- a/mide-plugins/vscode/src/extension.ts +++ b/mide-plugins/vscode/src/extension.ts @@ -1,84 +1,540 @@ -// mcpp for VS Code: the adapter of mcpp-ide. It starts the backend (`mcpp-ide serve`) and maps its -// protocol onto VS Code: tasks in pseudoterminals, the Testing view, debug configurations, the -// Problems panel, the project view, the shared panels, mcpp.toml editing help. Language features of -// C++ files are mcppls's (the extension pack installs it); this extension registers none. +import * as path from "node:path"; +import * as os from "node:os"; +import type { ProjectMcpp } from "./ide/protocol"; import * as vscode from "vscode"; -import { dispatch, registerCommands, type CommandContext } from "./commands"; -import { Debugging } from "./debug"; -import { Problems } from "./diagnostics"; -import { Jobs } from "./jobs"; -import { Panels } from "./panels"; -import { ProjectTree } from "./projectTree"; -import { Projects } from "./projects"; -import { Session } from "./session"; -import { StatusBar } from "./status"; -import { Tasks } from "./tasks"; -import { Testing } from "./testing"; -import { registerToml } from "./toml"; +import { McppCliController } from "./cli/controller"; +import { findCurrentProject } from "./projects/current"; +import { runMcpp, useMcppExecutor, useMcpplsExecutor } from "./cli/process"; +import { parseProtocolInfo } from "./cli/protocol"; +import { buildSelfCheckText } from "./cli/selfCheck"; +import { CLI_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "./commands/ids"; +import type { McppProjectDiscovery } from "./projects/discovery"; +import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./projects/context"; +import { + createLanguageServerBridge, + type LanguageServerBridge, + type LanguageServerCommandResult, +} from "./mcppls/bridge"; +import { CAPABILITIES, MCPPLS_EXTENSION_ID } from "./mcppls/contract"; +import { formatResult } from "./mcppls/messages"; +import { describeState } from "./mcppls/state"; +import { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, +} from "./mcppls/stateSource"; +import { + buildModuleSetupPlan, + executeModuleSetup, + moduleSetupConfirmation, + type ModuleSetupDecision, + type ModuleSetupStepResult, +} from "./workflows/moduleSetup"; +import type { ProjectTaskKind, TaskCompletion } from "./cli/tasks"; +import { Debugging } from "./ide/debug"; +import { packArguments, selectTarget } from "./ide/flows"; +import { Jobs } from "./ide/jobs"; +import type { IdeProjects } from "./ide/projects"; +import { Session } from "./ide/session"; +import { Testing } from "./ide/testing"; +import { McppCodeLens } from "./ide/codeLens"; +import { changedSettings, onDidChange as onConfigurationChanged, read } from "./config/access"; +import { applyRenames, pendingRenames, renamePrompt } from "./config/migrate"; +import { languagePreference, setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; +import { CACHE_VIEW_FOCUS_COMMAND } from "./cache/cachePanel"; +import { readCacheSnapshot } from "./cache/cacheView"; +import { registerPages } from "./pages"; +import { registerLanguageServerCommands } from "./views/languageServerView"; +/** When the language service was last asked to reload, for the self-check. */ +let lastRefresh: { at: string; state: string; command?: string } | undefined; + +/** `mcpp.ui.language` decides which of our strings the user sees. */ +function applyLanguagePreference(): void { + setLanguagePreference(read("mcpp.ui.language")); +} + + +function outputText(output: vscode.OutputChannel, line: string): void { + try { + output.appendLine(line); + } catch { + // The output channel can close before an async task finishes during shutdown. + } +} + +function resultText(output: vscode.OutputChannel, result: LanguageServerCommandResult): void { + const formatted = formatResult(result); + outputText(output, `[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); + if (result.state === "unavailable") { + const install = t("Install extension"); + void vscode.window.showWarningMessage(formatted.message, install).then((choice) => { + if (choice === install) { + void vscode.commands.executeCommand("workbench.extensions.search", `@id:${MCPPLS_EXTENSION_ID}`); + } + }); + return; + } + if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } else if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); + } +} + +function moduleSetupBlockedMessage(reason: Extract["reason"]): string { + switch (reason) { + case "untrusted": + return t("This workspace is not trusted, so mcpp will not run and the C++ Modules language service will not refresh. Trust the workspace first."); + case "busy": + return t("An mcpp operation is already running; wait for it to finish and try again."); + } +} + +function taskCompletionText(completion: TaskCompletion): string { + if (completion.state === "cancelled") { + return t("The build was cancelled; the C++ Modules language service was not refreshed."); + } + if (completion.state === "failed") { + return t("The mcpp build failed (exit code {0}); the C++ Modules language service re-read the existing build description.", completion.exitCode ?? t("unknown")); + } + return t("The mcpp build finished; the C++ Modules language service has been refreshed."); +} + +async function autoConfigureModulesWizard( + bridge: LanguageServerBridge, + cliController: McppCliController, + output: vscode.OutputChannel, +): Promise { + if (!vscode.workspace.isTrusted) { + await vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); + return; + } + if (cliController.isBusy()) { + await vscode.window.showWarningMessage(moduleSetupBlockedMessage("busy")); + return; + } + + const decision = buildModuleSetupPlan(true, false); + if (decision.kind === "blocked") { + await vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); + return; + } + const confirmation = moduleSetupConfirmation(); + // One definition: the label is shown *and* the reply is compared against it. + const confirmLabel = t("Confirm one-click setup"); + const choice = await vscode.window.showWarningMessage( + confirmation.message, + { modal: true, detail: confirmation.detail }, + confirmLabel, + ); + if (choice !== confirmLabel) { + return; + } + + const outcome = await executeModuleSetup(decision, { + build: async (): Promise => { + const completion = await cliController.runProjectTask("build", { notify: false }); + if (completion === undefined) { + return { + stage: "build", + state: "not-started", + detail: t("mcpp build was not started."), + }; + } + return { + stage: "build", + state: completion.state, + exitCode: completion.exitCode, + }; + }, + refreshLanguageServer: async (): Promise => { + const result = await bridge.refreshLanguageServerAfterBuild(); + resultText(output, result); + return { + stage: "language-server", + state: result.state === "completed" ? "succeeded" : "failed", + detail: result.state === "completed" ? undefined : formatResult(result).message, + }; + }, + }); + + const buildStep = outcome.steps[0]; + if (outcome.state === "succeeded") { + await vscode.window.showInformationMessage( + taskCompletionText(buildStep?.state === "failed" ? { state: "failed", exitCode: buildStep.exitCode } : { state: "succeeded" }), + ); + } else if (outcome.state === "cancelled") { + await vscode.window.showWarningMessage(taskCompletionText({ state: "cancelled" })); + } else { + await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? t("Configuring the C++ Modules language service failed.")); + } +} + +/** What the extension hands to whoever activates it (its own tests): the backend session. */ export interface McppApi { session: Session; - projects: Projects; } -export async function activate(context: vscode.ExtensionContext): Promise { +export async function activate(extensionContext: vscode.ExtensionContext): Promise { const output = vscode.window.createOutputChannel("mcpp"); - const session = new Session(context, output); - const projects = new Projects(session); - const jobs = new Jobs(session); - const tasks = new Tasks(session, jobs, projects); - const debugging = new Debugging(session, projects); - // The panels run commands, and the commands open panels: the context is completed once both exist. - const c = { session, projects, jobs, tasks, debugging } as CommandContext; - c.panels = new Panels(context, session, async (command, params) => { - const entry = session.commands.find((x) => x.id === command); - if (!entry) throw new Error(session.t("unknown command {0}", command)); - await dispatch(c, entry, [params?.args ?? params]); - return true; + + // Language first: everything below may want to speak to the user. + applyLanguagePreference(); + extensionContext.subscriptions.push(onConfigurationChanged(applyLanguagePreference)); + + // The mcpp-ide backend (`mcpp-ide serve`, carried as one ok-image bundle and every system's + // runner) answers the views' data, mcppls's account of the workspace among them; it starts in the + // background below, and nothing waits for it. + const session = new Session(extensionContext, output); + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, + serverActions: () => session.languageService?.serverActions, + activateExtension: async (id) => { + await vscode.extensions.getExtension(id)?.activate(); + }, + executeCommand: (command: string, ...args: unknown[]) => vscode.commands.executeCommand(command, ...args), }); - const panels = c.panels; - context.subscriptions.push(output, session, projects, jobs, tasks, debugging, panels, - new Problems(session), new Testing(session, jobs, projects), new ProjectTree(session, projects, jobs), new StatusBar(session, projects, jobs)); - registerCommands(context, c); - registerToml(context, session); - - const views = () => { - const get = (k: string) => vscode.workspace.getConfiguration().get(k) ?? true; - void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !get("mcpp.views.enabled")); - void vscode.commands.executeCommand("setContext", "mcpp.views.project", get("mcpp.views.project.show")); - void vscode.commands.executeCommand("setContext", "mcpp.views.library", get("mcpp.views.library.show")); - void vscode.commands.executeCommand("setContext", "mcpp.views.cache", get("mcpp.views.cache.show")); + const runGuarded = (operation: () => Promise): (() => Promise) => async () => { + try { + await operation(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + outputText(output, t("Unexpected error: {0}", message)); + void vscode.window.showErrorMessage(t("mcpp: {0}", message)); + } }; - views(); - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("mcpp.views")) views(); }), + const invokeLanguageServer = ( + operation: () => Promise, + ): (() => Promise) => runGuarded(async () => { + resultText(output, await operation()); + }); + + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); + const inProjectContext = registerInProjectContext({ + currentProject: findCurrentProject, + setContextValue: (key, value) => vscode.commands.executeCommand("setContext", key, value), + subscribe: (listener) => [ + vscode.window.onDidChangeActiveTextEditor(listener), + vscode.workspace.onDidChangeWorkspaceFolders(listener), + manifestWatcher.onDidCreate(listener), + manifestWatcher.onDidDelete(listener), + ], + }); + + const afterProjectTask = async ( + _project: McppProjectDiscovery, + _kind: ProjectTaskKind, + completion: TaskCompletion, + ): Promise => { + if (completion.state === "cancelled") { + return; + } + const mode = read("mcpp.languageService.refreshAfterBuild"); + if (mode === "off") { + lastRefresh = { at: new Date().toISOString(), state: "skipped (mcpp.languageService.refreshAfterBuild = off)" }; + return; + } + // `auto`: mcppls 0.0.18 and later read mcpp's result record after any build and refresh + // themselves, so nothing is forwarded (the backend reports an older mcppls as outdated). + const result = + mode === "restart" ? await bridge.restartLanguageServer() + : mode === "reload" ? await bridge.refreshLanguageServerAfterBuild() + : undefined; + lastRefresh = result === undefined + ? { at: new Date().toISOString(), state: "mcppls reads mcpp's result record" } + : { at: new Date().toISOString(), state: result.state, command: result.command }; + if (result !== undefined) resultText(output, result); + if (completion.state === "succeeded" && (result === undefined || result.state === "completed")) { + await vscode.window.showInformationMessage(taskCompletionText(completion)); + } else if (completion.state === "failed" && (result === undefined || result.state === "completed")) { + await vscode.window.showWarningMessage(taskCompletionText(completion)); + } + }; + + const projectMcpp = new Map(); + const cliController = new McppCliController({ + output, + extensionUri: extensionContext.extensionUri, + currentProject: findCurrentProject, + afterProjectTask, + isTrusted: () => vscode.workspace.isTrusted, + languageServerSummary: () => { + const view = readLanguageServerState(session.languageService); + return view.available ? view.state : undefined; + }, + projectMcpp: () => { + const project = findCurrentProject(); + return project === undefined ? undefined : projectMcpp.get(project.root); + }, + }); + + // ── the mcpp-ide backend ───────────────────────────────────────────────── + // The session (created above) also answers the flows 0.9.0 did not have: debugging, the Testing + // view, the targets and target sets, packing. + const ideProjects: IdeProjects = { + current: () => findCurrentProject(), + all: () => session.info?.projects ?? [], + }; + const jobs = new Jobs(session); + // The project's mcpp (§4.4): asked when the backend starts, when the current project changes, + // after the window regains focus, and when a job's envelope named another version. + const refreshProjectMcpp = async (): Promise => { + const project = findCurrentProject(); + if (project === undefined || !session.running) return; + try { + projectMcpp.set(project.root, await session.request("project/mcpp", { root: project.root })); + } catch { + projectMcpp.delete(project.root); + } + cliController.refreshStatus(); + }; + // 0.9.0's one seam for running mcpp now runs it through the backend; the parsers are unchanged. + // Where the backend does not run (the declared degraded mode, part 2 §8 F15), mcpp runs here. + useMcppExecutor(async (_executable, args, cwd, options = {}) => { + if (session.degraded) return undefined; + try { + const answer = await session.request<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>("mcpp/exec", { + args, + ...(cwd ? { cwd } : {}), + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + maxOutputBytes: Math.max(1, options.maxBufferMiB ?? 16) * 1024 * 1024, + }); + return { exitCode: answer.timedOut && answer.exitCode === 0 ? 1 : answer.exitCode, stdout: answer.stdout, stderr: answer.stderr }; + } catch (error) { + return { exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } + }); + useMcpplsExecutor(async (args, cwd, options = {}) => { + if (session.degraded) return undefined; + try { + const answer = await session.request<{ exitCode: number; stdout: string; stderr: string; timedOut: boolean }>("languageService/exec", { + args, + ...(cwd ? { cwd } : {}), + ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + maxOutputBytes: Math.max(1, options.maxBufferMiB ?? 16) * 1024 * 1024, + }); + return { exitCode: answer.timedOut && answer.exitCode === 0 ? 1 : answer.exitCode, stdout: answer.stdout, stderr: answer.stderr }; + } catch (error) { + return { exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : String(error) }; + } + }); + extensionContext.subscriptions.push({ dispose: () => { useMcppExecutor(undefined); useMcpplsExecutor(undefined); } }); + const testing = new Testing(session, jobs, ideProjects); + extensionContext.subscriptions.push( + session, + jobs, + new Debugging(session, ideProjects), + testing, + new McppCodeLens(session, testing, (root) => projectMcpp.get(root)), jobs.onDidChangeRunning.event((n) => void vscode.commands.executeCommand("setContext", "mcpp.jobRunning", n > 0)), - // A save builds the file's project when asked to (mcpp.build.onSave); a save while a build - // waits replaces it, and the running one is superseded. - vscode.workspace.onDidSaveTextDocument(async (doc) => { - if (!(vscode.workspace.getConfiguration().get("mcpp.build.onSave") ?? false)) return; - if (!/\.(cppm|cpp|cc|cxx|ixx|mpp|h|hpp|toml|mcpp)$/.test(doc.fileName)) return; - const project = await projects.resolve(); - if (project && doc.fileName.startsWith(project.root)) await tasks.execute("build", project.root); + vscode.tasks.onDidStartTask(() => void updateJobRunning()), + vscode.tasks.onDidEndTask(() => void updateJobRunning()), + vscode.commands.registerCommand(CLI_COMMANDS.debug, runGuarded(async () => { + const project = findCurrentProject(); + const folder = project ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(project.root)) : undefined; + await vscode.debug.startDebugging(folder, { type: "mcpp", request: "launch", name: "mcpp: debug" }); + })), + vscode.commands.registerCommand(CLI_COMMANDS.selectTarget, runGuarded(() => selectTarget(session, ideProjects))), + vscode.commands.registerCommand(CLI_COMMANDS.showTargets, runGuarded(() => selectTarget(session, ideProjects, true))), + vscode.commands.registerCommand(CLI_COMMANDS.pack, runGuarded(async () => { + const project = findCurrentProject(); + if (project === undefined) { + void vscode.window.showWarningMessage(t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); + return; + } + const args = await packArguments(session, project.root); + if (args !== undefined) await cliController.runProjectTask("pack", { args }); + })), + vscode.commands.registerCommand(CLI_COMMANDS.cancelJob, runGuarded(async () => { + for (const execution of vscode.tasks.taskExecutions) { + if (execution.task.definition.type === "mcpp") execution.terminate(); + } + for (const id of jobs.runningIds) await session.request("job/cancel", { jobId: id }); + })), + vscode.commands.registerCommand(CLI_COMMANDS.showCachePanel, () => vscode.commands.executeCommand(CACHE_VIEW_FOCUS_COMMAND)), + // `mcpp.build.onSave`: a save in a project builds it, unless an mcpp task already runs. + vscode.workspace.onDidSaveTextDocument(async (document) => { + if (!read("mcpp.build.onSave", document.uri) || cliController.isBusy()) return; + if (!/\.(cppm|cpp|cc|cxx|ixx|mpp|h|hpp|toml|mcpp)$/.test(document.fileName)) return; + const project = findCurrentProject(); + if (project && document.fileName.startsWith(project.root)) await cliController.runProjectTask("build", { notify: false }); }), ); + function updateJobRunning(): Thenable { + const running = jobs.runningCount > 0 || vscode.tasks.taskExecutions.some((e) => e.task.definition.type === "mcpp"); + return vscode.commands.executeCommand("setContext", "mcpp.jobRunning", running); + } + void session.start(); + + // ── views ──────────────────────────────────────────────────────────────── + // The C++ Modules block of the project view reads mcppls through the bridge; the views themselves + // (the project tree, the libraries, the cache, the settings) are src/pages.ts's, which the + // workbench mcpp-clion runs registers in the same way. + const languageService = registerLanguageServerCommands(extensionContext, { bridge, output, session }); + registerPages(extensionContext, { + output, + mcppExecutable: (project) => cliController.mcppExecutable(project), + languageService, + }); + + // mcpp.toml's and build.mcpp's completion, hover, jumps and diagnostics are the backend's, which + // the language client of `session` connects (design 2026-10-30 part 2 §8): text analysis that runs + // nothing, so they serve a restricted workspace too, as the `limited` capability promises. + + extensionContext.subscriptions.push( + output, + manifestWatcher, + inProjectContext, + ...cliController.register(), + + // The ids from 0.4.x keep working. + vscode.commands.registerCommand(CLI_COMMANDS.autoConfigureModules, runGuarded(async () => { + const project = findCurrentProject(); + if (project === undefined) { + await vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); + return; + } + await autoConfigureModulesWizard(bridge, cliController, output); + })), + vscode.commands.registerCommand(LEGACY_LANGUAGE_SERVER_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { + await cliController.runProjectTask("build"); + })), - if (await session.start()) { - if (vscode.workspace.getConfiguration().get("mcpp.diagnostics.selfCheckOnStartup")) - void vscode.commands.executeCommand("mcpp.selfCheck"); - // The language service is not what it should be: said once, with what to do; activation does not - // wait for an answer. - const ls = session.info?.languageService; - if (ls && ls.state !== "ok") - void vscode.window.showWarningMessage(`mcpp: ${ls.message}`, ...ls.actions.map((a) => a.title)).then((choice) => { - const action = ls.actions.find((a) => a.title === choice); - if (action) void vscode.commands.executeCommand("mcpp.languageServer.act", action.id); + // Environment self-check: one copyable snapshot of everything a bug report needs. + vscode.commands.registerCommand(TOOL_COMMANDS.selfCheck, runGuarded(async () => { + await showSelfCheck(output, cliController, bridge, session, extensionContext.extension.packageJSON.version); + })), + + vscode.workspace.onDidGrantWorkspaceTrust(() => cliController.refreshStatus()), + + // The IDE report (§3.2): what a bug report needs, written where the person chooses. + vscode.commands.registerCommand(TOOL_COMMANDS.exportReport, runGuarded(async () => { + const project = findCurrentProject(); + let report: unknown; + try { + report = await session.request("report/collect", project ? { root: project.root } : {}); + } catch (error) { + report = { kind: "mcpp-ide.report", version: 1, backend: null, error: String(error), degraded: session.degraded }; + } + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const target = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(path.join(project?.root ?? os.homedir(), `mcpp-ide-report-${stamp}.json`)), + filters: { JSON: ["json"] }, }); + if (target === undefined) return; + await vscode.workspace.fs.writeFile(target, Buffer.from(`${JSON.stringify(report, null, 2)}\n`, "utf8")); + void vscode.window.showInformationMessage(t("The IDE report is written to {0}", target.fsPath)); + })), + session.onDidStart.event(() => void refreshProjectMcpp()), + session.onDidRefresh.event(() => { projectMcpp.clear(); void refreshProjectMcpp(); }), + session.on("project/mcppChanged", (n: { root: string }) => { projectMcpp.delete(n.root); void refreshProjectMcpp(); }), + vscode.window.onDidChangeActiveTextEditor(() => void refreshProjectMcpp()), + ); + + void offerSettingRenames(extensionContext); + + if (read("mcpp.diagnostics.selfCheckOnStartup")) { + void showSelfCheck(output, cliController, bridge, session, extensionContext.extension.packageJSON.version); + } + + extensionContext.subscriptions.push( + manifestWatcher.onDidCreate(() => cliController.refreshStatus()), + manifestWatcher.onDidChange(() => cliController.refreshStatus()), + manifestWatcher.onDidDelete(() => cliController.refreshStatus()), + ); + return { session }; +} + +/** + * The 0.4.x keys that were renamed still hold values nothing reads. Offer to move + * them, once per workspace — and leave the old key in place either way, because + * deleting a user's setting is their decision. + */ +async function offerSettingRenames(context: vscode.ExtensionContext): Promise { + const pending = pendingRenames(vscode.window.activeTextEditor?.document.uri); + if (pending.length === 0 || context.workspaceState.get("mcpp.renamesOffered") === true) { + return; + } + + const toUser = t("Move them to my user settings"); + const toWorkspace = t("Move them to this workspace"); + const choice = await vscode.window.showInformationMessage(renamePrompt(pending), toUser, toWorkspace); + if (choice !== toUser && choice !== toWorkspace) { + // Dismissed is not answered: the marker is only written after a choice, so + // the offer comes back next session instead of being lost forever — a + // marker written before the prompt stranded exactly those users (external + // review P2-9). + return; + } + await context.workspaceState.update("mcpp.renamesOffered", true); + const moved = await applyRenames(pending, choice === toUser ? "user" : "workspace"); + void vscode.window.showInformationMessage(t("mcpp: moved {0} setting(s).", moved)); +} + +/** + * The self-check gathers, never guesses: anything it cannot read is reported as + * unknown rather than left blank. It is the first thing to ask for in a bug + * report, and it writes to the `mcpp` channel so it can be copied in one go. + */ +async function showSelfCheck( + output: vscode.OutputChannel, + cliController: McppCliController, + bridge: LanguageServerBridge, + session: Session, + extensionVersion: unknown, +): Promise { + const project = findCurrentProject(); + const executable = cliController.mcppExecutable(project); + const timeoutSeconds = read("mcpp.runtime.timeoutSeconds"); + // The untrusted-workspace promise in the manifest is "no mcpp command runs", + // and `mcpp.path` is resource-scoped, so the probe refuses rather than + // executing a workspace-named program; the report says so in plain words. + const probeResult = await runMcpp(vscode.workspace.isTrusted, executable, ["--protocol-version"], project?.root, { + timeoutMs: timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined, + }); + const info = probeResult !== undefined && probeResult.exitCode === 0 ? parseProtocolInfo(probeResult.stdout) : undefined; + const state = readLanguageServerState(await session.refreshLanguageService()); + const capabilities = CAPABILITIES.map((entry) => ({ + key: entry.key, + state: bridge.isGone(entry.key) ? "missing" : bridge.isUnconfirmed(entry.key) ? "unconfirmed" : "available", + })); + const text = buildSelfCheckText({ + extensionVersion: typeof extensionVersion === "string" ? extensionVersion : "unknown", + vscodeVersion: vscode.version, + platform: `${process.platform}-${process.arch}`, + languagePreference: languagePreference(), + trusted: vscode.workspace.isTrusted, + workspaceRoots: (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath), + projectRoot: project?.root, + mcppPath: executable, + mcppProbe: + info === undefined + ? undefined + : { version: info.mcppVersion, envelopeMax: info.envelopeMax, kinds: Object.keys(info.kinds) }, + mcppls: { + installed: languageServerInstalled(), + version: languageServerVersion(), + enabled: languageServerEnabled(), + state: describeState(state), + capabilities, + }, + changedSettings: changedSettings(project === undefined ? undefined : vscode.Uri.file(project.root)), + lastRefresh: lastRefresh, + cache: await readCacheSnapshot(), + }); + output.appendLine(""); + output.appendLine("===== mcpp: environment self-check ====="); + output.appendLine(text); + if (probeResult === undefined) { + output.appendLine(t("The workspace is not trusted; mcpp itself was not probed.")); } - return { session, projects }; + output.appendLine("========================================"); + output.show(true); } export function deactivate(): void { - // Everything registered in activate() is disposed by VS Code; the session stops the backend. + // VS Code disposes everything registered in activate(). } diff --git a/mide-plugins/vscode/src/graphs/export.ts b/mide-plugins/vscode/src/graphs/export.ts new file mode 100644 index 0000000..4693dc7 --- /dev/null +++ b/mide-plugins/vscode/src/graphs/export.ts @@ -0,0 +1,18 @@ +/** Validate untrusted webview bytes before writing a user-selected destination. */ +export function decodeGraphExport(format: unknown, data: unknown): { extension: string; bytes: Uint8Array } { + if (typeof data !== "string" || data.length > 32 * 1024 * 1024) { throw new Error("Invalid graph export size"); } + if (format === "svg") { + const normalized = data.replace(/"|&#(?:34|x22);/gi, '\"').replace(/'|&#(?:39|x27);/gi, "'"); + if (!/^ 8192 || height > 8192 || width * height > 16_000_000) { throw new Error("PNG export exceeds pixel limit"); } + return { extension: "png", bytes }; + } + throw new Error("Unsupported graph export format"); +} diff --git a/mide-plugins/vscode/src/graphs/model.ts b/mide-plugins/vscode/src/graphs/model.ts new file mode 100644 index 0000000..06d0ef5 --- /dev/null +++ b/mide-plugins/vscode/src/graphs/model.ts @@ -0,0 +1,136 @@ +/** Provider-independent graph. Containment edges belong only to presentation. */ +export interface VisualNode { + id: string; label: string; lines: string[]; + kind: "project" | "manifest" | "index" | "unknown"; + files?: string[]; entry?: boolean; virtual?: boolean; +} +export interface VisualEdge { from: string; to: string; label?: string; dashed?: boolean; containment?: boolean; } +export interface GraphGroup { id: string; label: string; nodes: string[]; } +export interface VisualGraph { nodes: VisualNode[]; edges: VisualEdge[]; roots: string[]; groups?: GraphGroup[]; truncated?: boolean; } +export interface GraphExpansion { + depth: number; expanded: ReadonlySet; collapsed: ReadonlySet; focus?: string; + upstreamDepth?: number; localDepth?: ReadonlyMap; + upstreamLocalDepth?: ReadonlyMap; upstreamCollapsed?: ReadonlySet; +} +export interface VisibleGraph extends VisualGraph { + hidden: Map; hiddenParents: Map; remainingDepth: Map; + upstreamRemaining: Map; limited: boolean; +} +export const GRAPH_BUDGET = { nodes: 150, edges: 300 } as const; +export const INITIAL_GRAPH_BUDGET = { nodes: 60, edges: 80 } as const; + +export function graphRoots(nodes: readonly VisualNode[], edges: readonly VisualEdge[], preferred: string[] = []): string[] { + const adj = new Map(), incoming = new Set(edges.map(edge => edge.to)); + for (const edge of edges) { const list = adj.get(edge.from) ?? []; list.push(edge.to); adj.set(edge.from, list); } + const roots: string[] = [], seen = new Set(), known = new Set(nodes.map(node => node.id)); + const visit = (id: string): void => { + if (!known.has(id) || seen.has(id)) { return; } + roots.push(id); const stack = [id]; + while (stack.length) { + const next = stack.pop()!; if (seen.has(next)) { continue; } seen.add(next); + for (const child of adj.get(next) ?? []) { stack.push(child); } + } + }; + for (const id of preferred) { visit(id); } + for (const node of nodes) { if (!incoming.has(node.id)) { visit(node.id); } } + for (const node of nodes) { visit(node.id); } + return roots; +} + +/** Cached per immutable provider snapshot; camera/selection never rebuild these indexes. */ +const indexes = new WeakMap; out: Map; incoming: Map }>(); +function graphIndex(graph: VisualGraph) { + let index = indexes.get(graph); + if (!index) { + index = { nodes: new Map(graph.nodes.map(node => [node.id, node])), out: new Map(), incoming: new Map() }; + for (const edge of graph.edges) { + const out = index.out.get(edge.from) ?? []; out.push(edge); index.out.set(edge.from, out); + const incoming = index.incoming.get(edge.to) ?? []; incoming.push(edge); index.incoming.set(edge.to, incoming); + } + indexes.set(graph, index); + } + return index; +} + +/** Nodes enter with their connecting edge, so an edge budget cannot strand new nodes. */ +export function visibleGraph(graph: VisualGraph, state: GraphExpansion, budget: { nodes: number; edges: number } = GRAPH_BUDGET): VisibleGraph { + const index = graphIndex(graph), seen = new Set(), accepted = new Set(); + const remainingDepth = new Map(), upstreamRemaining = new Map(); + let limited = false; + const addRoot = (id: string): boolean => { + if (!index.nodes.has(id)) { return false; } + if (!seen.has(id) && seen.size >= budget.nodes) { limited = true; return false; } + seen.add(id); return true; + }; + const walk = (seeds: string[], upstream: boolean, base: number, nodeLimit: number, edgeLimit = budget.edges): void => { + const remaining = upstream ? upstreamRemaining : remainingDepth; + const local = upstream ? state.upstreamLocalDepth : state.localDepth; + const collapsed = upstream ? state.upstreamCollapsed : state.collapsed; + const queue: Array<[string, number]> = seeds.map(id => [id, base]); + for (let cursor = 0; cursor < queue.length; cursor++) { + const [id, depth] = queue[cursor]; + let effective = Math.max(depth, local?.get(id) ?? 0); + if (!upstream && state.expanded.has(id)) { effective = Math.max(effective, 1); } + if ((remaining.get(id) ?? -1) >= effective) { continue; } + remaining.set(id, effective); + if (collapsed?.has(id)) { continue; } + for (const edge of (upstream ? index.incoming : index.out).get(id) ?? []) { + if (upstream && edge.containment) { continue; } + if (effective <= 0 && !edge.containment) { continue; } + const next = upstream ? edge.from : edge.to; + if (!index.nodes.has(next)) { continue; } + if ((!accepted.has(edge) && accepted.size >= edgeLimit) || (!seen.has(next) && seen.size >= nodeLimit)) { limited = true; continue; } + accepted.add(edge); seen.add(next); + queue.push([next, edge.containment ? effective : Math.max(0, effective - 1)]); + } + } + }; + const focused = state.focus && index.nodes.has(state.focus) ? state.focus : undefined; + if (focused) { + addRoot(focused); + // Reserve parent context, but never let a high fan-in consume the whole view. + const parentBudget = Math.min(20, Math.max(0, Math.floor((budget.nodes - 1) / 3))); + walk([focused], true, state.upstreamDepth ?? 1, 1 + parentBudget, Math.floor(budget.edges / 3)); + walk([focused], false, state.depth, budget.nodes); + // Explicit local exploration also works on nodes first reached from the other direction. + for (const id of state.localDepth?.keys() ?? []) { if (seen.has(id)) { walk([id], false, 0, budget.nodes); } } + for (const id of state.upstreamLocalDepth?.keys() ?? []) { if (seen.has(id)) { walk([id], true, 0, budget.nodes); } } + } else { + // Process a component before admitting more roots; many tests cannot starve every edge. + if (graph.roots.every(id => index.nodes.get(id)?.virtual)) { for (const root of graph.roots) { addRoot(root); } } + for (const root of graph.roots) { if (addRoot(root)) { walk([root], false, state.depth, budget.nodes); } } + } + const hidden = new Map(), hiddenParents = new Map(); + for (const id of seen) { + const children = new Set((index.out.get(id) ?? []).filter(edge => !accepted.has(edge)).map(edge => edge.to)); + const parents = new Set((index.incoming.get(id) ?? []).filter(edge => !edge.containment && !accepted.has(edge)).map(edge => edge.from)); + if (children.size) { hidden.set(id, children.size); } + if (parents.size) { hiddenParents.set(id, parents.size); } + } + return { nodes: graph.nodes.filter(node => seen.has(node.id)), edges: graph.edges.filter(edge => accepted.has(edge)), roots: focused ? [focused] : graph.roots, + truncated: graph.truncated, hidden, hiddenParents, remainingDepth, upstreamRemaining, limited }; +} + +/** Presentation groups page entries without consuming import depth. */ +export function groupedGraph(graph: VisualGraph, activeGroup?: string, offset = 0): VisualGraph { + if (!graph.groups?.length) { return graph; } + const groups = graph.groups; + const nodes: VisualNode[] = groups.map(group => ({ id: group.id, label: group.label, lines: [String(group.nodes.length)], kind: "manifest", virtual: true })); + const edges: VisualEdge[] = []; + for (const group of groups) { + if (activeGroup ? group.id !== activeGroup : group.id !== groups[0].id) { continue; } + for (const id of group.nodes.slice(offset, offset + 20)) { edges.push({ from: group.id, to: id, dashed: true, containment: true }); } + } + return { ...graph, nodes: [...nodes, ...graph.nodes], edges: [...edges, ...graph.edges], roots: groups.map(group => group.id) }; +} + +export function mermaidGraph(graph: VisibleGraph): { source: string; nodeIds: string[] } { + const nodeIds = graph.nodes.map(node => node.id), ids = new Map(nodeIds.map((id, index) => [id, `n${index}`])); + const label = (value: string): string => value.replace(/[\r\n]/g, " ").replace(/[&"<>[\]{}|`\\#]/g, char => `#${char.charCodeAt(0)};`); + const source = ["flowchart LR", ...graph.nodes.map(node => { + const hidden = graph.hidden.get(node.id); + const text = [node.label, ...node.lines, ...(hidden ? [`+${hidden}`] : [])].map(label).join("
    "); + return ` ${ids.get(node.id)}["${text}"]:::${node.kind}`; + }), ...graph.edges.map(edge => ` ${ids.get(edge.from)} ${edge.dashed ? "-.-" : "--"}>${edge.label ? `|"${label(edge.label)}"|` : ""} ${ids.get(edge.to)}`), " classDef unknown stroke-dasharray: 4 3"].join("\n"); + return { source, nodeIds }; +} diff --git a/mide-plugins/vscode/src/graphs/panelHtml.ts b/mide-plugins/vscode/src/graphs/panelHtml.ts new file mode 100644 index 0000000..26e1628 --- /dev/null +++ b/mide-plugins/vscode/src/graphs/panelHtml.ts @@ -0,0 +1,52 @@ +import { t } from "../i18n/t"; +import { escapeHtml } from "../util/text"; +import type { WebviewAssets } from "../webview/document"; + +export function graphPanelHtml(assets: WebviewAssets, renderer: string, script: string): string { + const labels = { + copied: t("Copied"), error: t("Could not render the graph. Copy Mermaid source or refresh to retry."), + loading: t("Reading project dependencies…"), empty: t("No module relationships were found for this project."), + limited: t("View limit reached. Focus a node to explore its dependencies."), + partial: t("The source graph is incomplete. Some modules may be omitted."), + exportError: t("Could not export this graph. Try SVG or a smaller view."), focus: t("Focus"), + group: t("Entry group"), selectedPrefix: t("Selected"), + expand: t("Expand next level"), selected: t("Selected node"), + }; + const button = (action: string, label: string): string => ``; + return ` + + +

    ${escapeHtml(t("Dependency graph"))}

    +
    +
    + + + +
    ${escapeHtml(t("View options"))}
    + + + ${button("locateFocus", t("Locate focus"))} +
    +
    +
    ${escapeHtml(t("Select a node to explore its relationships."))}
    +
    ${button("focus", t("Focus"))}${button("locate", t("Locate selected"))} +
    +
    +
    + + +
    ${escapeHtml(t("Legend and source"))}
    ${escapeHtml(t("Project"))}path / partitionindex / external${escapeHtml(t("Unavailable"))}

    ${escapeHtml(t("Packages show declarations; modules show mcppls imports. External modules stop after one hop."))}

    ${button("manifest", t("Open mcpp.toml"))}
    + + `; +} diff --git a/mide-plugins/vscode/src/graphs/refresh.ts b/mide-plugins/vscode/src/graphs/refresh.ts new file mode 100644 index 0000000..ce8e100 --- /dev/null +++ b/mide-plugins/vscode/src/graphs/refresh.ts @@ -0,0 +1,44 @@ +/** Event bursts coalesce into one cancellable query and at most one pending rerun. */ +export class RefreshCoordinator { + private timer?: ReturnType; + private started = 0; + private dirty = false; + private flight?: Promise; + private controller?: AbortController; + private disposed = false; + public visible = true; + public constructor(private readonly refresh: (signal: AbortSignal) => Promise, private readonly delay = 200, private readonly maxWait = 1000) {} + public request(immediate = false): Promise { + if (this.disposed) { return Promise.resolve(); } + this.dirty = true; + if (!this.visible) { return Promise.resolve(); } + if (this.flight) { return this.flight; } + if (!this.started) { this.started = Date.now(); } + if (this.timer) { clearTimeout(this.timer); } + if (immediate) { return this.run(); } + this.timer = setTimeout(() => { void this.run(); }, Math.min(this.delay, Math.max(0, this.maxWait - (Date.now() - this.started)))); + return Promise.resolve(); + } + public setVisible(visible: boolean): void { + this.visible = visible; + if (!visible && this.timer) { clearTimeout(this.timer); this.timer = undefined; } + if (visible && this.dirty) { void this.request(true); } + } + private run(): Promise { + if (this.flight || this.disposed || !this.visible) { return this.flight ?? Promise.resolve(); } + if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } + this.started = 0; this.dirty = false; + const controller = new AbortController(); this.controller = controller; + this.flight = Promise.resolve().then(() => this.refresh(controller.signal)).finally(() => { + this.flight = undefined; + if (this.controller === controller) { this.controller = undefined; } + if (this.dirty && !this.disposed && this.visible) { void this.request(); } + }); + return this.flight; + } + public cancel(): void { + this.controller?.abort(); this.dirty = false; this.started = 0; + if (this.timer) { clearTimeout(this.timer); this.timer = undefined; } + } + public dispose(): void { this.disposed = true; this.cancel(); } +} diff --git a/mide-plugins/vscode/src/i18n/t.ts b/mide-plugins/vscode/src/i18n/t.ts new file mode 100644 index 0000000..f36c3af --- /dev/null +++ b/mide-plugins/vscode/src/i18n/t.ts @@ -0,0 +1,81 @@ +/** + * Runtime strings, in one place, following the editor's language by default. + * + * The **English text is the key** (the same convention `vscode.l10n` uses), so a + * missing translation degrades to readable English rather than to an identifier. + * `data/i18n/zh-cn.json` carries the translations; `tools/generate-l10n.mjs` + * turns it into `l10n/bundle.l10n.zh-cn.json`, which is what makes `auto` work + * inside VS Code. + * + * `mcpp.ui.language` overrides the choice for **our** strings — runtime messages + * and our webviews. It cannot change the command palette or the Settings UI: + * VS Code resolves `package.nls.*` once at startup from its own locale. That + * limitation is documented in `docs/settings.md` and stated in the panel. + */ + +import type * as vscode from "vscode"; + +import zhCn from "../../data/i18n/zh-cn.json"; +import { format, translate, type Bundle, type LanguagePreference } from "./translate"; + +export type { LanguagePreference } from "./translate"; + +const BUNDLES: Readonly> = { + en: {}, + "zh-cn": zhCn as Bundle, +}; + +let preference: LanguagePreference = "auto"; + +/** The effective preference, for the environment self-check and the panel. */ +export function languagePreference(): LanguagePreference { + return preference; +} + +export function setLanguagePreference(value: LanguagePreference | undefined): void { + preference = value === "en" || value === "zh-cn" ? value : "auto"; +} + +export type Substitution = string | number | boolean; + +/** + * The editor's own `l10n`, loaded on demand. + * + * `t()` needs it only on the `auto` path, so requiring it lazily keeps this + * module importable from **pure** modules: the TOML completion tables are unit + * tested without an editor, and importing them must not fail on a missing + * `vscode`. Outside an editor host there is no locale to consult, and the + * English text — which is the key — is the correct answer: it is exactly what a + * missing translation degrades to. + */ +function editorL10n(): typeof vscode.l10n | undefined { + try { + return (require("vscode") as typeof vscode).l10n; + } catch { + return undefined; + } +} + +/** + * Resolve one runtime string. + * + * - `auto` asks VS Code, which consults `l10n/bundle.l10n..json` and + * falls back to the English text we passed in; + * - a manual preference reads our own bundle, so the escape hatch works even + * when the editor is in a third language. + */ +export function t(english: string, ...args: readonly Substitution[]): string { + if (preference === "auto") { + const l10n = editorL10n(); + return l10n === undefined ? format(english, args) : l10n.t(english, ...args); + } + return translate(preference, english, args, BUNDLES[preference]); +} + +/** Substitute into an already-translated template (webviews, generated HTML). */ +export { format }; + +/** The bundle this build ships, for `tools/l10n-check.mjs`. */ +export function translationBundle(): Bundle { + return BUNDLES["zh-cn"]; +} diff --git a/mide-plugins/vscode/src/i18n/translate.ts b/mide-plugins/vscode/src/i18n/translate.ts new file mode 100644 index 0000000..122b706 --- /dev/null +++ b/mide-plugins/vscode/src/i18n/translate.ts @@ -0,0 +1,55 @@ +/** + * The language-independent half of `src/i18n/t.ts`. Pure: no `vscode`, so the + * resolution rules are unit-testable. + * + * English text is the key; a translation bundle maps it to the target language. + * A missing entry returns the English text, which is why a forgotten translation + * reads as English rather than as an identifier. + */ + +export type LanguagePreference = "auto" | "en" | "zh-cn"; +export type Bundle = Readonly>; + +/** `{0}`-style substitution, the same placeholder syntax `vscode.l10n` uses. */ +export function format(template: string, args: readonly unknown[]): string { + return template.replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number.parseInt(index, 10)]; + return value === undefined ? whole : String(value); + }); +} + +/** + * Resolve one string for an explicit preference. `auto` is not handled here: + * that path belongs to VS Code, which owns the editor's locale. + */ +export function translate( + preference: Exclude, + english: string, + args: readonly unknown[], + bundle: Bundle, +): string { + if (preference === "en") { + return format(english, args); + } + const translated = bundle[english]; + return format(typeof translated === "string" && translated.length > 0 ? translated : english, args); +} + +/** `"zh-cn"` for any Chinese locale VS Code may report; `undefined` otherwise. */ +export function localeFromEditorLanguage(language: string | undefined): "en" | "zh-cn" | undefined { + if (language === undefined) { + return undefined; + } + return language.toLowerCase().startsWith("zh") ? "zh-cn" : "en"; +} + +/** Missing entries for a set of used strings, for `tools/l10n-check.mjs`. */ +export function missingTranslations(used: Iterable, bundle: Bundle): string[] { + const missing: string[] = []; + for (const key of used) { + if (typeof bundle[key] !== "string" || bundle[key].length === 0) { + missing.push(key); + } + } + return missing; +} diff --git a/mide-plugins/vscode/src/backend/locate.ts b/mide-plugins/vscode/src/ide/backend/locate.ts similarity index 82% rename from mide-plugins/vscode/src/backend/locate.ts rename to mide-plugins/vscode/src/ide/backend/locate.ts index 9638e12..3fd0cf2 100644 --- a/mide-plugins/vscode/src/backend/locate.ts +++ b/mide-plugins/vscode/src/ide/backend/locate.ts @@ -91,3 +91,21 @@ export function locateBackend(options: { const found = onPath("mcpp-ide", env, platform); return found ? { program: found, args: [], source: "PATH" } : undefined; } + +/** + * The extension carries the backend, but okrun runs on neither this system nor PATH (macOS x86_64, + * Windows arm64, Linux armhf): the backend does not run here, and the extension goes on in its + * declared degraded mode (design 2026-10-30 part 2 §8, F15). + */ +export function backendUnavailableHere(options: { + extensionRoot: string; + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; + arch?: string; +}): boolean { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + if (!isFile(path.join(options.extensionRoot, "backend", "mcpp-ide.okb"))) return false; + if (isFile(path.join(options.extensionRoot, "backend", "okrun", okrunDir(platform, arch), executable("okrun", platform)))) return false; + return onPath("okrun", options.env ?? process.env, platform) === undefined; +} diff --git a/mide-plugins/vscode/src/backend/mcppls.ts b/mide-plugins/vscode/src/ide/backend/mcppls.ts similarity index 100% rename from mide-plugins/vscode/src/backend/mcppls.ts rename to mide-plugins/vscode/src/ide/backend/mcppls.ts diff --git a/mide-plugins/vscode/src/ide/codeLens.ts b/mide-plugins/vscode/src/ide/codeLens.ts new file mode 100644 index 0000000..ecc303c --- /dev/null +++ b/mide-plugins/vscode/src/ide/codeLens.ts @@ -0,0 +1,88 @@ +/** + * Run and Debug above `main` (design 2026-10-30 part 2 §6.1, §6.3): the backend says what the file + * is to its project (`source/entries`: a program's `main`, a test's, or neither); the position is + * the language service's, from the document symbols of the server answering the file (C++ Modules), + * and only without one the line the backend read from the text, which the lens's tooltip calls + * approximate. `mcpp.codeLens.enable` turns the lenses off. + * + * 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`). + */ +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { mainLineOf } from "./mapping"; +import type { ProjectMcpp, SourceEntries } from "./protocol"; +import type { Session } from "./session"; +import type { Testing } from "./testing"; + +export const CODELENS_COMMANDS = { + runTest: "mcpp.codeLens.runTest", + debugTest: "mcpp.codeLens.debugTest", +} as const; + +export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + readonly onDidChangeCodeLenses = this.changed.event; + private readonly disposables: vscode.Disposable[] = [this.changed]; + + constructor( + private readonly session: Session, + private readonly testing: Testing, + private readonly projectMcpp: (root: string) => ProjectMcpp | undefined, + ) { + this.disposables.push( + vscode.languages.registerCodeLensProvider([{ language: "cpp", scheme: "file" }], this), + session.onDidStart.event(() => this.changed.fire()), + vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("mcpp.codeLens")) this.changed.fire(); }), + vscode.commands.registerCommand(CODELENS_COMMANDS.runTest, async (root: string, test: string) => { + if (!this.testing.item(root, test)) await this.testing.discover(); + const item = this.testing.item(root, test); + if (item) await this.testing.runItem(item); + }), + vscode.commands.registerCommand(CODELENS_COMMANDS.debugTest, async (root: string, test: string) => { + const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)); + await vscode.debug.startDebugging(folder, { type: "mcpp", request: "launch", name: `mcpp: debug ${test}`, root, test }); + }), + ); + } + + async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + if (read("mcpp.codeLens.enable") === false || !this.session.running) return []; + let entries: SourceEntries; + try { + entries = await this.session.request("source/entries", { file: document.uri.fsPath, text: document.getText() }); + } catch { + return []; + } + if (token.isCancellationRequested || entries.kind === "none" || entries.root === undefined) return []; + let line: number | undefined; + try { + const symbols = await vscode.commands.executeCommand("vscode.executeDocumentSymbolProvider", document.uri); + line = mainLineOf(symbols); + } catch { + line = undefined; + } + const approximate = line === undefined; + line ??= entries.main?.line; + if (line === undefined) return []; + 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; + 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 }), + ]; + const test = entries.test ?? ""; + const lenses = [new vscode.CodeLens(range, { title: t("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 })); + return lenses; + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/mide-plugins/vscode/src/debug.ts b/mide-plugins/vscode/src/ide/debug.ts similarity index 58% rename from mide-plugins/vscode/src/debug.ts rename to mide-plugins/vscode/src/ide/debug.ts index f67f355..ee99761 100644 --- a/mide-plugins/vscode/src/debug.ts +++ b/mide-plugins/vscode/src/ide/debug.ts @@ -1,17 +1,20 @@ -// Debugging: a launch configuration of type "mcpp" names what to debug (target, profile, arguments); -// the backend builds the program and answers the configuration of the debugger at hand (CodeLLDB, -// the C/C++ extension, lldb-dap or Native Debug), which this provider returns in its place. A -// program that runs through a runner (an ok image under okrun) is debugged as the runner. +/** + * Debugging: a launch configuration of type "mcpp" names what to debug (target, profile, + * arguments); the backend builds the program and answers the configuration of the debugger at hand + * (CodeLLDB, the C/C++ extension, lldb-dap or Native Debug), which this provider returns in its + * place. A program that runs through a runner (an ok image under okrun) is debugged as the runner. + */ import * as vscode from "vscode"; +import { t } from "../i18n/t"; import { availableDebuggers } from "./mapping"; -import type { Projects } from "./projects"; +import type { IdeProjects } from "./projects"; 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: Projects) { + constructor(private readonly session: Session, private readonly projects: IdeProjects) { this.disposables.push( vscode.debug.registerDebugConfigurationProvider("mcpp", this), vscode.debug.registerDebugConfigurationProvider("mcpp", { provideDebugConfigurations: () => [{ type: "mcpp", request: "launch", name: "mcpp: debug" }] }, @@ -21,21 +24,28 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp async resolveDebugConfiguration(folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration): Promise { if (!config.type) Object.assign(config, { type: "mcpp", request: "launch", name: "mcpp: debug" }); - const project = (config.root && { root: config.root as string }) || (await this.projects.require()); - if (!project) return undefined; + const project = (config.root && { root: config.root as string }) || this.projects.current(); + if (!project) { + void vscode.window.showWarningMessage(t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); + return undefined; + } const available = availableDebuggers((id) => vscode.extensions.getExtension(id) !== undefined); if (available.length === 0) { - const install = this.session.t("Install CodeLLDB"); - const choice = await vscode.window.showErrorMessage( - this.session.t("No debugger extension is installed: install CodeLLDB, the C/C++ extension or lldb-dap."), install); - if (choice === install) await vscode.commands.executeCommand("workbench.extensions.search", "@id:vadimcn.vscode-lldb"); + const install = t("Install CodeLLDB"); + // The session is refused now; the message's action is the user's, whenever they take it. + void vscode.window.showErrorMessage( + t("No debugger extension is installed: install CodeLLDB, the C/C++ extension or lldb-dap."), install) + .then((choice) => choice === install ? vscode.commands.executeCommand("workbench.extensions.search", "@id:vadimcn.vscode-lldb") : undefined); return undefined; } try { const answer = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Window, title: this.session.t("mcpp: building for the debugger…") }, + { location: vscode.ProgressLocation.Window, title: t("mcpp: building for the debugger…") }, () => 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 } : {}), + ...(config.framework ? { framework: config.framework, cases: Array.isArray(config.cases) ? config.cases : [] } : {}), ...(config.target !== undefined ? { target: config.target } : {}), ...(config.profile ? { profile: config.profile } : {}), ...(config.engine ? { engine: config.engine } : {}), @@ -49,14 +59,15 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp } catch (e) { const detail = (e as { error?: { data?: { narration?: string } } }).error?.data?.narration; if (detail) this.session.output.appendLine(detail); - void vscode.window.showErrorMessage(this.session.t("mcpp: {0}", e instanceof Error ? e.message : String(e))); + void vscode.window.showErrorMessage(t("mcpp: {0}", e instanceof Error ? e.message : String(e))); return undefined; } } /** `mcpp.debug`: the same, without a launch.json. */ async start(): Promise { - const folder = this.projects.project ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(this.projects.project.root)) : undefined; + const project = this.projects.current(); + const folder = project ? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(project.root)) : undefined; await vscode.debug.startDebugging(folder, { type: "mcpp", request: "launch", name: "mcpp: debug" }); } diff --git a/mide-plugins/vscode/src/ide/flows.ts b/mide-plugins/vscode/src/ide/flows.ts new file mode 100644 index 0000000..3e3949d --- /dev/null +++ b/mide-plugins/vscode/src/ide/flows.ts @@ -0,0 +1,85 @@ +/** + * The flows the backend answers and 0.9.0 did not have, in 0.9.0's form (quick picks and task + * terminals): choosing what the build tasks build for, among the targets and the target sets the + * installed mcpp knows; packing, with the formats, modes and linkages mcpp lists for the project. + */ +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import type { IdeProjects } from "./projects"; +import type { TargetRow } from "./protocol"; +import type { Session } from "./session"; + +interface TargetPick extends vscode.QuickPickItem { + row: TargetRow & { set?: boolean; members?: string[]; label?: string }; +} + +/** `mcpp.selectTarget`: writes `mcpp.build.target` for the workspace; this machine clears it. */ +export async function selectTarget(session: Session, projects: IdeProjects, readOnly = false): Promise { + const root = projects.current()?.root; + const r = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("mcpp: reading the targets…") }, + () => session.request<{ host: string; rows: TargetPick["row"][] }>("targets/list", { root }), + ); + const picks: TargetPick[] = r.rows.map((row) => ({ + label: `${row.selected ? "$(check) " : ""}${row.set ? row.label ?? row.target : row.target}`, + description: [row.host ? t("this machine") : "", row.set ? t("every architecture of the platform") : "", row.platformText] + .filter(Boolean).join(" · "), + detail: row.set ? undefined : row.toolchains.map((tc) => `${tc.spec || "—"} (${tc.status})`).join(", ") || undefined, + row, + })); + const pick = await vscode.window.showQuickPick(picks, { + title: readOnly ? t("The targets mcpp builds for") : t("Select the build target"), + placeHolder: readOnly ? undefined : t("build, run, test and debug use it"), + }); + if (!pick || readOnly) return; + await vscode.workspace.getConfiguration().update("mcpp.build.target", pick.row.host ? "" : pick.row.target, + vscode.ConfigurationTarget.Workspace); + if (!pick.row.set && !pick.row.installed && pick.row.pin) { + const install = t("Install"); + if ((await vscode.window.showInformationMessage(t("No toolchain is installed for {0}.", pick.row.target), install)) === install) { + await vscode.commands.executeCommand("mcpp.installToolchain", pick.row.pin); + } + } +} + +interface PackFormats { + formats?: { name: string; builtin?: boolean; providers?: string[] }[]; + modes?: string[]; + linkages?: string[]; +} + +/** + * `mcpp.pack`: three quick picks (format, mode, linkage) over what mcpp lists for the project, the + * pack's target taken from `mcpp.build.target`; the arguments for the pack task, or undefined when + * the user left a pick. + */ +export async function packArguments(session: Session, root: string): Promise { + const r = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("mcpp: reading the package formats…") }, + () => session.request("pack/formats", { root }), + ); + const projectDefault = t("the project's default"); + const step = async (title: string, values: readonly { label: string; description?: string; value: string }[]) => + vscode.window.showQuickPick([{ label: projectDefault, value: "" }, ...values], { title }); + const format = await step(t("mcpp: Pack (1/3): the package format"), + (r.formats ?? []).map((f) => ({ label: f.name, description: f.builtin ? undefined : (f.providers ?? []).join(", "), value: f.name }))); + if (!format) return undefined; + const mode = await step(t("mcpp: Pack (2/3): what the machine that runs it provides"), + (r.modes ?? []).map((m) => ({ label: m, value: m }))); + if (!mode) return undefined; + let linkage: { value: string } | undefined = { value: "" }; + if ((r.linkages ?? []).length > 0) { + linkage = await step(t("mcpp: Pack (3/3): the libraries inside the program or beside it"), + (r.linkages ?? []).map((l) => ({ label: l, value: l }))); + if (!linkage) return undefined; + } + const args: string[] = []; + if (format.value) args.push("--pkg-format", format.value); + if (mode.value) args.push("--mode", mode.value); + if (linkage.value) args.push("--linkage", linkage.value); + const target = (read("mcpp.build.target", vscode.Uri.file(root)) ?? "").trim(); + if (target) args.push("--target", target); + return args; +} diff --git a/mide-plugins/vscode/src/jobs.ts b/mide-plugins/vscode/src/ide/jobs.ts similarity index 94% rename from mide-plugins/vscode/src/jobs.ts rename to mide-plugins/vscode/src/ide/jobs.ts index f90923d..bd92ccc 100644 --- a/mide-plugins/vscode/src/jobs.ts +++ b/mide-plugins/vscode/src/ide/jobs.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import type { JobFinished } from "./protocol"; import type { Session } from "./session"; -const JOB_EVENTS = ["job/started", "job/output", "job/plan", "job/progress", "job/diagnostic", "job/artifact", "job/record", "job/finished", "test/result", "test/summary"]; +const JOB_EVENTS = ["job/started", "job/output", "job/plan", "job/progress", "job/diagnostic", "job/artifact", "job/record", "job/finished", "test/result", "test/summary", "test/caseResult"]; export interface JobEvents { output?(text: string): void; @@ -15,6 +15,8 @@ export interface JobEvents { progress?(p: { finished: number; total: number }): void; testResult?(p: any): void; testSummary?(p: any): void; + /** A case's outcome (test/runCases), sent before the job's end. */ + caseResult?(p: any): void; finished?(p: JobFinished): void; } @@ -80,6 +82,7 @@ export class Jobs implements vscode.Disposable { case "job/progress": e.progress?.(params); break; case "test/result": e.testResult?.(params); break; case "test/summary": e.testSummary?.(params); break; + case "test/caseResult": e.caseResult?.(params); break; case "job/finished": e.finished?.(params); owner.handle.finish(params); diff --git a/mide-plugins/vscode/src/mapping.ts b/mide-plugins/vscode/src/ide/mapping.ts similarity index 57% rename from mide-plugins/vscode/src/mapping.ts rename to mide-plugins/vscode/src/ide/mapping.ts index 3ed2b08..054d0ec 100644 --- a/mide-plugins/vscode/src/mapping.ts +++ b/mide-plugins/vscode/src/ide/mapping.ts @@ -24,7 +24,41 @@ export function testId(root: string, member: string, test: string): string { return `${root}::${member}::${test}`; } +/** What separates a case's name from its program's id: a name is the framework's, and may hold "::". */ +const CASE = "\u001f"; + +/** A case of a test program (design 2026-10-30 part 2 §7): its program's id, then its full name. */ +export function caseId(root: string, member: string, test: string, name: string): string { + return `${testId(root, member, test)}${CASE}${name}`; +} + +export function parseCaseId(id: string): { root: string; member: string; test: string; name: string } | undefined { + const at = id.indexOf(CASE); + if (at < 0) return undefined; + const program = parseTestId(id.slice(0, at)); + return program === undefined ? undefined : { ...program, name: id.slice(at + 1) }; +} + +/** A case result's name as its source names it: GoogleTest's instance prefix and parameter dropped. */ +export function baseCaseName(framework: string | null, name: string): string { + if (framework !== "gtest") return name; + const dot = name.indexOf("."); + if (dot < 0) return name; + const suiteParts = name.slice(0, dot).split("/").filter((part) => part !== "" && !/^\d+$/.test(part)); + const test = name.slice(dot + 1).split("/")[0]; + return `${suiteParts[suiteParts.length - 1] ?? ""}.${test}`; +} + +/** VS Code's SymbolKind.Function (LSP numbers it 12). */ +const FUNCTION_SYMBOL = 11; + +/** The line of `main` among a document's symbols: a function named main at the top level. */ +export function mainLineOf(symbols: readonly { name: string; kind: number; range: { start: { line: number } } }[] | undefined): number | undefined { + return symbols?.find((s) => s.kind === FUNCTION_SYMBOL && (s.name === "main" || s.name.startsWith("main(")))?.range.start.line; +} + export function parseTestId(id: string): { root: string; member: string; test: string } | undefined { + if (id.includes(CASE)) return undefined; const parts = id.split("::"); if (parts.length < 3) return undefined; const test = parts.pop()!; diff --git a/mide-plugins/vscode/src/ide/projects.ts b/mide-plugins/vscode/src/ide/projects.ts new file mode 100644 index 0000000..b792d1d --- /dev/null +++ b/mide-plugins/vscode/src/ide/projects.ts @@ -0,0 +1,16 @@ +/** + * The projects the backend-driven flows (debugging, testing, packing) act on, as the extension + * already knows them: the current project is 0.9's discovery (`findNearestMcppProject` from the + * active editor), the window's projects are the backend's answer at `initialize`. + */ +export interface IdeProject { + root: string; + name?: string; +} + +export interface IdeProjects { + /** The project of the active editor, else of the first workspace folder. */ + current(): IdeProject | undefined; + /** Every project of the window's folders. */ + all(): IdeProject[]; +} diff --git a/mide-plugins/vscode/src/protocol.ts b/mide-plugins/vscode/src/ide/protocol.ts similarity index 65% rename from mide-plugins/vscode/src/protocol.ts rename to mide-plugins/vscode/src/ide/protocol.ts index 167bfaf..c6ab7f9 100644 --- a/mide-plugins/vscode/src/protocol.ts +++ b/mide-plugins/vscode/src/ide/protocol.ts @@ -14,6 +14,17 @@ export interface Check { message: string; } +/** One of mcppls's actions under each editor's own id (mcppls S3 section 8). */ +export interface ServerAction { + id: string; + title: string; + vscode?: string; + vscodeArguments?: unknown[]; + jetbrains?: string; + nvim?: string; + command?: string; +} + export interface LanguageServiceStatus { state: "ok" | "outdated" | "missing" | "unreadable"; program: string; @@ -22,6 +33,27 @@ export interface LanguageServiceStatus { required: string; message: string; actions: { id: "install" | "update"; title: string }[]; + /** What mcppls says about the workspace: `mcppls status` (0.0.20 and later) or the frozen table. */ + root?: string; + statusSource?: "status" | "frozen" | "none"; + statusError?: string; + capabilities?: string[]; + serverActions?: ServerAction[]; + workspace?: Record | null; + instances?: unknown[]; +} + +/** `project/mcpp` (design 2026-10-30 part 2 §4.4): the project's mcpp, asked in the project. */ +export interface ProjectMcpp { + root: string; + state: "ok" | "pinned-missing" | "failed" | "not-probed"; + version: string; + program: string; + features: string[]; + featuresFrozen: boolean; + fromSetting: boolean; + message?: string; + install?: string; } export interface InitializeResult { @@ -138,6 +170,38 @@ export interface JobFinished { artifacts?: { kind: string; name: string; path: string }[]; } +/** `source/entries` (design 2026-10-30 part 2 §6.3): what a C++ file is to its project. */ +export interface SourceEntries { + file: string; + root?: string; + kind: "program" | "test" | "none"; + test?: string; + framework?: string; + main?: { line: number; approximate?: boolean }; +} + +/** `test/cases` (§7.3): a test program's cases, from its source and, once listed, from the program. */ +export interface TestCases { + root: string; + test: string; + file: string; + framework: string | null; + listed?: boolean; + main?: { line: number; approximate?: boolean }; + cases: { name: string; line?: number; source: "static" | "listed" }[]; +} + +/** `test/caseResult`: one case's outcome, read from the framework's report. */ +export interface CaseResult { + jobId: string; + root: string; + test: string; + case: string; + status: "passed" | "failed" | "skipped"; + durationMs: number; + message: string; +} + export interface TestResult { jobId: string; root: string; diff --git a/mide-plugins/vscode/src/ide/session.ts b/mide-plugins/vscode/src/ide/session.ts new file mode 100644 index 0000000..bf142e5 --- /dev/null +++ b/mide-plugins/vscode/src/ide/session.ts @@ -0,0 +1,336 @@ +/** + * The session with the mcpp-ide backend (`mcpp-ide serve`): started at activation, given the + * editor's settings, trust, language and folders, kept current as they change, and restarted (once, + * then on request) when it ends. The backend is an LSP server (design 2026-10-30 part 2 §8): VS + * Code's language client starts it and connects to it, which brings mcpp.toml's and build.mcpp's + * completion, hover, jumps and diagnostics; the views' data adapters and the debugging, testing and + * packing flows reach mcpp-ide's own methods through `session.request`; the strings are this + * extension's own (`t`). + */ +import * as vscode from "vscode"; +import type * as lc from "vscode-languageclient/node"; + +import { t } from "../i18n/t"; +import { languageServerServes } from "../mcppls/stateSource"; +import { backendUnavailableHere, locateBackend, okrunDir, type Located } from "./backend/locate"; +import { mcpplsCandidates } from "./backend/mcppls"; +import { MCPPLS_EXTENSION } from "./mapping"; +import type { CommandEntry, InitializeResult, LanguageServiceStatus } from "./protocol"; + +// The language client, bundled into dist/vendor (tools/bundle-languageclient.mjs): the VSIX carries +// no node_modules. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const lsp = require("../../vendor/languageclient") as typeof lc; + +/** mcpp-ide's own methods and notifications are under `mcpp-ide/` beside LSP's. */ +const NAMESPACE = "mcpp-ide/"; + +export interface RpcError { + code: number; + message: string; + data?: unknown; +} + +export class BackendError extends Error { + constructor(public readonly method: string, public readonly error: RpcError) { + super(error.message); + } +} + +/** The documents the backend's rules layer reads: mcpp.toml, and build.mcpp (a `cpp` document). */ +export const DOCUMENT_SELECTOR = [ + { scheme: "file", language: "mcpp-toml" }, + { scheme: "file", language: "cpp", pattern: "**/build.mcpp" }, +]; + +export class Session implements vscode.Disposable { + private client: lc.LanguageClient | undefined; + /** The notifications the current client delivers to `listeners`. */ + private subscribed = new Set(); + private located: Located | undefined; + private initResult: InitializeResult | undefined; + private commandTable: CommandEntry[] = []; + private languageServiceNow: LanguageServiceStatus | undefined; + private languageServiceAsking: Promise | undefined; + /** When the backend ended, within the last three minutes (§3.1). */ + private exits: number[] = []; + private degradedNow = false; + private starting: Promise | undefined; + private readonly disposables: vscode.Disposable[] = []; + private readonly listeners = new Map void>>(); + readonly onDidStart = new vscode.EventEmitter(); + /** The backend ended four times in three minutes and is no longer restarted (§3.1, §8). */ + readonly onDidDegrade = new vscode.EventEmitter(); + + constructor(readonly context: vscode.ExtensionContext, readonly output: vscode.OutputChannel) { + this.disposables.push( + this.onDidStart, + this.onDidDegrade, + // The editor regained focus: a `xlings use` or a changed pin happened outside it (§4.2), or a + // server started or stopped (§5.2). + vscode.window.onDidChangeWindowState((state) => { + if (!state.focused || !this.running) return; + void this.request("project/refresh", {}).then(() => this.onDidRefresh.fire(), () => undefined); + void this.refreshLanguageService(); + }), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("mcpp.ide.backendPath")) { void this.restart(); return; } + if (e.affectsConfiguration("mcpp") || e.affectsConfiguration("mcppls.enable")) void this.pushSettings(); + }), + vscode.workspace.onDidGrantWorkspaceTrust(() => void this.pushSettings()), + vscode.workspace.onDidChangeWorkspaceFolders(() => void this.restart()), + // Whether C++ Modules serves build.mcpp: installed, enabled, or switched off for the workspace. + vscode.extensions.onDidChange(() => { void this.refreshLanguageService(); void this.pushSettings(); }), + this.onDidChangeLanguageService, + ); + // mcppls answered differently, or a job ended (a build changes what the server serves). + this.on("languageService/changed", () => void this.refreshLanguageService()); + this.on("job/finished", () => void this.refreshLanguageService()); + } + + /** What mcppls says about the workspace (§5.2), as last asked; `undefined` before the first answer. */ + get languageService(): LanguageServiceStatus | undefined { + return this.languageServiceNow; + } + + /** Fired when `languageService` was asked again. */ + readonly onDidChangeLanguageService = new vscode.EventEmitter(); + + /** + * Asks the backend which mcppls answers and what it says about the workspace: one question at a + * time, and none while the backend is not running (nothing is started to answer it). + */ + refreshLanguageService(root?: string): Promise { + if (!this.running) return Promise.resolve(this.languageServiceNow); + if (this.languageServiceAsking) return this.languageServiceAsking; + const params: Record = { candidates: this.mcpplsCandidates() }; + const folder = root ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (folder !== undefined) params.root = folder; + this.languageServiceAsking = this + .request("languageService/status", params) + .then((status) => { + this.languageServiceNow = status; + this.onDidChangeLanguageService.fire(status); + return status; + }, () => this.languageServiceNow) + .finally(() => { this.languageServiceAsking = undefined; }); + return this.languageServiceAsking; + } + + /** Fired after the projects' mcpp were forgotten, so a view asks again. */ + readonly onDidRefresh = new vscode.EventEmitter(); + + /** Whether the backend was given up on after ending too often (§3.1). */ + get degraded(): boolean { + return this.degradedNow; + } + + /** The backend's command and where it was found. */ + get started(): Located | undefined { + return this.located; + } + + get info(): InitializeResult | undefined { + return this.initResult; + } + + get commands(): CommandEntry[] { + return this.commandTable; + } + + /** Every mcpp.* value, as VS Code resolves it for the window. */ + settings(): Record { + const out: Record = {}; + const props = (this.context.extension.packageJSON?.contributes?.configuration?.properties ?? {}) as Record; + const config = vscode.workspace.getConfiguration(); + for (const key of Object.keys(props)) out[key] = config.get(key); + return out; + } + + mcpplsCandidates(): string[] { + return mcpplsCandidates(vscode.extensions.getExtension(MCPPLS_EXTENSION)?.extensionPath); + } + + on(method: string, fn: (params: any) => void): vscode.Disposable { + if (!this.listeners.has(method)) this.listeners.set(method, new Set()); + this.listeners.get(method)!.add(fn); + if (this.client) this.subscribe(this.client, method); + return new vscode.Disposable(() => this.listeners.get(method)?.delete(fn)); + } + + private subscribe(client: lc.LanguageClient, method: string): void { + if (this.subscribed.has(method)) return; + this.subscribed.add(method); + client.onNotification(NAMESPACE + method, (params: unknown) => { + for (const fn of this.listeners.get(method) ?? []) { + try { fn(params); } catch (e) { this.output.appendLine(`mcpp: ${method}: ${String(e)}`); } + } + }); + } + + /** What the backend is told at the start and on each change. */ + private sessionOptions(): Record { + return { + locale: vscode.env.language, + trusted: vscode.workspace.isTrusted, + settings: this.settings(), + mcpplsServes: languageServerServes(), + }; + } + + async start(): Promise { + if (this.starting) return this.starting; + this.starting = this.doStart().finally(() => { this.starting = undefined; }); + return this.starting; + } + + private async doStart(): Promise { + const configured = vscode.workspace.getConfiguration().get("mcpp.ide.backendPath") ?? ""; + this.located = locateBackend({ configured: vscode.workspace.isTrusted ? configured : "", extensionRoot: this.context.extensionPath }); + if (!this.located && backendUnavailableHere({ extensionRoot: this.context.extensionPath })) { + // The declared degraded mode: build, run, test and the project pages, without the backend. + this.degradedNow = true; + this.onDidDegrade.fire(); + const message = t("The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.", okrunDir()); + this.output.appendLine(`mcpp: ${message}`); + void vscode.window.showWarningMessage(`mcpp: ${message}`); + return false; + } + if (!this.located) { + const message = t("The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath."); + this.output.appendLine(`mcpp: ${message}`); + const open = t("Open Settings"); + void vscode.window.showErrorMessage(`mcpp: ${message}`, open).then((c) => { + if (c === open) void vscode.commands.executeCommand("workbench.action.openSettings", "mcpp.ide.backendPath"); + }); + return false; + } + this.output.appendLine(`mcpp-ide: starting ${[this.located.program, ...this.located.args].join(" ")} (${this.located.source})`); + const client: lc.LanguageClient = new lsp.LanguageClient("mcpp-ide", "mcpp-ide", { + command: this.located.program, + args: [...this.located.args, "serve"], + transport: lsp.TransportKind.stdio, + }, { + documentSelector: DOCUMENT_SELECTOR, + initializationOptions: { + editor: "vscode", + ...this.sessionOptions(), + languageService: { candidates: this.mcpplsCandidates() }, + }, + // The backend's log (its standard error) and the client's own lines share the mcpp channel. + outputChannel: this.output, + revealOutputChannelOn: lsp.RevealOutputChannelOn.Never, + diagnosticCollectionName: "mcpp", + initializationFailedHandler: () => false, + // The restarts are §3.1's, not the client's: 1, 4 and 16 seconds, then the degraded mode. + errorHandler: { + error: () => ({ action: lsp.ErrorAction.Continue }), + closed: () => { + this.onExit(client); + return { action: lsp.CloseAction.DoNotRestart, handled: true }; + }, + }, + }); + this.client = client; + this.subscribed = new Set(); + for (const method of this.listeners.keys()) this.subscribe(client, method); + try { + await client.start(); + this.initResult = client.initializeResult as unknown as InitializeResult; + this.commandTable = (await this.request<{ commands: CommandEntry[] }>("commands/list", { editor: "vscode" })).commands; + if (this.initResult.languageService) { + this.languageServiceNow = this.initResult.languageService; + this.onDidChangeLanguageService.fire(this.initResult.languageService); + } + for (const c of this.initResult.checks) if (c.status === "error") this.output.appendLine(`mcpp: ${c.message}`); + this.onDidStart.fire(this.initResult); + return true; + } catch (e) { + this.output.appendLine(`mcpp-ide: initialize failed: ${String(e)}`); + return false; + } + } + + /** + * THE BACKEND ENDED (design 2026-10-30 part 2 §3.1). It is started again after 1, 4 and 16 + * seconds; a fourth end within three minutes stops the restarts, and the extension goes on in its + * declared degraded mode (build, run, test and the project pages) with two actions: start the + * backend again, and export the IDE report. + */ + private onExit(client: lc.LanguageClient): void { + if (client !== this.client) return; + this.client = undefined; + this.output.appendLine("mcpp-ide: the backend ended"); + const now = Date.now(); + this.exits = [...this.exits.filter((at) => now - at < 3 * 60_000), now]; + const delays = [1000, 4000, 16000]; + if (this.exits.length <= delays.length) { + const delay = delays[this.exits.length - 1]; + this.output.appendLine(`mcpp-ide: starting it again in ${delay / 1000} s`); + setTimeout(() => void this.start(), delay); + return; + } + this.degradedNow = true; + this.onDidDegrade.fire(); + const again = t("Restart the backend"); + const report = t("Export IDE Report"); + void vscode.window.showErrorMessage( + `mcpp: ${t("The mcpp-ide backend ended four times in three minutes; build, run, test and the project pages go on without it.")}`, + again, report, + ).then((c) => { + if (c === again) void this.restart(); + if (c === report) void vscode.commands.executeCommand("mcpp.exportReport"); + }); + } + + async restart(): Promise { + const old = this.client; + this.client = undefined; + if (old) await stopClient(old); + this.exits = []; + this.degradedNow = false; + await this.start(); + } + + async request(method: string, params: unknown = {}): Promise { + if (this.degradedNow && !this.running) { + throw new BackendError(method, { code: -32099, message: t("the mcpp-ide backend is not running") }); + } + if (!this.running && !this.starting) { + if (!(await this.start())) throw new BackendError(method, { code: -32099, message: t("the mcpp-ide backend is not running") }); + } + const client = this.client; + if (!client) throw new BackendError(method, { code: -32099, message: t("the mcpp-ide backend is not running") }); + try { + return await client.sendRequest(NAMESPACE + method, params); + } catch (e) { + if (e instanceof lsp.ResponseError) throw new BackendError(method, { code: e.code, message: e.message, data: e.data }); + throw e; + } + } + + async pushSettings(): Promise { + if (!this.running) return; + await this.request("settings/update", this.sessionOptions()); + } + + /** Whether a backend is running now (nothing is started to answer this). */ + get running(): boolean { + return this.client?.isRunning() === true; + } + + dispose(): void { + this.onDidRefresh.dispose(); + this.disposables.forEach((d) => d.dispose()); + if (this.client) void stopClient(this.client); + } +} + +/** Shutdown and exit, or the process ended, within a few seconds either way. */ +async function stopClient(client: lc.LanguageClient): Promise { + try { + await client.stop(3000); + } catch { + // it ended already + } +} diff --git a/mide-plugins/vscode/src/ide/testing.ts b/mide-plugins/vscode/src/ide/testing.ts new file mode 100644 index 0000000..b0fdcc5 --- /dev/null +++ b/mide-plugins/vscode/src/ide/testing.ts @@ -0,0 +1,205 @@ +/** + * mcpp's tests in VS Code's Testing view (design 2026-10-30 part 2 §7). The tests of each project + * come from `test/list` (mcpp test --list: no build), each a program located at its `main`; a + * program of a test framework has its cases as children, located at their macros (`test/cases`, + * read from the source), and after a build the program lists them (only where its source calls its + * framework's macros, and only in a trusted workspace), which adds the cases macros generate. + * + * A run of whole programs is one test job, filtered to the program asked for; a run of cases is + * `test/runCases`, whose program receives the framework's filter, and each `test/caseResult` marks + * its case. + */ +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { t } from "../i18n/t"; +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"; + +interface Program { + root: string; + member: string; + test: string; + framework: string | null; +} + +export class Testing implements vscode.Disposable { + private readonly controller = vscode.tests.createTestController("mcpp", "mcpp"); + private readonly disposables: vscode.Disposable[] = [this.controller]; + private readonly programs = new Map(); + + constructor(private readonly session: Session, private readonly jobs: Jobs, private readonly projects: IdeProjects) { + this.controller.resolveHandler = async (item) => { if (!item) await this.discover(); }; + this.controller.refreshHandler = async () => { await this.discover(true); }; + this.controller.createRunProfile(t("Run"), vscode.TestRunProfileKind.Run, (request, token) => this.run(request, token), true); + this.disposables.push( + session.on("manifest/changed", () => void this.discover()), + session.on("result/changed", () => void this.discover()), + session.onDidStart.event(() => void this.discover()), + // A build ended: the programs can list the cases their macros generate. + session.on("job/finished", (p: JobFinished) => { + if (p.status === "ok" && (p.kind === "build" || p.kind === "test") && vscode.workspace.isTrusted) void this.discover(true); + }), + vscode.workspace.onDidSaveTextDocument((doc) => { + if (doc.languageId === "cpp" && /[\\/]tests[\\/]/.test(doc.uri.fsPath)) void this.discover(); + }), + ); + } + + /** The program a test's `main` is, by its project and name (the CodeLens runs it). */ + item(root: string, test: string): vscode.TestItem | undefined { + const group = this.controller.items.get(root); + let found: vscode.TestItem | undefined; + group?.children.forEach((child) => { + const id = parseTestId(child.id); + if (id && id.test === test) found = child; + }); + return found; + } + + /** Runs one program or case, as the Testing view's ▶ does. */ + async runItem(item: vscode.TestItem): Promise { + const source = new vscode.CancellationTokenSource(); + try { + await this.run(new vscode.TestRunRequest([item]), source.token); + } finally { + source.dispose(); + } + } + + async discover(list = false): Promise { + for (const project of this.projects.all()) { + let tests: { test: string; member: string; main: string }[] = []; + try { + tests = (await this.session.request<{ tests: { test: string; member: string; main: string }[] }>("test/list", { root: project.root })).tests; + } catch { + continue; + } + let group = this.controller.items.get(project.root); + if (!group) { + group = this.controller.createTestItem(project.root, project.name ?? path.basename(project.root), vscode.Uri.file(project.root)); + this.controller.items.add(group); + } + const items: vscode.TestItem[] = []; + for (const test of tests) { + const id = testId(project.root, test.member, test.test); + const item = this.controller.createTestItem(id, test.member ? `${test.member} · ${test.test}` : test.test, + test.main ? vscode.Uri.file(test.main) : undefined); + const program: Program = { root: project.root, member: test.member, test: test.test, framework: null }; + let cases: TestCases | undefined; + try { + cases = await this.session.request("test/cases", { root: project.root, test: test.test, list }); + } catch { + cases = undefined; + } + // The program at its `main` (F11); a framework's program at its first case when it has none. + const mainLine = cases?.main?.line ?? cases?.cases.find((c) => c.line !== undefined)?.line ?? 0; + if (test.main) item.range = new vscode.Range(mainLine, 0, mainLine, 0); + if (cases?.framework) { + program.framework = cases.framework; + item.description = cases.framework; + item.children.replace(cases.cases.map((c) => { + const child = this.controller.createTestItem(caseId(project.root, test.member, test.test, c.name), c.name, + test.main ? vscode.Uri.file(test.main) : undefined); + if (test.main && c.line !== undefined) child.range = new vscode.Range(c.line, 0, c.line, 0); + return child; + })); + } + this.programs.set(id, program); + items.push(item); + } + group.children.replace(items); + } + } + + private async run(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { + const run = this.controller.createTestRun(request); + // One job per program whose cases were asked for; one job per project for whole programs. + const wholePrograms = new Map(); + const casesOf = new Map(); + const collect = (item: vscode.TestItem) => { + if (request.exclude?.includes(item)) return; + const c = parseCaseId(item.id); + if (c) { + const owner = testId(c.root, c.member, c.test); + casesOf.set(owner, [...(casesOf.get(owner) ?? []), item]); + return; + } + const id = parseTestId(item.id); + if (!id) { item.children.forEach(collect); return; } + wholePrograms.set(id.root, [...(wholePrograms.get(id.root) ?? []), item]); + }; + if (request.include) request.include.forEach(collect); + else this.controller.items.forEach(collect); + + for (const [owner, items] of casesOf) { + if (token.isCancellationRequested) break; + const program = this.programs.get(owner); + if (!program?.framework) continue; + items.forEach((i) => run.enqueued(i)); + // Every case of the program asked for: the filter is left out and the program runs them all. + let all = 0; + this.controller.items.get(program.root)?.children.get(owner)?.children.forEach(() => { all++; }); + const names = items.length === all ? [] : items.map((i) => parseCaseId(i.id)!.name); + const byName = new Map(items.map((i) => [parseCaseId(i.id)!.name, i])); + const seen = new Set(); + 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")), + caseResult: (r: CaseResult) => { + const item = byName.get(r.case) ?? byName.get(baseCaseName(program.framework, r.case)); + if (!item) return; + seen.add(item); + 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); + }, + }, + "test/runCases", + ); + const cancel = token.onCancellationRequested(() => void handle.cancel()); + const done = await handle.done; + cancel.dispose(); + for (const i of items) + if (!seen.has(i)) { + if (done.status === "ok" || done.status === "failed") run.skipped(i); + else run.errored(i, new vscode.TestMessage(t("The test program did not run: {0}", done.status))); + } + } + + for (const [root, items] of wholePrograms) { + if (token.isCancellationRequested) break; + items.forEach((i) => run.enqueued(i)); + 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")), + testResult: (r: TestResult) => { + const item = index.get(r.test); + if (!item) return; + const ms = r.durationMs; + 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); + if (r.status === "errored") run.errored(item, message, ms); else run.failed(item, message, ms); + } + }, + }); + const cancel = token.onCancellationRequested(() => void handle.cancel()); + const done = await handle.done; + cancel.dispose(); + if (done.status !== "ok" && done.status !== "failed") + for (const i of items) run.skipped(i); + } + run.end(); + } + + dispose(): void { + this.disposables.forEach((d) => d.dispose()); + } +} diff --git a/mide-plugins/vscode/src/library/addDependency.ts b/mide-plugins/vscode/src/library/addDependency.ts new file mode 100644 index 0000000..48d7d2b --- /dev/null +++ b/mide-plugins/vscode/src/library/addDependency.ts @@ -0,0 +1,143 @@ +/** + * `mcpp add @ [--dev]` — the only way this extension changes a + * project's dependencies. + * + * **`mcpp.toml` is never written here.** `mcpp add` is the official "add a + * dependency to mcpp.toml" command; it owns the file's formatting, comments and + * lockfile. Editing TOML from an extension is how a manifest gets corrupted. + * + * The version is **mandatory**, and that is mcpp's rule, not ours: a bare + * `mcpp add compat.argparse` is refused with + * `error: package version required: \`mcpp add compat.argparse@\` (M2 supports + * exact-version only)`. The caller therefore always passes the version it picked + * from `mcpp xpkg parse --json` (the greatest one for this platform), and this + * module refuses to run without one rather than producing an error the user + * cannot act on. + * + * Failures are values, not exceptions: the exit code and the stderr tail travel + * back to the caller, which shows them. Nothing here throws, so a failed add can + * never leave the detail page half-rendered. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { clampOutput } from "../util/text"; + +/** `mcpp add` may have to fetch an index or build the cache entry, so it gets a long budget. */ +const ADD_TIMEOUT_MS = 300_000; + +export interface AddDependencyDeps { + /** The configured `mcpp` path, or `mcpp`. */ + mcppExecutable: () => string; + /** The workspace folder holding `mcpp.toml`; `undefined` is a refusal, not a crash. */ + projectRoot: () => string | undefined; + /** Where the command and its output are logged, the same channel the CLI uses. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; an untrusted workspace may not be written to. */ + isTrusted: () => boolean; + quiet?: boolean; +} + +export interface AddDependencyRequest { + /** The package id, `ns.name`. */ + id: string; + /** The exact version; `undefined` is refused. */ + version?: string; + /** `--dev`, i.e. a `[dev-dependencies]` entry. */ + dev: boolean; +} + +export interface AddDependencyResult { + ok: boolean; + /** The argv that ran (empty when nothing ran), for the log and the UI. */ + argv: string[]; + exitCode: number; + /** A sentence for the detail page, already localized. */ + message: string; +} + +function failure(message: string, argv: readonly string[] = []): AddDependencyResult { + return { ok: false, argv: [...argv], exitCode: 1, message }; +} + +/** The argv of one add, without running it. The preview in the UI uses the same shape. */ +export function addDependencyArgv(request: { id: string; version: string; dev: boolean }): string[] { + return ["add", `${request.id}@${request.version}`, ...(request.dev ? ["--dev"] : [])]; +} + +/** + * Run one `mcpp add`, with a progress notification, and report what happened. + * + * Every refusal is explicit and happens **before** anything runs: no version, no + * `mcpp.toml`, an untrusted workspace. A run that fails returns the exit code and + * the last lines of stderr, which the detail page prints verbatim — the user gets + * mcpp's own words, not ours. + */ +export async function addDependency( + deps: AddDependencyDeps, + request: AddDependencyRequest, +): Promise { + const version = request.version?.trim() ?? ""; + if (request.id.trim().length === 0 || version.length === 0) { + const message = t("mcpp add needs an exact version: mcpp add {0}@.", request.id); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + const root = deps.projectRoot(); + if (root === undefined) { + const message = t("This workspace has no mcpp.toml."); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + if (!deps.isTrusted()) { + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + if (!deps.quiet) void vscode.window.showWarningMessage(message); + return failure(message); + } + + const argv = addDependencyArgv({ id: request.id, version, dev: request.dev }); + const executable = deps.mcppExecutable(); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Adding {0}…", request.id) }, + () => + runMcpp(deps.isTrusted(), executable, argv, root, { + timeoutMs: ADD_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + // The seam refused: the guard above usually answers first with a friendlier + // message, so this is the defensive spelling of the same refusal. + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + return failure(message); + } + + try { + deps.output?.appendLine(`\n[${new Date().toISOString()}] mcpp ${argv.join(" ")}`); + deps.output?.appendLine(`$ ${[executable, ...argv].join(" ")}`); + const stdout = clampOutput(result.stdout); + const stderr = clampOutput(result.stderr); + if (stdout.text.trim().length > 0) deps.output?.appendLine(stdout.text.trimEnd()); + if (stderr.text.trim().length > 0) deps.output?.appendLine(stderr.text.trimEnd()); + deps.output?.appendLine(`[exit ${result.exitCode}]`); + } catch { + // The channel can already be gone during shutdown. + } + + if (result.exitCode === 0) { + const message = t("Added {0} to mcpp.toml.", `${request.id}@${version}`); + if (!deps.quiet) void vscode.window.showInformationMessage(message); + return { ok: true, argv, exitCode: 0, message }; + } + + const detail = clampOutput(result.stderr.length > 0 ? result.stderr : result.stdout).text.trim(); + const message = + detail.length === 0 + ? t("mcpp add failed with exit code {0}.", result.exitCode) + : t("mcpp add failed (exit {0}). See output for details.", result.exitCode); + if (!deps.quiet) void vscode.window.showErrorMessage(message); + return { ok: false, argv, exitCode: result.exitCode, message }; +} diff --git a/mide-plugins/vscode/src/library/detailHtml.ts b/mide-plugins/vscode/src/library/detailHtml.ts new file mode 100644 index 0000000..d061614 --- /dev/null +++ b/mide-plugins/vscode/src/library/detailHtml.ts @@ -0,0 +1,686 @@ +/** + * The package detail page's document, as a pure function (plan §12.1–§12.5). + * + * The sidebar answers "which package"; this page answers "what is it, how do I + * consume it, and how do I add it". It opens in the **editor area** (a + * `WebviewPanel`, see `src/library/detailPanel.ts`), because a 200 px sidebar + * cannot show a version matrix and a code block. + * + * The same house rules as the other webviews apply: strict CSP, every label from + * `DetailModel.ui`, every interpolation escaped, no inline `style=`, and no state + * that is only a colour. Everything shown is either authoritative + * (`mcpp xpkg parse --json`, `mcpp.toml`) or explicitly attributed (the example + * code comes from the index's own CI-built test projects, and says so). + * + * The example block is tokenized by `indexModel.tokenizeCppLine` — a deliberately + * small highlighter: comments, strings, the preprocessor directive, a short + * keyword list and punctuation. It is not a parser and is not meant to be. + */ + +import { dependencyLocation, type DependencyKind, type ProjectDependencyState } from "./projectState"; + +import { + BADGE_UI, + surfaceLabel, + tokenizeCppLine, + type BadgeKey, + type Surface, +} from "./indexModel"; + +/** One platform's row in the version matrix. */ +export interface DetailVersionGroup { + platform: string; + versions: string[]; + /** The platform this extension host is running on. */ + current: boolean; +} + +/** One declared dependency, as far as it is cheaply known. */ +export interface DetailDependency { + id: string; + version?: string; + /** The version the project's `mcpp.lock` resolved, when it is known. */ + resolved?: string; + key?: string; +} + +/** One window of real example code. */ +export interface DetailSnippet { + file: string; + startLine: number; + lines: string[]; + usageLine: number; + fileKey?: string; +} + +/** The outcome of the last `mcpp add`, shown in place. */ +export interface DetailResult { + /** `pending` is the client's own "I asked the host" line, replaced by the answer. */ + state: "ok" | "error" | "pending"; + message: string; +} + +/** The workspace's own answer about one package. */ +export interface DetailInstalled { + version: string; + /** Declared under `[dev-dependencies]`. */ + dev: boolean; +} + +export interface DetailModel { + /** Everything is already localized by the caller. */ + ui: Record; + key?: string; + generation?: number; + projectRoot?: string; + platform?: string; + projectState?: ProjectDependencyState; + dependencyGroups?: Record; + dependencyKind?: DependencyKind; + manifestAvailable?: boolean; + blockedReason?: string; + versionNotice?: string; + loading?: boolean; + preview?: boolean; + previewVersion?: string; + busy?: boolean; + id: string; + name: string; + description?: string; + licenses: string[]; + repo?: string; + registry: string; + surface?: Surface; + surfaces: Surface[]; + badges: BadgeKey[]; + /** The version matrix, current platform first. */ + versions: DetailVersionGroup[]; + /** Every version the current platform has, greatest first. */ + currentVersions: string[]; + /** What `mcpp add` would use by default. */ + latest?: string; + /** + * The version this workspace already asks for — `mcpp.toml` first, then + * `mcpp.lock` — so the page can say "you have this one" and offer the switch + * instead of a blind add. Absent when the project does not depend on the + * package, or when the dependency names no version (a path or git entry). + */ + installed?: DetailInstalled; + standard?: string; + dependencies: DetailDependency[]; + includeDirs: string[]; + targets: string[]; + snippets: DetailSnippet[]; + /** The example project the snippets come from, when there is one. */ + exampleProject?: string; + exampleProvenance?: string; + /** + * How a reader brings the package into code (§22): the example project's + * real `import …;` / `#include …` lines when there are any, else the honest + * synthetic form for the surface. Empty when the surface is not importable. + */ + usage: string[]; + /** The index site's package page; omitted for a registry that has no site. */ + indexUrl?: string; + /** `mcpp add {0}@{1}` / with `--dev`, so the preview and the run agree. */ + commandTemplate: string; + commandDevTemplate: string; + /** Set when `mcpp xpkg parse` could not be read; the page still renders. */ + parseNotice?: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + result?: DetailResult; +} + +export interface DetailAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** The `ui` keys the renderer reads, so the caller and the renderer cannot drift. */ +export const DETAIL_UI = { + htmlLang: "detail.htmlLang", + title: "detail.title", + project: "detail.project", + chooseProject: "detail.chooseProject", + platform: "detail.platform", + version: "detail.version", + otherPlatforms: "detail.otherPlatforms", + declared: "detail.declared", + locked: "detail.locked", + sourceDependency: "detail.sourceDependency", + unknownUsage: "detail.unknownUsage", + usageSource: "detail.usageSource", + source: "detail.source", + loading: "detail.loading", + pending: "detail.pending", + groupChange: "detail.groupChange", + output: "detail.output", + retry: "detail.retry", + openManifest: "detail.openManifest", + openFile: "detail.openFile", + readOnlySource: "detail.readOnlySource", + preview: "detail.preview", + platforms: "detail.platforms", + kind: "detail.kind", + kindNormal: "detail.kind.normal", + kindDev: "detail.kind.dev", + kindBuild: "detail.kind.build", + addDevAction: "detail.addDevAction", + buildHint: "detail.buildHint", + moveHint: "detail.moveHint", + overview: "detail.overview", + license: "detail.license", + repo: "detail.repo", + openRepo: "detail.openRepo", + registry: "detail.registry", + surface: "detail.surface", + surfaceExternal: "detail.surface.external", + standard: "detail.standard", + versions: "detail.versions", + versionsAll: "detail.versions.all", + versionsCurrent: "detail.versions.current", + versionsNone: "detail.versions.none", + versionsPick: "detail.versions.pick", + dependencies: "detail.dependencies", + dependenciesNone: "detail.dependencies.none", + dependenciesHint: "detail.dependencies.hint", + resolved: "detail.resolved", + code: "detail.code", + codeNone: "detail.code.none", + codeSource: "detail.code.source", + codeProject: "detail.code.project", + add: "detail.add", + addDev: "detail.addDev", + switchTo: "detail.switchTo", + alreadyAdded: "detail.alreadyAdded", + installed: "detail.installed", + opening: "detail.opening", + addLatest: "detail.addLatest", + addNoVersion: "detail.add.noVersion", + command: "detail.command", + usage: "detail.usage", + copy: "detail.copy", + copied: "detail.copied", + copying: "detail.copying", + indexLink: "detail.indexLink", + badgeExamples: BADGE_UI.examples.key, + badgeCn: BADGE_UI.cn.key, + badgeOpenkalEcosystem: BADGE_UI["openkal-ecosystem"].key, + badgeOpenkalCompat: BADGE_UI["openkal-compat"].key, + badgeOpenkalPosix: BADGE_UI["openkal-posix"].key, + badgeOpenkalPlatform: BADGE_UI["openkal-platform"].key, + targets: "detail.targets", + includeDirs: "detail.includeDirs", +} as const; + +const BADGE_KEY_UI: Readonly> = { + examples: DETAIL_UI.badgeExamples, + cn: DETAIL_UI.badgeCn, + "openkal-ecosystem": DETAIL_UI.badgeOpenkalEcosystem, + "openkal-compat": DETAIL_UI.badgeOpenkalCompat, + "openkal-posix": DETAIL_UI.badgeOpenkalPosix, + "openkal-platform": DETAIL_UI.badgeOpenkalPlatform, +}; + +/** Messages carry a page key and generation at the transport boundary. + * Mounting replays the latest model in place; it never rebuilds the document. + */ +export type DetailMessage = + | { type: "add"; version: string; dev: boolean } + | { type: "openUrl"; url: string } + | { type: "copy"; text: string; token?: string } + | { type: "selectProject" | "showOutput" | "retry" | "openManifest" | "mounted" } + | { type: "openDependency" | "openFile"; key: string }; + +export type UiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly (string | number)[]): string { + return label(key).replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number(index)]; + return value === undefined ? whole : String(value); + }); +} + +function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string[] { + return badges.map((badge) => label(BADGE_KEY_UI[badge])); +} + +/** One source line, tokenized. The renderer escapes every token. */ +function renderLine(line: string, usage: boolean): string { + if (line.length === 0) { + return "\n"; + } + const tokens = tokenizeCppLine(line) + .map((token) => `${escapeHtml(token.text)}`) + .join(""); + return `${tokens}\n`; +} + +function actionIcon(kind: "copy" | "source"): string { + const paths = kind === "copy" ? '' : ''; + return ``; +} + +function renderSnippets(model: DetailModel, label: UiLabel): string { + if (model.snippets.length === 0) { + return `

    ${escapeHtml(label(DETAIL_UI.codeNone))}

    `; + } + const blocks = model.snippets.map((snippet, index) => { + const lines = snippet.lines + .map((line, index) => renderLine(line, snippet.startLine + index === snippet.usageLine)) + .join(""); + const caption = fill(label, DETAIL_UI.codeSource, [snippet.file, snippet.startLine]); + return [ + `

    ${escapeHtml(index === 0 ? label(DETAIL_UI.code) : snippet.file.split("/").pop() ?? snippet.file)}

    ${snippet.fileKey === undefined ? "" : ``}
    `, + `
    ${lines}
    `, + `

    ${escapeHtml(caption)}

    `, + ].join("\n"); + }); + return blocks.join("\n"); +} + +/** Read-only platform information; the current-environment select owns the command. */ +function renderVersions(model: DetailModel, label: UiLabel): string { + if (!model.versions.length) { return `

    ${escapeHtml(label(DETAIL_UI.versionsNone))}

    `; } + const row = (group: DetailVersionGroup) => `
  • + ${escapeHtml(group.platform)} + ${group.versions.map((version) => `${escapeHtml(version)}`).join("") || "—"} + ${group.current ? `${escapeHtml(label(DETAIL_UI.versionsCurrent))}` : ""}
  • `; + return `
      ${model.versions.filter((group) => group.current).map(row).join("")}
    +
    ${escapeHtml(label(DETAIL_UI.otherPlatforms))} +
      ${model.versions.filter((group) => !group.current).map(row).join("")}
    `; +} + +function renderDependencies(model: DetailModel, label: UiLabel): string { + if (model.dependencies.length === 0) { + return `

    ${escapeHtml(label(DETAIL_UI.dependenciesNone))}

    `; + } + const rows = model.dependencies.map((dependency) => { + const parts = [dependency.version ?? ""]; + if (dependency.resolved !== undefined) { + parts.push(fill(label, DETAIL_UI.resolved, [dependency.resolved])); + } + return ( + `
  • ${dependency.key ? `` : `${escapeHtml(dependency.id)}`}` + + (parts.length === 0 ? "" : ` — ${escapeHtml(parts.join(" · "))}`) + + `
  • ` + ); + }); + return [ + `

    ${escapeHtml(label(DETAIL_UI.dependenciesHint))}

    `, + `
      `, + rows.join("\n"), + `
    `, + ].join("\n"); +} + +function renderMeta(model: DetailModel, label: UiLabel): string { + const tag = (kind: string, value: string, title: string) => `${escapeHtml(value)}`; + return `
    ${model.surface ? tag("surface", surfaceLabel(model.surface, label), label(DETAIL_UI.surface)) : ""}${model.standard ? tag("standard", model.standard.toUpperCase(), label(DETAIL_UI.standard)) : ""}${model.licenses.map((license) => tag("license", license, license)).join("")}
    `; +} + +function renderProject(model: DetailModel, label: UiLabel): string { + return `
    ${escapeHtml(model.platform ?? "—")}
    `; +} + +function renderExtras(model: DetailModel, label: UiLabel): string { + const blocks: string[] = []; + if (model.targets.length > 0) { + blocks.push( + `

    ${escapeHtml(label(DETAIL_UI.targets))}: ${escapeHtml(model.targets.join(", "))}

    `, + ); + } + if (model.includeDirs.length > 0) { + blocks.push( + `

    ${escapeHtml(label(DETAIL_UI.includeDirs))}: ${escapeHtml(model.includeDirs.join(", "))}

    `, + ); + } + return blocks.join("\n"); +} + +/** + * The primary block: what this page is *for*, immediately under the title. + * + * It used to be the last thing on the page, below four sections of prose, so the + * button a reader came for was the one thing they had to scroll to find. The + * version is chosen in the list below (`renderVersions`), and this block shows + * the command the choice produces. + */ +function groupsFor(model: DetailModel): Record { + const empty: ProjectDependencyState = { declared: false, dev: false, sourceType: "none", ambiguous: false }; + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const state = model.projectState ?? (model.installed ? { ...empty, declared: true, declaredVersion: model.installed.version, dev: model.installed.dev, sourceType: "registry" as const } : empty); + return model.dependencyGroups ?? { normal: kind === "normal" ? state : empty, dev: kind === "dev" ? state : { ...empty, dev: true }, build: kind === "build" ? state : empty }; +} + +function selectedVersion(model: DetailModel): string | undefined { + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const declared = groupsFor(model)[kind].declaredVersion; + return declared !== undefined && model.currentVersions.includes(declared) ? declared : model.latest; +} + +function renderActions(model: DetailModel, label: UiLabel): string { + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const groups = groupsFor(model); + const current = groups[kind]; + const version = selectedVersion(model); + const manual = kind === "build" || current.ambiguous || current.declared && current.sourceType !== "registry" || !current.declared && groups[kind === "dev" ? "normal" : "dev"].declared; + const already = current.declaredVersion !== undefined && current.declaredVersion === version && !manual; + const canOpen = model.manifestAvailable ?? !!model.projectRoot; + const disabled = !!model.busy || !!model.loading || (manual ? !canOpen : !!model.blockedReason || !version || already); + const location = dependencyLocation(model.id, kind); + const command = model.preview ? `mcpp search ${model.id}` : manual ? `[${location.table}] +${location.name} = ${JSON.stringify(version ?? "")}` : (kind === "dev" ? model.commandDevTemplate : model.commandTemplate).replace("{0}", model.id).replace("{1}", version ?? "?"); + const addLabel = model.busy ? label(DETAIL_UI.pending) : manual ? label(DETAIL_UI.openManifest) : already ? label(DETAIL_UI.alreadyAdded) : current.declared ? fill(label, DETAIL_UI.switchTo, [version ?? "?"]) : label(kind === "dev" ? DETAIL_UI.addDevAction : DETAIL_UI.add); + const link = (url: string, text: string) => ``; + const kinds: Array<[DependencyKind, string, string]> = [["normal", DETAIL_UI.kindNormal, "dependencies"], ["dev", DETAIL_UI.kindDev, "dev-dependencies"], ["build", DETAIL_UI.kindBuild, "build-dependencies"]]; + return `
    + ${model.loading ? `

    ${escapeHtml(label(DETAIL_UI.loading))}

    ` : ""} + ${model.preview ? `

    ${escapeHtml(label(DETAIL_UI.preview))}

    ${escapeHtml(label(DETAIL_UI.version))}: ${escapeHtml(model.previewVersion ?? "—")}

    ` : ""} + ${model.preview ? "" : `
    ${kind === "dev" ? "dev-dependencies" : kind === "build" ? "build-dependencies" : "dependencies"}${current.resolvedVersion && current.resolvedVersion !== current.declaredVersion ? `${escapeHtml(label(DETAIL_UI.locked))}: ${escapeHtml(current.resolvedVersion)}` : ""}
    `} +
    + ${model.preview ? "" : `
    `} + ${model.repo === undefined ? "" : link(model.repo, label(DETAIL_UI.openRepo))} + ${model.indexUrl === undefined ? "" : link(model.indexUrl, label(DETAIL_UI.indexLink))} +
    + ${model.versionNotice ? `

    ${escapeHtml(model.versionNotice)}

    ` : ""} + ${model.blockedReason ? `

    ${escapeHtml(model.blockedReason)}

    ` : ""} +

    ${escapeHtml(manual ? label(kind === "build" ? DETAIL_UI.buildHint : DETAIL_UI.moveHint) : "")}

    +

    ${escapeHtml(command)}

    +

    ${escapeHtml(model.result?.message ?? "")}

    +
    +
    `; +} + +export function detailClientState(model: DetailModel): Record { + return { + key: model.key ?? model.id, generation: model.generation ?? 0, projectRoot: model.projectRoot, + id: model.id, versions: model.currentVersions, version: selectedVersion(model), + groups: groupsFor(model), kind: model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"), + locations: Object.fromEntries((["normal", "dev", "build"] as const).map((kind) => [kind, dependencyLocation(model.id, kind)])), + loading: !!model.loading, canOpenManifest: model.manifestAvailable ?? !!model.projectRoot, + blocked: !!model.blockedReason, busy: !!model.busy, preview: !!model.preview, + command: model.commandTemplate, commandDev: model.commandDevTemplate, + labels: Object.fromEntries(Object.entries(DETAIL_UI).map(([name, key]) => [name, model.ui[key] ?? key])), + }; +} + +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; + var pages = saved.pages || {}; + var busy = state.busy; + var version = state.version || ""; + var kind = state.kind || "normal"; + var copyId = 0; + var copyRequests = {}; + function copyLabel(button, text) { (button.querySelector("[data-action-label]") || button).textContent = text; } + function restoreCopyFeedback() { + Object.keys(copyRequests).forEach(function (token) { + var request = copyRequests[token]; + if (request.key !== pageKey() || request.until < Date.now()) { delete copyRequests[token]; return; } + var buttons = Array.from(document.querySelectorAll(request.command ? "[data-copy-command]" : "[data-copy]")); + var button = buttons.find(function (node) { return request.command ? document.getElementById("detail-command").textContent === request.text : node.getAttribute("data-copy") === request.text; }); + if (button) { button.setAttribute("data-copy-token", token); if (request.message) { copyLabel(button, request.message); } } + }); + } + function pageKey() { return state.key + "|" + (state.projectRoot || ""); } + function post(message) { message.pageKey = state.key; message.generation = state.generation; if (api) { api.postMessage(message); } } + function persist() { + if (state.loading) { return; } + var expanded = Array.from(document.querySelectorAll("details[data-persist]")).filter(function (node) { return node.open; }).map(function (node) { return node.getAttribute("data-persist"); }); + pages[pageKey()] = { version: version, kind: kind, scroll: window.scrollY, expanded: expanded }; + var keys = Object.keys(pages); while (keys.length > 20) { delete pages[keys.shift()]; } + if (api && api.setState) { api.setState({ pages: pages }); } + } + function closeKindMenu(focus) { + var menu = document.getElementById("detail-kind-menu"); + var toggle = document.getElementById("detail-kind-toggle"); + if (menu) { menu.hidden = true; } + if (toggle) { toggle.setAttribute("aria-expanded", "false"); if (focus) { toggle.focus(); } } + } + function manualSelection() { + var group = state.groups[kind]; + return kind === "build" || group.ambiguous || group.declared && group.sourceType !== "registry" || !group.declared && state.groups[kind === "dev" ? "normal" : "dev"].declared; + } + function update() { + var select = document.getElementById("detail-version"); + var button = document.getElementById("detail-add"); + if (state.preview) { return; } + var group = state.groups[kind]; + var manual = manualSelection(); + var same = !!group.declaredVersion && group.declaredVersion === version && !manual; + if (select) { select.value = version; select.disabled = busy || state.loading || !state.versions.length; } + var toggle = document.getElementById("detail-kind-toggle"); if (toggle) { toggle.disabled = busy || state.loading; } + var kindLabel = document.getElementById("detail-kind-label"); + if (kindLabel) { kindLabel.textContent = kind === "dev" ? "dev-dependencies" : kind === "build" ? "build-dependencies" : "dependencies"; } + document.querySelectorAll("[data-kind]").forEach(function (item) { item.setAttribute("aria-checked", String(item.getAttribute("data-kind") === kind)); }); + if (button) { + button.disabled = busy || state.loading || (manual ? !state.canOpenManifest : state.blocked || !version || same); + button.textContent = busy ? state.labels.pending : manual ? state.labels.openManifest : same ? state.labels.alreadyAdded : group.declared ? state.labels.switchTo.split("{0}").join(version) : kind === "dev" ? state.labels.addDevAction : state.labels.add; + if (same) { button.setAttribute("data-installed", ""); } else { button.removeAttribute("data-installed"); } + } + var note = document.getElementById("detail-selection-note"); if (note) { note.textContent = manual ? kind === "build" ? state.labels.buildHint : state.labels.moveHint : ""; note.hidden = !manual; } + var command = document.getElementById("detail-command"); + var location = state.locations[kind]; + if (command) { command.textContent = manual ? "[" + location.table + "]\\n" + location.name + " = " + JSON.stringify(version || "") : (kind === "dev" ? state.commandDev : state.command).split("{0}").join(state.id).split("{1}").join(version || "?"); } + } + function restore() { + var page = pages[pageKey()] || {}; + if (state.versions.indexOf(page.version) >= 0) { version = page.version; } + kind = ["normal", "dev", "build"].indexOf(page.kind) >= 0 ? page.kind : state.kind || "normal"; + document.querySelectorAll("details[data-persist]").forEach(function (node) { node.open = (page.expanded || []).indexOf(node.getAttribute("data-persist")) >= 0; }); + update(); window.scrollTo(0, page.scroll || 0); + } + document.addEventListener("change", function (event) { + if (event.target.id === "detail-version") { version = event.target.value; } + if (event.target.id === "detail-version") { update(); persist(); } + }); + document.addEventListener("toggle", persist, true); + window.addEventListener("scroll", persist); + document.addEventListener("click", function (event) { + var target = event.target.closest && event.target.closest("button"); + if (!target || target.disabled) { return; } + if (target.id === "detail-kind-toggle") { + var menu = document.getElementById("detail-kind-menu"); menu.hidden = !menu.hidden; + target.setAttribute("aria-expanded", String(!menu.hidden)); + if (!menu.hidden) { menu.querySelector('[aria-checked="true"]').focus(); } + return; + } + if (target.hasAttribute("data-kind")) { + kind = target.getAttribute("data-kind"); + var declared = state.groups[kind].declaredVersion; + if (state.versions.indexOf(declared) >= 0) { version = declared; } + closeKindMenu(true); update(); persist(); return; + } + closeKindMenu(false); + var action = target.getAttribute("data-action"); + if (action) { post({ type: action }); return; } + if (target.hasAttribute("data-open-dependency")) { persist(); post({ type: "openDependency", key: target.getAttribute("data-open-dependency") }); return; } + if (target.hasAttribute("data-open-file")) { post({ type: "openFile", key: target.getAttribute("data-open-file") }); return; } + if (target.hasAttribute("data-open-url")) { post({ type: "openUrl", url: target.getAttribute("data-open-url") }); return; } + if (target.hasAttribute("data-copy") || target.hasAttribute("data-copy-command")) { + var token = "copy-" + (++copyId); target.setAttribute("data-copy-token", token); + copyRequests[token] = { key: pageKey(), command: target.hasAttribute("data-copy-command"), text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, until: Date.now() + 20000 }; + post({ type: "copy", text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, token: token }); return; + } + if (target.id === "detail-add" && !busy) { + if (manualSelection()) { post({ type: "openManifest" }); return; } + if (!version) { return; } + busy = true; update(); + var result = document.getElementById("detail-result"); result.hidden = false; result.dataset.state = "pending"; result.textContent = state.labels.pending; + persist(); post({ type: "add", version: version, dev: kind === "dev" }); + } + }); + document.addEventListener("click", function (event) { if (!event.target.closest || !event.target.closest(".detail-add-group")) { closeKindMenu(false); } }); + document.addEventListener("keydown", function (event) { + var menu = document.getElementById("detail-kind-menu"); + if (!menu || menu.hidden) { return; } + if (event.key === "Escape") { event.preventDefault(); closeKindMenu(true); return; } + if (event.key === "Tab") { closeKindMenu(false); return; } + var options = Array.from(menu.querySelectorAll("[data-kind]")); + if (["ArrowDown", "ArrowUp", "Home", "End"].indexOf(event.key) < 0) { return; } + event.preventDefault(); var index = options.indexOf(document.activeElement); + index = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : options.length - 1)) % options.length; + options[index].focus(); + }); + window.addEventListener("message", function (event) { + var data = event.data; + if (!data) { return; } + if (data.type === "detailUpdate") { + persist(); + var focused = document.activeElement && document.activeElement.id; + var doc = new DOMParser().parseFromString(data.html, "text/html"); + ["detail-header", "detail-main", "detail-footer"].forEach(function (id) { var old = document.getElementById(id); var next = doc.getElementById(id); if (old && next) { old.replaceWith(next); } }); + state = data.state; busy = state.busy; version = state.version || ""; kind = state.kind || "normal"; + restore(); restoreCopyFeedback(); + var nextFocus = focused && document.getElementById(focused); if (nextFocus) { nextFocus.focus({ preventScroll: true }); } + return; + } + if (data.key !== state.key || data.generation !== state.generation) { return; } + if (data.type === "copyResult") { + var request = copyRequests[data.token]; + if (request) { + request.message = data.message; request.until = Date.now() + 1800; restoreCopyFeedback(); + setTimeout(function () { var button = document.querySelector('[data-copy-token="' + data.token + '"]'); if (button) { copyLabel(button, state.labels.copy); } delete copyRequests[data.token]; }, 1800); + } + } + if (data.type === "linkResult") { var status = document.getElementById("detail-link-result"); status.textContent = data.message; status.hidden = !data.message; } + }); + restore(); + post({ type: "mounted" }); +})();`; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const title = fill(label, DETAIL_UI.title, [model.id]); + const currentPlatform = model.versions.find((group) => group.current)?.platform; + const versionsHeading = + currentPlatform === undefined ? label(DETAIL_UI.versionsAll) : fill(label, DETAIL_UI.versions, [currentPlatform]); + return ` + + + + + + +${escapeHtml(title)} + + +
    +

    ${escapeHtml(model.id)}

    ${renderMeta(model, label)}
    ${renderProject(model, label)}
    + ${model.description === undefined ? "" : `

    ${escapeHtml(model.description)}

    `} +
    +
    + ${renderActions(model, label)} + + ${model.preview ? "" : `
    +
    +

    ${escapeHtml(label(DETAIL_UI.usage))}

    + ${model.usage.length ? `

    ${escapeHtml(label(DETAIL_UI.usageSource))}

    ` + model.usage.map((line) => `
    ${escapeHtml(line)}
    `).join("") : model.snippets.length ? "" : `

    ${escapeHtml(label(DETAIL_UI.unknownUsage))}

    `} + ${model.snippets.length ? renderSnippets(model, label) : ""} + ${model.exampleProject === undefined ? "" : `

    ${escapeHtml(fill(label, DETAIL_UI.codeProject, [model.exampleProject]))}

    `} + ${model.exampleProvenance === undefined ? "" : `

    ${escapeHtml(model.exampleProvenance)}

    `} +
    + +
    `} +
    +
    ${escapeHtml(label(DETAIL_UI.source))}

    ${escapeHtml(model.dataSource)}

    ${model.projectRoot ? `` : ""}

    ${escapeHtml(badgeLabels(model.badges, label).join(" · "))}

    ${model.parseNotice === undefined ? "" : `

    ${escapeHtml(model.parseNotice)}

    `}
    + + + +`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not one of the three shapes is dropped. `openUrl` is restricted to `https` + * here as well as in the host, so a compromised document cannot ask the host to + * open a `file:` or `command:` uri. + */ +export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "mounted": + case "selectProject": + case "showOutput": + case "retry": + case "openManifest": + return { type: raw.type }; + case "openDependency": + case "openFile": + return nonEmptyString(raw.key) ? { type: raw.type, key: raw.key } : undefined; + case "add": + return nonEmptyString(raw.version) && typeof raw.dev === "boolean" + ? { type: "add", version: raw.version, dev: raw.dev } + : undefined; + case "openUrl": + return typeof raw.url === "string" && raw.url.startsWith("https://") + ? { type: "openUrl", url: raw.url } + : undefined; + case "copy": + // Clipboard content, same trust level as the https-restricted url. The + // cap is 16 KiB — far above any command or usage line, low enough that a + // hostile document cannot park a novel in the clipboard — and everything + // else about the text is the user's own click. + return nonEmptyString(raw.text) && raw.text.length <= 16_384 + ? { type: "copy", text: raw.text, ...(typeof raw.token === "string" && /^copy-\d+$/.test(raw.token) ? { token: raw.token } : {}) } + : undefined; + default: + return undefined; + } +} diff --git a/mide-plugins/vscode/src/library/detailPanel.ts b/mide-plugins/vscode/src/library/detailPanel.ts new file mode 100644 index 0000000..2fb0adb --- /dev/null +++ b/mide-plugins/vscode/src/library/detailPanel.ts @@ -0,0 +1,632 @@ +/** + * The package detail page: a `WebviewPanel` in the **editor area** (plan §12.1). + * + * The sidebar answers "which package"; this page answers "what is it, how do I + * use it, how do I add it". It is where the authoritative reader finally runs: + * `mcpp xpkg parse --json` (see `src/library/xpkg.ts`), on + * demand, for the one package the reader opened — never once per row. + * + * Boundaries, in the same spirit as `src/cache/cachePanel.ts`: + * + * - **One panel per window.** Opening another package re-renders the existing + * panel instead of stacking a second one. + * - **A failure still renders.** A descriptor that cannot be parsed, an example + * that disappeared, or a failed `mcpp add` each produce a document with the + * reason in it. + * - **Nothing is written here.** The add button delegates to + * `src/library/addDependency.ts`, which runs `mcpp add`; `mcpp.toml` is mcpp's + * file. + */ + +import { existsSync, readFileSync } from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { WebviewDocument } from "../webview/document"; +import { projectDependencyState, readProjectDeclarations, operationMatches } from "./projectState"; +import type { LibraryRow } from "./libraryHtml"; +import { addDependency } from "./addDependency"; +import { + badgesOf, + descriptorDependencies, + lockPackageVersions, + mergeSurfaces, + parseXpkgJson, + platformKey, + surfaceLabel, + usageLinesFor, + type LibraryEntry, + type Surface, +} from "./indexModel"; +import { cachedDescriptorText, loadSnapshot, readDescriptorText, readExampleFiles } from "./indexLocator"; +import { bundledExample, ExampleDocuments, EXAMPLE_SCHEME } from "./examples"; +import { + DETAIL_UI, + decodeDetailMessage, + renderDetailHtml, + detailClientState, + type DetailModel, + type DetailVersionGroup, + type DetailResult, +} from "./detailHtml"; +import { compareVersions, versionGroups, type XpkgInfo } from "./xpkg"; + +const PANEL_VIEW_TYPE = "mcpp.libraryDetail"; +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "library.css"; + +/** `mcpp xpkg parse` is a local, read-only command; this is generous. */ +const PARSE_TIMEOUT_MS = 20_000; + +export interface DetailPanelDeps { + mcppExecutable: (projectRoot?: string) => string; + chooseProject?: () => Promise; + onPackageOpened?: (key: string) => void; + projectRoot: () => string | undefined; + output: vscode.OutputChannel; + isTrusted: () => boolean; + /** + * Called after a successful `mcpp add`. `mcpp add` writes `mcpp.toml` on disk + * without the editor saving a document, so no save event fires; the sidebar's + * "已添加" state would otherwise stay stale. `extension.ts` wires this to the + * library view's `refresh()`. + */ + onDependenciesChanged?: () => void; + onProjectFilesRead?: (root: string, manifest: string, lock: string) => void; +} + +/** Build the opener once; `extension.ts` passes `openLibraryDetail` to the view. */ +export function createLibraryDetailOpener( + context: vscode.ExtensionContext, + deps: DetailPanelDeps, +): LibraryDetailOpener { + const sourceChanged = new vscode.EventEmitter(); + const exampleDocuments = new ExampleDocuments((uri) => sourceChanged.fire(vscode.Uri.parse(uri))); + context.subscriptions.push(sourceChanged, vscode.workspace.registerTextDocumentContentProvider(EXAMPLE_SCHEME, { + onDidChange: sourceChanged.event, + provideTextDocumentContent: (uri) => exampleDocuments.read(uri.toString()) ?? "", + })); + const session: DetailSession = { + context, + exampleDocuments, + deps, + panel: undefined, + id: undefined, + busy: false, + generation: 0, + projectRoot: deps.projectRoot(), + painted: false, + webviewDocument: new WebviewDocument(STYLESHEET), + }; + context.subscriptions.push({ + dispose: () => { + session.panel?.dispose(); + session.panel = undefined; + }, + }); + const opener = (id: string, preview?: LibraryRow) => open(session, id, preview); + opener.refresh = async () => { + if (session.id === undefined || session.panel === undefined) { return; } + const root = deps.projectRoot(); + if (root !== session.projectRoot) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + } + await render(session, session.id); + }; + return opener; +} + +export interface LibraryDetailOpener { + (id: string, preview?: LibraryRow): Promise; + refresh(): Promise; +} + +/** One panel per window, remembered so a second `open` reveals rather than stacks. */ +export interface DetailSession { + exampleDocuments: ExampleDocuments; + context: vscode.ExtensionContext; + deps: DetailPanelDeps; + panel: vscode.WebviewPanel | undefined; + id: string | undefined; + /** `mcpp add` in flight: a second click must not start a second command. */ + busy: boolean; + generation: number; + renderRequest?: number; + projectRoot?: string; + preview?: LibraryRow; + model?: DetailModel; + painted: boolean; + lastHtml?: string; + result?: DetailResult; + /** + * The document and its nonce, kept per session so re-opening the *same* + * package renders byte for byte the same document and does not reload the + * page (which would throw away the reader's scroll position). + */ + webviewDocument: WebviewDocument; +} + +/** The parse result per descriptor, so re-opening a package costs no process. */ +const parsed = new Map(); + +export async function open(session: DetailSession, id: string, preview?: LibraryRow): Promise { + if (session.panel === undefined) { + const panel = vscode.window.createWebviewPanel( + PANEL_VIEW_TYPE, + t("Library"), + vscode.ViewColumn.Active, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY)], + retainContextWhenHidden: false, + }, + ); + session.panel = panel; + panel.onDidDispose(() => { + session.panel = undefined; + session.id = undefined; + session.generation++; + session.painted = false; + session.lastHtml = undefined; + // The next open creates a brand-new webview: what was on screen says + // nothing about it, so the document comparison starts from nothing. + session.webviewDocument.invalidate(); + }); + panel.webview.onDidReceiveMessage((raw: unknown) => { + void handle(session, raw); + }); + } + const panel = session.panel; + const changed = session.id !== id; + session.id = id; + session.preview = preview; + session.deps.onPackageOpened?.(id); + if (changed) { session.generation++; session.result = undefined; } + const nextProject = session.deps.projectRoot(); + if (nextProject !== session.projectRoot) { session.generation++; session.result = undefined; } + session.projectRoot = nextProject; + panel.title = preview?.id ?? path.basename(id, ".lua"); + if (changed || !session.painted) { + const loading = emptyModel(preview?.id ?? path.basename(id, ".lua"), labels(), t("Reading package details…")); + loading.key = id; loading.generation = session.generation; loading.projectRoot = session.projectRoot; + loading.loading = true; loading.blockedReason = t("Reading package details…"); + paintModel(session, loading); + } + panel.reveal(vscode.ViewColumn.Active, false); + await render(session, id); +} + +async function handle(session: DetailSession, raw: unknown): Promise { + const message = decodeDetailMessage(raw); + if (message === undefined || session.panel === undefined || session.id === undefined) { return; } + const envelope = raw as { pageKey?: unknown; generation?: unknown }; + if (message.type === "mounted") { + if (session.model) { paintModel(session, session.model, true); } + return; + } + if (envelope.pageKey !== session.id || envelope.generation !== session.generation) { return; } + const operation = { key: session.id, projectRoot: session.projectRoot, generation: session.generation }; + const current = () => operationMatches(operation, { key: session.id ?? "", projectRoot: session.projectRoot, generation: session.generation }); + switch (message.type) { + case "selectProject": { + const root = await session.deps.chooseProject?.(); + if (root !== undefined && current()) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + await render(session, session.id); + } + return; + } + case "showOutput": session.deps.output.show(true); return; + case "openManifest": + if (session.projectRoot) { await vscode.window.showTextDocument(vscode.Uri.file(path.join(session.projectRoot, "mcpp.toml"))); } + return; + case "retry": + if (session.result?.state === "error") { session.result = undefined; } + await render(session, session.id); return; + case "openDependency": + if (session.model?.dependencies.some((dependency) => dependency.key === message.key)) { await open(session, message.key); } + return; + case "openFile": { + if (!session.model?.snippets.some((snippet) => snippet.fileKey === message.key)) { return; } + if (message.key.startsWith(`${EXAMPLE_SCHEME}:`)) { await vscode.window.showTextDocument(vscode.Uri.parse(message.key)); } + return; + } + case "copy": { + let result: string; + try { await vscode.env.clipboard.writeText(message.text); result = t("Copied"); } + catch { result = t("Copy failed"); } + if (current()) { void session.panel?.webview.postMessage({ type: "copyResult", key: operation.key, generation: operation.generation, token: message.token, message: result }); } + return; + } + case "openUrl": { + // Only links in this package's model can ask the host to open a browser. + if (message.url !== session.model?.repo && message.url !== session.model?.indexUrl) { return; } + let opened = false; + try { opened = await vscode.env.openExternal(vscode.Uri.parse(message.url)); } catch { /* report below */ } + let copied = false; + if (!opened) { try { await vscode.env.clipboard.writeText(message.url); copied = true; } catch { /* accurate feedback below */ } } + if (current()) { void session.panel?.webview.postMessage({ type: "linkResult", key: operation.key, generation: operation.generation, + message: opened ? t("Opened in your browser.") : copied ? t("Could not open the browser. Link copied to clipboard.") : t("Could not open the browser or copy the link: {0}", message.url) }); } + return; + } + case "add": { + const model = session.model; + if (session.busy || !model || model.blockedReason || model.preview || !model.currentVersions.includes(message.version)) { return; } + const state = model.dependencyGroups?.[message.dev ? "dev" : "normal"] ?? model.projectState; + const other = model.dependencyGroups?.[message.dev ? "normal" : "dev"]; + if (state?.ambiguous || state?.declared && state.sourceType !== "registry" || !state?.declared && other?.declared) { return; } + session.busy = true; + session.result = { state: "pending", message: t("Adding dependency…") }; + paintModel(session, { ...model, busy: true, result: session.result }); + try { + const executable = session.deps.mcppExecutable(operation.projectRoot); + const snapshot = await loadSnapshot({ projectRoot: operation.projectRoot }); + const declarations = readProjectDeclarations(operation.projectRoot ? readTextTolerantly(path.join(operation.projectRoot, "mcpp.toml")) : ""); + const catalog = snapshot.entries.map((entry) => entry.id); + const fresh = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "dev" : "normal"); + const otherGroup = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "normal" : "dev"); + if (fresh.ambiguous || fresh.declared && fresh.sourceType !== "registry" || !fresh.declared && otherGroup.declared) { + if (current()) { session.result = { state: "error", message: t("The dependency declaration changed. Review mcpp.toml before adding it.") }; } + return; + } + const outcome = await addDependency({ + mcppExecutable: () => executable, + projectRoot: () => operation.projectRoot, + isTrusted: session.deps.isTrusted, + output: session.deps.output, + quiet: true, + }, { id: model.id, version: message.version, dev: message.dev }); + if (outcome.ok) { session.deps.onDependenciesChanged?.(); } + if (current() && session.panel) { + session.result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; + } else { + void (outcome.ok ? vscode.window.showInformationMessage(outcome.message) : vscode.window.showErrorMessage(outcome.message)); + } + } catch (error) { + session.deps.output.appendLine(String(error)); + if (current()) { session.result = { state: "error", message: t("Could not add the dependency. See output for details.") }; } + } finally { + session.busy = false; + if (session.panel && session.id) { await render(session, session.id); } + } + return; + } + } +} + +function paintModel(session: DetailSession, model: DetailModel, replay = false): void { + const panel = session.panel; + if (!panel) { return; } + session.model = model; + const html = renderDetailHtml(model, session.webviewDocument.assets(vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), panel.webview)); + if (session.lastHtml === html && !replay) { return; } + session.lastHtml = html; + if (!session.painted && !replay) { + session.webviewDocument.paint(panel.webview, html); session.painted = true; + } else { + void panel.webview.postMessage({ type: "detailUpdate", html, state: detailClientState(model) }); + } +} + +async function render(session: DetailSession, id: string): Promise { + const panel = session.panel; + const generation = session.generation; + const request = session.renderRequest = (session.renderRequest ?? 0) + 1; + const root = session.projectRoot; + if (!panel) { return; } + let model: DetailModel; + try { model = await buildModel(session, id, request); } + catch (error) { + session.deps.output.appendLine(String(error)); + model = emptyModel(path.basename(id, ".lua"), labels(), t("Could not read package details. See output for details.")); + model.blockedReason = model.parseNotice; + model.result = { state: "error", message: model.parseNotice ?? "" }; + } + if (session.panel !== panel || session.id !== id || generation !== session.generation || request !== session.renderRequest || root !== session.projectRoot) { return; } + paintModel(session, { ...model, key: id, generation, busy: session.busy, result: session.result ?? model.result }); +} + +async function buildModel(session: DetailSession, id: string, request: number): Promise { + const projectRoot = session.projectRoot; + const generation = session.generation; + const snapshot = await loadSnapshot(projectRoot === undefined ? {} : { projectRoot }); + const entry = snapshot.entries.find((candidate) => candidate.file === id || candidate.id === id); + // The root the descriptor lives under, matched by path: two roots may share a + // directory name, and the site link belongs to the one that holds the file. + const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => { const relative = path.relative(candidate.path, entry.file); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }); + const ui = labels(); + if (entry === undefined) { + const preview = session.preview; + const model = emptyModel(preview?.id ?? path.basename(id, ".lua"), ui, preview ? t("This is a search preview. Set an index path containing this package to read its complete details.") : t("This package is not in the local index any more.")); + model.preview = preview !== undefined; + model.previewVersion = preview?.version; + model.description = preview?.description; + model.registry = preview?.source ?? ""; + model.projectRoot = projectRoot; + model.blockedReason = preview ? t("Search previews cannot be added. Open a complete local descriptor first.") : t("The package descriptor is missing. Reread the local index."); + return model; + } + const text = cachedDescriptorText(entry.file) ?? (await readDescriptorText(entry.file)) ?? ""; + const platform = platformKey(process.platform); + // Keyed by the index revision as well as the path, so a refreshed index is not + // answered from a parse of the previous one. + const cacheKey = `${snapshot.revision}\u0000${entry.file}\u0000${session.deps.isTrusted()}\u0000${session.deps.mcppExecutable(projectRoot)}\u0000${projectRoot ?? ""}`; + let info = parsed.get(cacheKey); + if (!parsed.has(cacheKey)) { + if (session.id === id && session.generation === generation && session.renderRequest === request && session.model?.loading) { + const lightweight: DetailModel = { + ...emptyModel(entry.id, ui, t("Reading package details…")), + key: id, generation, projectRoot, platform: platform ?? process.platform, + description: entry.description, registry: entry.registry, surface: entry.surface, surfaces: entry.surfaces, + licenses: entry.licenses, repo: entry.repo, badges: badgesOf(entry), + currentVersions: entry.versions[platform ?? ""] ?? [], latest: entry.version, + versions: versionGroups(entry.versions, platform).map((group) => ({ ...group, current: group.platform === platform })), + loading: true, blockedReason: t("Reading package details…"), dataSource: dataSource(entry, snapshot.roots.length), + }; + paintModel(session, lightweight); + } + info = await readXpkg(session, entry, projectRoot); + if (info !== undefined) { parsed.set(cacheKey, info); } + while (parsed.size > 100) { parsed.delete(parsed.keys().next().value!); } + } + + const versions = info === undefined ? entry.versions : info.versions; + const groups: DetailVersionGroup[] = versionGroups(versions, platform).map((group) => ({ + platform: group.platform, + versions: group.versions, + current: group.platform === platform, + })); + const currentVersions = platform === undefined ? [] : (versions[platform] ?? []); + const latest = currentVersions.length === 0 ? undefined : sortVersions(currentVersions)[0]; + + const localExamples = entry.example === undefined ? [] : await readExampleFiles(root ? [root] : [], entry.example); + const bundled = root?.kind === "official" && localExamples.length === 0 ? bundledExample(entry.id) : undefined; + const exampleFiles = localExamples.length ? localExamples : bundled?.files ?? []; + const snippets = exampleFiles.slice(0, 2).map((file) => ({ + file: file.path, startLine: 1, usageLine: 0, lines: file.text.split(/\r?\n/).slice(0, 80), + fileKey: session.exampleDocuments.capture(file), + })); + // Only verified lines from real example source are copyable. + const realUsage = usageLinesFor(exampleFiles, entry.id); + const usage = realUsage; + + // The lock answers what a build actually resolved for each declared edge — + // the same matching rule `installedFor` uses for this package itself. + const lockText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.lock")) : ""; + const manifestText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.toml")) : ""; + if (projectRoot) { session.deps.onProjectFilesRead?.(projectRoot, manifestText, lockText); } + const lock = lockPackageVersions(lockText); + const declarations = readProjectDeclarations(manifestText); + const catalog = snapshot.entries.map((entry) => entry.id); + const dependencyGroups = { + normal: projectDependencyState(entry.id, declarations, lock, catalog, "normal"), + dev: projectDependencyState(entry.id, declarations, [], catalog, "dev"), + build: projectDependencyState(entry.id, declarations, [], catalog, "build"), + }; + const dependencyKind = dependencyGroups.normal.declared ? "normal" : dependencyGroups.dev.declared ? "dev" : dependencyGroups.build.declared ? "build" : "normal"; + const state = dependencyGroups[dependencyKind]; + const dependencies = descriptorDependencies(text).map((dependency) => { + const resolved = lock.find((entry) => entry.id === dependency.id)?.version; + const local = snapshot.entries.find((entry) => entry.id === dependency.id); + return { ...dependency, ...(resolved ? { resolved } : {}), ...(local ? { key: local.file } : {}) }; + }); + const model: DetailModel = { + ui, + projectRoot, + manifestAvailable: !!projectRoot && existsSync(path.join(projectRoot, "mcpp.toml")), + platform: platform ?? process.platform, + projectState: state, + dependencyGroups, + dependencyKind, + blockedReason: !session.deps.isTrusted() ? t("Trust this workspace to run mcpp and add dependencies.") + : !projectRoot || !existsSync(path.join(projectRoot, "mcpp.toml")) ? t("Select an mcpp project before adding this library.") + : platform === undefined ? t("The current environment platform is not recognized by this index.") + : latest === undefined ? info === undefined ? t("Version availability could not be confirmed. Retry reading this descriptor.") : t("No version is published for the current environment platform.") + : undefined, + versionNotice: info === undefined ? t("Versions are from descriptor text; mcpp will confirm availability when adding.") : undefined, + id: entry.id, + name: entry.name, + ...(entry.description === undefined ? {} : { description: entry.description }), + licenses: entry.licenses, + ...(entry.repo === undefined ? {} : { repo: entry.repo }), + registry: entry.registry, + ...(mergeSurfaces(info, text)[0] === undefined ? {} : { surface: mergeSurfaces(info, text)[0] }), + surfaces: mergeSurfaces(info, text), + badges: badgesOf(entry), + versions: groups, + currentVersions: sortVersions(currentVersions), + ...(latest === undefined ? {} : { latest }), + ...(info?.standard === undefined ? {} : { standard: info.standard }), + dependencies, + includeDirs: info?.includeDirs ?? [], + targets: (info?.targets ?? []).map((target) => target.name ?? "").filter((name) => name.length > 0), + snippets, + ...(bundled ? { exampleProject: bundled.project, exampleProvenance: t("Bundled official index tests · commit {0}", bundled.commit.slice(0, 12)) } : entry.example ? { exampleProject: entry.example.project } : {}), + usage, + // One discreet link, and only when this root really is the index that + // publishes those package pages (see `IndexRoot.site`). + ...(root?.site === undefined ? {} : { indexUrl: `${root.site}/${entry.id}/` }), + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + ...(info === undefined + ? { + parseNotice: session.deps.isTrusted() + ? t( + "mcpp xpkg parse could not read this descriptor; the versions below come from its text, which is less authoritative.", + ) + : t("This workspace is not trusted, so mcpp is not run; the versions below come from the descriptor's text."), + } + : {}), + dataSource: dataSource(entry, snapshot.roots.length), + }; + return model; +} + +/** Run the authoritative reader for one descriptor; never throws. */ +async function readXpkg(session: DetailSession, entry: LibraryEntry, projectRoot?: string): Promise { + // `mcpp.path` is resource-scoped, so an untrusted workspace must not name + // the program: the refusal degrades to the descriptor's own text. + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Reading {0}…", entry.id) }, + () => + runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(projectRoot), ["xpkg", "parse", entry.file, "--json"], projectRoot, { + timeoutMs: PARSE_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + return undefined; + } + if (result.exitCode !== 0) { + session.deps.output.appendLine(`mcpp xpkg parse ${entry.file} failed with exit code ${result.exitCode}`); + if (result.stderr.trim().length > 0) { + session.deps.output.appendLine(result.stderr.trimEnd()); + } + return undefined; + } + return parseXpkgJson(result.stdout); +} + +function dataSource(entry: LibraryEntry, roots: number): string { + return t( + "Read offline from {0} in the {1} index ({2} index folder(s) found).", + entry.file, + entry.registry, + roots, + ); +} + +/** A model that still renders: the page says why it is empty instead of going blank. */ +function emptyModel(id: string, ui: Record, notice: string): DetailModel { + return { + ui, + id, + name: id, + licenses: [], + registry: "", + surfaces: [], + badges: [], + versions: [], + currentVersions: [], + dependencies: [], + includeDirs: [], + targets: [], + snippets: [], + usage: [], + commandTemplate: t("mcpp add {0}@{1}"), + commandDevTemplate: t("mcpp add {0}@{1} --dev"), + parseNotice: notice, + dataSource: t("No descriptor was read."), + }; +} + +function readTextTolerantly(file: string): string { + try { return readFileSync(file, "utf8"); } catch { return ""; } +} + +/** Every visible string, resolved once per model. */ +function labels(): Record { + return { + [DETAIL_UI.htmlLang]: htmlLanguage(), + [DETAIL_UI.platforms]: t("Platforms and versions"), + [DETAIL_UI.kind]: t("Dependency type"), + [DETAIL_UI.kindNormal]: t("Project dependency"), + [DETAIL_UI.kindDev]: t("Development dependency"), + [DETAIL_UI.kindBuild]: t("Build dependency"), + [DETAIL_UI.addDevAction]: t("Add development dependency"), + [DETAIL_UI.buildHint]: t("Build dependencies are used by the build script. Copy this entry into mcpp.toml; mcpp add does not support this group yet."), + [DETAIL_UI.moveHint]: t("Changing this declaration requires a manifest edit. Copy the entry and update its dependency group in mcpp.toml."), + [DETAIL_UI.chooseProject]: t("Choose project"), + [DETAIL_UI.platform]: t("Environment platform"), + [DETAIL_UI.version]: t("Version"), + [DETAIL_UI.otherPlatforms]: t("Other platforms (read only)"), + [DETAIL_UI.declared]: t("Declared"), + [DETAIL_UI.locked]: t("Resolved in lockfile"), + [DETAIL_UI.sourceDependency]: t("Source dependency"), + [DETAIL_UI.unknownUsage]: t("No verified import or header path is available locally. See the repository or index site for usage."), + [DETAIL_UI.usageSource]: t("From the index's example source"), + [DETAIL_UI.source]: t("Source and diagnostics"), + [DETAIL_UI.loading]: t("Reading package details…"), + [DETAIL_UI.pending]: t("Adding dependency…"), + [DETAIL_UI.groupChange]: t("Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration."), + [DETAIL_UI.output]: t("Show output"), + [DETAIL_UI.retry]: t("Retry reading details"), + [DETAIL_UI.openManifest]: t("Open mcpp.toml"), + [DETAIL_UI.openFile]: t("Source"), + [DETAIL_UI.readOnlySource]: t("Open read-only source"), + [DETAIL_UI.preview]: t("Search preview: only the fields returned by mcpp search are available."), + [DETAIL_UI.title]: t("Library package {0}"), + [DETAIL_UI.overview]: t("Build shape"), + [DETAIL_UI.license]: t("License"), + [DETAIL_UI.repo]: t("Repository"), + [DETAIL_UI.openRepo]: t("Open the repository"), + [DETAIL_UI.registry]: t("Registry"), + [DETAIL_UI.surface]: t("Use"), + [DETAIL_UI.surfaceExternal]: t("upstream mcpp.toml"), + [DETAIL_UI.standard]: t("Standard"), + [DETAIL_UI.versions]: t("Versions ({0})"), + [DETAIL_UI.versionsAll]: t("Versions"), + [DETAIL_UI.versionsCurrent]: t("this platform"), + [DETAIL_UI.versionsNone]: t("This index publishes no version for any platform."), + [DETAIL_UI.versionsPick]: t("Click a version to aim the command above at it."), + [DETAIL_UI.dependencies]: t("Dependencies"), + [DETAIL_UI.dependenciesNone]: t("This descriptor declares no dependencies."), + [DETAIL_UI.dependenciesHint]: t( + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.", + ), + [DETAIL_UI.resolved]: t("resolved {0}"), + [DETAIL_UI.code]: t("Example code"), + [DETAIL_UI.codeNone]: t("This package has no test project in the index, so there is no example to show."), + [DETAIL_UI.codeSource]: t("{0} · line {1}"), + [DETAIL_UI.codeProject]: t("Example project: {0} — built and run by the index's CI, not written for this page."), + [DETAIL_UI.add]: t("Add dependency"), + [DETAIL_UI.addDev]: t("dev dependency"), + [DETAIL_UI.addLatest]: t("The version is required: mcpp accepts an exact version only."), + [DETAIL_UI.switchTo]: t("Switch to {0}"), + [DETAIL_UI.alreadyAdded]: t("Already declared"), + [DETAIL_UI.installed]: t("added"), + [DETAIL_UI.opening]: t("Opening {0}…"), + [DETAIL_UI.addNoVersion]: t("This index publishes no version for this platform, so there is nothing to add."), + [DETAIL_UI.command]: t("Command"), + [DETAIL_UI.usage]: t("Usage"), + [DETAIL_UI.copy]: t("Copy"), + [DETAIL_UI.copied]: t("Copied to the clipboard."), + [DETAIL_UI.copying]: t("Copying…"), + [DETAIL_UI.indexLink]: t("Open on the index site"), + [DETAIL_UI.badgeExamples]: t("✓ Has examples"), + [DETAIL_UI.badgeCn]: t("China mirror"), + [DETAIL_UI.badgeOpenkalEcosystem]: t("openkal-ecosystem"), + [DETAIL_UI.badgeOpenkalCompat]: t("openkal-compat"), + [DETAIL_UI.badgeOpenkalPosix]: t("POSIX environment"), + [DETAIL_UI.badgeOpenkalPlatform]: t("uses platform interfaces"), + [DETAIL_UI.targets]: t("Targets"), + [DETAIL_UI.includeDirs]: t("Include directories"), + }; +} + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} + +/** Kept next to the model so the surface vocabulary has one import site. */ +export { surfaceLabel }; +export type { Surface }; + +/** Greatest first — the order `xpkg.latestVersion` picks from. */ +function sortVersions(versions: readonly string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} diff --git a/mide-plugins/vscode/src/library/examples.ts b/mide-plugins/vscode/src/library/examples.ts new file mode 100644 index 0000000..71a3c00 --- /dev/null +++ b/mide-plugins/vscode/src/library/examples.ts @@ -0,0 +1,22 @@ +import snapshot from "../../data/library-examples.json"; +import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; +import { exampleCatalog, type CodeFile } from "./indexModel"; + +const catalog = exampleCatalog(snapshot.projects.map((project) => ({ + project: project.project, text: project.text, sources: project.files.map((file) => file.path), +}))); +const files = new Map(snapshot.projects.flatMap((project) => project.files.map((file) => [file.path, file.text] as const))); + +/** Official-index fallback only. Local checkouts use their own current tests. */ +export function bundledExample(id: string): { project: string; files: CodeFile[]; commit: string } | undefined { + const ref = catalog.get(id); + return ref ? { project: ref.project, commit: snapshot.commit, files: ref.paths.map((path) => ({ path, text: files.get(path)! })) } : undefined; +} + +export function bundledExampleText(path: string): string | undefined { return files.get(path.replace(/^\//, "")); } +export const EXAMPLE_SCHEME = "mcpp-example"; + +/** Immutable virtual documents keep local and bundled index sources equally read-only. */ +export class ExampleDocuments extends ReadOnlyDocuments { + constructor(captured?: (uri: string) => void) { super(EXAMPLE_SCHEME, captured); } +} diff --git a/mide-plugins/vscode/src/library/indexLocator.ts b/mide-plugins/vscode/src/library/indexLocator.ts new file mode 100644 index 0000000..afa6291 --- /dev/null +++ b/mide-plugins/vscode/src/library/indexLocator.ts @@ -0,0 +1,439 @@ +/** Local package discovery. Official indexes are separate from the xlings tool registry. + * Descriptor contents are cached independently from the project's dependency state. + */ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { projectDependencyState, readProjectDeclarations } from "./projectState"; +import { + descriptorEntry, + descriptorId, + exampleCatalog, + openkalFacetFor, + parseDescriptorLua, + parseOpenkalJson, + parseSelfEnv, + platformKey, + type CodeFile, + type ExampleInput, + type ExampleRef, + type LibraryEntry, + type OpenkalIndex, +} from "./indexModel"; + +/** How many descriptor files a walk will read before it says the index is wrong. */ +const MAX_DESCRIPTORS = 20_000; + +/** + * How long `mcpp self env --format json` may take. It only runs when the cheap + * globs found nothing, and a hang there must not hold the view hostage. + */ +const SELF_ENV_TIMEOUT_MS = 15_000; + +/** The index site of the official C++ library index, and its package pages. */ +export const INDEX_SITE = "https://mcpplibs.github.io/mcpp-index/packages"; + +/** A directory that holds `pkgs/`, and the shared metadata beside it. */ +export interface IndexRoot { + /** The directory name, e.g. `mcpplibs`. */ + registry: string; + kind: "official" | "custom"; + updatedAt?: number; + /** The index root: it contains `pkgs`, and may contain `tests/` and `.xpkgindex/`. */ + path: string; + /** `/pkgs`. */ + pkgs: string; + /** `/tests/examples` exists, so the example code can be shown. */ + hasExamples: boolean; + /** `/.xpkgindex/openkal-compat.json` exists. */ + hasOpenkal: boolean; + /** + * The package page base URL of the index site, when this root is the official + * index. + * + * Two shapes of the same index occur: the packed copy mcpp installs into + * `/.mcpp/registry/data/mcpplibs`, and a git checkout of `mcpp-index` + * (whose directory is named after the repository). The generator itself — + * `.xpkgindex/plugins/mcpp.py`, which only that repository carries — is the + * signal that this root is that index, so a checkout gets the deep link too. + */ + site?: string; +} + +/** Everything the view renders, read once per revision. */ +export interface LibrarySnapshot { + entries: LibraryEntry[]; + roots: IndexRoot[]; + revision: string; +} + +/** `~` is what a user types; the file system wants the home directory. */ +function expandHome(value: string): string { + if (value === "~") { + return os.homedir(); + } + if (value.startsWith("~/") || value.startsWith("~\\")) { + return path.join(os.homedir(), value.slice(2)); + } + return value; +} + +async function isDirectory(target: string): Promise { + try { + return (await fs.stat(target)).isDirectory(); + } catch { + return false; + } +} + +async function isFile(target: string): Promise { + try { + return (await fs.stat(target)).isFile(); + } catch { + return false; + } +} + +/** One index root, or `undefined` when the directory is not one. */ +async function rootAt(directory: string, official = false): Promise { + const pkgs = path.join(directory, "pkgs"); + if (!(await isDirectory(pkgs))) { + return undefined; + } + directory = await fs.realpath(directory); + const registry = path.basename(directory); + const updated = await statSafe(path.join(directory, ".mcpp-index-updated")); + const generator = await isFile(path.join(directory, ".xpkgindex", "plugins", "mcpp.py")); + return { + registry, + kind: official ? "official" : "custom", + ...(updated === undefined ? {} : { updatedAt: updated.mtimeMs }), + path: directory, + pkgs: path.join(directory, "pkgs"), + hasExamples: await isDirectory(path.join(directory, "tests", "examples")), + hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), + ...(official || generator ? { site: INDEX_SITE } : {}), + }; +} + +/** Explicit paths never silently fall back. Only the official library source is auto-discovered. */ +export async function readIndexRoots(projectRoot?: string): Promise { + const configured = read("mcpp.library.indexPath"); + return discoverIndexRoots(configured, configured.trim() ? path.join(os.homedir(), ".mcpp") : await activeHome(projectRoot)); +} + +/** Filesystem seam used by the extension and fixture-based tests. */ +export async function discoverIndexRoots(configured: string, home: string): Promise { + if (configured.trim()) { + const target = path.resolve(expandHome(configured.trim())); + const own = await rootAt(path.basename(target) === "pkgs" ? path.dirname(target) : target); + const roots = own === undefined + ? (await Promise.all((await readdirSafe(target)).sort().map((name) => rootAt(path.join(target, name))))) + .filter((root): root is IndexRoot => root !== undefined) + : [own]; + if (roots.length === 0) { + throw new Error(`No package index (pkgs/) at ${target}`); + } + return roots.filter((root, index) => roots.findIndex((other) => other.path === root.path) === index); + } + for (const directory of [path.join(home, "indices", "mcpplibs"), path.join(home, "registry", "data", "mcpplibs")]) { + const root = await rootAt(directory, true); + if (root !== undefined) { return [root]; } + } + return []; +} + +let homeProbe: { key: string; promise: Promise } | undefined; + +/** CLI also supports self-contained installs: ask it once rather than merge unrelated homes. */ +async function activeHome(projectRoot?: string): Promise { + const explicit = process.env.MCPP_HOME?.trim(); + if (explicit) { return path.resolve(expandHome(explicit)); } + return (await mcppHomeFromCli(projectRoot)) ?? path.join(os.homedir(), ".mcpp"); +} + +export function invalidateIndexCache(): void { + cache = undefined; + homeProbe = undefined; +} + +/** Share concurrent probes, with separate answers for different configured executables/projects. */ +async function mcppHomeFromCli(projectRoot?: string): Promise { + const resource = projectRoot === undefined ? undefined : vscode.Uri.file(projectRoot); + const executable = read("mcpp.path", resource).trim() || "mcpp"; + const key = `${executable}\u0000${projectRoot ?? ""}\u0000${vscode.workspace.isTrusted}`; + if (homeProbe?.key === key) { return homeProbe.promise; } + const promise = (async () => { + const result = await runMcpp(vscode.workspace.isTrusted, executable, ["self", "env", "--format", "json"], projectRoot, + { timeoutMs: SELF_ENV_TIMEOUT_MS, maxBufferMiB: 1 }); + if (result === undefined || result.exitCode !== 0) { return undefined; } + const env = parseSelfEnv(result.stdout); + return env?.mcppHome ?? (env?.registry === undefined ? undefined : path.dirname(env.registry)); + })(); + homeProbe = { key, promise }; + return promise; +} + +export { readIndexRoots as locateIndexRoots }; + +/** A directory listing, or nothing when it cannot be read. */ +async function readdirSafe(directory: string): Promise { + try { + return await fs.readdir(directory); + } catch { + return []; + } +} + +/** Every `*.lua` under `/pkgs`, depth first, with a hard cap. */ +async function descriptorFiles(pkgs: string): Promise { + const out: string[] = []; + const walk = async (directory: string): Promise => { + if (out.length >= MAX_DESCRIPTORS) { + return; + } + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (out.length >= MAX_DESCRIPTORS) { + return; + } + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + await walk(full); + } else if (entry.isFile() && entry.name.endsWith(".lua")) { + out.push(full); + } + } + }; + await walk(pkgs); + return out.sort(); +} + +/** Include source identity and every descriptor stat, including checkout edits without a marker. */ +async function revisionOf(roots: readonly IndexRoot[]): Promise { + const parts: string[] = []; + for (const root of roots) { + const files = await descriptorFiles(root.pkgs); + const stats = await Promise.all(files.map(async (file) => `${file}:${JSON.stringify(await statSafe(file))}`)); + parts.push(`${root.path}:${root.kind}:${root.updatedAt ?? ""}:${stats.join(";")}`); + } + return createHash("sha256").update(parts.join("|")).digest("hex"); +} + +async function statSafe(target: string): Promise<{ mtimeMs: number; size: number } | undefined> { + try { + const stat = await fs.stat(target); + return { mtimeMs: stat.mtimeMs, size: stat.size }; + } catch { + return undefined; + } +} + +/** + * The example projects of one index root, read as manifests plus the *names* of + * their test files. The file contents are read later, only for the package the + * reader actually opened. + */ +async function readExamples(root: IndexRoot): Promise { + if (!root.hasExamples) { + return []; + } + const base = path.join(root.path, "tests", "examples"); + const out: ExampleInput[] = []; + for (const project of await readdirSafe(base)) { + const directory = path.join(base, project); + const manifest = path.join(directory, "mcpp.toml"); + let text: string; + try { + text = await fs.readFile(manifest, "utf8"); + } catch { + continue; + } + const tests = path.join(directory, "tests"); + const sources: string[] = []; + for (const file of (await readdirSafe(tests)).sort()) { + if (file.endsWith(".cpp") || file.endsWith(".cppm") || file.endsWith(".cc")) { + sources.push(path.relative(root.path, path.join(tests, file)).split(path.sep).join("/")); + } + } + if (sources.length > 0) { + out.push({ project, text, sources }); + } + } + return out; +} + +/** The openkal measurement, merged across roots; the first one wins per member. */ +async function readOpenkal(roots: readonly IndexRoot[]): Promise { + const members: OpenkalIndex["members"] = {}; + let measured: string | undefined; + let found = false; + for (const root of roots) { + if (!root.hasOpenkal) { + continue; + } + let text: string; + try { + text = await fs.readFile(path.join(root.path, ".xpkgindex", "openkal-compat.json"), "utf8"); + } catch { + continue; + } + const parsed = parseOpenkalJson(text); + if (parsed === undefined) { + continue; + } + found = true; + measured = measured ?? parsed.measured; + for (const [name, member] of Object.entries(parsed.members)) { + if (members[name] === undefined) { + members[name] = member; + } + } + } + return found ? { members, ...(measured === undefined ? {} : { measured }) } : undefined; +} + +/** Project overlay is always fresh and never changes the descriptor cache. */ +async function withProject(snapshot: LibrarySnapshot, projectRoot?: string): Promise { + const text = projectRoot === undefined ? "" : await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8").catch(() => ""); + const declarations = readProjectDeclarations(text); + const ids = snapshot.entries.map((entry) => entry.id); + return { ...snapshot, entries: snapshot.entries.map((entry) => ({ + ...entry, added: (["normal", "dev", "build"] as const).some((kind) => projectDependencyState(entry.id, declarations, [], ids, kind).declared), + })) }; +} + +interface Cache { + revision: string; + snapshot: LibrarySnapshot; + /** Descriptor text, kept so the detail page does not re-read what the list did. */ + texts: Map; +} + +let cache: Cache | undefined; + +/** + * The list, read from disk only when the revision changed. + * + * Only descriptor contents are cached. Project declarations are reapplied on + * every read, so disk writes and project switches cannot reuse old badges. + */ +export async function loadSnapshot(options: { projectRoot?: string; platform?: string } = {}): Promise { + const roots = await readIndexRoots(options.projectRoot); + const revision = `${await revisionOf(roots)}|${options.platform ?? platformKey(process.platform)}`; + if (cache !== undefined && cache.revision === revision) { + return withProject(cache.snapshot, options.projectRoot); + } + if (roots.length === 0) { + const empty: LibrarySnapshot = { entries: [], roots, revision }; + cache = { revision, snapshot: empty, texts: new Map() }; + return empty; + } + + const platform = options.platform ?? platformKey(process.platform); + const entries: LibraryEntry[] = []; + const texts = new Map(); + /** Example refs, keyed by package id, from every root that has examples. */ + const exampleRefs = new Map(); + for (const root of roots) { + for (const [id, ref] of exampleCatalog(await readExamples(root))) { + exampleRefs.set(`${root.path}\u0000${id}`, ref); + } + } + + for (const root of roots) { + const openkal = await readOpenkal([root]); + for (const file of await descriptorFiles(root.pkgs)) { + let text: string; + try { + text = await fs.readFile(file, "utf8"); + } catch { + continue; + } + const fileName = path.basename(file); + const fields = parseDescriptorLua(text); + // `descriptorEntry` decides the identity; the example and openkal lookups + // below must use that same id, so it is computed the same way here. + const id = descriptorId(fields, fileName).id; + const example = exampleRefs.get(`${root.path}\u0000${id}`); + const facet = openkalFacetFor(openkal, id); + const entry = descriptorEntry({ + fileName, + registry: root.registry, + file, + text, + platform, + added: false, + ...(example === undefined ? {} : { example }), + ...(facet === undefined ? {} : { openkal: facet }), + }); + entries.push(entry); + texts.set(entry.file, text); + } + } + + entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + const snapshot: LibrarySnapshot = { entries, roots, revision }; + cache = { revision, snapshot, texts }; + return withProject(snapshot, options.projectRoot); +} + +/** The descriptor text of one package, from the cache when possible. */ +export async function readDescriptorText(file: string): Promise { + try { + return await fs.readFile(file, "utf8"); + } catch { + return undefined; + } +} + +/** The cached text for a package id, when the last snapshot read it. */ +export function cachedDescriptorText(file: string): string | undefined { + return cache?.texts.get(file); +} + +/** + * The example files behind a package: real code from + * `tests/examples//tests`, which CI builds and runs. + * + * The ref's `paths` are relative to the index root, so the root is found by + * matching the prefix. Missing files are skipped, never invented. + */ +export async function readExampleFiles(roots: readonly IndexRoot[], example: ExampleRef): Promise { + const files: CodeFile[] = []; + for (const root of roots) { + if (!root.hasExamples) { + continue; + } + for (const relative of example.paths) { + const absolute = path.join(root.path, relative); + if (path.relative(root.path, absolute).startsWith("..") || path.isAbsolute(path.relative(root.path, absolute))) { + continue; + } + try { + files.push({ path: relative, text: await fs.readFile(absolute, "utf8") }); + } catch { + // A file that disappeared between the listing and the read. + } + } + if (files.length > 0) { + return files; + } + } + return files; +} + +/** Test seam: forget the cached snapshot. */ +export function resetSnapshotCache(): void { + cache = undefined; +} diff --git a/mide-plugins/vscode/src/library/indexModel.ts b/mide-plugins/vscode/src/library/indexModel.ts new file mode 100644 index 0000000..a0b486a --- /dev/null +++ b/mide-plugins/vscode/src/library/indexModel.ts @@ -0,0 +1,1525 @@ +/** + * The library view's model: everything that can be decided without an editor. + * + * The index is a directory of Lua descriptors on disk + * (`/.mcpp/registry/data//pkgs//.lua`). The list + * phase has a hard budget: **no process per package** — 239 `mcpp` spawns would + * freeze the sidebar — so the catalog fields (`namespace`, `name`, + * `description`, `licenses`, `repo`, whether a `CN` mirror exists) and the + * version numbers are read out of the Lua **text**, tolerantly, and anything + * that does not fit a single-line shape is simply left out. Every authoritative + * value comes from `mcpp xpkg parse --json` instead (`src/library/xpkg.ts`), + * which runs only when a row is expanded or the detail page opens. + * + * The vocabulary is the index site generator's, not ours + * (`.xpkgindex/plugins/mcpp.py`): the usage label is one of the four `SURFACES`, + * the badges are the generator's own badges, and the openkal facets are + * **measurements** recorded in `.xpkgindex/openkal-compat.json` — never + * descriptor fields, so they are read from that file or not shown at all. + * + * This module is `vscode`-free on purpose (`test/architecture.test.ts`): the + * escaping, the tolerant parsing, the version ordering and the example lookup are + * all unit-testable without an editor host. + */ + +import { + SURFACE_TEXT, + SURFACES, + latestVersion, + parseXpkgJson, + platformKey, + surfacesOf, + textNamesBinaryTarget, + versionGroups, + versionsFor, + type Surface, + type XpkgInfo, +} from "./xpkg"; + +// ─────────────────────────────────────────────────────────────── vocabulary ── + +/** + * The packages that make up openkal — the site generator's `OPENKAL_FAMILY`, + * copied verbatim (it is a list of **names**, not prose, so it is not + * translated). `openkal-compat` needs no list: it is read from the measurement + * file. A rename upstream is a one-line change here. + */ +export const OPENKAL_FAMILY: readonly string[] = [ + "mcpplibs.openkal", + "mcpplibs.openkal-kit", + "mcpplibs.openkal-linux", + "mcpplibs.openkal-macos", + "mcpplibs.openkal-windows", + "mcpplibs.openkal-uefi", + "mcpplibs.openkal-opensbi", + "mcpplibs.openkal-emscripten", + "mcpplibs.openkal-libc", + "mcpplibs.openkal-musl", + "mcpplibs.openkal-llvm-runtime", + "mcpplibs.std-freestanding-alloc-kal", +]; + +/** Every badge the view may show, in the order it shows them (§12.5). */ +export type BadgeKey = + | "examples" + | "cn" + | "openkal-ecosystem" + | "openkal-compat" + | "openkal-posix" + | "openkal-platform"; + +/** The `ui` key and English fallback for each badge. */ +export const BADGE_UI: Readonly> = { + examples: { key: "library.badge.examples", fallback: "✓ Has examples" }, + cn: { key: "library.badge.cn", fallback: "China mirror" }, + "openkal-ecosystem": { key: "library.badge.openkalEcosystem", fallback: "openkal-ecosystem" }, + "openkal-compat": { key: "library.badge.openkalCompat", fallback: "openkal-compat" }, + "openkal-posix": { key: "library.badge.openkalPosix", fallback: "POSIX environment" }, + "openkal-platform": { key: "library.badge.openkalPlatform", fallback: "uses platform interfaces" }, +}; + +/** The current platform, as `mcpp` names it. Re-exported so callers need one import. */ +export { platformKey }; + +// ──────────────────────────────────────────────────────────────── Lua text ── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Drop Lua comments without touching string literals. + * + * It matters: descriptors are heavily commented, and a comment that mentions + * `kind = "bin"` or a `.cppm` path must not become a fact about the package. + * Line comments (`-- …`), long comments (`--[[ … ]]`, `--[=[ … ]=]`) and the + * long strings they can be confused with are all handled; `"` and `'` literals + * are copied through with their escapes. + */ +export function stripLuaComments(text: string): string { + let out = ""; + let index = 0; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + out += char; + index += 1; + while (index < text.length) { + const inner = text[index]; + out += inner; + index += 1; + if (inner === "\\") { + if (index < text.length) { + out += text[index]; + index += 1; + } + continue; + } + if (inner === quote) { + break; + } + } + continue; + } + if (char === "-" && text[index + 1] === "-") { + const long = /^\[(=*)\[/.exec(text.slice(index + 2)); + if (long !== null) { + const close = `]${long[1]}]`; + const end = text.indexOf(close, index + 2 + long[0].length); + index = end === -1 ? text.length : end + close.length; + continue; + } + const newline = text.indexOf("\n", index); + index = newline === -1 ? text.length : newline; + continue; + } + out += char; + index += 1; + } + return out; +} + +/** + * The body of the `{ … }` that starts at `open` (the index of the brace), or + * `undefined` when the braces do not balance. String literals are skipped so a + * `}` inside a URL cannot close the block early. + */ +export function bracedBody(text: string, open: number): { body: string; end: number } | undefined { + if (text[open] !== "{") { + return undefined; + } + let depth = 0; + let index = open; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < text.length && text[index] !== quote) { + index += text[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + if (depth === 0) { + return { body: text.slice(open + 1, index), end: index + 1 }; + } + } + index += 1; + } + return undefined; +} + +/** The `{ … }` assigned to `key`, searching the whole text. */ +export function keyBlock(text: string, key: string): string | undefined { + const pattern = new RegExp(`(?:^|[^\\w.])${key}\\s*=\\s*\\{`, "m"); + const match = pattern.exec(text); + if (match === null) { + return undefined; + } + const open = match.index + match[0].length - 1; + return bracedBody(text, open)?.body; +} + +/** One `key = value` pair at the top level of a Lua table body. */ +export interface LuaEntry { + key: string; + /** A string literal's contents, or the raw body of a `{ … }` value. */ + value?: string; + body?: string; +} + +/** + * The assignments at the **top level** of a table body, in source order. + * + * Only the top level, deliberately: `package` contains the whole `xpm` table, + * and a `name = "x"` nested inside a manifest target is not the package name. + * A value that is neither a one-line string nor a brace block is skipped — the + * caller then omits the field rather than guessing at it. + */ +export function topLevelEntries(body: string): LuaEntry[] { + const entries: LuaEntry[] = []; + let depth = 0; + let index = 0; + while (index < body.length) { + const char = body[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < body.length && body[index] !== quote) { + index += body[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{" || char === "(") { + depth += 1; + index += 1; + continue; + } + if (char === "}" || char === ")") { + depth -= 1; + index += 1; + continue; + } + if (depth === 0) { + const quoted = /^\[["']([^"']*)["']\]\s*=\s*/.exec(body.slice(index)); + const bare = quoted === null ? /^([A-Za-z_]\w*)\s*=\s*/.exec(body.slice(index)) : null; + const match = quoted ?? bare; + if (match !== null) { + const key = quoted === null ? bare![1] : quoted[1]; + let cursor = index + match[0].length; + while (cursor < body.length && /\s/.test(body[cursor])) { + cursor += 1; + } + if (body[cursor] === "{") { + const block = bracedBody(body, cursor); + if (block === undefined) { + return entries; + } + entries.push({ key, body: block.body }); + index = block.end; + continue; + } + if (body[cursor] === '"' || body[cursor] === "'") { + const quote = body[cursor]; + let end = cursor + 1; + let value = ""; + while (end < body.length && body[end] !== quote) { + if (body[end] === "\\") { + value += body[end + 1] ?? ""; + end += 2; + continue; + } + value += body[end]; + end += 1; + } + // A value that spans a line break is not the single-line shape the + // catalog fields are written in; it is omitted rather than guessed at. + if (!value.includes("\n") && !value.includes("\r")) { + entries.push({ key, value }); + } + index = end + 1; + continue; + } + // A number, a boolean or an expression: not a catalog field. + const lineEnd = body.indexOf("\n", cursor); + index = lineEnd === -1 ? body.length : lineEnd; + continue; + } + } + index += 1; + } + return entries; +} + +/** Every `"…"` literal in a table body, in order — the shape of `licenses`. */ +export function stringLiterals(body: string): string[] { + const out: string[] = []; + for (const match of body.matchAll(/"((?:[^"\\]|\\.)*)"/g)) { + out.push(match[1].replace(/\\(.)/g, "$1")); + } + return out; +} + +/** The catalog fields of one descriptor, as far as its text states them. */ +export interface DescriptorFields { + namespace?: string; + name?: string; + description?: string; + licenses: string[]; + repo?: string; +} + +/** + * Parse a descriptor's Lua text tolerantly. Never throws, and never invents a + * value: a field written across lines, escaped unusually, or nested somewhere + * unexpected is left out, and the caller falls back to the file name and says + * the descriptor could not be read. + */ +export function parseDescriptorLua(text: string): DescriptorFields { + const clean = stripLuaComments(text); + const body = keyBlock(clean, "package") ?? clean; + const entries = topLevelEntries(body); + const fields: DescriptorFields = { licenses: [] }; + for (const entry of entries) { + if (entry.body !== undefined) { + if (entry.key === "licenses") { + fields.licenses = stringLiterals(entry.body).filter((value) => value.length > 0); + } + continue; + } + if (entry.value === undefined || entry.value.length === 0) { + continue; + } + if (entry.key === "namespace") { + fields.namespace = entry.value; + } else if (entry.key === "name") { + fields.name = entry.value; + } else if (entry.key === "description") { + fields.description = entry.value; + } else if (entry.key === "repo") { + fields.repo = entry.value; + } + } + return fields; +} + +/** A `CN` mirror url — the badge the site derives from the descriptor's urls. */ +export function hasCnMirror(text: string): boolean { + const clean = stripLuaComments(text); + // Both shapes occur: `CN = "…"` and `["CN"] = "…"`. + return /\bCN\b\s*=/.test(clean) || /\[\s*["']CN["']\s*\]\s*=/.test(clean); +} + +/** `name` and `namespace` as the file name encodes them (`ns.name.lua`, or `name.lua`). */ +export function idFromFileName(fileName: string): { namespace?: string; name: string } { + const base = fileName.replace(/\.lua$/i, ""); + // The separator is the *last* dot: `boost-ext.ut` is `boost-ext` + `ut`, and + // the one descriptor whose own fields are unreadable + // (`huxerui.huxerui.lua`) is `huxerui` + `huxerui`, not `huxerui` + `huxerui.huxerui`. + const dot = base.lastIndexOf("."); + if (dot <= 0) { + return { name: base }; + } + return { namespace: base.slice(0, dot), name: base.slice(dot + 1) }; +} + +/** + * The package id, `ns.name`, with the file name as the fallback (§9.3). + * + * `name` may be written **fully qualified**: `huxerui.huxerui.lua` declares + * `namespace = "huxerui"` and `name = "huxerui.huxerui"` on purpose (mcpp#278 + * INV-NAME — the split form "parses but can never be installed"), and `mcpp` + * itself reports the two halves separately. The prefix is therefore stripped + * again here, which is the difference between a usable id and `huxerui` three + * times over. + */ +export function descriptorId(fields: DescriptorFields, fileName: string): { id: string; namespace?: string; name: string } { + const fallback = idFromFileName(fileName); + let name = fields.name !== undefined && fields.name.length > 0 ? fields.name : fallback.name; + let namespace = fields.namespace !== undefined && fields.namespace.length > 0 ? fields.namespace : fallback.namespace; + const dot = name.lastIndexOf("."); + if (dot > 0) { + const head = name.slice(0, dot); + const tail = name.slice(dot + 1); + if (namespace === undefined || namespace === head) { + namespace = head; + } + name = tail; + } + return { id: namespace === undefined ? name : `${namespace}.${name}`, ...(namespace === undefined ? {} : { namespace }), name }; +} + +/** + * The list's cheap surface guess, from the descriptor text alone. + * + * The authoritative answer is `surfacesOf(xpkg parse --json)`; this exists + * because the list may not spawn a process per package. It asks the same + * questions in the same order as the site generator's `_surfaces`: a `.cppm` + * anywhere makes it a module, a `kind = "bin"` target makes it a tool, an + * inline `sources`/`include_dirs` manifest makes it a header package, and a + * descriptor with no inline manifest at all is a Form A `external`. + * + * `undefined` means the text does not say — the row then shows no usage label, + * and opening the package gets the authoritative answer. + */ +export function surfaceFromDescriptorText(text: string): Surface | undefined { + const clean = stripLuaComments(text); + if (/\.cppm"/.test(clean)) { + return "module"; + } + if (textNamesBinaryTarget(clean)) { + return "tool"; + } + if (/\b(?:sources|include_dirs)\s*=\s*\{/.test(clean)) { + return "header"; + } + if (/\btargets\s*=\s*\{/.test(clean)) { + // Targets but no sources and no headers: the text does not say how the + // package is consumed. The parse's own answer is used when it is available. + return undefined; + } + // No inline manifest at all is what a Form A descriptor looks like. + return /\b(?:package|xpm)\s*=/.test(clean) ? "external" : undefined; +} + +/** + * Merge what the parse says with what the text says, without ever letting the + * cheap guess override the authoritative reader. + * + * The **only** thing the text may add is a `tool` surface: `xpkg parse` prints + * target names, so a `kind = "bin"` is visible in the descriptor's own inline + * manifest but not in the command's output. Everything else comes from the parse + * when it succeeded — which is what keeps a feature-gated `.cppm` (ftxui) from + * being reported as a module just because the text mentions one. + */ +export function mergeSurfaces(info: XpkgInfo | undefined, text: string): Surface[] { + const fromParse = info === undefined ? [] : surfacesOf(info); + const out: Surface[] = [...fromParse]; + if (textNamesBinaryTarget(stripLuaComments(text)) && !out.includes("tool")) { + out.push("tool"); + } + return SURFACES.filter((surface) => out.includes(surface)); +} + +/** + * The version numbers the descriptor's own `xpm` table lists, per platform. + * + * A cheap list-phase stand-in for the authoritative `versions` in + * `mcpp xpkg parse --json`, which the detail page uses. It reads the + * `[""] =` keys of each platform block and, for the xlings-style + * `["latest"] = { ref = "1.6.43" }` alias, the `ref` it points at — but never + * returns `latest` itself as a version, because `mcpp add` accepts only an exact + * version. + */ +export function versionsFromDescriptorText(text: string): Record { + const clean = stripLuaComments(text); + const xpm = keyBlock(clean, "xpm"); + if (xpm === undefined) { + return {}; + } + const out: Record = {}; + for (const platform of topLevelEntries(xpm)) { + if (platform.body === undefined) { + continue; + } + const found: string[] = []; + const aliases: string[] = []; + for (const entry of topLevelEntries(platform.body)) { + // An entry with a `ref` is an alias (`["latest"] = { ref = "1.6.43" }`, + // and the xlings-style named variants): `mcpp add` accepts an exact + // version only, so the ref is the version and the alias name is dropped. + const ref = entry.body === undefined ? null : /\bref\s*=\s*"((?:[^"\\]|\\.)*)"/.exec(entry.body); + if (ref !== null) { + aliases.push(ref[1]); + continue; + } + // A version entry names an archive: a numeric key, or one whose body + // carries a `url`/`sha256`. That is what keeps structural keys — `deps`, + // `cxxflags`, `runtime` — out of the version list, while non-semver + // revisions (`b10069.2`, the llama.cpp checkpoint tags) stay in it. + if (/^\d/.test(entry.key) || (entry.body !== undefined && /\b(?:url|sha256)\s*=/.test(entry.body))) { + found.push(entry.key); + } + } + const versions = [...new Set([...found, ...aliases])]; + if (versions.length > 0) { + out[platform.key] = versions; + } + } + return out; +} + +// ───────────────────────────────────────────────────────────── the entries ── + +/** Where one example project demonstrates a package. */ +export interface ExampleRef { + project: string; + /** The first test file, relative to the index root; the site calls this `path`. */ + path: string; + paths: string[]; + count: number; +} + +/** One source file of an example project, already read. */ +export interface CodeFile { + path: string; + text: string; +} + +/** One index descriptor, as the view needs it. */ +export interface LibraryEntry { + id: string; + namespace?: string; + name: string; + description?: string; + licenses: string[]; + repo?: string; + /** The registry directory name, e.g. `mcpplibs`. */ + registry: string; + /** Absolute path of the descriptor. */ + file: string; + /** The list's cheap guess; the detail page replaces it with the authoritative list. */ + surface?: Surface; + surfaces: Surface[]; + hasCnMirror: boolean; + hasExamples: boolean; + openkal?: OpenkalFacet; + /** The example project that demonstrates this package, when the index has one. */ + example?: ExampleRef; + /** The greatest version this platform has, from the descriptor text. */ + version?: string; + versions: Record; + /** Declared in the workspace's `mcpp.toml`. */ + added: boolean; + /** Set when the descriptor text could not be read at all. */ + unreadable?: boolean; +} + +export interface DescriptorInput { + /** The descriptor's base name, used as the fallback identity. */ + fileName: string; + registry: string; + file: string; + text: string; + platform?: string; + example?: ExampleRef; + openkal?: OpenkalFacet; + added?: boolean; +} + +/** + * Assemble one entry from its text. The only place a descriptor becomes a row: + * the identity falls back to the file name, the surface to the cheap text + * guess, and every optional field is omitted rather than filled with a guess. + */ +export function descriptorEntry(input: DescriptorInput): LibraryEntry { + const fields = parseDescriptorLua(input.text); + const identity = descriptorId(fields, input.fileName); + const versions = versionsFromDescriptorText(input.text); + const surface = surfaceFromDescriptorText(input.text); + const entry: LibraryEntry = { + id: identity.id, + ...(identity.namespace === undefined ? {} : { namespace: identity.namespace }), + name: identity.name, + ...(fields.description === undefined ? {} : { description: fields.description }), + licenses: [...fields.licenses], + ...(fields.repo === undefined ? {} : { repo: fields.repo }), + registry: input.registry, + file: input.file, + ...(surface === undefined ? {} : { surface }), + surfaces: surface === undefined ? [] : [surface], + hasCnMirror: hasCnMirror(input.text), + hasExamples: input.example !== undefined, + version: latestVersion(versions, input.platform), + versions, + added: input.added === true, + }; + if (input.example !== undefined) { + entry.example = input.example; + } + const openkal = input.openkal; + if (openkal !== undefined && (openkal.level !== undefined || openkal.kind !== undefined)) { + entry.openkal = openkal; + } + if (fields.namespace === undefined && fields.name === undefined) { + // Nothing at all was readable: the row still exists, from its file name, + // and the UI says the descriptor could not be read. + entry.unreadable = true; + } + return entry; +} + +/** Replace the cheap text guess with the authoritative parse's answer. */ +export function withAuthoritative( + entry: LibraryEntry, + info: XpkgInfo | undefined, + text: string, + platform: string | undefined, +): LibraryEntry { + if (info === undefined) { + return entry; + } + const surfaces = mergeSurfaces(info, text); + const next: LibraryEntry = { + ...entry, + surfaces, + versions: info.versions, + version: latestVersion(info.versions, platform), + }; + if (surfaces[0] === undefined) { + delete next.surface; + } else { + next.surface = surfaces[0]; + } + return next; +} + +// ─────────────────────────────────────────────────────────────── examples ── + +/** + * The dependency ids a manifest declares, at any nesting depth. + * + * Mirrors the site generator's `_collect_dependencies`: a bare + * `tinyhttps = "…"` resolves in the index's default namespace, so it names both + * `tinyhttps` and `mcpplibs.tinyhttps`; `[dependencies.compat]` with + * `argparse = "3.2"` names `compat.argparse`; `[target.'cfg(linux)'.dependencies…]` + * is found too, because a dependency a platform gate hides is still the package + * that example demonstrates. + * + * The same reader serves two callers: the example index (one `mcpp.toml` per + * project under `tests/examples`) and the workspace's own `mcpp.toml`, which is + * how the "already declared" filter knows what is added. + */ +export function declaredDependencies(tomlText: string): string[] { + const out = new Set(); + const bare = new Set(); + const header = /^\s*\[([^\]]+)\]\s*$/; + let prefix = ""; + let collecting = false; + const lines = tomlText.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].replace(/#.*$/, "").trim(); + const section = header.exec(line); + if (section !== null) { + const path = section[1] + .split(".") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "")) + .filter((part) => part.length > 0); + const at = path.findIndex((part) => part === "dependencies" || part === "dev-dependencies"); + collecting = at !== -1; + prefix = at !== -1 && path.length > at + 1 ? path[at + 1] : ""; + continue; + } + if (!collecting) { + continue; + } + // A dependency table can be written inline (`compat = { argparse = "3.2" }`) + // or across several lines; both are accumulated before the keys are read. + let statement = line; + while (bracesOpen(statement) > 0 && index + 1 < lines.length) { + index += 1; + statement += ` ${lines[index].replace(/#.*$/, "").trim()}`; + } + if (statement.length === 0) { + continue; + } + const assignment = /^([A-Za-z0-9_.\-]+)\s*=\s*(.*)$/.exec(statement); + if (assignment === null) { + continue; + } + const key = assignment[1]; + const value = assignment[2].trim(); + if (value.startsWith("{")) { + // A namespace table: every key inside names a package in it. + for (const inner of value.matchAll(/([A-Za-z0-9_.\-]+)\s*=/g)) { + addDependency(out, bare, prefix.length > 0 ? `${prefix}.${key}.${inner[1]}` : `${key}.${inner[1]}`, false); + } + continue; + } + if (!/^[A-Za-z_][\w\-]*$/.test(key)) { + // `ns.name = "1.0"`: already a fully-qualified id. + addDependency(out, bare, key, false); + continue; + } + // Under `[dependencies.compat]` the key is a bare name in that namespace. + addDependency(out, bare, prefix.length > 0 ? `${prefix}.${key}` : key, prefix.length === 0); + } + for (const name of bare) { + out.add(name); + out.add(`mcpplibs.${name}`); + } + // Sorted: the answer is a set, and a stable order keeps callers and tests honest. + return [...out].sort(); +} + +/** One dependency name, plus the unqualified form when it may resolve by default. */ +function addDependency(out: Set, bare: Set, id: string, unqualified: boolean): void { + if (unqualified) { + bare.add(id); + } + out.add(id); +} + +/** How many `{` are still open on one (possibly concatenated) TOML line. */ +function bracesOpen(text: string): number { + let depth = 0; + let index = 0; + while (index < text.length) { + const char = text[index]; + if (char === '"' || char === "'") { + const quote = char; + index += 1; + while (index < text.length && text[index] !== quote) { + index += text[index] === "\\" ? 2 : 1; + } + index += 1; + continue; + } + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + } + index += 1; + } + return depth; +} + +// ─────────────────────────────────────────────── what the project already has ── + +/** One dependency a workspace manifest declares, with the constraint it names. */ +export interface DeclaredDependencyEntry { + id: string; + /** + * The constraint text (`3.2`, `^1.0`) when the value is a plain string. + * A table value — `{ path = "…" }`, a git source — names no version, and the + * project's lock file is the next place to look. + */ + version?: string; + /** Declared under `[dev-dependencies]` (or a feature only tests pull in). */ + dev: boolean; +} + +/** + * The version-aware sibling of `declaredDependencies` (§22): the detail page + * asks "which version does this project already ask for", which an id-only + * list cannot answer. Same walk, same shapes — bare keys stay bare here + * (`argparse` is not expanded to `compat.argparse`), because the matching rule + * of the only caller (`installedFor`) compares against both forms anyway. + */ +export function declaredDependencyEntries(tomlText: string): DeclaredDependencyEntry[] { + const out: DeclaredDependencyEntry[] = []; + const seen = new Set(); + const push = (id: string, version: string | undefined, dev: boolean): void => { + if (id.length === 0 || seen.has(`${dev ? "d" : "r"}:${id}`)) { + return; + } + seen.add(`${dev ? "d" : "r"}:${id}`); + out.push(version === undefined ? { id, dev } : { id, version, dev }); + }; + const header = /^\s*\[([^\]]+)\]\s*$/; + let dev = false; + let prefix = ""; + let collecting = false; + const lines = tomlText.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].replace(/#.*$/, "").trim(); + const section = header.exec(line); + if (section !== null) { + const path = section[1] + .split(".") + .map((part) => part.trim().replace(/^['"]|['"]$/g, "")) + .filter((part) => part.length > 0); + const at = path.findIndex((part) => part === "dependencies" || part === "dev-dependencies"); + collecting = at !== -1; + dev = path[at] === "dev-dependencies"; + prefix = at !== -1 && path.length > at + 1 ? path[at + 1] : ""; + continue; + } + if (!collecting) { + continue; + } + let statement = line; + while (bracesOpen(statement) > 0 && index + 1 < lines.length) { + index += 1; + statement += ` ${lines[index].replace(/#.*$/, "").trim()}`; + } + if (statement.length === 0) { + continue; + } + const assignment = /^([A-Za-z0-9_.\-]+)\s*=\s*(.*)$/.exec(statement); + if (assignment === null) { + continue; + } + const key = assignment[1]; + const value = assignment[2].trim(); + const version = /^"([^"]*)"$/.exec(value); + if (version !== null) { + push(prefix.length > 0 ? `${prefix}.${key}` : key, version[1], dev); + continue; + } + if (value.startsWith("{")) { + // Three shapes live inside braces. A namespace table + // (`compat = { argparse = "3.2" }`) names `compat.argparse` with that + // version. A source table (`counters = { path = "../counters" }`, git + // tables) names `counters` with no version at all; a `version` key + // inside one is honoured, the other source fields are not facts about + // the package. + const SOURCE_KEYS = new Set(["path", "git", "url", "branch", "rev", "tag"]); + const pairs = [...value.matchAll(/([A-Za-z0-9_.\-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^,}]+))/g)] + .map((match) => ({ key: match[1], value: (match[2] ?? match[3] ?? match[4] ?? "").trim() })) + .filter((pair) => pair.key !== undefined); + const named = pairs.filter((pair) => !SOURCE_KEYS.has(pair.key) && pair.key !== "version"); + if (named.length > 0) { + for (const pair of named) { + push( + prefix.length > 0 ? `${prefix}.${key}.${pair.key}` : `${key}.${pair.key}`, + pair.value.length > 0 ? pair.value : undefined, + dev, + ); + } + } else { + const tableVersion = pairs.find((pair) => pair.key === "version" && pair.value.length > 0)?.value; + push(prefix.length > 0 ? `${prefix}.${key}` : key, tableVersion, dev); + } + continue; + } + push(prefix.length > 0 ? `${prefix}.${key}` : key, undefined, dev); + } + return out; +} + +/** One package a build resolved, straight out of `mcpp.lock`. */ +export interface LockPackage { + id: string; + version: string; +} + +/** + * `mcpp.lock` (format `version = 2`): one `[package.""]` table per + * resolved package, with `namespace` and `version` beside it — measured, not + * assumed, against a real lock written by `mcpp 2026.9.30.2`. The file's own + * header says the rest: dev-dependencies are excluded, and only index-resolved + * packages appear, so a path or git dependency is never an answer here. + */ +export function lockPackageVersions(lockText: string): LockPackage[] { + const out: LockPackage[] = []; + const header = /^\s*\[package\."?([A-Za-z0-9_.\-]+)"?\]\s*$/; + let namespace: string | undefined; + let version: string | undefined; + let name: string | undefined; + const flush = (): void => { + if (name !== undefined && version !== undefined) { + // The key is the short name (`[package."openkal"]` + namespace). A + // fully-qualified key also occurs (`[package."mcpplibs.cmdline"]`): when + // the namespace is only the name's own head, the name already is the id. + let id = + namespace === undefined || namespace.length === 0 ? name : `${namespace}.${name}`; + const dot = name.lastIndexOf("."); + if (namespace !== undefined && dot > 0 && namespace === name.slice(0, dot)) { + id = name; + } + out.push({ id, version }); + } + namespace = undefined; + version = undefined; + name = undefined; + }; + for (const raw of lockText.split(/\r?\n/)) { + const line = raw.replace(/#.*$/, "").trim(); + if (line.length === 0) { + continue; + } + const section = header.exec(line); + if (section !== null) { + flush(); + name = section[1]; + continue; + } + if (/^\s*\[/.test(line)) { + // Some other table: whatever half-read package there was is over. + flush(); + continue; + } + if (name === undefined) { + continue; + } + const assignment = /^([A-Za-z_][\w]*)\s*=\s*"([^"]*)"/.exec(line); + if (assignment === null) { + continue; + } + if (assignment[1] === "namespace") { + namespace = assignment[2]; + } else if (assignment[1] === "version") { + version = assignment[2]; + } + } + flush(); + return out; +} + +/** The project's own answer about one package (§20.1, wired in §22). */ +export interface InstalledDependency { + /** The constraint the manifest asks for, or the version a build resolved. */ + version: string; + /** Declared under `[dev-dependencies]`. */ + dev: boolean; +} + +/** + * Whether the project already depends on `id`, and on which version: + * `mcpp.toml` first — the matching rule is §20.1's, the key equals the id or + * is its last segment, because `mcpp add compat.argparse` writes the short + * `argparse = "3.2"` — then `mcpp.lock`, for a dependency whose manifest value + * named no version. `undefined` is a real answer: the project does not have it. + */ +export function installedFor( + id: string, + tomlText: string, + lockText: string, +): InstalledDependency | undefined { + const short = id.slice(id.lastIndexOf(".") + 1); + for (const entry of declaredDependencyEntries(tomlText)) { + if ((entry.id === id || entry.id === short) && entry.version !== undefined) { + return { version: entry.version, dev: entry.dev }; + } + } + for (const locked of lockPackageVersions(lockText)) { + if (locked.id === id || locked.id === short) { + return { version: locked.version, dev: false }; + } + } + return undefined; +} + +/** One example project's manifest and its test files, already read. */ +export interface ExampleInput { + project: string; + text: string; + /** Paths relative to the index root, in the order the caller found them. */ + sources: string[]; +} + +/** + * package id → the example project that consumes it. + * + * The site generator's `_scan_examples` contract: `{project, path, paths[], + * count}`, keyed by every dependency id the project's manifest names. Several + * projects may consume one package; prefer its own named test project over + * incidental consumers, then use deterministic alphabetical order. + */ +export function exampleCatalog(inputs: readonly ExampleInput[]): Map { + const catalog = new Map(); + const priorities = new Map(); + const sorted = [...inputs].sort((a, b) => (a.project < b.project ? -1 : a.project > b.project ? 1 : 0)); + for (const input of sorted) { + if (input.sources.length === 0) { + continue; + } + const ref: ExampleRef = { + project: input.project, + path: input.sources[0], + paths: [...input.sources], + count: input.sources.length, + }; + for (const id of declaredDependencies(input.text)) { + const short = id.slice(id.lastIndexOf(".") + 1).replace(/[-_]/g, "").toLowerCase(); + const projectName = input.project.replace(/[-_]/g, "").toLowerCase(); + const priority = projectName === short ? 0 : projectName.startsWith(short) ? 1 : 2; + if (!catalog.has(id) || priority < priorities.get(id)!) { + catalog.set(id, ref); + priorities.set(id, priority); + } + } + } + return catalog; +} + +/** The `import x.y;` / `#include ` lines, in file order. */ +export interface UsageLine { + file: string; + /** 1-based, so it can be shown beside the code. */ + line: number; + text: string; +} + +const IMPORT_LINE = /^\s*import\s+[A-Za-z_][\w.]*\s*;/; +const INCLUDE_LINE = /^\s*#include\s*[<"][^>"]+[>"]/; + +/** The interface lines the site files a package under a `SURFACE` by. */ +export function usageLines(files: readonly CodeFile[]): UsageLine[] { + const out: UsageLine[] = []; + for (const file of files) { + file.text.split(/\r?\n/).forEach((text, index) => { + if (IMPORT_LINE.test(text) || INCLUDE_LINE.test(text)) { + out.push({ file: file.path, line: index + 1, text: text.replace(/\s+$/, "") }); + } + }); + } + return out; +} + +/** Regex metacharacters are literal in a package name, never a pattern. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * The `import` / `#include` lines a reader of **this** package writes, out of + * an example project's real code (§22): a line counts when it names the + * package — the dotted id or its short name as a word — so `import std;` and + * the example's own other dependencies stay out. Deduplicated, capped at + * four: this is a hint beside the command, not a second code section. + */ +export function usageLinesFor(files: readonly CodeFile[], id: string): string[] { + const short = id.slice(id.lastIndexOf(".") + 1); + const dotted = new RegExp(`\\b${escapeRegExp(id)}\\b`); + const bare = new RegExp(`\\b${escapeRegExp(short)}\\b`); + const out: string[] = []; + for (const line of usageLines(files)) { + if ((dotted.test(line.text) || bare.test(line.text)) && !out.includes(line.text)) { + out.push(line.text); + if (out.length >= 4) { + break; + } + } + } + return out; +} + +/** + * The usage lines to show when no example project states the real ones (§22). + * + * The module name follows the **dotted id** — `import nlohmann.json;`, in that + * descriptor's own words (`export module nlohmann.json;`) — except the default + * `mcpplibs.` namespace, whose packages export their short name: measured in a + * real project (`openkal = "0.12.0"` in the manifest, `import openkal.types;` + * in the sources) and in the cmdline descriptor. A header package's real path + * lives inside the upstream archive and is unknowable offline, so the index + * site's own muted placeholder answers (`#include `) rather than an + * invented path. `tool` and `external` packages are not imported at all; their + * surface badge already says what they are. + */ +export function syntheticUsageLines(surfaces: readonly Surface[], id: string): string[] { + if (surfaces.includes("module")) { + const short = id.slice(id.lastIndexOf(".") + 1); + const moduleName = id.startsWith("mcpplibs.") ? short : id; + return [`import ${moduleName};`]; + } + if (surfaces.includes("header")) { + return [SURFACE_TEXT.header.usage]; + } + return []; +} + +/** A window of an example file around one interface line. */ +export interface CodeSnippet { + file: string; + /** 1-based line number of the first line of `lines`. */ + startLine: number; + lines: string[]; + /** The 1-based line the interface line is on. */ + usageLine: number; +} + +export interface SnippetOptions { + /** Lines of context before and after the interface line. */ + context?: number; + maxSnippets?: number; + maxLines?: number; +} + +/** + * The example code to show: one window per interface line, with overlapping + * windows merged, capped so a package with many test files cannot push the + * detail page's buttons off the screen. This is the *real* code from + * `tests/examples//tests/*.cpp` — CI builds and runs it. + */ +export function codeSnippets(files: readonly CodeFile[], options: SnippetOptions = {}): CodeSnippet[] { + const context = Math.max(0, options.context ?? 2); + const maxSnippets = Math.max(1, options.maxSnippets ?? 3); + const maxLines = Math.max(1, options.maxLines ?? 24); + const snippets: CodeSnippet[] = []; + for (const file of files) { + const lines = file.text.split(/\r?\n/); + const anchors: number[] = []; + lines.forEach((text, index) => { + if (IMPORT_LINE.test(text) || INCLUDE_LINE.test(text)) { + anchors.push(index); + } + }); + for (const anchor of anchors) { + const from = Math.max(0, anchor - context); + const to = Math.min(lines.length, anchor + context + 1); + const last = snippets[snippets.length - 1]; + if (last !== undefined && last.file === file.path && from <= last.startLine - 1 + last.lines.length) { + // Overlaps or touches the previous window: extend it instead of + // repeating its lines. + const merged = lines.slice(last.startLine - 1, to); + last.lines = merged.slice(0, maxLines); + continue; + } + snippets.push({ + file: file.path, + startLine: from + 1, + lines: lines.slice(from, Math.min(to, from + maxLines)), + usageLine: anchor + 1, + }); + if (snippets.length >= maxSnippets) { + return snippets; + } + } + } + return snippets; +} + +// ─────────────────────────────────────────────────────────── highlighting ── + +export type TokenKind = "plain" | "comment" | "string" | "preprocessor" | "keyword" | "punctuation"; + +export interface Token { + kind: TokenKind; + text: string; +} + +/** + * A deliberately small C++ tokenizer for the example block: enough for token + * *colours*, not a parser. Comments, string and character literals, the + * preprocessor directive, a short keyword list and punctuation; everything else + * is plain text. The renderer escapes each token, so no token can become markup. + */ +const KEYWORDS = new Set([ + "import", "export", "module", "include", "define", "ifdef", "ifndef", "endif", + "namespace", "using", "template", "typename", "class", "struct", "enum", "public", + "private", "protected", "static", "inline", "const", "constexpr", "consteval", + "auto", "void", "bool", "char", "int", "long", "short", "unsigned", "signed", + "float", "double", "return", "if", "else", "for", "while", "switch", "case", + "break", "continue", "true", "false", "nullptr", "new", "delete", "try", "catch", + "throw", "this", "operator", "noexcept", "override", "final", "co_await", + "co_return", "co_yield", "requires", "concept", "friend", "virtual", "explicit", +]); + +const PUNCTUATION = new Set([";", "(", ")", "{", "}", "[", "]", ",", "<", ">", ":", "*", "&", "=", "#", ".", "+", "-", "/", "!", "|", "%", "^", "~", "?"]); + +export function tokenizeCppLine(line: string): Token[] { + const tokens: Token[] = []; + let plain = ""; + const flush = (): void => { + if (plain.length > 0) { + tokens.push({ kind: "plain", text: plain }); + plain = ""; + } + }; + const push = (kind: TokenKind, text: string): void => { + flush(); + tokens.push({ kind, text }); + }; + let index = 0; + while (index < line.length) { + const char = line[index]; + if (char === "/" && line[index + 1] === "/") { + push("comment", line.slice(index)); + break; + } + if (char === '"' || char === "'") { + const quote = char; + let end = index + 1; + while (end < line.length) { + if (line[end] === "\\") { + end += 2; + continue; + } + if (line[end] === quote) { + end += 1; + break; + } + end += 1; + } + push("string", line.slice(index, Math.min(end, line.length))); + index = end; + continue; + } + if (/[A-Za-z_]/.test(char)) { + let end = index; + while (end < line.length && /[\w]/.test(line[end])) { + end += 1; + } + const word = line.slice(index, end); + if (KEYWORDS.has(word)) { + push("keyword", word); + } else { + plain += word; + } + index = end; + continue; + } + if (PUNCTUATION.has(char)) { + push("punctuation", char); + index += 1; + continue; + } + plain += char; + index += 1; + } + flush(); + return tokens; +} + +// ────────────────────────────────────────────────────────────── openkal ── + +export interface OpenkalTarget { + kind?: string; + status?: string; +} + +export interface OpenkalMember { + packages: string[]; + portable: boolean; + targets: Record; +} + +export interface OpenkalIndex { + members: Record; + measured?: string; +} + +/** + * `.xpkgindex/openkal-compat.json`, decoded. The file is a *measurement* log + * (`tests/openkal/compat.py` writes it), not a descriptor field, which is why + * nothing about it is inferred from the Lua text. A missing or malformed file + * yields `undefined`, and the view then shows no openkal badge at all. + */ +export function parseOpenkalJson(text: string): OpenkalIndex | undefined { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return undefined; + } + if (!isRecord(value) || !isRecord(value.members)) { + return undefined; + } + const members: Record = {}; + for (const [name, raw] of Object.entries(value.members)) { + if (!isRecord(raw)) { + continue; + } + const targets: Record = {}; + if (isRecord(raw.targets)) { + for (const [target, entry] of Object.entries(raw.targets)) { + if (!isRecord(entry)) { + continue; + } + targets[target] = { + ...(typeof entry.kind === "string" ? { kind: entry.kind } : {}), + ...(typeof entry.status === "string" ? { status: entry.status } : {}), + }; + } + } + members[name] = { + packages: Array.isArray(raw.packages) ? raw.packages.filter((id): id is string => typeof id === "string") : [], + portable: raw.portable !== false, + targets, + }; + } + return { + members, + ...(typeof value.measured === "string" ? { measured: value.measured } : {}), + }; +} + +const OPENKAL_RANK: Readonly> = { fails: 0, builds: 1, runs: 2 }; + +/** The openkal facet a package is filed under, or nothing. */ +export interface OpenkalFacet { + level?: "ecosystem" | "compat"; + kind?: "posix" | "platform"; +} + +/** + * The facet for one package id, mirroring the generator's `_openkal_level` / + * `_openkal_kind`: + * + * - `openkal-ecosystem` from the family list; + * - `openkal-compat` only when some measured target **runs** (a package measured + * only to build, or to fail, is filed under neither — the site says so in as + * many words); + * - `platform` wins over `posix`, because the package-level kind is the + * strictest measured target's answer, not a claim about every target. + */ +export function openkalFacetFor(index: OpenkalIndex | undefined, id: string): OpenkalFacet | undefined { + if (index === undefined) { + return undefined; + } + const facet: OpenkalFacet = {}; + if (OPENKAL_FAMILY.includes(id)) { + facet.level = "ecosystem"; + } + const targets: OpenkalTarget[] = []; + let best = -1; + for (const member of Object.values(index.members)) { + if (!member.packages.includes(id)) { + continue; + } + for (const target of Object.values(member.targets)) { + targets.push(target); + const rank = OPENKAL_RANK[target.status ?? ""] ?? -1; + if (rank > best) { + best = rank; + } + } + } + if (facet.level === undefined && best === 2) { + facet.level = "compat"; + } + if (targets.some((target) => target.kind === "platform")) { + facet.kind = "platform"; + } else if (targets.some((target) => target.kind === "posix")) { + facet.kind = "posix"; + } + if (facet.level === undefined && facet.kind === undefined) { + return undefined; + } + return facet; +} + +/** The badges one entry earns, in the site's order (§12.5). */ +export function badgesOf(input: { + hasExamples?: boolean; + hasCnMirror?: boolean; + openkal?: OpenkalFacet; +}): BadgeKey[] { + const out: BadgeKey[] = []; + if (input.hasExamples === true) { + out.push("examples"); + } + if (input.hasCnMirror === true) { + out.push("cn"); + } + if (input.openkal?.level === "ecosystem") { + out.push("openkal-ecosystem"); + } else if (input.openkal?.level === "compat") { + out.push("openkal-compat"); + } + if (input.openkal?.kind === "posix") { + out.push("openkal-posix"); + } else if (input.openkal?.kind === "platform") { + out.push("openkal-platform"); + } + return out; +} + +/** One dependency a descriptor declares for itself. */ +export interface DescriptorDependency { + id: string; + version?: string; +} + +/** + * The dependencies a descriptor's own inline manifest names. + * + * Two shapes occur in this index: a table keyed by package id + * (`deps = { ["compat.vulkan"] = "1.4.357.3" }`) and, in the xlings-style + * descriptors, a list of `ns:name@version` strings. Only the **declared** edge is + * read — the resolved version lives in a project's `mcpp.lock`, which is not this + * package's file, so it is never invented here. + */ +export function descriptorDependencies(text: string): DescriptorDependency[] { + const clean = stripLuaComments(text); + const body = keyBlock(clean, "deps"); + if (body === undefined) { + return []; + } + const out: DescriptorDependency[] = []; + const seen = new Set(); + const push = (id: string, version: string | undefined): void => { + const key = id.trim(); + if (key.length === 0 || seen.has(key) || out.length >= 100) { + return; + } + seen.add(key); + out.push(version === undefined || version.length === 0 ? { id: key } : { id: key, version }); + }; + for (const entry of topLevelEntries(body)) { + if (entry.value !== undefined) { + push(entry.key, entry.value); + continue; + } + const version = entry.body === undefined ? null : /\bversion\s*=\s*"([^"]*)"/.exec(entry.body); + push(entry.key, version === null ? undefined : version[1]); + } + if (out.length === 0) { + // The xlings-style list form: `deps = { "xim:gtk4@4.16.13" }`. Only entries + // that carry the `ns:name@version` shape count; a bare string is not a + // dependency id in this index. + for (const literal of stringLiterals(body)) { + const at = literal.lastIndexOf("@"); + if (at > 0 && literal.slice(0, at).includes(":")) { + push(literal.slice(0, at), literal.slice(at + 1)); + } else if (literal.includes(":")) { + push(literal, undefined); + } + } + } + return out; +} + +// ──────────────────────────────────────────────────── cross-registry search ── + +/** One hit from `mcpp search `. */ +export interface SearchHit { + id: string; + description?: string; + version?: string; +} + +/** + * The one place this extension reads **human** output, and it is behind a + * setting that is off by default (plan §10.2, risk table "第二处解析人类输出", + * upstream request U.8 `mcpp search --format json`). + * + * `mcpp search argparse` prints one hit per line: + * + * ```text + * compat:argparse argparse — header-only argument parser for modern C++ (3.2) + * ``` + * + * The reader is deliberately forgiving — a line that does not match is dropped, + * and an empty result is an empty list, never an error. The caller keeps the + * local index results either way, so a change in this format degrades to "no + * extra hits" instead of to a broken view. + */ +export function parseSearchOutput(text: string): SearchHit[] { + const hits: SearchHit[] = []; + const seen = new Set(); + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.length === 0) { + continue; + } + const match = /^([A-Za-z0-9_.\-]+)[:.]([A-Za-z0-9_.\-]+)\s+(.*)$/.exec(line); + if (match === null) { + continue; + } + const id = `${match[1]}.${match[2]}`; + if (seen.has(id)) { + continue; + } + seen.add(id); + const tail = match[3].trim(); + const version = /\(([^()]*)\)\s*$/.exec(tail); + const description = (version === null ? tail : tail.slice(0, version.index)).trim(); + hits.push({ + id, + ...(description.length === 0 ? {} : { description }), + ...(version === null || version[1].trim().length === 0 ? {} : { version: version[1].trim() }), + }); + if (hits.length >= 100) { + break; + } + } + return hits; +} + +// ─────────────────────────────────────────────────────── mcpp self env ── + +/** The shape of `mcpp self env --format json`, as far as this extension reads it. */ +export interface SelfEnv { + /** `$MCPP_HOME`, the directory everything else hangs off. */ + mcppHome?: string; + /** `/registry`, when mcpp states it. */ + registry?: string; +} + +/** + * `mcpp self env --format json`, read for the one field that matters. + * + * This is a **machine** shape, not human text, so it is held to the detection + * rule `src/cli/protocol.ts` states — presence *and* type of `schemaVersion`, + * plus the `kind` this reader owns — and anything else is refused rather than + * guessed at. The library view falls back to its own globs when this returns + * `undefined`, so a version of mcpp that renames a field costs a fallback and + * never a crash. + */ +export function parseSelfEnv(text: string): SelfEnv | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return undefined; + } + if (!isRecord(parsed) || typeof parsed.schemaVersion !== "number" || parsed.kind !== "mcpp.env") { + return undefined; + } + const data = parsed.data; + if (!isRecord(data)) { + return undefined; + } + const home = data.mcppHome; + const registry = data.registry; + return { + ...(typeof home === "string" && home.trim().length > 0 ? { mcppHome: home.trim() } : {}), + ...(typeof registry === "string" && registry.trim().length > 0 ? { registry: registry.trim() } : {}), + }; +} + +// ───────────────────────────────────────────────────────────────── search ── + +/** The fields a search looks at — never the whole Lua text. */ +export function searchText(entry: LibraryEntry): string { + return [entry.id, entry.description ?? "", entry.licenses.join(" "), entry.repo ?? "", entry.registry].join(" ").toLowerCase(); +} + +export function matchesQuery(entry: LibraryEntry, query: string): boolean { + const needle = query.trim().toLowerCase(); + if (needle.length === 0) { + return true; + } + const haystack = searchText(entry); + return needle.split(/\s+/).every((part) => haystack.includes(part)); +} + +/** The surface's label, localized by the caller and falling back to the vocabulary. */ +export function surfaceLabel(surface: Surface, label: (key: string) => string): string { + const text = SURFACE_TEXT[surface]; + if (text.uiKey === undefined) { + return text.label; + } + const localized = label(text.uiKey); + return localized === undefined || localized.length === 0 || localized === text.uiKey ? text.label : localized; +} + +/** + * Decode `mcpp xpkg parse --json` output for a caller that has the descriptor's + * text too. Kept here so the vscode halves import one library module; the + * parsing itself lives in `xpkg.ts`. + */ +export { parseXpkgJson, versionGroups, versionsFor, surfacesOf, SURFACE_TEXT, SURFACES }; +export type { Surface, XpkgInfo }; diff --git a/mide-plugins/vscode/src/library/libraryHtml.ts b/mide-plugins/vscode/src/library/libraryHtml.ts new file mode 100644 index 0000000..58df6d8 --- /dev/null +++ b/mide-plugins/vscode/src/library/libraryHtml.ts @@ -0,0 +1,488 @@ +/** + * The library sidebar's document, as a pure function (plan §9.3, §10.2, §12). + * + * `src/library/libraryView.ts` resolves the index into a `LibraryModel`; this + * module turns that into one self-contained HTML document. No `vscode`, no file + * system, no network — which is what makes the interesting parts (escaping, the + * strict CSP, the filter/search behaviour, the clamped description) unit + * testable. + * + * House rules, the same ones `src/config/panelHtml.ts` pins down: + * + * - **Strict CSP.** `default-src 'none'` plus exactly three sources: the + * stylesheet, the nonced inline script and the webview's own image source. + * There are no external resources and no inline `style=` attributes. + * - **No hard-coded copy.** Every user-visible label is looked up in + * `LibraryModel.ui`, which the caller has already localized; a key the caller + * forgot degrades to the English fallback beside it, not to a blank control. + * - **No unescaped interpolation.** Every value that reaches the markup goes + * through `escapeHtml`; the client script only ever writes through + * `textContent` and `setAttribute`, never `innerHTML`. + * - **State is text plus shape, not colour.** "已添加" is a labelled badge and a + * `data-added` attribute; the stylesheet may colour it, but the row reads the + * same in a high-contrast theme. + * + * The one deliberate performance choice: the search box + * are applied **in the client**, on rows that are already in the document, so + * typing never round-trips through the extension host and never moves the caret. + * The messages still go back (`search`, `filter`) so the host knows what the + * reader is looking at; the host re-renders only when the *data* changes — + * a refresh, the cross-registry toggle, or a new index revision. + */ + +import { BADGE_UI, SURFACE_TEXT, surfaceLabel, type BadgeKey, type Surface } from "./indexModel"; + +/** One result row, already resolved for display. */ +export interface LibraryRow { + id: string; + key?: string; + source?: string; + namespace?: string; + name: string; + /** The greatest version this platform has, or `undefined`. */ + version?: string; + surface?: Surface; + /** Every surface the descriptor supports, not only the leading one. */ + surfaces: Surface[]; + badges: BadgeKey[]; + /** + * The lower-cased text a query is matched against, built by the host through + * `indexModel.searchText`. It travels in the row so the client's filter and the + * host's `visibleEntries` cannot drift apart on what "matches" means. + */ + haystack: string; + description?: string; + /** Declared in the workspace's `mcpp.toml`. */ + added: boolean; + /** The descriptor's own fields could not be read; identity comes from the file name. */ + unreadable: boolean; + /** Came from `mcpp search`, not from the local index. */ + crossRegistry?: boolean; +} + +export interface LibraryModel { + /** Everything is already localized by the caller. */ + ui: Record; + rows: LibraryRow[]; + selectedKey?: string; + query: string; + networkSearch: boolean; + /** + * The setting the toggle writes. Carried in the model so the client and the + * host cannot disagree about which key the control drives. + */ + networkSearchSetting: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + sourceDetails?: string; + status?: string; + projectNotice?: string; + /** `{0}` visible of `{1}` known, already localized; the header count uses it. */ + countTemplate: string; + /** Rows before filtering; the count line shows it against what is visible. */ + total: number; + /** Set when there is nothing to show, already localized. */ + notice?: string; +} + +export interface LibraryAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** + * The `ui` keys the renderer reads, so the caller and the renderer cannot drift + * apart on a string. `libraryView.ts` fills all of them through `t()`. + */ +export const LIBRARY_UI = { + htmlLang: "library.htmlLang", + title: "library.title", + search: "library.search", + networkSearch: "library.networkSearch", + networkSearchHint: "library.networkSearch.hint", + versionLatest: "library.version.latest", + surfaceExternal: SURFACE_TEXT.external.uiKey ?? "library.surface.external", + badgeExamples: BADGE_UI.examples.key, + badgeCn: BADGE_UI.cn.key, + badgeOpenkalEcosystem: BADGE_UI["openkal-ecosystem"].key, + badgeOpenkalCompat: BADGE_UI["openkal-compat"].key, + badgeOpenkalPosix: BADGE_UI["openkal-posix"].key, + badgeOpenkalPlatform: BADGE_UI["openkal-platform"].key, + added: "library.added", + unreadable: "library.unreadable", + crossRegistry: "library.crossRegistry", + noResults: "library.noResults", + noIndex: "library.noIndex", + dataSource: "library.dataSource", + count: "library.count", + open: "library.open", + refresh: "library.refresh", + sources: "library.sources", + other: "library.other", + settings: "library.settings", + update: "library.update", + output: "library.output", + clear: "library.clear", +} as const; + +/** One badge key's `ui` key, so the renderer has a single lookup table. */ +const BADGE_KEY_UI: Readonly> = { + examples: LIBRARY_UI.badgeExamples, + cn: LIBRARY_UI.badgeCn, + "openkal-ecosystem": LIBRARY_UI.badgeOpenkalEcosystem, + "openkal-compat": LIBRARY_UI.badgeOpenkalCompat, + "openkal-posix": LIBRARY_UI.badgeOpenkalPosix, + "openkal-platform": LIBRARY_UI.badgeOpenkalPlatform, +}; + +/** A mounted handshake replays data in place after the initial document loads. + * The host never assigns webview.html in response, so it cannot create a reload loop. + */ +export type LibraryMessage = + | { type: "refresh" | "settings" | "update" | "output" | "mounted" } + | { type: "search"; query: string } + | { type: "networkSearch"; enabled: boolean; token?: string } + | { type: "open"; id: string }; + +export type UiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * The CSP is an attribute value, but its single quotes are syntax: escaping + * them would make the document state a different policy than it means. Only the + * characters that could end the attribute or start a tag are escaped. + */ +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** ` name="value"`, or nothing when the value is absent. */ +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +/** ` name`, or nothing. Boolean HTML attributes only. */ +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly (string | number)[]): string { + return label(key).replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number(index)]; + return value === undefined ? whole : String(value); + }); +} + +/** The badge labels, in `BADGE_UI` order. */ +export function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string[] { + return badges.map((badge) => label(BADGE_KEY_UI[badge])); +} + +function renderRow(row: LibraryRow, label: UiLabel): string { + const badges: string[] = []; + const openTitle = fill(label, LIBRARY_UI.open, [row.id]); + const surface = row.surface === undefined ? undefined : surfaceLabel(row.surface, label); + const version = + row.version === undefined ? "" : `${escapeHtml(fill(label, LIBRARY_UI.versionLatest, [row.version]))}`; + const secondary = [ + surface === undefined ? "" : `${escapeHtml(surface)}`, + ...badges.map((badge) => `${escapeHtml(badge)}`), + row.added ? `${escapeHtml(label(LIBRARY_UI.added))}` : "", + row.unreadable ? `${escapeHtml(label(LIBRARY_UI.unreadable))}` : "", + row.crossRegistry === true || row.source ? `${escapeHtml(row.source ?? label(LIBRARY_UI.crossRegistry))}` : "", + ] + .filter((part) => part.length > 0) + .join(""); + const description = row.description === undefined ? "" : row.description; + return [ + `
  • `, + ` `, + `
  • `, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +/** + * The search box, and the network toggle under it. + * + * There used to be a wrapped row of filter chips between them — one per + * namespace, one per surface, plus "Added" — which on a real index is three + * lines of buttons above the list, and the list is the view. The search box + * already matches the namespace (it is part of a row's haystack), so the + * taxonomy the chips offered is still one keystroke away; the "Added" state is + * on the row itself, as a badge. + */ +function renderToolbar(model: LibraryModel, label: UiLabel): string { + return [ + `
    `, + ` `, + ` `, + ` `, + ` `, + ` ${escapeHtml(fill(label, LIBRARY_UI.count, [model.rows.filter((row) => !row.crossRegistry && rowMatchesClient(model, row)).length, model.rows.filter((row) => !row.crossRegistry).length]))}`, + `
    `, + ].join("\n"); +} + +function renderList(model: LibraryModel, label: UiLabel): string { + const local = model.rows.filter((row) => !row.crossRegistry); + const extra = model.rows.filter((row) => row.crossRegistry); + return `
    + ${model.projectNotice ? `

    ${escapeHtml(model.projectNotice)}

    ` : ""} +
      ${local.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(label(LIBRARY_UI.other))} (${extra.length})

    +
      ${extra.map((row) => renderRow(row, label)).join("\n")}
    +
    +

    ${escapeHtml(model.notice ?? label(LIBRARY_UI.noResults))}

    +

    ${escapeHtml(model.status ?? "")}

    +
    `; +} + +/** + * The client. Dependency-free, no template literals of its own, and it only + * writes through `textContent`/`setAttribute`. + * + * Search is local: a row carries the text the search box matches against as a + * `data-haystack` attribute, and the query decides its `hidden` flag. The host is + * told the query, and answers with a whole new document only when the data + * behind it changed. + */ +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; + var searchInput = document.getElementById("library-search"); + var networkInput = document.getElementById("library-network"); + var networkToken; + var networkSequence = 0; + var selected = state.selectedKey || saved.selected || ""; + if (searchInput && typeof saved.query === "string") { searchInput.value = saved.query; } + function post(message) { if (api) { api.postMessage(message); } } + function persist() { + if (api && api.setState) { api.setState({ query: searchInput.value, selected: selected, scroll: window.scrollY }); } + } + function apply() { + document.querySelector('[data-action="clear"]').hidden = searchInput.value.length === 0; + var query = searchInput.value.trim().toLowerCase(); + var parts = query.split(/\\s+/); + var rows = Array.from(document.querySelectorAll("[data-row]")); + var local = 0; + var extra = 0; + rows.forEach(function (row) { + var haystack = row.getAttribute("data-haystack") || ""; + var shown = parts.every(function (part) { return !part || haystack.indexOf(part) >= 0; }); + row.parentNode.hidden = !shown; + if (shown) { if (row.getAttribute("data-cross") === "true") { extra++; } else { local++; } } + if (row.getAttribute("data-row") === selected) { row.setAttribute("aria-current", "true"); } + else { row.removeAttribute("aria-current"); } + }); + var list = document.getElementById("library-list"); + if (list) { + Array.from(list.children).sort(function (a, b) { + function rank(item) { + var id = item.firstElementChild.getAttribute("data-id").toLowerCase(); + var name = id.slice(id.lastIndexOf(".") + 1); + return !query ? 2 : id === query || name === query ? 0 : id.indexOf(query) === 0 || name.indexOf(query) === 0 ? 1 : 2; + } + return rank(a) - rank(b) || a.firstElementChild.getAttribute("data-id").localeCompare(b.firstElementChild.getAttribute("data-id")); + }).forEach(function (item) { list.appendChild(item); }); + } + var empty = document.getElementById("library-empty"); + if (empty) { empty.hidden = local + extra > 0; } + var group = document.getElementById("library-extra"); + if (group) { group.hidden = extra === 0; } + document.getElementById("library-count").textContent = state.countTemplate.split("{0}").join(String(local)).split("{1}").join(String(state.localTotal)); + persist(); + } + searchInput.addEventListener("input", function () { apply(); post({ type: "search", query: searchInput.value }); }); + networkInput.addEventListener("change", function () { networkToken = Date.now() + "-" + (++networkSequence); post({ type: "networkSearch", enabled: networkInput.checked, token: networkToken }); }); + document.addEventListener("click", function (event) { + var target = event.target.closest && event.target.closest("[data-action], [data-row]"); + if (!target) { return; } + var action = target.getAttribute("data-action"); + if (action === "clear") { searchInput.value = ""; searchInput.focus(); apply(); post({ type: "search", query: "" }); return; } + if (action) { post({ type: action }); return; } + selected = target.getAttribute("data-row"); apply(); post({ type: "open", id: selected }); + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && event.target === searchInput) { + searchInput.value = ""; apply(); post({ type: "search", query: "" }); + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } + var rows = Array.from(document.querySelectorAll("[data-row]")).filter(function (row) { return !row.parentNode.hidden; }); + if (!rows.length || (event.target !== searchInput && !event.target.hasAttribute("data-row"))) { return; } + event.preventDefault(); + var index = rows.indexOf(document.activeElement); + var next = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index - 1); + rows[next].focus(); + }); + window.addEventListener("scroll", persist); + window.addEventListener("message", function (event) { + var payload = event.data; + if (payload && payload.type === "networkSearchSaved") { if (payload.token === networkToken) { networkToken = undefined; networkInput.checked = payload.enabled; } return; } + if (!payload || payload.type !== "libraryUpdate") { return; } + var scroll = window.scrollY; + var focused = document.activeElement && document.activeElement.getAttribute("data-row"); + var details = document.querySelector("footer details"); + var expanded = details && details.open; + var doc = new DOMParser().parseFromString(payload.html, "text/html"); + ["library-results", "library-footer"].forEach(function (id) { + var old = document.getElementById(id); var next = doc.getElementById(id); + if (old && next) { old.replaceWith(next); } + }); + state = payload.state; + if (state.selectedKey) { selected = state.selectedKey; } + if (!networkToken) { networkInput.checked = payload.networkSearch; } + apply(); + var nextDetails = document.querySelector("footer details"); if (nextDetails) { nextDetails.open = expanded; } + if (focused) { Array.from(document.querySelectorAll("[data-row]")).some(function (row) { if (row.getAttribute("data-row") === focused) { row.focus(); return true; } return false; }); } + window.scrollTo(0, scroll); + }); + apply(); + window.scrollTo(0, saved.scroll || 0); + post({ type: "mounted" }); + post({ type: "search", query: searchInput.value }); +})();`; +} + +/** + * The little state the client needs, not the whole model. + * + * The rows are already in the document and the badges are already resolved, so + * embedding the model again would double the page for nothing. `<` is escaped in + * `embedJson`, so even this cannot close the script element. + */ +export function libraryClientState(model: LibraryModel): Record { + return { + countTemplate: model.countTemplate, + selectedKey: model.selectedKey, + total: model.total, + localTotal: model.rows.filter((row) => !row.crossRegistry).length, + query: model.query, + }; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const visible = model.rows.filter((row) => rowMatchesClient(model, row)).length; + const count = fill(label, LIBRARY_UI.count, [visible, model.total]); + return ` + + + + + + +${escapeHtml(label(LIBRARY_UI.title))} + + +
    +

    ${escapeHtml(label(LIBRARY_UI.title))}

    +
    +${renderToolbar(model, label)} +
    +${renderList(model, label)} +
    +
    +
    ${escapeHtml(model.dataSource)}

    ${escapeHtml(model.sourceDetails ?? "")}

    + +
    + + + +`; +} + +/** + * The rows the initial query keeps. The live search is the client's + * `rowMatches`; this mirrors it for the header count, so the count the reader + * first sees matches the list beneath it. + */ +function rowMatchesClient(model: LibraryModel, row: LibraryRow): boolean { + const query = model.query.trim().toLowerCase(); + if (query.length === 0) { + return true; + } + return query.split(/\s+/).every((part) => part.length === 0 || row.haystack.includes(part)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not exactly one of the four shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + */ +export function decodeLibraryMessage(raw: unknown): LibraryMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "mounted": + case "refresh": + case "settings": + case "update": + case "output": + return { type: raw.type }; + case "search": + return typeof raw.query === "string" ? { type: "search", query: raw.query } : undefined; + case "networkSearch": + return typeof raw.enabled === "boolean" ? { type: "networkSearch", enabled: raw.enabled, ...(typeof raw.token === "string" && raw.token.length <= 80 ? { token: raw.token } : {}) } : undefined; + case "open": + return nonEmptyString(raw.id) ? { type: "open", id: raw.id } : undefined; + default: + return undefined; + } +} diff --git a/mide-plugins/vscode/src/library/libraryView.ts b/mide-plugins/vscode/src/library/libraryView.ts new file mode 100644 index 0000000..043705e --- /dev/null +++ b/mide-plugins/vscode/src/library/libraryView.ts @@ -0,0 +1,535 @@ +/** + * The library sidebar: the `mcpp.library` view. + * + * The *model* is `src/library/libraryHtml.ts` plus `src/library/indexModel.ts`, + * both pure and unit-tested; the *reading* is `src/library/indexLocator.ts`, + * which caches per index revision. This file is the seam: it owns the webview's + * lifetime, the `t()` literals, the settings and the messages. + * + * Three deliberate boundaries: + * + * - **The list never spawns a process per package and never touches the + * network.** The descriptors are read as text; `mcpp xpkg parse --json` runs + * only from the detail page. The one exception is `mcpp search`, behind + * `mcpp.library.networkSearch`, which is **off by default** and whose output + * reader is the tolerant best-effort one in `indexModel.parseSearchOutput` + * (plan §10.2; upstream request U.8). + * - **One render, applied in place.** Filtering happens in the document; the + * host re-renders only when the data changed. A `webview.html` assignment + * would otherwise steal the caret on every keystroke. + * - **A failure still renders.** No index, no descriptors or a failed read all + * produce a document with a sentence in it, never a blank sidebar. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { effective, read, write } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { WebviewDocument } from "../webview/document"; +import { badgesOf, searchText, parseSearchOutput, type LibraryEntry } from "./indexModel"; +import { loadSnapshot, invalidateIndexCache, type IndexRoot, type LibrarySnapshot } from "./indexLocator"; +import { + LIBRARY_UI, + decodeLibraryMessage, + renderLibraryHtml, + libraryClientState, + type LibraryModel, + type LibraryRow, +} from "./libraryHtml"; + +/** The view id `package.json` declares. */ +export const LIBRARY_VIEW_ID = "mcpp.library"; + +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "library.css"; + +/** The delay before a cross-registry search runs: one per pause, not per keystroke. */ +const SEARCH_DEBOUNCE_MS = 400; +const SEARCH_TIMEOUT_MS = 20_000; + +export interface LibraryViewDeps { + /** + * The workspace folder whose `mcpp.toml` decides which packages are "already + * declared". `undefined` is a normal state: the list then simply has none. + */ + projectRoot: () => string | undefined; + /** The configured executable, for the cross-registry search only. */ + mcppExecutable: () => string; + /** Opens the editor-area detail page for one package id. */ + openDetail: (id: string, preview?: LibraryRow) => Promise | void; + /** Where `mcpp search` is logged; the same channel the other commands use. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; `mcpp search` never runs without it. */ + isTrusted: () => boolean; +} + +/** + * Register the view: the provider, its settings/save listeners and the + * `registerWebviewViewProvider` call that makes VS Code ask it for a document. + * + * That last call is the whole point — without it the view has no document at + * all, and every `refresh()` below is a no-op against `this.view === undefined`. + * It lives here, next to the provider it hands over, exactly like + * `registerCachePanel`; `extension.ts` only wires the two entry-point commands. + * + * `retainContextWhenHidden` is not supported by a webview view, which is why the + * render is idempotent: the document is rebuilt from the snapshot every time the + * view becomes visible again. + */ +export function registerLibraryView(context: vscode.ExtensionContext, deps: LibraryViewDeps): LibraryViewHandle { + const provider = new LibraryViewProvider(context, deps); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(LIBRARY_VIEW_ID, provider, { + webviewOptions: { retainContextWhenHidden: false }, + }), + provider, + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library") || event.affectsConfiguration("mcpp.path")) { + invalidateIndexCache(); + void provider.refresh(); + } + }), + // `mcpp.toml` decides the "已添加" state, so saving it re-renders the list. + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.uri.path.endsWith("/mcpp.toml") || document.uri.path.endsWith("\\mcpp.toml")) { + void provider.refresh(); + } + }), + ); + return { + refresh: () => provider.refresh(), + select: (key: string) => provider.select(key), + setUpdateStatus: (status: string) => provider.setUpdateStatus(status), + dispose: () => provider.dispose(), + }; +} + +/** + * What `extension.ts` keeps a reference to. + * + * `refresh()` is how a dependency change made **outside the editor** — `mcpp add` + * run by the detail page, or `mcpp index update` — reaches the list, because no + * document is saved in that path and therefore no save event fires. The provider + * itself is not handed back: `registerLibraryView` has already registered it, + * and a caller that wants to reach into the view would be reaching past the two + * methods below. + */ +export interface LibraryViewHandle { + refresh(): Promise; + select(key: string): void; + setUpdateStatus(status: string): void; + dispose(): void; +} + +class LibraryViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private snapshot: LibrarySnapshot | undefined; + private query = ""; + private selectedKey = ""; + /** Cross-registry hits for the current query, from `mcpp search`. */ + private extra: LibraryRow[] = []; + private searchNote: string | undefined; + private searchTimer: NodeJS.Timeout | undefined; + private rendering: Promise | undefined; + private refreshAgain = false; + private requestGeneration = 0; + private error: string | undefined; + private updateNote: string | undefined; + private networkWrites: Promise = Promise.resolve(); + private networkChange = 0; + private painted = false; + private lastHtml = ""; + /** + * The document on screen and the one CSP nonce it may be built with. Both + * live in `WebviewDocument`: the nonce is per view (a nonce per render would + * make every render a *different* document and defeat the comparison, which + * is how the reload loop started), and `paint()` refuses to re-assign a + * document that has not changed, because an assignment reloads the view. + */ + private readonly webviewDocument = new WebviewDocument(STYLESHEET); + + public constructor( + private readonly context: vscode.ExtensionContext, + private readonly deps: LibraryViewDeps, + ) {} + + public setUpdateStatus(status: string): void { this.updateNote = status; this.paint(); } + + public select(key: string): void { this.selectedKey = key; this.paint(); } + + public dispose(): void { + this.requestGeneration++; + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + this.searchTimer = undefined; + } + } + + public resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + // A resolved view is a fresh, empty webview; see the note in `onDidDispose`. + this.webviewDocument.invalidate(); + this.painted = false; + this.lastHtml = ""; + view.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY)], + }; + view.webview.onDidReceiveMessage((raw: unknown) => { + void this.handle(raw); + }); + view.onDidDispose(() => { + if (this.view === view) { + this.view = undefined; + // The next `resolveWebviewView` gets a brand-new, empty webview: what was + // pushed to the old one says nothing about it, so the comparison in + // `paint()` must start from nothing again. + this.webviewDocument.invalidate(); + } + }); + // First paint from the cached snapshot, so the view is never blank while the + // index is being read. + this.paint(); + void this.refresh(); + } + + /** Re-read the index (from the cache when the revision is unchanged) and paint. */ + public refresh(): Promise { + this.requestGeneration++; + this.extra = []; + this.searchNote = undefined; + if (this.rendering !== undefined) { + this.refreshAgain = true; + return this.rendering; + } + this.rendering = (async () => { + do { + this.refreshAgain = false; + const projectRoot = this.deps.projectRoot(); + try { + const snapshot = await loadSnapshot({ projectRoot }); + if (projectRoot !== this.deps.projectRoot()) { this.refreshAgain = true; continue; } + this.snapshot = snapshot; + this.error = undefined; + } catch (error) { + this.error = t("Could not read the index: {0}", error instanceof Error ? error.message : String(error)); + this.deps.output?.appendLine(this.error); + } + this.paint(); + } while (this.refreshAgain); + })().finally(() => { this.rendering = undefined; }); + return this.rendering; + } + + private async handle(raw: unknown): Promise { + const message = decodeLibraryMessage(raw); + if (message === undefined) { + return; + } + switch (message.type) { + case "mounted": + this.lastHtml = ""; + this.paint(); + return; + case "settings": + await vscode.commands.executeCommand("workbench.action.openSettings", "mcpp.library.indexPath"); + return; + case "update": + await vscode.commands.executeCommand("mcpp.library.updateIndex"); + return; + case "output": + this.deps.output?.show(true); + return; + case "refresh": + invalidateIndexCache(); + await this.refresh(); + return; + case "search": + this.query = message.query; + this.scheduleSearch(); + return; + case "networkSearch": + await this.setNetworkSearch(message.enabled, message.token); + return; + case "open": + await this.deps.openDetail(message.id, this.extra.find((row) => (row.key ?? row.id) === message.id)); + return; + default: + return; + } + } + + /** `mcpp.library.networkSearch`: persisted, then honoured on the next query. */ + private async setNetworkSearch(enabled: boolean, token?: string): Promise { + const change = ++this.networkChange; + this.requestGeneration++; + this.extra = []; + this.searchNote = undefined; + try { + const source = effective("mcpp.library.networkSearch").source; + const queued = this.networkWrites.then(() => write("mcpp.library.networkSearch", enabled, source === "workspace" || source === "workspaceFolder" ? "workspace" : "user")); + this.networkWrites = queued.catch(() => {}); + await queued; + } catch (error) { + this.deps.output?.appendLine( + `mcpp library: could not write mcpp.library.networkSearch: ${error instanceof Error ? error.message : String(error)}`, + ); + this.searchNote = t("The setting could not be written; the toggle was not saved."); + } + if (change !== this.networkChange) { return; } + void this.view?.webview.postMessage({ type: "networkSearchSaved", token, enabled: this.networkSearch() }); + if (enabled && this.networkSearch()) { + await this.runCrossRegistrySearch(); + } else { + this.paint(); + } + } + + private scheduleSearch(): void { + this.requestGeneration++; + const hadExtra = this.extra.length > 0 || this.searchNote !== undefined; + this.extra = []; + this.searchNote = undefined; + if (hadExtra) { this.paint(); } + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + } + if (!this.networkSearch() || this.query.trim().length < 2) { + this.extra = []; + this.searchNote = undefined; + return; + } + this.searchTimer = setTimeout(() => { + this.searchTimer = undefined; + void this.runCrossRegistrySearch(); + }, SEARCH_DEBOUNCE_MS); + } + + private networkSearch(): boolean { + return read("mcpp.library.networkSearch") === true; + } + + /** + * The cross-registry tier: `mcpp search `. + * + * Best effort by design (§10.2): it may use the network, its output is human + * text (upstream request U.8 asks for `--format json`), and any failure keeps + * the local results and *says so* rather than failing silently. Only ids the + * local index does not already have are added. + */ + private async runCrossRegistrySearch(): Promise { + const query = this.query.trim(); + const generation = ++this.requestGeneration; + const projectRoot = this.deps.projectRoot(); + if (!this.networkSearch() || query.length < 2) { + this.extra = []; + this.searchNote = undefined; + this.paint(); + return; + } + // `mcpp search` runs the configured executable and `mcpp.path` is + // resource-scoped: an untrusted workspace must not name the program, so the + // tier refuses and says so instead of silently showing nothing extra. + this.searchNote = t("Searching other registries…"); + this.paint(); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Searching all registries…") }, + () => + runMcpp(this.deps.isTrusted(), this.deps.mcppExecutable(), ["search", query], this.deps.projectRoot(), { + timeoutMs: SEARCH_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (generation !== this.requestGeneration || !this.networkSearch() || this.query.trim() !== query || this.deps.projectRoot() !== projectRoot) { return; } + if (result === undefined) { + this.extra = []; + this.searchNote = t("The workspace is not trusted; the other registries were not searched."); + this.paint(); + return; + } + if (result.exitCode !== 0) { + this.extra = []; + this.searchNote = t( + "Searching all registries failed (exit {0}); showing the local index only.", + result.exitCode, + ); + this.paint(); + return; + } + const known = new Set((this.snapshot?.entries ?? []).map((entry) => entry.id)); + this.extra = parseSearchOutput(result.stdout) + .filter((hit) => !known.has(hit.id)) + .map((hit) => ({ + id: hit.id, + key: `search:${hit.id}`, + source: t("Other registry"), + name: hit.id.includes(".") ? hit.id.slice(hit.id.indexOf(".") + 1) : hit.id, + ...(hit.id.includes(".") ? { namespace: hit.id.slice(0, hit.id.indexOf(".")) } : {}), + ...(hit.version === undefined ? {} : { version: hit.version }), + surfaces: [], + badges: [], + haystack: `${hit.id} ${hit.description ?? ""}`.toLowerCase(), + ...(hit.description === undefined ? {} : { description: hit.description }), + added: false, + unreadable: false, + crossRegistry: true, + })); + this.searchNote = + this.extra.length === 0 + ? t("No further packages were found in the other registries.") + : t("{0} further package(s) came from the other registries.", this.extra.length); + this.paint(); + } + + private labels(): Record { + return { + [LIBRARY_UI.htmlLang]: htmlLanguage(), + [LIBRARY_UI.title]: t("Libraries"), + [LIBRARY_UI.sources]: t("Source details"), + [LIBRARY_UI.other]: t("Other registries"), + [LIBRARY_UI.settings]: t("Set index path"), + [LIBRARY_UI.update]: t("Update mcpp index"), + [LIBRARY_UI.output]: t("Show output"), + [LIBRARY_UI.clear]: t("Clear search"), + [LIBRARY_UI.search]: t("Search packages"), + [LIBRARY_UI.networkSearch]: t("Search other registries"), + [LIBRARY_UI.networkSearchHint]: t( + "Off by default: this tier runs mcpp search, which may use the network and is read from human output on a best-effort basis. Failures fall back to the local index.", + ), + [LIBRARY_UI.versionLatest]: t("{0}"), + [LIBRARY_UI.surfaceExternal]: t("upstream mcpp.toml"), + [LIBRARY_UI.badgeExamples]: t("✓ Has examples"), + [LIBRARY_UI.badgeCn]: t("China mirror"), + [LIBRARY_UI.badgeOpenkalEcosystem]: t("openkal-ecosystem"), + [LIBRARY_UI.badgeOpenkalCompat]: t("openkal-compat"), + [LIBRARY_UI.badgeOpenkalPosix]: t("POSIX environment"), + [LIBRARY_UI.badgeOpenkalPlatform]: t("uses platform interfaces"), + [LIBRARY_UI.added]: t("Declared"), + [LIBRARY_UI.unreadable]: t("Descriptor not readable"), + [LIBRARY_UI.noResults]: t("No package matches this search."), + [LIBRARY_UI.noIndex]: t( + "No mcpp index was found. Run mcpp: Refresh the mcpp Package Index once, or set mcpp.library.indexPath to an index checkout.", + ), + [LIBRARY_UI.dataSource]: t("Data source"), + [LIBRARY_UI.count]: t("{0} of {1} packages"), + [LIBRARY_UI.open]: t("Open the detail page for {0}"), + [LIBRARY_UI.crossRegistry]: t("other registry"), + [LIBRARY_UI.refresh]: t("Reread locally"), + }; + } + + /** + * Put the current model on screen — but only when it says something new. + * + * `webview.html = …` reloads the document, so pushing an identical one would + * throw away the scroll position and the half-typed query for nothing. With + * a per-view nonce, "identical" means identical: the same model renders byte + * for byte the same document, and a render that produced it is dropped in + * `WebviewDocument.paint()`. + */ + private paint(): void { + const view = this.view; + if (view === undefined) { + return; + } + const document = renderLibraryHtml( + this.model(), + this.webviewDocument.assets(vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY), view.webview), + ); + if (document === this.lastHtml) { return; } + this.lastHtml = document; + if (!this.painted) { + this.webviewDocument.paint(view.webview, document); + this.painted = true; + } else { + const model = this.model(); + void view.webview.postMessage({ type: "libraryUpdate", html: document, state: libraryClientState(model), networkSearch: model.networkSearch }); + } + } + + private model(): LibraryModel { + const snapshot = this.snapshot; + const ui = this.labels(); + if (snapshot === undefined) { + return this.emptyModel(ui, this.error ?? t("Reading the index…"), t("Reading the index…")); + } + const entries = snapshot.entries; + const rows = [...entries.map((entry) => rowOf(entry, snapshot.roots.some((root) => root.kind === "custom"))), ...this.extra]; + const notice = + snapshot.roots.length === 0 + ? ui[LIBRARY_UI.noIndex] + : entries.length === 0 + ? t("The index folders contain no descriptors.") + : ui[LIBRARY_UI.noResults]; + return { + ui, + rows, + selectedKey: this.selectedKey, + query: this.query, + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource: this.dataSource(snapshot.roots, entries.length), + sourceDetails: snapshot.roots.map((root) => `${root.kind === "official" ? t("Official index") : t("Custom index")}: ${root.path}${root.updatedAt === undefined ? "" : ` · ${new Date(root.updatedAt).toLocaleString()}`}`).join("\n"), + status: [this.error, this.searchNote, this.updateNote].filter(Boolean).join(" ") || undefined, + projectNotice: this.deps.projectRoot() === undefined ? t("Browse libraries; select an mcpp project in the detail page to add dependencies.") : undefined, + countTemplate: ui[LIBRARY_UI.count], + total: rows.length, + ...(rows.length === 0 ? { notice } : {}), + }; + } + + private emptyModel(ui: Record, notice: string, dataSource: string): LibraryModel { + return { + ui, + rows: [], + query: "", + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource, + countTemplate: ui[LIBRARY_UI.count], + total: 0, + notice, + }; + } + + /** The footer, in plain words: where the rows came from and whether it is offline. */ + private dataSource(roots: readonly IndexRoot[], total: number): string { + if (roots.length === 0) { return t("No local mcpp-index found"); } + const name = roots.length === 1 && roots[0].kind === "official" ? "mcpp-index" : t("Custom index"); + return t("{0} · {1} packages · local", name, total); + } +} + +/** One entry as the row the document renders. */ +function rowOf(entry: LibraryEntry, showSource = false): LibraryRow { + return { + id: entry.id, + key: entry.file, + ...(showSource ? { source: entry.registry } : {}), + haystack: searchText(entry), + ...(entry.namespace === undefined ? {} : { namespace: entry.namespace }), + name: entry.name, + ...(entry.version === undefined ? {} : { version: entry.version }), + ...(entry.surface === undefined ? {} : { surface: entry.surface }), + surfaces: entry.surfaces, + badges: badgesOf(entry), + ...(entry.description === undefined ? {} : { description: entry.description }), + added: entry.added, + unreadable: entry.unreadable === true, + }; +} + + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} diff --git a/mide-plugins/vscode/src/library/projectState.ts b/mide-plugins/vscode/src/library/projectState.ts new file mode 100644 index 0000000..ec16bac --- /dev/null +++ b/mide-plugins/vscode/src/library/projectState.ts @@ -0,0 +1,105 @@ +import type { DeclaredDependencyEntry, LockPackage } from "./indexModel"; +import { parseMcppToml, type TomlValueNode, type TomlKeyValueNode } from "../toml/parser"; + +export type DependencyKind = "normal" | "dev" | "build"; + +export interface ProjectDeclaration extends DeclaredDependencyEntry { + kind?: DependencyKind; + source?: boolean; + conditional?: boolean; + path?: string; + git?: string; +} + +/** Reuse the editor's TOML parser so quoted keys, comments and source+version tables keep their meaning. */ +export function readProjectDeclarations(text: string): ProjectDeclaration[] { + const out: ProjectDeclaration[] = []; + const collect = (id: string, value: TomlValueNode | undefined, kind: DependencyKind, conditional: boolean): void => { + const dev = kind === "dev"; + if (!value) { return; } + if (value.kind === "string") { out.push({ id, version: value.text, dev, kind, conditional }); return; } + const entries = value.entries ?? []; + const source = entries.some((entry) => ["path", "git", "url"].includes(entry.keyPath[0]?.name)); + const version = entries.find((entry) => entry.keyPath[0]?.name === "version")?.value?.text; + const specKeys = new Set(["version", "path", "git", "url", "branch", "rev", "tag", "features", "backend", "tools", "host-module", "reexport", "workspace"]); + if (source || entries.some((entry) => specKeys.has(entry.keyPath[0]?.name))) { + const localPath = entries.find((entry) => entry.keyPath[0]?.name === "path")?.value?.text; + const git = entries.find((entry) => entry.keyPath[0]?.name === "git")?.value?.text; + out.push({ id, version, dev, kind, source, conditional, ...(localPath ? { path: localPath } : {}), ...(git ? { git } : {}) }); + } else if (entries.length) { + for (const entry of entries) { collect(`${id}.${entry.keyPath.map((key) => key.name).join(".")}`, entry.value, kind, conditional); } + } else { out.push({ id, dev, kind, conditional }); } + }; + const groups: Array<{ section: string[]; entries: TomlKeyValueNode[] }> = [{ section: [], entries: [] }]; + for (const node of parseMcppToml(text.split(/\r?\n/)).nodes) { + if (node.type === "section") { groups.push({ section: node.segments.map((segment) => segment.name), entries: [] }); } + else { groups[groups.length - 1].entries.push(node); } + } + for (const { section, entries } of groups) { + const index = section.findIndex((part) => part === "dependencies" || part === "dev-dependencies" || part === "build-dependencies"); + if (index < 0 || section[0] === "workspace") { continue; } + const kind = section[index] === "dev-dependencies" ? "dev" : section[index] === "build-dependencies" ? "build" : "normal"; + if (section.length > index + 1 && entries.some((entry) => entry.keyPath.length === 1 && ["version", "path", "git", "url", "workspace"].includes(entry.keyPath[0].name))) { + collect(section.slice(index + 1).join("."), { kind: "inlineTable", open: false, range: entries[0].range, entries }, kind, index !== 0); + } else { + for (const node of entries) { + const id = [...section.slice(index + 1), ...node.keyPath.map((key) => key.name)].join("."); + collect(id, node.value, kind, index !== 0); + } + } + } + return out; +} + +export interface ProjectDependencyState { + declared: boolean; + declaredVersion?: string; + resolvedVersion?: string; + dev: boolean; + sourceType: "registry" | "path-or-git" | "none"; + ambiguous: boolean; +} + +/** Qualified coordinates win. A short name is usable only when the catalog is unambiguous. */ +export function projectDependencyState( + id: string, + declarations: readonly ProjectDeclaration[], + locked: readonly LockPackage[], + catalog: readonly string[], + kind?: DependencyKind, +): ProjectDependencyState { + declarations = declarations.filter((entry) => kind === undefined ? entry.kind !== "build" : (entry.kind ?? (entry.dev ? "dev" : "normal")) === kind); + const short = id.slice(id.lastIndexOf(".") + 1); + const unique = new Set(catalog.filter((candidate) => candidate.slice(candidate.lastIndexOf(".") + 1) === short)).size === 1; + const exact = declarations.filter((entry) => entry.id === id); + const matches = exact.length ? exact : unique ? declarations.filter((entry) => entry.id === short) : []; + const entry = matches[0]; + const resolved = locked.find((entry) => entry.id === id) ?? (unique ? locked.find((entry) => entry.id === short) : undefined); + return { + declared: entry !== undefined, + ...(entry?.version === undefined ? {} : { declaredVersion: entry.version }), + ...(resolved === undefined ? {} : { resolvedVersion: resolved.version }), + dev: entry?.dev ?? false, + sourceType: entry === undefined ? "none" : entry.source || entry.version === undefined ? "path-or-git" : "registry", + ambiguous: matches.length > 1 || !!entry?.conditional || (!unique && exact.length === 0 && declarations.some((entry) => entry.id === short)), + }; +} + +/** Every asynchronous result belongs to both an operation and a rendered page. */ +export function operationMatches( + operation: { key: string; projectRoot?: string; generation: number }, + page: { key: string; projectRoot?: string; generation: number }, +): boolean { + return operation.key === page.key && operation.projectRoot === page.projectRoot && operation.generation === page.generation; +} + + +/** TOML preview only. The CLI currently cannot add build dependencies or migrate groups. */ +export function dependencyLocation(id: string, kind: DependencyKind): { table: string; name: string } { + const at = id.lastIndexOf("."); + const namespace = at < 0 ? "" : id.slice(0, at); + const name = at < 0 ? id : id.slice(at + 1); + const quote = (value: string) => /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value); + const section = kind === "build" ? "build-dependencies" : kind === "dev" ? "dev-dependencies" : "dependencies"; + return { table: !namespace || namespace === "mcpplibs" ? section : `${section}.${quote(namespace)}`, name: quote(name) }; +} diff --git a/mide-plugins/vscode/src/library/projectWatcher.ts b/mide-plugins/vscode/src/library/projectWatcher.ts new file mode 100644 index 0000000..5a463e7 --- /dev/null +++ b/mide-plugins/vscode/src/library/projectWatcher.ts @@ -0,0 +1,34 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +/** Compare the two small project files, including states observed by a rendered detail. + * Native events may be unavailable and a file can appear/disappear between ticks. + */ +export function watchProjectFiles(root: string, changed: () => void, interval = 1000): { dispose(): void; check(): Promise; observe(manifest: string, lock: string): void } { + const files = ["mcpp.toml", "mcpp.lock"].map((name) => path.join(root, name)); + let previous: string | undefined; + let disposed = false; + let checking: Promise | undefined; + let again = false; + const fingerprint = (texts: readonly string[]) => createHash("sha256").update(JSON.stringify(texts)).digest("hex"); + const check = (): Promise => { + if (disposed) { return Promise.resolve(); } + if (checking) { again = true; return checking; } + checking = (async () => { + do { + again = false; + const texts = await Promise.all(files.map((file) => fs.readFile(file, "utf8").catch(() => ""))); + if (disposed) { return; } + const next = fingerprint(texts); + const different = previous !== undefined && next !== previous; + previous = next; + if (different) { changed(); } + } while (again && !disposed); + })().finally(() => { checking = undefined; }); + return checking; + }; + const timer = setInterval(() => { void check(); }, interval); + void check(); + return { check, observe: (manifest, lock) => { if (!disposed) { previous = fingerprint([manifest, lock]); } }, dispose: () => { disposed = true; clearInterval(timer); } }; +} diff --git a/mide-plugins/vscode/src/library/xpkg.ts b/mide-plugins/vscode/src/library/xpkg.ts new file mode 100644 index 0000000..f0355e6 --- /dev/null +++ b/mide-plugins/vscode/src/library/xpkg.ts @@ -0,0 +1,434 @@ +/** + * `mcpp xpkg parse --json`, decoded — as a pure module. + * + * The index's Lua descriptors are read cheaply and tolerantly by + * `src/library/indexModel.ts` (a few single-line catalog fields, no process). + * Everything **authoritative** comes from here instead: `mcpp`'s own resolver + * is the only thing that knows which platform a version belongs to, whether a + * descriptor is Form A, and what a target's `kind` is. That command is run on + * demand — when a row is expanded or the detail page opens — never once per + * package (233 spawns would freeze the view). + * + * The document is the flat shape `mcpp` prints, verified against + * `mcpp 2026.9.30.2` and all 239 `mcpplibs` descriptors: + * + * ```json + * {"namespace":"compat","name":"argparse", + * "versions":{"linux":["3.2"],"macosx":["3.2"],"windows":["3.2"]}, + * "standard":"c++23","import_std":false, + * "sources":["mcpp_generated/argparse_anchor.c"], + * "include_dirs":["<archive>/include"], + * "generated_files":[{"path":"…","bytes":52}], + * "generated_contents":{"mcpp_generated/argparse_anchor.c":"…"}, + * "targets":["argparse"],"unknown_keys":[]} + * ``` + * + * Two facts this module is built around, both measured rather than assumed: + * + * - a **Form A** descriptor answers with only `namespace`, `name`, `versions` + * and `form: "A"` — it deliberately carries no build information, because the + * upstream archive ships its own manifest. That is the index's `external` + * surface ("上游 mcpp.toml"), and it is the only honest answer offline. + * - `targets` is an **array of names** in every descriptor here (239/239). The + * site generator's `targets` *kinds* come from the upstream manifest, which + * `xpkg parse` does not expose. So a `kind` is honoured when the JSON happens + * to carry one and is not invented when it does not. + */ + +/** + * The consumer-facing usage label, exactly the index site's `SURFACES` table + * (`.xpkgindex/plugins/mcpp.py`): what a reader does with the package, not how + * it is built. An earlier design invented shape letters here; those were wrong. + */ +export type Surface = "module" | "header" | "tool" | "external"; + +/** `SURFACES` order, which is also the order the chips and badges are shown in. */ +export const SURFACES: readonly Surface[] = ["module", "header", "tool", "external"]; + +/** + * The label and the example line for each surface. + * + * `import` / `#include` / `tool` are code, not prose — the site generator emits + * them untranslated in every language, so they are constants here. Only + * `external` is a sentence, and it is looked up through `uiKey` so the caller + * can localize it. `usage` is the muted placeholder the site shows when a + * descriptor names no module or header of its own. + */ +export const SURFACE_TEXT: Readonly< + Record +> = { + module: { label: "import", usage: "import x.y;" }, + header: { label: "#include", usage: "#include " }, + tool: { label: "tool", usage: "$ tool" }, + external: { label: "upstream mcpp.toml", usage: "import x.y;", uiKey: "library.surface.external" }, +}; + +/** One `targets[]` entry. `mcpp` prints bare names; the site's kinds are optional. */ +export interface XpkgTarget { + name?: string; + kind?: string; +} + +/** The decoded `mcpp xpkg parse --json` document. Every list is present. */ +export interface XpkgInfo { + namespace: string; + name: string; + /** `form: "A"` means the descriptor names no build shape of its own. */ + form?: string; + versions: Record; + standard?: string; + importStd?: boolean; + sources: string[]; + includeDirs: string[]; + generatedFiles: string[]; + /** Only the paths; the contents themselves are of no use to a reader. */ + generatedContents: string[]; + targets: XpkgTarget[]; + unknownKeys: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** A `versions` object; anything else becomes an empty table. */ +function versionTable(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const out: Record = {}; + for (const [platform, list] of Object.entries(value)) { + const versions = stringList(list); + if (versions.length > 0) { + out[platform] = versions; + } + } + return out; +} + +function targetList(value: unknown): XpkgTarget[] { + if (!Array.isArray(value)) { + return []; + } + const out: XpkgTarget[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push({ name: entry }); + continue; + } + if (isRecord(entry)) { + const name = typeof entry.name === "string" ? entry.name : undefined; + const kind = typeof entry.kind === "string" ? entry.kind : undefined; + out.push({ ...(name === undefined ? {} : { name }), ...(kind === undefined ? {} : { kind }) }); + } + } + return out; +} + +/** + * Decode one already-parsed JSON value. Anything that is not the documented + * document — a different `kind`, a resolver error envelope, a hand-edited file — + * returns `undefined`, which the callers render as "cannot read this descriptor" + * rather than as a package with no versions. + */ +export function parseXpkgJsonValue(value: unknown): XpkgInfo | undefined { + if (!isRecord(value)) { + return undefined; + } + const name = value.name; + if (typeof name !== "string" || name.length === 0) { + return undefined; + } + const namespace = typeof value.namespace === "string" ? value.namespace : ""; + const generatedContents = isRecord(value.generated_contents) ? Object.keys(value.generated_contents) : []; + return { + namespace, + name, + ...(typeof value.form === "string" && value.form.length > 0 ? { form: value.form } : {}), + versions: versionTable(value.versions), + ...(typeof value.standard === "string" && value.standard.length > 0 ? { standard: value.standard } : {}), + ...(typeof value.import_std === "boolean" ? { importStd: value.import_std } : {}), + sources: stringList(value.sources), + includeDirs: stringList(value.include_dirs), + generatedFiles: generatedFilePaths(value.generated_files), + generatedContents, + targets: targetList(value.targets), + unknownKeys: stringList(value.unknown_keys), + }; +} + +/** `generated_files` is either a list of paths or a list of `{path, bytes}`. */ +function generatedFilePaths(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push(entry); + } else if (isRecord(entry) && typeof entry.path === "string" && entry.path.length > 0) { + out.push(entry.path); + } + } + return out; +} + +/** + * Decode the command's stdout. A non-JSON answer (an error message on stdout, a + * partial write) returns `undefined`; the caller keeps the descriptor's own + * fields and says the authoritative read failed. + */ +export function parseXpkgJson(text: string): XpkgInfo | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return undefined; + } + try { + return parseXpkgJsonValue(JSON.parse(trimmed)); + } catch { + return undefined; + } +} + +/** `.cppm` anywhere in a source or a generated file makes the package modular. */ +function isModular(info: XpkgInfo): boolean { + return ( + info.generatedContents.some((path) => path.endsWith(".cppm")) || + info.sources.some((source) => source.includes(".cppm")) || + info.generatedFiles.some((path) => path.endsWith(".cppm")) + ); +} + +/** A target the site files under the `tool` surface. */ +function hasBinaryTarget(info: XpkgInfo): boolean { + return info.targets.some((target) => /^(bin|binary)$/i.test(target.kind ?? "")); +} + +/** + * Every surface a descriptor's **parsed fields** support, in the order the site + * generator emits them (`_surfaces`): a module package may also offer its + * header, a package may ship both a library and a binary. + * + * A Form A descriptor answers `["external"]` and nothing else: the fields that + * would justify a module or header claim are not in the descriptor, and the + * index site only fills them in by fetching the upstream manifest — which this + * extension deliberately never does. + * + * An empty array is a real answer, not a failure, and it happens when a + * descriptor's fields name targets but neither a module nor a header. The site's + * generator degrades exactly that case to `external`; saying nothing is more + * honest than calling an inline Form B descriptor "upstream mcpp.toml". The row + * then shows no usage label, and the detail page still shows everything known. + * (The seven `compat.*-runtime` anchor packages are *not* that case: they carry a + * generated `.c` source, which is the header surface the generator gives a Form B + * package whose interfaces no module wraps.) + */ +export function surfacesOf(info: XpkgInfo): Surface[] { + if (info.form !== undefined) { + return ["external"]; + } + const out: Surface[] = []; + const modular = isModular(info); + if (modular) { + out.push("module"); + } + // `include_dirs` is not by itself a public header surface: on a module package + // it can exist only so the wrapper's own `#include` resolves. The site + // generator therefore keeps the header surface only for a package that is not + // modular (or that demonstrates an include line, which needs the example scan). + if (!modular && (info.includeDirs.length > 0 || info.sources.length > 0)) { + out.push("header"); + } + if (hasBinaryTarget(info)) { + out.push("tool"); + } + return out; +} + +/** The surface a row leads with, or `undefined` when nothing is claimed. */ +export function surfaceOf(info: XpkgInfo): Surface | undefined { + return surfacesOf(info)[0]; +} + +/** Does the descriptor's own text name a `kind = "bin"` target? */ +export function textNamesBinaryTarget(text: string): boolean { + return /\bkind\s*=\s*"(?:bin|binary)"/.test(text); +} + +/** + * The site's per-platform version groups, in a stable order: the current + * platform first, then the rest as `mcpp` printed them. Used by the detail + * page's version matrix, where the current platform must be the one a reader + * finds first. + */ +export function versionGroups( + versions: Record, + currentPlatform: string | undefined, +): Array<{ platform: string; versions: string[] }> { + const platforms = Object.keys(versions); + const ordered = [ + ...(currentPlatform !== undefined && platforms.includes(currentPlatform) ? [currentPlatform] : []), + ...platforms.filter((platform) => platform !== currentPlatform), + ]; + return ordered.map((platform) => ({ platform, versions: [...versions[platform]] })); +} + +/** + * The current platform as `mcpp` names it: `linux` / `macosx` / `windows`. + * `undefined` for a platform the index has no vocabulary for, so the caller + * shows the matrix rather than guessing which column is "mine". + */ +export function platformKey(nodePlatform: string): string | undefined { + switch (nodePlatform) { + case "linux": + return "linux"; + case "darwin": + return "macosx"; + case "win32": + return "windows"; + default: + return undefined; + } +} + +/** + * Version order, total and deterministic. + * + * mcpp's versions are dotted numbers (`3.2`, `1.6.43`, `0.2.4`), sometimes with + * a packaging revision (`22.1.8.1`), a prerelease tail (`1.0.0-rc1`) or a + * non-semver tag the upstream project publishes (`b10069.2`, the llama.cpp + * checkpoint revisions). The comparison follows the shape a reader expects: + * + * - the dotted core first, segment by segment, numerically when both segments + * are numbers (`1.10` is after `1.9`); + * - build metadata (`+…`) is ignored; + * - a prerelease (`-rc1`) sorts **below** its release, as semver says; + * - a numeric segment outranks a non-numeric one, which is what makes the + * `"latest"` alias some xlings descriptors publish lose to the concrete + * version beside it — the only answer `mcpp add` accepts. + */ +export function compareVersions(a: string, b: string): number { + const left = splitVersion(a); + const right = splitVersion(b); + const core = compareSegments(left.core, right.core); + if (core !== 0) { + return core; + } + if (left.pre === undefined && right.pre === undefined) { + return 0; + } + if (left.pre === undefined) { + return 1; + } + if (right.pre === undefined) { + return -1; + } + return compareSegments(left.pre, right.pre); +} + +function splitVersion(version: string): { core: string[]; pre?: string[] } { + const withoutBuild = version.split("+")[0]; + const dash = withoutBuild.indexOf("-"); + if (dash === -1) { + return { core: withoutBuild.split(".") }; + } + return { core: withoutBuild.slice(0, dash).split("."), pre: withoutBuild.slice(dash + 1).split(".") }; +} + +function compareSegments(left: readonly string[], right: readonly string[]): number { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const one = left[index]; + const two = right[index]; + if (one === undefined) { + return two === undefined ? 0 : -1; + } + if (two === undefined) { + return 1; + } + const oneNumber = /^\d+$/.test(one); + const twoNumber = /^\d+$/.test(two); + if (oneNumber && twoNumber) { + const difference = Number(one) - Number(two); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (oneNumber !== twoNumber) { + return oneNumber ? 1 : -1; + } + const natural = compareNatural(one, two); + if (natural !== 0) { + return natural; + } + } + return 0; +} + +/** `rc2` before `rc10`: within an alphanumeric identifier, digit runs are numbers. */ +function compareNatural(left: string, right: string): number { + const one = left.match(/\d+|\D+/g) ?? []; + const two = right.match(/\d+|\D+/g) ?? []; + const length = Math.max(one.length, two.length); + for (let index = 0; index < length; index += 1) { + const a = one[index]; + const b = two[index]; + if (a === undefined) { + return b === undefined ? 0 : -1; + } + if (b === undefined) { + return 1; + } + const aNumber = /^\d+$/.test(a); + const bNumber = /^\d+$/.test(b); + if (aNumber && bNumber) { + const difference = Number(a) - Number(b); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (a !== b) { + return a < b ? -1 : 1; + } + } + return 0; +} + +/** The list's versions, greatest first. */ +export function sortVersions(versions: readonly string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} + +/** + * The version `mcpp add` must be given: the greatest one this platform has. + * + * `undefined` means the index knows no version for the current platform, and the + * UI says so instead of running a command mcpp would reject. + */ +export function latestVersion(versions: Record, platform: string | undefined): string | undefined { + if (platform === undefined) { + return undefined; + } + const list = versions[platform]; + if (list === undefined || list.length === 0) { + return undefined; + } + return sortVersions(list)[0]; +} + +/** Every version this platform has, greatest first; `[]` when it has none. */ +export function versionsFor(versions: Record, platform: string | undefined): string[] { + if (platform === undefined) { + return []; + } + return sortVersions(versions[platform] ?? []); +} diff --git a/mide-plugins/vscode/src/mcppls/bridge.ts b/mide-plugins/vscode/src/mcppls/bridge.ts new file mode 100644 index 0000000..9811692 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/bridge.ts @@ -0,0 +1,109 @@ +/** + * The one place this extension talks to the C++ Modules extension. + * + * It forwards mcppls's actions and nothing else: no LSP client, no reading of + * `mcppls.*` settings, no writing of them, no parsing of its logs. mcppls says + * which actions exist and which command each one is (the backend's + * `languageService/status`); the capability table (`./contract.ts`) says how + * much confirmation each needs; `./capabilities.ts` puts the two together. + * + * Results are **structured, not phrased**: a caller that has a user in front of + * it turns them into words through `./messages.ts`. That keeps this module free + * of `vscode` and therefore unit-testable, and it keeps the strings translatable + * without threading a translator through the probe logic. + */ + +import { CapabilityRegistry, type CapabilityEnvironment, type InvokeResult } from "./capabilities"; +import { capability } from "./contract"; + +export { MCPPLS_EXTENSION_ID } from "./contract"; + +export type LanguageServerCommandState = InvokeResult["state"]; +export type LanguageServerCommandResult = InvokeResult; +export { CapabilityRegistry } from "./capabilities"; +export type { CapabilityEnvironment } from "./capabilities"; + +export interface LanguageServerBridge { + /** The probe, exposed so the environment self-check can print the whole table. */ + readonly capabilities: CapabilityRegistry; + /** Generic entry point for the view and the menu. */ + invoke(key: string, ...args: unknown[]): Promise; + /** True when the user should not be offered this capability. */ + isGone(key: string): boolean; + /** True when mcppls has not answered yet. */ + isUnconfirmed(key: string): boolean; + /** How much confirmation the action needs. */ + dangerOf(key: string): "none" | "confirm" | "destructive"; + + refreshLanguageServerAfterBuild(): Promise; + restartLanguageServer(): Promise; + restartEngine(): Promise; + resetWorkspaceCache(): Promise; + selectContext(): Promise; + showModuleGraph(): Promise; + showLanguageServerLogs(): Promise; + collectReport(): Promise; + exportDiagnosticBundle(): Promise; + runBuildToolInTerminal(): Promise; + manageConflicts(restore?: boolean): Promise; + toggleInWorkspace(enable: boolean): Promise; + installCommandLineTools(): Promise; + installKit(): Promise; + reviewChanges(clear?: boolean): Promise; +} + +/** Methods that map one-to-one onto a capability, so the bridge stays a table. */ +const FORWARDED = { + restartLanguageServer: "restart", + restartEngine: "restartEngine", + resetWorkspaceCache: "resetCache", + selectContext: "selectContext", + showModuleGraph: "moduleGraph", + showLanguageServerLogs: "showLog", + collectReport: "collectReport", + exportDiagnosticBundle: "exportDiagnostics", + runBuildToolInTerminal: "runBuildTool", + installCommandLineTools: "installTools", + installKit: "installKit", +} as const; + +export function createLanguageServerBridge(environment: CapabilityEnvironment): LanguageServerBridge { + const capabilities = new CapabilityRegistry(environment); + let refreshInFlight: Promise | undefined; + + const invoke = (key: string, ...args: unknown[]): Promise => + capabilities.invoke(key, ...args); + + /** + * Builds are frequent; a second refresh while one is in flight would restart + * the language server twice for one edit. The single-flight promise is shared, + * so both callers see the same outcome. + */ + function refreshLanguageServerAfterBuild(): Promise { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = invoke("reload").finally(() => { + refreshInFlight = undefined; + }); + return refreshInFlight; + } + + const forwarded = Object.fromEntries( + Object.entries(FORWARDED).map(([method, key]) => [method, () => invoke(key)]), + ) as Record Promise>; + + return { + capabilities, + invoke, + isGone: (key) => capabilities.isGone(key), + isUnconfirmed: (key) => capabilities.isUnconfirmed(key), + dangerOf: (key) => capability(key)?.danger ?? "none", + refreshLanguageServerAfterBuild, + ...forwarded, + // The two commands whose *argument* selects the direction. + manageConflicts: (restore = false) => invoke(restore ? "restoreOtherCppFeatures" : "turnOffOtherCppFeatures"), + toggleInWorkspace: (enable) => invoke(enable ? "turnOnInWorkspace" : "turnOffInWorkspace"), + reviewChanges: (clear = false) => invoke(clear ? "reviewClear" : "reviewRun"), + }; +} diff --git a/mide-plugins/vscode/src/mcppls/capabilities.ts b/mide-plugins/vscode/src/mcppls/capabilities.ts new file mode 100644 index 0000000..6fd1c44 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/capabilities.ts @@ -0,0 +1,161 @@ +/** + * Which of mcppls's actions VS Code offers, and running them. + * + * Pure: the only VS Code-shaped things it needs arrive through `CapabilityEnvironment`, which is + * exactly what the extension host supplies and what the tests fake. + * + * WHAT EXISTS IS mcppls's ANSWER. The backend asks mcppls (`mcppls status`, or the frozen table of + * 0.0.18 and 0.0.19) and hands over its actions, each under the VS Code command mcppls names and the + * arguments it is called with (design 2026-10-30 part 2 §5.3). An action mcppls names no VS Code + * command for is not offered; before the backend has answered, an action is *greyed out*, not + * hidden, so a slow answer cannot remove a feature. + * + * The first real use calls the command and classifies a failure: `command '…' not found` means the + * action is gone for this session (an extension that has not registered it); anything else is a + * failure of that one call and does not remove the action. + */ + +import { CAPABILITIES, MCPPLS_EXTENSION_ID, capability } from "./contract"; +import type { ServerAction } from "../ide/protocol"; + +const POLICY_KEYS = CAPABILITIES.map((entry) => entry.key); + +export interface CapabilityEnvironment { + /** `vscode.extensions.getExtension(id) !== undefined`. */ + extensionInstalled(id: string): boolean; + /** mcppls's actions as the backend last reported them; `undefined` before it has answered. */ + serverActions(): readonly ServerAction[] | undefined; + /** Activate the dependency before the first forward, when VS Code has not. */ + activateExtension?(id: string): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; +} + +export type CapabilityState = "available" | "declared" | "undeclared" | "missing" | "unavailable"; + +export interface CapabilityStatus { + key: string; + state: CapabilityState; + /** The VS Code command mcppls names for the action, when it names one. */ + command?: string; + /** What mcppls says the command is called with. */ + arguments?: readonly unknown[]; +} + +export type InvokeState = "completed" | "unavailable" | "missing" | "failed"; + +export interface InvokeResult { + state: InvokeState; + capabilityKey: string; + /** The command that ran, when one ran. */ + command?: string; + /** Error text from the failed call, verbatim. */ + error?: string; + /** + * Whatever the command answered with, when it answered with anything. + * + * A few mcppls commands return a path — `mcppls.exportDiagnosticBundle` + * resolves to the zip it wrote (`exportDiagnosticBundle(): Promise` upstream) — and that is the only reliable way to point at the + * file afterwards. Most commands return nothing, and the caller must treat this + * as unknown: it is passed on untouched, never parsed. + */ + value?: unknown; +} + +/** `command 'x' not found` in the several spellings VS Code has used. */ +const NOT_FOUND = /command\s+(?:['"`][^'"`]+['"`]\s+)?not found|not registered|no such command/i; + +export function classifyCommandError(error: unknown): "missing" | "failed" { + const message = error instanceof Error ? error.message : String(error); + return NOT_FOUND.test(message) ? "missing" : "failed"; +} + +export class CapabilityRegistry { + private readonly statuses = new Map(); + + public constructor(private readonly environment: CapabilityEnvironment) { + this.probe(); + } + + /** Re-read mcppls's actions; called when the backend answered again or an extension changed. */ + public invalidate(): void { + this.statuses.clear(); + this.probe(); + } + + public status(key: string): CapabilityStatus { + return this.statuses.get(key) ?? { key, state: "unavailable" }; + } + + /** Capabilities whose command is believed usable right now. */ + public availableKeys(): string[] { + return [...this.statuses.values()] + .filter((status) => status.state === "available" || status.state === "declared") + .map((status) => status.key); + } + + /** True when the user should not be offered the capability. */ + public isGone(key: string): boolean { + const state = this.status(key).state; + return state === "unavailable" || state === "missing"; + } + + /** True when mcppls has not answered yet: grey out, do not hide. */ + public isUnconfirmed(key: string): boolean { + return this.status(key).state === "undeclared"; + } + + private probe(): void { + const installed = this.environment.extensionInstalled(MCPPLS_EXTENSION_ID); + const actions = installed ? this.environment.serverActions() : undefined; + for (const action of actions ?? []) { + if (capability(action.id) === undefined) continue; // an action mcpp-ide has no policy for is not offered + this.statuses.set( + action.id, + action.vscode === undefined + ? { key: action.id, state: "unavailable" } + : { key: action.id, state: "declared", command: action.vscode, ...(action.vscodeArguments ? { arguments: action.vscodeArguments } : {}) }, + ); + } + if (actions !== undefined) return; + // Not answered yet (or not installed): every action is unconfirmed, or unavailable without mcppls. + for (const key of POLICY_KEYS) this.statuses.set(key, { key, state: installed ? "undeclared" : "unavailable" }); + } + + /** + * Run a capability, activating the dependency first. Returns the command that + * ran so the caller can name it in a log line. + */ + public async invoke(key: string, ...args: unknown[]): Promise { + if (capability(key) === undefined) { + return { state: "failed", capabilityKey: key, error: `unknown capability ${key}` }; + } + if (!this.environment.extensionInstalled(MCPPLS_EXTENSION_ID)) { + return { state: "unavailable", capabilityKey: key }; + } + const known = this.status(key); + if (known.state === "missing" || known.state === "unavailable" || known.command === undefined) { + // Not offered by this mcppls, remembered from an earlier call, or not answered yet: the + // installed C++ Modules does not offer it now, and VS Code is not asked. + return { state: "missing", capabilityKey: key }; + } + const command = known.command; + const callArgs = args.length > 0 ? args : [...(known.arguments ?? [])]; + try { + await this.environment.activateExtension?.(MCPPLS_EXTENSION_ID); + const value = await this.environment.executeCommand(command, ...callArgs); + this.statuses.set(key, { ...known, state: "available" }); + return { state: "completed", capabilityKey: key, command, ...(value === undefined ? {} : { value }) }; + } catch (error) { + if (classifyCommandError(error) === "missing") { + this.statuses.set(key, { key, state: "missing" }); + return { state: "missing", capabilityKey: key, command }; + } + return { state: "failed", capabilityKey: key, command, error: messageOf(error) }; + } + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/mide-plugins/vscode/src/mcppls/contract.ts b/mide-plugins/vscode/src/mcppls/contract.ts new file mode 100644 index 0000000..fd3ac8b --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/contract.ts @@ -0,0 +1,202 @@ +/** + * The C++ Modules language service we depend on, as **data**. + * + * Everything this extension knows about `sunrisepeak.mcpp-language-server` + * (mcppls) lives here: its extension id, the actions we offer, and how badly + * each one needs confirming. No `vscode` import, so the table is unit-testable + * and `tools/` can read it. + * + * Two rules this file exists to keep: + * + * 1. **Our own commands never use the `mcppls.` prefix.** mcppls's S3 spec + * (S3-5.6-3) forbids a client from registering a command id the server + * advertises: `vscode-languageclient` registers those itself, and a collision + * stops the language client from starting. See `.agents/docs/mcppls-integration.md`. + * 2. **Nothing here writes an `mcppls.*` setting.** The only settings we ever + * touch are our own `mcpp.*`. The two commands that change mcppls state + * (`turnOn/turnOffInWorkspace`) are mcppls's own, and the user triggers them. + */ + +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; + +/** How much ceremony an action needs before it runs. */ +export type Danger = "none" | "confirm" | "destructive"; + +/** + * mcpp-ide's policy for one of mcppls's actions. The key is mcppls's own action id (`mcppls + * status`'s `actions[].id`, mcppls S3-8-7); the command it runs in VS Code, and what it is called + * with, are mcppls's answer, never a copy kept here (design 2026-10-30 part 2 §5.3). What stays here + * is what mcpp-ide decides: the title it shows, how much confirmation the action needs, and what to + * say when it is not there. + */ +export interface Capability { + key: string; + /** English; the UI resolves it through `src/i18n/t.ts`. */ + title: string; + danger: Danger; + /** Shown when the action is not offered; says what still works. */ + degradedHint: string; + /** Extra sentence for a destructive action, spelling out what it does *not* touch. */ + confirmHint?: string; +} + +export const CAPABILITIES: readonly Capability[] = [ + { + key: "reload", + title: "Refresh the C++ Modules build description", + danger: "none", + degradedHint: + "The build finished, but the language service could not be refreshed. Run \"C++ Modules: Restart Language Server\" from the Command Palette.", + }, + { + key: "selectContext", + title: "Select the C++ Modules analysis context", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a context picker; analysis uses the default context.", + }, + { + key: "moduleGraph", + title: "Show the module graph", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a module graph.", + }, + { + key: "showLog", + title: "Show the C++ Modules log", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a log command; look for its output channel in the Output view.", + }, + { + key: "restart", + title: "Restart the C++ Modules language server", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a restart command.", + }, + { + key: "restartEngine", + title: "Restart the C++ semantic engine", + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer an engine restart.", + confirmHint: "The semantic engine restarts; module preparation starts over and may take a while.", + }, + { + key: "resetCache", + title: "Reset this workspace's C++ Modules cache", + danger: "destructive", + degradedHint: "Installed C++ Modules does not offer a cache reset.", + confirmHint: + "This discards the language server's model cache for this workspace and prepares it again, which can take minutes. mcpp's own build cache and the project's target/ directory are not touched.", + }, + { + key: "openLogDirectory", + title: "Open the C++ Modules log folder", + danger: "none", + degradedHint: + "Installed C++ Modules does not offer a reveal command; its log lives in the mcppls cache directory.", + }, + { + key: "collectReport", + title: "Collect a C++ Modules diagnostic report", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a report command.", + }, + { + key: "exportDiagnostics", + title: "Export a C++ Modules diagnostic bundle", + danger: "none", + degradedHint: "Installed C++ Modules does not offer a diagnostic bundle.", + }, + { + key: "runBuildTool", + title: "Run the build tool in a terminal", + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer this action.", + confirmHint: "The build tool runs in an integrated terminal, with its normal side effects.", + }, + // `danger: "none"` because mcppls's own command asks where to act (this + // workspace or user settings) and does nothing when that question is closed; + // the settings it writes are the other extensions' own keys, never `mcppls.*`. + { + key: "turnOffOtherCppFeatures", + title: "Turn off other C++ language services", + danger: "none", + degradedHint: "Installed C++ Modules does not offer this; turn the other extension's language features off in its own settings.", + }, + { + key: "restoreOtherCppFeatures", + title: "Restore other C++ language services", + danger: "none", + degradedHint: "Installed C++ Modules does not offer this; turn the other extension's language features on in its own settings.", + }, + { + key: "turnOffInWorkspace", + title: "Enable or disable C++ Modules in this workspace", + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a per-workspace switch.", + confirmHint: "This writes mcppls.enable, a setting owned by the C++ Modules extension.", + }, + { + key: "turnOnInWorkspace", + title: "Enable or disable C++ Modules in this workspace", + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a per-workspace switch.", + confirmHint: "This writes mcppls.enable, a setting owned by the C++ Modules extension.", + }, + { + key: "installTools", + title: "Install the C++ Modules command line tools", + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a command line tools installer.", + confirmHint: "This may install system packages the language server needs.", + }, + { + key: "installKit", + title: "Install the semantic kit", + danger: "confirm", + degradedHint: "This mcppls does not install kits; files are read with the openkal kit it carries.", + confirmHint: "The C++ Modules server installs the target's semantic kit with xlings, in the background.", + }, + { + key: "reviewRun", + title: "Review workspace changes", + danger: "none", + degradedHint: "Installed C++ Modules does not offer its review commands here.", + }, + { + key: "reviewClear", + title: "Review workspace changes", + danger: "none", + degradedHint: "Installed C++ Modules does not offer its review commands here.", + }, +]; + +const BY_KEY = new Map(CAPABILITIES.map((capability) => [capability.key, capability])); + +export function capability(key: string): Capability | undefined { + return BY_KEY.get(key); +} + +/** + * The capability table's own invariants (must be empty). + * + * The "our ids never use the mcppls prefix" rule is checked where our ids live + * (`src/commands/ids.ts`); this covers the table itself: a key is mcppls's action id, never a + * command id of either extension. + */ +export function capabilityProblems(): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const entry of CAPABILITIES) { + if (seen.has(entry.key)) { + problems.push(`duplicate capability ${entry.key}`); + } + seen.add(entry.key); + if (entry.key.includes(".")) { + problems.push(`${entry.key} is a command id, not mcppls's action id`); + } + if (entry.danger !== "none" && entry.confirmHint === undefined) { + problems.push(`${entry.key} needs confirmation but has no confirmHint`); + } + } + return problems; +} diff --git a/mide-plugins/vscode/src/mcppls/messages.ts b/mide-plugins/vscode/src/mcppls/messages.ts new file mode 100644 index 0000000..7d0282e --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/messages.ts @@ -0,0 +1,61 @@ +/** + * Turning a structured bridge result into something a person reads. + * + * Split out of `./bridge.ts` so the bridge stays free of `vscode` (and testable) + * while these strings still go through `src/i18n/t.ts`. Every English string here + * is a key: `tools/l10n-check.mjs` fails the build if one has no Chinese entry. + */ + +import { t } from "../i18n/t"; +import type { LanguageServerCommandResult } from "./bridge"; +import { MCPPLS_EXTENSION_ID, capability } from "./contract"; + +export type ResultSeverity = "info" | "warning" | "error"; + +export interface FormattedResult { + severity: ResultSeverity; + message: string; + /** The capability's own advice, when it is gone. */ + hint?: string; +} + +function titleOf(key: string): string { + return capability(key)?.title ?? key; +} + +export function formatResult(result: LanguageServerCommandResult): FormattedResult { + const title = titleOf(result.capabilityKey); + switch (result.state) { + case "completed": + return { severity: "info", message: t("{0}: done.", title) }; + case "unavailable": + return { + severity: "warning", + message: t("The C++ Modules extension ({0}) is not installed or is disabled.", MCPPLS_EXTENSION_ID), + }; + case "missing": + return { + severity: "warning", + message: t("{0}: the installed C++ Modules does not offer this action.", title), + hint: capability(result.capabilityKey)?.degradedHint, + }; + default: + return { + severity: "error", + message: t("{0} failed: {1}", title, result.error ?? t("unknown error")), + }; + } +} + +/** Confirmation text for an action that needs one, or `undefined` when it does not. */ +export function confirmationFor(key: string, value?: unknown): string | undefined { + const entry = capability(key); + if (entry === undefined || entry.danger === "none") { + return undefined; + } + const parts = [entry.confirmHint ?? entry.title]; + if (typeof value === "boolean") { + parts.push(value ? t("Direction: enable.") : t("Direction: disable.")); + } + return parts.join(" "); +} diff --git a/mide-plugins/vscode/src/mcppls/otherCppFeatures.ts b/mide-plugins/vscode/src/mcppls/otherCppFeatures.ts new file mode 100644 index 0000000..f3434a8 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/otherCppFeatures.ts @@ -0,0 +1,65 @@ +/** + * Which other C++ language services are serving next to C++ Modules, normalised. + * + * Pure, like `./state.ts`: no `vscode`, nothing here throws. The read is local and read-only: + * mcppls's three candidates, from the installed extensions and their settings. The mcppls + * extension's API object is not read (design 2026-10-30 part 2 §5.2); whether there is something to + * restore is mcppls's own command's answer, so `canRestore` says only whether mcppls offers the + * command, and the caller sets it. + * + * This extension only *reads*. Turning a service off or restoring it + * is mcppls's command (`mcppls.turnOffOtherCppFeatures`, which asks where), and + * the settings it writes are the other extensions' own, never `mcppls.*`. + */ + +export interface OtherCppService { + extensionId: string; + displayName: string; + /** `true` when a setting turns its language features off; `false` (ccls) means disabling the extension. */ + settable: boolean; +} + +export interface OtherCppFeatures { + active: OtherCppService[]; + /** mcppls offers to put back the settings it changed. */ + canRestore: boolean; +} + +/** A candidate the local read knows; `section`/`key`/`off` is how its language features are turned off. */ +interface Candidate { + extensionId: string; + displayName: string; + setting?: { section: string; key: string; off: unknown }; +} + +/** The same three mcppls checks (its `conflictCandidates.ts`), for when it cannot be asked. */ +export const OTHER_CPP_CANDIDATES: readonly Candidate[] = [ + { extensionId: "ms-vscode.cpptools", displayName: "C/C++", setting: { section: "C_Cpp", key: "intelliSenseEngine", off: "disabled" } }, + { extensionId: "llvm-vs-code-extensions.vscode-clangd", displayName: "clangd", setting: { section: "clangd", key: "enable", off: false } }, + { extensionId: "ccls-project.ccls", displayName: "ccls" }, +]; + +/** The settings whose change can make a candidate active or inactive. */ +export const OTHER_CPP_SETTINGS: readonly string[] = OTHER_CPP_CANDIDATES.flatMap((candidate) => + candidate.setting === undefined ? [] : [`${candidate.setting.section}.${candidate.setting.key}`], +); + +/** + * The local read. `installed` answers for installed *and enabled* extensions + * (`vscode.extensions.getExtension`); `value` is the effective setting. + */ +export function localOtherCppFeatures( + installed: (extensionId: string) => boolean, + value: (section: string, key: string) => unknown, +): OtherCppFeatures { + const active = OTHER_CPP_CANDIDATES.filter( + (candidate) => + installed(candidate.extensionId) && + (candidate.setting === undefined || value(candidate.setting.section, candidate.setting.key) !== candidate.setting.off), + ).map((candidate) => ({ + extensionId: candidate.extensionId, + displayName: candidate.displayName, + settable: candidate.setting !== undefined, + })); + return { active, canRestore: false }; +} diff --git a/mide-plugins/vscode/src/mcppls/state.ts b/mide-plugins/vscode/src/mcppls/state.ts new file mode 100644 index 0000000..c3548e9 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/state.ts @@ -0,0 +1,121 @@ +/** + * What the C++ Modules language service reports about the workspace, normalised. + * + * Pure: it takes the backend's `languageService/status` answer, whose `workspace` is what + * `mcppls status` printed (mcppls S3 section 8, mcppls 0.0.20 and later), and answers with a shape + * the project view renders, or with `available: false` and why. The three IDEs show the same + * document (design 2026-10-30 part 2 §5.2); nothing here reads the mcppls extension's API object. + * Nothing here throws. + */ + +import type { LanguageServiceStatus } from "../ide/protocol"; + +export interface McpplsIssue { + code: string; + message: string; +} + +export const MCPPLS_STATES = ["ready", "starting", "degraded", "stopped", "none"] as const; +export type McpplsState = (typeof MCPPLS_STATES)[number]; + +export interface McpplsStateView { + available: boolean; + /** Why it is unavailable, for the log and the tooltip; never shown as an error. */ + reason?: string; + version?: string; + enabled?: boolean; + /** The best state among the servers serving the workspace; `none` when none ever did. */ + state?: McpplsState; + engine?: { name: string; version: string; restarts?: number }; + kit?: { name: string; state: string; install?: string; inUse?: string }; + mcpp?: { version: string; program?: string }; + /** How many servers (editors) serve the workspace now. */ + instances?: number; + issues?: McpplsIssue[]; +} + +export interface McpplsMeta { + version?: string; + enabled?: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function knownState(value: unknown): McpplsState | undefined { + return typeof value === "string" && (MCPPLS_STATES as readonly string[]).includes(value) ? (value as McpplsState) : undefined; +} + +/** The view of the backend's answer, `undefined` before the backend has answered. */ +export function readStateFromStatus(status: LanguageServiceStatus | undefined, meta: McpplsMeta = {}): McpplsStateView { + const base: McpplsStateView = { available: false, version: status?.version || meta.version, enabled: meta.enabled }; + if (status === undefined) { + return { ...base, reason: "the mcpp-ide backend has not answered yet" }; + } + if (status.state !== "ok") { + return { ...base, reason: status.message }; + } + if (status.statusSource !== "status" || !isRecord(status.workspace)) { + return { + ...base, + reason: + status.statusError !== undefined + ? `mcppls status: ${status.statusError}` + : `mcppls ${status.version} has no status command; 0.0.20 and later state the workspace's state`, + }; + } + const workspace = status.workspace as Record; + const state = knownState(workspace.state); + if (state === undefined) { + return { ...base, reason: `unknown state ${JSON.stringify(workspace.state)}` }; + } + const view: McpplsStateView = { ...base, available: true, state, instances: Array.isArray(status.instances) ? status.instances.length : 0 }; + if (isRecord(workspace.engine) && text(workspace.engine.name) !== undefined) { + const restarts = workspace.engine.restarts; + view.engine = { + name: text(workspace.engine.name) as string, + version: text(workspace.engine.version) ?? "", + ...(typeof restarts === "number" && Number.isFinite(restarts) ? { restarts } : {}), + }; + } + if (isRecord(workspace.kit) && text(workspace.kit.state) !== undefined) { + view.kit = { + name: text(workspace.kit.name) ?? "", + state: text(workspace.kit.state) as string, + ...(text(workspace.kit.install) !== undefined ? { install: text(workspace.kit.install) } : {}), + ...(text(workspace.kit.inUse) !== undefined ? { inUse: text(workspace.kit.inUse) } : {}), + }; + } + if (isRecord(workspace.mcpp) && text(workspace.mcpp.version) !== undefined) { + view.mcpp = { version: text(workspace.mcpp.version) as string, program: text(workspace.mcpp.program) }; + } + if (Array.isArray(workspace.problems)) { + const issues = workspace.problems + .map((entry): McpplsIssue | undefined => + isRecord(entry) && text(entry.code) !== undefined && text(entry.message) !== undefined + ? { code: text(entry.code) as string, message: text(entry.message) as string } + : undefined, + ) + .filter((entry): entry is McpplsIssue => entry !== undefined); + if (issues.length > 0) view.issues = issues; + } + return view; +} + +/** One line for the environment self-check; never throws. */ +export function describeState(view: McpplsStateView): string { + if (!view.available) { + return `unavailable (${view.reason ?? "unknown reason"})`; + } + const parts: string[] = [view.state ?? "unknown"]; + if (view.engine !== undefined) parts.push(`engine ${view.engine.name} ${view.engine.version}`.trimEnd()); + if (view.kit !== undefined) parts.push(`kit ${view.kit.name} (${view.kit.state})`); + if (view.instances !== undefined && view.instances > 1) parts.push(`${view.instances} servers`); + if (view.issues !== undefined) parts.push(`${view.issues.length} issue(s)`); + return parts.join(" · "); +} diff --git a/mide-plugins/vscode/src/mcppls/stateSource.ts b/mide-plugins/vscode/src/mcppls/stateSource.ts new file mode 100644 index 0000000..be13d40 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/stateSource.ts @@ -0,0 +1,76 @@ +/** + * The `vscode`-facing half of the C++ Modules state read. + * + * `state.ts` normalises what it is given; this file adds what VS Code knows (whether the extension + * is installed and enabled). The state itself is `mcppls status` as the backend reported it + * (`Session.languageService`, design 2026-10-30 part 2 §5.2); the mcppls extension's API object is + * not read. + * + * `mcpp.languageService.readState` can switch the state off: the view then shows the degraded + * content it always has for an installed-but-unreadable mcppls. + */ + +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { MCPPLS_EXTENSION_ID } from "./contract"; +import type { LanguageServiceStatus } from "../ide/protocol"; +import { localOtherCppFeatures, type OtherCppFeatures } from "./otherCppFeatures"; +import { readStateFromStatus, type McpplsStateView } from "./state"; + +/** Why a state read did not happen. The view prints it verbatim. */ +export function stateUnavailableReason(reason: "disabled" | "missing"): string { + return reason === "disabled" + ? "reading the C++ Modules state is turned off (mcpp.languageService.readState)" + : `mcppls ${MCPPLS_EXTENSION_ID} is not installed`; +} + +function dependency(): vscode.Extension | undefined { + return vscode.extensions.getExtension(MCPPLS_EXTENSION_ID); +} + +export function languageServerInstalled(): boolean { + return dependency() !== undefined; +} + +export function languageServerVersion(): string | undefined { + const version = (dependency()?.packageJSON as { version?: string } | undefined)?.version; + return typeof version === "string" ? version : undefined; +} + +/** `mcppls.enable`, read-only: the setting belongs to the other extension. */ +export function languageServerEnabled(): boolean { + return vscode.workspace.getConfiguration("mcppls").get("enable", true) !== false; +} + +/** + * Whether C++ Modules serves this workspace's C++ files, `build.mcpp` included: + * installed and enabled in VS Code, and not switched off here (`mcppls.enable`). + * Read-only, like everything else in this file. + */ +export function languageServerServes(): boolean { + return languageServerInstalled() && languageServerEnabled(); +} + +export function readLanguageServerState(status: LanguageServiceStatus | undefined): McpplsStateView { + const meta = { version: languageServerVersion(), enabled: languageServerEnabled() }; + // The gate: `read()` falls back to the declared default (true) when the registry has no value. + if (read("mcpp.languageService.readState") === false) { + return { available: false, reason: stateUnavailableReason("disabled"), ...meta }; + } + if (dependency() === undefined) { + return { available: false, reason: stateUnavailableReason("missing"), ...meta }; + } + return readStateFromStatus(status, meta); +} + +/** + * The other C++ language services serving next to C++ Modules: a read-only look at the candidates' + * installation and their own settings. + */ +export function readOtherCppFeatures(): OtherCppFeatures { + return localOtherCppFeatures( + (extensionId) => vscode.extensions.getExtension(extensionId) !== undefined, + (section, key) => vscode.workspace.getConfiguration(section).get(key), + ); +} diff --git a/mide-plugins/vscode/src/mcppls/timers.ts b/mide-plugins/vscode/src/mcppls/timers.ts new file mode 100644 index 0000000..f915951 --- /dev/null +++ b/mide-plugins/vscode/src/mcppls/timers.ts @@ -0,0 +1,80 @@ +/** + * The one interval timer two views share. + * + * Both `mcpp.cache.autoRefreshSeconds` and + * `mcpp.languageService.stateRefreshSeconds` mean the same thing: re-read while + * the view is visible, stop when it is not, and never let a second read overlap + * one that is still running. Keeping the mechanism here — free of `vscode`, so + * `node:test` can drive it with a fake clock — means the two call sites own only + * the decision of *what* to refresh. + * + * The caller disposes the timer with the extension; `dispose()` is idempotent. + */ + +export interface PollTimerOptions { + periodMs: number; + /** The refresh itself. Runs between ticks; the timer never awaits it. */ + tick: () => void; + /** Injected for tests; defaults to `setInterval`/`clearInterval`. */ + setIntervalFn?: (handler: () => void, periodMs: number) => unknown; + clearIntervalFn?: (handle: unknown) => void; +} + +export class PollTimer { + private readonly setIntervalFn: (handler: () => void, periodMs: number) => unknown; + private readonly clearIntervalFn: (handle: unknown) => void; + private handle: unknown; + private periodMs: number; + + public constructor(private readonly options: PollTimerOptions) { + this.setIntervalFn = + options.setIntervalFn ?? ((handler, periodMs) => setInterval(handler, periodMs)); + this.clearIntervalFn = options.clearIntervalFn ?? ((handle) => clearInterval(handle as NodeJS.Timeout)); + this.periodMs = options.periodMs; + } + + /** True while a period is armed, so the caller can assert it did not leak. */ + public get active(): boolean { + return this.handle !== undefined; + } + + /** The period currently armed, in milliseconds. */ + public get period(): number { + return this.periodMs; + } + + /** + * Arm, re-arm or stop. A non-positive or unreadable period stops the timer, so + * "0 = off" holds no matter what the settings file contains. + */ + public start(periodMs: number): void { + this.periodMs = periodMs; + this.stop(); + if (!Number.isFinite(periodMs) || periodMs <= 0) { + return; + } + this.handle = this.setIntervalFn(() => { + try { + this.options.tick(); + } catch { + // A refresh that throws must not kill the timer or the host. + } + }, periodMs); + } + + public stop(): void { + if (this.handle === undefined) { + return; + } + try { + this.clearIntervalFn(this.handle); + } catch { + // Already cleared, or the host is shutting down. + } + this.handle = undefined; + } + + public dispose(): void { + this.stop(); + } +} diff --git a/mide-plugins/vscode/src/pages.ts b/mide-plugins/vscode/src/pages.ts new file mode 100644 index 0000000..48c91b0 --- /dev/null +++ b/mide-plugins/vscode/src/pages.ts @@ -0,0 +1,185 @@ +/** + * The pages: the views of the activity bar (the project tree, the libraries, the cache), the panels + * they open (a package's page, the settings, the graphs) and their commands, wired together. The + * extension registers them at activation; mcpp-clion's workbench (webui/) registers the same + * modules in the same way, so both IDEs show mcpp-vscode's pages. + */ +import * as vscode from "vscode"; + +import { registerCacheView } from "./cache/cacheView"; +import { runMcpp } from "./cli/process"; +import { LIBRARY_COMMANDS } from "./commands/ids"; +import { onDidChange as onConfigurationChanged, read } from "./config/access"; +import { registerSettingsPanel } from "./config/panel"; +import { t } from "./i18n/t"; +import { createLibraryDetailOpener } from "./library/detailPanel"; +import { invalidateIndexCache, loadSnapshot } from "./library/indexLocator"; +import { registerLibraryView } from "./library/libraryView"; +import { watchProjectFiles } from "./library/projectWatcher"; +import { MCPP_MANIFEST_GLOB } from "./projects/context"; +import { findCurrentProject } from "./projects/current"; +import type { McppProjectDiscovery } from "./projects/discovery"; +import type { LanguageServerBlockSource } from "./views/languageServerView"; +import { registerProjectView } from "./views/projectView"; + +export interface PagesDeps { + output: vscode.OutputChannel; + /** The mcpp the commands run (`mcpp.path` for the project's folder, else `mcpp`). */ + mcppExecutable: (project: McppProjectDiscovery | undefined) => string; + /** The project view's C++ Modules block; absent where no mcppls bridge is (the workbench). */ + languageService?: LanguageServerBlockSource; +} + +export function registerPages(context: vscode.ExtensionContext, deps: PagesDeps): void { + const output = deps.output; + // ── views ──────────────────────────────────────────────────────────────── + // Each view owns its own tree provider and its own commands; the project view + // reads the manifest, the cache view runs mcpp's read-only queries, and the + // C++ Modules view only forwards to mcppls. + const applyViewVisibility = (): void => { + // Literal keys, so the wiring gate can see them. + // `mcpp.views.enabled` is the master switch: with it off every view is hidden. + // Whether VS Code also drops the container's icon from the activity bar is + // up to VS Code itself — an extension cannot mark its own container + // hidden-when-empty (the setting's description says the same). The `when` + // clauses in package.json are negated (`!mcpp.sidebarHidden`) so the default + // state is visible — otherwise activation would never run to set the key back. + void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !read("mcpp.views.enabled")); + void vscode.commands.executeCommand("setContext", "mcpp.views.project", read("mcpp.views.project.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.library", read("mcpp.views.library.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.cache", read("mcpp.views.cache.show")); + }; + applyViewVisibility(); + context.subscriptions.push(onConfigurationChanged(applyViewVisibility)); + + // The language service is a block inside the project view, so its state source has + // to exist before the view is registered. + registerProjectView(context, { currentProject: findCurrentProject, languageService: deps.languageService }); + // The library ecosystem: a sidebar view plus an editor-area package page. The + // detail page writes `mcpp.toml` through `mcpp add`, so it tells the view to + // refresh — no save event fires for a file written outside the editor. + // Keep the selected library project when an editor-area webview takes focus. + let libraryProjectRoot: string | undefined = vscode.window.activeTextEditor?.document.uri.scheme === "file" + || vscode.workspace.workspaceFolders?.length === 1 ? findCurrentProject()?.root : undefined; + const libraryProject = (): string | undefined => libraryProjectRoot; + const chooseLibraryProject = async (): Promise => { + const manifests = await vscode.workspace.findFiles(MCPP_MANIFEST_GLOB, "**/{node_modules,target,.git}/**", 200); + const roots = [...new Set(manifests.map((uri) => uri.fsPath.slice(0, -"/mcpp.toml".length)))]; + const active = libraryProject(); + if (active && !roots.includes(active)) { roots.unshift(active); } + const selected = await vscode.window.showQuickPick(roots.map((root) => ({ label: vscode.workspace.asRelativePath(root), description: root, root })), { + title: t("Choose the target mcpp project"), placeHolder: t("Dependencies will be added to this project's mcpp.toml"), + }); + if (selected) { libraryProjectRoot = selected.root; watchSelectedLibraryProject(); refreshLibraries(); } + return selected?.root; + }; + const openLibraryDetail = createLibraryDetailOpener(context, { + mcppExecutable: (root) => deps.mcppExecutable(root ? { root, manifestPath: `${root}/mcpp.toml` } : undefined), + projectRoot: libraryProject, + chooseProject: chooseLibraryProject, + onPackageOpened: (key) => library.select(key), + output, + isTrusted: () => vscode.workspace.isTrusted, + onDependenciesChanged: () => { void library.refresh(); }, + onProjectFilesRead: (root, manifest, lock) => { if (root === libraryProjectRoot) { selectedLibraryPoll?.observe(manifest, lock); } }, + }); + const library = registerLibraryView(context, { + projectRoot: libraryProject, + mcppExecutable: () => deps.mcppExecutable(libraryProjectRoot ? { root: libraryProjectRoot, manifestPath: `${libraryProjectRoot}/mcpp.toml` } : undefined), + openDetail: openLibraryDetail, + output, + isTrusted: () => vscode.workspace.isTrusted, + }); + const dependencyWatcher = vscode.workspace.createFileSystemWatcher("**/mcpp.{toml,lock}"); + let selectedLibraryWatcher: vscode.Disposable | undefined; + let selectedLibraryPoll: ReturnType | undefined; + let libraryTimer: NodeJS.Timeout | undefined; + const refreshLibraries = (): void => { + void selectedLibraryPoll?.check(); + if (libraryTimer) { clearTimeout(libraryTimer); } + libraryTimer = setTimeout(() => { + libraryTimer = undefined; + void library.refresh(); + void openLibraryDetail.refresh(); + }, 120); + }; + // A non-recursive RelativePattern also observes scratch/gitignored projects + // which a workspace's recursive watcher may intentionally exclude. + function watchSelectedLibraryProject(): void { + selectedLibraryWatcher?.dispose(); + selectedLibraryWatcher = undefined; + selectedLibraryPoll = undefined; + if (!libraryProjectRoot) { return; } + const watcher = vscode.workspace.createFileSystemWatcher(new vscode.RelativePattern(vscode.Uri.file(libraryProjectRoot), "mcpp.{toml,lock}")); + selectedLibraryPoll = watchProjectFiles(libraryProjectRoot, refreshLibraries); + selectedLibraryWatcher = vscode.Disposable.from(selectedLibraryPoll, watcher, watcher.onDidCreate(refreshLibraries), watcher.onDidChange(refreshLibraries), watcher.onDidDelete(refreshLibraries)); + } + watchSelectedLibraryProject(); + context.subscriptions.push( + { dispose: () => selectedLibraryWatcher?.dispose() }, + dependencyWatcher, + dependencyWatcher.onDidCreate(refreshLibraries), + dependencyWatcher.onDidChange(refreshLibraries), + dependencyWatcher.onDidDelete(refreshLibraries), + vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor?.document.uri.scheme === "file") { const active = findCurrentProject()?.root; if (active && active !== libraryProjectRoot) { libraryProjectRoot = active; watchSelectedLibraryProject(); } refreshLibraries(); } }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { libraryProjectRoot = undefined; watchSelectedLibraryProject(); refreshLibraries(); }), + vscode.workspace.onDidGrantWorkspaceTrust(refreshLibraries), + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library") || event.affectsConfiguration("mcpp.path") || event.affectsConfiguration("mcpp.ui.language")) { refreshLibraries(); } + }), + { dispose: () => { if (libraryTimer) { clearTimeout(libraryTimer); } } }, + ); + // `registerLibraryView` registers its own webview provider; these are only the + // entry points a menu or the project view can name. + context.subscriptions.push( + vscode.commands.registerCommand(LIBRARY_COMMANDS.search, async () => { + await vscode.commands.executeCommand("mcpp.library.focus"); + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.openDetail, async (id: unknown) => { + if (typeof id === "string" && id.length > 0) { + await openLibraryDetail(id); + } + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.updateIndex, async () => { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage(t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.")); + return; + } + const root = libraryProject(); + const project = root ? { root, manifestPath: `${root}/mcpp.toml` } : undefined; + if (read("mcpp.library.indexPath").trim()) { + output.appendLine(t("Updating the global mcpp index; a configured custom checkout is only reread.")); + } + library.setUpdateStatus(t("Updating mcpp index…")); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Refreshing the mcpp Package Index…") }, + () => runMcpp(vscode.workspace.isTrusted, deps.mcppExecutable(project), ["index", "update"], project?.root, { timeoutMs: 300_000 }), + ); + if (result === undefined || result.exitCode !== 0) { + library.setUpdateStatus(t("Index update failed; showing the last available local snapshot.")); + if (result?.stderr) { output.appendLine(result.stderr.trimEnd()); } + void vscode.window.showErrorMessage(t("mcpp index update failed with exit code {0}", result?.exitCode ?? -1)); + return; + } + invalidateIndexCache(); + await library.refresh(); + await openLibraryDetail.refresh(); + const snapshot = await loadSnapshot({ projectRoot: root }); + library.setUpdateStatus(t("Index updated; {0} local packages.", snapshot.entries.length)); + }), + ); + // There is no `mcpp.library.indexFound` context key and no `viewsWelcome` + // entry for the library view. `paneview.ts`'s base `ViewPane` answers + // `shouldShowWelcome() === false`; only the tree-shaped panes override it, so + // welcome content can never render inside a `"type": "webview"` view. The + // library view states its own empty case in the document instead, which is the + // only place VS Code will show it. + + registerCacheView(context, { + output, + currentProject: findCurrentProject, + mcppExecutable: (project) => deps.mcppExecutable(project), + isTrusted: () => vscode.workspace.isTrusted, + }); + registerSettingsPanel(context); +} diff --git a/mide-plugins/vscode/src/panels.ts b/mide-plugins/vscode/src/panels.ts deleted file mode 100644 index 1c8917e..0000000 --- a/mide-plugins/vscode/src/panels.ts +++ /dev/null @@ -1,165 +0,0 @@ -// The shared panels (webui/) in VS Code: editor-area panels and the sidebar's webview views. The -// panel speaks the backend's protocol through this host (rpc in, notifications out) and asks it for -// what only the editor does: open a file, confirm, write a setting, run a command. -import { randomBytes } from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as vscode from "vscode"; - -import { MCPPLS_EXTENSION, panelTitle } from "./mapping"; -import type { Session } from "./session"; - -export interface PanelParams { - root?: string; - graph?: string; - id?: string; - focus?: string; -} - -type HostAction = (params: any) => Promise | unknown; - -export class Panels implements vscode.Disposable { - private readonly open = new Map(); - private readonly webviews = new Set(); - private readonly disposables: vscode.Disposable[] = []; - private readonly webuiRoot: vscode.Uri; - - constructor(private readonly context: vscode.ExtensionContext, private readonly session: Session, - private readonly runCommand: (command: string, params: any) => Promise) { - this.webuiRoot = vscode.Uri.joinPath(context.extensionUri, "webui"); - // Every notification of the backend reaches every panel; each panel takes what it shows. - this.disposables.push(session.on("*", ({ method, params }: { method: string; params: unknown }) => { - for (const w of this.webviews) void w.postMessage({ type: "notify", method, params }); - })); - for (const [view, panel] of [["mcpp.library", "libraries"], ["mcpp.cache", "cache"]] as const) - this.disposables.push(vscode.window.registerWebviewViewProvider(view, { - resolveWebviewView: (v) => { const forget = this.attach(v.webview, panel, () => this.paramsForView()); v.onDidDispose(forget); }, - })); - } - - private paramsForView(): PanelParams { - const editor = vscode.window.activeTextEditor; - const projects = this.session.info?.projects ?? []; - const root = editor ? projects.find((p) => editor.document.uri.fsPath.startsWith(p.root))?.root : undefined; - return { root: root ?? projects[0]?.root }; - } - - /** Opens (or reveals) a panel in the editor area. */ - show(panel: string, params: PanelParams = {}): void { - const key = `${panel}:${params.root ?? ""}:${params.graph ?? ""}:${params.id ?? ""}`; - const existing = this.open.get(key); - if (existing) { existing.reveal(); return; } - const titles: Record = { - project: this.session.t("Project"), libraries: this.session.t("Libraries"), library: params.id ?? this.session.t("Libraries"), - cache: this.session.t("Cache"), settings: this.session.t("Settings"), pack: this.session.t("Pack"), - targets: this.session.t("Targets and Platforms"), - graph: params.graph === "modules" ? this.session.t("Module graph") : this.session.t("Dependency graph"), - }; - const p = vscode.window.createWebviewPanel(`mcpp.${panel}`, panelTitle(titles[panel] ?? panel), vscode.ViewColumn.Active, - { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [this.webuiRoot] }); - p.iconPath = vscode.Uri.joinPath(this.context.extensionUri, "media", "logo.png"); - this.open.set(key, p); - const forget = this.attach(p.webview, panel === "library" ? "libraries" : panel, () => params); - p.onDidDispose(() => { this.open.delete(key); forget(); }); - } - - /** Loads the panel into a webview; the returned function forgets it when the webview ends. */ - private attach(webview: vscode.Webview, panel: string, params: () => PanelParams): () => void { - webview.options = { enableScripts: true, localResourceRoots: [this.webuiRoot] }; - const nonce = randomBytes(16).toString("base64"); - const base = `${webview.asWebviewUri(this.webuiRoot).toString()}/`; - const csp = `default-src 'none'; img-src ${webview.cspSource} data:; style-src ${webview.cspSource} 'unsafe-inline'; script-src 'nonce-${nonce}' ${webview.cspSource};`; - const html = fs.readFileSync(path.join(this.webuiRoot.fsPath, "index.html"), "utf8") - .split("{{base}}").join(base).split("{{csp}}").join(csp).split("{{nonce}}").join(nonce) - .split("{{lang}}").join(vscode.env.language); - this.webviews.add(webview); - const sub = webview.onDidReceiveMessage((m) => this.receive(webview, panel, params, m)); - webview.html = html; - return () => { this.webviews.delete(webview); sub.dispose(); }; - } - - private async receive(webview: vscode.Webview, panel: string, params: () => PanelParams, m: any): Promise { - if (m?.type === "ready") { - await webview.postMessage({ type: "init", panel, params: params(), bundle: this.session.strings, editor: "vscode" }); - return; - } - if (m?.type === "rpc") { - try { - const result = await this.session.request(m.method, m.params); - await webview.postMessage({ type: "rpcResult", id: m.id, result }); - } catch (e) { - const error = (e as { error?: unknown }).error ?? { code: -32603, message: e instanceof Error ? e.message : String(e) }; - await webview.postMessage({ type: "rpcError", id: m.id, error }); - } - return; - } - if (m?.type === "host") { - const action = this.actions()[m.action]; - try { - if (!action) throw new Error(`unknown host action ${m.action}`); - const result = await action(m.params ?? {}); - await webview.postMessage({ type: "hostResult", id: m.id, result: result ?? null }); - } catch (e) { - await webview.postMessage({ type: "hostError", id: m.id, error: { message: e instanceof Error ? e.message : String(e) } }); - } - } - } - - private actions(): Record { - const t = (k: string, ...a: unknown[]) => this.session.t(k, ...a); - const target = (key: string) => { - const i = vscode.workspace.getConfiguration().inspect(key); - if (i?.workspaceFolderValue !== undefined) return vscode.ConfigurationTarget.WorkspaceFolder; - if (i?.workspaceValue !== undefined) return vscode.ConfigurationTarget.Workspace; - return vscode.ConfigurationTarget.Global; - }; - return { - settings: () => this.session.settings(), - setSetting: async (p) => { await vscode.workspace.getConfiguration().update(p.key, p.value, target(p.key)); return true; }, - resetSettings: async (p) => { - for (const key of p.keys as string[]) await vscode.workspace.getConfiguration().update(key, undefined, target(key)); - return true; - }, - migrateSettings: async (p) => { - for (const r of p.renames as { from: string; to: string }[]) { - const i = vscode.workspace.getConfiguration().inspect(r.from); - if (i?.globalValue !== undefined) await vscode.workspace.getConfiguration().update(r.to, i.globalValue, vscode.ConfigurationTarget.Global); - if (i?.workspaceValue !== undefined) await vscode.workspace.getConfiguration().update(r.to, i.workspaceValue, vscode.ConfigurationTarget.Workspace); - } - return true; - }, - openFile: async (p) => { - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(p.path)); - const line = Math.max(0, (p.line ?? 1) - 1); - await vscode.window.showTextDocument(doc, { selection: new vscode.Range(line, 0, line, 0) }); - return true; - }, - openExternal: (p) => vscode.env.openExternal(vscode.Uri.parse(p.url)), - reveal: (p) => vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(p.path)), - copy: async (p) => { await vscode.env.clipboard.writeText(p.text); void vscode.window.showInformationMessage(t("Copied.")); return true; }, - notify: (p) => (p.level === "error" ? vscode.window.showErrorMessage(p.message) : p.level === "warning" ? vscode.window.showWarningMessage(p.message) : vscode.window.showInformationMessage(p.message)), - openPanel: (p) => { const { panel, ...rest } = p; this.show(panel, rest); return true; }, - runCommand: (p) => this.runCommand(p.command, p), - confirm: async (p) => { - const yes = t("Continue"); - const detail = [p.detail, p.preview].filter(Boolean).join("\n\n"); - const choice = await vscode.window.showWarningMessage(p.title, { modal: true, detail }, yes); - if (choice !== yes) return { confirmed: false }; - if (!p.acknowledge) return { confirmed: true }; - const ack = t("I understand this affects every mcpp project on this machine"); - const second = await vscode.window.showWarningMessage(p.title, { modal: true, detail: ack }, ack); - return { confirmed: second === ack, acknowledged: second === ack }; - }, - languageService: async (p) => { - if (p.action === "install") await vscode.commands.executeCommand("workbench.extensions.installExtension", MCPPLS_EXTENSION); - else await vscode.commands.executeCommand("workbench.extensions.search", `@id:${MCPPLS_EXTENSION}`); - return true; - }, - }; - } - - dispose(): void { - for (const p of this.open.values()) p.dispose(); - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/projectTree.ts b/mide-plugins/vscode/src/projectTree.ts deleted file mode 100644 index becb336..0000000 --- a/mide-plugins/vscode/src/projectTree.ts +++ /dev/null @@ -1,117 +0,0 @@ -// The project view (mcpp.project): the current project's executables, tests and libraries, the -// target it builds for and the platform that configures it, its last build, and the language -// service that answers its sources. Everything shown is the backend's `project/summary` and -// `languageService/status`. -import * as vscode from "vscode"; - -import type { Jobs } from "./jobs"; -import type { Projects } from "./projects"; -import type { LanguageServiceStatus, ProjectSummary } from "./protocol"; -import type { Session } from "./session"; - -class Node extends vscode.TreeItem { - children?: Node[]; - constructor(label: string, opts: { description?: string; icon?: string; context?: string; command?: vscode.Command; tooltip?: string; children?: Node[]; expanded?: boolean } = {}) { - super(label, opts.children ? (opts.expanded === false ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.Expanded) : vscode.TreeItemCollapsibleState.None); - this.description = opts.description; - if (opts.icon) this.iconPath = new vscode.ThemeIcon(opts.icon); - this.contextValue = opts.context; - this.command = opts.command; - this.tooltip = opts.tooltip; - this.children = opts.children; - } -} - -export class ProjectTree implements vscode.TreeDataProvider, vscode.Disposable { - private readonly changed = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this.changed.event; - private readonly disposables: vscode.Disposable[] = []; - private summary: ProjectSummary | undefined; - private languageService: LanguageServiceStatus | undefined; - private error: string | undefined; - - constructor(private readonly session: Session, private readonly projects: Projects, private readonly jobs: Jobs) { - const refresh = () => void this.load(); - this.disposables.push( - this.changed, - vscode.window.registerTreeDataProvider("mcpp.project", this), - projects.onDidChange.event(refresh), - session.on("result/changed", refresh), - session.on("manifest/changed", refresh), - session.on("job/plan", refresh), - jobs.onDidChangeRunning.event(() => this.changed.fire()), - vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("mcpp.build.target")) refresh(); }), - vscode.extensions.onDidChange(refresh), - ); - } - - async load(): Promise { - const project = this.projects.project; - this.error = undefined; - if (!project) { this.summary = undefined; this.changed.fire(); return; } - try { - [this.summary, this.languageService] = await Promise.all([ - this.session.request("project/summary", { root: project.root }), - this.session.request("languageService/status", { candidates: this.session.mcpplsCandidates() }), - ]); - } catch (e) { - this.error = e instanceof Error ? e.message : String(e); - } - this.changed.fire(); - } - - getTreeItem(n: Node): vscode.TreeItem { - return n; - } - - getChildren(n?: Node): Node[] { - if (n) return n.children ?? []; - const t = (k: string, ...a: unknown[]) => this.session.t(k, ...a); - const project = this.projects.project; - if (!project) return [new Node(t("No mcpp project in this workspace"), { icon: "info" })]; - if (this.error) return [new Node(t("mcpp could not answer"), { icon: "error", tooltip: this.error, command: { command: "mcpp.selfCheck", title: "" } })]; - const s = this.summary; - if (!s) return [new Node(t("Loading…"), { icon: "loading~spin" })]; - const cmd = (command: string, ...args: unknown[]): vscode.Command => ({ command, title: "", arguments: args }); - const out: Node[] = []; - out.push(new Node(project.name, { - description: s.toolchain, icon: "project", tooltip: project.root, - command: cmd("mcpp.openProjectPanel"), - })); - out.push(new Node(t("Target"), { - description: `${s.target || t("this machine")}${s.platformText ? ` · ${s.platformText}` : ""}`, icon: "target", - command: cmd("mcpp.selectTarget"), tooltip: s.hostPlatformText ? `${t("Host platform")}: ${s.hostPlatformText}` : undefined, - })); - if (s.lastBuild) { - const ok = s.lastBuild.status === "ok"; - out.push(new Node(t("Last build"), { description: `${s.lastBuild.status}${s.lastBuild.client ? ` · ${s.lastBuild.client}` : ""}`, icon: ok ? "pass" : "error" })); - } - if (this.jobs.runningCount > 0) - out.push(new Node(t("{0} job(s) running", this.jobs.runningCount), { icon: "sync~spin", command: cmd("mcpp.cancelJob"), tooltip: t("Cancel the Running Job") })); - out.push(new Node(t("Executables"), { icon: "symbol-event", children: s.targets.map((x) => - new Node(x.name, { icon: "play", context: "mcppExecutable", command: cmd("mcpp.run", { bin: x.name }) })) })); - out.push(new Node(t("Tests"), { icon: "beaker", children: s.tests.map((x) => - new Node(x.name, { icon: "beaker", context: "mcppTest", command: cmd("mcpp.test", { filter: x.name }) })) })); - out.push(new Node(t("Libraries"), { icon: "library", expanded: false, children: s.libraries.map((x) => new Node(x.name, { description: x.kind, icon: "package" })) })); - out.push(new Node(t("Dependencies"), { icon: "type-hierarchy", context: "mcppProjectDependencies", command: cmd("mcpp.project.showDependencyGraph") })); - const ls = this.languageService; - if (ls) { - const state = ls.state === "ok" ? "pass" : ls.state === "missing" ? "error" : "warning"; - out.push(new Node(t("C++ Modules language service"), { - description: ls.state === "ok" ? `mcppls ${ls.version}` : t("needs attention"), icon: state, tooltip: ls.message, - children: [ - new Node(ls.message, { icon: "info", tooltip: ls.message }), - ...ls.actions.map((a) => new Node(a.title, { icon: "cloud-download", command: cmd("mcpp.languageServer.act", a.id) })), - new Node(t("Restart the language server"), { icon: "debug-restart", command: cmd("mcpp.languageServer.restart") }), - new Node(t("Open the log"), { icon: "output", command: cmd("mcpp.languageServer.showLogs") }), - ], - expanded: ls.state !== "ok", - })); - } - return out; - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/projects.ts b/mide-plugins/vscode/src/projects.ts deleted file mode 100644 index 136efe9..0000000 --- a/mide-plugins/vscode/src/projects.ts +++ /dev/null @@ -1,69 +0,0 @@ -// Which project the user is in: the one the active editor's file belongs to (the backend answers, -// `projects/forFile`), else the first project of the window. The answer sets `mcpp.inProject` and -// `mcpp.editorTitleButtons`, which the generated menus and keybindings read. -import * as vscode from "vscode"; - -import type { Project } from "./protocol"; -import type { Session } from "./session"; - -export class Projects implements vscode.Disposable { - private current: Project | undefined; - private readonly disposables: vscode.Disposable[] = []; - readonly onDidChange = new vscode.EventEmitter(); - - constructor(private readonly session: Session) { - this.disposables.push( - this.onDidChange, - vscode.window.onDidChangeActiveTextEditor(() => void this.refresh()), - vscode.workspace.onDidChangeWorkspaceFolders(() => void this.refresh()), - vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("mcpp.task.editorTitleButtons")) void this.refresh(); }), - session.onDidStart.event(() => void this.refresh()), - ); - } - - get project(): Project | undefined { - return this.current; - } - - get all(): Project[] { - return this.session.info?.projects ?? []; - } - - /** The current project, asking the backend if the answer is not known yet. */ - async resolve(): Promise { - if (!this.current) await this.refresh(); - return this.current; - } - - async refresh(): Promise { - let next: Project | undefined; - const editor = vscode.window.activeTextEditor; - try { - if (editor && editor.document.uri.scheme === "file") { - const folder = vscode.workspace.getWorkspaceFolder(editor.document.uri); - const boundary = vscode.workspace.getConfiguration().get("mcpp.project.discoveryBoundary") === "filesystem" ? "" : folder?.uri.fsPath ?? ""; - next = (await this.session.request("projects/forFile", { path: editor.document.uri.fsPath, boundary })) ?? undefined; - } - } catch { - next = undefined; - } - if (!next) next = this.current && this.all.some((p) => p.root === this.current!.root) ? this.current : this.all[0]; - const changed = next?.root !== this.current?.root; - this.current = next; - await vscode.commands.executeCommand("setContext", "mcpp.inProject", !!next); - await vscode.commands.executeCommand("setContext", "mcpp.editorTitleButtons", - vscode.workspace.getConfiguration().get("mcpp.task.editorTitleButtons") ?? true); - if (changed) this.onDidChange.fire(next); - } - - /** The current project, or a message that there is none. */ - async require(): Promise { - const p = await this.resolve(); - if (!p) void vscode.window.showWarningMessage(this.session.t("No mcpp.toml was found in this workspace. Run this command inside an mcpp project.")); - return p; - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/projects/context.ts b/mide-plugins/vscode/src/projects/context.ts new file mode 100644 index 0000000..7b9f590 --- /dev/null +++ b/mide-plugins/vscode/src/projects/context.ts @@ -0,0 +1,53 @@ +export const IN_PROJECT_CONTEXT_KEY = "mcpp.inProject"; +export const MCPP_MANIFEST_GLOB = "**/mcpp.toml"; + +/** + * `mcpp.task.editorTitleButtons`: the second half of the editor/title `when` + * clause. `mcpp.inProject` says "this file belongs to an mcpp project"; this key + * lets the user turn the Run/Test buttons off without hiding them for everyone. + */ +export const EDITOR_TITLE_BUTTONS_CONTEXT_KEY = "mcpp.editorTitleButtons"; + +export interface DisposableLike { + dispose(): unknown; +} + +export interface InProjectEnvironment { + currentProject(): unknown | undefined; + setContextValue(key: string, value: boolean): PromiseLike; + subscribe(listener: () => void): readonly DisposableLike[]; +} + +export async function updateInProjectContext(env: InProjectEnvironment): Promise { + const inProject = env.currentProject() !== undefined; + await env.setContextValue(IN_PROJECT_CONTEXT_KEY, inProject); + return inProject; +} + +export interface EditorTitleButtonsEnvironment { + enabled(): boolean; + setContextValue(key: string, value: boolean): PromiseLike; +} + +/** + * Apply `mcpp.task.editorTitleButtons` to the context key `package.json` gates + * the editor/title buttons with. The caller re-runs it on a configuration + * change; like every other context write it is fire-and-forget. + */ +export async function updateEditorTitleButtonsContext(env: EditorTitleButtonsEnvironment): Promise { + const enabled = env.enabled(); + await env.setContextValue(EDITOR_TITLE_BUTTONS_CONTEXT_KEY, enabled); + return enabled; +} + +export function registerInProjectContext(env: InProjectEnvironment): { dispose(): unknown } { + void updateInProjectContext(env); + const disposables = env.subscribe(() => void updateInProjectContext(env)); + return { + dispose: () => { + for (const disposable of disposables) { + disposable.dispose(); + } + }, + }; +} diff --git a/mide-plugins/vscode/src/projects/current.ts b/mide-plugins/vscode/src/projects/current.ts new file mode 100644 index 0000000..82a0394 --- /dev/null +++ b/mide-plugins/vscode/src/projects/current.ts @@ -0,0 +1,44 @@ +/** + * Which mcpp project the user is in: the nearest one above the active editor's file, within its + * workspace folder unless `mcpp.project.discoveryBoundary` says otherwise, else the first one a + * workspace folder holds. The extension and the workbench mcpp-clion runs the pages in ask the same. + */ +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { discoveryBoundaryOf, findNearestMcppProject, type DiscoveryBoundary, type McppProjectDiscovery } from "./discovery"; + +export function discoveryBoundaryFromSettings(resource?: vscode.Uri): DiscoveryBoundary { + return discoveryBoundaryOf(read("mcpp.project.discoveryBoundary", resource)); +} + +export function findCurrentProject(): McppProjectDiscovery | undefined { + const activeEditor = vscode.window.activeTextEditor; + if (activeEditor !== undefined) { + const activeUri = activeEditor.document.uri; + if (activeUri.scheme !== "file") { + return undefined; + } + const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeUri); + if (workspaceFolder === undefined) { + return undefined; + } + return findNearestMcppProject( + activeUri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); + } + + for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); + if (project !== undefined) { + return project; + } + } + return undefined; +} diff --git a/mide-plugins/vscode/src/projects/dependencyGraph.ts b/mide-plugins/vscode/src/projects/dependencyGraph.ts new file mode 100644 index 0000000..7a2a39d --- /dev/null +++ b/mide-plugins/vscode/src/projects/dependencyGraph.ts @@ -0,0 +1,73 @@ +import path from "node:path"; +import { readProjectDeclarations, type ProjectDeclaration } from "../library/projectState"; +import { readProjectSummary } from "./summary"; + +export interface GraphNode { key: string; name: string; version?: string; tags?: readonly string[]; dependencies?: number; depth: number; source: "project" | "manifest" | "index" | "unknown"; } +export interface GraphEdge { from: string; to: string; kind: string; conditional: boolean; constraint?: string; name?: string; declaration?: string; } +export interface DependencyGraph { nodes: GraphNode[]; edges: GraphEdge[]; limited: boolean; } +export interface GraphReader { + manifest(root: string): Promise; + descriptor(id: string): { id: string; dependencies: ProjectDeclaration[]; resolved?: string; tags?: string[] } | undefined; +} + +/** Project is depth zero; traverse at most three dependency hops, preserving shared/cyclic edges. */ +export async function buildDependencyGraph(root: string, name: string, reader: GraphReader, maxDepth = 3): Promise { + const graph: DependencyGraph = { nodes: [{ key: root, name, depth: 0, source: "project" }], edges: [], limited: false }; + const queue: Array<{ key: string; root?: string; declarations?: ProjectDeclaration[]; depth: number }> = [{ key: root, root, depth: 0 }]; + const seen = new Set([root]); + const edges = new Set(); + while (queue.length) { + const current = queue.shift()!; + if (current.depth >= maxDepth) { continue; } + const text = current.root ? await reader.manifest(current.root) : undefined; + const declarations = current.declarations ?? (text === undefined ? [] : readProjectDeclarations(text)); + const currentNode = graph.nodes.find((node) => node.key === current.key)!; + currentNode.dependencies = declarations.length; + if (text !== undefined) { + const summary = readProjectSummary(current.root!, text.split(/\r?\n/)); + currentNode.version ??= summary.version; + currentNode.tags = [currentNode.source === "project" ? "project" : "path", ...(summary.standard ? [summary.standard] : [])]; + } + for (const dep of declarations) { + const localRoot = dep.path && current.root ? path.resolve(current.root, dep.path) : undefined; + const descriptor = dep.source ? undefined : reader.descriptor(dep.id); + const key = localRoot ?? descriptor?.id ?? `unknown:${dep.id}`; + if (!seen.has(key)) { + if (seen.size >= 120) { graph.limited = true; continue; } + seen.add(key); + const localText = localRoot ? await reader.manifest(localRoot) : undefined; + const local = localText === undefined ? undefined : readProjectSummary(localRoot!, localText.split(/\r?\n/)); + graph.nodes.push({ key, name: descriptor?.id ?? local?.name ?? dep.id, version: descriptor?.resolved ?? dep.version ?? local?.version, depth: current.depth + 1, + tags: descriptor?.tags ?? (local ? ["path", ...(local.standard ? [local.standard] : [])] : ["unavailable"]), + dependencies: descriptor?.dependencies.length ?? (localText === undefined ? undefined : readProjectDeclarations(localText).length), + source: localRoot && localText !== undefined ? "manifest" : descriptor ? "index" : "unknown" }); + if (descriptor || localText !== undefined) { queue.push({ key, root: localRoot, declarations: descriptor?.dependencies, depth: current.depth + 1 }); } + } + const kind = dep.kind ?? (dep.dev ? "dev" : "normal"); + const edgeKey = JSON.stringify([current.key, key, kind, !!dep.conditional, dep.version]); + if (!edges.has(edgeKey)) { + if (edges.size >= 240) { graph.limited = true; continue; } + edges.add(edgeKey); + graph.edges.push({ from: current.key, to: key, kind, conditional: !!dep.conditional, constraint: dep.version, name: descriptor?.id ?? dep.id, declaration: dep.id }); + } + } + } + return graph; +} + +/** IDs are generated; every label is quoted and escaped, so manifest text cannot inject Mermaid syntax. */ +export function graphMermaid(graph: DependencyGraph): string { + const ids = new Map(graph.nodes.map((node, index) => [node.key, `n${index}`])); + const label = (value: string) => value.replace(/[\r\n]/g, " ").replace(/[&"<>[\]{}|`\\#]/g, (char) => `#${char.charCodeAt(0)};`); + const nodes = new Map(graph.nodes.map((node) => [node.key, node])); + return ["flowchart LR", ...graph.nodes.map((node) => { + const text = [node.name, ...(node.tags?.length ? [node.tags.slice(0, 2).join(" · ")] : []), `deps ${node.dependencies ?? "?"}`]; + return ` ${ids.get(node.key)}["${text.map(label).join("
    ")}"]:::${node.source}`; + }), ...graph.edges.map((edge) => { + const target = nodes.get(edge.to)!; + const version = edge.constraint ?? target.version; + const coordinate = (edge.name ?? target.name) + (version ? `@${version}` : ""); + const text = coordinate + (edge.kind === "normal" ? "" : ` · ${edge.kind}`) + (edge.conditional ? " · conditional" : ""); + return ` ${ids.get(edge.from)} ${edge.conditional ? "-.-" : "--"}>|"${label(text)}"| ${ids.get(edge.to)}`; + }), " classDef unknown stroke-dasharray: 4 3"].join("\n"); +} diff --git a/mide-plugins/vscode/src/projects/dependencyGraphPanel.ts b/mide-plugins/vscode/src/projects/dependencyGraphPanel.ts new file mode 100644 index 0000000..f5711b2 --- /dev/null +++ b/mide-plugins/vscode/src/projects/dependencyGraphPanel.ts @@ -0,0 +1,290 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import * as vscode from "vscode"; +import { TOOL_COMMANDS } from "../commands/ids"; +import { t } from "../i18n/t"; +import { queryModuleGraph } from "../cli/moduleGraph"; +import { runMcppls } from "../cli/process"; +import { cachedDescriptorText, loadSnapshot } from "../library/indexLocator"; +import { descriptorDependencies, lockPackageVersions } from "../library/indexModel"; +import type { ProjectSummary } from "../views/models"; +import { WebviewDocument } from "../webview/document"; +import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; +import { graphPanelHtml } from "../graphs/panelHtml"; +import { groupedGraph, INITIAL_GRAPH_BUDGET, mermaidGraph, visibleGraph, type VisibleGraph, type VisualGraph } from "../graphs/model"; +import { RefreshCoordinator } from "../graphs/refresh"; +import { decodeGraphExport } from "../graphs/export"; +import { assertCompleteManifest } from "./dependencyState"; +import { readProjectSummary } from "./summary"; +import { buildDependencyGraph } from "./dependencyGraph"; +import { buildModuleGraph, moduleEntries, moduleOwnership, sourcePath, testDiscovery, isTestSource, testCandidateGlob } from "./moduleGraph"; + +interface ViewState { + root: string; mode: "packages" | "modules"; name: string; graph?: VisualGraph; + dependencyTargets?: Map; dependency?: string; dependencyKind?: string; + metrics: { reads: number; projections: number }; + coordinator?: RefreshCoordinator; dirty?: boolean; revision?: string; sentRevision?: string; retry?: ReturnType; attempts?: number; + visible?: VisibleGraph; group?: string; offset: number; initialized?: boolean; + history: Array<{ focus?: string; group?: string; offset: number; selected?: string; depth: number; upstreamDepth: number; localDepth: Map; collapsed: Set; upstreamLocalDepth: Map; upstreamCollapsed: Set }>; + upstreamDepth: number; localDepth: Map; upstreamLocalDepth: Map; upstreamCollapsed: Set; + depth: number; expanded: Set; collapsed: Set; selected?: string; focus?: string; +} +const SOURCE_SCHEME = "mcpp-graph-source"; + +/** Two providers, one persistent graph document. Expansion never re-runs the CLI. */ +export function registerDependencyGraph(context: vscode.ExtensionContext, project: () => ProjectSummary | undefined): (uri?: vscode.Uri) => void { + let panel: vscode.WebviewPanel | undefined, current: ViewState | undefined, source = "", generation = 0, publication = 0, ready = false, loading = false; + const views = new Map(); + const document = new WebviewDocument("dependencyGraph.css"), media = vscode.Uri.joinPath(context.extensionUri, "media"), vendor = vscode.Uri.joinPath(context.extensionUri, "dist", "vendor"); + const sourceChanged = new vscode.EventEmitter(); + const sources = new ReadOnlyDocuments(SOURCE_SCHEME, uri => sourceChanged.fire(vscode.Uri.parse(uri))); + context.subscriptions.push(sourceChanged, vscode.workspace.registerTextDocumentContentProvider(SOURCE_SCHEME, { onDidChange: sourceChanged.event, provideTextDocumentContent: uri => sources.read(uri.toString()) ?? "" })); + const viewKey = (view: ViewState): string => `${view.mode}:${view.root}`; + const exists = async (file: string): Promise => fs.access(file).then(() => true, () => false); + const read = (file: string): Promise => fs.readFile(file, "utf8").catch(() => undefined); + + const publish = async (center = false, fit = false): Promise => { + const view = current, target = panel, version = ++publication; + if (!ready || !target?.visible || !view?.graph) { return; } + view.metrics.projections++; + const presentation = view.focus ? view.graph : groupedGraph(view.graph, view.group, view.offset); + const graph = visibleGraph(presentation, view, INITIAL_GRAPH_BUDGET), rendered = mermaidGraph(graph); + view.visible = graph; + if (view.sentRevision !== view.revision) { + await target.webview.postMessage({ type: "snapshot", viewKey: viewKey(view), + search: view.graph.nodes.map(node => ({ id: node.id, label: node.label })), + groups: view.graph.groups ?? [] }); + view.sentRevision = view.revision; + } + if (current !== view || panel !== target || version !== publication) { return; } + source = rendered.source; + await target.webview.postMessage({ type: "graph", viewKey: viewKey(view), mode: view.mode, name: view.name, + depth: view.depth === Infinity ? "all" : String(view.depth), upstreamDepth: String(view.upstreamDepth), + source, nodes: graph.nodes.map(node => ({ id: node.id, label: node.label, hidden: graph.hidden.get(node.id) ?? 0, parents: graph.hiddenParents.get(node.id) ?? 0, virtual: !!node.virtual })), + selected: view.selected, focus: view.focus, group: view.group, offset: view.offset, + debug: vscode.workspace.getConfiguration?.("mcpp").get("log.level") === "debug" ? view.metrics : undefined, + back: !!view.history.length, center, fit, limited: graph.limited, truncated: graph.truncated }); + }; + const readGraph = async (view: ViewState, signal?: AbortSignal): Promise => { + if (view !== current || !panel || !ready || signal?.aborted) { return; } + const target = panel, request = ++generation; + loading = !view.graph; + if (!view.graph) { await target.webview.postMessage({ type: "loading", viewKey: viewKey(view), mode: view.mode, name: view.name }); } + try { + view.metrics.reads++; + const manifest = await read(path.join(view.root, "mcpp.toml")); + if (manifest === undefined) { throw new Error(t("The project manifest is missing or unreadable.")); } + assertCompleteManifest(manifest); + view.name = readProjectSummary(view.root, manifest.split(/\r?\n/)).name ?? path.basename(view.root); + let graph: VisualGraph; + if (view.mode === "modules") { + // mcppls as the backend found it: the payload's runner and image (mcppls 0.0.18 on), or a program. + const raw = await queryModuleGraph(vscode.workspace.isTrusted, "mcppls", view.root, (_executable, args, cwd, options) => runMcppls(args, cwd, options), signal); + const patterns = testDiscovery(manifest); + const discovered = new Set(); let discoveryLimited = false; + for (const pattern of new Set(patterns.filter(pattern => !pattern.startsWith("!")).map(testCandidateGlob))) { + // Search is only a candidate generator; the pure mcpp matcher decides membership. + const candidates = await vscode.workspace.findFiles(new vscode.RelativePattern(view.root, pattern), "**/{.git,.mcpp,node_modules,build,target}/**", 10000, signal ? { isCancellationRequested: signal.aborted, onCancellationRequested: listener => { const abort = () => listener(undefined); signal.addEventListener("abort", abort); return { dispose: () => signal.removeEventListener("abort", abort) }; } } : undefined); + discoveryLimited ||= candidates.length >= 10000; + for (const file of candidates) { if (isTestSource(path.relative(view.root, file.fsPath).split(path.sep).join("/"), patterns)) { discovered.add(file.fsPath); } } + } + const entries = moduleEntries(view.root, manifest, view.name, [...discovered]); + const files = new Set(raw.modules.flatMap(node => node.files.map(file => sourcePath(view.root, file)))); + const names = new Set(raw.modules.map(node => node.name)); + const importers = new Set(raw.imports.filter(edge => !names.has(edge.from)).map(edge => sourcePath(view.root, edge.from))); + // A truncated response can still contain edges from omitted module names. + // Only an existing source file may become an ordinary importer node. + for (const file of importers) { if (await exists(file)) { files.add(file); } } + for (const file of entries.keys()) { if (await exists(file)) { files.add(file); } } + graph = buildModuleGraph(view.root, raw, await moduleOwnership(view.root, [...files], exists), entries); + graph.truncated ||= discoveryLimited; + } else { + const [snapshot, lock] = await Promise.all([loadSnapshot({ projectRoot: view.root }).catch(() => undefined), read(path.join(view.root, "mcpp.lock"))]); + const locked = lockPackageVersions(lock ?? ""), entries = snapshot?.entries ?? []; + const packages = await buildDependencyGraph(view.root, view.name, { + manifest: directory => directory === view.root ? Promise.resolve(manifest) : read(path.join(directory, "mcpp.toml")), + descriptor: id => { + const matches = entries.filter(entry => entry.id === id || (!id.includes(".") && entry.name === id)); + if (matches.length !== 1) { return undefined; } + const entry = matches[0], text = cachedDescriptorText(entry.file); + if (text === undefined) { return undefined; } + return { id: entry.id, tags: [entry.surface === "header" ? "#include" : entry.surface === "module" ? "import" : entry.surface ?? "index", ...entry.licenses.slice(0, 1)], + resolved: locked.find(pkg => pkg.id === entry.id)?.version, dependencies: descriptorDependencies(text).map(dep => ({ ...dep, dev: false, kind: "normal" as const })) }; + }, + }, Infinity); + view.dependencyTargets = new Map(packages.edges.filter(edge => edge.from === view.root).map(edge => [`${edge.declaration}:${edge.kind}`, edge.to])); + const nodes = new Map(packages.nodes.map(node => [node.key, node])); + graph = { roots: [view.root], truncated: packages.limited, + nodes: packages.nodes.map(node => ({ id: node.key, label: node.name, kind: node.source, lines: [...(node.tags?.length ? [node.tags.slice(0, 2).join(" · ")] : []), `deps ${node.dependencies ?? "?"}`] })), + edges: packages.edges.map(edge => { + const node = nodes.get(edge.to)!, version = edge.constraint ?? node.version; + return { from: edge.from, to: edge.to, dashed: edge.conditional, label: (edge.name ?? node.name) + (version ? `@${version}` : "") + (edge.kind === "normal" ? "" : ` · ${edge.kind}`) + (edge.conditional ? " · conditional" : "") }; + }) }; + } + if (panel !== target || current !== view || request !== generation) { return; } + if (graph.groups) { graph.groups = graph.groups.map(group => ({ ...group, label: t(group.label) })); } + if (signal?.aborted) { return; } + view.dirty = false; + const revision = createHash("sha256").update(JSON.stringify(graph)).digest("hex"); + const unchanged = revision === view.revision; + view.graph = unchanged ? view.graph : graph; view.revision = revision; + if (!view.initialized) { + const activeFile = vscode.window.activeTextEditor?.document.uri.fsPath; + const entry = graph.groups?.flatMap(group => group.nodes).find(id => graph.nodes.find(node => node.id === id)?.files?.includes(activeFile ?? "")); + view.focus ??= entry ?? graph.groups?.[0]?.nodes[0]; + view.selected ??= view.focus; view.initialized = true; + } + if (view.focus && !graph.nodes.some(node => node.id === view.focus)) { view.focus = graph.groups?.[0]?.nodes[0]; } + if (view.selected && !graph.nodes.some(node => node.id === view.selected)) { view.selected = view.focus; } + if (view.retry) { clearTimeout(view.retry); view.retry = undefined; } + if (graph.truncated && view.mode === "modules" && !unchanged && (view.attempts ?? 0) < 4) { + const wait = [2000, 5000, 10000, 20000][view.attempts ?? 0]; view.attempts = (view.attempts ?? 0) + 1; + view.retry = setTimeout(() => { view.dirty = true; if (current === view && panel?.visible) { void load(true); } }, wait); + } + if (view.dependency) { + view.selected = view.dependencyTargets?.get(`${view.dependency}:${view.dependencyKind ?? "normal"}`) ?? view.selected; + view.focus = view.selected; view.dependency = undefined; + } + loading = false; await publish(); + } catch (error) { + if (panel !== target || current !== view || request !== generation) { return; } + if (signal?.aborted) { return; } + loading = false; + await target.webview.postMessage({ type: "error", error: t("Could not read project dependencies: {0}", String(error)) }); + } + }; + const load = async (refresh = false): Promise => { + const view = current; + if (!view || !ready || !panel?.visible) { return; } + if (view.graph && !refresh && !view.dirty) { await publish(); return; } + view.coordinator ??= new RefreshCoordinator(async signal => { await readGraph(view, signal); }); + view.coordinator.visible = true; + await view.coordinator.request(true); + }; + const chooseView = (root: string, mode: ViewState["mode"]): ViewState => { + const key = `${mode}:${root}`; + let view = views.get(key); + if (!view) { view = { root, mode, name: path.basename(root), depth: 3, upstreamDepth: mode === "modules" ? 2 : 0, offset: 0, metrics: { reads: 0, projections: 0 }, history: [], localDepth: new Map(), upstreamLocalDepth: new Map(), upstreamCollapsed: new Set(), expanded: new Set(), collapsed: new Set() }; views.set(key, view); + if (views.size > 8) { const oldest = views.keys().next().value!; views.get(oldest)?.coordinator?.dispose(); const retry = views.get(oldest)?.retry; if (retry) { clearTimeout(retry); } views.delete(oldest); } + } + views.delete(key); views.set(key, view); + return view; + }; + const openSource = async (view: ViewState, id: string): Promise => { + const node = view.graph?.nodes.find(node => node.id === id); + if (!node) { return; } + view.selected = id; + if (view.mode !== "modules") { return; } + const files = node.files ?? []; + if (!files.length) { void vscode.window.showInformationMessage(t("Source is unavailable for this module.")); return; } + const file = files.length === 1 ? files[0] : (await vscode.window.showQuickPick(files.map(file => ({ label: path.basename(file), description: path.relative(view.root, file), file })), { placeHolder: t("Choose a module source file") }))?.file; + if (!file || current !== view) { return; } + const text = await read(file); + if (current !== view) { return; } + if (text === undefined) { void vscode.window.showInformationMessage(t("Source is unavailable for this module.")); return; } + const uri = vscode.Uri.parse(sources.capture({ path: file, text })); + await vscode.window.showTextDocument(uri, { viewColumn: vscode.ViewColumn.Beside, preview: true, preserveFocus: true }); + }; + const receive = async (message: unknown): Promise => { + if (!message || typeof message !== "object") { return; } + const value = message as Record; + if (value.type === "ready") { ready = true; await load(); return; } + if (value.type !== "action" || !current || value.viewKey !== viewKey(current)) { return; } + if (value.action === "mode" && (value.mode === "packages" || value.mode === "modules")) { current.coordinator?.cancel(); current = chooseView(current.root, value.mode); await load(); return; } + if (value.action === "refresh") { await load(true); return; } + if (value.action === "manifest") { await vscode.window.showTextDocument(vscode.Uri.file(path.join(current.root, "mcpp.toml"))); return; } + if (loading || !current.graph) { return; } + if (value.action === "copy") { await vscode.env.clipboard.writeText(source); await panel?.webview.postMessage({ type: "copied" }); return; } + if (value.action === "depth" && ["0", "1", "2", "3", "5", "all"].includes(String(value.depth))) { + current.depth = value.depth === "all" ? Infinity : Number(value.depth); current.expanded.clear(); current.collapsed.clear(); current.localDepth.clear(); await publish(); return; + } + if (value.action === "upstreamDepth" && ["0", "1", "2", "3", "5"].includes(String(value.depth))) { current.upstreamDepth = Number(value.depth); current.upstreamLocalDepth.clear(); current.upstreamCollapsed.clear(); await publish(); return; } + const remember = (): void => { + current!.history.push({ focus: current!.focus, group: current!.group, offset: current!.offset, + selected: current!.visible?.nodes.some(node => node.id === current!.selected) ? current!.selected : current!.focus, depth: current!.depth, upstreamDepth: current!.upstreamDepth, + localDepth: new Map(current!.localDepth), collapsed: new Set(current!.collapsed), upstreamLocalDepth: new Map(current!.upstreamLocalDepth), upstreamCollapsed: new Set(current!.upstreamCollapsed) }); + if (current!.history.length > 20) { current!.history.shift(); } + }; + if (value.action === "back") { const previous = current.history.pop(); if (previous) { Object.assign(current, previous); await publish(); } return; } + if (value.action === "overview" || value.action === "group") { + remember(); current.focus = undefined; + current.group = typeof value.group === "string" && current.graph.groups?.some(group => group.id === value.group) ? value.group : undefined; + current.offset = 0; current.selected = current.group; await publish(false, true); return; + } + if (value.action === "page") { + const group = current.graph.groups?.find(group => group.id === current!.group); + if (group) { current.offset = Math.max(0, Math.min(Math.floor((group.nodes.length - 1) / 20) * 20, current.offset + (value.next ? 20 : -20))); current.selected = current.group; await publish(false, true); } return; + } + if (value.action === "export") { + const payload = decodeGraphExport(value.format, value.data); + const destination = await vscode.window.showSaveDialog({ defaultUri: vscode.Uri.file(path.join(current.root, `modules-${current.mode}.${payload.extension}`)), filters: { [payload.extension.toUpperCase()]: [payload.extension] } }); + if (destination) { await vscode.workspace.fs.writeFile(destination, payload.bytes); } return; + } + const id = typeof value.id === "string" ? value.id : undefined; + if (!id) { return; } + if (current.graph.groups?.some(group => group.id === id)) { + if (["focus", "expand", "open"].includes(String(value.action))) { remember(); current.focus = undefined; current.group = id; current.offset = 0; current.selected = id; await publish(false, true); } + return; + } + if (!current.graph.nodes.some(node => node.id === id)) { return; } + current.selected = id; + if (value.action === "select") { return; } + if (value.action === "open") { await openSource(current, id); return; } + if (value.action === "expand" || value.action === "expandParents") { + const upstream = value.action === "expandParents", overrides = upstream ? current.upstreamLocalDepth : current.localDepth; + const remaining = upstream ? current.visible?.upstreamRemaining : current.visible?.remainingDepth; + const depth = Math.max(0, remaining?.get(id) ?? 0, overrides.get(id) ?? 0); + overrides.set(id, Number.isFinite(depth) ? depth + 1 : 1); + (upstream ? current.upstreamCollapsed : current.collapsed).delete(id); + } + else if (value.action === "collapse" || value.action === "collapseParents") { (value.action === "collapseParents" ? current.upstreamCollapsed : current.collapsed).add(id); } + else if (value.action === "focus" || value.action === "search") { remember(); current.focus = id; current.group = undefined; current.offset = 0; current.localDepth = new Map(); current.collapsed = new Set(); current.upstreamLocalDepth = new Map(); current.upstreamCollapsed = new Set(); } + else { return; } + await publish(value.action === "search", value.action === "focus"); + }; + const open = async (mode: ViewState["mode"], argument?: unknown): Promise => { + const requested = argument && typeof argument === "object" ? argument as { root?: unknown; dependency?: unknown; path?: unknown; kind?: unknown } : undefined; + const root = typeof requested?.root === "string" ? requested.root : project()?.root; + if (!root) { void vscode.window.showInformationMessage(t("Open an mcpp project first.")); return; } + if (current && (current.root !== root || current.mode !== mode)) { current.coordinator?.cancel(); } + current = chooseView(root, mode); + if (typeof requested?.dependency === "string") { + current.dependency = requested.dependency; + current.dependencyKind = typeof requested.kind === "string" ? requested.kind : "normal"; + current.selected = current.dependencyTargets?.get(`${requested.dependency}:${current.dependencyKind}`) ?? (typeof requested.path === "string" ? path.resolve(root, requested.path) : current.graph?.nodes.find(node => node.id === requested.dependency || node.label === requested.dependency)?.id ?? requested.dependency); + current.focus = current.selected; + } + if (!panel) { + panel = vscode.window.createWebviewPanel("mcpp.dependencyGraph", t("Project dependency graph"), vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, localResourceRoots: [media, vendor] }); + ready = false; + panel.webview.onDidReceiveMessage(message => { void receive(message).catch(error => vscode.window.showErrorMessage(String(error))); }); + panel.onDidChangeViewState(() => { + for (const view of views.values()) { view.coordinator?.setVisible(panel?.visible === true && view === current); } + if (panel?.visible) { void load(); } + }); + panel.onDidDispose(() => { for (const view of views.values()) { view.coordinator?.cancel(); view.sentRevision = undefined; if (view.retry) { clearTimeout(view.retry); } } panel = undefined; ready = false; generation++; document.invalidate(); }); + const assets = document.assets(media, panel.webview); + document.paint(panel.webview, graphPanelHtml(assets, panel.webview.asWebviewUri(vscode.Uri.joinPath(vendor, "mermaid.js")).toString(), panel.webview.asWebviewUri(vscode.Uri.joinPath(media, "graph.js")).toString())); + } else { panel.reveal(); await load(); } + }; + const invalidate = (uri?: vscode.Uri): void => { + for (const view of views.values()) { + if (uri) { const relative = path.relative(view.root, uri.fsPath); if (relative.startsWith("..") || path.isAbsolute(relative)) { continue; } } + if (uri?.fsPath.endsWith("mcpp.lock") && view.mode === "modules") { continue; } + view.dirty = true; view.attempts = 0; + if (view === current && panel?.visible && ready) { + view.coordinator ??= new RefreshCoordinator(async signal => { await readGraph(view, signal); }); + void view.coordinator.request(); + } + } + }; + context.subscriptions.push(vscode.workspace.onDidSaveTextDocument(document => { if (/(?:mcpp\.(?:toml|lock)|\.(?:cpp|cc|cxx|cppm|ixx|mpp|ccm))$/.test(document.uri.fsPath)) invalidate(document.uri); })); + const watcher = vscode.workspace.createFileSystemWatcher("**/{mcpp.toml,mcpp.lock,*.cpp,*.cc,*.cxx,*.cppm,*.ixx,*.mpp,*.ccm}"); + context.subscriptions.push(watcher, watcher.onDidChange(uri => invalidate(uri)), watcher.onDidCreate(uri => invalidate(uri)), watcher.onDidDelete(uri => invalidate(uri)), + vscode.tasks.onDidEndTaskProcess(event => { const scope = event.execution.task.scope; if (scope && typeof scope === "object") { invalidate(scope.uri); } }), + { dispose: () => { for (const view of views.values()) { view.coordinator?.dispose(); if (view.retry) { clearTimeout(view.retry); } } } }); + context.subscriptions.push(vscode.commands.registerCommand(TOOL_COMMANDS.dependencyGraph, argument => open("packages", argument)), + vscode.commands.registerCommand(TOOL_COMMANDS.moduleDependencyGraph, argument => open("modules", argument)), { dispose: () => panel?.dispose() }); + return invalidate; +} diff --git a/mide-plugins/vscode/src/projects/dependencyState.ts b/mide-plugins/vscode/src/projects/dependencyState.ts new file mode 100644 index 0000000..ec5856c --- /dev/null +++ b/mide-plugins/vscode/src/projects/dependencyState.ts @@ -0,0 +1,29 @@ +import { parseMcppToml, type TomlValueNode } from "../toml/parser"; +import type { ProjectSummary } from "../views/models"; + +function completeValue(value: TomlValueNode | undefined): boolean { + if (!value || value.open) { return false; } + if (value.kind === "array") { return (value.elements ?? []).every(completeValue); } + if (value.kind === "inlineTable") { return (value.entries ?? []).every(entry => completeValue(entry.value)); } + // The editor parser models floats, dates and non-decimal integers as unknown scalars. + if (value.kind === "unknown") { return /^(?:[+-]?(?:inf|nan|\d[\d_]*(?:\.[\d_]+)?(?:[eE][+-]?[\d_]+)?)|0[xob][\da-fA-F_]+|\d{4}-\d{2}-\d{2}(?:[Tt ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})?)?|\d{2}:\d{2}:\d{2}(?:\.\d+)?)$/.test(value.text ?? ""); } + return true; +} + +/** Incomplete edits must not masquerade as a successfully empty dependency list. */ +export function assertCompleteManifest(text: string): void { + const lines = text.split(/\r?\n/), document = parseMcppToml(lines), covered = new Set(); + for (const node of document.nodes) { + if (node.type === "section" ? node.open : !completeValue(node.value)) { throw new Error("The manifest is incomplete"); } + for (let line = node.range.startLine; line <= node.range.endLine; line++) { covered.add(line); } + } + for (let line = 0; line < lines.length; line++) { + if (lines[line].trim() && !lines[line].trimStart().startsWith("#") && !covered.has(line)) { throw new Error("The manifest is incomplete"); } + } +} +export function retainProjectDependencies(next: ProjectSummary | undefined, previous?: ProjectSummary): ProjectSummary | undefined { + if (next?.error && previous?.root === next.root && !previous.error) { + return { ...previous, dependencyState: "stale", dependencyError: next.error }; + } + return next ? { ...next, dependencyState: next.error ? "error" : (next.dependencies?.length ? "ready" : "empty") } : undefined; +} diff --git a/mide-plugins/vscode/src/projects/discovery.ts b/mide-plugins/vscode/src/projects/discovery.ts new file mode 100644 index 0000000..2f680f4 --- /dev/null +++ b/mide-plugins/vscode/src/projects/discovery.ts @@ -0,0 +1,69 @@ +import { existsSync, statSync } from "node:fs"; +import path from "node:path"; + +export interface McppProjectDiscovery { + root: string; + manifestPath: string; +} + +/** + * How far the upward walk may go. `workspaceFolder` is the shipped behaviour; + * `filesystem` also reaches a project outside the opened folder + * (`mcpp.project.discoveryBoundary`). The walk itself never reads a setting — + * the `vscode` layer resolves the boundary and passes it in, which keeps this + * module pure and testable. + */ +export type DiscoveryBoundary = "workspaceFolder" | "filesystem"; + +/** `mcpp.project.discoveryBoundary`; anything unknown is the safe default. */ +export function discoveryBoundaryOf(value: unknown): DiscoveryBoundary { + return value === "filesystem" ? "filesystem" : "workspaceFolder"; +} + +function isPathWithin(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return relative === "" || ( + relative !== ".." + && !relative.startsWith(`..${path.sep}`) + && !path.isAbsolute(relative) + ); +} + +export function findNearestMcppProject( + startPath: string, + workspaceRoot?: string, + boundary: DiscoveryBoundary = "workspaceFolder", +): McppProjectDiscovery | undefined { + let current = path.resolve(startPath); + // `filesystem` drops the boundary entirely and keeps walking to the root. + const stop = boundary === "filesystem" || workspaceRoot === undefined + ? undefined + : path.resolve(workspaceRoot); + + try { + if (statSync(current).isFile()) { + current = path.dirname(current); + } + } catch { + // A newly-created workspace path may not exist yet; treat it as a directory. + } + + if (stop !== undefined && !isPathWithin(current, stop)) { + return undefined; + } + + while (true) { + const manifestPath = path.join(current, "mcpp.toml"); + if (existsSync(manifestPath)) { + return { root: current, manifestPath }; + } + if (stop !== undefined && current === stop) { + return undefined; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; + } +} diff --git a/mide-plugins/vscode/src/projects/metadataWatch.ts b/mide-plugins/vscode/src/projects/metadataWatch.ts new file mode 100644 index 0000000..4c0c32e --- /dev/null +++ b/mide-plugins/vscode/src/projects/metadataWatch.ts @@ -0,0 +1,24 @@ +import { statSync } from "node:fs"; +import path from "node:path"; + +/** Two cheap file stamps provide a fallback when the editor's watcher drops events. */ +export class ProjectMetadataWatch { + private versions = new Map(); + public constructor(private readonly version: (file: string) => string = file => { + try { const stat = statSync(file, {bigint:true}); return `${stat.ino}:${stat.size}:${stat.mtimeNs}`; } + catch { return "unavailable"; } + }) {} + public capture(root?: string): void { + this.versions.clear(); + if (root) { for (const name of ["mcpp.toml", "mcpp.lock"]) { const file = path.join(root,name); this.versions.set(file,this.version(file)); } } + } + public changes(visible = true): string[] { + if (!visible) { return []; } + const changed: string[] = []; + for (const [file,previous] of this.versions) { + const next=this.version(file); + if(next!==previous){this.versions.set(file,next);changed.push(file);} + } + return changed; + } +} diff --git a/mide-plugins/vscode/src/projects/moduleGraph.ts b/mide-plugins/vscode/src/projects/moduleGraph.ts new file mode 100644 index 0000000..d3b3c49 --- /dev/null +++ b/mide-plugins/vscode/src/projects/moduleGraph.ts @@ -0,0 +1,175 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseMcppToml } from "../toml/parser"; +import { graphRoots, type VisualGraph, type VisualNode } from "../graphs/model"; + +export interface ModuleGraphJson { + modules: Array<{ name: string; files: string[]; external: boolean }>; + imports: Array<{ from: string; to: string }>; + truncated?: boolean; + snapshot?: { indexing?: boolean; preparing?: boolean }; +} +/** Reject incompatible output instead of presenting an empty, apparently successful graph. */ +export function parseModuleGraph(text: string): ModuleGraphJson { + const value = JSON.parse(text) as ModuleGraphJson; + if (!value || !Array.isArray(value.modules) || !Array.isArray(value.imports) + || value.modules.some(node => typeof node.name !== "string" || !node.name || !Array.isArray(node.files) || node.files.some(file => typeof file !== "string") || typeof node.external !== "boolean") + || value.imports.some(edge => typeof edge.from !== "string" || typeof edge.to !== "string")) { + throw new Error("Unsupported mcppls module graph format"); + } + return value; +} +export function sourcePath(root: string, file: string): string { + return file.startsWith("file:") ? fileURLToPath(file) : path.resolve(root, file); +} +/** Read entry configuration only; C++ syntax and module resolution remain owned by mcppls. */ +export interface ModuleEntries extends Map { roles: Map; } +export function moduleEntries(root: string, manifest: string, packageName: string, candidates: readonly string[] = []): ModuleEntries { + const entries = new Map() as ModuleEntries; entries.roles = new Map(); + let explicitLib = false; + const add = (file: string, label: string, role: "main" | "library" | "test"): void => { + const previous = entries.get(file); + entries.set(file, previous && previous !== label ? `${previous} / ${label}` : label); entries.roles.set(file, role); + }; + let hasTargets = false; + let section: string[] = [], target: string | undefined, kind: string | undefined, main: string | undefined; + const flush = (): void => { + if (target && (!kind || kind === "bin")) { add(path.resolve(root, main ?? "src/main.cpp"), target, "main"); } + target = kind = main = undefined; + }; + for (const node of parseMcppToml(manifest.split(/\r?\n/)).nodes) { + if (node.type === "section") { + flush(); section = node.segments.map(segment => segment.name); + if (section.length === 2 && section[0] === "targets") { target = section[1]; hasTargets = true; } + } else if (node.value?.kind === "string") { + const key = node.keyPath.map(part => part.name).join("."); + if (section.length === 1 && section[0] === "lib" && key === "path") { explicitLib = true; add(path.resolve(root, node.value.text!), "lib", "library"); } + if (target && key === "kind") { kind = node.value.text; } + if (target && key === "main") { main = node.value.text; } + } + } + flush(); + if (!explicitLib) { add(path.resolve(root, `src/${packageName.split(".").at(-1)}.cppm`), "lib · default", "library"); } + if (!hasTargets) { add(path.resolve(root, "src/main.cpp"), "default", "main"); } + const patterns = testDiscovery(manifest); + for (const file of candidates) { + const relative = path.relative(root, file).split(path.sep).join("/"); + if (isTestSource(relative, patterns)) { add(file, "test · configuration", "test"); } + } + return entries; +} + +/** mcpp path-glob vocabulary: * stays within a segment; ** crosses segments. */ +export function matchesSourceGlob(relative: string, pattern: string): boolean { + let positions = new Uint8Array(relative.length + 1); positions[0] = 1; + for (let i = 0; i < pattern.length; i++) { + const next = new Uint8Array(relative.length + 1); + if (pattern[i] === "*") { + const across = pattern[i + 1] === "*"; + if (across) { i++; if (pattern[i + 1] === "/") { i++; } } + let reachable = false; + for (let at = 0; at <= relative.length; at++) { + reachable ||= !!positions[at]; + if (reachable) { next[at] = 1; } + if (!across && relative[at] === "/") { reachable = false; } + } + } else { + for (let at = 0; at < relative.length; at++) { if (positions[at] && relative[at] === pattern[i]) { next[at + 1] = 1; } } + } + positions = next; + } + return !!positions[relative.length]; +} +/** Candidate generation must be a superset of mcpp's character-based ** semantics. */ +export function testCandidateGlob(pattern: string): string { + const wildcard = pattern.search(/[*?{\[]/); + if (wildcard < 0) { return pattern; } + const slash = pattern.lastIndexOf("/", wildcard); + return (slash < 0 ? "" : pattern.slice(0, slash + 1)) + "**/*"; +} +export function testDiscovery(manifest: string): string[] { + let section = ""; + for (const node of parseMcppToml(manifest.split(/\r?\n/)).nodes) { + if (node.type === "section") { section = node.segments.map(segment => segment.name).join("."); } + else if (section === "test" && node.keyPath.map(part => part.name).join(".") === "discover") { + if (node.value?.kind !== "array" || node.value.open || node.value.elements?.some(value => value.kind !== "string" || !value.text)) { + throw new Error("Invalid test discovery patterns"); + } + return node.value.elements?.map(value => value.text!) ?? []; + } + } + return ["tests/**/*.cpp"]; +} +export function isTestSource(relative: string, patterns: readonly string[]): boolean { + return !relative.startsWith("../") && patterns.some(pattern => !pattern.startsWith("!") && matchesSourceGlob(relative, pattern)) + && !patterns.some(pattern => pattern.startsWith("!") && matchesSourceGlob(relative, pattern.slice(1))); +} + +/** Map already-resolved source paths to project ownership, including nested path packages. */ +export async function moduleOwnership(root: string, files: readonly string[], exists: (file: string) => Promise): Promise> { + const owned = new Set(), cache = new Map>(); + const inside = (file: string): boolean => { const relative = path.relative(root, file); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }; + const directoryOwned = (directory: string): Promise => { + if (directory === root) { return Promise.resolve(true); } + if (!inside(directory)) { return Promise.resolve(false); } + let result = cache.get(directory); + if (!result) { + result = exists(path.join(directory, "mcpp.toml")).then(nested => nested ? false : directoryOwned(path.dirname(directory))); + cache.set(directory, result); + } + return result; + }; + for (const file of files) { if (await directoryOwned(path.dirname(file))) { owned.add(file); } } + return owned; +} + +/** Keep all internal relationships and just one outgoing hop into dependency modules. */ +export function buildModuleGraph(root: string, raw: ModuleGraphJson, owned: ReadonlySet, entries: ReadonlyMap): VisualGraph { + const nodes = new Map(), internal = new Set(); + for (const module of raw.modules) { + const files = module.files.map(file => sourcePath(root, file)); + const local = !module.external && files.some(file => owned.has(file)); + const entry = files.map(file => entries.get(file)).find(Boolean); + const declared = local ? (module.name.includes(":") ? "partition" : "export module") : "external"; + nodes.set(module.name, { id: module.name, label: module.name, kind: local ? (module.name.includes(":") ? "manifest" : "project") : "index", + lines: [entry ? `entry · ${entry} · ${declared}` : declared, ...(files[0] ? [local ? path.relative(root, files[0]) : path.basename(files[0])] : [])], files, entry: !!entry }); + if (local) { internal.add(module.name); } + } + // Ordinary source files (including executable entry points) are first-class importers. + for (const edge of raw.imports) { + if (nodes.has(edge.from)) { continue; } + const file = sourcePath(root, edge.from); + if (!owned.has(file)) { continue; } + const entry = entries.get(file); + nodes.set(edge.from, { id: edge.from, label: path.relative(root, file), lines: [entry ? `entry · ${entry}` : "source"], kind: "project", files: [file], entry: !!entry }); + internal.add(edge.from); + } + const representedFiles = new Set([...nodes.values()].flatMap(node => node.files ?? [])); + for (const [file, entry] of entries) { + if (!owned.has(file) || representedFiles.has(file)) { continue; } + const id = path.relative(root, file); + nodes.set(id, { id, label: id, lines: [`entry · ${entry}`], kind: "project", files: [file], entry: true }); + internal.add(id); + } + const edges = raw.imports.filter(edge => internal.has(edge.from)).map(edge => ({ from: edge.from, to: edge.to })); + for (const edge of edges) { if (!nodes.has(edge.to)) { nodes.set(edge.to, { id: edge.to, label: edge.to, lines: ["unavailable"], kind: "unknown" }); } } + const needed = new Set([...internal, ...edges.map(edge => edge.to)]); + const retained = [...nodes.values()].filter(node => needed.has(node.id)); + // Entries without imports still deserve a visible node when mcppls supplied their file. + const counts = new Map>(); + for (const edge of edges) { const targets = counts.get(edge.from) ?? new Set(); targets.add(edge.to); counts.set(edge.from, targets); } + for (const node of retained) { node.lines.push(`imports ${counts.get(node.id)?.size ?? 0}`); } + const buckets = new Map([["Main", []], ["Library", []], ["Tests", []], ["Other", []]]); + for (const node of retained) { + const entry = node.files?.map(file => entries.get(file)).find(Boolean); + if (entry) { + const role = node.files?.map(file => (entries as Partial).roles?.get(file)).find(Boolean); + buckets.get(role === "test" || (!role && entry.startsWith("test ·")) ? "Tests" : role === "library" || (!role && (entry === "lib" || entry === "lib · default")) ? "Library" : "Main")!.push(node.id); + } + } + const roots = graphRoots(retained, edges, retained.filter(node => node.entry).map(node => node.id)); + const classified = new Set([...buckets.values()].flat()); + buckets.set("Other", roots.filter(id => !classified.has(id))); + const groups = [...buckets].filter(([, ids]) => ids.length).map(([label, ids]) => ({ id: `@group/${label.toLowerCase()}`, label, nodes: ids.sort() })); + return { nodes: retained, edges, roots, groups, truncated: !!raw.truncated || !!raw.snapshot?.indexing || !!raw.snapshot?.preparing }; +} diff --git a/mide-plugins/vscode/src/projects/summary.ts b/mide-plugins/vscode/src/projects/summary.ts new file mode 100644 index 0000000..3ecc978 --- /dev/null +++ b/mide-plugins/vscode/src/projects/summary.ts @@ -0,0 +1,111 @@ +/** + * A project summary read out of `mcpp.toml`, for the project view. + * + * Deliberately shallow and line-oriented: the view shows an identity line and + * the toolchain/targets, so it needs a handful of scalars, not a manifest model. + * Anything it cannot read stays `undefined` and the view simply omits it — the + * authoritative reader is mcpp itself, and `mcpp: Environment Self-check` shows + * what mcpp says. + * + * Pure: no `vscode`, no `mcpp` execution. + */ + +import type { ProjectSummary, TargetSummary } from "../views/models"; + +/** `[section]` or `[section.sub]`, ignoring quotes inside dotted names. */ +function headerOf(line: string): string | undefined { + const match = /^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(line); + if (match === null) { + return undefined; + } + const name = match[1].replace(/^['"]|['"]$/g, "").trim(); + return name.length === 0 ? undefined : name; +} + +/** `key = "value"` where the value is a plain string or a bare word. */ +function scalar(body: string, key: string): string | undefined { + const pattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*=\\s*(.+?)\\s*(?:#.*)?$`); + const match = pattern.exec(body); + if (match === null) { + return undefined; + } + return unquote(match[1]); +} + +function unquote(raw: string): string | undefined { + const trimmed = raw.trim(); + const quoted = /^"(.*)"$/.exec(trimmed) ?? /^'(.*)'$/.exec(trimmed); + const value = quoted === null ? trimmed : quoted[1]; + return value.length === 0 ? undefined : value; +} + +/** Section names whose scalars we read, matched by prefix. */ +const IDENTITY = "package"; +const TOOLCHAIN = "toolchain"; +const TARGETS = "targets."; + +export function readProjectSummary(root: string, lines: readonly string[]): ProjectSummary { + let section: string | undefined; + let name: string | undefined; + let version: string | undefined; + let standard: string | undefined; + let profile: string | undefined; + let toolchainSpec: string | undefined; + let target: string | undefined; + const targets: TargetSummary[] = []; + let currentTarget: { name: string; kind?: string } | undefined; + + const flushTarget = (): void => { + if (currentTarget !== undefined) { + targets.push({ name: currentTarget.name, kind: currentTarget.kind ?? "target" }); + currentTarget = undefined; + } + }; + + for (const line of lines) { + const header = headerOf(line); + if (header !== undefined) { + flushTarget(); + section = header; + if (header.startsWith(TARGETS)) { + currentTarget = { name: header.slice(TARGETS.length).replace(/^['"]|['"]$/g, "") }; + } + continue; + } + if (section === IDENTITY) { + // `[package]` only: a `[target.'cfg(..)'.package]` is a different plane. + name ??= scalar(line, "name"); + version ??= scalar(line, "version"); + standard ??= scalar(line, "standard"); + profile ??= scalar(line, "default_profile"); + } else if (section === TOOLCHAIN) { + toolchainSpec ??= scalar(line, "spec") ?? scalar(line, "family"); + } else if (section === "build") { + profile ??= scalar(line, "profile"); + } else if (currentTarget !== undefined) { + currentTarget.kind ??= scalar(line, "kind"); + } + } + flushTarget(); + + // `[target.]` names the triple; `[targets.]` names an artifact. + for (const line of lines) { + const match = /^\s*\[\s*target\s*\.\s*(?:'([^']+)'|"([^"]+)"|([^\]]+))\s*\]\s*$/.exec(line); + if (match !== null) { + target = (match[1] ?? match[2] ?? match[3] ?? "").trim() || undefined; + break; + } + } + + // Keys are omitted rather than set to undefined, so a summary compares cleanly. + const summary: ProjectSummary = { root }; + if (name !== undefined) summary.name = name; + if (version !== undefined) summary.version = version; + if (standard !== undefined) summary.standard = standard; + if (profile !== undefined) summary.profile = profile; + if (toolchainSpec !== undefined) summary.toolchainSpec = toolchainSpec; + if (target !== undefined) summary.target = target; + if (targets.length > 0) summary.targets = targets; + if (lines.some((line) => headerOf(line) === "test")) summary.hasTests = true; + return summary; +} diff --git a/mide-plugins/vscode/src/run.ts b/mide-plugins/vscode/src/run.ts deleted file mode 100644 index a77bb01..0000000 --- a/mide-plugins/vscode/src/run.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Running: the backend builds the program and answers how it starts (mcpp run --print-launch: argv, -// the environment mcpp adds, the working directory, the runner); the program then runs in a terminal -// of its own, where it reads input and its colours are its own. -import * as vscode from "vscode"; - -import type { Launch } from "./protocol"; -import type { Session } from "./session"; - -export async function runProgram(session: Session, root: string, options: { target?: string; bin?: string } = {}): Promise { - const args = vscode.workspace.getConfiguration().get("mcpp.task.runArgs") ?? []; - let launch: Launch; - try { - launch = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Window, title: session.t("mcpp: building…") }, - () => session.request("run/launch", { root, args, ...(options.target !== undefined ? { target: options.target } : {}), ...(options.bin ? { bin: options.bin } : {}) })); - } catch (e) { - const data = (e as { error?: { data?: { narration?: string } } }).error?.data; - if (data?.narration) { session.output.appendLine(data.narration); session.output.show(true); } - void vscode.window.showErrorMessage(session.t("mcpp: {0}", e instanceof Error ? e.message : String(e))); - return; - } - const [program, ...rest] = launch.argv; - const name = `mcpp run: ${(launch.program ?? program).split(/[\\/]/).pop()}`; - vscode.window.terminals.find((t) => t.name === name)?.dispose(); - const terminal = vscode.window.createTerminal({ name, shellPath: program, shellArgs: rest, cwd: launch.cwd, env: launch.env }); - terminal.show(!(vscode.workspace.getConfiguration().get("mcpp.task.focusTerminal") ?? true)); -} diff --git a/mide-plugins/vscode/src/session.ts b/mide-plugins/vscode/src/session.ts deleted file mode 100644 index 7517119..0000000 --- a/mide-plugins/vscode/src/session.ts +++ /dev/null @@ -1,183 +0,0 @@ -// The session with the backend: started at activation, given the editor's settings, trust, -// language and folders, kept current as they change, and restarted (once, then on request) when it -// ends. Everything else in the adapter reaches the backend through `session.request`. -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as vscode from "vscode"; - -import { Backend, BackendError } from "./backend/client"; -import { locateBackend, type Located } from "./backend/locate"; -import { mcpplsCandidates } from "./backend/mcppls"; -import { format, MCPPLS_EXTENSION } from "./mapping"; -import type { CommandEntry, InitializeResult } from "./protocol"; - -export class Session implements vscode.Disposable { - private backend: Backend | undefined; - private located: Located | undefined; - private initResult: InitializeResult | undefined; - private bundle: Record = {}; - private commandTable: CommandEntry[] = []; - private restarts = 0; - private starting: Promise | undefined; - private readonly disposables: vscode.Disposable[] = []; - private readonly listeners = new Map void>>(); - readonly onDidStart = new vscode.EventEmitter(); - - constructor(readonly context: vscode.ExtensionContext, readonly output: vscode.OutputChannel) { - // The strings the extension carries (spec/i18n/runtime.zh-cn.json): what is said before the - // backend runs, or when it cannot. - if (vscode.env.language.toLowerCase().startsWith("zh")) { - try { - this.bundle = JSON.parse(fs.readFileSync(path.join(context.extensionPath, "i18n", "runtime.zh-cn.json"), "utf8")); - } catch { - this.bundle = {}; - } - } - this.disposables.push( - this.onDidStart, - vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("mcpp.ide.backendPath")) { void this.restart(); return; } - if (e.affectsConfiguration("mcpp")) void this.pushSettings(); - }), - vscode.workspace.onDidGrantWorkspaceTrust(() => void this.pushSettings()), - vscode.workspace.onDidChangeWorkspaceFolders(() => void this.restart()), - vscode.extensions.onDidChange(() => void this.request("languageService/status", { candidates: this.mcpplsCandidates() }).catch(() => undefined)), - ); - } - - /** The backend's command and where it was found. */ - get started(): Located | undefined { - return this.located; - } - - get info(): InitializeResult | undefined { - return this.initResult; - } - - get commands(): CommandEntry[] { - return this.commandTable; - } - - /** A runtime string in the session's language. */ - t(key: string, ...args: unknown[]): string { - return format(this.bundle[key] ?? key, args); - } - - /** The bundle for webviews, which translate on their own. */ - get strings(): Record { - return this.bundle; - } - - /** Every mcpp.* value, as VS Code resolves it for the window. */ - settings(): Record { - const out: Record = {}; - const props = (this.context.extension.packageJSON?.contributes?.configuration?.properties ?? {}) as Record; - const config = vscode.workspace.getConfiguration(); - for (const key of Object.keys(props)) out[key] = config.get(key); - return out; - } - - mcpplsCandidates(): string[] { - return mcpplsCandidates(vscode.extensions.getExtension(MCPPLS_EXTENSION)?.extensionPath); - } - - on(method: string, fn: (params: any) => void): vscode.Disposable { - if (!this.listeners.has(method)) this.listeners.set(method, new Set()); - this.listeners.get(method)!.add(fn); - return new vscode.Disposable(() => this.listeners.get(method)?.delete(fn)); - } - - async start(): Promise { - if (this.starting) return this.starting; - this.starting = this.doStart().finally(() => { this.starting = undefined; }); - return this.starting; - } - - private async doStart(): Promise { - const configured = vscode.workspace.getConfiguration().get("mcpp.ide.backendPath") ?? ""; - this.located = locateBackend({ configured: vscode.workspace.isTrusted ? configured : "", extensionRoot: this.context.extensionPath }); - if (!this.located) { - const message = this.t("The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or set mcpp.ide.backendPath."); - this.output.appendLine(`mcpp: ${message}`); - const open = this.t("Open Settings"); - void vscode.window.showErrorMessage(`mcpp: ${message}`, open).then((c) => { - if (c === open) void vscode.commands.executeCommand("workbench.action.openSettings", "mcpp.ide.backendPath"); - }); - return false; - } - this.output.appendLine(`mcpp-ide: starting ${[this.located.program, ...this.located.args].join(" ")} (${this.located.source})`); - const backend = new Backend({ - program: this.located.program, - args: [...this.located.args, "serve"], - log: (line) => this.output.appendLine(`[backend] ${line}`), - }); - this.backend = backend; - backend.notifications.on("*", (method: string, params: unknown) => { - for (const fn of this.listeners.get(method) ?? []) { - try { fn(params); } catch (e) { this.output.appendLine(`mcpp: ${method}: ${String(e)}`); } - } - for (const fn of this.listeners.get("*") ?? []) fn({ method, params }); - }); - void backend.exited.then((code) => this.onExit(backend, code)); - try { - const folders = (vscode.workspace.workspaceFolders ?? []).map((f) => f.uri.fsPath); - this.initResult = await backend.request("initialize", { - client: { name: "VS Code", version: vscode.version }, - editor: "vscode", - locale: vscode.env.language, - trusted: vscode.workspace.isTrusted, - settings: this.settings(), - workspaceFolders: folders, - languageService: { candidates: this.mcpplsCandidates() }, - }); - this.bundle = await backend.request>("i18n/bundle", {}); - this.commandTable = (await backend.request<{ commands: CommandEntry[] }>("commands/list", { editor: "vscode" })).commands; - for (const c of this.initResult.checks) if (c.status === "error") this.output.appendLine(`mcpp: ${c.message}`); - this.onDidStart.fire(this.initResult); - return true; - } catch (e) { - this.output.appendLine(`mcpp-ide: initialize failed: ${String(e)}`); - return false; - } - } - - private onExit(backend: Backend, code: number | null): void { - if (backend !== this.backend) return; - this.backend = undefined; - this.output.appendLine(`mcpp-ide: the backend ended (${code ?? "signal"})`); - if (this.restarts < 1) { - ++this.restarts; - void this.start(); - return; - } - const again = this.t("Restart"); - void vscode.window.showErrorMessage(`mcpp: ${this.t("The mcpp-ide backend ended.")}`, again).then((c) => { - if (c === again) { this.restarts = 0; void this.start(); } - }); - } - - async restart(): Promise { - const old = this.backend; - this.backend = undefined; - if (old) await old.stop(); - this.restarts = 0; - await this.start(); - } - - async request(method: string, params: unknown = {}): Promise { - if (!this.backend?.running) { - if (!(await this.start())) throw new BackendError(method, { code: -32099, message: this.t("the mcpp-ide backend is not running") }); - } - return this.backend!.request(method, params); - } - - async pushSettings(): Promise { - if (!this.backend?.running) return; - await this.backend.request("settings/update", { settings: this.settings(), trusted: vscode.workspace.isTrusted, locale: vscode.env.language }); - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - void this.backend?.stop(); - } -} diff --git a/mide-plugins/vscode/src/status.ts b/mide-plugins/vscode/src/status.ts deleted file mode 100644 index 458c8be..0000000 --- a/mide-plugins/vscode/src/status.ts +++ /dev/null @@ -1,49 +0,0 @@ -// The status bar: the target the project builds for (and its platform), and whether a job runs. -// A click on the first selects another target; on the second, cancels the running jobs. -import * as vscode from "vscode"; - -import type { Jobs } from "./jobs"; -import type { Projects } from "./projects"; -import type { Session } from "./session"; - -export class StatusBar implements vscode.Disposable { - private readonly target = vscode.window.createStatusBarItem("mcpp.target", vscode.StatusBarAlignment.Left, 50); - private readonly job = vscode.window.createStatusBarItem("mcpp.job", vscode.StatusBarAlignment.Left, 49); - private readonly disposables: vscode.Disposable[] = [this.target, this.job]; - private platformText = ""; - - constructor(private readonly session: Session, private readonly projects: Projects, private readonly jobs: Jobs) { - this.target.name = "mcpp target"; - this.target.command = "mcpp.selectTarget"; - this.job.name = "mcpp job"; - this.job.command = "mcpp.cancelJob"; - this.disposables.push( - projects.onDidChange.event(() => this.update()), - jobs.onDidChangeRunning.event(() => this.update()), - session.on("job/plan", (p: { root: string; platformText?: string }) => { - if (p.root === projects.project?.root) { this.platformText = p.platformText ?? ""; this.update(); } - }), - vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("mcpp.build.target") || e.affectsConfiguration("mcpp.ui.statusBar.show")) this.update(); }), - ); - this.update(); - } - - private update(): void { - const t = (k: string, ...a: unknown[]) => this.session.t(k, ...a); - const show = vscode.workspace.getConfiguration().get("mcpp.ui.statusBar.show") ?? true; - if (!show || !this.projects.project) { this.target.hide(); this.job.hide(); return; } - const target = vscode.workspace.getConfiguration().get("mcpp.build.target") || t("this machine"); - this.target.text = `$(target) ${target}`; - this.target.tooltip = [t("The target build, run, test, debug and pack use"), this.platformText].filter(Boolean).join("\n"); - this.target.show(); - if (this.jobs.runningCount > 0) { - this.job.text = `$(sync~spin) mcpp`; - this.job.tooltip = t("{0} job(s) running", this.jobs.runningCount); - this.job.show(); - } else this.job.hide(); - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/tasks.ts b/mide-plugins/vscode/src/tasks.ts deleted file mode 100644 index 952bfc8..0000000 --- a/mide-plugins/vscode/src/tasks.ts +++ /dev/null @@ -1,115 +0,0 @@ -// mcpp's builds and tests as VS Code tasks (type "mcpp"): each runs as a backend job in a -// pseudoterminal that shows mcpp's own narration, in colour, and ends with the job's exit code. -// Closing the terminal cancels the job; mcpp then ends the steps it started and records the build as -// cancelled. A task's problems come from mcpp's diagnostics (diagnostics.ts), not from a matcher. -import * as vscode from "vscode"; - -import type { Jobs, JobHandle } from "./jobs"; -import type { Projects } from "./projects"; -import type { Session } from "./session"; - -export interface McppTaskDefinition extends vscode.TaskDefinition { - type: "mcpp"; - command: "build" | "test" | "run" | "clean"; - target?: string; - profile?: "dev" | "release"; - args?: string[]; - root?: string; -} - -/** A terminal that renders one backend job. */ -export class JobTerminal implements vscode.Pseudoterminal { - private readonly writeEmitter = new vscode.EventEmitter(); - private readonly closeEmitter = new vscode.EventEmitter(); - readonly onDidWrite = this.writeEmitter.event; - readonly onDidClose = this.closeEmitter.event; - private handle: JobHandle | undefined; - private ended = false; - - constructor(private readonly start: (term: JobTerminal) => Promise, private readonly session: Session) {} - - print(text: string): void { - this.writeEmitter.fire(text.replace(/\r?\n/g, "\r\n")); - } - - end(code: number): void { - if (this.ended) return; - this.ended = true; - this.closeEmitter.fire(code); - } - - open(): void { - if (vscode.workspace.getConfiguration().get("mcpp.task.clearTerminal")) this.writeEmitter.fire("\x1bc"); - this.start(this).then((h) => { this.handle = h; if (!h) this.end(1); }, (e) => { - this.print(`\x1b[31m${this.session.t("mcpp: {0}", e instanceof Error ? e.message : String(e))}\x1b[0m\n`); - this.end(1); - }); - } - - close(): void { - // The terminal closed while the job runs: ask mcpp to end it. - if (!this.ended && this.handle) void this.handle.cancel(); - } -} - -export class Tasks implements vscode.TaskProvider, vscode.Disposable { - private readonly registration: vscode.Disposable; - - constructor(private readonly session: Session, private readonly jobs: Jobs, private readonly projects: Projects) { - this.registration = vscode.tasks.registerTaskProvider("mcpp", this); - } - - provideTasks(): vscode.Task[] { - const root = this.projects.project?.root; - if (!root) return []; - return (["build", "test"] as const).map((command) => this.task({ type: "mcpp", command }, root)); - } - - resolveTask(task: vscode.Task): vscode.Task | undefined { - const def = task.definition as McppTaskDefinition; - return this.task(def, def.root || this.projects.project?.root || "", task.scope); - } - - task(def: McppTaskDefinition, root: string, scope?: vscode.WorkspaceFolder | vscode.TaskScope): vscode.Task { - const folder = scope ?? vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)) ?? vscode.TaskScope.Workspace; - const execution = new vscode.CustomExecution(async () => new JobTerminal((term) => this.run(def, root, term), this.session)); - const task = new vscode.Task(def, folder, def.command, "mcpp", execution, []); - task.group = def.command === "build" ? vscode.TaskGroup.Build : def.command === "test" ? vscode.TaskGroup.Test : undefined; - const reveal = vscode.workspace.getConfiguration().get("mcpp.task.revealTerminal"); - task.presentationOptions = { - reveal: reveal === "never" ? vscode.TaskRevealKind.Never : reveal === "silent" ? vscode.TaskRevealKind.Silent : vscode.TaskRevealKind.Always, - focus: vscode.workspace.getConfiguration().get("mcpp.task.focusTerminal") ?? false, - clear: false, - }; - return task; - } - - private async run(def: McppTaskDefinition, root: string, term: JobTerminal): Promise { - if (!root) { term.print(`${this.session.t("No mcpp project in this workspace")}\n`); return undefined; } - const params: Record = { kind: def.command === "test" ? "test" : "build", root, color: true, supersede: true }; - if (def.target !== undefined) params.target = def.target; - if (def.profile) params.profile = def.profile; - if (def.args?.length) params.args = def.args; - const handle = await this.jobs.start(params, { - output: (text) => term.print(text), - testResult: (r) => { - const mark = r.status === "passed" ? "\x1b[32m✓\x1b[0m" : r.status === "skipped" ? "-" : "\x1b[31m✗\x1b[0m"; - term.print(`${mark} ${r.test} (${r.durationMs} ms)\n`); - }, - finished: (p) => { - term.print(`\n${p.status === "ok" ? "\x1b[32m" : "\x1b[31m"}${this.session.t("mcpp {0}: {1}", def.command, p.status)}\x1b[0m\n`); - term.end(p.exitCode ?? (p.status === "ok" ? 0 : 1)); - }, - }); - return handle; - } - - /** Runs a build or test task now, as the commands do. */ - async execute(command: "build" | "test", root: string, extra: Partial = {}): Promise { - return vscode.tasks.executeTask(this.task({ type: "mcpp", command, ...extra }, root)); - } - - dispose(): void { - this.registration.dispose(); - } -} diff --git a/mide-plugins/vscode/src/testing.ts b/mide-plugins/vscode/src/testing.ts deleted file mode 100644 index e853f65..0000000 --- a/mide-plugins/vscode/src/testing.ts +++ /dev/null @@ -1,94 +0,0 @@ -// mcpp's tests in VS Code's Testing view. The tests of each project come from `test/list` (mcpp -// test --list: no build), each item located at the file that holds its `main`; a run is one test -// job, filtered to the test asked for, and each `test/result` marks its item. -import * as vscode from "vscode"; - -import type { Jobs } from "./jobs"; -import { parseTestId, testId } from "./mapping"; -import type { Projects } from "./projects"; -import type { Session } from "./session"; -import type { TestResult } from "./protocol"; - -export class Testing implements vscode.Disposable { - private readonly controller = vscode.tests.createTestController("mcpp", "mcpp"); - private readonly disposables: vscode.Disposable[] = [this.controller]; - - constructor(private readonly session: Session, private readonly jobs: Jobs, private readonly projects: Projects) { - this.controller.resolveHandler = async (item) => { if (!item) await this.discover(); }; - this.controller.refreshHandler = async () => { await this.discover(); }; - this.controller.createRunProfile(session.t("Run"), vscode.TestRunProfileKind.Run, (request, token) => this.run(request, token), true); - this.disposables.push( - session.on("manifest/changed", () => void this.discover()), - session.on("result/changed", () => void this.discover()), - session.onDidStart.event(() => void this.discover()), - ); - } - - async discover(): Promise { - for (const project of this.projects.all) { - let tests: { test: string; member: string; main: string }[] = []; - try { - tests = (await this.session.request<{ tests: { test: string; member: string; main: string }[] }>("test/list", { root: project.root })).tests; - } catch { - continue; - } - let group = this.controller.items.get(project.root); - if (!group) { - group = this.controller.createTestItem(project.root, project.name, vscode.Uri.file(project.root)); - this.controller.items.add(group); - } - const items = tests.map((t) => { - const item = this.controller.createTestItem(testId(project.root, t.member, t.test), t.member ? `${t.member} · ${t.test}` : t.test, - t.main ? vscode.Uri.file(t.main) : undefined); - if (t.main) item.range = new vscode.Range(0, 0, 0, 0); - return item; - }); - group.children.replace(items); - } - } - - private async run(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { - const run = this.controller.createTestRun(request); - // One job per project; a single test asked for becomes the job's filter. - const byRoot = new Map(); - const collect = (item: vscode.TestItem) => { - const id = parseTestId(item.id); - if (!id) { item.children.forEach(collect); return; } - if (request.exclude?.includes(item)) return; - if (!byRoot.has(id.root)) byRoot.set(id.root, []); - byRoot.get(id.root)!.push(item); - }; - if (request.include) request.include.forEach(collect); - else this.controller.items.forEach(collect); - for (const [root, items] of byRoot) { - if (token.isCancellationRequested) break; - items.forEach((i) => run.enqueued(i)); - 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")), - testResult: (r: TestResult) => { - const item = index.get(r.test); - if (!item) return; - const ms = r.durationMs; - 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); - if (r.status === "errored") run.errored(item, message, ms); else run.failed(item, message, ms); - } - }, - }); - const cancel = token.onCancellationRequested(() => void handle.cancel()); - const done = await handle.done; - cancel.dispose(); - if (done.status !== "ok" && done.status !== "failed") - for (const i of items) run.skipped(i); - } - run.end(); - } - - dispose(): void { - this.disposables.forEach((d) => d.dispose()); - } -} diff --git a/mide-plugins/vscode/src/toml.ts b/mide-plugins/vscode/src/toml.ts deleted file mode 100644 index ab6f369..0000000 --- a/mide-plugins/vscode/src/toml.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Editing help for mcpp.toml and build.mcpp, answered by the backend from mcpp's own key registry -// and build-program directives (toml/complete, toml/hover, toml/symbols, buildscript/complete). -// Text analysis only: no mcpp process runs, so it works in an untrusted workspace too. Whether the -// manifest is valid is mcpp's to say (diagnostics.ts). -import * as path from "node:path"; -import * as vscode from "vscode"; - -import type { Session } from "./session"; - -interface Item { - label: string; - kind?: string; - detail?: string; - documentation?: string; - insertText?: string; - snippet?: boolean; - range?: { start: { line: number; character: number }; end: { line: number; character: number } }; -} - -const KINDS: Record = { - section: vscode.CompletionItemKind.Module, - table: vscode.CompletionItemKind.Module, - key: vscode.CompletionItemKind.Property, - value: vscode.CompletionItemKind.Value, - enum: vscode.CompletionItemKind.EnumMember, - function: vscode.CompletionItemKind.Function, - template: vscode.CompletionItemKind.Snippet, - snippet: vscode.CompletionItemKind.Snippet, -}; - -function toItem(i: Item): vscode.CompletionItem { - const item = new vscode.CompletionItem(i.label, KINDS[i.kind ?? "key"] ?? vscode.CompletionItemKind.Text); - item.detail = i.detail; - if (i.documentation) item.documentation = new vscode.MarkdownString(i.documentation); - if (i.insertText) item.insertText = i.snippet ? new vscode.SnippetString(i.insertText) : i.insertText; - if (i.range) item.range = new vscode.Range(i.range.start.line, i.range.start.character, i.range.end.line, i.range.end.character); - return item; -} - -export function registerToml(context: vscode.ExtensionContext, session: Session): void { - const enabled = (key: string) => vscode.workspace.getConfiguration().get(key) ?? true; - const params = (doc: vscode.TextDocument, pos: vscode.Position) => ({ text: doc.getText(), line: pos.line, character: pos.character }); - context.subscriptions.push( - vscode.languages.registerCompletionItemProvider({ language: "mcpp-toml" }, { - async provideCompletionItems(doc, pos) { - if (!enabled("mcpp.toml.completion")) return []; - const r = await session.request<{ items: Item[] }>("toml/complete", params(doc, pos)).catch(() => ({ items: [] as Item[] })); - return r.items.map(toItem); - }, - }, "[", ".", "=", "\"", " "), - vscode.languages.registerHoverProvider({ language: "mcpp-toml" }, { - async provideHover(doc, pos) { - if (!enabled("mcpp.toml.hover")) return undefined; - const r = await session.request<{ contents: string } | null>("toml/hover", params(doc, pos)).catch(() => null); - return r ? new vscode.Hover(new vscode.MarkdownString(r.contents)) : undefined; - }, - }), - vscode.languages.registerDocumentSymbolProvider({ language: "mcpp-toml" }, { - async provideDocumentSymbols(doc) { - if (!enabled("mcpp.toml.navigation")) return []; - type Sym = { name: string; kind: string; line: number; children?: Sym[] }; - const r = await session.request("toml/symbols", { text: doc.getText() }).catch(() => [] as Sym[]); - const convert = (s: Sym): vscode.DocumentSymbol => { - const line = doc.lineAt(Math.min(s.line, doc.lineCount - 1)).range; - const sym = new vscode.DocumentSymbol(s.name, "", s.kind === "table" ? vscode.SymbolKind.Namespace : vscode.SymbolKind.Property, line, line); - sym.children = (s.children ?? []).map(convert); - return sym; - }; - return r.map(convert); - }, - }), - // build.mcpp is C++ (mcppls answers it); only `mcpp::` directives are completed here. - vscode.languages.registerCompletionItemProvider({ language: "cpp", pattern: "**/build.mcpp" }, { - async provideCompletionItems(doc, pos) { - if (!enabled("mcpp.buildScript.intelligence") || path.basename(doc.fileName) !== "build.mcpp") return []; - const r = await session.request<{ items: Item[] }>("buildscript/complete", params(doc, pos)).catch(() => ({ items: [] as Item[] })); - return r.items.map(toItem); - }, - }, ":"), - ); -} diff --git a/mide-plugins/vscode/src/toml/parser.ts b/mide-plugins/vscode/src/toml/parser.ts new file mode 100644 index 0000000..25a249e --- /dev/null +++ b/mide-plugins/vscode/src/toml/parser.ts @@ -0,0 +1,506 @@ +// mcpp.toml read tolerantly, for the views that list what a manifest declares (its dependencies, +// its targets): the structure an edit in progress already has, with exact 0-based ranges, never an +// exception for a half-typed `[`, string, array or inline table. The rules layer -- completion, +// hover, the jumps, the diagnostics -- is the mcpp-ide backend's (design 2026-10-30 part 2 §8), whose +// scanner answers the same structure (its tests/rules goldens were written by this one). +// +// The subset read: table headers (`[[...]]` arrays of tables, quoted segments), bare, quoted and +// dotted keys, strings (`"""` and `'''` multi-line ones too), integers, booleans, arrays and inline +// tables (nested, across lines), and comments. A CRLF line end is dropped before the scan; columns +// count the text without it. + +/** 0 基行列范围,end 为开区间(与 VS Code Range 同构)。 */ +export interface TomlRange { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; +} + +/** 键/段路径中的一段:已去引号的名字 + 原始 token 的范围(含引号)。 */ +export interface TomlKeySegment { + name: string; + quoted: boolean; + range: TomlRange; +} + +export type TomlValueKind = "string" | "integer" | "boolean" | "array" | "inlineTable" | "unknown"; + +/** 值节点。open = true 表示结构未闭合(输入到一半),range 延伸到文档末尾。 */ +export interface TomlValueNode { + kind: TomlValueKind; + range: TomlRange; + /** 未闭合(字符串缺右引号、数组缺 ]、内联表缺 })。 */ + open: boolean; + /** 标量原文:字符串为去引号内容(未做转义还原),整数/布尔为原文 token。 */ + text?: string; + /** 字符串节点:引号字符。 */ + quote?: '"' | "'"; + /** 字符串节点:是否三引号多行串。 */ + multiline?: boolean; + /** 字符串节点:内容范围(不含引号;未闭合时延伸到扫描终点)。 */ + contentRange?: TomlRange; + /** 数组节点:已解析出的元素。 */ + elements?: TomlValueNode[]; + /** 内联表节点:已解析出的键值条目。 */ + entries?: TomlKeyValueNode[]; +} + +/** 键值对节点:keyPath 为点分键拆出的段数组(如 capi.lua → [capi, lua])。 */ +export interface TomlKeyValueNode { + type: "keyValue"; + keyPath: TomlKeySegment[]; + range: TomlRange; + /** 缺省表示 `=` 之后没有可用值(含尚未输入的情况)。 */ + value?: TomlValueNode; +} + +/** 段头节点。open = true 表示 `]` 尚未输入。 */ +export interface TomlSectionNode { + type: "section"; + /** 段路径段(已去引号,如 [target.'cfg(windows)'.build] → target / cfg(windows) / build)。 */ + segments: TomlKeySegment[]; + /** 是否为 [[...]] 数组表段头。 */ + isArray: boolean; + open: boolean; + range: TomlRange; + /** 段头所在行(0 基)。 */ + line: number; +} + +export type TomlNode = TomlSectionNode | TomlKeyValueNode; + +export interface TomlDocument { + nodes: TomlNode[]; +} + +interface Pos { + line: number; + col: number; +} + +function isBareKeyChar(ch: string): boolean { + return ( + (ch >= "a" && ch <= "z") || + (ch >= "A" && ch <= "Z") || + (ch >= "0" && ch <= "9") || + ch === "_" || + ch === "-" + ); +} + +/** 逐行容错扫描器。行列均为 0 基,列按剥除 \r 后的文本计算。 */ +class Scanner { + private readonly lines: string[]; + private line = 0; + private col = 0; + + constructor(lines: readonly string[]) { + this.lines = lines.map((text) => (text.endsWith("\r") ? text.slice(0, -1) : text)); + if (this.lines.length === 0) { + this.lines.push(""); + } + } + + private pos(): Pos { + return { line: this.line, col: this.col }; + } + + private eof(): boolean { + return this.line >= this.lines.length; + } + + private atEol(): boolean { + return this.eof() || this.col >= this.lines[this.line].length; + } + + private peek(): string { + return this.atEol() ? "" : this.lines[this.line][this.col]; + } + + private peekAt(offset: number): string { + if (this.eof()) { + return ""; + } + const text = this.lines[this.line]; + return this.col + offset < text.length ? text[this.col + offset] : ""; + } + + private advance(): void { + if (this.eof()) { + return; + } + this.col += 1; + if (this.col > this.lines[this.line].length) { + this.line += 1; + this.col = 0; + } + } + + private samePos(a: Pos, b: Pos): boolean { + return a.line === b.line && a.col === b.col; + } + + private skipInlineWs(): void { + while (this.peek() === " " || this.peek() === "\t") { + this.col += 1; + } + } + + /** 跳过空白、换行与行注释(# 到行尾;字符串内的 # 不会走到这里)。 */ + private skipTrivia(): void { + while (!this.eof()) { + const ch = this.peek(); + if (ch === " " || ch === "\t") { + this.col += 1; + continue; + } + if (this.atEol()) { + this.line += 1; + this.col = 0; + continue; + } + if (ch === "#") { + while (!this.atEol()) { + this.col += 1; + } + continue; + } + break; + } + } + + private rangeFrom(start: Pos): TomlRange { + return { startLine: start.line, startCharacter: start.col, endLine: this.line, endCharacter: this.col }; + } + + private sliceText(a: Pos, b: Pos): string { + // A scan that ran to the end of the document (an unclosed multi-line string) ends one line past + // the last; its text ends where the last line does. + if (b.line >= this.lines.length) { + const last = this.lines.length - 1; + b = { line: last, col: this.lines[last].length }; + if (a.line > last) { + return ""; + } + } + if (a.line === b.line) { + return this.lines[a.line].slice(a.col, b.col); + } + const parts = [this.lines[a.line].slice(a.col)]; + for (let l = a.line + 1; l < b.line; l += 1) { + parts.push(this.lines[l]); + } + parts.push(this.lines[b.line].slice(0, b.col)); + return parts.join("\n"); + } + + scan(): TomlNode[] { + const nodes: TomlNode[] = []; + while (!this.eof()) { + this.skipTrivia(); + if (this.eof()) { + break; + } + const start = this.pos(); + if (this.peek() === "[") { + nodes.push(this.scanHeader(start)); + } else { + const keyValue = this.scanKeyValue(); + if (keyValue !== undefined) { + nodes.push(keyValue); + } + } + // 进度保护:任何无法识别的垃圾字符都必须被消费,保证容错扫描必然终止。 + if (this.samePos(this.pos(), start)) { + this.advance(); + } + } + return nodes; + } + + /** 段头:[a.b] / [[a.b]] / 引号段;未闭合(缺 ])时 open = true 并跳过该行剩余内容。 */ + private scanHeader(start: Pos): TomlSectionNode { + this.advance(); // [ + let isArray = false; + if (this.peek() === "[") { + isArray = true; + this.advance(); + } + const segments: TomlKeySegment[] = []; + let open = true; + for (;;) { + this.skipInlineWs(); + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + if (this.peek() === "]") { + this.advance(); + if (isArray && this.peek() === "]") { + this.advance(); + } + // 容错:[[x] 只写了一个 ] 也视为已闭合。 + open = false; + } + if (open) { + // 未闭合段头:跳过该行剩余内容,避免残余字符被当作下一条语句。 + while (!this.atEol()) { + this.col += 1; + } + } + return { type: "section", segments, isArray, open, range: this.rangeFrom(start), line: start.line }; + } + + /** 单个键/段路径段:裸键或单/双引号键(引号键跨行即按未闭合截断)。 */ + private scanKeySegment(): TomlKeySegment | undefined { + const start = this.pos(); + const quote = this.peek(); + if (quote === '"' || quote === "'") { + this.advance(); + const contentStart = this.pos(); + while (!this.atEol() && this.peek() !== quote) { + // 双引号键内的转义(如 \")不结束键。 + if (quote === '"' && this.peek() === "\\" && this.col + 1 < this.lines[this.line].length) { + this.col += 2; + continue; + } + this.col += 1; + } + const contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + } + return { name: this.sliceText(contentStart, contentEnd), quoted: true, range: this.rangeFrom(start) }; + } + if (!isBareKeyChar(quote)) { + return undefined; + } + while (isBareKeyChar(this.peek())) { + this.col += 1; + } + return { name: this.sliceText(start, this.pos()), quoted: false, range: this.rangeFrom(start) }; + } + + /** 键值对:点分键 [= 值]。无法识别出键也没有值时返回 undefined。 */ + private scanKeyValue(): TomlKeyValueNode | undefined { + const start = this.pos(); + const segments: TomlKeySegment[] = []; + for (;;) { + this.skipInlineWs(); + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + let value: TomlValueNode | undefined; + if (this.peek() === "=") { + this.advance(); + this.skipInlineWs(); + // 值必须在同行开始(TOML 本就如此);行尾没有值则按缺失处理。 + if (!this.atEol() && this.peek() !== "#") { + value = this.scanValue(); + } + } + if (segments.length === 0 && value === undefined) { + return undefined; + } + return { type: "keyValue", keyPath: segments, range: this.rangeFrom(start), value }; + } + + private scanValue(): TomlValueNode { + const ch = this.peek(); + if (ch === '"' || ch === "'") { + return this.scanStringValue(); + } + if (ch === "[") { + return this.scanArrayValue(); + } + if (ch === "{") { + return this.scanInlineTableValue(); + } + return this.scanBareValue(); + } + + /** 字符串:单/双引号单行串与三引号多行串;未闭合时 open = true。 */ + private scanStringValue(): TomlValueNode { + const start = this.pos(); + const quote = this.peek() as '"' | "'"; + this.advance(); + let multiline = false; + let open = true; + let contentStart = this.pos(); + let contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + if (this.peek() === quote) { + multiline = true; + this.advance(); + contentStart = this.pos(); + } else { + open = false; + } + } + if (open) { + while (!this.eof()) { + if (!multiline && this.atEol()) { + break; + } + const ch = this.peek(); + if (ch === "\\" && quote === '"') { + this.advance(); + if (!this.eof() && (multiline || !this.atEol())) { + this.advance(); + } + continue; + } + if (ch === quote) { + if (multiline) { + if (this.peekAt(1) === quote && this.peekAt(2) === quote) { + contentEnd = this.pos(); + this.advance(); + this.advance(); + this.advance(); + open = false; + break; + } + this.advance(); + continue; + } + contentEnd = this.pos(); + this.advance(); + open = false; + break; + } + this.advance(); + } + if (open) { + contentEnd = this.pos(); + } + } + return { + kind: "string", + range: this.rangeFrom(start), + open, + text: this.sliceText(contentStart, contentEnd), + quote, + multiline, + contentRange: { + startLine: contentStart.line, + startCharacter: contentStart.col, + endLine: contentEnd.line, + endCharacter: contentEnd.col, + }, + }; + } + + /** 数组:可跨行,元素递归解析;缺 ] 时 open = true。 */ + private scanArrayValue(): TomlValueNode { + const start = this.pos(); + this.advance(); // [ + const elements: TomlValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "]") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const elementStart = this.pos(); + elements.push(this.scanValue()); + if (this.samePos(this.pos(), elementStart)) { + this.advance(); + } + } + return { kind: "array", range: this.rangeFrom(start), open, elements }; + } + + /** 内联表:可嵌套、可跨行,条目是完整键值对;缺 } 时 open = true。 */ + private scanInlineTableValue(): TomlValueNode { + const start = this.pos(); + this.advance(); // { + const entries: TomlKeyValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "}") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const entryStart = this.pos(); + const entry = this.scanKeyValue(); + if (entry !== undefined) { + entries.push(entry); + } + if (this.samePos(this.pos(), entryStart)) { + this.advance(); + } + } + return { kind: "inlineTable", range: this.rangeFrom(start), open, entries }; + } + + /** 裸值 token:布尔、整数,其余归为 unknown(容错,不校验合法性)。 */ + private scanBareValue(): TomlValueNode { + const start = this.pos(); + let end = this.pos(); + while (!this.atEol()) { + const ch = this.peek(); + if (ch === "," || ch === "]" || ch === "}" || ch === "[" || ch === "{" || ch === "#") { + break; + } + this.col += 1; + if (ch !== " " && ch !== "\t") { + end = this.pos(); + } + } + const text = this.sliceText(start, end); + let kind: TomlValueKind = "unknown"; + if (text === "true" || text === "false") { + kind = "boolean"; + } else if (/^[+-]?\d[\d_]*$/.test(text)) { + kind = "integer"; + } + const range: TomlRange = { startLine: start.line, startCharacter: start.col, endLine: end.line, endCharacter: end.col }; + return { kind, range, open: false, text }; + } +} + +/** 容错解析整个文档,产出带 0 基行列范围的节点树。永不抛异常。 */ +export function parseMcppToml(lines: readonly string[]): TomlDocument { + return { nodes: new Scanner(lines).scan() }; +} diff --git a/mide-plugins/vscode/src/util/format.ts b/mide-plugins/vscode/src/util/format.ts new file mode 100644 index 0000000..d4a03b9 --- /dev/null +++ b/mide-plugins/vscode/src/util/format.ts @@ -0,0 +1,153 @@ +/** + * Byte / count formatting for the cache views and the status bar. + * + * Pure: no `vscode`, no i18n. The unit suffixes (B/KiB/MiB/GiB/TiB) are the same + * in every language this extension speaks, so they are not translated; the + * surrounding sentence is (`src/i18n/t.ts`). + */ + +export type NumberFormat = "binary" | "decimal"; + +const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; +const DECIMAL_UNITS = ["B", "kB", "MB", "GB", "TB", "PB"] as const; + +function significant(value: number): string { + if (!Number.isFinite(value)) { + return "0"; + } + const abs = Math.abs(value); + if (abs >= 100) { + return value.toFixed(0); + } + if (abs >= 10) { + return value.toFixed(1); + } + return value.toFixed(2); +} + +/** + * `1500000` -> `"1.43 MiB"` (binary) / `"1.50 MB"` (decimal). + * + * Three significant digits, matching what the plan promises for the cache views: + * big numbers stay readable and small ones keep two decimals. + */ +export function formatBytes(bytes: number, format: NumberFormat = "binary"): string { + const units = format === "decimal" ? DECIMAL_UNITS : BINARY_UNITS; + const step = format === "decimal" ? 1000 : 1024; + let value = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; + let index = 0; + while (value >= step && index < units.length - 1) { + value /= step; + index += 1; + } + return `${significant(value)} ${units[index]}`; +} + +/** `12345` -> `"12,345"`. Grouping follows the host locale. */ +export function formatCount(value: number): string { + return Number.isFinite(value) ? Math.round(value).toLocaleString() : "0"; +} + +/** Seconds since the Unix epoch -> `Date`, or `undefined` for a non-finite input. */ +export function fromUnixSeconds(seconds: number | undefined): Date | undefined { + if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) { + return undefined; + } + return new Date(seconds * 1000); +} + +/** + * Numbers of whole days between two instants, floored at 0. + * Used by the cache age buckets: `<1d` / `1–7d` / `7–30d` / `>30d`. + */ +export function ageInDays(at: Date, now: Date): number { + const delta = now.getTime() - at.getTime(); + return delta <= 0 ? 0 : Math.floor(delta / 86_400_000); +} + +/** + * Bucket boundaries in days, ascending, from settings such as + * `["1d", "7d", "30d"]`. Unparsable entries are dropped; the result is sorted + * and de-duplicated so a hand-edited setting cannot produce overlapping buckets. + */ +export function parseAgeBuckets(values: readonly string[] | undefined): number[] { + const parsed = new Set(); + for (const raw of values ?? []) { + const match = /^\s*(\d+)\s*([dhwm]?)\s*$/.exec(raw); + if (match === null) { + continue; + } + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + const days = unit === "h" ? amount / 24 : unit === "w" ? amount * 7 : unit === "m" ? amount * 30 : amount; + const rounded = Math.max(0, Math.round(days)); + parsed.add(rounded); + } + if (parsed.size === 0) { + return [1, 7, 30]; + } + return [...parsed].sort((a, b) => a - b); +} + +/** Which bucket index `days` falls in; `boundaries.length` is the last (oldest) bucket. */ +export function bucketIndex(days: number, boundaries: readonly number[]): number { + for (let index = 0; index < boundaries.length; index += 1) { + if (days < boundaries[index]) { + return index; + } + } + return boundaries.length; +} + +export interface CacheEntrySize { + bytes: number; + accessed?: number; + complete?: boolean; +} + +export interface CacheProjection { + /** Entries removed, in LRU order, to reach the budget. */ + removed: CacheEntrySize[]; + kept: CacheEntrySize[]; + freedBytes: number; + remainingBytes: number; +} + +/** + * Simulate `mcpp cache gc --max-size `: drop least-recently-used entries + * until the total fits the budget. Entries without a usable `accessed` timestamp + * are treated as the most recent, so a projection never promises to free + * something mcpp would keep. + * + * This is an **estimate** — mcpp's own LRU also weighs entry completeness and + * its own bookkeeping, so the figure is offered as a preview only. + */ +export function projectGc(entries: readonly CacheEntrySize[], budgetBytes: number): CacheProjection { + const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.bytes), 0); + if (budgetBytes >= total) { + return { removed: [], kept: [...entries], freedBytes: 0, remainingBytes: total }; + } + const ranked = entries + .map((entry, index) => ({ entry, index })) + .sort((a, b) => { + const left = a.entry.accessed ?? Number.MAX_SAFE_INTEGER; + const right = b.entry.accessed ?? Number.MAX_SAFE_INTEGER; + return left === right ? a.index - b.index : left - right; + }); + const removed: CacheEntrySize[] = []; + let remaining = total; + for (const { entry } of ranked) { + if (remaining <= budgetBytes) { + break; + } + removed.push(entry); + remaining -= Math.max(0, entry.bytes); + } + const removedSet = new Set(removed); + return { + removed, + kept: entries.filter((entry) => !removedSet.has(entry)), + freedBytes: total - remaining, + remainingBytes: remaining, + }; +} diff --git a/mide-plugins/vscode/src/util/log.ts b/mide-plugins/vscode/src/util/log.ts new file mode 100644 index 0000000..2119e4d --- /dev/null +++ b/mide-plugins/vscode/src/util/log.ts @@ -0,0 +1,82 @@ +/** + * One place that decides whether a line reaches the `mcpp` output channel. + * + * `mcpp.log.level` is a **threshold**, not a filter list: `error` writes only + * errors, `warn` adds warnings, `info` is the declared default, and `debug` + * writes everything. Two rules keep the channel honest: + * + * - an error is never suppressed by any configured level — that is why a failed + * command's raw stdout/stderr is written at `error` by its caller; + * - an unknown configured value degrades to the default instead of silencing + * the channel, and an unknown line level is treated as the most verbose thing + * it could be. + * + * Pure and `vscode`-free: `createLogger` takes any `appendLine`-shaped sink and + * a level callback, so the policy can be asserted without an editor. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const ORDER: Readonly> = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const DEFAULT_LEVEL: LogLevel = "info"; + +export function isLogLevel(value: unknown): value is LogLevel { + return value === "error" || value === "warn" || value === "info" || value === "debug"; +} + +/** Coerce a configured value: anything unknown becomes the declared default. */ +export function logLevelOf(value: unknown): LogLevel { + return isLogLevel(value) ? value : DEFAULT_LEVEL; +} + +/** + * True when a line at `level` should be written while `configured` is the + * effective `mcpp.log.level`. An error passes at every level. + */ +export function shouldLog(level: LogLevel, configured: unknown): boolean { + const threshold = ORDER[logLevelOf(configured)]; + const wanted = ORDER[isLogLevel(level) ? level : "debug"]; + return wanted <= threshold; +} + +/** The part of an `OutputChannel` this module needs. */ +export interface LogChannel { + appendLine(line: string): unknown; +} + +export interface Logger { + error(line: string): void; + warn(line: string): void; + info(line: string): void; + debug(line: string): void; +} + +/** + * A levelled writer over an output channel. The level is read per line, so a + * configuration change applies to the next line without re-creating the logger. + * A channel that has already been disposed is a no-op, never a crash. + */ +export function createLogger(channel: LogChannel, levelOf: () => unknown): Logger { + const write = (level: LogLevel, line: string): void => { + if (!shouldLog(level, levelOf())) { + return; + } + try { + channel.appendLine(line); + } catch { + // The output channel can be released during a window reload or shutdown. + } + }; + return { + error: (line) => write("error", line), + warn: (line) => write("warn", line), + info: (line) => write("info", line), + debug: (line) => write("debug", line), + }; +} diff --git a/mide-plugins/vscode/src/util/text.ts b/mide-plugins/vscode/src/util/text.ts new file mode 100644 index 0000000..ec3c0ae --- /dev/null +++ b/mide-plugins/vscode/src/util/text.ts @@ -0,0 +1,79 @@ +/** + * Small text helpers shared by the CLI adapter and the views. Pure: no `vscode`. + */ + +/** `mcpp` colourises its status row; anything captured from a terminal may carry ANSI. */ +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE, ""); +} + +export interface ClampedText { + text: string; + truncated: boolean; + /** Lines dropped from the front, so a caller can say "… N earlier lines". */ + droppedLines: number; +} + +export interface ClampOptions { + maxLines?: number; + maxChars?: number; +} + +/** + * Keep the **tail** of a long output and say so. Command output that matters + * (a failure, a list of stale directories) is at the end; the head is status + * narration. The full text is expected to be in the `mcpp` output channel. + */ +export function clampOutput(input: string, options: ClampOptions = {}): ClampedText { + const maxLines = options.maxLines ?? 400; + const maxChars = options.maxChars ?? 64 * 1024; + const normalised = stripAnsi(input).replace(/\r\n/g, "\n"); + const lines = normalised.split("\n"); + let truncated = false; + let droppedLines = 0; + let kept = lines; + if (lines.length > maxLines) { + droppedLines = lines.length - maxLines; + kept = lines.slice(droppedLines); + truncated = true; + } + let text = kept.join("\n"); + if (text.length > maxChars) { + text = text.slice(text.length - maxChars); + truncated = true; + } + return { text, truncated, droppedLines }; +} + +/** First line of a multi-line string, trimmed; used for one-line tree tooltips. */ +export function firstLine(text: string): string { + const index = text.indexOf("\n"); + return (index === -1 ? text : text.slice(0, index)).trim(); +} + +/** Escape a string for use as a `markdown` tooltip / webview text node. */ +export function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Split a comma-separated setting value (`"mcpp,cmake"`) into trimmed entries. */ +export function splitList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** `["-j","4"]` -> `"-j 4"`, quoting entries that contain whitespace. */ +export function formatArguments(args: readonly string[]): string { + return args + .map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg)) + .join(" "); +} diff --git a/mide-plugins/vscode/src/views/languageServerView.ts b/mide-plugins/vscode/src/views/languageServerView.ts new file mode 100644 index 0000000..4e1cd3b --- /dev/null +++ b/mide-plugins/vscode/src/views/languageServerView.ts @@ -0,0 +1,367 @@ +/** + * The C++ Modules language service, as commands plus one state source. + * + * There is no view of its own any more: the four things worth seeing — state, + * provider, problems, actions — are folded into the project view's 「基本信息」 + * section, and this file feeds that block. Every button still forwards an mcppls + * command; this extension never reimplements language-service behaviour and never + * writes an `mcppls.*` setting itself. The block states its provider on its own + * line, so "who owns what" stays answerable after the fold. + * + * Confirmation policy comes from the capability table (`danger` + `confirmHint`), + * so a new dangerous action cannot be added without saying what it does. + * `mcpp.languageService.confirmResetCache` can add one more explicit step to the + * reset-cache action; it can never take the capability's own modal away, and + * `./viewPolicy.ts` is where that rule lives. + * + * Three settings also own the block's *timing*: `mcpp.languageService.readState` + * decides whether state is read at all, `…stateRefreshSeconds` whether it is + * re-read on an interval, and `…notifyOnDegraded` whether the one-time + * missing-dependency line is written. `mcpp.views.languageServer.show` decides + * whether the block appears in the project view at all. + */ + +import { existsSync } from "node:fs"; + +import * as vscode from "vscode"; + +import { LANGUAGE_SERVER_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import type { Session } from "../ide/session"; +import type { LanguageServerBridge } from "../mcppls/bridge"; +import { MCPPLS_EXTENSION_ID, capability } from "../mcppls/contract"; +import { confirmationFor, formatResult, type FormattedResult } from "../mcppls/messages"; +import { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, + readOtherCppFeatures, +} from "../mcppls/stateSource"; +import { OTHER_CPP_SETTINGS } from "../mcppls/otherCppFeatures"; +import { PollTimer } from "../mcppls/timers"; +import { refreshTimerDecision } from "../cache/cacheState"; +import type { LanguageServiceBlock } from "./models"; +import { degradedNoticeEnabled, resetCacheConfirmation } from "./viewPolicy"; + +/** The tick granularity; the settings are in seconds. */ +const REFRESH_TICK_MS = 100; + +/** The shortest interval at which `mcppls status` is asked while the view is visible (§5.2). */ +export const STATE_REFRESH_FLOOR_SECONDS = 30; + +export interface LanguageServerViewDeps { + bridge: LanguageServerBridge; + output: vscode.OutputChannel; + /** The backend session: what mcppls says about the workspace (§5.2). */ + session: Session; +} + +/** + * The folded block's data source, handed to the project view. + * + * `input()` reads; `onDidChange` tells the project view when to rebuild; and + * `setVisible` is how the poller learns whether the project view is on screen at + * all. Keeping the timer here rather than in the project view means the setting + * that shapes it (`mcpp.languageService.stateRefreshSeconds`) is read next to the + * action it governs. + */ +export interface LanguageServerBlockSource { + /** The block's current input, or `undefined` while `mcpp.views.languageServer.show` is off. */ + input(): LanguageServiceBlock | undefined; + /** Fires whenever the state, or the setting that shows it, may have changed. */ + onDidChange(listener: () => void): vscode.Disposable; + /** The project view says whether its tree is visible; the poller follows it. */ + setVisible(visible: boolean): void; +} + +/** Ask, run, report — the same shape for every forwarded command. */ +export async function runLanguageServerCommand( + bridge: LanguageServerBridge, + output: vscode.OutputChannel, + key: string, + options: { args?: unknown[]; value?: unknown; extraConfirmation?: string } = {}, +): Promise<{ message: string; value?: unknown } | undefined> { + const confirmation = confirmationFor(key, options.value); + if (confirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage(confirmation, { modal: true }, run); + if (choice !== run) { + return undefined; + } + } + // A setting may ask once more; it never replaces the capability's own modal. + if (options.extraConfirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage( + options.extraConfirmation, + { modal: true }, + run, + ); + if (choice !== run) { + return undefined; + } + } + const result = await bridge.invoke(key, ...(options.args ?? [])); + const formatted: FormattedResult = formatResult(result); + try { + output.appendLine(`[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); + } catch { + // The channel can be gone during shutdown. + } + if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); + } else if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } + return result.value === undefined + ? { message: formatted.message } + : { message: formatted.message, value: result.value }; +} + +/** + * The extra confirmation `mcpp.languageService.confirmResetCache` adds, or + * `undefined` when the capability's own `danger` level should be the only one. + * `bridge.dangerOf` is the capability table's answer, never a copy of it. + * + * The text is the capability's own `confirmHint`, so the second modal repeats + * exactly what the reset does — the setting buys a second explicit "yes", not a + * second explanation. It is resolved at render time, like every other capability + * string (`src/mcppls/messages.ts`). + */ +export function extraResetCacheConfirmation( + bridge: LanguageServerBridge, + confirmResetCache: boolean, +): string | undefined { + const decision = resetCacheConfirmation({ + danger: bridge.dangerOf("resetCache"), + confirmResetCache, + }); + return decision === "extra-modal" ? capability("resetCache")?.confirmHint : undefined; +} + +/** + * Registers every `mcpp.languageServer.*` command (and the forwarded mcppls + * actions behind them) and returns the state source the project view renders. + */ +export function registerLanguageServerCommands( + context: vscode.ExtensionContext, + deps: LanguageServerViewDeps, +): LanguageServerBlockSource { + const changed = new vscode.EventEmitter(); + context.subscriptions.push(changed); + + // Whether the project view's tree is on screen. The timer below follows it, so + // a collapsed sidebar does not keep asking mcppls for state. + let visible = false; + const refresh = (): void => { + changed.fire(); + }; + + const input = (): LanguageServiceBlock | undefined => { + // Literal key, so the wiring gate can see it. + if (read("mcpp.views.languageServer.show") === false) { + return undefined; + } + return { + show: true, + installed: languageServerInstalled(), + enabled: languageServerEnabled(), + version: languageServerVersion(), + state: readLanguageServerState(deps.session.languageService), + otherCppFeatures: languageServerInstalled() + ? { ...readOtherCppFeatures(), canRestore: !deps.bridge.isGone("restoreOtherCppFeatures") } + : undefined, + }; + }; + + // `mcpp.languageService.stateRefreshSeconds`, but only while the project view is + // visible, only while state is read at all (0 = off), and never more often than every 30 s: + // each tick runs `mcppls status` (§5.2). + const timer = new PollTimer({ + periodMs: 0, + tick: (): void => { + if (visible) { + void deps.session.refreshLanguageService(); + } + }, + }); + context.subscriptions.push(timer); + const applyTimer = (): void => { + const disabled = read("mcpp.languageService.readState") === false; + const seconds = read("mcpp.languageService.stateRefreshSeconds"); + const usable = !disabled && Number.isFinite(seconds) && seconds > 0; + const decision = refreshTimerDecision(usable ? Math.max(STATE_REFRESH_FLOOR_SECONDS, seconds) : 0, visible); + timer.start(decision.active ? decision.seconds * 1000 : 0); + }; + applyTimer(); + + const register = (id: string, handler: (...args: unknown[]) => Promise): void => { + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]) => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.output.appendLine(t("C++ Modules command failed: {0}", message)); + void vscode.window.showErrorMessage(t("{0} failed: {1}", id, message)); + } + }), + ); + }; + + const forward = (id: string, key: string, args?: () => unknown[]): void => { + register(id, async () => { + const options: { args?: unknown[]; extraConfirmation?: string } = + args === undefined ? {} : { args: args() }; + if (key === "resetCache") { + const extra = extraResetCacheConfirmation( + deps.bridge, + read("mcpp.languageService.confirmResetCache"), + ); + if (extra !== undefined) { + options.extraConfirmation = extra; + } + } + await runLanguageServerCommand(deps.bridge, deps.output, key, options); + refresh(); + }); + }; + + register(LANGUAGE_SERVER_COMMANDS.refreshState, async () => { + refresh(); + }); + + /** + * Where the last bundle was written. + * + * `mcppls.exportDiagnosticBundle` resolves to the zip it wrote, and that path + * is the only reliable answer to "where is it?" — the directory is + * `/bundles` on the server's own terms, which this extension + * must not guess at. Remembered machine-wide, because the bundle directory is + * machine-wide too. + */ + const BUNDLE_PATH_KEY = "mcpp.languageServer.lastDiagnosticBundle"; + const bundleFrom = (value: unknown): string | undefined => { + const path = value instanceof vscode.Uri ? value.fsPath : typeof value === "string" ? value : undefined; + return path !== undefined && path.length > 0 ? path : undefined; + }; + + register(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, async () => { + const result = await runLanguageServerCommand(deps.bridge, deps.output, "exportDiagnostics"); + const bundle = bundleFrom(result?.value); + if (bundle !== undefined) { + await context.globalState.update(BUNDLE_PATH_KEY, bundle); + } + refresh(); + }); + + register(LANGUAGE_SERVER_COMMANDS.revealBundle, async () => { + const remembered = context.globalState.get(BUNDLE_PATH_KEY); + if (remembered === undefined || !existsSync(remembered)) { + const exportNow = t("Capture logs now"); + const choice = await vscode.window.showInformationMessage( + t("No diagnostic bundle has been written yet. Capturing the logs writes one zip with the report, the environment and the recent logs."), + exportNow, + ); + if (choice === exportNow) { + await vscode.commands.executeCommand(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle); + } + return; + } + await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(remembered)); + }); + + // mcppls owns the path and the arguments: its action names the command and what it is called + // with (`revealCacheDirectory('logs')` today). Nothing here reconstructs a path. + forward(LANGUAGE_SERVER_COMMANDS.openLogFolder, "openLogDirectory"); + forward(LANGUAGE_SERVER_COMMANDS.restart, "restart"); + forward(LANGUAGE_SERVER_COMMANDS.restartEngine, "restartEngine"); + forward(LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, "resetCache"); + forward(LANGUAGE_SERVER_COMMANDS.selectContext, "selectContext"); + forward(LANGUAGE_SERVER_COMMANDS.showModuleGraph, "moduleGraph"); + forward(LANGUAGE_SERVER_COMMANDS.showLogs, "showLog"); + forward(LANGUAGE_SERVER_COMMANDS.collectReport, "collectReport"); + // `exportDiagnosticBundle` is registered above rather than forwarded: its + // answer is the zip's path, and the caller has to keep it. + forward(LANGUAGE_SERVER_COMMANDS.runBuildToolInTerminal, "runBuildTool"); + forward(LANGUAGE_SERVER_COMMANDS.installTools, "installTools"); + forward(LANGUAGE_SERVER_COMMANDS.installKit, "installKit"); + forward(LANGUAGE_SERVER_COMMANDS.manageConflicts, "turnOffOtherCppFeatures"); + forward(LANGUAGE_SERVER_COMMANDS.restoreConflicts, "restoreOtherCppFeatures"); + forward(LANGUAGE_SERVER_COMMANDS.reviewChanges, "reviewRun"); + register(LANGUAGE_SERVER_COMMANDS.toggleInWorkspace, async () => { + const enable = !languageServerEnabled(); + await runLanguageServerCommand(deps.bridge, deps.output, enable ? "turnOnInWorkspace" : "turnOffInWorkspace", { value: enable }); + refresh(); + }); + + // The legacy ids from 0.4.x keep working, pointing at the new actions. + register(LEGACY_LANGUAGE_SERVER_COMMANDS.configureLanguageServer, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register("mcpp.configureClangd", async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.checkModuleSupport, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "restart"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showModuleGraph, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "moduleGraph"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showLanguageServerLogs, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "showLog"); + }); + + register(TOOL_COMMANDS.openLanguageServerSettings, async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "@ext:sunrisepeak.mcpp-language-server"); + }); + + context.subscriptions.push( + // The backend asked mcppls again: its actions and the workspace's state may differ. + deps.session.onDidChangeLanguageService.event(() => { + deps.bridge.capabilities.invalidate(); + refresh(); + }), + vscode.extensions.onDidChange(() => refresh()), + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration("mcppls.enable") || + event.affectsConfiguration("mcpp.views.languageServer") || + event.affectsConfiguration("mcpp.languageService") || + // Another C++ service turned on or off, by mcppls's command or by hand. + OTHER_CPP_SETTINGS.some((setting) => event.affectsConfiguration(setting)) + ) { + refresh(); + applyTimer(); + } + }), + ); + + // A capability that has gone missing is worth one sentence, once — unless + // `mcpp.languageService.notifyOnDegraded` says the user does not want it. + if (degradedNoticeEnabled(read("mcpp.languageService.notifyOnDegraded")) && !languageServerInstalled()) { + deps.output.appendLine( + t("The C++ Modules extension ({0}) is not installed or is disabled.", MCPPLS_EXTENSION_ID), + ); + } + + return { + input, + onDidChange: (listener: () => void): vscode.Disposable => changed.event(listener), + setVisible: (next: boolean): void => { + if (visible === next) { + return; + } + visible = next; + applyTimer(); + // Becoming visible is exactly when a stale block must be re-read. + if (visible) { + void deps.session.refreshLanguageService(); + refresh(); + } + }, + }; +} + diff --git a/mide-plugins/vscode/src/views/models.ts b/mide-plugins/vscode/src/views/models.ts new file mode 100644 index 0000000..c960f3e --- /dev/null +++ b/mide-plugins/vscode/src/views/models.ts @@ -0,0 +1,858 @@ +/** + * The trees, as **data**. + * + * Labels are keys, not sentences: the builder stays free of `vscode` and of the + * current language, the tests assert stable keys, and `src/views/treeProvider.ts` + * resolves them through `src/i18n/t.ts` at render time. A label's arguments may + * themselves be labels, so a phrase like `C++23 · 87 source file(s)` is composed + * from two independently translatable pieces instead of one frozen sentence. + * + * The project view has **Basics** (what this project is), folded direct dependencies + * and **Common commands** (what can be done to it). The C++ Modules block is + * folded into the first one, with the status line carrying the problem count, so + * a degraded language service is visible **without expanding anything**. The + * commands section is the only place in the view whose rows are all commands, and + * it is the one that starts open: the sidebar should open on what can be done, + * with 「基本信息」 one click away. + * + * Everything the project view needs from disk lives here as well — the declared + * dependencies, `mcpp.lock`'s resolved versions and the source-file count — so + * the whole layout can be unit tested without an editor host. Nothing here + * imports `vscode`; `test/architecture.test.ts` enforces that. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { readProjectDeclarations } from "../library/projectState"; + +export type LabelArgument = string | number | Label; + +export interface Label { + key: string; + args?: readonly LabelArgument[]; +} + +export interface TreeCommand { + command: string; + title: Label; + arguments?: readonly unknown[]; +} + +export interface TreeNode { + id: string; + label: Label; + description?: Label; + tooltip?: Label; + /** A codicon id without the `$()`, e.g. `"database"`. */ + icon?: string; + /** + * A `ThemeColor` id the icon is drawn in, e.g. `"charts.blue"`. + * + * Only ids the colour registry always carries are used (`charts.*`, + * `disabledForeground`), and a theme that omits one leaves the icon in the + * normal foreground colour rather than making the row unreadable — colour is a hint here, never the state. + */ + iconColor?: string; + /** Consumed by `when` clauses in package.json for inline actions. */ + contextValue?: string; + /** + * Render expanded on first display. A node with children is collapsed unless + * this is set. 「常用命令」 starts open because it is the part of the view that + * gets used; 「基本信息」 starts closed, so the sidebar opens on what can be + * *done* rather than on what the project happens to be. + */ + expanded?: boolean; + command?: TreeCommand; + children?: readonly TreeNode[]; +} + +const plain = (text: string): Label => ({ key: text }); + +/** `a`, `b` -> `a · b`, nested so each side keeps its own translation. */ +function joined(parts: readonly Label[]): Label | undefined { + if (parts.length === 0) { + return undefined; + } + return parts.reduce((left, right): Label => ({ key: "{0} · {1}", args: [left, right] })); +} + +export interface TargetSummary { + name: string; + kind: string; +} + +/** One dependency declared in `mcpp.toml`, before `mcpp.lock` says what it resolved to. */ +export interface DependencyDeclaration { + kind?: "normal" | "dev" | "build"; + conditional?: boolean; + /** `namespace.name` as written in the manifest. */ + name: string; + /** The declared version constraint, when the manifest states one. */ + version?: string; + /** Declared in `[dev-dependencies]`. */ + dev?: boolean; + /** A local path dependency. */ + path?: string; + /** A git dependency. */ + git?: string; +} + +export interface ProjectSummary { + root: string; + name?: string; + version?: string; + standard?: string; + profile?: string; + toolchainSpec?: string; + target?: string; + targets?: readonly TargetSummary[]; + hasTests?: boolean; + /** Declared in `[dependencies]` / `[dev-dependencies]`; absent when there are none. */ + dependencies?: readonly DependencyDeclaration[]; + dependencyState?: "loading" | "ready" | "empty" | "stale" | "error"; + dependencyError?: string; + /** Source files counted under the project root; absent when not measured. */ + sourceFiles?: number; + /** Set when `mcpp.toml` could not be read; the tree then says so instead of lying. */ + error?: string; +} + +/* ------------------------------------------------------------------ mcpp.toml */ + +/** Direct declarations only; share the namespace-aware parser with Libraries and the graph. */ +export function readDependencies(lines: readonly string[]): DependencyDeclaration[] { + return readProjectDeclarations(lines.join("\n")).map((entry) => ({ + name: entry.id, + ...(entry.version ? { version: entry.version } : {}), + ...(entry.dev ? { dev: true } : {}), + ...(entry.kind === "build" ? { kind: "build" as const } : {}), + ...(entry.conditional ? { conditional: true } : {}), + ...(entry.path ? { path: entry.path } : {}), + ...(entry.git ? { git: entry.git } : {}), + })); +} + +/* ------------------------------------------------------------------ mcpp.lock */ + +/** One `[package."…"]` entry of `mcpp.lock`: a resolved package, with no parent edge. */ +export interface LockPackage { + /** The table key as written: `openkal` or `compat.freetype`. */ + key: string; + namespace?: string; + version?: string; + source?: string; + hash?: string; +} + +const LOCK_HEADER = /^\[\s*package\s*\.\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*\]\s*$/; +const LOCK_FIELD = /^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^#\s]+))\s*(?:#.*)?$/; +const LOCK_FIELDS: ReadonlySet = new Set(["namespace", "version", "source", "hash"]); + +/** + * `mcpp.lock`, read tolerantly. + * + * The file is a flat TOML set — `version = 2` then one `[package."…"]` table per + * resolved package — and it deliberately carries **no parent/child edges**, + * which is why the view can only ever show two levels. Anything unrecognised is + * skipped and nothing here throws: a file being written is still a valid lock + * with fewer entries, not a broken view. + */ +export function parseLockfile(text: string): LockPackage[] { + const packages: LockPackage[] = []; + try { + let current: LockPackage | undefined; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.length === 0 || line.startsWith("#")) { + continue; + } + const header = LOCK_HEADER.exec(line); + if (header !== null) { + const key = header[1] ?? header[2] ?? header[3] ?? ""; + if (key.length === 0) { + current = undefined; + continue; + } + current = { key }; + packages.push(current); + continue; + } + if (line.startsWith("[")) { + current = undefined; + continue; + } + if (current === undefined) { + continue; + } + const field = LOCK_FIELD.exec(line); + if (field === null || !LOCK_FIELDS.has(field[1])) { + continue; + } + const value = field[2] ?? field[3] ?? field[4]; + if (value === undefined || value.length === 0) { + continue; + } + if (field[1] === "namespace") current.namespace = value; + else if (field[1] === "version") current.version = value; + else if (field[1] === "source") current.source = value; + else current.hash = value; + } + } catch { + // Tolerant by construction: an unreadable lock is an empty lock. + } + return packages; +} + +/** {@link parseLockfile} for a path that may not exist. A missing lock is empty, not an error. */ +export function readLockfile(file: string): LockPackage[] { + try { + return parseLockfile(readFileSync(file, "utf8")); + } catch { + return []; + } +} + +/** + * The lock entry a declaration resolved to, matched by `namespace.name`. + * + * Real lock files are inconsistent about the table key: `[package."openkal"]` + * carries `namespace = "mcpplibs"` while `[package."compat.freetype"]` already + * folds the namespace into the key. Both resolve here; when the entry has no + * namespace of its own, the name is the only thing left to match on. + */ +export function resolveLockedPackage(entries: readonly LockPackage[], name: string): LockPackage | undefined { + const dot = name.lastIndexOf("."); + const namespace = dot > 0 ? name.slice(0, dot) : undefined; + const local = dot > 0 ? name.slice(dot + 1) : name; + for (const entry of entries) { + if (entry.key === name) { + return entry; + } + const entryLocal = entry.key.includes(".") ? entry.key.slice(entry.key.lastIndexOf(".") + 1) : entry.key; + if (entryLocal !== local) { + continue; + } + if (namespace === undefined || entry.namespace === undefined || entry.namespace === namespace) { + return entry; + } + } + return undefined; +} + +/* --------------------------------------------------------------- source files */ + +/** Extensions counted as source: module interfaces, translation units and headers. */ +const SOURCE_EXTENSIONS: ReadonlySet = new Set([ + ".c", + ".cc", + ".cpp", + ".cxx", + ".c++", + ".cppm", + ".ixx", + ".mpp", + ".h", + ".hh", + ".hpp", + ".hxx", + ".ipp", + ".inl", +]); + +/** Directories that hold build output or other people's code, never the project's own sources. */ +const SOURCE_SKIP_DIRS: ReadonlySet = new Set([ + "target", + "build", + "out", + "dist", + "node_modules", + ".git", + ".mcpp", + ".vscode", + ".cache", +]); + +const SOURCE_MAX_ENTRIES = 20_000; +const SOURCE_MAX_DEPTH = 8; + +/** + * How many source files this project has, counted under `root`. + * + * Bounded and non-throwing, like every other walk in this codebase: a huge or + * unreadable tree stops early rather than hanging the extension host, and the + * build directories are skipped so `target/` cannot flatter the number. The + * count is what makes the identity row say something about the project's size + * without running mcpp. + */ +export function countSourceFiles(root: string): number { + let count = 0; + let seen = 0; + const visit = (dir: string, depth: number): void => { + if (depth > SOURCE_MAX_DEPTH || seen >= SOURCE_MAX_ENTRIES) { + return; + } + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + seen += 1; + if (seen > SOURCE_MAX_ENTRIES) { + return; + } + if (entry.isSymbolicLink()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SOURCE_SKIP_DIRS.has(entry.name)) { + visit(full, depth + 1); + } + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + count += 1; + } + } + }; + visit(root, 0); + return count; +} + +/* ---------------------------------------------------------------- keybindings */ + +export interface KeybindingHint { + key?: string; + mac?: string; +} + +export type KeyboardPlatform = "mac" | "other"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The keybindings this extension contributes, keyed by command id, read from + * `package.json` rather than copied into the source. + * + * A hint in the tree that disagrees with the manifest is worse than no hint, so + * the row asks the manifest. `context.extension.packageJSON` is what the caller + * hands in; this function itself is pure and testable. + */ +export function keybindingsFromPackage(packageJSON: unknown): Readonly> { + const bindings: Record = {}; + if (!isRecord(packageJSON) || !isRecord(packageJSON.contributes)) { + return bindings; + } + const list = packageJSON.contributes.keybindings; + if (!Array.isArray(list)) { + return bindings; + } + for (const entry of list) { + if (!isRecord(entry) || typeof entry.command !== "string" || bindings[entry.command] !== undefined) { + continue; + } + const key = typeof entry.key === "string" && entry.key.length > 0 ? entry.key : undefined; + const mac = typeof entry.mac === "string" && entry.mac.length > 0 ? entry.mac : undefined; + if (key === undefined && mac === undefined) { + continue; + } + const hint: KeybindingHint = {}; + if (key !== undefined) hint.key = key; + if (mac !== undefined) hint.mac = mac; + bindings[entry.command] = hint; + } + return bindings; +} + +function keyToken(token: string, platform: KeyboardPlatform): string { + if (platform === "mac") { + switch (token) { + case "cmd": + case "meta": + case "super": + return "⌘"; + case "ctrl": + case "control": + return "⌃"; + case "alt": + case "option": + return "⌥"; + case "shift": + return "⇧"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } + } + switch (token) { + case "cmd": + case "meta": + case "super": + return "Meta"; + case "ctrl": + case "control": + return "Ctrl"; + case "alt": + case "option": + return "Alt"; + case "shift": + return "Shift"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } +} + +/** `cmd+alt+b` -> `⌘⌥B` on macOS, `ctrl+alt+b` -> `Ctrl+Alt+B` elsewhere. */ +export function formatKeybinding(hint: KeybindingHint | undefined, platform: KeyboardPlatform): string | undefined { + if (hint === undefined) { + return undefined; + } + const raw = platform === "mac" ? hint.mac ?? hint.key : hint.key ?? hint.mac; + if (raw === undefined || raw.trim().length === 0) { + return undefined; + } + const parts = raw + .split("+") + .map((part) => keyToken(part.trim().toLowerCase(), platform)) + .filter((part) => part.length > 0); + if (parts.length === 0) { + return undefined; + } + return platform === "mac" ? parts.join("") : parts.join("+"); +} + +/* ------------------------------------------------------------ language service */ + +/** The part of an mcppls issue the folded block renders. `clangd` is never among them. */ +export interface LanguageServiceIssue { + code: string; + message: string; + /** mcppls's own remedy for this issue, when it offers one. */ + command?: { command: string; arguments?: unknown[]; title?: string }; +} + +/** + * The folded C++ Modules block. + * + * Only the language service's own identity, its state and its problems are + * rendered: the engines, the semantic profile and the compilation database used + * to *define* the old view, and the spec is explicit that `clangd` must not + * appear anywhere in it (§8.2). + */ +export interface LanguageServiceBlock { + /** `mcpp.views.languageServer.show`; `false` removes the block from the tree. */ + show?: boolean; + installed: boolean; + enabled?: boolean; + version?: string; + state?: { + available: boolean; + reason?: string; + state?: string; + issues?: readonly LanguageServiceIssue[]; + /** The semantic kit of the workspace's target (`mcppls status`, §5.2). */ + kit?: { name: string; state: string }; + }; + /** Other C++ language services serving next to it (C/C++, clangd, ccls). */ + otherCppFeatures?: { + active: readonly { extensionId: string; displayName: string; settable: boolean }[]; + canRestore: boolean; + }; +} + +/** The provider line the block always carries, so "who owns this" stays answerable. */ +export const LANGUAGE_SERVICE_PROVIDER = "sunrisepeak.mcpp-language-server"; + +interface ActionRow { + id: string; + label: string; + icon: string; + command: string; +} + +/** The four actions worth a permanent row; everything else folds into 「其它 N 项…」. */ +const LANGUAGE_SERVICE_COMMON_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.restart", label: "Restart language service", icon: "debug-restart", command: "mcpp.languageServer.restart" }, + { id: "project.languageService.action.selectContext", label: "Select analysis context", icon: "symbol-interface", command: "mcpp.languageServer.selectContext" }, + { id: "project.languageService.action.graph", label: "Module graph", icon: "type-hierarchy", command: "mcpp.languageServer.showModuleGraph" }, + { id: "project.languageService.action.logs", label: "Open logs", icon: "output", command: "mcpp.languageServer.showLogs" }, +]; + +const LANGUAGE_SERVICE_MORE_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.refreshState", label: "Refresh this view", icon: "refresh", command: "mcpp.languageServer.refreshState" }, + { id: "project.languageService.action.restartEngine", label: "Restart the semantic engine", icon: "debug-restart", command: "mcpp.languageServer.restartEngine" }, + { id: "project.languageService.action.resetCache", label: "Reset this workspace's cache", icon: "trash", command: "mcpp.languageServer.resetWorkspaceCache" }, + { id: "project.languageService.action.report", label: "Collect a diagnostic report", icon: "report", command: "mcpp.languageServer.collectReport" }, + { id: "project.languageService.action.bundle", label: "Export a diagnostic bundle", icon: "file-zip", command: "mcpp.languageServer.exportDiagnosticBundle" }, + { id: "project.languageService.action.runBuildTool", label: "Run the build tool in a terminal", icon: "terminal", command: "mcpp.languageServer.runBuildToolInTerminal" }, + { id: "project.languageService.action.settings", label: "Open the C++ Modules settings", icon: "settings-gear", command: "mcpp.openMcpplsSettings" }, +]; + +/* -------------------------------------------------------------- project view */ + +export interface ProjectTreeOptions { + /** `mcpp.lock`'s resolved packages, matched to the declarations by name. */ + lock?: readonly LockPackage[]; + /** The folded C++ Modules block; omitted means "no block". */ + languageService?: LanguageServiceBlock; + /** Keybindings read from the extension manifest. */ + keybindings?: Readonly>; + platform?: KeyboardPlatform; +} + +/** Project facts, folded direct dependencies, then common actions. */ +export function buildProjectTree(project: ProjectSummary | undefined, options: ProjectTreeOptions = {}): TreeNode[] { + if (project === undefined) { + // An empty workspace gets the one thing that can be done in it, next to the + // sentence that says why the view is otherwise empty. 「初始化当前目录」 is + // deliberately absent: `mcpp new` refuses a destination that already exists + // (`scaffold/create.cppm`: `"'{}' already exists"`) and has no `--here`, so + // there is nothing to run for "make this folder a project" — see §17 of + // `.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md`. + return [ + { + id: "project.none", + label: plain("No mcpp project in this workspace"), + icon: "info", + tooltip: plain("Open a folder containing mcpp.toml, or create a project with mcpp: New Project."), + }, + newProjectNode(), + ]; + } + if (project.error !== undefined) { + return [ + { + id: "project.error", + label: plain("mcpp.toml could not be read"), + description: { key: "{0}", args: [project.error] }, + icon: "error", + contextValue: "mcppProjectError", + }, + ...dependencyNodes(project, options), + ]; + } + + const facts: Label[] = []; + if (project.standard !== undefined) { + facts.push({ key: "{0}", args: [project.standard] }); + } + if (project.sourceFiles !== undefined) { + facts.push({ key: "{0} source file(s)", args: [project.sourceFiles] }); + } + const identity: TreeNode = { + id: "project.package", + label: plain(project.name ?? "mcpp project"), + icon: "package", + tooltip: + project.version === undefined + ? { key: "{0}", args: [project.root] } + : { key: "{0} · version {1}", args: [project.root, project.version] }, + contextValue: "mcppProject", + }; + const identityFacts = joined(facts); + if (identityFacts !== undefined) { + identity.description = identityFacts; + } + + const basic: TreeNode[] = [ + identity, + ...(project.target === undefined + ? [] + : [{ id: "project.target", label: plain("Target"), description: { key: "{0}", args: [project.target] }, icon: "target" }]), + { + id: "project.toolchain", + label: plain("Toolchain"), + description: { key: "{0}", args: [project.toolchainSpec ?? "host default"] }, + icon: "chip", + }, + { id: "project.moduleDependencies", label: plain("C++ Modules Deps"), icon: "type-hierarchy", iconColor: "charts.blue", command: { command: "mcpp.project.showModuleDependencyGraph", title: plain("C++ Modules Deps"), arguments: [{ root: project.root }] } }, + ...languageServiceNodes(options.languageService), + ]; + + return [ + { id: "project.section.basic", label: plain("Basics"), icon: "info", children: basic }, + ...dependencyNodes(project, options), + { id: "project.section.commands", label: plain("Common commands"), icon: "terminal", expanded: true, children: commandNodes(options) }, + ]; +} + +interface CommandRow { + id: string; + label: string; + icon: string; + /** See `TreeNode.iconColor`; the palette groups rows by what they act on. */ + iconColor: string; + command: string; +} + +/** + * The nine rows of 「常用命令」, in the order the prototype fixes them, with + * creation first: the section is what the view offers to *do*, and in a new + * workspace the only thing to do is make a project. + * + * The colours are a small, explainable palette grouped by **what the row acts + * on**, not by decoration: blue builds or adds, green runs or verifies, purple + * tests, red deletes, yellow manages the toolchain, and configuration stays in + * the ordinary foreground so it never competes with the actions. + * + * Deletion is `charts.red` and not `charts.orange` because `charts.orange` + * resolves to `minimap.findMatchHighlight` -> `editor.findMatchHighlightBackground`, + * which is `#EA5C00` at 33% alpha: as a glyph colour that is a washed-out smear + * in both themes. `charts.red` is `editorError.foreground`, a solid value in + * every theme. The quick menu uses the same words, one file per colour — see + * `src/commands/menu.ts`. + */ +const COMMON_COMMANDS: readonly CommandRow[] = [ + { id: "project.action.new", label: "New mcpp project…", icon: "new-folder", iconColor: "charts.blue", command: "mcpp.newProject" }, + { id: "project.action.build", label: "Build", icon: "tools", iconColor: "charts.blue", command: "mcpp.build" }, + { id: "project.action.run", label: "Run", icon: "play", iconColor: "charts.green", command: "mcpp.run" }, + { id: "project.action.test", label: "Test", icon: "beaker", iconColor: "charts.purple", command: "mcpp.test" }, + { id: "project.action.clean", label: "Clean", icon: "trash", iconColor: "charts.red", command: "mcpp.cleanProjectArtifacts" }, + { id: "project.action.toolchain", label: "Toolchain", icon: "chip", iconColor: "charts.yellow", command: "mcpp.showToolchains" }, + { id: "project.action.librarySearch", label: "Search and add a dependency…", icon: "library", iconColor: "charts.blue", command: "mcpp.library.search" }, + { id: "project.action.selfCheck", label: "Environment self-check", icon: "heart", iconColor: "charts.green", command: "mcpp.selfCheck" }, + { id: "project.action.settings", label: "Settings", icon: "settings-gear", iconColor: "", command: "mcpp.openSettings" }, +]; + +/** The creation row, built once so the empty state and the section agree. */ +function newProjectNode(): TreeNode { + const row = COMMON_COMMANDS[0]; + const label = plain(row.label); + return { + id: row.id, + label, + icon: row.icon, + iconColor: row.iconColor, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; +} + +function commandNodes(options: ProjectTreeOptions): TreeNode[] { + return COMMON_COMMANDS.map((row): TreeNode => { + const hint = formatKeybinding(options.keybindings?.[row.command], options.platform ?? "other"); + const label = plain(row.label); + const node: TreeNode = { + id: row.id, + label, + icon: row.icon, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; + if (row.iconColor.length > 0) { + node.iconColor = row.iconColor; + } + if (hint !== undefined) { + node.description = plain(hint); + } + return node; + }); +} + +function dependencyNodes(project: ProjectSummary, options: ProjectTreeOptions): TreeNode[] { + const declared = project.dependencies ?? []; + if (declared.length === 0) { + const failed = project.dependencyState === "error" || project.dependencyState === "stale" || !!project.error; + return [{ id: "project.dependencies", icon: failed ? "warning" : "info", + label: plain(failed ? "Dependencies unavailable — click to retry" : project.dependencyState === "loading" ? "Reading dependencies…" : "This project declares no dependencies"), + ...(failed ? { command: { command: "mcpp.internal.refreshProjectView", title: plain("Refresh") }, tooltip: plain(project.dependencyError ?? project.error ?? "") } : {}) }]; + } + const lock = options.lock ?? []; + const dev = declared.filter((entry) => entry.dev === true).length; + return [ + { + id: "project.dependencies", + contextValue: "mcppProjectDependencies", + label: { key: "Dependencies ({0})", args: [declared.length] }, + ...(project.dependencyState === "stale" ? { description: plain("Refresh pending") } : dev === 0 ? {} : { description: { key: "{0} dev", args: [dev] } }), + icon: "library", + tooltip: plain(project.dependencyError ?? "Declared in mcpp.toml; the resolved version comes from mcpp.lock."), + expanded: false, + children: declared.map((entry) => dependencyNode(entry, lock, project.root)), + }, + ]; +} + +/** One clickable direct dependency; transitive edges belong exclusively to the graph. */ +function dependencyNode(declaration: DependencyDeclaration, lock: readonly LockPackage[], root: string): TreeNode { + const resolved = resolveLockedPackage(lock, declaration.name); + const version = declaration.version ?? resolved?.version; + const coordinate = declaration.name + (version ? `@${version}` : ""); + const source = declaration.path ? `path · ${declaration.path}` : declaration.git ? "git" : "index"; + const markers = [plain(source), ...(declaration.dev ? [plain("dev")] : []), ...(declaration.kind === "build" ? [plain("build")] : []), ...(declaration.conditional ? [plain("conditional")] : [])]; + return { + id: `project.dependency.${declaration.name}${declaration.dev ? ":dev" : declaration.kind === "build" ? ":build" : ""}${declaration.conditional ? ":conditional" : ""}`, + label: plain(coordinate), description: joined(markers), + tooltip: plain(coordinate + "\n" + source + (resolved?.version && resolved.version !== version ? `\nResolved ${resolved.version}` : "")), + icon: declaration.path ? "folder" : declaration.git ? "git-branch" : "package", + iconColor: declaration.path ? "charts.purple" : declaration.git ? "charts.orange" : "charts.green", + command: { command: "mcpp.project.showDependencyGraph", title: plain("Project dependency graph"), arguments: [{ root, dependency: declaration.name, kind: declaration.dev ? "dev" : declaration.kind ?? "normal", path: declaration.path }] }, + }; +} + +function languageServiceNodes(block: LanguageServiceBlock | undefined): TreeNode[] { + if (block === undefined || block.show === false) { + return []; + } + const issues = block.state?.issues ?? []; + const healthy = block.state?.available === true && block.state.state === "ready"; + const identity: Label = { key: "mcppls {0}", args: [block.version ?? "?"] }; + const node: TreeNode = { + id: "project.languageService", + label: plain("Language service"), + description: + issues.length === 0 + ? identity + : { key: "{0} · {1}", args: [identity, { key: "{0} problem(s)", args: [issues.length] }] }, + icon: healthy ? "pass" : "warning", + contextValue: "mcppLanguageService", + children: languageServiceChildren(block), + }; + if (block.state?.available === false && block.state.reason !== undefined) { + node.tooltip = plain(block.state.reason); + } + return [node]; +} + +/** + * The expanded block: state, provider, every problem, then the four common + * actions and one folded row for the rest. mcppls's engines and semantic profile + * are not read here at all — only the language service itself. + */ +function languageServiceChildren(block: LanguageServiceBlock): TreeNode[] { + if (!block.installed) { + return [ + { + id: "project.languageService.status", + label: plain("C++ Modules is not installed"), + icon: "warning", + command: { command: "mcpp.openMcpplsSettings", title: plain("Install C++ Modules") }, + }, + ]; + } + + const state = block.state; + const nodes: TreeNode[] = [ + languageServiceStatus(state), + { + id: "project.languageService.provider", + label: plain("Provided by"), + description: plain(LANGUAGE_SERVICE_PROVIDER), + icon: "beaker", + }, + ]; + if (block.otherCppFeatures !== undefined) { + nodes.push(otherCppFeaturesNode(block.otherCppFeatures)); + } + // A missing kit: files are read with the openkal kit, and mcppls installs the target's (§5.3). + if (state?.available === true && state.kit?.state === "missing" && state.kit.name !== "") { + nodes.push({ + id: "project.languageService.kit", + label: plain("The semantic kit is not installed"), + description: { key: "{0}", args: [state.kit.name] }, + icon: "cloud-download", + tooltip: plain("Files are read with the openkal kit C++ Modules carries. Installing the target's kit gives its own standard library and headers."), + command: { command: "mcpp.languageServer.installKit", title: plain("Install the semantic kit") }, + }); + } + + (state?.issues ?? []).forEach((issue, index) => { + nodes.push({ + id: `project.languageService.issue.${index}.${issue.code}`, + label: plain(issue.message), + description: plain(issue.code), + icon: "warning", + // S3 hands us the remedy; use it rather than inventing one. + command: + issue.command === undefined + ? undefined + : { + command: issue.command.command, + // mcppls's own title — a sentence we did not write, shown as-is. + title: plain(issue.command.title ?? "Fix"), + arguments: issue.command.arguments, + }, + }); + }); + + for (const action of LANGUAGE_SERVICE_COMMON_ACTIONS) { + nodes.push(actionNode(action)); + } + nodes.push({ + id: "project.languageService.action.more", + label: { key: "More ({0})…", args: [LANGUAGE_SERVICE_MORE_ACTIONS.length] }, + icon: "ellipsis", + children: LANGUAGE_SERVICE_MORE_ACTIONS.map((action) => actionNode(action)), + }); + return nodes; +} + +/** + * 「其他 C++ 语言服务」: another service answering the same files shows every + * result twice, so while one is active the row says which and one click hands + * the decision to mcppls (`mcppls.turnOffOtherCppFeatures`, which asks where). + * With none, the row stays as a quiet, dimmed confirmation and does nothing. + * When mcppls remembers settings it changed, the row's context menu offers to + * put them back (`package.json`: `viewItem =~ /Restorable$/`). + */ +function otherCppFeaturesNode(features: NonNullable): TreeNode { + const restorable = features.canRestore ? "Restorable" : ""; + if (features.active.length === 0) { + return { + id: "project.languageService.otherCppFeatures", + label: plain("Only C++ Modules is serving C++"), + icon: "pass", + iconColor: "disabledForeground", + tooltip: plain("No other C++ language service (C/C++, clangd, ccls) is active, so no result is shown twice."), + contextValue: `mcppOtherCppFeatures${restorable}`, + }; + } + const label = plain("Other C++ language services"); + return { + id: "project.languageService.otherCppFeatures", + label, + description: { key: "{0}", args: [features.active.map((entry) => entry.displayName).join(", ")] }, + icon: "warning", + tooltip: plain("They answer the same C++ files, so results appear twice. Click to turn their C++ language features off; C++ Modules asks whether for this workspace or everywhere."), + contextValue: `mcppOtherCppFeatures${restorable}`, + command: { command: "mcpp.languageServer.manageConflicts", title: label }, + }; +} + +function actionNode(action: ActionRow): TreeNode { + const label = plain(action.label); + return { + id: action.id, + label, + icon: action.icon, + contextValue: "mcppProjectCommand", + command: { command: action.command, title: label }, + }; +} + +/** The workspace's state as `mcppls status` states it. */ +const STATE_LABELS: Readonly> = { + ready: "Ready", + starting: "Starting", + degraded: "Degraded", + stopped: "Stopped", + none: "Not serving this workspace", +}; + +function languageServiceStatus(state: LanguageServiceBlock["state"]): TreeNode { + if (state === undefined || !state.available) { + return { + id: "project.languageService.status", + label: plain("Unavailable"), + description: plain(state?.reason ?? "not read yet"), + icon: "warning", + }; + } + return { + id: "project.languageService.status", + label: plain(STATE_LABELS[state.state ?? ""] ?? "Unknown"), + icon: state.state === "ready" ? "pass" : "warning", + }; +} diff --git a/mide-plugins/vscode/src/views/projectView.ts b/mide-plugins/vscode/src/views/projectView.ts new file mode 100644 index 0000000..9b15d32 --- /dev/null +++ b/mide-plugins/vscode/src/views/projectView.ts @@ -0,0 +1,197 @@ +/** + * The project view: 「基本信息」, folded 「依赖」, then 「常用命令」. + * + * The first section is what the project is (identity, target, toolchain, the + * module graph and the folded C++ Modules block); the final section contains actions. The summary is read from `mcpp.toml` and refreshed when the + * manifest changes; anything unreadable is omitted rather than guessed, and + * `mcpp: Environment Self-check` is the place that reports what mcpp itself + * resolves. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import * as vscode from "vscode"; + +import { ProjectMetadataWatch } from "../projects/metadataWatch"; +import { RefreshCoordinator } from "../graphs/refresh"; +import { assertCompleteManifest, retainProjectDependencies } from "../projects/dependencyState"; +import { readProjectSummary } from "../projects/summary"; +import { MCPP_MANIFEST_GLOB } from "../projects/context"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import type { LanguageServerBlockSource } from "./languageServerView"; +import { + buildProjectTree, + countSourceFiles, + keybindingsFromPackage, + readDependencies, + readLockfile, + type KeyboardPlatform, + type LockPackage, + type ProjectSummary, +} from "./models"; +import { registerTreeView } from "./treeProvider"; +import { registerDependencyGraph } from "../projects/dependencyGraphPanel"; + +export const PROJECT_VIEW_ID = "mcpp.project"; + +export interface ProjectViewDeps { + currentProject: () => McppProjectDiscovery | undefined; + /** + * The folded C++ Modules block. Omit it and the block is never rendered, which + * is also what happens when `mcpp.views.languageServer.show` is off. + */ + languageService?: LanguageServerBlockSource; +} + +/** `mcpp.lock` sits next to `mcpp.toml`, and changes without the manifest changing. */ +const LOCKFILE_NAME = "mcpp.lock"; +const LOCKFILE_GLOB = "**/mcpp.lock"; + +/** Read one manifest into a summary; a failure becomes `error`, never a throw. */ +export function summariseProject(project: McppProjectDiscovery | undefined): ProjectSummary | undefined { + if (project === undefined) { + return undefined; + } + try { + const text = readFileSync(project.manifestPath, "utf8"); + assertCompleteManifest(text); + const lines = text.split(/\r?\n/); + const summary = readProjectSummary(project.root, lines); + const dependencies = readDependencies(lines).map((entry) => { + if (!entry.path || entry.version) { return entry; } + try { + const local = readProjectSummary(path.resolve(project.root, entry.path), readFileSync(path.resolve(project.root, entry.path, "mcpp.toml"), "utf8").split(/\r?\n/)); + return { ...entry, ...(local.version ? { version: local.version } : {}) }; + } catch { return entry; } + }); + return dependencies.length === 0 ? summary : { ...summary, dependencies }; + } catch (error) { + return { + root: project.root, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +interface ProjectSnapshot { + summary: ProjectSummary | undefined; + lock: LockPackage[]; +} + +export function registerProjectView(context: vscode.ExtensionContext, deps: ProjectViewDeps): void { + // Read from the manifest rather than copied into the source: a hint in the tree + // that disagrees with `package.json` is worse than no hint. + const keybindings = keybindingsFromPackage(context.extension.packageJSON); + const platform: KeyboardPlatform = process.platform === "darwin" ? "mac" : "other"; + const bridge = deps.languageService; + + // Counting source files walks the project, so it is not repeated for every + // active-editor change: the manifest watcher and a workspace-folder change ask + // for a fresh count, everything else reuses the last one. + let measured: { root: string; count: number } | undefined; + const withSources = (summary: ProjectSummary | undefined, fresh: boolean): ProjectSummary | undefined => { + if (summary === undefined || summary.error !== undefined) { + return summary; + } + if (fresh || measured === undefined || measured.root !== summary.root) { + measured = { root: summary.root, count: countSourceFiles(summary.root) }; + } + return { ...summary, sourceFiles: measured.count }; + }; + + const snapshot = (project: McppProjectDiscovery | undefined, freshSources: boolean): ProjectSnapshot => ({ + summary: withSources(summariseProject(project), freshSources), + lock: project === undefined ? [] : readLockfile(path.join(project.root, LOCKFILE_NAME)), + }); + + let current = snapshot(deps.currentProject(), true); + const invalidateGraph = registerDependencyGraph(context, () => current.summary); + const metadata = new ProjectMetadataWatch(); + + const view = registerTreeView(PROJECT_VIEW_ID, () => + buildProjectTree(current.summary, { + lock: current.lock, + keybindings, + platform, + languageService: bridge?.input(), + }), + ); + context.subscriptions.push(view.disposable); + + const reload = (freshSources: boolean): void => { + const active = vscode.window.activeTextEditor; + let project = (!active || active.document.uri.scheme !== "file") && current.summary + ? { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") } + : deps.currentProject(); + if (!project && current.summary && active?.document.uri.scheme === "file") { + const relative = path.relative(current.summary.root, active.document.uri.fsPath); + if (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) { + project = { root: current.summary.root, manifestPath: path.join(current.summary.root, "mcpp.toml") }; + } + } + metadata.capture(project?.root); + const next = snapshot(project, freshSources); + next.summary = retainProjectDependencies(next.summary, current.summary); + const changed = JSON.stringify(next) !== JSON.stringify(current); + current = next; + if (changed) { view.provider.refresh(); } + }; + + let freshSourcesRequested = false; + const refresh = new RefreshCoordinator(async () => { const fresh = freshSourcesRequested; freshSourcesRequested = false; reload(fresh); }); + const schedule = (fresh = false): void => { freshSourcesRequested ||= fresh; void refresh.request(); }; + context.subscriptions.push(refresh); + let metadataTimer: ReturnType | undefined; + const checkMetadata = (): void => { + const changes = metadata.changes(view.view.visible); + for (const file of changes) { invalidateGraph(vscode.Uri.file(file)); } + if (changes.length) { schedule(changes.some(file => path.basename(file) === "mcpp.toml")); } + }; + const metadataVisibility = (): void => { + if (metadataTimer) { clearInterval(metadataTimer); metadataTimer = undefined; } + if (view.view.visible) { checkMetadata(); metadataTimer = setInterval(checkMetadata, 2000); } + }; + context.subscriptions.push(view.view.onDidChangeVisibility(metadataVisibility), {dispose:()=>{if(metadataTimer)clearInterval(metadataTimer);}}, + vscode.workspace.onDidSaveTextDocument(document => { if (["mcpp.toml","mcpp.lock"].includes(path.basename(document.uri.fsPath))) schedule(document.uri.fsPath.endsWith("mcpp.toml")); })); + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(LOCKFILE_GLOB); + context.subscriptions.push( + manifestWatcher, + lockWatcher, + manifestWatcher.onDidCreate(() => schedule(true)), + manifestWatcher.onDidChange(() => schedule(true)), + manifestWatcher.onDidDelete(() => schedule(true)), + lockWatcher.onDidCreate(() => schedule()), + lockWatcher.onDidChange(() => schedule()), + lockWatcher.onDidDelete(() => schedule()), + vscode.tasks.onDidEndTaskProcess(() => schedule()), + vscode.window.onDidChangeActiveTextEditor(() => { + const active = vscode.window.activeTextEditor; + if (active?.document.uri.scheme !== "file") { return; } + const project = deps.currentProject(); + if (project?.root !== current.summary?.root) { reload(false); } + }), + vscode.workspace.onDidChangeWorkspaceFolders(() => { current = snapshot(deps.currentProject(), true); view.provider.refresh(); }), + vscode.workspace.onDidGrantWorkspaceTrust(() => schedule(true)), + ); + + if (bridge !== undefined) { + const tree = view.view; + context.subscriptions.push( + bridge.onDidChange(() => view.provider.refresh()), + // The folded block's poller only runs while this tree is on screen. + tree.onDidChangeVisibility(() => bridge.setVisible(tree.visible)), + ); + bridge.setVisible(tree.visible); + } + + context.subscriptions.push( + vscode.commands.registerCommand("mcpp.internal.refreshProjectView", async () => { + reload(true); + }), + ); + + reload(true); + metadataVisibility(); +} diff --git a/mide-plugins/vscode/src/views/treeProvider.ts b/mide-plugins/vscode/src/views/treeProvider.ts new file mode 100644 index 0000000..e1e3435 --- /dev/null +++ b/mide-plugins/vscode/src/views/treeProvider.ts @@ -0,0 +1,113 @@ +/** + * The tree provider for the project view. + * + * The trees are described as data (`./models.ts`); this file is the only part + * that knows about `vscode.TreeItem`. Labels carry keys, so the translation + * happens here, at render time. + */ + +import * as vscode from "vscode"; + +import { t } from "../i18n/t"; +import type { Label, LabelArgument, TreeNode } from "./models"; + +/** `{ key, args }` -> a sentence in the user's language. */ +export function resolveLabel(label: Label | undefined): string { + if (label === undefined) { + return ""; + } + return t(label.key, ...(label.args ?? []).map(resolveArgument)); +} + +/** + * An argument is usually a value, but it may be another label: `C++23 · 87 + * source file(s)` is two translatable pieces, and the join happens here rather + * than in a builder that must stay free of the current language. + */ +function resolveArgument(argument: LabelArgument): string | number { + return typeof argument === "object" ? resolveLabel(argument) : argument; +} + +export function toTreeItem(node: TreeNode): vscode.TreeItem { + const collapsible = + node.children === undefined || node.children.length === 0 + ? vscode.TreeItemCollapsibleState.None + : node.expanded === true + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + + const item = new vscode.TreeItem(resolveLabel(node.label), collapsible); + item.id = node.id; + item.description = node.description === undefined ? undefined : resolveLabel(node.description); + item.tooltip = node.tooltip === undefined ? undefined : resolveLabel(node.tooltip); + // VS Code otherwise substitutes the tooltip for the accessible tree label. + item.accessibilityInformation = { label: [resolveLabel(node.label), item.description].filter(Boolean).join(" ") }; + if (node.icon !== undefined) { + // A `ThemeColor` id is resolved by the host; an id a theme does not define + // leaves the icon in the normal foreground colour, which is why the model + // only ever names `charts.*`. + item.iconPath = + node.iconColor === undefined || node.iconColor.length === 0 + ? new vscode.ThemeIcon(node.icon) + : new vscode.ThemeIcon(node.icon, new vscode.ThemeColor(node.iconColor)); + } + item.contextValue = node.contextValue; + if (node.command !== undefined) { + item.command = { + command: node.command.command, + title: resolveLabel(node.command.title), + arguments: [...(node.command.arguments ?? [])], + }; + } + return item; +} + +/** + * A provider over a snapshot the caller recomputes whenever something changes. + * `refresh()` re-asks for the root nodes and fires the tree event. + * + * It is disposable so it can be handed straight to `createTreeView`'s + * `context.subscriptions.push`, next to the view it feeds. + */ +export class StaticTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + + public readonly onDidChangeTreeData = this.changed.event; + + public constructor(private readonly roots: () => readonly TreeNode[]) {} + + public refresh(): void { + this.changed.fire(undefined); + } + + public dispose(): void { + this.changed.dispose(); + } + + public getTreeItem(element: TreeNode): vscode.TreeItem { + return toTreeItem(element); + } + + public getChildren(element?: TreeNode): TreeNode[] { + if (element === undefined) { + return [...this.roots()]; + } + return [...(element.children ?? [])]; + } +} + +/** + * Registers a view and hands back the provider, the view and its disposable. + * + * The `TreeView` is returned (rather than using `registerTreeDataProvider`) + * because a caller may need its visibility: the project view gates the C++ + * Modules poller on whether its tree is actually on screen. + */ +export function registerTreeView( + viewId: string, + roots: () => readonly TreeNode[], +): { provider: StaticTreeProvider; view: vscode.TreeView; disposable: vscode.Disposable } { + const provider = new StaticTreeProvider(roots); + const view = vscode.window.createTreeView(viewId, { treeDataProvider: provider }); + return { provider, view, disposable: view }; +} diff --git a/mide-plugins/vscode/src/views/viewPolicy.ts b/mide-plugins/vscode/src/views/viewPolicy.ts new file mode 100644 index 0000000..6dffc0d --- /dev/null +++ b/mide-plugins/vscode/src/views/viewPolicy.ts @@ -0,0 +1,46 @@ +/** + * The C++ Modules view's settings, as decisions. + * + * Three settings shape that view, and each one is a policy question rather than + * a rendering detail, so the answers live here — free of `vscode` and therefore + * testable: + * + * - `mcpp.languageService.confirmResetCache` — does resetting the workspace cache + * ask a modal of its own, or does it rely on the capability table's `danger`? + * The answer may only ever *add* a confirmation; + * - `mcpp.languageService.notifyOnDegraded` — is the one-time "the dependency is + * gone" notice emitted at all? + * + * `src/mcppls/contract.ts` stays the single source of what each capability's + * danger is; `src/views/languageServerView.ts` stays the only place that talks to + * `vscode.window`. + */ + +export type LsConfirmation = "extra-modal" | "capability" | "none"; + +export interface ResetCacheConfirmInput { + /** The capability's own `danger` level, from `contract.ts`. */ + danger: "none" | "confirm" | "destructive"; + /** `mcpp.languageService.confirmResetCache`. */ + confirmResetCache: boolean; +} + +/** + * What the reset-cache command must ask before forwarding. + * + * Both settings default to `true`, so the shipped behaviour is unchanged: + * the destructive capability asks its modal, and this setting adds a second + * explicit step. Turning the setting off falls back to the capability's own + * level — it never removes a `destructive` modal. + */ +export function resetCacheConfirmation(input: ResetCacheConfirmInput): LsConfirmation { + if (!input.confirmResetCache) { + return input.danger === "none" ? "none" : "capability"; + } + return input.danger === "none" ? "none" : "extra-modal"; +} + +/** `mcpp.languageService.notifyOnDegraded`: emit the one-time dependency notice? */ +export function degradedNoticeEnabled(notifyOnDegraded: boolean | undefined): boolean { + return notifyOnDegraded !== false; +} diff --git a/mide-plugins/vscode/src/webview/document.ts b/mide-plugins/vscode/src/webview/document.ts new file mode 100644 index 0000000..cdd8005 --- /dev/null +++ b/mide-plugins/vscode/src/webview/document.ts @@ -0,0 +1,64 @@ +/** + * One webview document: the CSP nonce and the render-only-on-change rule every + * webview host in this extension shares (library view, cache view, settings + * panel, package detail page). + * + * Assigning `webview.html` reloads the document — it throws away the scroll + * position, any half-typed input and the focus. Two properties make a host + * safe against pointless and looping reloads: + * + * 1. **The nonce is per view, not per render.** The CSP nonce is embedded in + * the document, so a fresh nonce per render makes every render a *different* + * document and no comparison can ever say "unchanged" — that is exactly how + * the library view once reloaded itself forever (flicker, unclickable rows, + * a pegged CPU). + * 2. **`paint()` assigns only when the document changed**, via + * `documentNeedsRender` in `./render`. + * + * A hidden `WebviewView` loses its context (`retainContextWhenHidden` is not + * supported), so the next resolve is a brand-new, empty webview: the host + * calls `invalidate()` on resolve and on dispose, and the comparison starts + * from nothing again. + */ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { documentNeedsRender } from "./render"; + +/** What an html renderer embeds: the CSP source, the script nonce, the stylesheet. */ +export interface WebviewAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** One webview's document state: its nonce and the html currently on screen. */ +export class WebviewDocument { + private readonly nonce = randomBytes(16).toString("base64"); + private current: string | undefined; + + /** @param stylesheet the stylesheet file, joined onto the media root. */ + constructor(private readonly stylesheet: string) {} + + /** The webview was (re)resolved or disposed: it is a fresh, empty document. */ + invalidate(): void { + this.current = undefined; + } + + assets(mediaRoot: vscode.Uri, webview: vscode.Webview): WebviewAssets { + return { + cspSource: webview.cspSource, + nonce: this.nonce, + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, this.stylesheet)).toString(), + }; + } + + /** Put the document on screen, but only when it says something new. */ + paint(webview: vscode.Webview, html: string): void { + if (!documentNeedsRender(this.current, html)) { + return; + } + this.current = html; + webview.html = html; + } +} diff --git a/mide-plugins/vscode/src/webview/readOnlyDocuments.ts b/mide-plugins/vscode/src/webview/readOnlyDocuments.ts new file mode 100644 index 0000000..b35f55a --- /dev/null +++ b/mide-plugins/vscode/src/webview/readOnlyDocuments.ts @@ -0,0 +1,20 @@ +import { createHash } from "node:crypto"; + +/** Content-addressed text documents. A content provider exposes them without a writable filesystem. */ +export class ReadOnlyDocuments { + private readonly documents = new Map(); + constructor(private readonly scheme: string, private readonly captured?: (uri: string) => void) {} + capture(file: { path: string; text: string }): string { + const digest = createHash("sha256").update(file.text).digest("hex"); + const uri = `${this.scheme}:/${file.path.split(/[\\/]/).map(encodeURIComponent).join("/")}?snapshot=${digest}`; + this.documents.set(digest, file.text); + this.captured?.(uri); + return uri; + } + read(uri: string): string | undefined { + try { + const digest = /[?&]snapshot=([a-f0-9]{64})(?:$|&)/.exec(decodeURIComponent(uri))?.[1]; + return digest ? this.documents.get(digest) : undefined; + } catch { return undefined; } + } +} diff --git a/mide-plugins/vscode/src/webview/render.ts b/mide-plugins/vscode/src/webview/render.ts new file mode 100644 index 0000000..0de9fe8 --- /dev/null +++ b/mide-plugins/vscode/src/webview/render.ts @@ -0,0 +1,18 @@ +/** + * The pure half of the webview render kit. + * + * This module deliberately imports nothing — not even `vscode` — so the unit + * tests can state its semantics directly under `node --test`. + */ + +/** + * Whether a freshly rendered document should replace the one on screen. + * + * Assigning `webview.html` reloads the view, so an identical document must not + * be pushed. This is the guard that keeps a "render again" request from + * turning into a reload loop; it is the one rule of the kit that a unit test + * can state in one line. + */ +export function documentNeedsRender(rendered: string | undefined, next: string): boolean { + return rendered !== next; +} diff --git a/mide-plugins/vscode/src/workflows/moduleSetup.ts b/mide-plugins/vscode/src/workflows/moduleSetup.ts new file mode 100644 index 0000000..6810dde --- /dev/null +++ b/mide-plugins/vscode/src/workflows/moduleSetup.ts @@ -0,0 +1,95 @@ +import { t } from "../i18n/t"; + +export type ModuleSetupStage = "build" | "language-server"; +export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled" | "not-started"; +export type ModuleSetupBlockedReason = "untrusted" | "busy"; + +export type ModuleSetupDecision = + | { kind: "ready" } + | { kind: "blocked"; reason: ModuleSetupBlockedReason }; + +export function buildModuleSetupPlan( + trusted: boolean, + busy: boolean, +): ModuleSetupDecision { + if (!trusted) { + return { kind: "blocked", reason: "untrusted" }; + } + if (busy) { + return { kind: "blocked", reason: "busy" }; + } + return { kind: "ready" }; +} + +export interface ModuleSetupConfirmation { + message: string; + detail: string; +} + +export function moduleSetupConfirmation(): ModuleSetupConfirmation { + return { + message: t("Build the current mcpp project and refresh the C++ Modules language service?"), + detail: t("This runs mcpp build and has the C++ Modules extension re-read the build description; it does not change the toolchain, mcpp.toml or any language service setting."), + }; +} + +export interface ModuleSetupStepResult { + stage: ModuleSetupStage; + state: ModuleSetupStepState; + detail?: string; + exitCode?: number; +} + +export interface ModuleSetupOperations { + build(): Promise; + refreshLanguageServer(): Promise; +} + +export type ModuleSetupOutcome = + | { + state: "succeeded"; + degraded: boolean; + steps: ModuleSetupStepResult[]; + } + | { + state: "failed" | "cancelled"; + stage: ModuleSetupStage; + degraded: false; + steps: ModuleSetupStepResult[]; + }; + +function stoppedOutcome( + result: ModuleSetupStepResult, + steps: ModuleSetupStepResult[], +): Exclude { + return { + state: result.state === "cancelled" ? "cancelled" : "failed", + stage: result.stage, + degraded: false, + steps, + }; +} + +export async function executeModuleSetup( + _decision: Extract, + operations: ModuleSetupOperations, +): Promise { + const steps: ModuleSetupStepResult[] = []; + const buildResult = await operations.build(); + steps.push(buildResult); + if (buildResult.state === "cancelled" || buildResult.state === "not-started") { + return stoppedOutcome(buildResult, steps); + } + + const languageServerResult = await operations.refreshLanguageServer(); + steps.push(languageServerResult); + if (languageServerResult.state !== "succeeded") { + return stoppedOutcome(languageServerResult, steps); + } + + return { + state: "succeeded", + degraded: buildResult.state === "failed", + steps, + }; +} diff --git a/mide-plugins/vscode/test/architecture.test.ts b/mide-plugins/vscode/test/architecture.test.ts new file mode 100644 index 0000000..a5cc8f6 --- /dev/null +++ b/mide-plugins/vscode/test/architecture.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The architectural rule this codebase is built on: **pure modules never import + * `vscode`**. + * + * It is what makes the logic testable under plain `node --test`, and it is easy + * to break by accident — a convenience `vscode.window.showWarningMessage` in a + * policy helper, or an `import * as vscode` at the top of a translation module, + * silently takes the whole file out of the unit test's reach. So the rule is + * checked two ways: the import is absent from the source, and the module can + * actually be loaded outside an editor host. + */ + +/** The modules that must stay importable without VS Code. */ +const PURE_MODULES = [ + "src/i18n/t.ts", + "src/i18n/translate.ts", + "src/cli/clean.ts", + "src/cli/cache.ts", + "src/cli/artifacts.ts", + "src/cli/process.ts", + "src/cli/protocol.ts", + "src/cli/tasks.ts", + "src/cli/newProject.ts", + "src/cli/toolchain.ts", + "src/cli/labels.ts", + "src/cli/statusBar.ts", + "src/cli/selfCheck.ts", + "src/config/registry.ts", + "src/config/validate.ts", + "src/config/panelHtml.ts", + "src/mcppls/contract.ts", + "src/mcppls/state.ts", + "src/mcppls/messages.ts", + "src/mcppls/timers.ts", + "src/projects/discovery.ts", + "src/projects/summary.ts", + "src/projects/moduleGraph.ts", + "src/graphs/model.ts", + "src/graphs/refresh.ts", + "src/graphs/export.ts", + "src/projects/dependencyState.ts", + "src/projects/metadataWatch.ts", + "src/graphs/panelHtml.ts", + "src/cli/moduleGraph.ts", + "src/webview/readOnlyDocuments.ts", + "src/toml/parser.ts", + "src/views/models.ts", + "src/cache/cacheState.ts", + "src/library/indexModel.ts", + "src/library/xpkg.ts", + "src/library/libraryHtml.ts", + "src/library/detailHtml.ts", + "src/cache/cachePanelHtml.ts", + "src/views/viewPolicy.ts", + "src/util/format.ts", + "src/util/log.ts", + "src/util/text.ts", + "src/webview/render.ts", + "src/workflows/moduleSetup.ts", +]; + +const root = process.cwd(); + +test("a pure module does not import vscode", () => { + const offenders: string[] = []; + for (const relative of PURE_MODULES) { + const text = readFileSync(path.join(root, relative), "utf8"); + // `import type` is fine: it is erased at compile time and costs nothing at + // runtime, which is exactly how `src/i18n/t.ts` reads the l10n API lazily. + for (const match of text.matchAll(/^\s*import\s+(?!type\b)[^;]*from\s+"vscode";/gm)) { + offenders.push(`${relative}: ${match[0].trim()}`); + } + assert.ok(!/require\(\s*["']vscode["']\s*\)/.test(text.replace(/require\(\s*["']vscode["']\s*\)\s*as\s+typeof\s+vscode/g, "")), + `${relative} requires vscode at module scope`); + } + assert.deepEqual(offenders, [], `these pure modules must not import vscode:\n ${offenders.join("\n ")}`); +}); + +test("a pure module can actually be loaded without an editor host", async () => { + // The source check above proves the absence of an import; this proves the + // consequence, which is the property the unit tests depend on. + const failed: string[] = []; + for (const relative of PURE_MODULES) { + const compiled = path.join(root, "dist", relative.replace(/\.ts$/, ".js")); + try { + await import(compiled); + } catch (error) { + failed.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`); + } + } + assert.deepEqual(failed, [], `these pure modules could not load outside VS Code:\n ${failed.join("\n ")}`); +}); + +test("the list is not so short that the rule stops meaning anything", () => { + assert.ok(PURE_MODULES.length >= 40, `only ${PURE_MODULES.length} modules are checked`); +}); + +test("every listed module exists", () => { + for (const relative of PURE_MODULES) { + readFileSync(path.join(root, relative), "utf8"); + } +}); + +test("runProcess stays inside src/cli — everything else runs mcpp through the trust-gated seam", () => { + // The external review's P0 (2026-10-03): `mcpp.path` is a resource-scoped + // setting, so any call site that skips the workspace-trust gate lets an + // untrusted workspace name the program that runs. The seam is `runMcpp`; + // `src/cli/` keeps `runProcess` because its controller wraps whole commands + // in `requireTrusted()` prompts of its own. + const offenders: string[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.name.endsWith(".ts")) { + continue; + } + const relative = path.relative(root, full); + if (relative.split(path.sep)[1] === "cli") { + continue; + } + const text = readFileSync(full, "utf8"); + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/\brunProcess\b/.test(code)) { + offenders.push(relative); + } + } + }; + walk(path.join(root, "src")); + assert.deepEqual(offenders, [], "these files call runProcess directly; use runMcpp(trusted, …) instead"); +}); diff --git a/mide-plugins/vscode/test/artifacts.test.ts b/mide-plugins/vscode/test/artifacts.test.ts new file mode 100644 index 0000000..a5ba7d9 --- /dev/null +++ b/mide-plugins/vscode/test/artifacts.test.ts @@ -0,0 +1,700 @@ +import assert from "node:assert/strict"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { contributedCommandIds } from "../src/commands/ids"; + +interface PackageManifest { + version?: string; + displayName?: string; + description?: string; + icon?: string; + dependencies?: Record; + devDependencies?: Record; + scripts?: Record; + repository?: { url?: string }; + homepage?: string; + bugs?: { url?: string }; + engines?: { vscode?: string }; + extensionDependencies?: string[]; + activationEvents?: string[]; + capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string; restrictedConfigurations?: string[] } }; + contributes?: { + commands?: Array<{ command: string; title?: string; category?: string; icon?: string }>; + menus?: { "editor/title"?: Array<{ command: string; group?: string; when?: string }>; "editor/context"?: Array<{ command: string; group?: string; when?: string }> }; + configuration?: { title?: string; properties?: Record }; + configurationDefaults?: Record; + languages?: Array<{ id: string; aliases?: string[]; filenames?: string[]; configuration?: string }>; + viewsContainers?: { activitybar?: Array<{ id: string; title?: string; icon?: string }> }; + views?: Record< + string, + Array<{ + id: string; + name?: string; + description?: string; + when?: string; + type?: string; + visibility?: string; + initialSize?: number; + }> + >; + viewsWelcome?: Array<{ view: string; contents: string; when?: string }>; + colors?: Array<{ id: string; description?: string }>; + keybindings?: Array<{ command: string; key?: string; mac?: string; when?: string }>; + grammars?: Array<{ language?: string; scopeName: string; injectTo?: string[]; path: string }>; + }; +} + +const root = path.resolve(process.cwd()); + +test("declares mcpp-language-server as the C++ modules language service", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // The version lives in three places; they must agree or a release tag is a lie. + const lock = JSON.parse(readFileSync(path.join(root, "package-lock.json"), "utf8")) as { + version?: string; + packages?: Record; + }; + assert.match(manifest.version ?? "", /^\d+\.\d+\.\d+$/); + assert.equal(lock.version, manifest.version); + assert.equal(lock.packages?.[""]?.version, manifest.version); + // User-visible strings live in the nls bundle; the manifest only names them. + assert.equal(manifest.description, "%description%"); + assert.equal(manifest.displayName, "%displayName%"); + assert.equal(manifest.engines?.vscode, "^1.91.0"); + assert.deepEqual(manifest.extensionDependencies, ["sunrisepeak.mcpp-language-server"]); + assert.ok(!manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); + // Since VS Code 1.74 every contributes.commands entry implies its own + // onCommand activation, so only the file- and folder-based triggers remain. + assert.deepEqual(manifest.activationEvents, [ + "workspaceContains:mcpp.toml", + "onLanguage:mcpp-toml", + // The `mcpp` launch configurations (1.1.0): resolved, and offered with no launch.json. + "onDebugResolve:mcpp", + "onDebugDynamicConfigurations:mcpp", + ]); + assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); + assert.equal(manifest.capabilities?.untrustedWorkspaces?.description, "%untrustedWorkspaces.description%"); + // The `limited` promise says "no mcpp command runs" in an untrusted + // workspace; a resource-scoped `mcpp.path` can only be kept out of the + // workspace's reach by naming it here (external review P0, 2026-10-03). + assert.deepEqual(manifest.capabilities?.untrustedWorkspaces?.restrictedConfigurations, [ + "mcpp.path", + "mcpp.clangd.path", + ]); + // Same set, not necessarily the same order: the manifest's order is the + // palette's presentation order, which `ids.ts` has no business dictating. + assert.deepEqual( + (manifest.contributes?.commands?.map((command) => command.command) ?? []).slice().sort(), + contributedCommandIds().slice().sort(), + ); + assert.deepEqual( + manifest.contributes?.viewsContainers?.activitybar?.map((container) => container.id), + ["mcpp"], + ); + assert.deepEqual( + manifest.contributes?.views?.mcpp?.map((view) => view.id), + ["mcpp.project", "mcpp.library", "mcpp.cache"], + ); + assert.deepEqual( + manifest.contributes?.colors?.map((color) => color.id), + ["mcpp.cacheOkForeground", "mcpp.cacheStaleForeground"], + ); + assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.path"]); + assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.tomlCompletion"]); + assert.equal(manifest.contributes?.configuration?.title, "%mcpp.configuration.title%"); + for (const command of manifest.contributes?.commands ?? []) { + assert.match(command.title ?? "", /^%command\.[^%]+\.title%$/, `command ${command.command} title must be an nls key`); + assert.equal(command.category, "%category%"); + } + // The language client connects the backend (mcpp.toml, build.mcpp); it is bundled into dist/vendor, + // so the package carries no node_modules and the manifest names no runtime dependency. + assert.equal(manifest.dependencies?.["vscode-languageclient"], undefined); + assert.equal(manifest.devDependencies?.["vscode-languageclient"], "9.0.1"); + assert.match(manifest.scripts?.compile ?? "", /node tools\/bundle-languageclient\.mjs/); + assert.deepEqual(manifest.contributes?.configurationDefaults?.["files.associations"], { + "*.ccm": "cpp", + "*.cppm": "cpp", + "*.ixx": "cpp", + "*.mpp": "cpp", + }); +}); + +test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + // Slice up to a marker that exists for its own sake, not as a test hook: the + // wizard is the last function before `activate`. + const start = source.indexOf("async function autoConfigureModulesWizard"); + const end = source.indexOf("export async function activate(", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const wizard = source.slice(start, end); + for (const expected of [ + "buildModuleSetupPlan", + "moduleSetupConfirmation", + "executeModuleSetup", + 'runProjectTask("build", { notify: false })', + "refreshLanguageServerAfterBuild", + ]) { + assert.ok(wizard.includes(expected), `wizard missing ${expected}`); + } + assert.match(wizard, /modal:\s*true/); + assert.doesNotMatch(wizard, /CLI_COMMANDS\.(installToolchain|selectDefaultToolchain)/); + assert.doesNotMatch(wizard, /readToolchainInventory|runAutomaticModuleSetup|ensureClangd|loadProjectContext/); + assert.doesNotMatch(controller, /runAutomaticModuleSetup|executeAutomaticModuleSetupCommand/); +}); + +test("the promised keybindings are contributed and scoped to a project", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const bindings = manifest.contributes?.keybindings ?? []; + // The set, not the order: the keys differ, so the order decides nothing (spec/commands.json + // lists the commands by group). + assert.deepEqual( + bindings.map((binding) => binding.command).sort(), + ["mcpp.build", "mcpp.cleanProjectArtifacts", "mcpp.run", "mcpp.showMenu", "mcpp.test"], + ); + for (const binding of bindings) { + assert.match(binding.key ?? "", /^ctrl\+alt\+[a-z]$/); + assert.match(binding.mac ?? "", /^cmd\+alt\+[a-z]$/); + assert.equal(binding.when, "mcpp.inProject"); + } +}); + +test("each view is gated by its own visibility setting", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // The master switch comes first, in negated form: with `mcpp.views.enabled` off + // every view is hidden and VS Code drops the container from the activity bar. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.when), [ + "!mcpp.sidebarHidden && mcpp.views.project", + "!mcpp.sidebarHidden && mcpp.views.library", + "!mcpp.sidebarHidden && mcpp.views.cache", + ]); + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.type), [ + undefined, + "webview", + "webview", + ]); + // The cache view starts folded, so the sidebar opens on the library list; the + // two above it start open. `visibility` is the only way a view can say this — + // VS Code keeps whatever the user does to the header afterwards. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.visibility), [ + undefined, + undefined, + "collapsed", + ]); + // `initialSize` becomes the view's split-view `weight`, and VS Code hands out + // the container height in proportion to it — defaulting every view to 20 + // (`computeInitialSizes()`: `dimension.height * (weight || 20) / total`). Twice + // the default is what makes the library take the bottom two thirds and leaves + // the tree above it its third, which is the layout the sidebar is for. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.initialSize), [ + undefined, + 40, + undefined, + ]); +}); + +test("every webview view registers the provider that fills it", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const webviews = (manifest.contributes?.views?.mcpp ?? []).filter((view) => view.type === "webview"); + assert.ok(webviews.length > 0, "this gate is pointless without a webview view"); + for (const view of webviews) { + // The view id is declared once, in the module that owns the view (other + // modules re-export the constant), and that module must also call + // `registerWebviewViewProvider`. Without it VS Code never asks the view for + // a document and every `refresh()` silently does nothing — which is exactly + // the bug this test exists for: the library view declared its id, exported a + // provider, wired its listeners, and never registered. + const declaration = new RegExp(`export const \\w*_VIEW_ID = "${view.id}"`); + const owners = sourceFiles().filter(([, text]) => declaration.test(text)); + assert.equal(owners.length, 1, `${view.id} must have exactly one declaring module, found ${owners.length}`); + assert.ok( + owners[0][1].includes("registerWebviewViewProvider("), + `${owners[0][0]} declares ${view.id} but never calls registerWebviewViewProvider`, + ); + } +}); + +test("the library view cannot reload itself in a loop", () => { + const view = readFileSync(path.join(root, "src/library", "libraryView.ts"), "utf8"); + const html = readFileSync(path.join(root, "src/library", "libraryHtml.ts"), "utf8"); + // Assigning `webview.html` reloads the document. The library document used to + // announce its own load with a `ready` message and the host answered it with a + // repaint, so the view reloaded itself forever: flicker, rows that could not be + // clicked, a pegged CPU. Both halves of that handshake are gone. + assert.doesNotMatch(html, /post\(\{\s*type:\s*"ready"\s*\}\)/, "the document must not announce its own load"); + assert.doesNotMatch(view, /case "ready"/, "the host must not answer a load with a re-render"); + assert.doesNotMatch(html, /\| \{ type: "ready" \}/); + // Two guards keep it shut: the render-only-on-change comparison, and a nonce + // that is per *view* — a nonce per render would make every render a different + // document, so the comparison could never say "unchanged". Both now live in + // the shared WebviewDocument kit, so the gate holds the host to the kit + // instead of to one implementation of it. Comments are stripped first: the + // prose explains the rule with the very assignment it forbids. + const viewCode = view.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(viewCode, /new WebviewDocument\(/, "the host must hold its document through the kit"); + assert.doesNotMatch(viewCode, /randomNonce\(\)/, "the host must not mint its own nonce"); + assert.doesNotMatch(viewCode, /webview\.html\s*=/, "the host must assign html only through the kit"); + // A freshly resolved view is a new, empty webview: the last document says + // nothing about it, so it must be forgotten when the old view goes away. + assert.match(view, /\.invalidate\(\);/); +}); + +test("every webview host holds its document through the kit", () => { + // The nonce and the html assignment are the two halves of the reload-loop + // accident (see the library gate above); since the kit extraction they are + // owned once. Every host — library, cache, settings panel, detail page — + // must go through `WebviewDocument`, and a nonce may be minted nowhere but + // the kit. + const hosts = [ + "src/library/libraryView.ts", + "src/cache/cachePanel.ts", + "src/config/panel.ts", + "src/library/detailPanel.ts", + ]; + for (const host of hosts) { + const text = readFileSync(path.join(root, host), "utf8"); + // Comments are stripped: the prose explains the rules with the very code + // it forbids. + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(code, /new WebviewDocument\(/, `${host} must hold its document through the kit`); + assert.doesNotMatch(code, /\.html\s*=/, `${host} must assign html only through the kit`); + } + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + if (file === path.join("src", "webview", "document.ts")) { + continue; + } + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/randomBytes/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a webview nonce is minted outside src/webview/document.ts"); +}); + +test("the one language client is the backend's, and it serves no C++ source", () => { + // C++ is C++ Modules' (mcppls): a second client over C++ sources would answer every request + // twice. The backend's client (src/ide/session.ts) reads mcpp.toml and build.mcpp alone. + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + if (file === path.join("src", "ide", "session.ts")) { + continue; + } + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/vscode-languageclient|vendor\/languageclient/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a language client is driven outside the session"); + const session = readFileSync(path.join(root, "src", "ide", "session.ts"), "utf8"); + const selector = /export const DOCUMENT_SELECTOR = \[([\s\S]*?)\];/.exec(session)?.[1] ?? ""; + assert.match(selector, /language: "mcpp-toml"/); + for (const entry of selector.split("\n").filter((line) => line.includes('language: "cpp"'))) { + assert.match(entry, /pattern: "\*\*\/build\.mcpp"/, `a C++ selector beyond build.mcpp: ${entry.trim()}`); + } +}); + +test("the committed snapshots record a clone-independent commit", () => { + // `git rev-parse --short` shortens to the shortest unique prefix *for that + // clone*, so the same mcpp produced a 7-character hash in a fresh CI checkout + // and 8 in a developer's — and the drift gate went red on an unchanged API. + for (const file of ["../../spec/buildscript-api.json", "../../spec/toml-schema.json"]) { + const snapshot = JSON.parse(readFileSync(path.join(root, file), "utf8")) as { + sourceVersion?: string; + sourceCommit?: string; + }; + assert.match(snapshot.sourceVersion ?? "", /^\d{4}\.\d+\.\d+/, `${file}: no mcpp version`); + assert.match( + snapshot.sourceCommit ?? "", + /^[0-9a-f]{40}$/, + `${file}: the provenance hash must be full length, not an abbreviation`, + ); + } +}); + +test("opening a link always produces an answer", () => { + // The comment above `openExternal` quotes the old call, so comments are + // stripped before the check — the point is the code, not the prose. + const panel = readFileSync(path.join(root, "src", "library", "detailPanel.ts"), "utf8").replace( + /\/\*[\s\S]*?\*\//g, + "", + ); + // `void vscode.env.openExternal(…)` drops the boolean it resolves to, so a host + // that cannot open a browser answered a click with nothing at all. The call is + // awaited, both outcomes are posted to the page, and a failure leaves the URL + // somewhere the reader can use it. + assert.doesNotMatch(panel, /void vscode\.env\.openExternal/); + assert.match(panel, /opened = await vscode\.env\.openExternal\(/); + assert.match(panel, /type: "linkResult"/); + assert.match(panel, /opened \? t\("Opened in your browser\."/); + assert.match(panel, /vscode\.env\.clipboard\.writeText\(message\.url\)/); + assert.match(panel, /copied \? t\("Could not open the browser\. Link copied to clipboard\."/); + const html = readFileSync(path.join(root, "src", "library", "detailHtml.ts"), "utf8"); + assert.match(html, /detail-link-result/); +}); + +/** `src/**\/*.ts`, relative path and text, for the source-level gates. */ +function sourceFiles(directory = path.join(root, "src")): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(full)); + } else if (entry.name.endsWith(".ts")) { + out.push([path.relative(root, full), readFileSync(full, "utf8")]); + } + } + return out; +} + +test("shows editor title buttons only inside mcpp projects", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // Both conditions matter: a project must be open, and the user must not have + // turned the buttons off with `mcpp.task.editorTitleButtons`. 0.9.0's Run and Test as they were, + // then Build and Debug (design 2026-10-30 part 2 §6.1). + assert.deepEqual(manifest.contributes?.menus?.["editor/title"], [ + { command: "mcpp.editor.build", group: "navigation@0", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + { command: "mcpp.run", group: "navigation@1", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + { command: "mcpp.debug", group: "navigation@3", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + { command: "mcpp.test", group: "navigation@2", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + ]); + // Run, Debug, Build and Test in the editor's context menu. + assert.deepEqual((manifest.contributes?.menus?.["editor/context"] ?? []).map((entry) => entry.command), + ["mcpp.build", "mcpp.run", "mcpp.debug", "mcpp.test"]); + + const commands = manifest.contributes?.commands ?? []; + assert.equal(commands.find((command) => command.command === "mcpp.run")?.icon, "$(play)"); + 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")); +}); + +test("build.mcpp is a cpp document, not a language of its own", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const associations = manifest.contributes?.configurationDefaults?.["files.associations"] as + | Record + | undefined; + // 0.6.0 claimed `build.mcpp` as `mcpp-build`. A filename match beats an + // extension match in VS Code, so that hid the file from C++ Modules (which + // selects `cpp`), and the build program got a text check instead of a compiler. + const languages = manifest.contributes?.languages ?? []; + assert.equal(languages.find((item) => item.id === "mcpp-build"), undefined); + assert.deepEqual(languages.find((item) => item.id === "cpp"), { id: "cpp", filenames: ["build.mcpp"] }); + assert.equal(manifest.contributes?.grammars?.find((item) => item.language === "mcpp-build"), undefined); + assert.equal(associations?.["build.mcpp"], undefined); + assert.equal(associations?.["*.mcpp"], undefined); + assert.ok(!existsSync(path.join(root, "syntaxes/mcpp-build.tmLanguage.json"))); + assert.ok(!existsSync(path.join(root, "syntaxes/mcpp-build-language-configuration.json"))); +}); + +test("restoring other C++ services is offered only on a row that says it can be", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as { + contributes?: { menus?: Record> }; + }; + const entries = manifest.contributes?.menus?.["view/item/context"] ?? []; + const restore = entries.find((entry) => entry.command === "mcpp.languageServer.restoreConflicts"); + assert.ok(restore?.when?.includes("view == mcpp.project")); + const pattern = /viewItem =~ \/(.*)\/$/.exec(restore?.when ?? ""); + assert.ok(pattern, "the menu matches the row's contextValue"); + const matches = new RegExp(pattern[1]); + assert.ok(matches.test("mcppOtherCppFeaturesRestorable")); + assert.ok(!matches.test("mcppOtherCppFeatures")); +}); + +test("ships TOML highlighting for the exact mcpp.toml filename", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const language = manifest.contributes?.languages?.find((item) => item.id === "mcpp-toml"); + assert.deepEqual(language, { + id: "mcpp-toml", + aliases: ["mcpp TOML", "mcpp.toml"], + filenames: ["mcpp.toml"], + }); + + const grammar = manifest.contributes?.grammars?.find((item) => item.language === "mcpp-toml"); + assert.deepEqual(grammar, { + language: "mcpp-toml", + scopeName: "source.toml.mcpp", + path: "./syntaxes/mcpp-toml.tmLanguage.json", + }); + + const grammarText = readFileSync(path.join(root, "syntaxes/mcpp-toml.tmLanguage.json"), "utf8"); + const grammarDocument = JSON.parse(grammarText) as { + repository?: Record; + }; + for (const key of ["comment", "table", "key", "string", "datetime", "number", "boolean", "punctuation"]) { + assert.ok(grammarDocument.repository?.[key], `missing TOML grammar rule: ${key}`); + } + + const tablePattern = new RegExp(grammarDocument.repository?.table.match ?? ""); + assert.match("[package]", tablePattern); + assert.match("[[target.generated]]", tablePattern); + + const keyPattern = new RegExp(grammarDocument.repository?.key.match ?? ""); + assert.match('standard = "c++23"', keyPattern); + assert.match('"quoted.key" = true', keyPattern); + + for (const scope of [ + "comment.line.number-sign.toml", + "entity.name.section.toml", + "variable.other.key.toml", + "string.quoted.double.toml", + "constant.numeric.toml", + "constant.language.boolean.toml", + ]) { + assert.match(grammarText, new RegExp(scope.replaceAll(".", "\\."))); + } +}); + +test("ships an injection grammar with module-specific scopes", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const grammar = manifest.contributes?.grammars?.find((item) => item.scopeName === "source.cpp.mcpp-modules"); + assert.deepEqual(grammar, { + scopeName: "source.cpp.mcpp-modules", + injectTo: ["source.cpp"], + path: "./syntaxes/mcpp-modules.tmLanguage.json", + }); + + const grammarText = readFileSync(path.join(root, "syntaxes/mcpp-modules.tmLanguage.json"), "utf8"); + const grammarDocument = JSON.parse(grammarText) as { + injectionSelector?: string; + repository?: Record; + }; + assert.equal(grammarDocument.injectionSelector, "L:source.cpp"); + for (const key of ["module-declaration", "import-declaration", "module-name"]) { + assert.ok(grammarDocument.repository?.[key], `missing grammar rule: ${key}`); + } + const importRule = grammarDocument.repository?.["import-declaration"] as { match?: string }; + assert.ok(importRule.match?.includes("(?:\\.[A-Za-z_][A-Za-z0-9_]*)*")); + assert.ok(importRule.match?.includes("(?:\\s*;)?")); + assert.ok(!importRule.match?.includes("(?=;)")); + + const javascriptPattern = (importRule.match ?? "").replace(/^\(\?x\)/, ""); + const importPattern = new RegExp(javascriptPattern); + assert.match("import xxx", importPattern); + assert.match("export import foo.bar;", importPattern); + + const importRuleWithCaptures = importRule as { + captures?: Record; + }; + assert.equal(importRuleWithCaptures.captures?.["2"]?.name, "keyword.control.import.cpp"); + assert.match("import mcpp;", importPattern); +}); + +test("设置全局默认后先释放工具链锁再提供立即构建", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const start = source.indexOf("private async selectDefaultToolchainFromInventory"); + const end = source.indexOf("private async pickInstallSpec", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + const method = source.slice(start, end); + const unlock = method.indexOf("this.operations.finishGlobal(token)"); + const immediateBuild = method.indexOf("const buildChoice"); + assert.ok(unlock >= 0 && unlock < immediateBuild); +}); + +test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const start = source.indexOf("public async runProjectTask"); + const end = source.indexOf("public async showToolchains", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + const method = source.slice(start, end); + const unlock = method.indexOf("this.operations.finishProject(project.root, token)"); + const refresh = method.indexOf("this.options.afterProjectTask(project, kind, completion)"); + assert.ok(unlock >= 0 && unlock < refresh); +}); + +test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const start = source.indexOf("public async installToolchain"); + const end = source.indexOf("public async selectDefaultToolchain", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + const method = source.slice(start, end); + assert.doesNotMatch( + method, + /if \(isMsvcToolchainSpec\(spec\) \|\| toolchainSpecTargetHint\(spec\) !== undefined\)/, + ); + assert.match(method, /toolchainInstallKind\(spec\)/); +}); + +test("泛化 triple 工具链由 mcpp 最终校验", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const start = source.indexOf("public async installToolchain"); + const end = source.indexOf("public async selectDefaultToolchain", start); + const method = source.slice(start, end); + + assert.match(method, /may carry target semantics.*mcpp validates it in the end/s); +}); + +test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const start = source.indexOf("public async newProject"); + const end = source.indexOf("private guarded", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + // 控制流本身由 test/newProject.test.ts 对 runNewProjectFlow 的行为级测试覆盖; + // 这里只验证控制器把 UI/进程依赖注入流程函数。 + const method = source.slice(start, end); + assert.match(method, /validateNewProjectName/); + assert.match(method, /runNewProjectFlow/); + const flow = method.indexOf("runNewProjectFlow"); + const exists = method.indexOf("existsSync", flow); + const confirm = method.indexOf("showWarningMessage", flow); + const create = method.indexOf("runMcppCommand", flow); + const open = method.indexOf('executeCommand("vscode.openFolder"', flow); + assert.ok(exists >= 0 && exists < confirm); + assert.ok(confirm >= 0 && confirm < create); + assert.ok(create >= 0 && create < open); +}); + +test("新建工程契约是创建并打开,不自动构建", () => { + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/); + assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/); +}); + +test("声明 GitHub 仓库和扩展图标", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.equal(manifest.icon, "media/logo.png"); + // From 1.0.0 the extension lives in mcpp-ide (mide-plugins/vscode), with the same identity. + assert.equal(manifest.repository?.url, "https://github.com/mcpp-community/mcpp-ide.git"); + assert.equal(manifest.homepage, "https://github.com/mcpp-community/mcpp-ide/tree/main/mide-plugins/vscode#readme"); + assert.equal(manifest.bugs?.url, "https://github.com/mcpp-community/mcpp-ide/issues"); + + const icon = readFileSync(path.join(root, manifest.icon)); + assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); +}); + +test("the activity bar gets the stencil, not the marketplace badge", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const container = manifest.contributes?.viewsContainers?.activitybar?.[0]; + // VS Code masks a container icon with the theme's foreground colour + // (`mask: url(icon)` at 24 px), so what is painted is the icon's *alpha + // channel*. The official badge is an opaque rounded square with the wordmark + // on it: as a stencil that is a solid block, which is what shipped. The + // marketplace icon keeps the real logo; the activity bar gets its derived + // stencil, and `npm run check:icon` proves the derivation is current. + assert.equal(manifest.icon, "media/logo.png"); + assert.equal(container?.icon, "media/activity-bar.png"); + const stencil = readFileSync(path.join(root, "media", "activity-bar.png")); + assert.deepEqual([...stencil.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + // Read from IHDR: 8-bit RGBA, and wider than tall, because the wordmark is. + assert.equal(stencil.readUInt32BE(16), 96); + assert.equal(stencil.readUInt32BE(20), 56); +}); + +test("the READMEs state the responsibility split, the boundary and the limits", () => { + // The English README is the Marketplace listing; the Chinese one is its mirror. + const readme = readFileSync(path.join(root, "README.md"), "utf8"); + const chinese = readFileSync(path.join(root, "README.zh-CN.md"), "utf8"); + + for (const text of [readme, chinese]) { + assert.match(text, /sunrisepeak\.mcpp-language-server/); + assert.match(text, /darwin-x64/); + assert.match(text, /mcpp\.path/); + } + assert.match(readme, /README\.zh-CN\.md/); + assert.match(chinese, /README\.md/); + + // The claims that must not creep back in: this extension is not a language + // client, does not read clangd settings, and does not claim LLVM-only support. + for (const text of [readme, chinese]) { + assert.doesNotMatch(text, /starts? (a|its own) (second )?LSP client/i); + assert.doesNotMatch(text, /reads your `clangd\./); + } + assert.doesNotMatch(readme, /only LLVM/i); +}); + +test("嵌套工程提示不猜测它一定是 mcpp 工作区成员", () => { + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); + assert.doesNotMatch(source, /isWorkspaceMember/); + assert.doesNotMatch(source, /当前是工作区成员/); +}); + +test("the rename offer is only marked answered after the user answered it", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + const body = /async function offerSettingRenames[\s\S]*?\n\}/.exec(source)?.[0] ?? ""; + assert.ok(body.length > 0, "offerSettingRenames must exist"); + assert.ok( + body.indexOf("showInformationMessage") < body.indexOf('workspaceState.update("mcpp.renamesOffered", true)'), + "the marker is written before the prompt again: a dismissed popup would strand the user's renamed settings forever (external review P2-9)", + ); +}); + +// The workflows are the repository's (mcpp-ide): the extension is mide-plugins/vscode of it. +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\*'\]/); + 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/); + assert.match(workflow, /artifacts\/mcpp-vscode-vsix\/\*\.vsix/); + assert.match(workflow, /sha256sum/); + assert.match(workflow, /gh release create/); +}); + +test("the package job guards the published artefact against local leakage", () => { + const workflow = readFileSync(path.join(workflows, "ci.yml"), "utf8"); + assert.match(workflow, /The VSIX stays small and free of local state/); + const forbidden = /for forbidden in ([^;\n]+)/.exec(workflow)?.[1].trim().split(/\s+/) ?? []; + for (const directory of [".dev-profile/", ".agents/", ".agnets/", "test/", "tools/", "src/", "node_modules/"]) { + assert.ok(forbidden.includes(directory), `${directory} must be excluded from the VSIX`); + } + assert.match(workflow, /unzip -t/); + // The backend the package carries: the bundle and every system's runner. + assert.match(workflow, /extension\/backend\/mcpp-ide\.okb/); + for (const system of ["linux-x86_64", "linux-aarch64", "macos-aarch64", "windows-x86_64"]) { + assert.match(workflow, new RegExp(`extension/backend/okrun/${system}/okrun`)); + } +}); + +test("a release publishes Open VSX and leaves the Marketplace upload manual", () => { + const workflow = readFileSync(path.join(workflows, "release.yml"), "utf8"); + assert.match(workflow, /npx ovsx publish "\$GITHUB_WORKSPACE\/\$vsix" --pat "\$OPENVSX_TOKEN"/); + assert.doesNotMatch(workflow, /npx vsce publish/); + // Open VSX is a required destination: a missing token fails the release. + assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); + assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); + assert.match(workflow, /Upload the GitHub Release VSIX manually/); + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.ok(manifest.devDependencies?.ovsx, "ovsx must be a devDependency the release resolves offline"); +}); + +test("CI runs the gates, the package checks, the drift check and every e2e variant", () => { + const workflow = readFileSync(path.join(workflows, "ci.yml"), "utf8"); + assert.match(workflow, /pull_request:/); + assert.match(workflow, /push:\s*\n\s+branches: \[main\]/); + assert.match(workflow, /concurrency:[\s\S]*cancel-in-progress: true/); + assert.match(workflow, /node-version: \$\{\{ env.NODE_VERSION \}\}/); + assert.match(workflow, /NODE_VERSION: 22/); + // The unit gates, the Extension Host e2e and the isolated install all run on Linux, macOS and + // Windows, where the spawn and path handling actually differ. + const osMatrixes = workflow.match(/os: \[ubuntu-latest, macos-15, windows-latest\]/g) ?? []; + assert.equal(osMatrixes.length, 3, "gates, the Extension Host e2e and the isolated install must run on three systems"); + for (const job of ["vscode-gates:", "vscode-drift:", "vscode:", "vscode-e2e:", "vscode-isolated-install:", "vscode-graph-browser:"]) { + assert.match(workflow, new RegExp(`^ ${job}`, "m"), job); + } + assert.match(workflow, /npm ci/); + assert.match(workflow, /npm test/); + assert.match(workflow, /npm run test:parity/); + assert.match(workflow, /npm run package/); + assert.match(workflow, /xvfb-run -a npm run test:e2e:one/); + // The manifest is spec/'s; the snapshots of mcpp are the backend's (spec/), read by its rules layer. + assert.match(workflow, /mcpp-ide-dev -- gen vscode\n/); + // The declared degraded mode runs in the Extension Host too (part 2 §8, F15). + assert.match(workflow, /MCPP_E2E_NO_BACKEND: "1"/); + assert.match(workflow, /npm run test:graphs-ui/); + assert.match(workflow, /sunrisepeak.mcpp-language-server/); + assert.match(workflow, /tools\/ci-vscode-cli\.mjs/); + assert.doesNotMatch(workflow, /^\s+code --/m); +}); diff --git a/mide-plugins/vscode/test/cache/cacheCss.test.ts b/mide-plugins/vscode/test/cache/cacheCss.test.ts new file mode 100644 index 0000000..1c7c97f --- /dev/null +++ b/mide-plugins/vscode/test/cache/cacheCss.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The cache view's stylesheet, checked as text. + * + * Geometry cannot be measured outside a browser, but the properties §8.1 + * actually asks for can be: theme tokens only, tabular figures, the 26 / 12 / 11 + * scale, one 6 px primary bar, a legend that flows as one line, and a narrow-width + * answer. These assertions are what stops the next edit from quietly turning the + * sidebar back into three 14 px bars and a card per kind. + */ + +const css = readFileSync(path.join(process.cwd(), "media", "cache.css"), "utf8"); + +/** Every declaration that can carry a colour. */ +const COLOUR_DECLARATION = + /(?:^|[;{\s])(border-bottom|border-top|border-left|border-right|border-color|border|background-color|background|box-shadow|outline-color|outline|text-decoration-color|text-decoration|fill|stroke|color)\s*:\s*([^;}]+)/gi; + +/** Values that are not a colour at all, so they cannot be a hard-coded one. */ +const NOT_A_COLOUR = new Set([ + "transparent", + "currentColor", + "inherit", + "initial", + "unset", + "none", + "solid", + "dashed", + "dotted", + "double", + "underline", + "line-through", + "overline", + "wavy", + "0", + "1px", + "2px", + "3px", + "1", +]); + +test("every colour comes from a --vscode-* token", () => { + const offenders: string[] = []; + for (const match of css.matchAll(COLOUR_DECLARATION)) { + const property = match[1]; + const value = match[2].trim(); + // Strip the theme references: what is left must be geometry or a keyword. + const rest = value.replace(/var\([^)]*\)/g, " "); + for (const token of rest.split(/[\s,()]+/).filter((part) => part.length > 0)) { + if (NOT_A_COLOUR.has(token)) continue; + if (/^[\d.]+(px|em|rem|%|fr|deg)?$/.test(token)) continue; + offenders.push(`${property}: ${value} (offending token "${token}")`); + } + } + assert.deepEqual(offenders, [], `these declarations carry a literal colour:\n ${offenders.join("\n ")}`); +}); + +test("and no colour is hidden in a hex or function form either", () => { + assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b/i, "a hex colour literal"); + assert.doesNotMatch(css, /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix|light-dark)\(/i, "a functional colour"); +}); + +test("the type scale is §8.1's 26 / 12 / 11 and the figures are tabular", () => { + assert.match(css, /font-variant-numeric:\s*tabular-nums/); + assert.match(css, /\.metric-value\s*\{[^}]*font-size:\s*26px/); + assert.match(css, /font-size:\s*12px/); + assert.match(css, /font-size:\s*11px/); +}); + +test("one 14 px composition bar and 6 px everywhere else", () => { + assert.match(css, /\.viz-bar\s*\{[^}]*height:\s*14px/); + assert.match(css, /\.viz-bar-thin\s*\{[^}]*height:\s*6px/); + // No third bar height sneaks in. + const heights = [...css.matchAll(/height:\s*(\d+)px/g)].map((match) => Number(match[1])); + assert.deepEqual([...new Set(heights)].sort((a, b) => a - b), [1, 6, 8, 14]); +}); + +test("the legend flows as one wrapped line separated by ·", () => { + assert.match(css, /\.legend-inline\s*\{[^}]*display:\s*block/); + assert.match(css, /\.legend-inline\s+\.legend-item\s*\{[^}]*display:\s*inline/); + assert.match(css, /content:\s*" · "/); + assert.doesNotMatch(css, /\.legend[^{]*\{[^}]*display:\s*grid/); +}); + +test("nothing in the stylesheet can force a 170 px sidebar to scroll sideways", () => { + const minimums = [...css.matchAll(/min-width:\s*(\d+)px/g)].map((match) => Number(match[1])); + for (const value of minimums) { + assert.ok(value <= 170, `min-width: ${value}px is wider than the narrowest sidebar`); + } + assert.match(css, /\.actions\s*\{[^}]*flex-wrap:\s*wrap/); + assert.match(css, /overflow-wrap:\s*anywhere/); + assert.match(css, /@media \(max-width: 260px\)/, "there is a rule for a narrow sidebar"); + // At 200 px the table stops being a table and each cell names its column. + assert.match(css, /td\[data-head\]::before\s*\{[^}]*content:\s*attr\(data-head\)/); +}); + +test("the house rules survive: hidden, the focus ring and the dashed disabled state", () => { + assert.match(css, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); + assert.match(css, /:focus-visible/); + assert.match(css, /button\[disabled\]/); + assert.match(css, /button\[disabled\][^{]*\{[^}]*border-style:\s*dashed/); +}); + +test("the sections are separated by space, not by nested boxes", () => { + // §8.1: "去掉多余色块边框,靠留白分节" — no block, card or viz draws a border. + for (const selector of ["\\.block\\b", "\\.viz\\b", "\\.card\\b"]) { + const rule = new RegExp(selector + "\\s*\\{[^}]*\\}", "g"); + for (const match of css.matchAll(rule)) { + assert.doesNotMatch(match[0], /\bborder(-[a-z]+)?\s*:/, `${match[0]} draws a border`); + } + } +}); diff --git a/mide-plugins/vscode/test/cache/cacheLegacy.test.ts b/mide-plugins/vscode/test/cache/cacheLegacy.test.ts new file mode 100644 index 0000000..e082386 --- /dev/null +++ b/mide-plugins/vscode/test/cache/cacheLegacy.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { measureDirectory } from "../../src/cli/artifacts"; + +/** + * The bounded walk behind `mcpp.cache.showLegacy`: the pre-v1 cache lives + * outside the workspace, so it is measured by path rather than as `target/`. + * + * Each case creates one throwaway directory and removes it in the test's own + * cleanup hook. + */ + +function withScratch(t: { after(callback: () => void): void }, body: (root: string) => void): void { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-legacy-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + body(root); +} + +test("a missing legacy directory measures as absent with zeros, without throwing", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(path.join(root, "does-not-exist")); + assert.equal(estimate.exists, false); + assert.equal(estimate.totalBytes, 0); + assert.equal(estimate.files, 0); + assert.deepEqual(estimate.byTopLevel, []); + }); +}); + +test("a non-empty legacy directory gets a byte figure", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg", "deep"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(700)); + writeFileSync(path.join(root, "pkg", "deep", "b.bin"), Buffer.alloc(300)); + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 1000); + assert.equal(estimate.files, 2); + }); +}); + +test("an empty legacy directory measures as zero bytes, not as absent", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 0); + }); +}); + +test("the walk never follows a symlink back into its own ancestor", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(64)); + symlinkSync(root, path.join(root, "pkg", "loop"), "dir"); + const estimate = measureDirectory(root, { maxEntries: 1000, maxDepth: 8 }); + // The link is counted at zero bytes, so the total stays the 64 it really holds. + assert.equal(estimate.totalBytes, 64); + }); +}); + +test("a walk that runs out of budget reports a floor instead of hanging", (t) => { + withScratch(t, (root) => { + for (let index = 0; index < 20; index += 1) { + writeFileSync(path.join(root, `f${index}.bin`), Buffer.alloc(10)); + } + const estimate = measureDirectory(root, { maxEntries: 5 }); + assert.equal(estimate.truncated, "entries"); + assert.ok(estimate.totalBytes <= 200); + }); +}); diff --git a/mide-plugins/vscode/test/cache/cachePanelHtml.test.ts b/mide-plugins/vscode/test/cache/cachePanelHtml.test.ts new file mode 100644 index 0000000..f87e138 --- /dev/null +++ b/mide-plugins/vscode/test/cache/cachePanelHtml.test.ts @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { + CACHE_PANEL_UI, + ageRampStep, + decodeCachePanelMessage, + renderCachePanelHtml, + type CachePanelAssets, + type CachePanelModel, +} from "../../src/cache/cachePanelHtml"; + +const ASSETS: CachePanelAssets = { + cspSource: "vscode-webview://cache", + nonce: "nonce-cache123", + styleUri: "vscode-webview://cache/media/cache.css", +}; + +const UI: Record = { + [CACHE_PANEL_UI.htmlLang]: "en", + [CACHE_PANEL_UI.title]: "Cache statistics", + [CACHE_PANEL_UI.boundary]: "Sizes are estimates.", + [CACHE_PANEL_UI.projectTitle]: "Project cache", + [CACHE_PANEL_UI.projectFiles]: "{0} file(s) · {1} group(s)", + [CACHE_PANEL_UI.projectStale]: "Stale artifacts: about {0}", + [CACHE_PANEL_UI.sharedTitle]: "Global build cache", + [CACHE_PANEL_UI.sharedEntries]: "{0} entries", + [CACHE_PANEL_UI.sharedRoot]: "Root: {0}", + [CACHE_PANEL_UI.legacyTitle]: "Pre-v1 cache", + [CACHE_PANEL_UI.legacyPath]: "Path: {0}", + [CACHE_PANEL_UI.unknown]: "not recorded", + [CACHE_PANEL_UI.projectUnavailable]: "Project artifacts could not be measured.", + [CACHE_PANEL_UI.sharedUnavailable]: "The shared build cache could not be read.", + [CACHE_PANEL_UI.actions]: "Cache actions", + [CACHE_PANEL_UI.cleanStale]: "Clean stale artifacts", + [CACHE_PANEL_UI.cleanProject]: "Clean project artifacts", + [CACHE_PANEL_UI.prune]: "Drop entries unused for a while", + [CACHE_PANEL_UI.verify]: "Verify the cache", + [CACHE_PANEL_UI.cleanLegacy]: "Remove the pre-v1 cache", + [CACHE_PANEL_UI.collect]: "Collect to this budget", + [CACHE_PANEL_UI.detailsFor]: "Show cache entry details for {0}", + [CACHE_PANEL_UI.reasonShared]: "The shared build cache is not available.", + [CACHE_PANEL_UI.reasonProject]: "Project artifacts are not available.", + [CACHE_PANEL_UI.reasonLegacy]: "There is no pre-v1 cache to remove.", + [CACHE_PANEL_UI.composition]: "Composition by kind", + [CACHE_PANEL_UI.compositionEmpty]: "No cache entries were found.", + [CACHE_PANEL_UI.age]: "Last use", + [CACHE_PANEL_UI.ageEmpty]: "No entry has a recorded last use.", + [CACHE_PANEL_UI.ageUnder]: "under {0} day(s)", + [CACHE_PANEL_UI.ageRange]: "{0}–{1} day(s)", + [CACHE_PANEL_UI.ageOverflow]: "more than {0} day(s)", + [CACHE_PANEL_UI.ageUnknown]: "{0} entries have no recorded last use.", + [CACHE_PANEL_UI.top]: "Largest packages (top {0})", + [CACHE_PANEL_UI.topEmpty]: "No cache label was read.", + [CACHE_PANEL_UI.colLabel]: "Label", + [CACHE_PANEL_UI.colEntries]: "Entries", + [CACHE_PANEL_UI.colBytes]: "Size", + [CACHE_PANEL_UI.colOldest]: "Oldest use", + [CACHE_PANEL_UI.budgetLabel]: "Keep the shared build cache under", + [CACHE_PANEL_UI.budgetHint]: "Simulates mcpp cache gc --max-size.", + [CACHE_PANEL_UI.budgetUnit]: "GiB", + [CACHE_PANEL_UI.incompleteWarning]: "{0} cache entries are incomplete.", + [CACHE_PANEL_UI.sizeWarning]: "The shared build cache is {0}, at or above the {1} warning threshold.", +}; + +interface PageOptions { + project?: Partial; + shared?: Partial; + legacy?: CachePanelModel["legacy"]; + limits?: Partial; + estimate?: string; +} + +/** + * A known cache: 500 B in two kinds (300 B `pkg`, 200 B `std`) and, when asked + * for, a 500 B pre-v1 cache — so the composition percentages are exactly + * 30 / 20 / 50 and the SVG widths are 300 / 200 / 500 out of 1000. The project + * block is a 1000 B `target/` in 3 files over 1 group. + * + * The byte formatter keeps its unit after a space, which is what the metric + * split relies on: `1000 B` -> value `1000`, unit `B`. + */ +function makeModel(options: PageOptions = {}): CachePanelModel { + return { + ui: UI, + project: { available: true, totalBytes: 1000, files: 3, groups: 1, ...options.project }, + shared: { + available: true, + totalBytes: 500, + totalEntries: 4, + byKind: [ + { kind: "pkg", entries: 3, bytes: 300 }, + { kind: "std", entries: 1, bytes: 200 }, + ], + buckets: [ + { fromDays: 0, toDays: 1, entries: 1, bytes: 100 }, + { fromDays: 1, toDays: 7, entries: 1, bytes: 100 }, + { fromDays: 7, toDays: 30, entries: 1, bytes: 100 }, + { fromDays: 30, entries: 1, bytes: 200 }, + ], + top: [{ label: "zlib", entries: 2, bytes: 200, oldestAccessed: 1_758_636_000 }], + incomplete: 0, + ...options.shared, + }, + format: { bytes: (value) => `${value} B`, count: (value) => `c${value}` }, + limits: { topN: 5, warnAboveGiB: 0, ...options.limits }, + ...(options.legacy === undefined ? {} : { legacy: options.legacy }), + ...(options.estimate === undefined ? {} : { estimate: options.estimate }), + }; +} + +function page(options: PageOptions = {}): string { + return renderCachePanelHtml(makeModel(options), ASSETS); +} + +/** The single source line a one-line legend has to fit on. */ +function legendLine(html: string, id: string): string { + const line = html.split("\n").find((candidate) => candidate.includes(`data-legend="${id}"`)); + assert.ok(line !== undefined, `no legend line for ${id}`); + return line; +} + +test("the document carries the nonce and the strict CSP", () => { + const html = page(); + assert.ok(html.includes("default-src 'none'")); + assert.ok(html.includes("style-src vscode-webview://cache;")); + assert.ok(html.includes("script-src 'nonce-nonce-cache123'")); + assert.ok(html.includes('img-src vscode-webview://cache"')); + assert.ok(html.includes('', entries: 1, bytes: 10 }] }, + }); + assert.ok(!html.includes("', + description: "A bold description", + }), + ]); + assert.ok(!html.includes("', '', '', '']) assert.throws(() => decodeGraphExport("svg", data)); + assert.throws(() => decodeGraphExport("svg", "x".repeat(32 * 1024 * 1024 + 1))); + assert.throws(() => decodeGraphExport("png", "broken")); + const png = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(png); png.write("IHDR",12); png.writeUInt32BE(20,16); png.writeUInt32BE(10,20); + assert.equal(decodeGraphExport("png", png.toString("base64")).extension, "png"); + png.writeUInt32BE(8193,16); assert.throws(() => decodeGraphExport("png", png.toString("base64"))); +}); diff --git a/mide-plugins/vscode/test/graphs/model.test.ts b/mide-plugins/vscode/test/graphs/model.test.ts new file mode 100644 index 0000000..7877dc0 --- /dev/null +++ b/mide-plugins/vscode/test/graphs/model.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { graphRoots, visibleGraph, mermaidGraph, type VisualGraph, type VisualNode } from "../../src/graphs/model"; +const node=(id:string):VisualNode=>({id,label:id,lines:[],kind:"project"}); +const state={depth:3,expanded:new Set(),collapsed:new Set()}; +const chain:VisualGraph={nodes:Array.from({length:12},(_,i)=>node(String(i))),edges:Array.from({length:11},(_,i)=>({from:String(i),to:String(i+1)})),roots:["0"]}; +test("depth folding expands a branch one hop and all keeps complete deep data",()=>{ + let graph=visibleGraph(chain,state);assert.equal(graph.nodes.length,4);assert.equal(graph.hidden.get("3"),1); + graph=visibleGraph(chain,{...state,expanded:new Set(["3"])});assert.equal(graph.nodes.length,5);assert.equal(graph.hidden.get("4"),1); + graph=visibleGraph(chain,{...state,depth:Infinity});assert.equal(graph.nodes.length,12); + graph=visibleGraph(chain,{...state,depth:Infinity,collapsed:new Set(["2"])});assert.equal(graph.nodes.length,3);assert.equal(graph.hidden.get("2"),1); +}); +test("collapsing one parent preserves a shared child reached through another parent; cycles terminate",()=>{ + const graph:VisualGraph={nodes:["a","b","shared","leaf"].map(node),edges:[{from:"a",to:"shared"},{from:"b",to:"shared"},{from:"shared",to:"leaf"},{from:"leaf",to:"b"}],roots:["a","b"]}; + const visible=visibleGraph(graph,{...state,depth:Infinity,collapsed:new Set(["a"])}); + assert.equal(visible.nodes.length,4);assert.ok(visible.edges.some(edge=>edge.from==="b"&&edge.to==="shared")); + assert.ok(!visible.edges.some(edge=>edge.from==="a")); +}); +test("every directed component has a root, including disconnected cycles and isolated nodes",()=>{ + const nodes=["a","b","c","d","e"].map(node),edges=[{from:"a",to:"b"},{from:"c",to:"d"},{from:"d",to:"c"}]; + const roots=graphRoots(nodes,edges,["a"]);assert.deepEqual(roots,["a","e","c"]); + assert.equal(visibleGraph({nodes,edges,roots},{...state,depth:Infinity}).nodes.length,5); +}); +test("node and edge budgets independently bound wide and dense subgraphs and retain hidden counts",()=>{ + const wide:VisualGraph={nodes:Array.from({length:1000},(_,i)=>node(String(i))),edges:Array.from({length:999},(_,i)=>({from:"0",to:String(i+1)})),roots:["0"]}; + const visible=visibleGraph(wide,{...state,depth:Infinity});assert.equal(visible.nodes.length,150);assert.equal(visible.limited,true);assert.equal(visible.hidden.get("0"),850); + const dense:VisualGraph={nodes:wide.nodes.slice(0,30),edges:Array.from({length:30},(_,i)=>Array.from({length:30},(_,j)=>({from:String(i),to:String(j)}))).flat(),roots:["0"]}; + const capped=visibleGraph(dense,{...state,depth:Infinity});assert.equal(capped.edges.length,300);assert.equal(capped.limited,true);assert.ok(capped.hidden.size); +}); +test("hidden-node focus includes direct importers but not their unrelated branches",()=>{ + const focused=visibleGraph(chain,{...state,focus:"9"});assert.deepEqual(focused.nodes.map(node=>node.id),["8","9","10","11"]); +}); +test("Mermaid labels cannot inject graph syntax and fold counts are generated before layout",()=>{ + const graph=visibleGraph({...chain,nodes:chain.nodes.map((node,i)=>i===0?{...node,label:'bad"]\nclick n0 "https://example.test"'}:node)},state); + const {source,nodeIds}=mermaidGraph(graph);assert.doesNotMatch(source,/\nclick/);assert.match(source,/#34;/);assert.match(source,/+1/);assert.equal(nodeIds.length,4); +}); diff --git a/mide-plugins/vscode/test/graphs/navigation.test.ts b/mide-plugins/vscode/test/graphs/navigation.test.ts new file mode 100644 index 0000000..30f89d8 --- /dev/null +++ b/mide-plugins/vscode/test/graphs/navigation.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { groupedGraph, visibleGraph, type VisualGraph, INITIAL_GRAPH_BUDGET } from "../../src/graphs/model"; +const node = (id: string) => ({ id, label: id, lines: [], kind: "project" as const }); +const state = { depth: 3, upstreamDepth: 2, expanded: new Set(), collapsed: new Set() }; +test("150 test roots cannot consume the budget before their imports", () => { + const graph = { nodes: [...Array.from({ length: 151 }, (_, i) => node(`test${i}`)), node("dep")], + edges: Array.from({ length: 151 }, (_, i) => ({ from: `test${i}`, to: "dep" })), roots: Array.from({ length: 151 }, (_, i) => `test${i}`) }; + const visible = visibleGraph(graph, state, INITIAL_GRAPH_BUDGET); + assert.ok(visible.edges.length > 0); assert.ok(visible.nodes.some(node => node.id === "dep")); + assert.ok(visible.nodes.length <= INITIAL_GRAPH_BUDGET.nodes && visible.edges.length <= INITIAL_GRAPH_BUDGET.edges); +}); +test("two parent hops exclude sibling imports and high fan-in leaves downstream capacity", () => { + const graph: VisualGraph = { nodes: ["focus", "parent", "grandparent", "sibling", "child", ...Array.from({length: 100}, (_, i) => `parent${i}`)].map(node), + edges: [{ from: "grandparent", to: "parent" }, { from: "parent", to: "focus" }, { from: "parent", to: "sibling" }, { from: "focus", to: "child" }, ...Array.from({length: 100}, (_, i) => ({from: `parent${i}`, to: "focus"}))], roots: ["grandparent"] }; + const visible = visibleGraph(graph, { ...state, focus: "focus" }, INITIAL_GRAPH_BUDGET); + assert.ok(visible.nodes.some(node => node.id === "child")); + assert.ok(!visible.nodes.some(node => node.id === "sibling")); + const small = visibleGraph({ ...graph, edges: graph.edges.slice(0, 4) }, { ...state, focus: "focus" }); + assert.ok(small.nodes.some(node => node.id === "grandparent")); +}); +test("successive local depths extend the selected interior node; collapse preserves shared reachability", () => { + const graph: VisualGraph = { nodes: ["a", "b", "c", "d", "e", "f", "other"].map(node), edges: ["a", "b", "c", "d", "e"].map((from, i) => ({ from, to: ["b", "c", "d", "e", "f"][i] })), roots: ["a"] }; + const one = visibleGraph(graph, { ...state, focus: "a", localDepth: new Map([["b", 3]]) }); + assert.ok(one.nodes.some(node => node.id === "e")); assert.ok(!one.nodes.some(node => node.id === "f")); + const two = visibleGraph(graph, { ...state, focus: "a", localDepth: new Map([["b", 4]]) }); + assert.ok(two.nodes.some(node => node.id === "f")); + const shared = { ...graph, edges: [...graph.edges, { from: "a", to: "other" }, { from: "other", to: "c" }] }; + const folded = visibleGraph(shared, { ...state, focus: "a", collapsed: new Set(["b"]) }); + assert.ok(folded.nodes.some(node => node.id === "c")); assert.ok(!folded.edges.some(edge => edge.from === "b")); +}); +test("group pages contain twenty entries and virtual edges cost no import depth", () => { + const ids = Array.from({length: 149}, (_, i) => `test${i}`); + const graph: VisualGraph = { nodes: [...ids.map(node), node("dep")], edges: ids.map(from => ({from, to: "dep"})), roots: ids, + groups: [{id: "@group/tests", label: "Tests", nodes: ids}] }; + const grouped = groupedGraph(graph, "@group/tests", 20); + assert.equal(grouped.edges.filter(edge => edge.containment).length, 20); + const visible = visibleGraph(grouped, {...state, depth: 1}); + assert.ok(visible.nodes.some(node => node.id === "dep")); assert.ok(!visible.nodes.some(node => node.id === "test0")); +}); +test("edge budget cannot strand nodes and large cyclic graphs terminate", () => { + const graph = {nodes: Array.from({length: 10000}, (_, i) => node(String(i))), edges: Array.from({length: 50000}, (_, i) => ({from: String(i % 10000), to: String((i + 1 + Math.floor(i / 10000)) % 10000)})), roots:["0"]}; + const visible = visibleGraph(graph, {...state, depth: Infinity}, {nodes: 80, edges: 20}); + assert.ok(visible.nodes.length <= 21); assert.equal(visible.edges.length, 20); + for (const node of visible.nodes.filter(node => node.id !== "0")) assert.ok(visible.edges.some(edge => edge.from === node.id || edge.to === node.id)); +}); diff --git a/mide-plugins/vscode/test/graphs/refresh.test.ts b/mide-plugins/vscode/test/graphs/refresh.test.ts new file mode 100644 index 0000000..9d9a6cb --- /dev/null +++ b/mide-plugins/vscode/test/graphs/refresh.test.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { RefreshCoordinator } from "../../src/graphs/refresh"; +const tick = (ms = 15) => new Promise(resolve => setTimeout(resolve, ms)); +test("100 event bursts coalesce, in-flight events produce one followup, hidden events wait", async () => { + let calls = 0, finish: (() => void) | undefined, active = 0, maxActive = 0; + const coordinator = new RefreshCoordinator(async () => { calls++; active++; maxActive = Math.max(maxActive, active); await new Promise(resolve => { finish = resolve; }); active--; }, 5, 20); + try { + for (let i = 0; i < 100; i++) void coordinator.request(); + await tick(); assert.equal(calls, 1); + for (let i = 0; i < 100; i++) void coordinator.request(); + finish!(); await tick(); assert.equal(calls, 2); assert.equal(maxActive, 1); + finish!(); await tick(); assert.equal(calls, 2); + coordinator.setVisible(false); void coordinator.request(); await tick(); assert.equal(calls, 2); + coordinator.setVisible(true); await tick(); assert.equal(calls, 3); finish!(); await tick(); + } finally { coordinator.dispose(); } +}); +test("cancellation aborts the active query and disposal cancels deferred refresh", async () => { + let aborted = false, calls = 0; + const coordinator = new RefreshCoordinator(signal => new Promise(resolve => { + calls++; signal.addEventListener("abort", () => { aborted = true; resolve(); }); + }), 5); + const flight = coordinator.request(true); await tick(); coordinator.cancel(); await flight; assert.equal(aborted, true); + void coordinator.request(); coordinator.dispose(); await tick(); assert.equal(calls, 1); +}); diff --git a/mide-plugins/vscode/test/i18n/hardcoded.test.ts b/mide-plugins/vscode/test/i18n/hardcoded.test.ts new file mode 100644 index 0000000..c1ffcc5 --- /dev/null +++ b/mide-plugins/vscode/test/i18n/hardcoded.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +/** + * No untranslated user-visible string may live in `src/`. + * + * The English text is the key for every runtime string (`src/i18n/t.ts`), so a + * hard-coded sentence is invisible to `tools/l10n-check.mjs` **and** ignores + * `mcpp.ui.language`: it shows Chinese on an English UI. The ceiling that used to + * freeze the count at 162 is now **zero** — the scan is a gate, not a ratchet. + */ + +const SCANNED_DIRECTORIES = ["src"]; + +export function sourceFiles(dir: string, found: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + sourceFiles(full, found); + } else if (entry.name.endsWith(".ts")) { + found.push(full); + } + } + return found; +} + +/** Lines carrying a CJK character outside a comment. */ +export function hardcodedChineseLines(root = process.cwd()): Array<{ file: string; line: number; text: string }> { + const hits: Array<{ file: string; line: number; text: string }> = []; + for (const dir of SCANNED_DIRECTORIES) { + for (const file of sourceFiles(path.join(root, dir))) { + const lines = readFileSync(file, "utf8").split("\n"); + lines.forEach((text, index) => { + const trimmed = text.trim(); + if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) { + return; + } + if (/[\u4e00-\u9fff]/.test(text)) { + hits.push({ file: path.relative(root, file), line: index + 1, text: trimmed }); + } + }); + } + } + return hits; +} + +test("src/ carries no untranslated user-visible string", () => { + const hits = hardcodedChineseLines(); + assert.equal( + hits.length, + 0, + `found ${hits.length} hard-coded Chinese line(s); route them through t(): ` + + hits.slice(0, 5).map((hit) => `${hit.file}:${hit.line}`).join(", "), + ); +}); + +test("the scan still inspects every source file", () => { + // The zero above is only meaningful while the scan keeps looking at the same + // tree: a walk that stopped early would report success for the wrong reason. + const files = sourceFiles(path.join(process.cwd(), "src")); + assert.ok(files.length >= 50, `the scan inspected only ${files.length} file(s)`); + assert.ok(files.every((file) => file.endsWith(".ts")), "the scan picked up a non-source file"); + for (const expected of [ + path.join("src", "cli", "controller.ts"), + path.join("src", "extension.ts"), + path.join("src", "toml", "parser.ts"), + path.join("src", "views", "languageServerView.ts"), + ]) { + assert.ok(files.includes(path.join(process.cwd(), expected)), `the scan skipped ${expected}`); + } +}); + +test("the scan still finds the strings it is meant to watch", () => { + // Plant the two shapes the scan must tell apart in a throw-away tree: a + // hard-coded CJK literal (a hit) and a Chinese comment (not a hit). + const root = mkdtempSync(path.join(tmpdir(), "mcpp-i18n-scan-")); + try { + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync( + path.join(root, "src", "planted.ts"), + [ + `const translated = t("Plant me");`, + `// 这行只是注释,不是用户可见字符串`, + `const hardcoded = "种下我";`, + "", + ].join("\n"), + ); + const hits = hardcodedChineseLines(root); + assert.equal(hits.length, 1, "the scan must still flag a hard-coded CJK literal"); + assert.equal(hits[0].file, path.join("src", "planted.ts")); + assert.match(hits[0].text, /种下我/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/mide-plugins/vscode/test/i18n/translate.test.ts b/mide-plugins/vscode/test/i18n/translate.test.ts new file mode 100644 index 0000000..c7b0d6f --- /dev/null +++ b/mide-plugins/vscode/test/i18n/translate.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { format, localeFromEditorLanguage, missingTranslations, translate } from "../../src/i18n/translate"; + +const BUNDLE = { "Build failed ({0}).": "构建失败({0})。" } as const; + +test("format substitutes positional placeholders", () => { + assert.equal(format("a {0} b {1}", ["x", 2]), "a x b 2"); + assert.equal(format("no placeholders", []), "no placeholders"); + // An out-of-range placeholder is left alone rather than becoming "undefined". + assert.equal(format("{0} {5}", ["x"]), "x {5}"); +}); + +test("translate falls back to English for a missing entry", () => { + assert.equal(translate("zh-cn", "Never translated", [], BUNDLE), "Never translated"); + assert.equal(translate("zh-cn", "Build failed ({0}).", ["gcc"], BUNDLE), "构建失败(gcc)。"); +}); + +test("translate in English mode ignores the bundle", () => { + assert.equal(translate("en", "Build failed ({0}).", ["gcc"], BUNDLE), "Build failed (gcc)."); +}); + +test("an empty translation is treated as missing", () => { + assert.equal(translate("zh-cn", "Empty", [], { Empty: "" }), "Empty"); +}); + +test("localeFromEditorLanguage maps Chinese locales and everything else to English", () => { + assert.equal(localeFromEditorLanguage("zh-cn"), "zh-cn"); + assert.equal(localeFromEditorLanguage("zh-TW"), "zh-cn"); + assert.equal(localeFromEditorLanguage("en"), "en"); + assert.equal(localeFromEditorLanguage("de"), "en"); + assert.equal(localeFromEditorLanguage(undefined), undefined); +}); + +test("missingTranslations reports only the gaps", () => { + assert.deepEqual(missingTranslations(["a", "b"], { a: "A" }), ["b"]); + assert.deepEqual(missingTranslations(["a"], { a: "A" }), []); + assert.deepEqual(missingTranslations(["a"], { a: "" }), ["a"]); +}); diff --git a/mide-plugins/vscode/test/ide/client.test.ts b/mide-plugins/vscode/test/ide/client.test.ts new file mode 100644 index 0000000..9c92431 --- /dev/null +++ b/mide-plugins/vscode/test/ide/client.test.ts @@ -0,0 +1,116 @@ +// The wire against the real backend, through the JSON-RPC library VS Code's language client is built +// on: `initialize` in LSP's shape over the mock mcpp finds the project; a build's notifications +// arrive under `mcpp-ide/` and end in job/finished; mcpp.toml opened has its diagnostics published, +// and completion and hover answer for it (design 2026-10-30 part 2 §8). +import * as assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import * as path from "node:path"; +import { test } from "node:test"; + +import { createMessageConnection, StreamMessageReader, StreamMessageWriter, type MessageConnection } from "vscode-jsonrpc/node"; + +import { runProcess, useMcppExecutor } from "../../src/cli/process"; +import { built, project } from "./harness"; + +interface Session { + connection: MessageConnection; + request(method: string, params?: unknown): Promise; + stop(): Promise; +} + +/** `mcpp-ide serve`, initialized for a workspace folder over the mock mcpp. */ +async function start(backend: string, mock: string, root: string): Promise<{ session: Session; init: any }> { + const child = spawn(backend, ["serve"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); + const connection = createMessageConnection(new StreamMessageReader(child.stdout!), new StreamMessageWriter(child.stdin!)); + connection.listen(); + const ended = new Promise((resolve) => child.on("exit", () => resolve())); + const session: Session = { + connection, + request: (method: string, params: unknown = {}) => connection.sendRequest(`mcpp-ide/${method}`, params) as Promise, + async stop() { + await connection.sendRequest("shutdown"); + connection.sendNotification("exit"); + await ended; + connection.dispose(); + }, + }; + const init = await connection.sendRequest("initialize", { + processId: process.pid, + clientInfo: { name: "test" }, + workspaceFolders: [{ uri: pathToFileURL(root).href, name: "app" }], + capabilities: {}, + initializationOptions: { editor: "vscode", settings: { "mcpp.path": mock } }, + }); + connection.sendNotification("initialized", {}); + return { session, init }; +} + +test("a session over the mock mcpp", async (t) => { + const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); + if (!backend || !mock) { t.skip("build the backend first (mcpp build)"); return; } + const root = project(); + const { session, init } = await start(backend, mock, root); + assert.equal(init.projects.length, 1); + assert.equal(init.mcpp.contract, true); + assert.equal(init.protocol, 2); + assert.equal(init.capabilities.textDocumentSync.change, 1); + const seen: string[] = []; + for (const m of ["job/started", "job/plan", "job/progress", "job/artifact"]) + session.connection.onNotification(`mcpp-ide/${m}`, () => seen.push(m)); + const finished = new Promise((resolve) => session.connection.onNotification("mcpp-ide/job/finished", resolve)); + const { jobId } = await session.request<{ jobId: string }>("job/start", { kind: "build", root }); + assert.match(jobId, /^job-/); + const done = (await finished) as { status: string }; + assert.equal(done.status, "ok"); + for (const m of ["job/started", "job/plan", "job/progress", "job/artifact"]) assert.ok(seen.includes(m), m); + await assert.rejects(session.request("no/such/method"), /unknown method/); + await session.stop(); +}); + +test("mcpp.toml through LSP: diagnostics published, completion, hover", async (t) => { + const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); + if (!backend || !mock) { t.skip("build the backend first (mcpp build)"); return; } + const root = project(); + const { session } = await start(backend, mock, root); + const uri = pathToFileURL(path.join(root, "mcpp.toml")).href; + const published = new Promise((resolve) => session.connection.onNotification("textDocument/publishDiagnostics", resolve)); + session.connection.sendNotification("textDocument/didOpen", { + textDocument: { uri, languageId: "mcpp-toml", version: 1, text: "[package]\nname = \"名字\"\ncolour = 1\n[build]\nbmi_schedule = \"\"\n" }, + }); + const diagnostics = await published; + assert.equal(diagnostics.uri, uri); + assert.deepEqual(diagnostics.diagnostics.map((d: any) => [d.code, d.range.start.line, d.range.start.character]), + [["mcpp.manifest.unknownKey", 2, 0]]); + const list = await session.connection.sendRequest("textDocument/completion", { textDocument: { uri }, position: { line: 4, character: 16 } }); + assert.deepEqual(list.items.map((i: any) => i.label).sort(), ["auto", "off", "on"]); + const hover = await session.connection.sendRequest("textDocument/hover", { textDocument: { uri }, position: { line: 4, character: 2 } }); + assert.match(hover.contents.value, /bmi_schedule/); + await session.stop(); +}); + +test("mcpp runs through the backend, and the extension's parsers read what it printed", async (t) => { + const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); + if (!backend || !mock) { t.skip("build the backend first (mcpp build)"); return; } + const root = project(); + const { session } = await start(backend, mock, root); + // 0.9.0's seam (`runProcess`) handed to the backend, as the extension does at activation. + useMcppExecutor(async (_exe, args, cwd, options = {}) => { + const r = await session.request<{ exitCode: number; stdout: string; stderr: string }>("mcpp/exec", { + args, ...(cwd ? { cwd } : {}), ...(options.timeoutMs ? { timeoutMs: options.timeoutMs } : {}), + }); + return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr }; + }); + try { + const version = await runProcess("mcpp", ["--version"], root); + assert.equal(version.exitCode, 0); + assert.match(version.stdout, /^mcpp \d/); + const files = await session.request<{ manifest: string; lock: string | null; sourceFiles: number }>("project/files", { root }); + assert.match(files.manifest, /^\[package\]/); + assert.equal(files.lock, null); + assert.equal(files.sourceFiles, 0); + } finally { + useMcppExecutor(undefined); + await session.stop(); + } +}); diff --git a/mide-plugins/vscode/test/unit/harness.ts b/mide-plugins/vscode/test/ide/harness.ts similarity index 98% rename from mide-plugins/vscode/test/unit/harness.ts rename to mide-plugins/vscode/test/ide/harness.ts index d716e09..895f61f 100644 --- a/mide-plugins/vscode/test/unit/harness.ts +++ b/mide-plugins/vscode/test/ide/harness.ts @@ -5,7 +5,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -// Compiled to out/test/unit. +// Compiled to dist/test/ide. export const extensionDir = path.resolve(__dirname, "..", "..", ".."); export const repo = path.resolve(extensionDir, "..", ".."); diff --git a/mide-plugins/vscode/test/ide/locate.test.ts b/mide-plugins/vscode/test/ide/locate.test.ts new file mode 100644 index 0000000..d6a2bce --- /dev/null +++ b/mide-plugins/vscode/test/ide/locate.test.ts @@ -0,0 +1,68 @@ +// Where the backend and mcppls are found. +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; + +import { backendUnavailableHere, locateBackend, okrunDir, onPath } from "../../src/ide/backend/locate"; +import { mcpplsCandidates } from "../../src/ide/backend/mcppls"; + +// PATH is this host's (its separator, its suffix), so these use the host's own system. +const host = process.platform; +const exe = host === "win32" ? ".exe" : ""; + +test("the configured path wins: a program, or a bundle run by okrun", () => { + const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-ext-")); + assert.deepEqual(locateBackend({ configured: "/x/mcpp-ide", extensionRoot: ext }), { program: "/x/mcpp-ide", args: [], source: "setting" }); + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okrun-")); + fs.writeFileSync(path.join(bin, `okrun${exe}`), ""); + assert.deepEqual(locateBackend({ configured: "/x/mcpp-ide.okb", extensionRoot: ext, platform: host, arch: "x64", env: { PATH: bin } }), + { program: path.join(bin, `okrun${exe}`), args: ["/x/mcpp-ide.okb"], source: "setting" }); +}); + +test("the bundle runs under the okrun carried for this system, else okrun on PATH", () => { + const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okb-")); + const carried = okrunDir(host, "x64"); + fs.mkdirSync(path.join(ext, "backend", "okrun", carried), { recursive: true }); + fs.writeFileSync(path.join(ext, "backend", "mcpp-ide.okb"), ""); + fs.writeFileSync(path.join(ext, "backend", "okrun", carried, `okrun${exe}`), ""); + const here = locateBackend({ extensionRoot: ext, platform: host, arch: "x64", env: { PATH: "" }, home: "/nonexistent" }); + assert.deepEqual(here, { program: path.join(ext, "backend", "okrun", carried, `okrun${exe}`), args: [path.join(ext, "backend", "mcpp-ide.okb")], source: "bundle" }); + // No okrun carried for the other architecture: okrun on PATH runs the bundle. + const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okrun-")); + fs.writeFileSync(path.join(bin, `okrun${exe}`), ""); + const other = locateBackend({ extensionRoot: ext, platform: host, arch: "arm64", env: { PATH: bin }, home: "/nonexistent" }); + assert.deepEqual(other?.args, [path.join(ext, "backend", "mcpp-ide.okb")]); + assert.equal(other?.program, path.join(bin, `okrun${exe}`)); + // No okrun at all: nothing in the package starts. + const none = locateBackend({ extensionRoot: ext, platform: host, arch: "arm64", env: { PATH: "" }, home: "/nonexistent" }); + assert.equal(none, undefined); + // That is the declared degraded mode (part 2 §8, F15): the bundle is carried, no runner runs it here. + assert.equal(backendUnavailableHere({ extensionRoot: ext, platform: host, arch: "arm64", env: { PATH: "" } }), true); + assert.equal(backendUnavailableHere({ extensionRoot: ext, platform: host, arch: "x64", env: { PATH: "" } }), false); + assert.equal(backendUnavailableHere({ extensionRoot: ext, platform: host, arch: "arm64", env: { PATH: bin } }), false); + // Without the bundle the backend is not installed, which is another message. + const bare = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-bare-")); + assert.equal(backendUnavailableHere({ extensionRoot: bare, platform: host, arch: "arm64", env: { PATH: "" } }), false); + assert.equal(okrunDir("linux", "x64"), "linux-x86_64"); + assert.equal(okrunDir("win32", "x64"), "windows-x86_64"); + assert.equal(okrunDir("darwin", "arm64"), "macos-aarch64"); +}); + +test("PATH lookup appends .exe on Windows", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-path-")); + fs.writeFileSync(path.join(dir, "mcpp-ide.exe"), ""); + assert.equal(onPath("mcpp-ide", { PATH: dir }, "win32"), path.join(dir, "mcpp-ide.exe")); + assert.equal(onPath("mcpp-ide", { PATH: dir }, "linux"), undefined); +}); + +test("mcppls: the extension's payload is the candidate, for the backend to start its server", () => { + const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppls-ext-")); + assert.deepEqual(mcpplsCandidates(ext, {}), [], "no payload, nothing"); + fs.mkdirSync(path.join(ext, "payload")); + fs.writeFileSync(path.join(ext, "payload", "payload.json"), JSON.stringify({ server: { path: "bin/mcppls.okb", runner: "bin/okrun" } })); + assert.deepEqual(mcpplsCandidates(ext, {}), [path.join(ext, "payload")]); + assert.deepEqual(mcpplsCandidates(ext, { MCPPLS_PAYLOAD: path.join(ext, "payload") }), [path.join(ext, "payload")]); + assert.deepEqual(mcpplsCandidates(undefined, { MCPPLS_SERVER: "/s/mcppls" }), ["/s/mcppls"]); +}); diff --git a/mide-plugins/vscode/test/ide/mapping.test.ts b/mide-plugins/vscode/test/ide/mapping.test.ts new file mode 100644 index 0000000..026426d --- /dev/null +++ b/mide-plugins/vscode/test/ide/mapping.test.ts @@ -0,0 +1,49 @@ +// The protocol's values in VS Code's terms. +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { availableDebuggers, baseCaseName, caseId, format, mainLineOf, parseCaseId, parseTestId, severityOf, testId, zeroBased } from "../../src/ide/mapping"; + +test("positions are 1-based in mcpp and 0-based in VS Code", () => { + assert.deepEqual(zeroBased({ line: 1, column: 21 }), { line: 0, character: 20 }); + assert.deepEqual(zeroBased(undefined), { line: 0, character: 0 }); +}); + +test("severities", () => { + assert.equal(severityOf({ severity: "error" }), "error"); + assert.equal(severityOf({ severity: "note" }), "hint"); + assert.equal(severityOf({ severity: "info" }), "information"); +}); + +test("a test's identity round-trips, roots with :: included", () => { + const id = testId("/a::b", "m", "t"); + assert.deepEqual(parseTestId(id), { root: "/a::b", member: "m", test: "t" }); + assert.equal(parseTestId("x"), undefined); +}); + +test("the debuggers present, in order of preference", () => { + assert.deepEqual(availableDebuggers((id) => id === "ms-vscode.cpptools" || id === "vadimcn.vscode-lldb"), ["lldb", "cppdbg"]); + assert.equal(format("{0} and {1}", ["a", 2]), "a and 2"); +}); + +test("a case's id holds its program's and its name, which may hold ::", () => { + const id = caseId("/w/p", "", "unit/math", "ns::Suite.Name"); + assert.deepEqual(parseCaseId(id), { root: "/w/p", member: "", test: "unit/math", name: "ns::Suite.Name" }); + assert.equal(parseTestId(id), undefined); + assert.equal(parseCaseId(testId("/w/p", "", "unit/math")), undefined); +}); + +test("a GoogleTest result is matched to its source's case without instance and parameter", () => { + assert.equal(baseCaseName("gtest", "Inst/Param.Works/1"), "Param.Works"); + assert.equal(baseCaseName("gtest", "Typed/0.Holds"), "Typed.Holds"); + assert.equal(baseCaseName("gtest", "Math.Adds"), "Math.Adds"); + assert.equal(baseCaseName("catch2", "a/b.c"), "a/b.c"); +}); + +test("main is the language service's top-level function of that name", () => { + const at = (line: number) => ({ start: { line } }); + assert.equal(mainLineOf([{ name: "helper", kind: 11, range: at(1) }, { name: "main", kind: 11, range: at(7) }]), 7); + assert.equal(mainLineOf([{ name: "main(int, char **)", kind: 11, range: at(3) }]), 3); + assert.equal(mainLineOf([{ name: "main", kind: 12, range: at(3) }]), undefined); + assert.equal(mainLineOf(undefined), undefined); +}); diff --git a/mide-plugins/vscode/test/library/detailHtml.test.ts b/mide-plugins/vscode/test/library/detailHtml.test.ts new file mode 100644 index 0000000..bb887c7 --- /dev/null +++ b/mide-plugins/vscode/test/library/detailHtml.test.ts @@ -0,0 +1,365 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DETAIL_UI, + decodeDetailMessage, + renderDetailHtml, + type DetailAssets, + type DetailModel, +} from "../../src/library/detailHtml"; + +const ASSETS: DetailAssets = { + cspSource: "vscode-webview://detail", + nonce: "nonce-detail123", + styleUri: "vscode-webview://detail/media/library.css", +}; + +const UI: Record = { + [DETAIL_UI.htmlLang]: "en", + [DETAIL_UI.title]: "Library package {0}", + [DETAIL_UI.overview]: "Build shape", + [DETAIL_UI.platforms]: "Platforms and versions", + [DETAIL_UI.license]: "License", + [DETAIL_UI.repo]: "Repository", + [DETAIL_UI.openRepo]: "Open the repository", + [DETAIL_UI.registry]: "Registry", + [DETAIL_UI.surface]: "Use", + [DETAIL_UI.surfaceExternal]: "upstream mcpp.toml", + [DETAIL_UI.standard]: "Standard", + [DETAIL_UI.versions]: "Versions ({0})", + [DETAIL_UI.versionsAll]: "Versions", + [DETAIL_UI.versionsCurrent]: "this platform", + [DETAIL_UI.versionsNone]: "This index publishes no version for any platform.", + [DETAIL_UI.versionsPick]: "Click a version to aim the command above at it.", + [DETAIL_UI.dependencies]: "Dependencies", + [DETAIL_UI.dependenciesNone]: "This descriptor declares no dependencies.", + [DETAIL_UI.dependenciesHint]: "What the descriptor declares.", + [DETAIL_UI.resolved]: "resolved {0}", + [DETAIL_UI.code]: "Example code", + [DETAIL_UI.codeNone]: "This package has no test project in the index.", + [DETAIL_UI.codeSource]: "{0} · line {1}", + [DETAIL_UI.codeProject]: "Example project: {0} — built and run by the index's CI.", + [DETAIL_UI.add]: "Add to mcpp.toml", + [DETAIL_UI.switchTo]: "Switch to {0}", + [DETAIL_UI.alreadyAdded]: "Already added", + [DETAIL_UI.installed]: "added", + [DETAIL_UI.opening]: "Opening {0}…", + [DETAIL_UI.addDev]: "dev dependency", + [DETAIL_UI.addLatest]: "The version is required.", + [DETAIL_UI.addNoVersion]: "This index publishes no version for this platform.", + [DETAIL_UI.command]: "Command", + [DETAIL_UI.usage]: "Bring it into your code", + [DETAIL_UI.copy]: "Copy", + [DETAIL_UI.copied]: "Copied to the clipboard.", + [DETAIL_UI.copying]: "Copying…", + [DETAIL_UI.indexLink]: "Open on the index site", + [DETAIL_UI.badgeExamples]: "✓ Has examples", + [DETAIL_UI.badgeCn]: "China mirror", + [DETAIL_UI.badgeOpenkalEcosystem]: "openkal-ecosystem", + [DETAIL_UI.badgeOpenkalCompat]: "openkal-compat", + [DETAIL_UI.badgeOpenkalPosix]: "POSIX environment", + [DETAIL_UI.badgeOpenkalPlatform]: "uses platform interfaces", + [DETAIL_UI.targets]: "Targets", + [DETAIL_UI.includeDirs]: "Include directories", +}; + +function model(patch: Partial = {}): DetailModel { + return { + ui: UI, + id: "compat.argparse", + name: "argparse", + description: "argparse — header-only argument parser for modern C++", + licenses: ["MIT"], + repo: "https://github.com/p-ranav/argparse", + registry: "mcpplibs", + surface: "header", + surfaces: ["header"], + badges: ["examples", "cn", "openkal-compat", "openkal-posix"], + versions: [ + { platform: "linux", versions: ["3.2"], current: true }, + { platform: "windows", versions: [], current: false }, + ], + currentVersions: ["3.2"], + latest: "3.2", + standard: "c++23", + dependencies: [{ id: "compat.vulkan", version: "1.4.357.3" }], + includeDirs: ["*/include"], + targets: ["argparse"], + snippets: [ + { + file: "tests/examples/argparse/tests/parse.cpp", + startLine: 1, + lines: ["// Behavioral test", '#include ', "int main() { return 0; }"], + usageLine: 2, + }, + ], + exampleProject: "argparse", + usage: [], + indexUrl: "https://mcpplibs.github.io/mcpp-index/packages/compat.argparse/", + commandTemplate: "mcpp add {0}@{1}", + commandDevTemplate: "mcpp add {0}@{1} --dev", + dataSource: "Read offline from /index/pkgs/c/compat.argparse.lua in the mcpplibs index (1 index folder(s) found).", + ...patch, + }; +} + +test("the detail page is a strict, self-contained webview", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /^/); + assert.match( + html, + //, + ); + assert.match(html, /', "import a.b;", ""], + usageLine: 2, + }, + ], + }), + ASSETS, + ); + assert.match(hostile, /class="tok-punctuation">#<\/span>/); + assert.match(hostile, /class="tok-keyword">include<\/span>/); + assert.match(hostile, /class="tok-comment">\/\/ <\/script><b><\/span>/); + assert.match(hostile, /class="tok-keyword">import<\/span>/); + assert.match(hostile, //); + assert.match(hostile, /a\.cpp · line 1/); + assert.match(hostile, /Example project: argparse/); + assert.equal(hostile.split("").length - 1, 1, "the hostile comment cannot close our script"); + + const none = renderDetailHtml(model({ snippets: [], exampleProject: undefined }), ASSETS); + assert.match(none, /This package has no test project in the index\./); +}); + +test("dependencies are listed as declared, and the resolved column is left out when unknown", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /compat\.vulkan<\/code> — 1\.4\.357\.3/); + assert.match(html, /What the descriptor declares\./); + const resolved = renderDetailHtml( + model({ dependencies: [{ id: "compat.zlib", version: "1.3", resolved: "1.2.13" }] }), + ASSETS, + ); + assert.match(resolved, /compat\.zlib<\/code> — 1\.3 · resolved 1\.2\.13/); + const none = renderDetailHtml(model({ dependencies: [] }), ASSETS); + assert.doesNotMatch(none, /data-section="dependencies"/); +}); + +test("the current-environment selector is the single source of the add command", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /]*placeholder="Search packages"/); + assert.doesNotMatch(html, /class="chip/); + assert.doesNotMatch(html, /data-chip/); + assert.doesNotMatch(html, /class="chips"/); + // The toggle still follows the search box, and no chip markup is left between. + const toolbar = html.slice(html.indexOf('class="toolbar"'), html.indexOf("", html.indexOf('class="toolbar"'))); + assert.match(toolbar, /library-search/); + assert.match(toolbar, /library-network/); +}); +test("the network toggle is off by default and names the setting it drives", () => { + const off = renderLibraryHtml(model(), ASSETS); + assert.match(off, //); + assert.match(off, /Search all registries/); + assert.doesNotMatch(off, /id="library-network"[^>]*checked/); + + const on = renderLibraryHtml(model({ networkSearch: true }), ASSETS); + assert.match(on, /id="library-network"[^>]*checked/); +}); + +test("the header count starts at what the first render keeps, and the footer names the source", () => { + // The count is the *query*'s count now: the filter chips that used to narrow it + // are gone, so an empty search keeps every row. + const html = renderLibraryHtml(model(), ASSETS); + assert.match(html, /id="library-count">2 of 2 packages { + const html = renderLibraryHtml(model({ rows: [], total: 0, notice: "No mcpp index was found." }), ASSETS); + assert.match(html, /id="library-empty" class="empty">No mcpp index was found\. { + assert.deepEqual( + badgeLabels(["examples", "cn", "openkal-compat", "openkal-posix"], (key) => UI[key] ?? key), + ["✓ Has examples", "China mirror", "openkal-compat", "POSIX environment"], + ); +}); + +test("decodeLibraryMessage accepts exactly four shapes", () => { + assert.deepEqual(decodeLibraryMessage({ type: "refresh" }), { type: "refresh" }); + assert.deepEqual(decodeLibraryMessage({ type: "search", query: "" }), { type: "search", query: "" }); + assert.deepEqual(decodeLibraryMessage({ type: "networkSearch", enabled: true }), { + type: "networkSearch", + enabled: true, + }); + assert.deepEqual(decodeLibraryMessage({ type: "open", id: "compat.argparse" }), { + type: "open", + id: "compat.argparse", + }); + + for (const raw of [ + undefined, + null, + "ready", + [], + { type: "unknown" }, + // The chips are gone, so the `filter` message they sent is not a shape. + { type: "filter", chip: "added" }, + { type: "open" }, + { type: "open", id: "" }, + { type: "networkSearch", enabled: "yes" }, + { type: "search" }, + // The document used to announce itself with this, and the host used to answer + // by re-rendering — which reloads the view, which announced itself again. + { type: "ready" }, + { type: "ready", extra: "field" }, + ]) { + assert.equal(decodeLibraryMessage(raw), undefined, `unexpected decode of ${JSON.stringify(raw)}`); + } +}); + +test("the document announces nothing, so it cannot make the host re-render it", () => { + const html = renderLibraryHtml(model(), ASSETS); + // The client script posts search / filter / networkSearch / open / refresh and + // nothing else. A "ready" post is what turned a render into a reload loop: + // `webview.html = …` reloads the document, so a document that answers its own + // load with "render me again" never stops loading. + assert.doesNotMatch(html, /post\(\{\s*type:\s*"ready"/); + assert.match(html, /post\(\{\s*type:\s*"mounted"\s*\}\)/); + assert.match(html, /libraryUpdate/); +}); + +test("the stylesheet uses theme tokens only, and clamps the description to two lines", () => { + const css = readFileSync(path.join(process.cwd(), "media", "library.css"), "utf8"); + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const literals = withoutComments.match(/#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/g) ?? []; + assert.deepEqual(literals, [], `library.css must not hard-code a colour: ${literals.join(", ")}`); + assert.match(withoutComments, /-webkit-line-clamp: 2/); + assert.match(withoutComments, /var\(--vscode-/); + // Colour is never the only signal: the added state is a bordered badge class. + assert.match(withoutComments, /\.badge-added/); +}); + +test("the client script is syntactically valid JavaScript", () => { + // A syntax error in the inline script would silently break the webview, and no + // other test would notice: the document still contains all the right markup. + const html = renderLibraryHtml(model({ query: "compat" }), ASSETS); + const script = / + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/cache-unavailable.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/cache-unavailable.html new file mode 100644 index 0000000..ffc61a4 --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/cache-unavailable.html @@ -0,0 +1,115 @@ + + + + + + + +cache.title + + +

    cache.title

    +
    +
    +

    cache.project.title

    +

    cache.unknown

    +

    mcpp.toml was not found

    + +
    +
    +

    cache.shared.title

    +

    mcpp cache list failed

    + +
    +

    cache.boundary

    +
    + + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-installed.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-installed.html new file mode 100644 index 0000000..82e858b --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-installed.html @@ -0,0 +1,216 @@ + + + + + + + +detail.title + + +
    +

    nlohmann.json

    #includeC++23MIT
    linux
    +

    JSON for Modern C++

    +
    +
    +
    + + +
    dependencies
    +
    +
    + + +
    + + + +

    mcpp add nlohmann.json@3.12.0

    + + +
    + +
    +
    +

    detail.usage

    +

    detail.usageSource

    import nlohmann.json;
    +

    detail.code

    +
    import nlohmann.json;
    +int main() {}
    +
    +

    detail.code.source

    +

    detail.code.project

    + +
    + +
    +
    +
    detail.source

    mcpp xpkg parse

    library.badge.examples

    + + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-loading.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-loading.html new file mode 100644 index 0000000..78a5bec --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/detail-loading.html @@ -0,0 +1,216 @@ + + + + + + + +detail.title + + +
    +

    nlohmann.json

    #includeC++23MIT
    linux
    +

    JSON for Modern C++

    +
    +
    +
    +

    detail.loading

    + +
    dependencies
    +
    +
    + + +
    + + + +

    mcpp add nlohmann.json@3.12.0

    + + +
    + +
    +
    +

    detail.usage

    +

    detail.usageSource

    import nlohmann.json;
    +

    detail.code

    +
    import nlohmann.json;
    +int main() {}
    +
    +

    detail.code.source

    +

    detail.code.project

    + +
    + +
    +
    +
    detail.source

    mcpp xpkg parse

    library.badge.examples

    + + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/graph.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/graph.html new file mode 100644 index 0000000..5833c06 --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/graph.html @@ -0,0 +1,36 @@ + + + +

    Dependency graph

    +
    +
    + + + +
    View options
    + + + +
    +
    +
    Select a node to explore its relationships.
    +
    +
    +
    +
    + + +
    Legend and source
    Projectpath / partitionindex / externalUnavailable

    Packages show declarations; modules show mcppls imports. External modules stop after one hop.

    + + \ No newline at end of file diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-empty.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-empty.html new file mode 100644 index 0000000..21ba308 --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-empty.html @@ -0,0 +1,145 @@ + + + + + + + +library.title + + +
    +

    library.title

    +
    +
    + + + + + library.count +
    +
    +
    + +
      + +

      No library index was found.

      +

      +
      +
      +
      +
      the local mcpp-index

      /home/user/.mcpp/registry/data/mcpp-index

      + +
      + + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-list.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-list.html new file mode 100644 index 0000000..cf9dafe --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/library-list.html @@ -0,0 +1,173 @@ + + + + + + + +library.title + + +
      +

      library.title

      +
      +
      + + + + + library.count +
      +
      +
      + +
      • + +
      • +
      • + +
      • +
      • + +
      + + +

      +
      +
      +
      +
      the local mcpp-index

      /home/user/.mcpp/registry/data/mcpp-index

      + +
      + + + diff --git a/mide-plugins/vscode/test/parity/snapshots/0.9.0/settings.html b/mide-plugins/vscode/test/parity/snapshots/0.9.0/settings.html new file mode 100644 index 0000000..beb5972 --- /dev/null +++ b/mide-plugins/vscode/test/parity/snapshots/0.9.0/settings.html @@ -0,0 +1,321 @@ + + + + + + + +panel.title + + +
      +

      panel.title

      +

      panel.boundary

      +

      panel.resourceLabel

      +
      +
      + + + + +
      + +
      +
      +

      + +

      +
      +
      +
      + Set the mcpp executable path + + + + panel.source.default +
      +

      Path to the mcpp CLI.

      + + +
      +
      + + +
      +
      +
      +
      + Reveal the task terminal + panel.modified + + + panel.source.workspace +
      +

      When to show the terminal.

      + +

      panel.applies.nextBuild

      +
      +
      + + +
      +
      + +
      +
      + Build arguments + + + + panel.source.default +
      +

      Extra arguments.

      + +

      panel.applies.nextBuild

      +

      panel.arrayHint

      +
      + + +
      +
      + +
      +
      +
      + + + + diff --git a/mide-plugins/vscode/test/projects/context.test.ts b/mide-plugins/vscode/test/projects/context.test.ts new file mode 100644 index 0000000..333df7b --- /dev/null +++ b/mide-plugins/vscode/test/projects/context.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + IN_PROJECT_CONTEXT_KEY, + MCPP_MANIFEST_GLOB, + registerInProjectContext, + updateInProjectContext, + type InProjectEnvironment, +} from "../../src/projects/context"; + +interface FakeState { + project: unknown | undefined; + contextValues: Map; + changeListeners: Array<() => void>; + disposed: boolean; +} + +function fakeEnvironment(project?: unknown): { env: InProjectEnvironment; state: FakeState } { + const state: FakeState = { + project, + contextValues: new Map(), + changeListeners: [], + disposed: false, + }; + const env: InProjectEnvironment = { + currentProject: () => state.project, + setContextValue: (key, value) => { + state.contextValues.set(key, value); + return Promise.resolve(); + }, + subscribe: (listener) => { + state.changeListeners.push(listener); + return [{ + dispose: () => { + state.disposed = true; + }, + }]; + }, + }; + return { env, state }; +} + +async function flushAsync(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +test("exposes the fixed context key and manifest glob", () => { + assert.equal(IN_PROJECT_CONTEXT_KEY, "mcpp.inProject"); + assert.equal(MCPP_MANIFEST_GLOB, "**/mcpp.toml"); +}); + +test("sets the context key to true when the active resource resolves to an mcpp project", async () => { + const { env, state } = fakeEnvironment({ root: "/work/A" }); + const result = await updateInProjectContext(env); + assert.equal(result, true); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), true); +}); + +test("sets the context key to false when the active resource has no mcpp project", async () => { + const { env, state } = fakeEnvironment(); + const result = await updateInProjectContext(env); + assert.equal(result, false); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), false); +}); + +test("writes the context on registration and owns the change subscriptions", async () => { + const { env, state } = fakeEnvironment({ root: "/work/A" }); + const registration = registerInProjectContext(env); + await flushAsync(); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), true); + assert.equal(state.changeListeners.length, 1); + + registration.dispose(); + assert.equal(state.disposed, true); +}); + +test("re-evaluates the context when the active project changes", async () => { + const { env, state } = fakeEnvironment(); + registerInProjectContext(env); + await flushAsync(); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), false); + + state.project = { root: "/work/A" }; + for (const listener of state.changeListeners) { + listener(); + } + await flushAsync(); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), true); + + state.project = undefined; + for (const listener of state.changeListeners) { + listener(); + } + await flushAsync(); + assert.equal(state.contextValues.get(IN_PROJECT_CONTEXT_KEY), false); +}); diff --git a/mide-plugins/vscode/test/projects/dependencyGraph.test.ts b/mide-plugins/vscode/test/projects/dependencyGraph.test.ts new file mode 100644 index 0000000..f3d5074 --- /dev/null +++ b/mide-plugins/vscode/test/projects/dependencyGraph.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { buildDependencyGraph, graphMermaid, type GraphReader } from "../../src/projects/dependencyGraph"; + +test("graph shows three dependency hops, shared edges and cycles without inventing lock edges", async () => { + const root = path.resolve("project"); + const reader: GraphReader = { + manifest: async () => '[dependencies.compat]\na="1"\nb="2"\n[dev-dependencies]\ntest="3"\n[build-dependencies]\ncodegen="4"', + descriptor: (id) => ({id, resolved: id === "compat.a" ? "1.2" : undefined, dependencies: ({"compat.a":["compat.c"],"compat.b":["compat.c"],"compat.c":["compat.a","compat.d"]} as Record)[id]?.map(id=>({id,dev:false})) ?? []}), + }; + const graph = await buildDependencyGraph(root, "demo", reader); + assert.equal(graph.nodes.find(n=>n.name==="compat.c")?.depth, 2); + assert.equal(graph.nodes.find(n=>n.name==="compat.d")?.depth, 3); + assert.equal(graph.nodes.filter(n=>n.name==="compat.c").length, 1); + assert.ok(graph.edges.some(e=>e.from==="compat.c"&&e.to==="compat.a")); + assert.equal(graph.nodes.find(n=>n.name==="compat.a")?.version, "1.2"); + assert.ok(graph.edges.some(e=>e.kind==="dev")); + assert.ok(graph.edges.some(e=>e.kind==="build")); + assert.match(graphMermaid(graph), /\|"compat\.a@1"\|/, "keep a coordinate intact in a quoted Mermaid edge label"); + const shallow = await buildDependencyGraph(root, "demo", reader, 2); + assert.ok(!shallow.nodes.some(n=>n.name==="compat.d")); +}); + +test("path packages traverse their manifests; unavailable and conditional declarations remain explicit", async () => { + const root = path.resolve("workspace/app"), local=path.resolve(root,"../local"); + const graph = await buildDependencyGraph(root, "app", { + manifest: async directory=>directory===root ? '[dependencies]\nremote={git="https://example.test/repo"}\n[dependencies.local]\npath="../local"\n[target.\'cfg(windows)\'.dependencies]\nwin="1"' : directory===local ? '[dependencies]\nleaf="2"' : undefined, + descriptor: () => undefined, + }); + assert.equal(graph.nodes.find(n=>n.key===local)?.source,"manifest"); + assert.ok(graph.edges.some(e=>e.from===local&&e.to==="unknown:leaf")); + assert.equal(graph.nodes.find(n=>n.name==="remote")?.source,"unknown"); + assert.ok(graph.edges.some(e=>e.conditional&&e.to==="unknown:win")); +}); + +test("large graphs are bounded and untrusted labels cannot escape Mermaid strings", async () => { + const graph=await buildDependencyGraph("root", 'evil"]\nclick n0 "https://example.test"', { + manifest:async()=>"[dependencies]\n"+Array.from({length:200},(_,i)=>`dep${i}="1"`).join("\n"),descriptor:()=>undefined, + }); + assert.equal(graph.nodes.length,120); assert.equal(graph.limited,true); + const source=graphMermaid(graph); + assert.doesNotMatch(source,/\nclick/);assert.match(source,/#34;/); +}); diff --git a/mide-plugins/vscode/test/projects/dependencyState.test.ts b/mide-plugins/vscode/test/projects/dependencyState.test.ts new file mode 100644 index 0000000..525c69d --- /dev/null +++ b/mide-plugins/vscode/test/projects/dependencyState.test.ts @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertCompleteManifest, retainProjectDependencies } from "../../src/projects/dependencyState"; +import { buildProjectTree, type ProjectSummary } from "../../src/views/models"; +test("temporary read failures preserve dependencies; successful removal clears them", () => { + const previous: ProjectSummary = {root: "/app", dependencies: [{name: "lib"}]}; + const stale = retainProjectDependencies({root: "/app", error: "temporarily unavailable"}, previous)!; + assert.equal(stale.dependencyState, "stale"); assert.equal(stale.dependencies?.length, 1); + const row = buildProjectTree(stale).find(node => node.id === "project.dependencies"); assert.equal(row?.children?.length, 1); + const empty = retainProjectDependencies({root: "/app", dependencies: []}, stale)!; + assert.equal(empty.dependencyState, "empty"); assert.equal(buildProjectTree(empty).find(node => node.id === "project.dependencies")?.children, undefined); + const other = retainProjectDependencies({root: "/other", error: "missing"}, stale)!; + assert.equal(other.dependencies, undefined); assert.equal(other.dependencyState, "error"); + assert.ok(buildProjectTree(other).find(node => node.id === "project.dependencies")?.command); +}); +test("incomplete edits cannot clear the last valid list, complete multiline manifests remain valid", () => { + for (const text of ['[dependencies', '[dependencies]\nlib = "', '[dependencies]\nlib = ', 'garbage', '[dependencies]\nlib={version=}']) assert.throws(() => assertCompleteManifest(text)); + for (const text of ['', '# comment', '[dependencies]\nlib="1"', '[test]\ndiscover = [\n"tests/**/*.cpp",\n]\n', '[custom]\nratio=1.5\nwhen=2026-10-20']) assert.doesNotThrow(() => assertCompleteManifest(text)); +}); diff --git a/mide-plugins/vscode/test/projects/discovery.test.ts b/mide-plugins/vscode/test/projects/discovery.test.ts new file mode 100644 index 0000000..d1d97ea --- /dev/null +++ b/mide-plugins/vscode/test/projects/discovery.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { findNearestMcppProject, discoveryBoundaryOf } from "../../src/projects/discovery"; + +test("finds the nearest mcpp manifest and project root", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-")); + try { + const sourceDirectory = path.join(root, "src", "nested"); + mkdirSync(sourceDirectory, { recursive: true }); + writeFileSync(path.join(root, "mcpp.toml"), "[package]\nname = 'demo'\n"); + + assert.deepEqual(findNearestMcppProject(sourceDirectory), { + root, + manifestPath: path.join(root, "mcpp.toml"), + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("selects the nearest member inside a multi-member workspace", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-members-")); + try { + const memberA = path.join(root, "A"); + const memberB = path.join(root, "B"); + const sourceA = path.join(memberA, "src"); + const sourceB = path.join(memberB, "src"); + mkdirSync(sourceA, { recursive: true }); + mkdirSync(sourceB, { recursive: true }); + writeFileSync(path.join(root, "mcpp.toml"), "[workspace]\nmembers = ['A', 'B']\n"); + writeFileSync(path.join(memberA, "mcpp.toml"), "[package]\nname = 'A'\n"); + writeFileSync(path.join(memberB, "mcpp.toml"), "[package]\nname = 'B'\n"); + + assert.equal(findNearestMcppProject(sourceA, root)?.root, memberA); + assert.equal(findNearestMcppProject(sourceB, root)?.root, memberB); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("does not discover an mcpp project outside the opened workspace folder", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-boundary-")); + try { + const openedMember = path.join(root, "A"); + const externalMemberSource = path.join(root, "B", "src"); + mkdirSync(openedMember, { recursive: true }); + mkdirSync(externalMemberSource, { recursive: true }); + writeFileSync(path.join(openedMember, "mcpp.toml"), "[package]\nname = 'A'\n"); + writeFileSync(path.join(root, "B", "mcpp.toml"), "[package]\nname = 'B'\n"); + + assert.equal(findNearestMcppProject(externalMemberSource, openedMember), undefined); + assert.equal(findNearestMcppProject(externalMemberSource, openedMember, "workspaceFolder"), undefined); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("mcpp.project.discoveryBoundary=filesystem finds the nested project outside the workspace", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-filesystem-")); + try { + const openedMember = path.join(root, "A"); + const externalMember = path.join(root, "B"); + const externalMemberSource = path.join(externalMember, "src", "nested"); + mkdirSync(openedMember, { recursive: true }); + mkdirSync(externalMemberSource, { recursive: true }); + writeFileSync(path.join(openedMember, "mcpp.toml"), "[package]\nname = 'A'\n"); + writeFileSync(path.join(externalMember, "mcpp.toml"), "[package]\nname = 'B'\n"); + + // The boundary parameter is the only difference; ignoring it fails this test. + assert.deepEqual(findNearestMcppProject(externalMemberSource, openedMember, "filesystem"), { + root: externalMember, + manifestPath: path.join(externalMember, "mcpp.toml"), + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the discovery boundary setting maps to the two supported walks", () => { + assert.equal(discoveryBoundaryOf("filesystem"), "filesystem"); + assert.equal(discoveryBoundaryOf("workspaceFolder"), "workspaceFolder"); + // An unknown value keeps the shipped behaviour: stop at the workspace folder. + assert.equal(discoveryBoundaryOf("everything"), "workspaceFolder"); + assert.equal(discoveryBoundaryOf(undefined), "workspaceFolder"); +}); diff --git a/mide-plugins/vscode/test/projects/graphPanel.test.ts b/mide-plugins/vscode/test/projects/graphPanel.test.ts new file mode 100644 index 0000000..a00d5a8 --- /dev/null +++ b/mide-plugins/vscode/test/projects/graphPanel.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { runInNewContext } from "node:vm"; +import test from "node:test"; + +const tick = (ms = 10) => new Promise(resolve => setTimeout(resolve, ms)); +async function until(predicate: () => boolean): Promise { + for (let i = 0; i < 200; i++) { if (predicate()) { return; } await tick(); } + assert.ok(predicate(), "host state did not settle"); +} + +test("real graph host coalesces file events, ignores lock-only events, defers hidden reads and rejects cross-view publications", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "graph-panel-")); + const roots = [path.join(directory, "a"), path.join(directory, "b")]; + for (const root of roots) { + await mkdir(path.join(root, "src"), {recursive:true}); + await writeFile(path.join(root, "mcpp.toml"), `[package]\nname="${path.basename(root)}"\n`); + await mkdir(path.join(root, "tests")); + await Promise.all(Array.from({length:25},(_,i)=>writeFile(path.join(root,`tests/test${i}.cpp`), "int main(){}\n"))); + await writeFile(path.join(root, "src/main.cpp"), "int main(){}\n"); + await writeFile(path.join(root, "src/lib.cppm"), "export module app;\n"); + } + const messages: Array> = [], commands = new Map Promise>(); + const fileEvents: Array<(uri: any) => void> = []; + let receive: (message: any) => void = () => {}, visibility: () => void = () => {}, queries = 0; + let holdSnapshot = false, releaseSnapshot: (() => void) | undefined; + const disposable = {dispose(){}}; + const uri = (file: string) => ({fsPath:file, toString:()=>file}); + const panel = { + visible:true, + reveal(){}, onDidChangeViewState(listener: () => void){visibility=listener;return disposable;}, onDidDispose(){return disposable;}, dispose(){}, + webview:{cspSource:"test",asWebviewUri:(value: any)=>value, + onDidReceiveMessage(listener: (message: any) => void){receive=listener;return disposable;}, + async postMessage(message: Record){messages.push(message); if(message.type==="snapshot"&&holdSnapshot){holdSnapshot=false;await new Promise(resolve=>{releaseSnapshot=resolve;});} return true;},html:""}, + }; + const vscode = { + Uri:{file:uri,parse:uri,joinPath:(base: any,...parts: string[])=>uri(path.join(base.fsPath,...parts))}, + EventEmitter:class{event=()=>disposable;fire(){}dispose(){}}, + RelativePattern:class{constructor(public root: string,public pattern: string){}},ViewColumn:{Active:1,Beside:2}, + workspace:{isTrusted:true,onDidSaveTextDocument:()=>disposable,findFiles:async(pattern: {root: string})=>Array.from({length:25},(_,i)=>uri(path.join(pattern.root,`tests/test${i}.cpp`))),registerTextDocumentContentProvider:()=>disposable, + createFileSystemWatcher:()=>({dispose(){},onDidChange(listener: (uri: any)=>void){fileEvents.push(listener);return disposable;},onDidCreate:()=>disposable,onDidDelete:()=>disposable})}, + window:{createWebviewPanel:()=>panel,showInformationMessage(){},showErrorMessage(error: string){throw new Error(error);}}, + extensions:{getExtension:()=>undefined},tasks:{onDidEndTaskProcess:()=>disposable}, + commands:{registerCommand:(id: string,handler: (...args: any[])=>Promise)=>{commands.set(id,handler);return disposable;}}, + }; + const filename = path.resolve(__dirname,"../../src/projects/dependencyGraphPanel.js"), nativeRequire=createRequire(filename); + const exports: Record = {}; + const context = {extensionUri:uri(directory),subscriptions:[] as Array<{dispose():void}>}; + const customRequire = (id: string) => { + if(id==="vscode")return vscode; + if(id==="../cli/moduleGraph")return {queryModuleGraph:async(_trusted: boolean,_executable: string,root: string)=>{ + queries++;return {modules:[{name:`app.${path.basename(root)}`,files:["src/lib.cppm"],external:false}],imports:[{from:"src/main.cpp",to:`app.${path.basename(root)}`} ]}; + }}; + if(id==="../library/indexLocator")return {}; + if(id==="../webview/document")return {WebviewDocument:class{assets(){return {nonce:"test",styleUri:"test",cspSource:"test"};}paint(){}invalidate(){}}}; + return nativeRequire(id); + }; + runInNewContext(await readFile(filename,"utf8"),{exports,require:customRequire,setTimeout,clearTimeout,AbortController,process,Buffer}); + try { + exports.registerDependencyGraph(context,()=>({root:roots[0]})); + const open=commands.get("mcpp.project.showModuleDependencyGraph")!; + await open({root:roots[0]});receive({type:"ready"}); + await until(()=>messages.some(message=>message.type==="graph"));assert.equal(queries,1); + assert.equal(messages.find(message=>message.type==="graph")?.center,false,"initial view keeps Fit instead of centering the root"); + const viewKey=`modules:${roots[0]}`; + receive({type:"action",action:"select",viewKey,id:"app.a"});await tick();assert.equal(queries,1); + receive({type:"action",action:"group",viewKey,group:"@group/tests"});await tick(); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.selected,"@group/tests"); + receive({type:"action",action:"select",viewKey,id:"tests/test0.cpp"}); + receive({type:"action",action:"page",viewKey,next:true});await tick(); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.selected,"@group/tests","paging clears a selection from the previous page"); + const focusedEntry = messages.filter(message=>message.type==="snapshot").at(-1)?.groups.find((group: {id:string})=>group.id==="@group/tests").nodes[20]; + assert.ok(focusedEntry); + receive({type:"action",action:"focus",viewKey,id:focusedEntry});await until(()=>messages.filter(message=>message.type==="graph").at(-1)?.focus===focusedEntry); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,undefined,"focusing one entry leaves the group pager"); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,0); + receive({type:"action",action:"back",viewKey});await until(()=>messages.filter(message=>message.type==="graph").at(-1)?.group==="@group/tests"); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.group,"@group/tests"); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.offset,20,"Back restores the previous test page"); + for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm"))); + await until(()=>queries===2);await tick(300);assert.equal(queries,2); + fileEvents[0](uri(path.join(roots[0],"mcpp.lock")));await tick(250);assert.equal(queries,2); + panel.visible=false;visibility();for(let i=0;i<100;i++)fileEvents[0](uri(path.join(roots[0],"src/lib.cppm"))); + await tick(250);assert.equal(queries,2);panel.visible=true;visibility();await until(()=>queries===3);await tick(50);assert.equal(queries,3); + // Reopening a disposed webview requires a fresh snapshot. Pause its delivery and switch projects. + holdSnapshot=true; + const switching=open({root:roots[1]});await until(()=>!!releaseSnapshot); + await open({root:roots[0]}); + const beforeRelease=messages.length;releaseSnapshot!();await switching;await tick(); + assert.ok(messages.slice(beforeRelease).every(message=>message.type!=="graph"||message.viewKey===viewKey)); + assert.equal(messages.filter(message=>message.type==="graph").at(-1)?.viewKey,viewKey); + } finally { + releaseSnapshot?.(); + for(const subscription of context.subscriptions)subscription.dispose(); + await rm(directory,{recursive:true,force:true}); + } +}); diff --git a/mide-plugins/vscode/test/projects/metadataWatch.test.ts b/mide-plugins/vscode/test/projects/metadataWatch.test.ts new file mode 100644 index 0000000..3f820f5 --- /dev/null +++ b/mide-plugins/vscode/test/projects/metadataWatch.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { ProjectMetadataWatch } from "../../src/projects/metadataWatch"; +test("metadata fallback reads only two stamps while visible and emits each change once",()=>{ + const values=new Map();let reads=0; + const watcher=new ProjectMetadataWatch(file=>{reads++;return values.get(file)||"missing";}); + watcher.capture("project");assert.equal(reads,2); + assert.deepEqual(watcher.changes(false),[]);assert.equal(reads,2); + assert.deepEqual(watcher.changes(),[]);assert.equal(reads,4); + const manifest=path.join("project","mcpp.toml");values.set(manifest,"new"); + assert.deepEqual(watcher.changes(),[manifest]);assert.deepEqual(watcher.changes(),[]); + values.set(manifest,"newer");watcher.capture("project");assert.deepEqual(watcher.changes(),[],"a normal refresh captures the version before fallback runs"); + watcher.capture();const before=reads;assert.deepEqual(watcher.changes(),[]);assert.equal(reads,before); +}); diff --git a/mide-plugins/vscode/test/projects/moduleGraph.test.ts b/mide-plugins/vscode/test/projects/moduleGraph.test.ts new file mode 100644 index 0000000..914db8c --- /dev/null +++ b/mide-plugins/vscode/test/projects/moduleGraph.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { matchesSourceGlob, testCandidateGlob, buildModuleGraph, moduleEntries, moduleOwnership, parseModuleGraph, type ModuleGraphJson } from "../../src/projects/moduleGraph"; +import { visibleGraph } from "../../src/graphs/model"; + +const root = path.resolve("workspace/app"); +const defaults = { depth: 3, expanded: new Set(), collapsed: new Set() }; +test("mcppls graph preserves main imports and all internal depths, but stops at external/path modules", async () => { + const raw: ModuleGraphJson = { modules: [ + ...Array.from({length: 8}, (_,i) => ({ name: `app.m${i}`, files: [`src/m${i}.cppm`], external: false })), + { name: "dep", files: ["vendor/dep/src/dep.cppm"], external: false }, + { name: "transitive", files: ["vendor/dep/src/other.cppm"], external: false }, + { name: "std", files: [path.resolve("kit/std.cppm")], external: true }, + ], imports: [{from:"src/main.cpp",to:"app.m0"}, ...Array.from({length:7},(_,i)=>({from:`app.m${i}`,to:`app.m${i+1}`})), {from:"app.m7",to:"dep"},{from:"dep",to:"transitive"}] }; + const files = [...raw.modules.flatMap(node=>node.files),"src/main.cpp"].map(file=>path.resolve(root,file)); + const owned = await moduleOwnership(root, files, async file=>file===path.join(root,"vendor/dep/mcpp.toml")); + const graph = buildModuleGraph(root, raw, owned, moduleEntries(root, "", "app")); + assert.ok(graph.nodes.find(node=>node.id==="src/main.cpp")?.entry); + assert.equal(graph.edges.length,9); + assert.ok(graph.nodes.some(node=>node.id==="app.m7")); + assert.equal(graph.nodes.find(node=>node.id==="dep")?.kind,"index"); + assert.ok(!graph.nodes.some(node=>node.id==="transitive"||node.id==="std")); + const initial = visibleGraph(graph,defaults); + assert.ok(!initial.nodes.some(node=>node.id==="app.m7")); + const full = visibleGraph(graph,{...defaults,depth:Infinity}); + assert.ok(full.nodes.some(node=>node.id==="app.m7")); + assert.ok(full.nodes.some(node=>node.id==="dep")); +}); +test("module entry conventions use explicit main/lib paths and preserve isolated library roots", () => { + const entries = moduleEntries(root,'[targets."demo app"]\nkind="bin"\nmain="app/start.cpp"\n[lib]\npath="interface/root.cppm"',"pkg.core"); + assert.equal(entries.get(path.join(root,"app/start.cpp")),"demo app"); + assert.equal(entries.get(path.join(root,"interface/root.cppm")),"lib"); + assert.ok(!entries.has(path.join(root,"src/main.cpp"))); + const inferred=moduleEntries(root,"","pkg.core"); + assert.ok(inferred.has(path.join(root,"src/core.cppm"))); + const graph=buildModuleGraph(root,{modules:[{name:"core",files:["src/core.cppm"],external:false}],imports:[]},new Set([path.join(root,"src/core.cppm"),path.join(root,"src/main.cpp")]),inferred); + assert.ok(graph.nodes.find(node=>node.id==="core")?.entry); + assert.ok(graph.nodes.find(node=>node.files?.includes(path.join(root,"src/main.cpp")))?.entry); +}); +test("partitions and unknown imported modules retain identity without fabricated files", () => { + const graph=buildModuleGraph(root,{modules:[{name:"app:types",files:["src/types.cppm"],external:false}],imports:[{from:"app:types",to:"missing"}]},new Set([path.join(root,"src/types.cppm")]),new Map()); + assert.equal(graph.nodes.find(node=>node.id==="app:types")?.kind,"manifest"); + assert.equal(graph.nodes.find(node=>node.id==="missing")?.files,undefined); + assert.equal(graph.nodes.find(node=>node.id==="missing")?.kind,"unknown"); +}); +test("malformed and upstream-truncated graphs cannot masquerade as complete", () => { + for(const value of ['null','{}','{"modules":[{}],"imports":[]}','{"modules":[],"imports":[null]}'])assert.throws(()=>parseModuleGraph(value)); + const graph=buildModuleGraph(root,parseModuleGraph('{"modules":[],"imports":[],"truncated":true}'),new Set(),new Map()); + assert.equal(graph.truncated,true); +}); +test("test discovery honors custom globs, exclusions and empty discover without inventing a library", () => { + const candidates = ["checks/a.cpp", "checks/deep/b.cpp", "checks/skip.cpp", "tests/default.cpp"].map(file => path.join(root, file)); + const manifest = '[test]\ndiscover=["checks/**/*.cpp", "!checks/skip.cpp"]'; + const entries = moduleEntries(root, manifest, "app", candidates); + assert.match(entries.get(candidates[0])!, /test/); assert.match(entries.get(candidates[1])!, /test/); + assert.equal(entries.get(candidates[2]), undefined); assert.equal(entries.get(candidates[3]), undefined); + assert.ok(!moduleEntries(root, '[test]\ndiscover=[]', "app", candidates).has(candidates[0])); + const graph = buildModuleGraph(root, {modules:[], imports:[]}, new Set(candidates), entries); + assert.deepEqual(graph.groups?.map(group => group.label), ["Tests"]); assert.equal(graph.groups?.[0].nodes.length, 2); +}); + +test("target names do not determine library roles and shared main files preserve target names", () => { + const entries = moduleEntries(root, '[targets.library-tool]\nmain="src/start.cpp"\n[targets.second]\nmain="src/start.cpp"', "app"); + assert.match(entries.get(path.join(root,"src/start.cpp"))!, /library-tool.*second/); + const graph = buildModuleGraph(root, {modules:[],imports:[]}, new Set([path.join(root,"src/start.cpp")]), entries); + assert.deepEqual(graph.groups?.map(group => group.label), ["Main"]); +}); + +test("mcpp glob matching is bounded and candidates include character-based double-star matches", () => { + assert.equal(matchesSourceGlob("tests/not-test.cpp", "tests/**/test.cpp"),true); + assert.equal(matchesSourceGlob("tests/nested/test.cpp", "tests/*.cpp"),false); + assert.equal(matchesSourceGlob("tests/test.cpp", "tests/**/*.cpp"),true); + assert.equal(matchesSourceGlob("a".repeat(1000), "**".repeat(100)+"b"),false); + assert.equal(testCandidateGlob("tests/**/test.cpp"),"tests/**/*"); + assert.equal(testCandidateGlob("checks/a*.cc"),"checks/**/*"); + assert.equal(testCandidateGlob("check.cpp"),"check.cpp"); + assert.equal(testCandidateGlob("checks/test[1].cpp"),"checks/**/*"); + assert.equal(matchesSourceGlob("checks/test[1].cpp","checks/test[1].cpp"),true); +}); diff --git a/mide-plugins/vscode/test/projects/moduleGraphCli.test.ts b/mide-plugins/vscode/test/projects/moduleGraphCli.test.ts new file mode 100644 index 0000000..eb1e6c8 --- /dev/null +++ b/mide-plugins/vscode/test/projects/moduleGraphCli.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { queryModuleGraph } from "../../src/cli/moduleGraph"; +test("module graph CLI uses structured output, bounds process time/output and enforces trust",async()=>{ + let calls=0; + const run=async(executable:string,args:string[],cwd?:string,options?:{timeoutMs?:number;maxBufferMiB?:number})=>{ + calls++;assert.equal(executable,"mcppls");assert.equal(cwd,"project");assert.deepEqual(args,["query","module","--graph","--root","project","--timeout","20","--format","json"]);assert.equal(options?.timeoutMs,30000);assert.equal(options?.maxBufferMiB,8); + return {exitCode:0,stdout:'{"modules":[],"imports":[]}',stderr:""}; + }; + await assert.rejects(queryModuleGraph(false,"mcppls","project",run),/Trust/);assert.equal(calls,0); + const graph=await queryModuleGraph(true,"mcppls","project",run);assert.deepEqual(graph.modules,[]);assert.equal(calls,1); +}); +test("module graph command failure remains retryable instead of returning an empty graph",async()=>{ + await assert.rejects(queryModuleGraph(true,"mcppls","project",async()=>({exitCode:2,stdout:"",stderr:"not ready"})),/not ready/); +}); diff --git a/mide-plugins/vscode/test/projects/summary.test.ts b/mide-plugins/vscode/test/projects/summary.test.ts new file mode 100644 index 0000000..77d3599 --- /dev/null +++ b/mide-plugins/vscode/test/projects/summary.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readProjectSummary } from "../../src/projects/summary"; + +const MANIFEST = [ + "[package]", + 'name = "greeter"', + 'version = "0.1.0"', + 'standard = "c++23"', + "# a comment with name = \"not-this\"", + "", + "[targets.greet]", + 'kind = "bin"', + 'main = "src/main.cpp"', + "", + "[targets.greet-lib]", + 'kind = "lib"', + "", + "[toolchain]", + 'spec = "llvm@22.1.8"', + "", + "[target.'cfg(os = \"linux\")']", + 'runner = "qemu"', + "", + "[test]", + 'discover = "tests/**/*.cpp"', +].join("\n") + .split("\n"); + +test("reads the identity, the toolchain and the artifact targets", () => { + const summary = readProjectSummary("/w", MANIFEST); + assert.equal(summary.name, "greeter"); + assert.equal(summary.version, "0.1.0"); + assert.equal(summary.standard, "c++23"); + assert.equal(summary.toolchainSpec, "llvm@22.1.8"); + assert.deepEqual(summary.targets, [ + { name: "greet", kind: "bin" }, + { name: "greet-lib", kind: "lib" }, + ]); + assert.equal(summary.hasTests, true); + assert.equal(summary.root, "/w"); +}); + +test("a commented-out assignment is not read", () => { + const summary = readProjectSummary("/w", ['[package]', 'name = "real"', '# name = "fake"']); + assert.equal(summary.name, "real"); +}); + +test("nested target sections do not leak into [targets]", () => { + const summary = readProjectSummary("/w", ["[targets.a]", 'kind = "bin"', "[target.'cfg(unix)']", 'runner = "x"']); + assert.deepEqual(summary.targets, [{ name: "a", kind: "bin" }]); +}); + +test("a target with no kind is still listed", () => { + const summary = readProjectSummary("/w", ["[targets.a]", 'main = "x.cpp"']); + assert.deepEqual(summary.targets, [{ name: "a", kind: "target" }]); +}); + +test("an empty or unreadable manifest yields a root only", () => { + const summary = readProjectSummary("/w", []); + assert.deepEqual(summary, { root: "/w" }); +}); + +test("single-quoted values are accepted", () => { + const summary = readProjectSummary("/w", ["[package]", "name = 'single'"]); + assert.equal(summary.name, "single"); +}); + +test("a trailing comment after a value is stripped", () => { + const summary = readProjectSummary("/w", ["[package]", 'name = "greeter" # the project']); + assert.equal(summary.name, "greeter"); +}); + +test("the default_profile and the build profile are both honoured", () => { + assert.equal(readProjectSummary("/w", ["[package]", 'default_profile = "release"']).profile, "release"); + assert.equal(readProjectSummary("/w", ["[build]", 'profile = "dev"']).profile, "dev"); +}); diff --git a/mide-plugins/vscode/test/toml/parser.test.ts b/mide-plugins/vscode/test/toml/parser.test.ts new file mode 100644 index 0000000..2633a30 --- /dev/null +++ b/mide-plugins/vscode/test/toml/parser.test.ts @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseMcppToml, + type TomlKeyValueNode, + type TomlSectionNode, +} from "../../src/toml/parser"; + +function sectionAt(lines: string[], index: number): TomlSectionNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "section"); + return node as TomlSectionNode; +} + +function keyValueAt(lines: string[], index: number): TomlKeyValueNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "keyValue"); + return node as TomlKeyValueNode; +} + +// ---- 段头解析 ---- + +test("解析普通段头及其范围", () => { + const section = sectionAt(["[package]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); + assert.equal(section.segments[0].quoted, false); + assert.equal(section.isArray, false); + assert.equal(section.open, false); + assert.equal(section.line, 0); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + assert.deepEqual(section.segments[0].range, { startLine: 0, startCharacter: 1, endLine: 0, endCharacter: 8 }); +}); + +test("解析参数化段头 [targets.myapp]", () => { + const section = sectionAt(["[targets.myapp]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["targets", "myapp"]); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 14 }); +}); + +test("解析单引号段 [target.'cfg(windows)'.build]", () => { + const section = sectionAt(["[target.'cfg(windows)'.build]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["target", "cfg(windows)", "build"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 22 }); +}); + +test("解析双引号段 [runtime.\"opengl.glx.driver\"](带点的段名是一段)", () => { + const section = sectionAt(['[runtime."opengl.glx.driver"]'], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["runtime", "opengl.glx.driver"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 28 }); +}); + +test("解析数组表段头 [[build.flags]]", () => { + const section = sectionAt(["[[build.flags]]"], 0); + assert.equal(section.isArray, true); + assert.equal(section.open, false); + assert.deepEqual(section.segments.map((s) => s.name), ["build", "flags"]); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 15 }); +}); + +test("未闭合段头解析为 open 状态且不抛错", () => { + const document = parseMcppToml(["[dep", 'name = "x"']); + const section = document.nodes[0] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); + // 后续行继续正常解析。 + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); +}); + +// ---- 键值解析 ---- + +test("解析裸键字符串键值对及其范围", () => { + const keyValue = keyValueAt(["[package]", 'name = "demo"'], 1); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "demo"); + assert.deepEqual(keyValue.value?.range, { startLine: 1, startCharacter: 7, endLine: 1, endCharacter: 13 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("解析引号键(键名带点仍是一段)", () => { + const keyValue = keyValueAt(['"chriskohlhoff.asio" = "1.28"'], 0); + assert.equal(keyValue.keyPath.length, 1); + assert.equal(keyValue.keyPath[0].name, "chriskohlhoff.asio"); + assert.equal(keyValue.keyPath[0].quoted, true); + assert.equal(keyValue.value?.text, "1.28"); +}); + +test("解析点分键 capi.lua", () => { + const keyValue = keyValueAt(['capi.lua = "1.0"'], 0); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["capi", "lua"]); + assert.deepEqual(keyValue.keyPath[1].range, { startLine: 0, startCharacter: 5, endLine: 0, endCharacter: 8 }); +}); + +test("解析整数与布尔值", () => { + const integer = keyValueAt(["opt = 2"], 0); + assert.equal(integer.value?.kind, "integer"); + assert.equal(integer.value?.text, "2"); + assert.deepEqual(integer.value?.range, { startLine: 0, startCharacter: 6, endLine: 0, endCharacter: 7 }); + + const boolean = keyValueAt(["debug = true"], 0); + assert.equal(boolean.value?.kind, "boolean"); + assert.equal(boolean.value?.text, "true"); + assert.deepEqual(boolean.value?.range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 12 }); +}); + +test("解析跨行数组", () => { + const keyValue = keyValueAt(["sources = [", ' "a",', ' "b",', "]"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, false); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 10, endLine: 3, endCharacter: 1 }); + assert.deepEqual(keyValue.value?.elements?.map((element) => element.text), ["a", "b"]); +}); + +test("未闭合数组解析为 open 状态", () => { + const keyValue = keyValueAt(["flags = [ { glob = \"x\" },"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.elements?.length, 1); + assert.equal(keyValue.value?.elements?.[0].kind, "inlineTable"); +}); + +test("解析嵌套内联表", () => { + const keyValue = keyValueAt(['dep = { version = "1", opts = { a = 1 } }'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, false); + const entries = keyValue.value?.entries ?? []; + assert.deepEqual(entries.map((entry) => entry.keyPath.map((s) => s.name)), [["version"], ["opts"]]); + assert.equal(entries[0].value?.kind, "string"); + assert.equal(entries[1].value?.kind, "inlineTable"); + assert.equal(entries[1].value?.entries?.[0].value?.kind, "integer"); +}); + +test("未闭合内联表解析为 open 状态并保留已输入条目", () => { + const keyValue = keyValueAt(['dep = { version = "1"'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.entries?.length, 1); + assert.deepEqual(keyValue.value?.entries?.[0].keyPath.map((s) => s.name), ["version"]); +}); + +// ---- 注释、字符串与行尾 ---- + +test("注释被跳过,字符串内的 # 不算注释", () => { + const keyValue = keyValueAt(["# 顶部注释", 'name = "a # b" # 行尾注释'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.text, "a # b"); +}); + +test("字符串内的转义引号不结束字符串", () => { + const keyValue = keyValueAt(['desc = "a \\"b\\" c"'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, 'a \\"b\\" c'); +}); + +test("解析三引号多行字符串", () => { + const keyValue = keyValueAt(['text = """', "hello", '"""'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.multiline, true); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "\nhello\n"); +}); + +test("未闭合字符串解析为 open 状态", () => { + const keyValue = keyValueAt(['name = "hel'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.text, "hel"); +}); + +test("CRLF 行尾被剥除,列号按剥除后计算", () => { + const document = parseMcppToml(["[package]\r", 'name = "demo"\r']); + const section = document.nodes[0] as TomlSectionNode; + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("无法识别的垃圾行不抛错、不中断后续解析", () => { + const document = parseMcppToml(["!!garbage!!", "[package]"]); + const section = document.nodes.find((node) => node.type === "section") as TomlSectionNode; + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); +}); + +// ---- 回归:裸值停止字符 ---- + +test("裸值不停在 [ / { 之外的后续结构:x = 1 [dep", () => { + const document = parseMcppToml(["x = 1 [dep"]); + const keyValue = document.nodes[0] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "1"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); + const section = document.nodes[1] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); +}); + +test("裸值在 { 前停止:y = 2 {k = 1}", () => { + const keyValue = keyValueAt(["y = 2 {k = 1}"], 0); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "2"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); +}); diff --git a/mide-plugins/vscode/test/unit/client.test.ts b/mide-plugins/vscode/test/unit/client.test.ts deleted file mode 100644 index 4f5d8da..0000000 --- a/mide-plugins/vscode/test/unit/client.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -// The wire against the real backend: frames split anywhere reassemble; `initialize` over the mock -// mcpp finds the project; a build's notifications arrive and end in job/finished. -import * as assert from "node:assert/strict"; -import { test } from "node:test"; - -import { Backend, encode, FrameReader } from "../../src/backend/client"; -import { built, project } from "./harness"; - -test("frames reassemble whatever the chunking", () => { - const all = Buffer.concat([Buffer.from("Junk: 1\r\n\r\n"), encode({ a: 1 }), encode({ b: "é" })]); - const r = new FrameReader(); - const bodies: string[] = []; - for (let i = 0; i < all.length; i++) bodies.push(...r.feed(all.subarray(i, i + 1))); - assert.deepEqual(bodies.map((b) => JSON.parse(b)), [{ a: 1 }, { b: "é" }]); -}); - -test("a session over the mock mcpp", async (t) => { - const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); - if (!backend || !mock) { t.skip("build the backend first (mcpp build)"); return; } - const root = project(); - const b = new Backend({ program: backend }); - const init = await b.request<{ projects: { root: string }[]; mcpp: { contract: boolean } }>("initialize", { - editor: "vscode", settings: { "mcpp.path": mock }, workspaceFolders: [root], - }); - assert.equal(init.projects.length, 1); - assert.equal(init.mcpp.contract, true); - const seen: string[] = []; - b.notifications.on("*", (method: string) => seen.push(method)); - const finished = new Promise((resolve) => b.notifications.on("job/finished", resolve)); - const { jobId } = await b.request<{ jobId: string }>("job/start", { kind: "build", root }); - assert.match(jobId, /^job-/); - const done = (await finished) as { status: string }; - assert.equal(done.status, "ok"); - for (const m of ["job/started", "job/plan", "job/progress", "job/artifact"]) assert.ok(seen.includes(m), m); - await assert.rejects(b.request("no/such/method"), /unknown method/); - await b.stop(); - assert.equal(b.running, false); -}); diff --git a/mide-plugins/vscode/test/unit/locate.test.ts b/mide-plugins/vscode/test/unit/locate.test.ts deleted file mode 100644 index 3741858..0000000 --- a/mide-plugins/vscode/test/unit/locate.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Where the backend and mcppls are found. -import * as assert from "node:assert/strict"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; -import { test } from "node:test"; - -import { locateBackend, okrunDir, onPath } from "../../src/backend/locate"; -import { mcpplsCandidates } from "../../src/backend/mcppls"; - -test("the configured path wins: a program, or a bundle run by okrun", () => { - const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-ext-")); - assert.deepEqual(locateBackend({ configured: "/x/mcpp-ide", extensionRoot: ext }), { program: "/x/mcpp-ide", args: [], source: "setting" }); - const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okrun-")); - fs.writeFileSync(path.join(bin, "okrun"), ""); - assert.deepEqual(locateBackend({ configured: "/x/mcpp-ide.okb", extensionRoot: ext, platform: "linux", arch: "x64", env: { PATH: bin } }), - { program: path.join(bin, "okrun"), args: ["/x/mcpp-ide.okb"], source: "setting" }); -}); - -test("the bundle runs under the okrun carried for this system, else okrun on PATH", () => { - const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okb-")); - fs.mkdirSync(path.join(ext, "backend", "okrun", "linux-x86_64"), { recursive: true }); - fs.writeFileSync(path.join(ext, "backend", "mcpp-ide.okb"), ""); - fs.writeFileSync(path.join(ext, "backend", "okrun", "linux-x86_64", "okrun"), ""); - const linux = locateBackend({ extensionRoot: ext, platform: "linux", arch: "x64", env: { PATH: "" }, home: "/nonexistent" }); - assert.deepEqual(linux, { program: path.join(ext, "backend", "okrun", "linux-x86_64", "okrun"), args: [path.join(ext, "backend", "mcpp-ide.okb")], source: "bundle" }); - // No okrun carried for macOS: okrun on PATH runs the bundle. - const bin = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-okrun-")); - fs.writeFileSync(path.join(bin, "okrun"), ""); - const mac = locateBackend({ extensionRoot: ext, platform: "darwin", arch: "arm64", env: { PATH: bin }, home: "/nonexistent" }); - assert.deepEqual(mac?.args, [path.join(ext, "backend", "mcpp-ide.okb")]); - assert.equal(mac?.program, path.join(bin, "okrun")); - // No okrun at all: nothing in the package starts. - const native = locateBackend({ extensionRoot: ext, platform: "linux", arch: "arm64", env: { PATH: "" }, home: "/nonexistent" }); - assert.equal(native, undefined); - assert.equal(okrunDir("win32", "x64"), "windows-x86_64"); - assert.equal(okrunDir("darwin", "arm64"), "macos-aarch64"); -}); - -test("PATH lookup appends .exe on Windows", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-path-")); - fs.writeFileSync(path.join(dir, "mcpp-ide.exe"), ""); - assert.equal(onPath("mcpp-ide", { PATH: dir }, "win32"), path.join(dir, "mcpp-ide.exe")); - assert.equal(onPath("mcpp-ide", { PATH: dir }, "linux"), undefined); -}); - -test("mcppls: the extension's payload is the candidate, for the backend to start its server", () => { - const ext = fs.mkdtempSync(path.join(os.tmpdir(), "mcppls-ext-")); - assert.deepEqual(mcpplsCandidates(ext, {}), [], "no payload, nothing"); - fs.mkdirSync(path.join(ext, "payload")); - fs.writeFileSync(path.join(ext, "payload", "payload.json"), JSON.stringify({ server: { path: "bin/mcppls.okb", runner: "bin/okrun" } })); - assert.deepEqual(mcpplsCandidates(ext, {}), [path.join(ext, "payload")]); - assert.deepEqual(mcpplsCandidates(ext, { MCPPLS_PAYLOAD: path.join(ext, "payload") }), [path.join(ext, "payload")]); - assert.deepEqual(mcpplsCandidates(undefined, { MCPPLS_SERVER: "/s/mcppls" }), ["/s/mcppls"]); -}); diff --git a/mide-plugins/vscode/test/unit/mapping.test.ts b/mide-plugins/vscode/test/unit/mapping.test.ts deleted file mode 100644 index c6ef829..0000000 --- a/mide-plugins/vscode/test/unit/mapping.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// The protocol's values in VS Code's terms. -import * as assert from "node:assert/strict"; -import { test } from "node:test"; - -import { availableDebuggers, format, parseTestId, severityOf, testId, zeroBased } from "../../src/mapping"; - -test("positions are 1-based in mcpp and 0-based in VS Code", () => { - assert.deepEqual(zeroBased({ line: 1, column: 21 }), { line: 0, character: 20 }); - assert.deepEqual(zeroBased(undefined), { line: 0, character: 0 }); -}); - -test("severities", () => { - assert.equal(severityOf({ severity: "error" }), "error"); - assert.equal(severityOf({ severity: "note" }), "hint"); - assert.equal(severityOf({ severity: "info" }), "information"); -}); - -test("a test's identity round-trips, roots with :: included", () => { - const id = testId("/a::b", "m", "t"); - assert.deepEqual(parseTestId(id), { root: "/a::b", member: "m", test: "t" }); - assert.equal(parseTestId("x"), undefined); -}); - -test("the debuggers present, in order of preference", () => { - assert.deepEqual(availableDebuggers((id) => id === "ms-vscode.cpptools" || id === "vadimcn.vscode-lldb"), ["lldb", "cppdbg"]); - assert.equal(format("{0} and {1}", ["a", 2]), "a and 2"); -}); diff --git a/mide-plugins/vscode/test/util/format.test.ts b/mide-plugins/vscode/test/util/format.test.ts new file mode 100644 index 0000000..9d48cba --- /dev/null +++ b/mide-plugins/vscode/test/util/format.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ageInDays, + bucketIndex, + formatBytes, + formatCount, + fromUnixSeconds, + parseAgeBuckets, + projectGc, +} from "../../src/util/format"; + +test("formatBytes uses binary units by default and three significant digits", () => { + assert.equal(formatBytes(0), "0.00 B"); + assert.equal(formatBytes(999), "999 B"); + assert.equal(formatBytes(1024), "1.00 KiB"); + assert.equal(formatBytes(1024 * 1024 * 1.5), "1.50 MiB"); + assert.equal(formatBytes(1024 * 1024 * 12), "12.0 MiB"); + assert.equal(formatBytes(1024 * 1024 * 123), "123 MiB"); + // 7.22 GiB — the figure the plan quotes from a real cache. + assert.equal(formatBytes(7_736_306_884), "7.20 GiB"); +}); + +test("formatBytes can speak decimal units", () => { + assert.equal(formatBytes(1_000_000, "decimal"), "1.00 MB"); + assert.equal(formatBytes(1_000_000, "binary"), "977 KiB"); +}); + +test("formatBytes survives nonsense", () => { + assert.equal(formatBytes(Number.NaN), "0.00 B"); + assert.equal(formatBytes(-5), "0.00 B"); + assert.equal(formatBytes(Number.POSITIVE_INFINITY), "0.00 B"); +}); + +test("formatCount rounds and groups", () => { + assert.equal(formatCount(0), "0"); + assert.equal(formatCount(657), "657"); + assert.equal(formatCount(1234.6), (1235).toLocaleString()); +}); + +test("fromUnixSeconds rejects zero and non-finite values", () => { + assert.equal(fromUnixSeconds(undefined), undefined); + assert.equal(fromUnixSeconds(0), undefined); + assert.equal(fromUnixSeconds(Number.NaN), undefined); + assert.equal(fromUnixSeconds(1_790_804_836)?.getTime(), 1_790_804_836_000); +}); + +test("ageInDays floors at zero for a future timestamp", () => { + const now = new Date("2026-10-02T12:00:00Z"); + assert.equal(ageInDays(new Date("2026-10-02T12:00:00Z"), now), 0); + assert.equal(ageInDays(new Date("2026-10-05T12:00:00Z"), now), 0); + assert.equal(ageInDays(new Date("2026-09-29T12:00:00Z"), now), 3); +}); + +test("parseAgeBuckets accepts d/h/w/m and falls back to the documented default", () => { + assert.deepEqual(parseAgeBuckets(["1d", "7d", "30d"]), [1, 7, 30]); + assert.deepEqual(parseAgeBuckets(["48h", "1w", "2m"]), [2, 7, 60]); + assert.deepEqual(parseAgeBuckets([]), [1, 7, 30]); + assert.deepEqual(parseAgeBuckets(["nonsense"]), [1, 7, 30]); + // Sorted and de-duplicated, so a hand-edited setting cannot overlap. + assert.deepEqual(parseAgeBuckets(["30d", "7d", "7d", "1d"]), [1, 7, 30]); +}); + +test("bucketIndex puts the oldest entries in the overflow bucket", () => { + const bounds = [1, 7, 30]; + assert.equal(bucketIndex(0, bounds), 0); + assert.equal(bucketIndex(1, bounds), 1); + assert.equal(bucketIndex(7, bounds), 2); + assert.equal(bucketIndex(29, bounds), 2); + assert.equal(bucketIndex(30, bounds), 3); +}); + +test("projectGc keeps everything when the budget already fits", () => { + const entries = [{ bytes: 100, accessed: 1 }, { bytes: 200, accessed: 2 }]; + const projection = projectGc(entries, 1000); + assert.deepEqual(projection.removed, []); + assert.equal(projection.freedBytes, 0); + assert.equal(projection.remainingBytes, 300); +}); + +test("projectGc drops least-recently-used entries first", () => { + const entries = [ + { bytes: 400, accessed: 30 }, + { bytes: 300, accessed: 10 }, + { bytes: 300, accessed: 20 }, + ]; + // 1000 bytes total, budget 600: the two oldest (10, then 20) go. + const projection = projectGc(entries, 600); + assert.deepEqual(projection.removed.map((entry) => entry.accessed), [10, 20]); + assert.equal(projection.freedBytes, 600); + assert.equal(projection.remainingBytes, 400); + assert.equal(projection.kept.length, 1); +}); + +test("projectGc treats entries without a timestamp as newest", () => { + const entries = [ + { bytes: 500, accessed: undefined }, + { bytes: 500, accessed: 1 }, + ]; + const projection = projectGc(entries, 500); + assert.deepEqual(projection.removed.map((entry) => entry.accessed), [1]); +}); + +test("projectGc never reports a negative remaining size", () => { + const projection = projectGc([{ bytes: 0, accessed: 1 }, { bytes: 0, accessed: 2 }], 0); + assert.equal(projection.remainingBytes, 0); + assert.equal(projection.freedBytes, 0); +}); diff --git a/mide-plugins/vscode/test/util/log.test.ts b/mide-plugins/vscode/test/util/log.test.ts new file mode 100644 index 0000000..540b29a --- /dev/null +++ b/mide-plugins/vscode/test/util/log.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createLogger, isLogLevel, logLevelOf, shouldLog } from "../../src/util/log"; + +test("错误永远不被任何 mcpp.log.level 抑制", () => { + for (const configured of ["error", "warn", "info", "debug", "nonsense", undefined]) { + assert.equal(shouldLog("error", configured), true, `error must pass at ${String(configured)}`); + } +}); + +test("threshold: 每个级别只写自己不高于配置的级别", () => { + assert.equal(shouldLog("error", "error"), true); + assert.equal(shouldLog("warn", "error"), false); + assert.equal(shouldLog("info", "error"), false); + assert.equal(shouldLog("debug", "error"), false); + + assert.equal(shouldLog("error", "warn"), true); + assert.equal(shouldLog("warn", "warn"), true); + assert.equal(shouldLog("info", "warn"), false); + assert.equal(shouldLog("debug", "warn"), false); + + // `info` is the declared default: the verbose command echo is hidden. + assert.equal(shouldLog("info", "info"), true); + assert.equal(shouldLog("debug", "info"), false); + + assert.equal(shouldLog("debug", "debug"), true); +}); + +test("未知配置回退 info,而不是静音频道", () => { + assert.equal(logLevelOf("chatty"), "info"); + assert.equal(logLevelOf(undefined), "info"); + assert.equal(logLevelOf(2), "info"); + assert.equal(isLogLevel("debug"), true); + assert.equal(isLogLevel("verbose"), false); + assert.equal(shouldLog("info", "chatty"), true); + assert.equal(shouldLog("debug", "chatty"), false); +}); + +test("createLogger 逐行读取级别,并在频道已释放时静默", () => { + const lines: string[] = []; + let level = "warn"; + const logger = createLogger({ appendLine: (line) => lines.push(line) }, () => level); + + logger.debug("d"); + logger.info("i"); + logger.warn("w"); + logger.error("e"); + assert.deepEqual(lines, ["w", "e"]); + + // A configuration change applies to the next line, without re-creating the logger. + level = "debug"; + logger.debug("d2"); + assert.deepEqual(lines, ["w", "e", "d2"]); + + // A disposed output channel is a no-op, never an error. + const disposed = createLogger({ + appendLine: () => { + throw new Error("channel disposed"); + }, + }, () => "debug"); + assert.doesNotThrow(() => disposed.error("e")); +}); diff --git a/mide-plugins/vscode/test/util/text.test.ts b/mide-plugins/vscode/test/util/text.test.ts new file mode 100644 index 0000000..9c29970 --- /dev/null +++ b/mide-plugins/vscode/test/util/text.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { clampOutput, escapeHtml, firstLine, formatArguments, splitList, stripAnsi } from "../../src/util/text"; + +test("stripAnsi removes colour codes", () => { + assert.equal(stripAnsi("\u001b[32mok\u001b[0m"), "ok"); + assert.equal(stripAnsi("plain"), "plain"); +}); + +test("clampOutput keeps the tail, because that is where the answer is", () => { + const lines = Array.from({ length: 10 }, (_, index) => `line ${index}`); + const clamped = clampOutput(lines.join("\n"), { maxLines: 3 }); + assert.equal(clamped.truncated, true); + assert.equal(clamped.droppedLines, 7); + assert.equal(clamped.text, "line 7\nline 8\nline 9"); +}); + +test("clampOutput leaves a short output untouched", () => { + const clamped = clampOutput("only\nlines", { maxLines: 10, maxChars: 1000 }); + assert.equal(clamped.truncated, false); + assert.equal(clamped.droppedLines, 0); + assert.equal(clamped.text, "only\nlines"); +}); + +test("clampOutput also bounds characters", () => { + const clamped = clampOutput("abcdefghij", { maxLines: 100, maxChars: 4 }); + assert.equal(clamped.truncated, true); + assert.equal(clamped.text, "ghij"); +}); + +test("clampOutput normalises CRLF and strips ANSI before measuring", () => { + const clamped = clampOutput("a\r\n\u001b[31mb\u001b[0m", { maxLines: 2, maxChars: 100 }); + assert.equal(clamped.text, "a\nb"); +}); + +test("firstLine trims and stops at the newline", () => { + assert.equal(firstLine(" hello \nworld"), "hello"); + assert.equal(firstLine("only"), "only"); +}); + +test("escapeHtml neutralises markup", () => { + assert.equal(escapeHtml('&\''), "<a href="x">&'</a>"); +}); + +test("splitList trims, drops blanks and tolerates undefined", () => { + assert.deepEqual(splitList("mcpp, cmake ,,xmake"), ["mcpp", "cmake", "xmake"]); + assert.deepEqual(splitList(undefined), []); + assert.deepEqual(splitList(""), []); +}); + +test("formatArguments quotes entries containing whitespace", () => { + assert.equal(formatArguments(["-j", "4"]), "-j 4"); + assert.equal(formatArguments(["--flag=a b"]), '"--flag=a b"'); + assert.equal(formatArguments([]), ""); +}); diff --git a/mide-plugins/vscode/test/views/models.test.ts b/mide-plugins/vscode/test/views/models.test.ts new file mode 100644 index 0000000..5dd2907 --- /dev/null +++ b/mide-plugins/vscode/test/views/models.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { format } from "../../src/i18n/translate"; +import { buildProjectTree, type Label, type TreeNode } from "../../src/views/models"; + +function ids(nodes: readonly TreeNode[]): string[] { + return nodes.map((node) => node.id); +} + +function find(nodes: readonly TreeNode[], id: string): TreeNode | undefined { + for (const node of nodes) { + if (node.id === id) return node; + const hit = node.children === undefined ? undefined : find(node.children, id); + if (hit !== undefined) return hit; + } + return undefined; +} + +/** The label as the tree renders it: `t()` with the English text as the key. */ +function text(label: Label | undefined): string { + if (label === undefined) { + return ""; + } + return format( + label.key, + (label.args ?? []).map((argument) => (typeof argument === "object" ? text(argument) : argument)), + ); +} + +test("an empty workspace says so and suggests the fix", () => { + const tree = buildProjectTree(undefined); + // The empty state names the situation and offers the one action that fits it: + // `mcpp new` cannot initialise a folder that already exists, so there is no + // "make this folder a project" row to add. + assert.deepEqual(ids(tree), ["project.none", "project.action.new"]); + assert.match(tree[0].tooltip?.key ?? "", /mcpp: New Project/); +}); + +test("an unreadable manifest is reported instead of half-rendered", () => { + const tree = buildProjectTree({ root: "/w", error: "bad TOML" }); + assert.equal(tree[0].id, "project.error"); + assert.equal(tree[0].icon, "error"); + assert.deepEqual(tree[0].description?.args, ["bad TOML"]); +}); + +test("the project view is two labelled sections: commands open, basics folded", () => { + const tree = buildProjectTree({ + root: "/w", + name: "greeter", + version: "0.1.0", + standard: "c++23", + profile: "debug", + toolchainSpec: "llvm@22.1.8", + target: "x86_64-unknown-linux-gnu", + targets: [{ name: "greet", kind: "bin" }], + sourceFiles: 87, + }); + assert.deepEqual(ids(tree), ["project.section.basic", "project.dependencies", "project.section.commands"]); + assert.equal(find(tree, "project.section.basic")?.label.key, "Basics"); + assert.equal(find(tree, "project.section.commands")?.label.key, "Common commands"); + // The sidebar opens on what can be *done*; 「基本信息」 is one click away. + assert.equal(find(tree, "project.section.basic")?.expanded, undefined); + assert.equal(find(tree, "project.section.commands")?.expanded, true); +}); + +test("the identity row carries the name, the standard and the source count", () => { + const tree = buildProjectTree({ root: "/w", name: "greeter", version: "0.1.0", standard: "c++23", sourceFiles: 87 }); + const identity = find(tree, "project.package"); + assert.equal(identity?.label.key, "greeter"); + assert.equal(text(identity?.description), "c++23 · 87 source file(s)"); + // The version is still reachable, just not competing with the name. + assert.match(text(identity?.tooltip), /0\.1\.0/); + assert.equal(find(tree, "project.target"), undefined); + assert.equal(find(tree, "project.toolchain")?.description?.args?.[0], "host default"); +}); + +test("a standard without a source count still renders, and vice versa", () => { + assert.equal(text(find(buildProjectTree({ root: "/w", standard: "c++23" }), "project.package")?.description), "c++23"); + assert.equal( + text(find(buildProjectTree({ root: "/w", sourceFiles: 3 }), "project.package")?.description), + "3 source file(s)", + ); + assert.equal(find(buildProjectTree({ root: "/w" }), "project.package")?.description, undefined); +}); + +test("a project without a toolchain says host default rather than inventing one", () => { + const tree = buildProjectTree({ root: "/w" }); + assert.deepEqual(find(tree, "project.toolchain")?.description?.args, ["host default"]); + assert.equal(find(tree, "project.target"), undefined); +}); + +test("a project with no declared dependency keeps one non-collapsible status row", () => { + for (const dependencies of [undefined, []]) { + const row = find(buildProjectTree({ root: "/w", dependencies }), "project.dependencies"); + assert.equal(row?.label.key, "This project declares no dependencies"); + assert.equal(row?.children, undefined); + } +}); diff --git a/mide-plugins/vscode/test/views/projectLayout.test.ts b/mide-plugins/vscode/test/views/projectLayout.test.ts new file mode 100644 index 0000000..378aed2 --- /dev/null +++ b/mide-plugins/vscode/test/views/projectLayout.test.ts @@ -0,0 +1,475 @@ +/** + * The project view's layout: two labelled sections, the two-level dependency + * group, the keybinding hints (read from `package.json`, never copied) and the + * folded C++ Modules block. + * + * The block's whole reason to exist is the line *above* its chevron: a problem + * has to be visible without expanding anything, so that is asserted directly. + * The same applies to what must **not** be there — §8.2 removes `clangd` from the + * view, and a test is the only way that removal stays removed. + */ + +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { format } from "../../src/i18n/translate"; +import * as models from "../../src/views/models"; +import { + buildProjectTree, + countSourceFiles, + formatKeybinding, + keybindingsFromPackage, + LANGUAGE_SERVICE_PROVIDER, + parseLockfile, + readDependencies, + readLockfile, + resolveLockedPackage, + type Label, + type LanguageServiceBlock, + type TreeNode, +} from "../../src/views/models"; + +function ids(nodes: readonly TreeNode[]): string[] { + return nodes.map((node) => node.id); +} + +function find(nodes: readonly TreeNode[], id: string): TreeNode | undefined { + for (const node of nodes) { + if (node.id === id) return node; + const hit = node.children === undefined ? undefined : find(node.children, id); + if (hit !== undefined) return hit; + } + return undefined; +} + +/** The label as the tree renders it: `t()` with the English text as the key. */ +function text(label: Label | undefined): string { + if (label === undefined) { + return ""; + } + return format( + label.key, + (label.args ?? []).map((argument) => (typeof argument === "object" ? text(argument) : argument)), + ); +} + +function allText(nodes: readonly TreeNode[]): string[] { + const out: string[] = []; + for (const node of nodes) { + out.push(text(node.label), text(node.description), text(node.tooltip), text(node.command?.title)); + if (node.children !== undefined) { + out.push(...allText(node.children)); + } + } + return out; +} + +/* ---------------------------------------------------------------- commands */ + +const MANIFEST = JSON.parse(readFileSync(path.join(process.cwd(), "package.json"), "utf8")) as { + contributes?: { keybindings?: Array<{ command: string; key?: string; mac?: string }> }; +}; +const BINDINGS = MANIFEST.contributes?.keybindings ?? []; +const HINTS = keybindingsFromPackage(MANIFEST); + +test("the command section holds the nine common commands, in order", () => { + const tree = buildProjectTree({ root: "/w" }, { keybindings: HINTS, platform: "mac" }); + assert.deepEqual(ids(find(tree, "project.section.commands")?.children ?? []), [ + // Creation first: it is the only row that does not act on *this* project. + "project.action.new", + "project.action.build", + "project.action.run", + "project.action.test", + "project.action.clean", + "project.action.toolchain", + "project.action.librarySearch", + "project.action.selfCheck", + "project.action.settings", + ]); + for (const child of find(tree, "project.section.commands")?.children ?? []) { + assert.ok(child.command, `${child.id} must be clickable`); + } + assert.equal(find(tree, "project.action.new")?.command?.command, "mcpp.newProject"); + assert.equal(find(tree, "project.action.librarySearch")?.command?.command, "mcpp.library.search"); + assert.equal(find(tree, "project.action.clean")?.command?.command, "mcpp.cleanProjectArtifacts"); + assert.equal(find(tree, "project.action.selfCheck")?.command?.command, "mcpp.selfCheck"); + assert.equal(find(tree, "project.action.settings")?.command?.command, "mcpp.openSettings"); +}); + +test("every keybinding hint is the manifest's own, not a copy of it", () => { + const tree = buildProjectTree({ root: "/w" }, { keybindings: HINTS, platform: "mac" }); + const bound: ReadonlyArray = [ + ["mcpp.build", "project.action.build"], + ["mcpp.run", "project.action.run"], + ["mcpp.test", "project.action.test"], + ["mcpp.cleanProjectArtifacts", "project.action.clean"], + ]; + for (const [command, id] of bound) { + const binding = BINDINGS.find((entry) => entry.command === command); + assert.ok(binding, `package.json must still bind ${command}`); + assert.equal( + text(find(tree, id)?.description), + formatKeybinding(binding, "mac"), + `${id} must show what package.json contributes for ${command}`, + ); + } + // A bare `mac`/`key` pair formats to the platform's own spelling. + assert.equal(formatKeybinding({ key: "ctrl+alt+b", mac: "cmd+alt+b" }, "mac"), "⌘⌥B"); + assert.equal(formatKeybinding({ key: "ctrl+alt+b", mac: "cmd+alt+b" }, "other"), "Ctrl+Alt+B"); + assert.equal(formatKeybinding(undefined, "other"), undefined); + assert.equal(formatKeybinding({}, "mac"), undefined); +}); + +test("a command without a binding carries no hint at all", () => { + const tree = buildProjectTree({ root: "/w" }, { keybindings: HINTS, platform: "mac" }); + for (const id of ["project.action.toolchain", "project.action.librarySearch", "project.action.selfCheck", "project.action.settings"]) { + assert.equal(find(tree, id)?.description, undefined, `${id} has no keybinding, so it has no hint`); + } +}); + +/* ------------------------------------------------------------ dependencies */ + +const MANIFEST_LINES = [ + "[package]", + 'name = "greeter"', + "", + "[dependencies]", + 'mcpplibs.openkal = "0.14.0"', + 'compat.freetype = "2.13.3"', + 'compat.argparse = "3.2"', + 'counters = { path = "../counters" }', + 'codec = { git = "https://example.test/codec.git", tag = "v1" }', + "", + "[dev-dependencies]", + 'compat.gtest = "1.15.2"', + "", + "[dependencies.legacy]", + 'version = "9.9"', +].join("\n") + .split("\n"); + +const LOCK_TEXT = [ + "# Auto-generated by mcpp. Do not edit by hand.", + "version = 2", + "", + '[package."openkal"]', + 'namespace = "mcpplibs"', + 'version = "0.14.0"', + 'source = "index+mcpplibs@0.14.0"', + 'hash = "fnv1a:e235291048c1a3d8"', + "", + '[package."compat.freetype"]', + 'namespace = "compat"', + 'version = "2.13.3"', + "", +].join("\n"); + +test("the manifest reader knows the short form, the inline table and the named table", () => { + const declared = readDependencies(MANIFEST_LINES); + assert.deepEqual( + declared.map((entry) => entry.name), + ["mcpplibs.openkal", "compat.freetype", "compat.argparse", "counters", "codec", "compat.gtest", "legacy"], + ); + assert.equal(declared.find((entry) => entry.name === "mcpplibs.openkal")?.version, "0.14.0"); + assert.equal(declared.find((entry) => entry.name === "counters")?.path, "../counters"); + assert.equal(declared.find((entry) => entry.name === "codec")?.git, "https://example.test/codec.git"); + assert.equal(declared.find((entry) => entry.name === "compat.gtest")?.dev, true); + assert.equal(declared.find((entry) => entry.name === "legacy")?.version, "9.9"); +}); + +test("mcpp.lock is read flat, with both key spellings the real files use", () => { + const locked = parseLockfile(LOCK_TEXT); + assert.equal(locked.length, 2); + assert.equal(locked[0].key, "openkal"); + assert.equal(locked[0].namespace, "mcpplibs"); + assert.equal(locked[0].source, "index+mcpplibs@0.14.0"); + assert.equal(locked[0].hash, "fnv1a:e235291048c1a3d8"); + // `[package."openkal"]` carries its namespace in a field… + assert.equal(resolveLockedPackage(locked, "mcpplibs.openkal")?.version, "0.14.0"); + // …while `[package."compat.freetype"]` already folds it into the key. + assert.equal(resolveLockedPackage(locked, "compat.freetype")?.version, "2.13.3"); + assert.equal(resolveLockedPackage(locked, "compat.absent"), undefined); +}); + +test("a missing or malformed lock file is empty, never an exception", () => { + assert.deepEqual(readLockfile(path.join(tmpdir(), "mcpp-a6-absent-dir", "mcpp.lock")), []); + assert.deepEqual(parseLockfile(""), []); + assert.deepEqual(parseLockfile("<<< not toml at all >>>\n[[[package..\n"), []); + assert.deepEqual(parseLockfile("version = 2\n[workspace]\nname = \"x\""), []); + // A header with no fields is still a header: the entry exists, its version does not. + assert.deepEqual(parseLockfile('[package."x"]'), [{ key: "x" }]); +}); + +test("the dependency group is two levels, marked and resolved, with no connector", () => { + const declared = readDependencies(MANIFEST_LINES); + const tree = buildProjectTree({ root: "/w", dependencies: declared }, { lock: parseLockfile(LOCK_TEXT) }); + const group = find(tree, "project.dependencies"); + assert.ok(group); + assert.equal(text(group.label), "Dependencies (7)"); + assert.equal(text(group.description), "1 dev"); + assert.equal(group.expanded, false); + assert.deepEqual(ids(group.children ?? []), declared.map((entry) => `project.dependency.${entry.name}${entry.dev ? ":dev" : ""}`)); + assert.equal(group.tooltip?.key, "Declared in mcpp.toml; the resolved version comes from mcpp.lock."); + + // `mcpplibs.openkal` matches `[package."openkal"]` through the namespace field; + // `compat.freetype` matches `[package."compat.freetype"]` through its key. + assert.equal(text(find(tree, "project.dependency.mcpplibs.openkal")?.label), "mcpplibs.openkal@0.14.0"); + assert.equal(text(find(tree, "project.dependency.compat.freetype")?.label), "compat.freetype@2.13.3"); + assert.equal(text(find(tree, "project.dependency.compat.argparse")?.label), "compat.argparse@3.2"); + assert.equal(text(find(tree, "project.dependency.compat.argparse")?.description), "index"); + assert.equal(text(find(tree, "project.dependency.counters")?.description), "path · ../counters"); + assert.equal( + text(find(tree, "project.dependency.codec")?.description), + "git", + ); + assert.equal(text(find(tree, "project.dependency.compat.gtest:dev")?.description), "index · dev"); + assert.deepEqual(find(tree, "project.dependency.compat.argparse")?.command?.arguments, [{ root: "/w", dependency: "compat.argparse", kind: "normal", path: undefined }]); + assert.equal(find(tree, "project.dependency.counters")?.icon, "folder"); + assert.equal(find(tree, "project.dependency.compat.argparse")?.icon, "package"); + + // Level 2 is the end of it: mcpp.lock has no parent/child edges, so a third + // level (or a drawn connector) would be invented rather than read. + const CONNECTOR = /[├└│─┌┐┘┬┴┼╰╭╯╮]/; + for (const rendered of allText([group])) { + assert.doesNotMatch(rendered, CONNECTOR, rendered); + } + for (const child of group.children ?? []) { + assert.equal(child.children, undefined, `${child.id} must not have a third level`); + } +}); + +test("dependencies follow Basics as a folded sibling; module graph remains inside Basics", () => { + const tree = buildProjectTree( + { root: "/w", target: "x86_64-linux-gnu", dependencies: [{ name: "compat.zlib" }] }, + { lock: parseLockfile(LOCK_TEXT), languageService: { installed: false } }, + ); + assert.deepEqual(ids(tree), ["project.section.basic", "project.dependencies", "project.section.commands"]); + assert.equal(find(tree, "project.dependencies")?.expanded, false); + assert.deepEqual(ids(find(tree, "project.section.basic")?.children ?? []), [ + "project.package", + "project.target", + "project.toolchain", + "project.moduleDependencies", + "project.languageService", + ]); +}); + +/* ------------------------------------------------------------- source count */ + +test("the source count skips the build directories instead of flattering itself", () => { + const root = mkdtempSync(path.join(tmpdir(), "mcpp-a6-src-")); + try { + mkdirSync(path.join(root, "src"), { recursive: true }); + mkdirSync(path.join(root, "target", "debug"), { recursive: true }); + mkdirSync(path.join(root, "node_modules", "dep"), { recursive: true }); + writeFileSync(path.join(root, "src", "main.cpp"), ""); + writeFileSync(path.join(root, "src", "app.cppm"), ""); + writeFileSync(path.join(root, "src", "util.hpp"), ""); + writeFileSync(path.join(root, "src", "notes.txt"), ""); + writeFileSync(path.join(root, "target", "debug", "generated.cpp"), ""); + writeFileSync(path.join(root, "node_modules", "dep", "dep.cpp"), ""); + assert.equal(countSourceFiles(root), 3); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("counting a directory that does not exist is zero, not a throw", () => { + assert.equal(countSourceFiles(path.join(tmpdir(), "mcpp-a6-absent-root")), 0); +}); + +/* -------------------------------------------------------- language service */ + +const HEALTHY: LanguageServiceBlock = { + show: true, + installed: true, + version: "0.0.9", + state: { available: true, state: "ready" }, +}; + +const DEGRADED: LanguageServiceBlock = { + show: true, + installed: true, + version: "0.0.9", + state: { + available: true, + state: "degraded", + issues: [{ code: "module-graph-stale", message: "The module graph needs rebuilding" }], + }, +}; + +test("a problem is visible without expanding the folded block", () => { + const tree = buildProjectTree({ root: "/w" }, { languageService: DEGRADED }); + const row = find(tree, "project.languageService"); + assert.ok(row, "the block must be in the tree when the setting is on"); + // Collapsed: the tree provider only expands what the model marks expanded. + assert.equal(row.expanded, undefined); + assert.equal(row.icon, "warning"); + assert.equal(text(row.description), "mcppls 0.0.9 · 1 problem(s)"); + assert.equal(row.label.key, "Language service"); +}); + +test("a healthy language service says so without a problem count", () => { + const row = find(buildProjectTree({ root: "/w" }, { languageService: HEALTHY }), "project.languageService"); + assert.equal(row?.icon, "pass"); + assert.equal(text(row?.description), "mcppls 0.0.9"); +}); + +test("the folded block hides entirely when its setting is off", () => { + const tree = buildProjectTree({ root: "/w" }, { languageService: { ...HEALTHY, show: false } }); + assert.equal(find(tree, "project.languageService"), undefined); + assert.equal(find(buildProjectTree({ root: "/w" }), "project.languageService"), undefined); +}); + +test("expanded, the block states the provider, each problem, four actions and the rest", () => { + const row = find(buildProjectTree({ root: "/w" }, { languageService: DEGRADED }), "project.languageService"); + assert.deepEqual(ids(row?.children ?? []), [ + "project.languageService.status", + "project.languageService.provider", + "project.languageService.issue.0.module-graph-stale", + "project.languageService.action.restart", + "project.languageService.action.selectContext", + "project.languageService.action.graph", + "project.languageService.action.logs", + "project.languageService.action.more", + ]); + assert.equal(text(find(row?.children ?? [], "project.languageService.status")?.label), "Degraded"); + const provider = find(row?.children ?? [], "project.languageService.provider"); + assert.equal(provider?.label.key, "Provided by"); + assert.equal(provider?.description?.key, LANGUAGE_SERVICE_PROVIDER); + assert.equal(LANGUAGE_SERVICE_PROVIDER, "sunrisepeak.mcpp-language-server"); + assert.equal( + text(find(row?.children ?? [], "project.languageService.issue.0.module-graph-stale")?.label), + "The module graph needs rebuilding", + ); + // §8.2's count: eleven actions, four of them permanent, the other seven folded. + const more = find(row?.children ?? [], "project.languageService.action.more"); + assert.equal(text(more?.label), "More (7)…"); + assert.equal(more?.children?.length, 7); + for (const child of more?.children ?? []) { + assert.ok(child.command, `${child.id} must forward to mcppls`); + } +}); + +test("the four common actions forward to mcppls and are always present", () => { + const row = find(buildProjectTree({ root: "/w" }, { languageService: HEALTHY }), "project.languageService"); + const expected: ReadonlyArray = [ + ["project.languageService.action.restart", "mcpp.languageServer.restart"], + ["project.languageService.action.selectContext", "mcpp.languageServer.selectContext"], + ["project.languageService.action.graph", "mcpp.languageServer.showModuleGraph"], + ["project.languageService.action.logs", "mcpp.languageServer.showLogs"], + ]; + for (const [id, command] of expected) { + assert.equal(find(row?.children ?? [], id)?.command?.command, command, id); + } +}); + +test("the block never renders clangd, even when mcppls reports it", () => { + // The state object is deliberately richer than the block's own type: mcppls's + // real state carries engines and a semantic profile, and none of them may leak + // into what the project view shows. + const block = { + show: true, + installed: true, + version: "0.0.9", + state: { + available: true, + state: "ready", + engine: { name: "clangd", version: "23.1.0" }, + engines: [{ name: "clangd", version: "23.1.0", role: "core", state: "ready" }], + profile: { compiler: "clang 22.1.8", stdlib: "libc++", target: "x86_64-linux-gnu" }, + project: { root: "/w", source: "mcpp build-database" }, + }, + } as unknown as LanguageServiceBlock; + const tree = buildProjectTree({ root: "/w" }, { languageService: block }); + for (const rendered of allText(tree)) { + assert.doesNotMatch(rendered, /clangd/i, rendered); + } +}); + +test("an installed-but-unreadable state keeps the reason on the row, not in the shown text", () => { + const tree = buildProjectTree( + { root: "/w" }, + { languageService: { show: true, installed: true, version: "0.0.9", state: { available: false, reason: "no status yet" } } }, + ); + const row = find(tree, "project.languageService"); + assert.equal(row?.icon, "warning"); + assert.equal(text(row?.tooltip), "no status yet"); + assert.equal(text(find(row?.children ?? [], "project.languageService.status")?.label), "Unavailable"); + assert.equal(text(find(row?.children ?? [], "project.languageService.status")?.description), "no status yet"); +}); + +test("a missing C++ Modules offers to install itself, not to configure clangd", () => { + const row = find(buildProjectTree({ root: "/w" }, { languageService: { show: true, installed: false } }), "project.languageService"); + const status = find(row?.children ?? [], "project.languageService.status"); + assert.equal(text(status?.label), "C++ Modules is not installed"); + assert.equal(status?.command?.command, "mcpp.openMcpplsSettings"); +}); + +/* -------------------------------------------------------- deleted structure */ + +test("the standalone C++ Modules view is gone", () => { + assert.equal((models as Record).buildLanguageServerTree, undefined); + const source = readFileSync(path.join(process.cwd(), "src", "views", "languageServerView.ts"), "utf8"); + assert.doesNotMatch(source, /LANGUAGE_SERVER_VIEW_ID/); + assert.doesNotMatch(source, /createTreeView/); + assert.match(source, /export function registerLanguageServerCommands\(/); +}); + +test("other C++ language services: active ones are named and one click turns them off", () => { + const block: LanguageServiceBlock = { + ...HEALTHY, + otherCppFeatures: { + active: [ + { extensionId: "ms-vscode.cpptools", displayName: "C/C++", settable: true }, + { extensionId: "llvm-vs-code-extensions.vscode-clangd", displayName: "clangd", settable: true }, + ], + canRestore: false, + }, + }; + const service = find(buildProjectTree({ root: "/w" }, { languageService: block }), "project.languageService"); + // Right under the provider, so "who answers my C++ files" reads top to bottom. + assert.deepEqual(ids(service?.children ?? []).slice(0, 3), [ + "project.languageService.status", + "project.languageService.provider", + "project.languageService.otherCppFeatures", + ]); + const row = find(service?.children ?? [], "project.languageService.otherCppFeatures"); + assert.equal(text(row?.label), "Other C++ language services"); + assert.equal(text(row?.description), "C/C++, clangd"); + assert.equal(row?.icon, "warning"); + assert.equal(row?.iconColor, undefined); + assert.equal(row?.command?.command, "mcpp.languageServer.manageConflicts"); + assert.equal(row?.contextValue, "mcppOtherCppFeatures"); +}); + +test("other C++ language services: with none, the row is a dimmed confirmation with nothing to click", () => { + const block: LanguageServiceBlock = { ...HEALTHY, otherCppFeatures: { active: [], canRestore: true } }; + const service = find(buildProjectTree({ root: "/w" }, { languageService: block }), "project.languageService"); + const row = find(service?.children ?? [], "project.languageService.otherCppFeatures"); + assert.equal(text(row?.label), "Only C++ Modules is serving C++"); + assert.equal(row?.icon, "pass"); + assert.equal(row?.iconColor, "disabledForeground"); + assert.equal(row?.command, undefined); + // What mcppls turned off can still be put back, from the row's context menu. + assert.equal(row?.contextValue, "mcppOtherCppFeaturesRestorable"); +}); + +test("other C++ language services: no row when there is nothing to read", () => { + const service = find(buildProjectTree({ root: "/w" }, { languageService: HEALTHY }), "project.languageService"); + assert.equal(find(service?.children ?? [], "project.languageService.otherCppFeatures"), undefined); +}); + +test("namespaced direct dependencies stay individual and every row opens its graph target", () => { + const declared = readDependencies('[dependencies.compat]\nargparse="3.2"\nzlib="1.3.2"\n[dependencies.local]\npath="../local"\n[build-dependencies]\ngenerator="2"'.split('\n')); + assert.deepEqual(declared.map(dep=>dep.name), ['compat.argparse','compat.zlib','local','generator']); + const tree = buildProjectTree({root:'/work/app',dependencies:declared},{lock:[{key:'transitive',version:'9'}]}); + const group = find(tree,'project.dependencies')!; + assert.equal(group.children?.length,4); + assert.ok(!allText([group]).some(text=>text.includes('transitive'))); + for(const row of group.children ?? []) assert.equal(row.command?.command,'mcpp.project.showDependencyGraph'); + assert.equal(find(tree,'project.dependency.local')?.icon,'folder'); +}); diff --git a/mide-plugins/vscode/test/webview/render.test.ts b/mide-plugins/vscode/test/webview/render.test.ts new file mode 100644 index 0000000..87fa092 --- /dev/null +++ b/mide-plugins/vscode/test/webview/render.test.ts @@ -0,0 +1,14 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { documentNeedsRender } from "../../src/webview/render"; + +test("an identical document is not pushed to the view", () => { + // `webview.html = same` reloads the iframe and throws away the scroll + // position and the caret. The rule is one line, so it is stated as one line; + // it moved here from `libraryHtml` when every webview host started sharing + // it (`WebviewDocument.paint()`). + assert.equal(documentNeedsRender(undefined, ""), true); + assert.equal(documentNeedsRender("", ""), false); + assert.equal(documentNeedsRender("", " "), true); +}); diff --git a/mide-plugins/vscode/test/workflows/moduleSetup.test.ts b/mide-plugins/vscode/test/workflows/moduleSetup.test.ts new file mode 100644 index 0000000..701902e --- /dev/null +++ b/mide-plugins/vscode/test/workflows/moduleSetup.test.ts @@ -0,0 +1,134 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildModuleSetupPlan, + executeModuleSetup, + moduleSetupConfirmation, + type ModuleSetupOperations, + type ModuleSetupStepResult, +} from "../../src/workflows/moduleSetup"; +import { t } from "../../src/i18n/t"; + +test("只信任且不忙时可开始一键配置", () => { + assert.deepEqual(buildModuleSetupPlan(true, false), { kind: "ready" }); + assert.deepEqual(buildModuleSetupPlan(false, false), { kind: "blocked", reason: "untrusted" }); + assert.deepEqual(buildModuleSetupPlan(true, true), { kind: "blocked", reason: "busy" }); +}); + +test("确认文案不再要求切换 LLVM、下载 llvm-tools 或配置 clangd", () => { + const confirmation = moduleSetupConfirmation(); + assert.equal(confirmation.message, t("Build the current mcpp project and refresh the C++ Modules language service?")); + assert.match(confirmation.detail, /mcpp build/); + assert.match(confirmation.detail, /C\+\+ Modules/); + assert.doesNotMatch(confirmation.detail, /LLVM|llvm-tools/i); + assert.doesNotMatch(confirmation.detail, /配置 clangd/i); +}); + +test("成功路径严格执行 build 后刷新语言服务", async () => { + const calls: string[] = []; + const operations: ModuleSetupOperations = { + build: async () => { + calls.push("build"); + return { stage: "build", state: "succeeded" }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; + }, + }; + + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, operations); + assert.deepEqual(outcome, { state: "succeeded", degraded: false, steps: [ + { stage: "build", state: "succeeded" }, + { stage: "language-server", state: "succeeded" }, + ] }); + assert.deepEqual(calls, ["build", "language-server"]); +}); + +test("build 失败但未取消时仍刷新已有语言服务描述", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "failed", exitCode: 2 }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; + }, + }); + + assert.equal(outcome.state, "succeeded"); + if (outcome.state === "succeeded") assert.equal(outcome.degraded, true); + assert.deepEqual(calls, ["build", "language-server"]); +}); + +test("build 取消后不刷新语言服务", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "cancelled" }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "build", state: "succeeded" }; + }, + }); + + assert.equal(outcome.state, "cancelled"); + assert.equal(outcome.stage, "build"); + assert.deepEqual(calls, ["build"]); +}); + +test("build 未启动时不刷新语言服务", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "not-started", detail: "未启动 mcpp 构建。" }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; + }, + }); + assert.equal(outcome.state, "failed"); + if (outcome.state === "failed") assert.equal(outcome.stage, "build"); + assert.deepEqual(calls, ["build"]); +}); + +test("语言服务依赖缺失时不伪装成成功", async () => { + const result: ModuleSetupStepResult = { + stage: "language-server", + state: "failed", + detail: "C++ 模块语言服务依赖未安装或已禁用。", + }; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => ({ stage: "build", state: "succeeded" }), + refreshLanguageServer: async () => result, + }); + + assert.equal(outcome.state, "failed"); + if (outcome.state === "failed") assert.equal(outcome.stage, "language-server"); + assert.deepEqual(outcome.steps, [ + { stage: "build", state: "succeeded" }, + result, + ]); +}); diff --git a/mide-plugins/vscode/tools/benchmark-dependency-graphs.mjs b/mide-plugins/vscode/tools/benchmark-dependency-graphs.mjs new file mode 100644 index 0000000..ae8c2f2 --- /dev/null +++ b/mide-plugins/vscode/tools/benchmark-dependency-graphs.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/** Compile first. Optional real fixture: MCPP_GRAPH_JSON and MCPP_GRAPH_ROOT. */ +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +const require=createRequire(import.meta.url); +const {visibleGraph,mermaidGraph,INITIAL_GRAPH_BUDGET}=require('../dist/src/graphs/model.js'); +const {buildModuleGraph,moduleEntries,moduleOwnership,parseModuleGraph,sourcePath}=require('../dist/src/projects/moduleGraph.js'); +const {readProjectSummary}=require('../dist/src/projects/summary.js'); +const {chromium}=require('playwright'); +const node=id=>({id:String(id),label:'app.module'+id,lines:['export module','src/module'+id+'.cppm'],kind:'project'}); +const shapes={ + chain:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:String(i),to:String(i+1)})),roots:['0']}, + wide:{nodes:Array.from({length:1000},(_,i)=>node(i)),edges:Array.from({length:999},(_,i)=>({from:'0',to:String(i+1)})),roots:['0']}, + shared:{nodes:Array.from({length:45},(_,i)=>node(i)),edges:Array.from({length:15},(_,i)=>Array.from({length:30},(_,j)=>({from:String(i),to:String(j+15)}))).flat(),roots:Array.from({length:15},(_,i)=>String(i))}, + tests:{nodes:Array.from({length:152},(_,i)=>node(i)),edges:Array.from({length:151},(_,i)=>({from:String(i),to:'151'})),roots:Array.from({length:151},(_,i)=>String(i))}, +}; +let realFocus; +if(process.env.MCPP_GRAPH_JSON&&process.env.MCPP_GRAPH_ROOT){ + const root=path.resolve(process.env.MCPP_GRAPH_ROOT),raw=parseModuleGraph(await fs.readFile(process.env.MCPP_GRAPH_JSON,'utf8')); + const manifest=await fs.readFile(path.join(root,'mcpp.toml'),'utf8'); + const files=new Set(raw.modules.flatMap(node=>node.files.map(file=>sourcePath(root,file)))),names=new Set(raw.modules.map(node=>node.name)); + const exists=file=>fs.access(file).then(()=>true,()=>false); + for(const edge of raw.imports)if(!names.has(edge.from)&&await exists(sourcePath(root,edge.from)))files.add(sourcePath(root,edge.from)); + const entries=moduleEntries(root,manifest,readProjectSummary(root,manifest.split(/\r?\n/)).name||path.basename(root),[...files]); + for(const file of entries.keys())if(await exists(file))files.add(file); + shapes.real=buildModuleGraph(root,raw,await moduleOwnership(root,[...files],exists),entries); + realFocus=shapes.real.groups?.[0]?.nodes[0]; +} +const browser=await chromium.launch({headless:true,...(process.env.MCPP_UI_CHROMIUM?{executablePath:process.env.MCPP_UI_CHROMIUM}:{}),args:['--no-sandbox']}); +const results=[]; +try{ + const page=await browser.newPage({viewport:{width:1400,height:900}});await page.setContent('
      ');await page.addScriptTag({path:path.resolve('dist/vendor/mermaid.js')}); + for(const [shape,full]of Object.entries(shapes)){ + const start=performance.now();const projected=visibleGraph(full,{depth:3,upstreamDepth:2,focus:shape==='real'?realFocus:undefined,expanded:new Set(),collapsed:new Set()},INITIAL_GRAPH_BUDGET);const projectionMs=performance.now()-start; + assert.ok(projected.nodes.length<=INITIAL_GRAPH_BUDGET.nodes&&projected.edges.length<=INITIAL_GRAPH_BUDGET.edges); + if(shape==='tests'||shape==='real')assert.ok(projected.edges.length>0,'entries retain connected dependencies'); + const {source}=mermaidGraph(projected),times=[]; + for(let sample=0;sample<20;sample++)times.push(await page.evaluate(async source=>{ + mermaid.initialize({startOnLoad:false,htmlLabels:false,securityLevel:'strict',theme:'dark',fontFamily:'system-ui',markdownAutoWrap:false,flowchart:{htmlLabels:false,useMaxWidth:true,wrappingWidth:1000,nodeSpacing:30,rankSpacing:90,padding:18},maxEdges:310}); + const start=performance.now();const result=await mermaid.render('benchmark',source);document.getElementById('graph').innerHTML=result.svg;return performance.now()-start; + },source)); + const sorted=[...times].sort((a,b)=>a-b);results.push({shape,nodes:projected.nodes.length,edges:projected.edges.length,projectionMs,medianMs:(sorted[9]+sorted[10])/2,p95Ms:sorted[18],samples:times,limited:projected.limited}); + } + const output=path.resolve('.dev-profile/graph-ui-artifacts/graph-performance.json');await fs.mkdir(path.dirname(output),{recursive:true});await fs.writeFile(output,JSON.stringify({browser:await browser.version(),results},null,2)); + console.log(JSON.stringify(results.map(({samples,...summary})=>summary),null,2)); +}finally{await browser.close()} diff --git a/mide-plugins/vscode/tools/bundle-languageclient.mjs b/mide-plugins/vscode/tools/bundle-languageclient.mjs new file mode 100644 index 0000000..a1c866d --- /dev/null +++ b/mide-plugins/vscode/tools/bundle-languageclient.mjs @@ -0,0 +1,20 @@ +// vscode-languageclient, bundled into dist/vendor/languageclient.js: the extension connects to the +// mcpp-ide backend as to an LSP server (design 2026-10-30 part 2 §8), and the VSIX carries no +// node_modules. `vscode` stays the host's. +import { copyFileSync } from "node:fs"; +import { build } from "esbuild"; + +await build({ + entryPoints: ["node_modules/vscode-languageclient/lib/node/main.js"], + bundle: true, + platform: "node", + format: "cjs", + target: "node18", + external: ["vscode"], + outfile: "dist/vendor/languageclient.js", + logLevel: "warning", + minify: true, + keepNames: true, + legalComments: "inline", +}); +copyFileSync("node_modules/vscode-languageclient/License.txt", "dist/vendor/LANGUAGECLIENT-LICENSE"); diff --git a/mide-plugins/vscode/tools/bundle-webui.mjs b/mide-plugins/vscode/tools/bundle-webui.mjs new file mode 100644 index 0000000..4120317 --- /dev/null +++ b/mide-plugins/vscode/tools/bundle-webui.mjs @@ -0,0 +1,43 @@ +/** + * The graph page mcpp-clion shows in JCEF (webui/, mcpp design 2026-10-30 part 2 §9): webui/src/graph.ts + * bundled into graph.js, beside graph.html and mermaid, into webui/dist (or the directory named by + * --out), with files.txt listing them (what mcpp-clion extracts from its jar). Nothing it writes is + * kept in the repository. + */ +import { build } from "esbuild"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const extension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const webui = path.resolve(extension, "..", "..", "webui"); +const at = process.argv.indexOf("--out"); +const out = at > 0 ? path.resolve(process.argv[at + 1]) : path.join(webui, "dist"); + +fs.rmSync(out, { recursive: true, force: true }); +fs.mkdirSync(path.join(out, "vendor"), { recursive: true }); +await build({ + entryPoints: [path.join(webui, "src", "graph.ts")], + bundle: true, + format: "iife", + platform: "browser", + target: "chrome120", + outfile: path.join(out, "graph.js"), + logLevel: "warning", + legalComments: "eof", +}); +fs.copyFileSync(path.join(webui, "graph.html"), path.join(out, "graph.html")); +const mermaid = path.join(extension, "node_modules", "@mermaid-js", "tiny"); +fs.copyFileSync(path.join(mermaid, "dist", "mermaid.tiny.js"), path.join(out, "vendor", "mermaid.js")); +fs.copyFileSync(path.join(mermaid, "LICENSE"), path.join(out, "vendor", "MERMAID-LICENSE")); +const listed = []; +const list = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, e.name); + if (e.isDirectory()) list(p); + else listed.push(path.relative(out, p).split(path.sep).join("/")); + } +}; +list(out); +fs.writeFileSync(path.join(out, "files.txt"), `${listed.join("\n")}\n`); +console.log(`bundle-webui: ${path.relative(process.cwd(), out) || "."}`); diff --git a/mide-plugins/vscode/tools/check-config.mjs b/mide-plugins/vscode/tools/check-config.mjs new file mode 100644 index 0000000..705be04 --- /dev/null +++ b/mide-plugins/vscode/tools/check-config.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * The registry is the single source of truth for settings; `package.json` is + * hand-written but must agree with it. This is the gate. + * + * Checks: + * registry – version present, groups unique, keys unique and prefixed, + * every `group` declared, `order` ascending within a group, + * enums non-empty and containing the default, numeric bounds + * consistent with the default, arrays hold strings; + * package – exactly the registry's keys under `contributes.configuration`, + * each with the same type/default/enum/scope and the `%…%` + * title/description the registry names; + * nls – `.title` and `.description` exist, and deprecated + * entries also carry `.deprecationMessage`. + * + * Exit 1 lists every mismatch; there is no "fix it for me" mode on purpose – + * rewriting package.json would produce an unreviewable diff. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const problems = []; + +const read = (relative) => { + const file = path.join(root, relative); + if (!fs.existsSync(file)) { + problems.push(`${relative}: missing`); + return undefined; + } + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + problems.push(`${relative}: invalid JSON (${error.message})`); + return undefined; + } +}; + +const registry = read("data/config-registry.json"); +const manifest = read("package.json"); +const nlsEn = read("package.nls.json") ?? {}; + +if (registry === undefined || manifest === undefined) { + for (const problem of problems) console.error(`error: ${problem}`); + process.exit(1); +} + +const groups = registry.groups ?? []; +const settings = registry.settings ?? []; +const groupIds = new Set(); +for (const group of groups) { + if (typeof group.id !== "string" || group.id.length === 0) problems.push("registry: a group has no id"); + if (groupIds.has(group.id)) problems.push(`registry: duplicate group ${group.id}`); + groupIds.add(group.id); + if (typeof group.title !== "string" || group.title.length === 0) problems.push(`registry: group ${group.id} has no title`); + if (typeof group.order !== "number") problems.push(`registry: group ${group.id} has no numeric order`); +} + +const seen = new Map(); +const orderInGroup = new Map(); +for (const entry of settings) { + const where = entry.key ?? ""; + if (typeof entry.key !== "string" || !entry.key.startsWith("mcpp.")) { + problems.push(`registry: ${where} is not a mcpp.* key`); + continue; + } + if (seen.has(entry.key)) problems.push(`registry: duplicate key ${entry.key}`); + seen.set(entry.key, entry); + if (!groupIds.has(entry.group)) problems.push(`registry: ${where} names undeclared group ${entry.group}`); + const previous = orderInGroup.get(entry.group); + if (previous !== undefined && !(entry.order > previous)) { + problems.push(`registry: ${entry.group} order is not ascending at ${where} (${previous} -> ${entry.order})`); + } + orderInGroup.set(entry.group, entry.order); + if (typeof entry.title !== "string" || entry.title.length === 0) problems.push(`registry: ${where} has no title`); + if (typeof entry.description !== "string" || entry.description.length === 0) { + problems.push(`registry: ${where} has no description`); + } + if (!["public", "advanced"].includes(entry.tier)) problems.push(`registry: ${where} has tier ${entry.tier}`); + if (!["immediate", "next-build", "next-clean", "view-reload", "restart"].includes(entry.applies)) { + problems.push(`registry: ${where} has applies ${entry.applies}`); + } + if (!["resource", "window", "machine"].includes(entry.scope)) problems.push(`registry: ${where} has scope ${entry.scope}`); + if (entry.enum !== undefined) { + if (!Array.isArray(entry.enum) || entry.enum.length === 0) problems.push(`registry: ${where} has an empty enum`); + else if (!entry.enum.includes(entry.default)) problems.push(`registry: ${where} default is not in its enum`); + } + const declaredType = entry.type === "array" ? "array" : entry.type; + const actualType = Array.isArray(entry.default) ? "array" : typeof entry.default; + if (declaredType !== actualType) { + problems.push(`registry: ${where} type ${entry.type} does not match default ${JSON.stringify(entry.default)}`); + } + if (entry.type === "number") { + if (entry.minimum !== undefined && entry.default < entry.minimum) problems.push(`registry: ${where} default < minimum`); + if (entry.maximum !== undefined && entry.default > entry.maximum) problems.push(`registry: ${where} default > maximum`); + } +} + +// ------------------------------------------------------------------ package +const properties = manifest.contributes?.configuration?.properties ?? {}; +const packageKeys = Object.keys(properties).filter((key) => key.startsWith("mcpp.")); +for (const key of packageKeys) { + if (!seen.has(key)) problems.push(`package.json: ${key} is not in the registry`); +} +for (const [key, entry] of seen) { + const property = properties[key]; + if (property === undefined) { + problems.push(`package.json: ${key} is missing from contributes.configuration`); + continue; + } + if (property.type !== entry.type) problems.push(`package.json: ${key} type ${property.type} != registry ${entry.type}`); + if (JSON.stringify(property.default) !== JSON.stringify(entry.default)) { + problems.push(`package.json: ${key} default ${JSON.stringify(property.default)} != registry ${JSON.stringify(entry.default)}`); + } + if (entry.enum !== undefined && JSON.stringify(property.enum) !== JSON.stringify(entry.enum)) { + problems.push(`package.json: ${key} enum differs from the registry`); + } + if (property.scope !== undefined && property.scope !== entry.scope) { + problems.push(`package.json: ${key} scope ${property.scope} != registry ${entry.scope}`); + } + if (property.minimum !== undefined && property.minimum !== entry.minimum) { + problems.push(`package.json: ${key} minimum differs from the registry`); + } + if (property.maximum !== undefined && property.maximum !== entry.maximum) { + problems.push(`package.json: ${key} maximum differs from the registry`); + } + if (property.description !== `%${key}.title%`) { + problems.push(`package.json: ${key} description must be %${key}.title%`); + } + if (property.markdownDescription !== `%${key}.description%`) { + problems.push(`package.json: ${key} markdownDescription must be %${key}.description%`); + } + if (entry.deprecated === true && property.deprecationMessage !== `%${key}.deprecationMessage%`) { + problems.push(`package.json: ${key} is deprecated but has no deprecationMessage reference`); + } +} + +// ---------------------------------------------------------------------- nls +for (const [key, entry] of seen) { + for (const suffix of ["title", "description"]) { + if (typeof nlsEn[`${key}.${suffix}`] !== "string") problems.push(`package.nls.json: ${key}.${suffix} is missing`); + } + if (nlsEn[`${key}.title`] !== entry.title) problems.push(`package.nls.json: ${key}.title differs from the registry title`); + if (nlsEn[`${key}.description`] !== entry.description) { + problems.push(`package.nls.json: ${key}.description differs from the registry description`); + } + if (entry.deprecated === true && typeof nlsEn[`${key}.deprecationMessage`] !== "string") { + problems.push(`package.nls.json: ${key}.deprecationMessage is missing`); + } +} + +if (problems.length > 0) { + for (const problem of problems) console.error(`error: ${problem}`); + console.error(`check-config: ${problems.length} problem(s)`); + process.exit(1); +} +console.log( + `check-config: ok (${groups.length} groups, ${settings.length} settings, ` + + `${settings.filter((entry) => entry.tier === "public").length} public)`, +); diff --git a/mide-plugins/vscode/tools/ci-vscode-cli.mjs b/mide-plugins/vscode/tools/ci-vscode-cli.mjs new file mode 100644 index 0000000..4acbcd5 --- /dev/null +++ b/mide-plugins/vscode/tools/ci-vscode-cli.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * A real `code` CLI for steps that manage extensions but run where VS Code is + * not installed. GitHub's ubuntu runner ships no `code` on PATH, which is how + * the isolated-install job used to die ten seconds in on + * `code: command not found`. The Extension Host e2e solves the same problem by + * letting @vscode/test-electron download a pinned build; this is that download + * reduced to the one thing those steps need — the CLI path. + * + * Usage: + * CODE=$(node tools/ci-vscode-cli.mjs) # the e2e pin, 1.91.0 + * CODE=$(node tools/ci-vscode-cli.mjs --version 1.95.0) + * "$CODE" --extensions-dir --install-extension + * + * The build lands in `.vscode-test/` (gitignored, kept out of the VSIX) and is + * cached, so a later `npm run test:e2e` on the same checkout reuses it. Keep the + * default in step with the `{ version: "1.91.0" }` pin in test/e2e/runTest.ts: + * CI deliberately tests against the oldest VS Code the extension claims to + * support (`engines.vscode`), not against whatever `stable` is today. + * + * The downloader retries internally, but all its attempts die within a second + * when the CDN edge resets the connection — a real macos-14 run hit exactly + * that. So this adds slower outer retries (30s, 60s, …) to give the edge time + * to recover; a CI cache over `.vscode-test/` makes the whole question moot on + * repeat runs. + */ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const args = process.argv.slice(2); +const indexOf = (name) => args.indexOf(`--${name}`); +const versionIndex = indexOf("version"); +const version = versionIndex === -1 ? "1.91.0" : args[versionIndex + 1]; +const retriesIndex = indexOf("retries"); +const retries = retriesIndex === -1 ? 3 : Number(args[retriesIndex + 1]); + +const { downloadAndUnzipVSCode, resolveCliPathFromVSCodeExecutablePath } = require("@vscode/test-electron"); + +for (let attempt = 1; ; attempt += 1) { + try { + const executable = await downloadAndUnzipVSCode(version); + console.log(resolveCliPathFromVSCodeExecutablePath(executable)); + break; + } catch (error) { + if (attempt >= retries) throw error; + const seconds = attempt * 30; + console.warn(`download failed (${error?.message ?? error}); retry ${attempt + 1}/${retries} in ${seconds}s`); + await new Promise((resolve) => setTimeout(resolve, seconds * 1000)); + } +} diff --git a/mide-plugins/vscode/tools/copy-webview-assets.mjs b/mide-plugins/vscode/tools/copy-webview-assets.mjs new file mode 100644 index 0000000..ae5cd84 --- /dev/null +++ b/mide-plugins/vscode/tools/copy-webview-assets.mjs @@ -0,0 +1,4 @@ +import fs from 'node:fs'; +fs.mkdirSync('dist/vendor',{recursive:true}); +fs.copyFileSync('node_modules/@mermaid-js/tiny/dist/mermaid.tiny.js','dist/vendor/mermaid.js'); +fs.copyFileSync('node_modules/@mermaid-js/tiny/LICENSE','dist/vendor/MERMAID-LICENSE'); diff --git a/mide-plugins/vscode/tools/dev-profile.mjs b/mide-plugins/vscode/tools/dev-profile.mjs new file mode 100644 index 0000000..774baa8 --- /dev/null +++ b/mide-plugins/vscode/tools/dev-profile.mjs @@ -0,0 +1,161 @@ +#!/usr/bin/env node +/** + * A throwaway VS Code profile for trying this extension by hand. + * + * It never touches your real profile: everything lives under + * `.dev-profile/` (gitignored) — a private `extensions/` and `user-data/`, plus a + * scratch workspace. The script installs the packed VSIX there, tries to install + * the C++ Modules dependency the same way, and prints the exact command to + * launch. + * + * Usage: + * node tools/dev-profile.mjs # build, install, print the command + * node tools/dev-profile.mjs --project # open an existing mcpp project + * node tools/dev-profile.mjs --from # copy a project into the profile instead + * node tools/dev-profile.mjs --mcppls-vsix # install a local mcppls VSIX (offline) + * node tools/dev-profile.mjs --code # a different VS Code build + * node tools/dev-profile.mjs --no-package # reuse an existing VSIX + * + * Nothing is installed into your normal extension directory, and deleting + * `.dev-profile/` removes every trace. + */ +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const args = process.argv.slice(2); + +function option(name, fallback) { + const index = args.indexOf(`--${name}`); + return index === -1 ? fallback : args[index + 1]; +} + +const has = (name) => args.includes(`--${name}`); + +const profileRoot = path.join(root, ".dev-profile"); +const extensionsDir = path.join(profileRoot, "extensions"); +const userDataDir = path.join(profileRoot, "user-data"); +const workspaceDir = option("project", path.join(profileRoot, "workspace")); +const code = option("code", process.env.MCPP_DEV_CODE ?? "code"); +const noPackage = has("no-package"); + +function run(executable, argv, options = {}) { + const result = spawnSync(executable, argv, { stdio: "inherit", ...options }); + return result.status ?? 1; +} + +function quiet(executable, argv) { + const result = spawnSync(executable, argv, { encoding: "utf8" }); + return { code: result.status ?? 1, out: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() }; +} + +// ---------------------------------------------------------------- preflight +const version = quiet(code, ["--version"]); +if (version.code !== 0) { + console.error(`dev-profile: '${code}' did not answer. Pass --code .`); + process.exit(1); +} +console.log(`dev-profile: using ${code} (${version.out.split("\n")[0]})`); + +// ------------------------------------------------------------------- package +fs.mkdirSync(extensionsDir, { recursive: true }); +fs.mkdirSync(userDataDir, { recursive: true }); + +let vsix = option("vsix", undefined); +if (vsix === undefined) { + const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + vsix = path.join(root, `mcpp-vscode-${manifest.version}.vsix`); +} +if (!noPackage) { + console.log("dev-profile: packaging the extension…"); + const status = run("npm", ["run", "package"], { cwd: root }); + if (status !== 0 || !fs.existsSync(vsix)) { + console.error(`dev-profile: packaging failed; expected ${vsix}`); + process.exit(1); + } +} +if (!fs.existsSync(vsix)) { + console.error(`dev-profile: ${vsix} does not exist (drop --no-package or run npm run package)`); + process.exit(1); +} + +// ------------------------------------------------------------------- install +console.log(`dev-profile: installing ${path.basename(vsix)} into the private profile…`); +run(code, [ + "--extensions-dir", extensionsDir, + "--user-data-dir", userDataDir, + "--install-extension", vsix, + "--force", +]); + +const mcpplsVsix = option("mcppls-vsix", undefined); +if (mcpplsVsix !== undefined) { + console.log(`dev-profile: installing ${path.basename(mcpplsVsix)}…`); + run(code, [ + "--extensions-dir", extensionsDir, + "--user-data-dir", userDataDir, + "--install-extension", mcpplsVsix, + "--force", + ]); +} else { + // The extension declares `sunrisepeak.mcpp-language-server` as a dependency, so + // VS Code resolves it when the extension activates. Installing it up front makes + // the first launch predictable, and failing here is not fatal — the profile still + // works, the C++ Modules view just says the dependency is missing. + console.log("dev-profile: trying to install the C++ Modules dependency…"); + const installed = run(code, [ + "--extensions-dir", extensionsDir, + "--user-data-dir", userDataDir, + "--install-extension", "sunrisepeak.mcpp-language-server", + ]); + if (installed !== 0) { + console.warn( + "dev-profile: could not install sunrisepeak.mcpp-language-server.\n" + + " Offline, pass --mcppls-vsix .vsix>; the extension still\n" + + " activates, and the C++ Modules view will say the dependency is missing.", + ); + } +} + +// ----------------------------------------------------------------- workspace +if (option("project", undefined) === undefined) { + const from = option("from", undefined); + if (from !== undefined) { + console.log(`dev-profile: copying ${from} into the profile workspace…`); + fs.rmSync(workspaceDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(workspaceDir), { recursive: true }); + fs.cpSync(from, workspaceDir, { recursive: true, filter: (source) => !source.includes(`${path.sep}target`) }); + } else if (!fs.existsSync(path.join(workspaceDir, "mcpp.toml"))) { + fs.mkdirSync(workspaceDir, { recursive: true }); + console.log("dev-profile: creating a scratch project with `mcpp new`…"); + const status = run("mcpp", ["new", "dev-scratch"], { cwd: workspaceDir }); + if (status !== 0) { + console.warn("dev-profile: `mcpp new` failed; open any mcpp project instead with --project ."); + } + } +} + +const openDir = fs.existsSync(path.join(workspaceDir, "mcpp.toml")) + ? workspaceDir + : fs.existsSync(path.join(workspaceDir, "dev-scratch", "mcpp.toml")) + ? path.join(workspaceDir, "dev-scratch") + : workspaceDir; + +// -------------------------------------------------------------------- report +console.log(""); +console.log("dev-profile: ready."); +console.log(""); +console.log("Launch:"); +console.log(` ${code} --extensions-dir "${extensionsDir}" --user-data-dir "${userDataDir}" "${openDir}"`); +console.log(""); +console.log("Try, in order:"); +console.log(" 1. the mcpp icon in the Activity Bar — Project / Library / Cache"); +console.log(" 2. the Project view: 「常用命令」 open with coloured icons, 「基本信息」 folded"); +console.log(" 3. the status bar's $(tools) mcpp quick menu — icons and one section per group"); +console.log(" 4. the Library view: filter, search, open a package page, add it to mcpp.toml"); +console.log(" 5. the Cache view: the project figure, the composition bar and its legend"); +console.log(" 6. mcpp: Environment Self-check, and mcpp.toml / build.mcpp intelligence"); +console.log(""); +console.log(`Remove everything with: rm -rf "${profileRoot}"`); diff --git a/mide-plugins/vscode/tools/generate-activitybar-icon.mjs b/mide-plugins/vscode/tools/generate-activitybar-icon.mjs new file mode 100644 index 0000000..e9a195a --- /dev/null +++ b/mide-plugins/vscode/tools/generate-activitybar-icon.mjs @@ -0,0 +1,284 @@ +#!/usr/bin/env node +/** + * `media/logo.png` -> `media/activity-bar.png`, the mark the activity bar + * draws in the theme's own colour. + * + * **Why this file exists.** VS Code does not paint a container icon as an + * ordinary image. `ActivityAction` builds a per-icon class whose rule is + * `mask: url() no-repeat 50% 50%` with `mask-size: 24px`, so the icon's + * **alpha channel** becomes a stencil and the colour comes from the theme + * (`--vscode-activityBar-inactiveForeground`, `--vscode-activityBar-foreground` + * on hover). The official logo is a 377x377 opaque black rounded square with the + * wordmark on it: as a stencil that is a solid white block, which is exactly + * what shipped and exactly what a reviewer saw. + * + * So the asset is derived rather than drawn by hand. Two properties matter: + * + * 1. **The background is transparent and only the wordmark is opaque.** The + * badge disappears; the letters become the stencil. + * 2. **The glyph is white.** Against a transparent background that is correct + * under an alpha mask (*the* behaviour) *and* under a luminance one, so the + * icon cannot break if the masking mode is ever the other of the two. + * + * The extraction is a coverage estimate, not a trace: the wordmark is drawn in + * two flat colours (#f08c00 and #1971c2) over black, so a pixel is + * `coverage * colour` per channel, and the largest channel relative to that + * channel's value in the pure colour recovers the coverage. Anything under 6% + * is the badge's own edge antialiasing and is dropped. + * + * `--check` re-derives the file in memory and exits 1 on any difference; + * `npm run check:icon` and `test/views/activityBarIcon.test.ts` use it, so the + * committed PNG cannot drift from the logo it came from. + */ +import fs from "node:fs"; +import path from "node:path"; +import zlib from "node:zlib"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const SOURCE = path.join(root, "media", "logo.png"); +const TARGET = path.join(root, "media", "activity-bar.png"); + +/** + * The wordmark's two flat colours (#f08c00 and #1971c2), reduced to the divisor + * each one contributes below. + */ +const INK_DIVISORS = [240, 140, 194]; + +/** Below this much coverage a pixel is the badge edge, not the wordmark. */ +const NOISE_FLOOR = 0.06; + +/** The output canvas: 96 px wide, so a 4 px margin is 1 px at the 24 px mask. */ +const MARGIN = 4; +const CONTENT_WIDTH = 88; + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n += 1) { + let c = n; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[n] = c; + } + return table; +})(); + +function crc32(buffer) { + let c = 0xffffffff; + for (const byte of buffer) { + c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8); + } + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, "ascii"), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([length, body, crc]); +} + +/** + * A PNG this script can read: 8-bit RGBA, non-interlaced. Anything else is + * refused rather than approximated — a silently mis-read logo is worse than a + * failed build. + */ +function decodePng(buffer) { + if (buffer.subarray(0, 8).toString("binary") !== "\x89PNG\r\n\x1a\n") { + throw new Error(`${SOURCE}: not a PNG`); + } + let offset = 8; + let header; + const idat = []; + while (offset < buffer.length) { + const length = buffer.readUInt32BE(offset); + const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); + const data = buffer.subarray(offset + 8, offset + 8 + length); + offset += 12 + length; + if (type === "IHDR") { + header = { + width: data.readUInt32BE(0), + height: data.readUInt32BE(4), + depth: data[8], + colorType: data[9], + interlace: data[12], + }; + } else if (type === "IDAT") { + idat.push(data); + } else if (type === "IEND") { + break; + } + } + if (header === undefined) { + throw new Error(`${SOURCE}: no IHDR`); + } + if (header.depth !== 8 || header.colorType !== 6 || header.interlace !== 0) { + throw new Error( + `${SOURCE}: expected an 8-bit RGBA, non-interlaced PNG (got depth ${header.depth}, colour type ${header.colorType}, interlace ${header.interlace})`, + ); + } + const { width, height } = header; + const raw = zlib.inflateSync(Buffer.concat(idat)); + const stride = width * 4; + const pixels = Buffer.alloc(stride * height); + let position = 0; + for (let y = 0; y < height; y += 1) { + const filter = raw[position]; + position += 1; + const line = raw.subarray(position, position + stride); + position += stride; + const out = pixels.subarray(y * stride, (y + 1) * stride); + const previous = y === 0 ? Buffer.alloc(stride) : pixels.subarray((y - 1) * stride, y * stride); + for (let i = 0; i < stride; i += 1) { + const a = i >= 4 ? out[i - 4] : 0; + const b = previous[i]; + const c = i >= 4 ? previous[i - 4] : 0; + const x = line[i]; + let value; + if (filter === 0) value = x; + else if (filter === 1) value = x + a; + else if (filter === 2) value = x + b; + else if (filter === 3) value = x + ((a + b) >> 1); + else if (filter === 4) { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + value = x + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + } else { + throw new Error(`${SOURCE}: unknown filter type ${filter} on row ${y}`); + } + out[i] = value & 0xff; + } + } + return { width, height, pixels }; +} + +/** + * How much of the wordmark's ink covers this pixel, 0..1. + * + * Nothing here is a guess about the artwork: the wordmark is painted in exactly + * two flat colours over a pure black field, so a pixel is `coverage * ink` and + * one channel of the pure ink recovers the coverage exactly. The three divisors + * are the largest channel of each ink — #f08c00's red and green, #1971c2's blue + * — so the largest ratio is 1 for either ink, 0 for the black field (which is + * why the badge, whose edge antialiases against transparency at rgb 0,0,0, + * vanishes rather than leaving a halo), and never divides by zero. + */ +function coverage(r, g, b) { + const value = Math.max(r / INK_DIVISORS[0], g / INK_DIVISORS[1], b / INK_DIVISORS[2]); + return value < NOISE_FLOOR ? 0 : Math.min(1, value); +} + +/** The wordmark as a coverage field, plus the box it actually occupies. */ +function wordmark(png) { + const { width, height, pixels } = png; + const field = new Float64Array(width * height); + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const at = (y * width + x) * 4; + if (pixels[at + 3] <= 8) continue; + const value = coverage(pixels[at], pixels[at + 1], pixels[at + 2]); + if (value <= 0) continue; + field[y * width + x] = value; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + if (maxX < 0) { + throw new Error(`${SOURCE}: no wordmark ink was found`); + } + return { field, minX, minY, maxX, maxY }; +} + +/** + * The icon: the wordmark fitted to the content width, centred on a transparent + * canvas trimmed to its own height, as white with a coverage alpha. + * + * The canvas is **not** square. The mask is sized by width alone + * (`mask-size: 24px`), so a square canvas would only shrink a 1.85:1 wordmark + * to fit its height. Trimming to the glyph's own aspect keeps it as large as the + * 24 px box allows. + */ +function render(png) { + const { field, minX, minY, maxX, maxY } = wordmark(png); + const sourceWidth = maxX - minX + 1; + const sourceHeight = maxY - minY + 1; + const contentHeight = Math.max(2, Math.round((CONTENT_WIDTH * sourceHeight) / sourceWidth / 2) * 2); + const width = CONTENT_WIDTH + 2 * MARGIN; + const height = contentHeight + 2 * MARGIN; + + const alpha = new Uint8Array(width * height); + for (let y = 0; y < contentHeight; y += 1) { + for (let x = 0; x < CONTENT_WIDTH; x += 1) { + // Box filter: the source box is much larger than one output pixel here, so + // averaging is the whole of the antialiasing. + const x0 = minX + (x * sourceWidth) / CONTENT_WIDTH; + const x1 = minX + ((x + 1) * sourceWidth) / CONTENT_WIDTH; + const y0 = minY + (y * sourceHeight) / contentHeight; + const y1 = minY + ((y + 1) * sourceHeight) / contentHeight; + let total = 0; + let samples = 0; + for (let sy = Math.floor(y0); sy <= Math.min(Math.ceil(y1) - 1, maxY); sy += 1) { + for (let sx = Math.floor(x0); sx <= Math.min(Math.ceil(x1) - 1, maxX); sx += 1) { + total += field[sy * png.width + sx]; + samples += 1; + } + } + const value = samples === 0 ? 0 : total / samples; + alpha[(MARGIN + y) * width + (MARGIN + x)] = Math.round(value * 255); + } + } + + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y += 1) { + const row = y * (stride + 1); + raw[row] = 0; // filter: none + for (let x = 0; x < width; x += 1) { + const at = row + 1 + x * 4; + // White, fully opaque where the stencil is: correct as an alpha mask and + // as a luminance one. + raw[at] = 255; + raw[at + 1] = 255; + raw[at + 2] = 255; + raw[at + 3] = alpha[y * width + x]; + } + } + + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; // bit depth + header[9] = 6; // RGBA + return Buffer.concat([ + Buffer.from("\x89PNG\r\n\x1a\n", "binary"), + chunk("IHDR", header), + chunk("IDAT", zlib.deflateSync(raw, { level: 9 })), + chunk("IEND", Buffer.alloc(0)), + ]); +} + +const generated = render(decodePng(fs.readFileSync(SOURCE))); +const relative = path.relative(root, TARGET); + +if (process.argv.includes("--check")) { + const committed = fs.existsSync(TARGET) ? fs.readFileSync(TARGET) : Buffer.alloc(0); + if (!generated.equals(committed)) { + console.error(`error: ${relative} is out of date — run \`npm run gen:icon\` and commit the result`); + process.exit(1); + } + console.log(`check-generators: ${relative} matches ${path.relative(root, SOURCE)}`); +} else { + fs.writeFileSync(TARGET, generated); + console.log(`generate-activitybar-icon: wrote ${relative} (${generated.length} bytes)`); +} diff --git a/mide-plugins/vscode/tools/generate-l10n.mjs b/mide-plugins/vscode/tools/generate-l10n.mjs new file mode 100644 index 0000000..cef735d --- /dev/null +++ b/mide-plugins/vscode/tools/generate-l10n.mjs @@ -0,0 +1,59 @@ +#!/usr/bin/env node +/** + * `data/i18n/.json` -> `l10n/bundle.l10n..json`. + * + * The runtime bundle is what `vscode.l10n` reads, so it must be generated rather + * than hand-maintained: `data/i18n` is the single source of truth and + * `tools/l10n-check.mjs` holds the source strings to it. + * + * The English bundle is intentionally empty: English is the key, so a missing + * translation already degrades to English without a lookup table. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourceDir = path.join(root, "data", "i18n"); +const targetDir = path.join(root, "l10n"); + +function readJson(file, fallback) { + if (!fs.existsSync(file)) return fallback; + return JSON.parse(fs.readFileSync(file, "utf8")); +} + +function assertStringMap(file, value) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`${file}: expected a JSON object of string -> string`); + } + for (const [key, text] of Object.entries(value)) { + if (typeof text !== "string" || text.length === 0) { + throw new Error(`${file}: key ${JSON.stringify(key)} does not map to a non-empty string`); + } + if (key.trim().length === 0) { + throw new Error(`${file}: empty key`); + } + } +} + +fs.mkdirSync(targetDir, { recursive: true }); + +const locales = fs.existsSync(sourceDir) + ? fs.readdirSync(sourceDir).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)) + : []; + +let written = 0; +for (const locale of locales) { + const file = path.join(sourceDir, `${locale}.json`); + const bundle = readJson(file, {}); + assertStringMap(file, bundle); + const out = path.join(targetDir, `bundle.l10n.${locale}.json`); + fs.writeFileSync(out, `${JSON.stringify(bundle, null, 2)}\n`); + written += 1; +} + +// The default bundle carries no translations: English text is the key. +const defaultBundle = path.join(targetDir, "bundle.l10n.json"); +fs.writeFileSync(defaultBundle, "{}\n"); + +console.log(`generate-l10n: wrote ${written} locale bundle(s) + the default bundle into l10n/`); diff --git a/mide-plugins/vscode/tools/generate-library-examples.mjs b/mide-plugins/vscode/tools/generate-library-examples.mjs new file mode 100644 index 0000000..d73fb75 --- /dev/null +++ b/mide-plugins/vscode/tools/generate-library-examples.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +// Pin real, tracked upstream tests for offline usage pages; never copy build artefacts. +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'); +const repo=process.env.MCPP_INDEX_REPO || path.resolve(root,'../mcpp-index'); +const git=(...args)=>execFileSync('git',['-C',repo,...args],{encoding:'utf8',maxBuffer:8*1024*1024}); +const commit=git('rev-parse',process.env.MCPP_INDEX_REF || 'HEAD').trim(); +const files=git('ls-tree','-r','--name-only',commit,'tests/examples').trim().split('\n'); +const projects=[]; +for(const manifest of files.filter(f=>/^tests\/examples\/[^/]+\/mcpp\.toml$/.test(f)).sort()) { + const directory=path.posix.dirname(manifest); + const sources=files.filter(f=>f.startsWith(directory+'/tests/') && /\.(cpp|cppm|cc)$/.test(f)).sort(); + if(sources.length) projects.push({project:path.posix.basename(directory),text:git('show',commit+':'+manifest),files:sources.map(p=>({path:p,text:git('show',commit+':'+p)}))}); +} +if(!projects.length) throw new Error('No tracked example projects'); +fs.writeFileSync(path.join(root,'data/library-examples.json'),JSON.stringify({repository:'https://github.com/mcpp-community/mcpp-index',commit,projects},null,2)+'\n'); +console.log(`Pinned ${projects.length} upstream example projects at ${commit}`); diff --git a/mide-plugins/vscode/tools/generate-quick-menu-icons.mjs b/mide-plugins/vscode/tools/generate-quick-menu-icons.mjs new file mode 100644 index 0000000..fd1b08d --- /dev/null +++ b/mide-plugins/vscode/tools/generate-quick-menu-icons.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * `src/commands/menu.ts` + `@vscode/codicons` -> `media/quick-menu/*.svg`. + * + * **Why this file exists.** A quick pick row paints its `iconPath` as a + * `background-image`; only a `ThemeIcon` becomes a font glyph, and VS Code drops + * that icon's colour (see the note at the top of `src/commands/menu.ts`). So the + * only way to give a quick pick row a coloured icon is to hand it a coloured + * image, and the only honest way to produce twenty of those is to generate them + * from the same table that defines the menu. + * + * Each glyph comes from `@vscode/codicons` — the artwork VS Code's own codicon + * font is built from, pinned in `devDependencies` — and each colour is the + * **default** light or dark value of the matching `charts.*` theme token, read + * out of VS Code 1.132's colour registry. That is what makes the menu and the + * project tree agree: the tree hands `charts.blue` to a `ThemeIcon` and the theme + * paints it, here the same default value is baked in. A custom theme that + * redefines `charts.*` therefore moves the tree icons and not these — the + * documented cost of an image, and the reason `neutral` uses `foreground`. + * + * Two files per row, one per theme, because `iconPath` takes a `{ light, dark }` + * pair and VS Code picks by the active theme. A single mid-tone would be a + * compromise in both themes. + * + * `--check` re-derives everything in memory and fails on any difference — + * including a file in `media/quick-menu/` that nothing asks for — so + * `npm run check:icons` and `test/commands/menu.test.ts` keep the committed + * assets exactly equal to the table. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const MENU = path.join(root, "src", "commands", "menu.ts"); +const GLYPHS = path.join(root, "node_modules", "@vscode", "codicons", "src", "icons"); +const OUTPUT = path.join(root, "media", "quick-menu"); + +const THEMES = ["dark", "light"]; + +/** + * The palette, as the default values of the theme tokens the project tree names. + * + * Verified against VS Code 1.132's colour registry: + * + * - `charts.blue` = `editorInfo.foreground` -> #59a4f9 / #0063d3 + * - `charts.green` -> #89d185 / #388a34 + * - `charts.purple` -> #b180d7 / #652d90 + * - `charts.yellow` = `editorWarning.foreground` -> #cca700 / #bf8803 + * - `charts.red` = `editorError.foreground` -> #f14c4c / #e51400 + * - `neutral` = `foreground` -> #cccccc / #616161 + * + * `charts.orange` is deliberately absent: it resolves to + * `minimap.findMatchHighlight` -> `editor.findMatchHighlightBackground`, which is + * `#EA5C00` at 33% alpha. As a glyph colour that is a washed-out smear in both + * themes, which is why destructive rows use `red` instead. + */ +const PALETTE = { + blue: { dark: "#59a4f9", light: "#0063d3" }, + green: { dark: "#89d185", light: "#388a34" }, + purple: { dark: "#b180d7", light: "#652d90" }, + yellow: { dark: "#cca700", light: "#bf8803" }, + red: { dark: "#f14c4c", light: "#e51400" }, + neutral: { dark: "#cccccc", light: "#616161" }, +}; + +/** + * The `(icon, colour)` pairs the menu table asks for. + * + * The table is read as text rather than imported: it is TypeScript, and the row + * shape is fixed and machine-checked (`test/commands/menu.test.ts` walks the + * same table and stats the files, so a row this parser misses fails the suite + * rather than the build). The two counts below are what stops a reformat from + * quietly dropping a row. + */ +function menuRows() { + const text = fs.readFileSync(MENU, "utf8"); + const rows = [...text.matchAll(/\{\s*labelKey: "(?:[^"\\]|\\.)*"[^}]*?icon: "([a-z0-9-]+)",\s*iconColor: "([a-z]+)"/g)].map( + (match) => ({ icon: match[1], colour: match[2] }), + ); + // A row starts with a quoted `labelKey`; the interface's `labelKey: string;` + // and the group table's `{ id: …, labelKey: … }` do not. + const declared = [...text.matchAll(/\{\s*labelKey: "/g)].length; + if (rows.length !== declared) { + throw new Error(`${MENU}: parsed ${rows.length} of ${declared} rows; the table's shape changed`); + } + for (const row of rows) { + if (PALETTE[row.colour] === undefined) { + throw new Error(`${MENU}: ${row.icon} asks for the colour "${row.colour}", which is not in the palette`); + } + } + return rows; +} + +/** One glyph, recoloured. The `currentColor` in the source is the theme's job. */ +function glyph(name, hex) { + const file = path.join(GLYPHS, `${name}.svg`); + if (!fs.existsSync(file)) { + throw new Error(`${file}: no such codicon (is @vscode/codicons installed and current?)`); + } + const svg = fs.readFileSync(file, "utf8"); + if (!svg.includes("currentColor")) { + throw new Error(`${file}: no currentColor to replace`); + } + return svg.replaceAll("currentColor", hex); +} + +/** Every file this generator owns, as `name -> contents`. */ +function assets() { + const files = new Map(); + for (const { icon, colour } of menuRows()) { + for (const theme of THEMES) { + files.set(`${icon}--${colour}--${theme}.svg`, glyph(icon, PALETTE[colour][theme])); + } + } + return files; +} + +function existing() { + if (!fs.existsSync(OUTPUT)) { + return []; + } + return fs.readdirSync(OUTPUT).sort(); +} + +const files = assets(); +const relative = (name) => path.relative(root, path.join(OUTPUT, name)); + +if (process.argv.includes("--check")) { + const problems = []; + for (const [name, expected] of files) { + const file = path.join(OUTPUT, name); + if (!fs.existsSync(file)) { + problems.push(`${relative(name)} is missing`); + } else if (fs.readFileSync(file, "utf8") !== expected) { + problems.push(`${relative(name)} is out of date`); + } + } + for (const name of existing()) { + if (!files.has(name)) { + problems.push(`${relative(name)} is not asked for by ${path.relative(root, MENU)}`); + } + } + if (problems.length > 0) { + for (const problem of problems) { + console.error(`error: ${problem}`); + } + console.error(`error: run \`npm run gen:menuicons\` and commit the result`); + process.exit(1); + } + console.log(`check-generators: ${files.size} quick menu icon(s) match ${path.relative(root, MENU)}`); +} else { + fs.mkdirSync(OUTPUT, { recursive: true }); + const wanted = new Set(files.keys()); + let removed = 0; + for (const name of existing()) { + if (!wanted.has(name)) { + fs.rmSync(path.join(OUTPUT, name)); + removed += 1; + } + } + let written = 0; + for (const [name, contents] of files) { + const file = path.join(OUTPUT, name); + if (!fs.existsSync(file) || fs.readFileSync(file, "utf8") !== contents) { + fs.writeFileSync(file, contents); + written += 1; + } + } + console.log(`generate-quick-menu-icons: wrote ${written}, removed ${removed}, total ${files.size}`); +} diff --git a/mide-plugins/vscode/tools/generate-settings-docs.mjs b/mide-plugins/vscode/tools/generate-settings-docs.mjs new file mode 100644 index 0000000..9beca04 --- /dev/null +++ b/mide-plugins/vscode/tools/generate-settings-docs.mjs @@ -0,0 +1,100 @@ +#!/usr/bin/env node +/** + * `data/config-registry.json` -> `docs/settings.md`. + * + * The user documentation of settings is generated so it cannot drift from the + * manifest: `tools/check-config.mjs` already holds the registry to + * `package.json`, and this holds the docs to the registry. Make targets run + * `npm run gen:docs` then `git diff --exit-code`. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const registry = JSON.parse(fs.readFileSync(path.join(root, "data", "config-registry.json"), "utf8")); +// The Chinese descriptions already exist for the Settings UI (`package.nls.zh-cn.json`); +// reusing them keeps one source per language instead of a third copy in these docs. +const zhNls = JSON.parse(fs.readFileSync(path.join(root, "package.nls.zh-cn.json"), "utf8")); + +const APPLIES = { + immediate: "Takes effect immediately", + "next-build": "Takes effect on the next build/run/test", + "next-clean": "Takes effect on the next clean", + "view-reload": "Takes effect after the window reloads", +}; + +const SCOPE = { + resource: "per folder", + window: "per window", +}; + +const groups = [...registry.groups].sort((a, b) => a.order - b.order); +const lines = []; +lines.push("# Settings"); +lines.push(""); +lines.push(""); +lines.push(""); +lines.push(`${registry.settings.length} settings, in ${groups.length} groups. All of them are read by the`); +lines.push("**mcpp** extension only. `mcppls.*` belongs to the C++ Modules extension and is never"); +lines.push("written by this one; the configuration panel says so on every screen."); +lines.push(""); +lines.push("| | |"); +lines.push("|---|---|"); +lines.push("| Panel | **mcpp: Open Settings Panel** (`mcpp.openSettings`) |"); +lines.push("| Native UI | Settings → Extensions → mcpp, or `@ext:mcpp-community.mcpp-vscode` |"); +lines.push("| Defaults | Every default is deliberately quiet: nothing here changes behaviour until you ask |"); +lines.push(""); + +for (const group of groups) { + const rows = registry.settings + .filter((entry) => entry.group === group.id) + .sort((a, b) => a.order - b.order); + if (rows.length === 0) continue; + lines.push(`## ${group.title}`); + lines.push(""); + for (const entry of rows) { + const badges = []; + if (entry.deprecated) badges.push("**deprecated**"); + if (entry.tier === "advanced") badges.push("advanced"); + lines.push(`### \`${entry.key}\`${badges.length > 0 ? ` — ${badges.join(", ")}` : ""}`); + lines.push(""); + lines.push(entry.description); + const zh = zhNls[`${entry.key}.description`]; + if (typeof zh === "string" && zh.length > 0) { + lines.push(""); + lines.push(zh); + } + lines.push(""); + lines.push("| | |"); + lines.push("|---|---|"); + lines.push(`| Type | \`${entry.type}\`${entry.enum ? `: ${entry.enum.map((value) => `\`${value}\``).join(" \\| ")}` : ""} |`); + lines.push(`| Default | \`${JSON.stringify(entry.default)}\` |`); + if (entry.minimum !== undefined || entry.maximum !== undefined) { + lines.push(`| Range | ${entry.minimum ?? "−∞"} … ${entry.maximum ?? "∞"} |`); + } + lines.push(`| Scope | ${SCOPE[entry.scope] ?? entry.scope} |`); + lines.push(`| Applies | ${APPLIES[entry.applies] ?? entry.applies} |`); + lines.push(`| Since | ${entry.since} |`); + if (entry.aliases?.length) { + lines.push(`| Old name | ${entry.aliases.map((alias) => `\`${alias}\``).join(", ")} (still read) |`); + } + if (entry.deprecationMessage) { + lines.push(`| Deprecated | ${entry.deprecationMessage} |`); + } + lines.push(""); + } +} + +lines.push("## Language"); +lines.push(""); +lines.push("`mcpp.ui.language` overrides the language of **runtime messages and this extension's"); +lines.push("panels**. The command palette and the Settings UI always follow the editor's own"); +lines.push("language: VS Code resolves `package.nls.*` once at startup, so a manual override"); +lines.push("cannot reach them. `auto` (the default) follows the editor everywhere."); +lines.push(""); + +const target = path.join(root, "docs", "settings.md"); +fs.mkdirSync(path.dirname(target), { recursive: true }); +fs.writeFileSync(target, `${lines.join("\n")}`); +console.log(`generate-settings-docs: wrote ${path.relative(root, target)} (${registry.settings.length} settings)`); diff --git a/mide-plugins/vscode/tools/l10n-check.mjs b/mide-plugins/vscode/tools/l10n-check.mjs new file mode 100644 index 0000000..163eb89 --- /dev/null +++ b/mide-plugins/vscode/tools/l10n-check.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Localization gates. Exits 1 on any of: + * + * 1. a runtime string used through `t("…")` in `src/**` has no entry in + * `data/i18n/zh-cn.json` (a new hardcoded string is also a new *missing* + * translation, which is what stops the drift); + * 2. `package.nls.json` and `package.nls.zh-cn.json` do not have identical key + * sets (a translated key that no longer exists is dead weight, a missing one + * shows English to a Chinese user); + * 3. a `%key%` reference in `package.json` is absent from `package.nls.json`. + * + * Unused translations are reported as warnings only: a string may legitimately + * be waiting for the feature that uses it. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const problems = []; +const warnings = []; + +function readJson(file) { + if (!fs.existsSync(file)) { + problems.push(`${path.relative(root, file)}: missing`); + return undefined; + } + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (error) { + problems.push(`${path.relative(root, file)}: invalid JSON (${error.message})`); + return undefined; + } +} + +function walk(dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full, out); + else if (entry.name.endsWith(".ts")) out.push(full); + } + return out; +} + +// ---------------------------------------------------------------- 1. runtime strings +// Two shapes count as "used": a literal `t("…")`, and a deferred label key +// `{ key: "…" }` — the tree models carry those and `treeProvider.resolveLabel` +// translates them at render time, which a scan for `t(` cannot see. +const USAGE = /\bt\(\s*"((?:[^"\\]|\\.)*)"/g; +const LABEL_KEY = /\bkey:\s*"((?:[^"\\]|\\.)*)"/g; +// `{ key: "…" }` is only a translatable label in the tree models. Elsewhere — +// `mcppls/contract.ts` — the same shape holds capability identifiers, which are +// protocol names and must never be translated. +const LABEL_KEY_FILES = [path.join(root, "src", "views")]; +const used = new Map(); +for (const file of walk(path.join(root, "src"))) { + const text = fs.readFileSync(file, "utf8"); + const patterns = [USAGE]; + if (LABEL_KEY_FILES.some((dir) => file.startsWith(dir))) { + patterns.push(LABEL_KEY); + } + for (const pattern of patterns) { + for (const match of text.matchAll(pattern)) { + const key = match[1].replace(/\\"/g, '"').replace(/\\n/g, "\n"); + if (!used.has(key)) used.set(key, path.relative(root, file)); + } + } +} + +const zhRuntime = readJson(path.join(root, "data", "i18n", "zh-cn.json")) ?? {}; +for (const [key, where] of used) { + if (!(key in zhRuntime)) { + problems.push(`data/i18n/zh-cn.json: missing translation for ${JSON.stringify(key)} (used in ${where})`); + } +} +for (const key of Object.keys(zhRuntime)) { + if (!used.has(key)) warnings.push(`data/i18n/zh-cn.json: unused string ${JSON.stringify(key)}`); +} + +// ------------------------------------------------- 2. package.nls key sets +const nlsEn = readJson(path.join(root, "package.nls.json")) ?? {}; +const nlsZh = readJson(path.join(root, "package.nls.zh-cn.json")) ?? {}; +const enKeys = new Set(Object.keys(nlsEn)); +const zhKeys = new Set(Object.keys(nlsZh)); +for (const key of enKeys) { + if (!zhKeys.has(key)) problems.push(`package.nls.zh-cn.json: missing key ${JSON.stringify(key)}`); +} +for (const key of zhKeys) { + if (!enKeys.has(key)) problems.push(`package.nls.zh-cn.json: key ${JSON.stringify(key)} is not in package.nls.json`); +} + +// --------------------------------------------- 3. %key% references resolve +const manifest = readJson(path.join(root, "package.json")) ?? {}; +const references = new Set(); +(function scan(value) { + if (typeof value === "string") { + for (const match of value.matchAll(/%([^%]+)%/g)) references.add(match[1]); + } else if (Array.isArray(value)) { + value.forEach(scan); + } else if (value && typeof value === "object") { + Object.values(value).forEach(scan); + } +})(manifest); +for (const key of references) { + if (!enKeys.has(key)) problems.push(`package.json: %${key}% has no entry in package.nls.json`); +} + +for (const warning of warnings) console.warn(`warning: ${warning}`); +if (problems.length > 0) { + for (const problem of problems) console.error(`error: ${problem}`); + console.error(`l10n-check: ${problems.length} problem(s)`); + process.exit(1); +} +console.log(`l10n-check: ok (${used.size} runtime string(s), ${enKeys.size} manifest key(s))`); diff --git a/mide-plugins/vscode/tools/review-libraries.mjs b/mide-plugins/vscode/tools/review-libraries.mjs new file mode 100644 index 0000000..3c4de2e --- /dev/null +++ b/mide-plugins/vscode/tools/review-libraries.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** Build and launch a real-CLI Libraries review window without touching the normal VS Code profile. */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const args = process.argv.slice(2); +const code = process.env.MCPP_DEV_CODE || "code"; +const project = path.join(root, ".dev-profile/library-review"); +const userData = path.join(root, ".dev-profile/library-review-user-data"); +const extensions = path.join(root, ".dev-profile/extensions"); +function run(executable, argv) { + const result = spawnSync(executable, argv, { cwd: root, stdio: "inherit" }); + if (result.error) { throw result.error; } + if (result.status !== 0) { throw new Error(`${executable} exited with ${result.status}`); } +} +function createOnce(file, text) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + if (!fs.existsSync(file)) { fs.writeFileSync(file, text); } +} +if (!args.includes("--no-package")) { run("npm", ["run", "package"]); } +const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version; +const vsix = path.join(root, `mcpp-vscode-${version}.vsix`); +if (!fs.existsSync(vsix)) { throw new Error(`Package missing: ${vsix}. Run without --no-package.`); } +createOnce(path.join(project, "mcpp.toml"), '[package]\nname = "library-review"\nversion = "0.1.0"\n\n[dependencies.compat]\nargparse = "3.2"\n'); +createOnce(path.join(project, "secondary/mcpp.toml"), '[package]\nname = "secondary-review"\nversion = "0.1.0"\n'); +createOnce(path.join(project, ".vscode/settings.json"), JSON.stringify({ + "mcpp.path": "mcpp", "mcpp.library.indexPath": "", "mcpp.library.networkSearch": false, + "mcpp.ui.language": "zh-cn", "mcpp.diagnostics.selfCheckOnStartup": false, +}, null, 2)); +createOnce(path.join(userData, "User/settings.json"), JSON.stringify({ + "workbench.startupEditor": "none", "window.title": "Libraries review · ${rootName}", + "git.openRepositoryInParentFolders": "never", "workbench.colorTheme": "Default Dark Modern", + "workbench.secondarySideBar.defaultVisibility": "hidden", "extensions.autoUpdate": false, +}, null, 2)); +run(code, ["--extensions-dir", extensions, "--user-data-dir", userData, "--install-extension", vsix, "--force"]); +const launch = ["--new-window", "--extensions-dir", extensions, "--user-data-dir", userData, "--skip-welcome", "--disable-workspace-trust"]; +const debugIndex = args.indexOf("--debug-port"); +if (debugIndex >= 0) { launch.push(`--remote-debugging-port=${Number(args[debugIndex + 1])}`); } +launch.push(project, path.join(project, "mcpp.toml")); +run(code, launch); +console.log(`\nLibraries review: ${project}\nReal mcpp CLI, official local index, isolated VS Code profile.\nOpen MC++ → Libraries. Try compat.zlib and the dependency-type arrow.\nExisting scratch edits and profile preferences are preserved.`); diff --git a/mide-plugins/vscode/tools/stage-backend.mjs b/mide-plugins/vscode/tools/stage-backend.mjs new file mode 100644 index 0000000..453c423 --- /dev/null +++ b/mide-plugins/vscode/tools/stage-backend.mjs @@ -0,0 +1,31 @@ +/** + * The backend the package carries (mcpp design 2026-10-28 part 2 §15): the ok-image bundle as + * backend/mcpp-ide.okb, one for every system, and every system's runner as + * backend/okrun/-/okrun[.exe]. Taken from MCPP_IDE_OKB (a file) and MCPP_IDE_OKRUN (a + * directory laid out -/), which CI's backend job produces; without them there is nothing + * to package. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const okb = process.env.MCPP_IDE_OKB; +const okrun = process.env.MCPP_IDE_OKRUN; +if (!okb || !fs.statSync(okb, { throwIfNoEntry: false })?.isFile()) { + console.error("stage-backend: MCPP_IDE_OKB names no file (the backend's ok-image bundle)"); + process.exit(1); +} +if (!okrun || !fs.statSync(okrun, { throwIfNoEntry: false })?.isDirectory()) { + console.error("stage-backend: MCPP_IDE_OKRUN names no directory (okrun for each -)"); + process.exit(1); +} +const out = path.join(root, "backend"); +fs.rmSync(out, { recursive: true, force: true }); +fs.mkdirSync(out, { recursive: true }); +fs.copyFileSync(okb, path.join(out, "mcpp-ide.okb")); +fs.cpSync(okrun, path.join(out, "okrun"), { recursive: true }); +for (const dir of fs.readdirSync(path.join(out, "okrun"))) { + for (const file of fs.readdirSync(path.join(out, "okrun", dir))) fs.chmodSync(path.join(out, "okrun", dir, file), 0o755); +} +console.log(`stage-backend: ${path.relative(root, out)}/mcpp-ide.okb and okrun for ${fs.readdirSync(path.join(out, "okrun")).join(", ")}`); diff --git a/mide-plugins/vscode/tools/test-graphs-ui.mjs b/mide-plugins/vscode/tools/test-graphs-ui.mjs new file mode 100644 index 0000000..6b1469c --- /dev/null +++ b/mide-plugins/vscode/tools/test-graphs-ui.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** Real Chromium + shipped Mermaid and canvas. No VS Code installation is needed. */ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {createRequire} from 'node:module'; +const require = createRequire(import.meta.url); +const {chromium} = require('playwright'); +const {graphPanelHtml} = require('../dist/src/graphs/panelHtml.js'); +const {visibleGraph, mermaidGraph, groupedGraph, INITIAL_GRAPH_BUDGET} = require('../dist/src/graphs/model.js'); +const {decodeGraphExport} = require('../dist/src/graphs/export.js'); +const artifacts = path.resolve('.dev-profile/graph-ui-artifacts'); +await fs.mkdir(artifacts, {recursive:true}); +const browser = await chromium.launch({headless:true, ...(process.env.MCPP_UI_CHROMIUM ? {executablePath:process.env.MCPP_UI_CHROMIUM} : {}), args:['--no-sandbox']}); +const checks=[]; +try { + const page = await browser.newPage({viewport:{width:1440,height:1000}}); + const errors=[]; page.on('pageerror', error=>errors.push(error.message)); + let html = graphPanelHtml({nonce:'graph-test',cspSource:'',styleUri:''},'', ''); + html = html.replace(/]*>/,'').replace(/]*>/g,'').replace(/ + + + +
      +
      +
      + + diff --git a/webui/index.html b/webui/index.html deleted file mode 100644 index 62021e6..0000000 --- a/webui/index.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - mcpp - - -
      - - - - - - - - - - - - - diff --git a/webui/js/app.js b/webui/js/app.js deleted file mode 100644 index 29b04c1..0000000 --- a/webui/js/app.js +++ /dev/null @@ -1,22 +0,0 @@ -// The panel's start: tell the host it is ready, receive which panel to show and with what (the -// project root, a graph kind, a package id), the language's strings and the editor's look, then -// mount it. A later `init` replaces the panel (an editor reuses one view for several). -(function (root) { - "use strict"; - const ui = root.McppUi; - let unmount = null; - ui.on("$init", (m) => { - ui.setBundle(m.bundle || {}); - if (m.theme && typeof m.theme === "object") - for (const [k, v] of Object.entries(m.theme)) root.document.documentElement.style.setProperty(k, v); - const el = root.document.getElementById("app"); - if (typeof unmount === "function") unmount(); - ui.clear(el); - const panel = ui.panels[m.panel]; - if (!panel) { el.appendChild(ui.empty(ui.t("Unknown panel {0}", m.panel))); return; } - root.document.title = panel.title(m.params || {}); - unmount = panel.mount(el, m.params || {}) || null; - }); - if (root.document.readyState === "loading") root.document.addEventListener("DOMContentLoaded", ui.ready); - else ui.ready(); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/core.js b/webui/js/core.js deleted file mode 100644 index c68b93a..0000000 --- a/webui/js/core.js +++ /dev/null @@ -1,84 +0,0 @@ -// The panels' shared helpers: the namespace, the runtime strings, formatting, and a small DOM -// builder. Classic scripts on one global (`McppUi`), so the same files load in a VS Code webview and -// in a JCEF page opened from disk, with no bundler between the repository and either editor. -(function (root) { - "use strict"; - const ui = (root.McppUi = root.McppUi || /** @type {McppUiApi} */ ({})); - ui.panels = ui.panels || {}; - - // The runtime strings: English keys, the bundle the backend answers for the session's language. - let bundle = {}; - ui.setBundle = (b) => { bundle = b && typeof b === "object" ? b : {}; }; - ui.t = (key, ...args) => { - let text = Object.prototype.hasOwnProperty.call(bundle, key) ? bundle[key] : key; - args.forEach((a, i) => { text = text.split(`{${i}}`).join(String(a)); }); - return text; - }; - - ui.formatBytes = (bytes) => { - const units = ["B", "KiB", "MiB", "GiB", "TiB"]; - let v = Math.max(0, Number(bytes) || 0); - let u = 0; - while (v >= 1024 && u + 1 < units.length) { v /= 1024; ++u; } - return u === 0 ? `${Math.round(v)} B` : `${v.toFixed(1)} ${units[u]}`; - }; - - // "3 days ago" from seconds (or milliseconds, when the value is that large) since the epoch. - ui.ago = (when, now = Date.now()) => { - if (when === undefined || when === null) return ""; - let ms = Number(when); - if (ms < 1e12) ms *= 1000; - const s = Math.max(0, Math.round((now - ms) / 1000)); - if (s < 60) return ui.t("just now"); - if (s < 3600) return ui.t("{0} min ago", Math.round(s / 60)); - if (s < 86400) return ui.t("{0} h ago", Math.round(s / 3600)); - return ui.t("{0} days ago", Math.round(s / 86400)); - }; - - ui.debounce = (fn, ms) => { - let timer; - return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; - }; - - // h("div.row#id", {onclick, title, ...}, children...) -- children are nodes, strings or arrays. - ui.h = (spec, props, ...children) => { - const doc = root.document; - const m = /^([a-z0-9-]+)?((?:[.#][\w-]+)*)$/i.exec(spec) || []; - const el = doc.createElement(m[1] || "div"); - for (const part of (m[2] || "").match(/[.#][\w-]+/g) || []) { - if (part[0] === ".") el.classList.add(part.slice(1)); else el.id = part.slice(1); - } - if (props && (typeof props !== "object" || props.nodeType || Array.isArray(props))) { children.unshift(props); props = null; } - for (const [k, v] of Object.entries(props || {})) { - if (v === undefined || v === null || v === false) continue; - if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v); - else if (k === "class") el.className = v; - else if (k === "style" && typeof v === "object") Object.assign(el.style, v); - else if (k === "dataset") Object.assign(el.dataset, v); - else if (k in el && typeof v !== "string") el[k] = v; - else el.setAttribute(k, v === true ? "" : String(v)); - } - const add = (c) => { - if (c === undefined || c === null || c === false) return; - if (Array.isArray(c)) { c.forEach(add); return; } - el.appendChild(c.nodeType ? c : doc.createTextNode(String(c))); - }; - children.forEach(add); - return el; - }; - - ui.clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); return el; }; - - // A status badge: ok | warning | error | info | running. - ui.badge = (state, text) => ui.h(`span.badge.badge-${state}`, text); - - ui.button = (text, onclick, opts = {}) => - ui.h(`button${opts.secondary ? ".secondary" : ""}`, { onclick, disabled: !!opts.disabled, title: opts.title }, text); - - ui.section = (title, ...children) => ui.h("section.card", ui.h("h2", title), ...children); - - ui.empty = (text) => ui.h("p.empty", text); - - ui.errorBox = (error) => - ui.h("div.error", ui.h("strong", ui.t("mcpp could not answer")), ui.h("pre", (error && (error.message || String(error))) || "")); -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/graph-layout.js b/webui/js/graph-layout.js deleted file mode 100644 index aeb8e5f..0000000 --- a/webui/js/graph-layout.js +++ /dev/null @@ -1,119 +0,0 @@ -// A layered layout for the dependency graphs: every edge points from a node to one it depends on, -// layers are assigned by longest path from the roots (cycles broken at the edge that closes them), -// and the order within a layer is improved by barycentre sweeps. Pure: nodes and edges in, positions -// out; the graph panel draws them. -(function (root) { - "use strict"; - const ui = (root.McppUi = root.McppUi || /** @type {McppUiApi} */ ({})); - - // nodes: [{id}], edges: [{from, to}] -> {nodes: [{id, x, y, layer}], edges: [{from, to, points}], width, height} - function layout(nodes, edges, opts = {}) { - const nodeW = opts.nodeWidth || 180, nodeH = opts.nodeHeight || 28, gapX = opts.gapX || 24, gapY = opts.gapY || 64; - const ids = nodes.map((n) => n.id); - const index = new Map(ids.map((id, i) => [id, i])); - const out = ids.map(() => []), inn = ids.map(() => []); - const kept = []; - for (const e of edges) { - const a = index.get(e.from), b = index.get(e.to); - if (a === undefined || b === undefined || a === b) continue; - kept.push([a, b]); - } - // Break cycles: a DFS marks back edges, which are drawn but do not constrain the layers. - const state = new Array(ids.length).fill(0); - const back = new Set(); - const adj = ids.map(() => []); - kept.forEach(([a, b], k) => adj[a].push([b, k])); - for (let s = 0; s < ids.length; ++s) { - if (state[s]) continue; - const stack = [[s, 0]]; - state[s] = 1; - while (stack.length) { - const top = stack[stack.length - 1]; - const [v, i] = top; - if (i < adj[v].length) { - top[1]++; - const [w, k] = adj[v][i]; - if (state[w] === 1) back.add(k); - else if (state[w] === 0) { state[w] = 1; stack.push([w, 0]); } - } else { state[v] = 2; stack.pop(); } - } - } - kept.forEach(([a, b], k) => { if (!back.has(k)) { out[a].push(b); inn[b].push(a); } }); - // Longest path from the roots (nodes nothing depends on): a dependency sits below its users. - const layer = new Array(ids.length).fill(0); - const indeg = inn.map((l) => l.length); - const queue = []; - indeg.forEach((d, i) => { if (d === 0) queue.push(i); }); - for (let q = 0; q < queue.length; ++q) { - const v = queue[q]; - for (const w of out[v]) { - layer[w] = Math.max(layer[w], layer[v] + 1); - if (--indeg[w] === 0) queue.push(w); - } - } - const layers = []; - layer.forEach((l, i) => { (layers[l] = layers[l] || []).push(i); }); - for (let l = 0; l < layers.length; ++l) if (!layers[l]) layers[l] = []; - // Barycentre sweeps, down then up. - const pos = new Array(ids.length).fill(0); - const place = () => layers.forEach((row) => row.forEach((v, i) => { pos[v] = i; })); - place(); - const sweep = (rows, neighbours) => { - for (const row of rows) { - const key = new Map(row.map((v) => { - const ns = neighbours[v]; - return [v, ns.length ? ns.reduce((s, w) => s + pos[w], 0) / ns.length : pos[v]]; - })); - row.sort((a, b) => key.get(a) - key.get(b) || a - b); - row.forEach((v, i) => { pos[v] = i; }); - } - }; - for (let k = 0; k < (opts.sweeps || 4); ++k) { - sweep(layers.slice(1), inn); - sweep(layers.slice(0, -1).reverse(), out); - } - const widest = Math.max(1, ...layers.map((r) => r.length)); - const width = widest * (nodeW + gapX) + gapX; - const placed = nodes.map((n, i) => { - const row = layers[layer[i]]; - const offset = (width - row.length * (nodeW + gapX) - gapX) / 2; - return Object.assign({}, n, { - layer: layer[i], - x: offset + gapX + pos[i] * (nodeW + gapX), - y: gapY / 2 + layer[i] * (nodeH + gapY), - width: nodeW, - height: nodeH, - }); - }); - const routed = kept.map(([a, b], k) => { - const p = placed[a], q = placed[b]; - return { - from: ids[a], to: ids[b], back: back.has(k), - points: [[p.x + nodeW / 2, p.y + nodeH], [q.x + nodeW / 2, q.y]], - }; - }); - return { nodes: placed, edges: routed, width, height: layers.length * (nodeH + gapY) + gapY / 2 }; - } - - // The part of a graph around one node: it, what it depends on and what depends on it, `depth` - // steps each way. Large module graphs are read this way rather than all at once. - function neighbourhood(nodes, edges, focus, depth = 1) { - const keep = new Set([focus]); - let frontier = new Set([focus]); - for (let d = 0; d < depth; ++d) { - const next = new Set(); - for (const e of edges) { - if (frontier.has(e.from) && !keep.has(e.to)) next.add(e.to); - if (frontier.has(e.to) && !keep.has(e.from)) next.add(e.from); - } - next.forEach((v) => keep.add(v)); - frontier = next; - } - return { - nodes: nodes.filter((n) => keep.has(n.id)), - edges: edges.filter((e) => keep.has(e.from) && keep.has(e.to)), - }; - } - - ui.graphLayout = { layout, neighbourhood }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/host.js b/webui/js/host.js deleted file mode 100644 index 315b7df..0000000 --- a/webui/js/host.js +++ /dev/null @@ -1,62 +0,0 @@ -// The bridge to the editor that hosts the panel. The panel speaks the backend's protocol through -// it (`rpc`), receives the backend's notifications (`on`), and asks the editor for what only the -// editor can do (`host`: open a file, confirm, write a setting, start a terminal). -// -// VS Code: `acquireVsCodeApi().postMessage` out, `window` "message" events in. -// JetBrains (JCEF): `window.mcppHostSend(text)` out (a JBCefJSQuery the plugin injects), and the -// plugin calls `window.mcppHostReceive(message)` in. -(function (root) { - "use strict"; - const ui = (root.McppUi = root.McppUi || /** @type {McppUiApi} */ ({})); - let send; - const vscode = typeof root.acquireVsCodeApi === "function" ? root.acquireVsCodeApi() : undefined; - if (vscode) send = (m) => vscode.postMessage(m); - else send = (m) => { if (typeof root.mcppHostSend === "function") root.mcppHostSend(JSON.stringify(m)); else queue.push(m); }; - const queue = []; - let nextId = 1; - const pending = new Map(); - const listeners = new Map(); - - function receive(m) { - if (typeof m === "string") { try { m = JSON.parse(m); } catch { return; } } - if (!m || typeof m !== "object") return; - if (m.type === "rpcResult" || m.type === "hostResult") { - const p = pending.get(m.id); pending.delete(m.id); - if (p) p.resolve(m.result); - } else if (m.type === "rpcError" || m.type === "hostError") { - const p = pending.get(m.id); pending.delete(m.id); - if (p) p.reject(Object.assign(new Error((m.error && m.error.message) || "failed"), { data: m.error && m.error.data, code: m.error && m.error.code })); - } else if (m.type === "notify") { - for (const fn of listeners.get(m.method) || []) fn(m.params); - for (const fn of listeners.get("*") || []) fn(m.method, m.params); - } else if (m.type === "init") { - for (const fn of listeners.get("$init") || []) fn(m); - } - } - if (vscode) root.addEventListener("message", (e) => receive(e.data)); - root.mcppHostReceive = receive; - // The JCEF query is injected after the page loads: whatever was sent before it goes then. - root.mcppHostAttached = () => { while (queue.length) send(queue.shift()); }; - - function request(type, fields) { - const id = nextId++; - return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject }); - send(Object.assign({ type, id }, fields)); - }); - } - - ui.rpc = (method, params = {}) => request("rpc", { method, params }); - ui.host = (action, params = {}) => request("host", { action, params }); - ui.on = (method, fn) => { - if (!listeners.has(method)) listeners.set(method, new Set()); - listeners.get(method).add(fn); - return () => listeners.get(method).delete(fn); - }; - ui.ready = () => send({ type: "ready" }); - ui.state = { - get: () => (vscode ? vscode.getState() : root.__mcppState) || {}, - set: (s) => { if (vscode) vscode.setState(s); else root.__mcppState = s; }, - }; - ui.editor = vscode ? "vscode" : "jetbrains"; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/cache.js b/webui/js/panels/cache.js deleted file mode 100644 index 6337ac7..0000000 --- a/webui/js/panels/cache.js +++ /dev/null @@ -1,72 +0,0 @@ -// The cache panel: what mcpp's shared build cache holds (`mcpp cache list`), by kind, by package and -// by age, and the cleanups. A cleanup is a plan the backend makes (its argv, its danger level, its -// preview); the editor confirms it natively, at the level the plan asks for. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - // plan -> preview -> the editor's confirmation -> apply. Used by the panel and nothing else: the - // editors' own commands run the same three steps against the same plan. - async function cleanup(action, rootDir) { - const plan = await ui.rpc("cache/plan", { action, root: rootDir }); - if (plan.refusedUntrusted) { - await ui.host("notify", { level: "warning", message: t("this action needs a trusted workspace") }); - return false; - } - let confirmation = {}; - if (plan.level > 0) { - let preview = ""; - if (plan.preview && plan.preview.source === "mcpp") preview = plan.preview.text || ""; - else if (plan.preview && plan.preview.entries) - preview = t("About {0} would be freed ({1} entries).", plan.preview.bytesText, plan.preview.count); - const answer = await ui.host("confirm", { title: plan.title, detail: plan.detail, preview, level: plan.level, acknowledge: plan.acknowledge }); - if (!answer || !answer.confirmed) return false; - confirmation = { confirmed: true, previewShown: !!plan.preview, acknowledged: !!answer.acknowledged }; - } - await ui.rpc("cache/apply", { action, root: rootDir, confirmation }); - return true; - } - ui.cleanup = cleanup; - - function bars(rows, total, label) { - return h("table.bars", rows.map((r) => h("tr", - h("td", label(r)), - h("td.num", ui.formatBytes(r.bytes)), - h("td.bar", h("div", { style: { width: `${total ? Math.max(1, Math.round((100 * r.bytes) / total)) : 0}%` } }))))); - } - - ui.panels.cache = { - title: () => t("Cache"), - mount(el, params) { - const rootDir = params.root; - async function render() { - ui.clear(el).appendChild(h("p.muted", t("Loading…"))); - let inv; - try { inv = await ui.rpc("cache/inventory", { root: rootDir }); } catch (e) { ui.clear(el).appendChild(ui.errorBox(e)); return; } - ui.clear(el); - el.append( - h("header.panel-head", - h("h1", t("Cache")), - h("p", h("strong", inv.totalText), " ", t("in {0} entries", inv.totalEntries), inv.root ? h("span.muted", ` · ${inv.root}`) : null), - h("div.actions", ui.button(t("Refresh"), render, { secondary: true }))), - ui.section(t("By kind"), bars(inv.byKind, inv.totalBytes, (r) => `${r.kind} (${r.entries})`)), - ui.section(t("Largest packages"), inv.topLabels.length ? bars(inv.topLabels, inv.totalBytes, (r) => r.label) : ui.empty(t("none"))), - ui.section(t("By last use"), bars(inv.ageBuckets, inv.totalBytes, (b) => - b.toDays === undefined ? t("older than {0} days", b.fromDays) : t("{0}–{1} days", b.fromDays, b.toDays))), - inv.incomplete.length ? ui.section(t("Incomplete entries"), h("ul.list", inv.incomplete.map((e) => h("li", e.label || e.key)))) : null, - ui.section(t("Clean up"), - h("div.actions.wrap", - [["stale", t("Clean stale artifacts")], ["project", t("Clean project artifacts")], ["cacheGc", t("Collect to a budget")], - ["cachePrune", t("Prune by age")], ["cacheDeps", t("Drop package entries")], ["cacheStd", t("Drop standard library entries")], - ["cacheVerify", t("Verify")], ["cacheLegacy", t("Remove the pre-v1 cache")], ["cacheAll", t("Drop everything")]] - .map(([action, label]) => ui.button(label, async () => { - try { if (await cleanup(action, rootDir)) setTimeout(render, 500); } catch (e) { el.prepend(ui.errorBox(e)); } - }, { secondary: action !== "stale", disabled: action === "project" && !rootDir }))))); - } - const off = ui.on("job/finished", (p) => { if (p.kind === "clean" || p.kind === "command") render(); }); - render(); - return off; - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/graph.js b/webui/js/panels/graph.js deleted file mode 100644 index 88ce573..0000000 --- a/webui/js/panels/graph.js +++ /dev/null @@ -1,113 +0,0 @@ -// The dependency graphs: packages (which set sees which) and modules (who provides, who imports), -// from mcpp's build database through the backend's view models. Drawn as SVG with a layered layout; -// a large graph opens on the neighbourhood of one node, chosen by search or by a click. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - const SVG = "http://www.w3.org/2000/svg"; - const svg = (tag, attrs = {}, ...children) => { - const el = root.document.createElementNS(SVG, tag); - for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v)); - children.forEach((c) => c && el.appendChild(c)); - return el; - }; - // Beyond this many nodes the whole graph is shown only when asked for. - const WHOLE_LIMIT = 150; - - ui.panels.graph = { - title: (params) => (params.graph === "modules" ? t("Module graph") : t("Dependency graph")), - mount(el, params) { - const kind = params.graph === "modules" ? "modules" : "packages"; - const rootDir = params.root; - let data = { nodes: [], edges: [] }; - let focus = null, depth = 1, whole = false; - let zoom = 1, panX = 0, panY = 0; - const search = h("input", { type: "search", placeholder: t("Find a node") }); - const list = h("datalist#graph-nodes"); - search.setAttribute("list", "graph-nodes"); - const depthSel = h("select", [1, 2, 3].map((d) => h("option", { value: d }, t("{0} step(s) around", d)))); - const canvas = h("div.graph-canvas"); - const status = h("p.muted"); - - function draw() { - ui.clear(canvas); - let view = data; - if (focus) view = ui.graphLayout.neighbourhood(data.nodes, data.edges, focus, depth); - else if (data.nodes.length > WHOLE_LIMIT && !whole) { - status.textContent = t("{0} nodes: choose one to see its neighbourhood, or show the whole graph.", data.nodes.length); - canvas.appendChild(h("div.actions", ui.button(t("Show the whole graph"), () => { whole = true; draw(); }, { secondary: true }))); - return; - } - status.textContent = focus ? t("{0} and {1} node(s) around it", focus, view.nodes.length - 1) - : t("{0} nodes, {1} edges", view.nodes.length, view.edges.length); - const lay = ui.graphLayout.layout(view.nodes, view.edges, { nodeWidth: kind === "modules" ? 200 : 180 }); - const g = svg("g"); - const defs = svg("defs", {}, svg("marker", { id: "arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 6, markerHeight: 6, orient: "auto-start-reverse" }, - svg("path", { d: "M 0 0 L 10 5 L 0 10 z", class: "arrow" }))); - for (const e of lay.edges) { - const [[x1, y1], [x2, y2]] = e.points; - const my = (y1 + y2) / 2; - g.appendChild(svg("path", { d: `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`, class: e.back ? "edge back" : "edge", "marker-end": "url(#arrow)" })); - } - for (const n of lay.nodes) { - const label = n.id.length > 28 ? `${n.id.slice(0, 26)}…` : n.id; - const node = svg("g", { class: `node kind-${n.kind || "set"}${n.id === focus ? " focus" : ""}`, transform: `translate(${n.x},${n.y})`, tabindex: 0 }, - svg("rect", { width: n.width, height: n.height, rx: 4 }), - svg("text", { x: 8, y: n.height / 2 + 4 }, root.document.createTextNode(label))); - const title = svg("title"); - title.textContent = [n.id, n.source || "", n.set ? `(${n.set})` : ""].filter(Boolean).join("\n"); - node.appendChild(title); - node.addEventListener("click", () => { focus = n.id; draw(); }); - node.addEventListener("dblclick", () => { if (n.source) ui.host("openFile", { path: n.source }); }); - node.addEventListener("keydown", (ev) => { if (ev.key === "Enter") { focus = n.id; draw(); } }); - g.appendChild(node); - } - const s = svg("svg", { width: "100%", height: "100%" }, defs, g); - const apply = () => g.setAttribute("transform", `translate(${panX},${panY}) scale(${zoom})`); - zoom = Math.min(1, (canvas.clientWidth || 800) / lay.width, (canvas.clientHeight || 600) / lay.height) || 1; - panX = Math.max(0, ((canvas.clientWidth || 800) - lay.width * zoom) / 2); panY = 8; - apply(); - s.addEventListener("wheel", (ev) => { - ev.preventDefault(); - const next = Math.max(0.05, Math.min(4, zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1))); - const r = s.getBoundingClientRect(), x = ev.clientX - r.left, y = ev.clientY - r.top; - panX = x - ((x - panX) * next) / zoom; panY = y - ((y - panY) * next) / zoom; zoom = next; apply(); - }, { passive: false }); - let drag = null; - s.addEventListener("pointerdown", (ev) => { if (ev.target === s) { drag = [ev.clientX - panX, ev.clientY - panY]; s.setPointerCapture(ev.pointerId); } }); - s.addEventListener("pointermove", (ev) => { if (drag) { panX = ev.clientX - drag[0]; panY = ev.clientY - drag[1]; apply(); } }); - s.addEventListener("pointerup", () => { drag = null; }); - canvas.appendChild(s); - } - - async function load() { - status.textContent = t("Loading…"); - try { data = await ui.rpc(kind === "modules" ? "graph/modules" : "graph/packages", { root: rootDir }); } - catch (e) { status.textContent = ""; ui.clear(canvas).appendChild(ui.errorBox(e)); return; } - ui.clear(list); - data.nodes.forEach((n) => list.appendChild(h("option", { value: n.id }))); - if (params.focus) focus = params.focus; - draw(); - } - search.addEventListener("change", () => { if (data.nodes.some((n) => n.id === search.value)) { focus = search.value; draw(); } }); - depthSel.addEventListener("change", () => { depth = Number(depthSel.value); draw(); }); - el.append( - h("header.panel-head", - h("h1", kind === "modules" ? t("Module graph") : t("Dependency graph")), - h("div.toolbar", search, list, depthSel, - ui.button(t("Overview"), () => { focus = null; draw(); }, { secondary: true }), - ui.button(t("Refresh"), load, { secondary: true }), - ui.button(t("Copy as Mermaid"), () => { - const lines = ["graph TD"]; - data.edges.forEach((e) => lines.push(` ${JSON.stringify(e.from)} --> ${JSON.stringify(e.to)}`.replace(/"/g, ""))); - ui.host("copy", { text: lines.join("\n") }); - }, { secondary: true })), - status), - canvas); - const off = ui.on("result/changed", (p) => { if (p.root === rootDir) load(); }); - load(); - return off; - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/libraries.js b/webui/js/panels/libraries.js deleted file mode 100644 index 71f54f8..0000000 --- a/webui/js/panels/libraries.js +++ /dev/null @@ -1,87 +0,0 @@ -// The libraries panel: the packages of every index mcpp reads (`mcpp search`), filtered and sorted -// by the backend, and one package's page. "Add" runs `mcpp add` on the project, which writes -// mcpp.toml; nothing here edits the manifest itself. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - function detail(el, pkg, rootDir, back) { - ui.clear(el); - const versions = pkg.versions || []; - const select = h("select", versions.map((v) => h("option", { value: v }, v))); - el.append( - h("header.panel-head", - h("a.back", { href: "#", onclick: (e) => { e.preventDefault(); back(); } }, `← ${t("All packages")}`), - h("h1", pkg.id), - h("p", pkg.description || ""), - h("dl.facts", - h("dt", t("Index")), h("dd", pkg.index || ""), - h("dt", t("Dependency name")), h("dd", h("code", pkg.dependencyName || pkg.name)), - h("dt", t("Licences")), h("dd", (pkg.licenses || []).join(", ") || "—"), - pkg.repo ? [h("dt", t("Repository")), h("dd", h("a", { href: "#", onclick: (e) => { e.preventDefault(); ui.host("openExternal", { url: pkg.repo }); } }, pkg.repo))] : null), - h("div.actions", - select, - ui.button(t("Add to mcpp.toml"), async () => { - if (!rootDir) { await ui.host("notify", { level: "warning", message: t("No mcpp project in this workspace") }); return; } - await ui.rpc("library/add", { root: rootDir, dependency: pkg.dependencyName || pkg.name, version: select.value }); - }, { disabled: !rootDir }))), - ui.section(t("Versions"), h("ul.list", versions.map((v) => h("li", h("code", v)))))); - } - - ui.panels.libraries = { - title: () => t("Libraries"), - mount(el, params) { - const rootDir = params.root; - const saved = ui.state.get().libraries || {}; - const search = h("input", { type: "search", placeholder: t("Search packages"), value: saved.keyword || "" }); - const sort = h("select", h("option", { value: "name" }, t("by name")), h("option", { value: "versions" }, t("by versions"))); - sort.value = saved.sort || "name"; - const index = h("select", h("option", { value: "" }, t("every index"))); - const results = h("div.results"); - const status = h("p.muted"); - let lastQuery = 0; - - async function query(refresh = false) { - const id = ++lastQuery; - ui.state.set(Object.assign(ui.state.get(), { libraries: { keyword: search.value, sort: sort.value } })); - status.textContent = t("Searching…"); - let r; - try { - r = await ui.rpc("library/search", { keyword: search.value, sort: sort.value, index: index.value, limit: 300, refresh, root: rootDir }); - } catch (e) { - if (id === lastQuery) { status.textContent = ""; ui.clear(results).appendChild(ui.errorBox(e)); } - return; - } - if (id !== lastQuery) return; - if (index.options.length === 1) for (const name of r.indexes || []) index.appendChild(h("option", { value: name }, name)); - status.textContent = t("{0} packages", r.total); - ui.clear(results); - if (!r.packages.length) { results.appendChild(ui.empty(t("No package matches."))); return; } - results.appendChild(h("ul.list.packages", r.packages.map((p) => - h("li.package", { onclick: () => detail(el, p, rootDir, () => { ui.clear(el); mountList(); }) }, - h("div", h("strong", p.id), h("span.muted", ` ${p.latest || ""}`)), - h("div.muted", p.description || ""))))); - } - - function mountList() { - el.append( - h("header.panel-head", - h("h1", t("Libraries")), - h("div.toolbar", search, sort, index, - ui.button(t("Refresh the index"), async () => { await ui.rpc("library/updateIndex", { root: rootDir }); }, { secondary: true }))), - status, results); - query(); - } - search.addEventListener("input", ui.debounce(() => query(), 200)); - sort.addEventListener("change", () => query()); - index.addEventListener("change", () => query()); - const off = ui.on("job/finished", (p) => { if ((p.envelope && p.envelope.kind) || p.kind === "command") query(true); }); - if (params.id) { - ui.rpc("library/search", { id: params.id, root: rootDir }).then((p) => detail(el, p, rootDir, () => { ui.clear(el); mountList(); }), - (e) => el.appendChild(ui.errorBox(e))); - } else mountList(); - return off; - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/pack.js b/webui/js/panels/pack.js deleted file mode 100644 index 8f20c6e..0000000 --- a/webui/js/panels/pack.js +++ /dev/null @@ -1,67 +0,0 @@ -// The pack panel: the formats mcpp can produce for this project (`mcpp pack --list-formats`, with -// the plugins that provide them), the mode, the target, and the result of a pack: what was written -// and where. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - ui.panels.pack = { - title: () => t("Pack"), - mount(el, params) { - const rootDir = params.root; - const format = h("select"); - const mode = h("select", h("option", { value: "" }, t("the project's default"))); - const linkage = h("select", h("option", { value: "" }, t("the project's default"))); - const profile = h("select", ["release", "dev"].map((p) => h("option", { value: p }, p))); - const target = h("input", { type: "text", placeholder: t("this machine") }); - const output = h("pre.log"); - const result = h("div"); - let jobId = null; - const go = ui.button(t("Pack"), async () => { - ui.clear(output); ui.clear(result); - try { - const r = await ui.rpc("pack/start", { root: rootDir, pkgFormat: format.value || undefined, mode: mode.value || undefined, - linkage: linkage.value || undefined, profile: profile.value, target: target.value }); - jobId = r.jobId; go.disabled = true; - } catch (e) { result.appendChild(ui.errorBox(e)); } - }); - const offs = [ - ui.on("job/output", (p) => { if (p.jobId === jobId) output.textContent += p.text; }), - ui.on("job/finished", (p) => { - if (p.jobId !== jobId) return; - go.disabled = false; jobId = null; - ui.clear(result); - const data = (p.envelope && p.envelope.data) || {}; - result.appendChild(ui.section(t("Result"), - h("p", ui.badge(p.status === "ok" ? "ok" : "error", p.status)), - (p.diagnostics || []).map((d) => h("p.error", d.message)), - data.outputs || data.artifacts ? h("ul.list", (data.outputs || data.artifacts).map((o) => { - const path = typeof o === "string" ? o : o.path; - return h("li", h("code", path), ui.button(t("Reveal"), () => ui.host("reveal", { path }), { secondary: true })); - })) : h("pre", JSON.stringify(data, null, 2)))); - }), - ]; - el.append(h("header.panel-head", h("h1", t("Pack")), h("p.muted", rootDir || "")), - ui.section(t("What to produce"), - h("dl.facts", - h("dt", t("Format")), h("dd", format), - h("dt", t("Mode")), h("dd", mode), - h("dt", t("Linkage")), h("dd", linkage), - h("dt", t("Profile")), h("dd", profile), - h("dt", t("Target")), h("dd", target)), - h("div.actions", go)), - result, output); - ui.rpc("pack/formats", { root: rootDir }).then((r) => { - format.appendChild(h("option", { value: "" }, t("the project's default"))); - for (const f of (r && r.formats) || []) - format.appendChild(h("option", { value: f.name }, f.builtin ? f.name : `${f.name} (${(f.providers || []).join(", ")})`)); - for (const m of (r && r.modes) || []) mode.appendChild(h("option", { value: m }, m)); - for (const l of (r && r.linkages) || []) linkage.appendChild(h("option", { value: l }, l)); - linkage.disabled = !((r && r.linkages) || []).length; - }, (e) => result.appendChild(ui.errorBox(e))); - ui.host("settings", {}).then((s) => { if (s && s["mcpp.build.target"]) target.value = s["mcpp.build.target"]; }); - return () => offs.forEach((off) => off()); - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/project.js b/webui/js/panels/project.js deleted file mode 100644 index f623a91..0000000 --- a/webui/js/panels/project.js +++ /dev/null @@ -1,92 +0,0 @@ -// The project panel: what the project builds (executables, tests, libraries), for which target and -// platform, how its last build went, and the language service that answers its sources. The actions -// are the command table's: the editor runs them as it runs the same commands from its menus. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - function languageServiceCard(status, refresh) { - if (!status) return null; - const state = status.state === "ok" ? "ok" : status.state === "missing" ? "error" : "warning"; - return ui.section( - t("C++ Modules language service"), - h("p", ui.badge(state, status.state === "ok" ? `mcppls ${status.version}` : t("needs attention")), " ", status.message), - h("div.actions", - (status.actions || []).map((a) => ui.button(a.title, () => ui.host("languageService", { action: a.id }))), - ui.button(t("Check again"), refresh, { secondary: true }))); - } - - function list(title, rows, render) { - return ui.section(`${title} (${rows.length})`, rows.length ? h("ul.list", rows.map(render)) : ui.empty(t("none"))); - } - - ui.panels.project = { - title: () => t("Project"), - mount(el, params) { - const rootDir = params.root; - let jobRunning = null; - const command = (id) => () => ui.host("runCommand", { command: id, root: rootDir }); - - async function render() { - ui.clear(el); - if (!rootDir) { el.appendChild(ui.empty(t("No mcpp project in this workspace"))); return; } - el.appendChild(h("p.muted", t("Loading…"))); - let summary, ls; - try { - [summary, ls] = await Promise.all([ui.rpc("project/summary", { root: rootDir }), ui.rpc("languageService/status", {})]); - } catch (e) { - ui.clear(el).appendChild(ui.errorBox(e)); - return; - } - ui.clear(el); - const name = rootDir.split(/[\\/]/).filter(Boolean).pop(); - const target = summary.target || ""; - el.appendChild(h("header.panel-head", - h("h1", name), - h("p.muted", rootDir), - h("div.actions", - ui.button(t("Build"), command("mcpp.build"), { disabled: !!jobRunning }), - ui.button(t("Run"), command("mcpp.run")), - ui.button(t("Debug"), command("mcpp.debug")), - ui.button(t("Test"), command("mcpp.test"), { disabled: !!jobRunning }), - ui.button(t("Pack"), () => ui.host("openPanel", { panel: "pack", root: rootDir }), { secondary: true }), - ui.button(t("Clean target"), command("mcpp.clean"), { secondary: true }), - jobRunning ? ui.button(t("Cancel"), () => ui.rpc("job/cancel", { jobId: jobRunning }), { secondary: true }) : null))); - - const facts = h("dl.facts", - h("dt", t("Target")), h("dd", target || t("this machine"), " ", ui.h("a", { href: "#", onclick: (e) => { e.preventDefault(); ui.host("openPanel", { panel: "targets", root: rootDir }); } }, t("change"))), - h("dt", t("Platform")), h("dd", summary.platformText || t("reported after the first build")), - summary.hostPlatformText ? [h("dt", t("Host platform")), h("dd", summary.hostPlatformText)] : null, - h("dt", t("Toolchain")), h("dd", summary.toolchain || "—")); - if (summary.lastBuild) { - const b = summary.lastBuild; - facts.append(h("dt", t("Last build")), - h("dd", ui.badge(b.status === "ok" ? "ok" : b.status === "cancelled" ? "info" : "error", b.status), " ", - ui.ago(b.finishedAt), b.client ? ` · ${b.client}` : "")); - } - el.appendChild(ui.section(t("Overview"), facts, summary.error ? ui.errorBox({ message: summary.error }) : null)); - el.appendChild(list(t("Executables"), summary.targets || [], (r) => - h("li", h("span", r.name), h("span.muted", r.configuration || "")))); - el.appendChild(list(t("Tests"), summary.tests || [], (r) => - h("li", h("span", r.name), ui.button(t("Run"), () => ui.host("runCommand", { command: "mcpp.test", root: rootDir, args: { filter: r.name } }), { secondary: true })))); - el.appendChild(list(t("Libraries"), summary.libraries || [], (r) => h("li", h("span", r.name), h("span.muted", r.kind)))); - el.appendChild(h("div.actions", - ui.button(t("Dependency graph"), () => ui.host("openPanel", { panel: "graph", root: rootDir, graph: "packages" }), { secondary: true }), - ui.button(t("Module graph"), () => ui.host("openPanel", { panel: "graph", root: rootDir, graph: "modules" }), { secondary: true }), - ui.button(t("Libraries"), () => ui.host("openPanel", { panel: "libraries", root: rootDir }), { secondary: true }))); - const card = languageServiceCard(ls, render); - if (card) el.appendChild(card); - } - - const offs = [ - ui.on("job/started", (p) => { if (p.root === rootDir) { jobRunning = p.jobId; render(); } }), - ui.on("job/finished", (p) => { if (p.root === rootDir || p.jobId === jobRunning) { jobRunning = null; render(); } }), - ui.on("result/changed", (p) => { if (p.root === rootDir) render(); }), - ui.on("manifest/changed", (p) => { if (p.root === rootDir) render(); }), - ]; - render(); - return () => offs.forEach((off) => off()); - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/settings.js b/webui/js/panels/settings.js deleted file mode 100644 index 921d2b4..0000000 --- a/webui/js/panels/settings.js +++ /dev/null @@ -1,89 +0,0 @@ -// The settings panel: every setting of the registry (spec/settings.json), grouped, with what it -// takes; a change is written by the editor into its own settings store, and the backend's resolution -// (rejections, renamed keys) is shown beside it. Presets apply several at once. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - function control(entry, value, write) { - if (entry.type === "boolean") - return h("input", { type: "checkbox", checked: !!value, onchange: (e) => write(e.target.checked) }); - if (entry.enum) - return h("select", { onchange: (e) => write(e.target.value) }, entry.enum.map((v) => h("option", { value: v, selected: v === value }, v))); - if (entry.type === "number") - return h("input", { type: "number", value: value ?? "", min: entry.minimum, max: entry.maximum, onchange: (e) => write(Number(e.target.value)) }); - if (entry.type === "array") - return h("textarea", { rows: 2, placeholder: t("one per line"), onchange: (e) => write(e.target.value.split("\n").map((s) => s.trim()).filter(Boolean)) }, - (value || []).join("\n")); - return h("input", { type: "text", value: value ?? "", onchange: (e) => write(e.target.value) }); - } - - ui.panels.settings = { - title: () => t("Settings"), - mount(el) { - const filter = h("input", { type: "search", placeholder: t("Filter settings") }); - const advanced = h("input", { type: "checkbox" }); - const body = h("div"); - let registry, values = {}, resolved = {}; - - async function load() { - ui.clear(body).appendChild(h("p.muted", t("Loading…"))); - try { - [registry, values] = await Promise.all([ui.rpc("settings/registry", {}), ui.host("settings", {})]); - resolved = await ui.rpc("settings/update", { settings: values }); - } catch (e) { ui.clear(body).appendChild(ui.errorBox(e)); return; } - draw(); - } - - function draw() { - ui.clear(body); - const needle = filter.value.toLowerCase(); - const rejected = new Map((resolved.rejected || []).map((r) => [r.key, r])); - const groups = new Map(); - for (const s of registry.settings) { - if (s.tier === "advanced" && !advanced.checked) continue; - if (needle && !`${s.key} ${s.title} ${s.description}`.toLowerCase().includes(needle)) continue; - if (!groups.has(s.group)) groups.set(s.group, []); - groups.get(s.group).push(s); - } - if ((resolved.renames || []).length) - body.appendChild(ui.section(t("Renamed settings"), - h("ul.list", resolved.renames.map((r) => h("li", h("code", r.from), " → ", h("code", r.to)))), - ui.button(t("Move them"), async () => { await ui.host("migrateSettings", { renames: resolved.renames }); load(); }))); - const groupTitle = new Map((registry.groups || []).map((g) => [g.id, g.title])); - for (const [group, entries] of groups) { - body.appendChild(ui.section(groupTitle.get(group) || group, entries.map((s) => { - const value = Object.prototype.hasOwnProperty.call(values, s.key) ? values[s.key] : s.default; - const bad = rejected.get(s.key); - return h("div.setting", - h("label", h("strong", s.title || s.key), h("code.muted", ` ${s.key}`)), - h("p.muted", s.description), - control(s, value, async (v) => { - await ui.host("setSetting", { key: s.key, value: v }); - values[s.key] = v; - resolved = await ui.rpc("settings/update", { settings: values }); - draw(); - }), - s.applies === "restart" ? h("p.note", t("Takes effect after a restart.")) : null, - bad ? h("p.error", t("Not accepted ({0}); expected {1}. The default is used.", bad.reason, bad.expectation)) : null); - }))); - } - if ((registry.presets || []).length) - body.appendChild(ui.section(t("Presets"), h("div.actions.wrap", registry.presets.map((p) => - ui.button(p.title || p.id, async () => { - const preset = await ui.rpc("settings/preset", { id: p.id }); - const entries = Object.entries(preset.values || {}); - // An empty preset is every setting at its default. - if (!entries.length) await ui.host("resetSettings", { keys: registry.settings.map((s) => s.key) }); - for (const [k, v] of entries) await ui.host("setSetting", { key: k, value: v }); - load(); - }, { secondary: true, title: p.description || "" }))))); - } - filter.addEventListener("input", ui.debounce(draw, 150)); - advanced.addEventListener("change", draw); - el.append(h("header.panel-head", h("h1", t("Settings")), h("div.toolbar", filter, h("label", advanced, ` ${t("Show advanced settings")}`))), body); - load(); - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/panels/targets.js b/webui/js/panels/targets.js deleted file mode 100644 index 3b0cf5d..0000000 --- a/webui/js/panels/targets.js +++ /dev/null @@ -1,63 +0,0 @@ -// The targets panel: every target mcpp knows, the toolchains installed for it, and the platform -// that configures it (mcpp's platforms: a built-in convention, a pinned platform package, or one the -// project or this machine chose), with each of the platform's fields and where its value came from. -// Choosing a target writes `mcpp.build.target`; build, run, test, debug and pack then use it. -(function (root) { - "use strict"; - const ui = root.McppUi; - const { h, t } = ui; - - function fields(platform) { - const f = (platform && platform.fields) || {}; - const keys = Object.keys(f); - if (!keys.length) return null; - return h("table.fields", keys.map((k) => { - const v = f[k] && Object.prototype.hasOwnProperty.call(f[k], "value") ? f[k].value : f[k]; - return h("tr", h("td", h("code", k)), h("td", typeof v === "string" ? v : JSON.stringify(v)), h("td.muted", (f[k] && f[k].source) || "")); - })); - } - - ui.panels.targets = { - title: () => t("Targets and Platforms"), - mount(el, params) { - const rootDir = params.root; - async function render() { - ui.clear(el).appendChild(h("p.muted", t("Loading…"))); - let r; - try { r = await ui.rpc("targets/list", { root: rootDir }); } catch (e) { ui.clear(el).appendChild(ui.errorBox(e)); return; } - ui.clear(el); - el.appendChild(h("header.panel-head", - h("h1", t("Targets and Platforms")), - h("p", t("This machine: {0}", r.host), r.hostPlatformText ? h("span.muted", ` · ${t("host platform")}: ${r.hostPlatformText}`) : null), - r.lastPlan && r.lastPlan.configuration ? h("p.muted", t("Last build: {0}, {1}", r.lastPlan.configuration.triple, r.lastPlan.platformText || t("host conventions"))) : null)); - const rows = r.rows.map((row) => { - const details = h("details", - h("summary", row.platformText || t("platform not reported by this mcpp")), - fields(row.platform) || h("p.muted", t("No platform fields reported."))); - const choose = row.selected ? ui.badge("ok", t("selected")) - : ui.button(t("Use"), async () => { await ui.host("setSetting", { key: "mcpp.build.target", value: row.host ? "" : row.target }); render(); }, { secondary: true }); - const toolchains = row.toolchains.length - ? row.toolchains.map((tc) => h("div", tc.spec || "—", " ", ui.badge(tc.status === "installed" ? "ok" : "info", tc.status), tc.default ? ` ${t("(default)")}` : "")) - : h("span.muted", "—"); - const install = !row.installed && row.pin - ? ui.button(t("Install"), () => ui.rpc("toolchains/install", { spec: row.pin, target: row.target, root: rootDir }), { secondary: true }) - : null; - return h(`tr${row.selected ? ".selected" : ""}`, - h("td", h("strong", row.target), row.host ? h("span.muted", ` ${t("(this machine)")}`) : null, row.note ? h("div.muted", row.note) : null), - h("td", toolchains, install), - h("td", details), - h("td", choose)); - }); - el.appendChild(h("table.targets", - h("thead", h("tr", h("th", t("Target")), h("th", t("Toolchains")), h("th", t("Platform")), h("th", ""))), - h("tbody", rows))); - } - const offs = [ - ui.on("job/finished", (p) => { if (p.kind === "toolchain") render(); }), - ui.on("job/plan", (p) => { if (p.root === rootDir) render(); }), - ]; - render(); - return () => offs.forEach((off) => off()); - }, - }; -})(typeof window !== "undefined" ? window : globalThis); diff --git a/webui/js/types.d.ts b/webui/js/types.d.ts deleted file mode 100644 index 1c1d8e4..0000000 --- a/webui/js/types.d.ts +++ /dev/null @@ -1,43 +0,0 @@ -// The panels' one global, for `tsc --checkJs` (vscode: npm run check:webui). Not loaded at run time. -interface McppPanel { - title(params: any): string; - mount(el: HTMLElement, params: any): void | (() => void); -} - -interface McppUiApi { - panels: Record; - setBundle(b: Record): void; - t(key: string, ...args: unknown[]): string; - formatBytes(bytes: number): string; - ago(when: number | null | undefined, now?: number): string; - debounce(fn: (...args: any[]) => void, ms: number): (...args: any[]) => void; - h(spec: string, props?: any, ...children: any[]): any; - clear(el: E): E; - badge(state: string, text: string): HTMLElement; - button(text: string, onclick: (ev?: Event) => unknown, opts?: { secondary?: boolean; disabled?: boolean; title?: string }): HTMLButtonElement; - section(title: string, ...children: any[]): HTMLElement; - empty(text: string): HTMLElement; - errorBox(error: any): HTMLElement; - rpc(method: string, params?: unknown): Promise; - host(action: string, params?: unknown): Promise; - on(method: string, fn: (...args: any[]) => void): () => void; - ready(): void; - state: { get(): any; set(s: any): void }; - editor: "vscode" | "jetbrains"; - graphLayout: { - layout(nodes: any[], edges: any[], opts?: any): { nodes: any[]; edges: any[]; width: number; height: number }; - neighbourhood(nodes: any[], edges: any[], focus: string, depth?: number): { nodes: any[]; edges: any[] }; - }; - cleanup(action: string, root?: string): Promise; -} - -interface Window { - McppUi: McppUiApi; - acquireVsCodeApi?: () => { postMessage(m: unknown): void; getState(): any; setState(s: any): void }; - mcppHostSend?: (text: string) => void; - mcppHostReceive: (m: unknown) => void; - mcppHostAttached: () => void; - __mcppState: any; -} - -declare var McppUi: McppUiApi; diff --git a/webui/src/graph.ts b/webui/src/graph.ts new file mode 100644 index 0000000..7ffdb25 --- /dev/null +++ b/webui/src/graph.ts @@ -0,0 +1,100 @@ +// The graph page mcpp-clion shows in JCEF (mcpp design 2026-10-30 part 2 §9): the backend's +// `mcpp-ide/graph/packages` or `mcpp-ide/graph/modules`, drawn with mermaid. The page decides +// nothing: the IDE hands it the backend's answer and its strings (`mcppRender`), and a click on a +// module whose source is known asks the IDE to open it (`mcppHostSend`). + +interface GraphNode { + id: string; + kind?: string; + family?: string; + units?: number; + set?: string; + source?: string; +} + +interface GraphEdge { + from: string; + to: string; +} + +export interface GraphPage { + kind: "packages" | "modules"; + graph: { nodes: GraphNode[]; edges: GraphEdge[] }; + title: string; + /** Said when the graph has no node. */ + empty: string; + /** Said when the graph was cut to `limit` nodes; `{0}` is the limit. */ + truncated: string; + dark: boolean; + limit?: number; +} + +declare const mermaid: { + initialize(options: object): void; + render(id: string, text: string): Promise<{ svg: string }>; +}; + +declare global { + interface Window { + mcppRender(page: GraphPage): Promise; + mcppHostSend?: (text: string) => void; + } +} + +/** A label as mermaid reads it inside `"…"`. */ +function label(text: string): string { + return text.replace(/"/g, "#quot;").replace(//g, "#gt;"); +} + +/** The flowchart, and which mermaid id stands for which node. */ +export function flowchart(page: GraphPage): { text: string; nodes: Map; cut: boolean } { + const limit = page.limit ?? 300; + const shown = page.graph.nodes.slice(0, limit); + const idOf = new Map(); + const nodes = new Map(); + const lines = ["flowchart LR"]; + shown.forEach((node, i) => { + const id = `n${i}`; + idOf.set(node.id, id); + nodes.set(id, node); + const detail = page.kind === "packages" && node.kind ? `
      ${label(node.kind)}` : ""; + lines.push(` ${id}["${label(node.id)}${detail}"]`); + if (node.kind === "external") lines.push(` class ${id} external`); + else if (node.kind === "test") lines.push(` class ${id} test`); + }); + for (const edge of page.graph.edges) { + const from = idOf.get(edge.from), to = idOf.get(edge.to); + if (from && to) lines.push(` ${from} --> ${to}`); + } + lines.push(" classDef external stroke-dasharray: 4 3"); + lines.push(" classDef test stroke-width: 2px"); + return { text: lines.join("\n"), nodes, cut: page.graph.nodes.length > shown.length }; +} + +let renders = 0; + +window.mcppRender = async (page: GraphPage): Promise => { + const title = document.getElementById("title")!; + const note = document.getElementById("note")!; + const target = document.getElementById("graph")!; + title.textContent = page.title; + document.body.classList.toggle("dark", page.dark); + if (page.graph.nodes.length === 0) { + note.textContent = page.empty; + target.innerHTML = ""; + return; + } + const { text, nodes, cut } = flowchart(page); + note.textContent = cut ? page.truncated.replace("{0}", String(page.limit ?? 300)) : ""; + mermaid.initialize({ startOnLoad: false, theme: page.dark ? "dark" : "default", securityLevel: "strict", maxEdges: 5000 }); + const { svg } = await mermaid.render(`graph${++renders}`, text); + target.innerHTML = svg; + // A module's source opens in the IDE. + for (const element of Array.from(target.querySelectorAll("g.node"))) { + const match = /-(n\d+)-/.exec(element.id); + const node = match ? nodes.get(match[1]) : undefined; + if (!node?.source) continue; + element.style.cursor = "pointer"; + element.addEventListener("click", () => window.mcppHostSend?.(JSON.stringify({ open: node.source }))); + } +}; diff --git a/webui/styles.css b/webui/styles.css deleted file mode 100644 index 7e4f110..0000000 --- a/webui/styles.css +++ /dev/null @@ -1,71 +0,0 @@ -/* The panels' look. Colours come from the editor: VS Code's theme variables, or the --mcpp-* ones - the JetBrains host sets from its own theme in `init`; the fallbacks are a neutral dark theme. */ -:root { - --fg: var(--mcpp-fg, var(--vscode-foreground, #cccccc)); - --bg: var(--mcpp-bg, var(--vscode-editor-background, #1e1e1e)); - --muted: var(--mcpp-muted, var(--vscode-descriptionForeground, #9d9d9d)); - --border: var(--mcpp-border, var(--vscode-panel-border, #3c3c3c)); - --card: var(--mcpp-card, var(--vscode-sideBar-background, #252526)); - --accent: var(--mcpp-accent, var(--vscode-button-background, #0e639c)); - --accent-fg: var(--mcpp-accent-fg, var(--vscode-button-foreground, #ffffff)); - --secondary: var(--mcpp-secondary, var(--vscode-button-secondaryBackground, #3a3d41)); - --secondary-fg: var(--mcpp-secondary-fg, var(--vscode-button-secondaryForeground, #ffffff)); - --input: var(--mcpp-input, var(--vscode-input-background, #3c3c3c)); - --ok: var(--mcpp-ok, #89d185); - --warn: var(--mcpp-warn, #e2c08d); - --err: var(--mcpp-err, var(--vscode-errorForeground, #f48771)); - --font: var(--mcpp-font, var(--vscode-font-family, system-ui, sans-serif)); - --mono: var(--mcpp-mono, var(--vscode-editor-font-family, ui-monospace, monospace)); - --size: var(--mcpp-font-size, var(--vscode-font-size, 13px)); -} -* { box-sizing: border-box; } -body { margin: 0; padding: 12px 16px; color: var(--fg); background: var(--bg); font: var(--size)/1.45 var(--font); } -h1 { font-size: 1.4em; margin: 0 0 4px; } -h2 { font-size: 1.05em; margin: 0 0 8px; } -code, pre { font-family: var(--mono); } -a { color: var(--accent); } -.muted, .empty { color: var(--muted); } -.panel-head { margin-bottom: 12px; } -.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; margin: 10px 0; } -.actions, .toolbar { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 8px; } -button { background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 3px; padding: 4px 10px; font: inherit; cursor: pointer; } -button.secondary { background: var(--secondary); color: var(--secondary-fg); } -button:disabled { opacity: .5; cursor: default; } -input, select, textarea { background: var(--input); color: var(--fg); border: 1px solid var(--border); border-radius: 3px; padding: 3px 6px; font: inherit; } -input[type="search"] { min-width: 220px; } -textarea { width: 100%; font-family: var(--mono); } -.badge { display: inline-block; padding: 0 6px; border-radius: 8px; font-size: .85em; border: 1px solid currentColor; } -.badge-ok { color: var(--ok); } .badge-warning { color: var(--warn); } .badge-error { color: var(--err); } .badge-info, .badge-running { color: var(--muted); } -.error { color: var(--err); } -.error pre { white-space: pre-wrap; color: var(--fg); } -.note { color: var(--warn); } -dl.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; } -dl.facts dt { color: var(--muted); } -dl.facts dd { margin: 0; } -ul.list { list-style: none; padding: 0; margin: 0; } -ul.list li { display: flex; justify-content: space-between; gap: 8px; padding: 3px 0; border-bottom: 1px solid var(--border); } -ul.list li:last-child { border-bottom: 0; } -li.package { display: block !important; cursor: pointer; } -li.package:hover { background: var(--card); } -table { border-collapse: collapse; width: 100%; } -td, th { padding: 4px 6px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--border); } -table.targets tr.selected { background: var(--card); } -table.bars td.num { text-align: right; white-space: nowrap; width: 7em; } -table.bars td.bar { width: 40%; } -table.bars td.bar div { height: 8px; background: var(--accent); border-radius: 2px; } -table.fields td { border: 0; padding: 1px 6px; } -.setting { padding: 6px 0; border-bottom: 1px solid var(--border); } -.setting:last-child { border-bottom: 0; } -.setting p { margin: 2px 0 4px; } -.graph-canvas { position: relative; height: calc(100vh - 150px); min-height: 320px; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } -.graph-canvas svg { display: block; } -.node rect { fill: var(--card); stroke: var(--border); } -.node.focus rect { stroke: var(--accent); stroke-width: 2; } -.node.kind-external rect { stroke-dasharray: 4 3; } -.node text { fill: var(--fg); font-size: 12px; font-family: var(--mono); pointer-events: none; } -.node { cursor: pointer; } -.edge { fill: none; stroke: var(--muted); stroke-width: 1; } -.edge.back { stroke: var(--warn); stroke-dasharray: 4 3; } -.arrow { fill: var(--muted); } -pre.log { max-height: 40vh; overflow: auto; background: var(--card); padding: 8px; border-radius: 4px; white-space: pre-wrap; } -details summary { cursor: pointer; } diff --git a/webui/tests/graph.test.mjs b/webui/tests/graph.test.mjs new file mode 100644 index 0000000..5bf27cd --- /dev/null +++ b/webui/tests/graph.test.mjs @@ -0,0 +1,106 @@ +// The graph page as mcpp-clion shows it, in Chromium (the engine of JCEF): a real `mcpp-ide serve` +// answers `mcpp-ide/graph/packages` and `mcpp-ide/graph/modules` for the mock mcpp's build database, +// and the page draws what it is handed -- every node, the edges, an external module dashed -- says +// so when there is nothing to draw, and asks the IDE to open a module's source when it is clicked. +// +// Run from mide-plugins/vscode: npm run test:webui (it bundles the page first). MCPP_IDE_BACKEND and +// MCPP_IDE_MOCK_MCPP name the programs; else the newest of the repository's target/ are taken. +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "..", ".."); +const extension = path.join(repo, "mide-plugins", "vscode"); +const require = createRequire(path.join(extension, "package.json")); +const { chromium } = require("playwright"); +const rpc = require("vscode-jsonrpc/node"); + +function built(name) { + const env = name === "mcpp-ide" ? process.env.MCPP_IDE_BACKEND : process.env.MCPP_IDE_MOCK_MCPP; + if (env) return env; + const exe = process.platform === "win32" ? `${name}.exe` : name; + let best, bestTime = 0; + const target = path.join(repo, "target"); + if (!fs.existsSync(target)) return undefined; + for (const triple of fs.readdirSync(target, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name)) + for (const config of fs.readdirSync(path.join(target, triple), { withFileTypes: true })) { + if (!config.isDirectory()) continue; + const p = path.join(target, triple, config.name, "bin", exe); + if (fs.existsSync(p) && fs.statSync(p).mtimeMs > bestTime) { best = p; bestTime = fs.statSync(p).mtimeMs; } + } + return best; +} + +const page = path.join(extension, ".webui-test", "graph.html"); + +test("the graph page draws the backend's graphs", async (t) => { + const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); + if (!backend || !mock) { t.skip("build the backend first (mcpp build)"); return; } + assert.ok(fs.existsSync(page), "bundle the page first (npm run test:webui)"); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mcpp-graph-")); + fs.mkdirSync(path.join(root, "src")); + fs.writeFileSync(path.join(root, "mcpp.toml"), '[package]\nname = "app"\nversion = "0.1.0"\n'); + + const child = spawn(backend, ["serve"], { stdio: ["pipe", "pipe", "inherit"] }); + const connection = rpc.createMessageConnection(new rpc.StreamMessageReader(child.stdout), new rpc.StreamMessageWriter(child.stdin)); + connection.listen(); + const browser = await chromium.launch(); + try { + await connection.sendRequest("initialize", { + processId: process.pid, + workspaceFolders: [{ uri: pathToFileURL(root).href, name: "app" }], + capabilities: {}, + initializationOptions: { editor: "jetbrains", settings: { "mcpp.path": mock } }, + }); + const packages = await connection.sendRequest("mcpp-ide/graph/packages", { root }); + const modules = await connection.sendRequest("mcpp-ide/graph/modules", { root }); + assert.ok(packages.nodes.length > 0 && modules.nodes.length > 0, JSON.stringify({ packages, modules }).slice(0, 400)); + + const tab = await browser.newPage(); + const opened = []; + await tab.exposeFunction("mcppHostSend", (text) => opened.push(JSON.parse(text))); + await tab.goto(pathToFileURL(page).href); + const draw = (graph, kind) => tab.evaluate(([g, k]) => window.mcppRender({ + kind: k, graph: g, title: `${k} graph`, empty: "nothing to draw", truncated: "the first {0} nodes", dark: false, + }), [graph, kind]); + + await draw(packages, "packages"); + const drawn = await tab.locator("#graph svg").innerHTML(); + for (const node of packages.nodes) assert.ok(drawn.includes(node.id), `${node.id} is not drawn`); + assert.equal(await tab.locator("#title").textContent(), "packages graph"); + + await draw(modules, "modules"); + assert.equal(await tab.locator("#graph g.node").count(), modules.nodes.length); + assert.equal(await tab.locator("#graph g.node.external").count(), modules.nodes.filter((n) => n.kind === "external").length); + const withSource = modules.nodes.findIndex((n) => n.source); + if (withSource >= 0) { + await tab.locator(`#graph g.node[id*="-n${withSource}-"]`).click(); + await tab.waitForTimeout(200); + assert.deepEqual(opened, [{ open: modules.nodes[withSource].source }]); + } + + await draw({ nodes: [], edges: [] }, "modules"); + assert.equal(await tab.locator("#note").textContent(), "nothing to draw"); + assert.equal(await tab.locator("#graph svg").count(), 0); + + // A large graph is cut, and says where. + const many = { nodes: Array.from({ length: 5 }, (_, i) => ({ id: `m${i}` })), edges: [] }; + await tab.evaluate((g) => window.mcppRender({ kind: "modules", graph: g, title: "t", empty: "", truncated: "the first {0} nodes", dark: true, limit: 3 }), many); + assert.equal(await tab.locator("#note").textContent(), "the first 3 nodes"); + assert.equal(await tab.locator("#graph g.node").count(), 3); + } finally { + await browser.close(); + const ended = new Promise((resolve) => child.on("exit", resolve)); + await connection.sendRequest("shutdown").catch(() => undefined); + await connection.sendNotification("exit").catch(() => undefined); + await ended; + connection.dispose(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/webui/tests/layout.test.js b/webui/tests/layout.test.js deleted file mode 100644 index ac6ac93..0000000 --- a/webui/tests/layout.test.js +++ /dev/null @@ -1,41 +0,0 @@ -// The layered layout and the neighbourhood: every edge that is not part of a cycle points down a -// layer, nodes of a layer do not overlap, and a neighbourhood holds exactly the nodes in reach. -const assert = require("node:assert/strict"); -const { test } = require("node:test"); -const { load } = require("./load"); - -const ui = load(["js/core.js", "js/graph-layout.js"]); - -test("edges point from a layer to a lower one; cycles are drawn, not obeyed", () => { - const nodes = ["app", "lib", "std", "a", "b"].map((id) => ({ id })); - const edges = [{ from: "app", to: "lib" }, { from: "lib", to: "std" }, { from: "app", to: "std" }, { from: "a", to: "b" }, { from: "b", to: "a" }]; - const l = ui.graphLayout.layout(nodes, edges); - const at = new Map(l.nodes.map((n) => [n.id, n])); - for (const e of l.edges) if (!e.back) assert.ok(at.get(e.from).layer < at.get(e.to).layer, `${e.from} -> ${e.to}`); - assert.equal(l.edges.filter((e) => e.back).length, 1); - const byLayer = new Map(); - for (const n of l.nodes) { - const row = byLayer.get(n.layer) || []; - for (const m of row) assert.ok(Math.abs(m.x - n.x) >= n.width, "overlap"); - row.push(n); - byLayer.set(n.layer, row); - } - assert.ok(l.width > 0 && l.height > 0); -}); - -test("a neighbourhood is the nodes within reach, both ways", () => { - const nodes = ["a", "b", "c", "d"].map((id) => ({ id })); - const edges = [{ from: "a", to: "b" }, { from: "b", to: "c" }, { from: "c", to: "d" }]; - const one = ui.graphLayout.neighbourhood(nodes, edges, "b", 1); - assert.deepEqual(one.nodes.map((n) => n.id).sort(), ["a", "b", "c"]); - const two = ui.graphLayout.neighbourhood(nodes, edges, "b", 2); - assert.equal(two.nodes.length, 4); -}); - -test("strings and sizes", () => { - ui.setBundle({ "{0} packages": "{0} 个包" }); - assert.equal(ui.t("{0} packages", 3), "3 个包"); - assert.equal(ui.t("missing {0}", "x"), "missing x"); - assert.equal(ui.formatBytes(1536), "1.5 KiB"); - assert.equal(ui.formatBytes(10), "10 B"); -}); diff --git a/webui/tests/load.js b/webui/tests/load.js deleted file mode 100644 index f4d379d..0000000 --- a/webui/tests/load.js +++ /dev/null @@ -1,16 +0,0 @@ -// Loads the panels' classic scripts into a context: Node's own global for the pure ones, a jsdom -// window for the ones that render. -const fs = require("node:fs"); -const path = require("node:path"); -const vm = require("node:vm"); - -const webui = path.resolve(__dirname, ".."); - -function load(files, context) { - const g = context || { console, setTimeout, clearTimeout }; - if (!context) { g.globalThis = g; vm.createContext(g); } - for (const f of files) vm.runInContext(fs.readFileSync(path.join(webui, f), "utf8"), g, { filename: f }); - return g.McppUi; -} - -module.exports = { load, webui }; diff --git a/webui/tests/panels.test.js b/webui/tests/panels.test.js deleted file mode 100644 index 56f3eea..0000000 --- a/webui/tests/panels.test.js +++ /dev/null @@ -1,119 +0,0 @@ -// Every panel, mounted in a DOM against the real backend over the mock mcpp: the host is played the -// way the JetBrains plugin plays it (window.mcppHostSend out, window.mcppHostReceive in), and each -// panel must render what the golden fixtures hold without an error box. -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { test, before, after } = require("node:test"); - -const repo = path.resolve(__dirname, "..", ".."); -// The VS Code adapter holds the DOM, the wire and the harness the panels are tested with. -const vscodeDir = path.join(repo, "mide-plugins", "vscode"); -const { JSDOM } = require(path.join(vscodeDir, "node_modules", "jsdom")); -const { Backend } = require(path.join(vscodeDir, "out", "src", "backend", "client.js")); -const { built } = require(path.join(vscodeDir, "out", "test", "unit", "harness.js")); - -const scripts = ["core.js", "host.js", "graph-layout.js", "panels/project.js", "panels/libraries.js", "panels/cache.js", - "panels/settings.js", "panels/graph.js", "panels/pack.js", "panels/targets.js", "app.js"].map((f) => path.join(repo, "webui", "js", f)); - -let backend, root, skip; -const hostCalls = []; - -before(async () => { - const program = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); - if (!program || !mock) { skip = "build the backend first (mcpp build)"; return; } - root = fs.mkdtempSync(path.join(os.tmpdir(), "mcppide-webui-")); - fs.mkdirSync(path.join(root, "src")); - fs.writeFileSync(path.join(root, "mcpp.toml"), '[package]\nname = "app"\nversion = "0.1.0"\n'); - backend = new Backend({ program }); - await backend.request("initialize", { editor: "jetbrains", settings: { "mcpp.path": mock }, workspaceFolders: [root] }); -}); - -after(async () => { if (backend) await backend.stop(); }); - -function mount(panel, params) { - const dom = new JSDOM('
      ', { runScripts: "outside-only", pretendToBeVisual: true }); - const w = dom.window; - w.mcppHostSend = async (text) => { - const m = JSON.parse(text); - if (m.type === "ready") { w.mcppHostReceive({ type: "init", panel, params, bundle: {} }); return; } - if (m.type === "rpc") { - try { w.mcppHostReceive({ type: "rpcResult", id: m.id, result: await backend.request(m.method, m.params) }); } - catch (e) { w.mcppHostReceive({ type: "rpcError", id: m.id, error: { message: e.message } }); } - return; - } - if (m.type === "host") { - hostCalls.push(m.action); - const answers = { settings: {}, setSetting: true, confirm: { confirmed: false } }; - w.mcppHostReceive({ type: "hostResult", id: m.id, result: answers[m.action] ?? true }); - } - }; - for (const f of scripts) w.eval(fs.readFileSync(f, "utf8")); - return w; -} - -async function settle(w, predicate, what) { - for (let i = 0; i < 200; i++) { - if (predicate(w.document)) return; - await new Promise((r) => setTimeout(r, 25)); - } - assert.fail(`${what}: ${w.document.getElementById("app").textContent.slice(0, 400)}`); -} - -const noError = (w) => assert.equal(w.document.querySelector(".error"), null, w.document.querySelector(".error")?.textContent); - -test("project", async (t) => { - if (skip) return t.skip(skip); - const w = mount("project", { root }); - await settle(w, (d) => d.body.textContent.includes("Tests (1)"), "the project's test"); - assert.ok(w.document.body.textContent.includes("app")); - noError(w); -}); - -test("targets", async (t) => { - if (skip) return t.skip(skip); - const w = mount("targets", { root }); - await settle(w, (d) => d.querySelectorAll("table.targets tbody tr").length > 0, "target rows"); - assert.equal(w.document.querySelectorAll("tr.selected").length, 1); - noError(w); -}); - -test("libraries", async (t) => { - if (skip) return t.skip(skip); - const w = mount("libraries", { root }); - await settle(w, (d) => d.querySelectorAll("li.package").length > 0, "packages"); - w.document.querySelector("li.package").dispatchEvent(new w.MouseEvent("click", { bubbles: true })); - await settle(w, (d) => d.querySelector("select") && d.body.textContent.includes("Add to mcpp.toml"), "a package page"); - noError(w); -}); - -test("cache", async (t) => { - if (skip) return t.skip(skip); - const w = mount("cache", { root }); - await settle(w, (d) => d.querySelectorAll("table.bars tr").length > 0, "cache bars"); - noError(w); -}); - -test("settings", async (t) => { - if (skip) return t.skip(skip); - const w = mount("settings", {}); - await settle(w, (d) => d.body.textContent.includes("mcpp.path"), "the settings"); - noError(w); -}); - -test("graphs", async (t) => { - if (skip) return t.skip(skip); - for (const graph of ["packages", "modules"]) { - const w = mount("graph", { root, graph }); - await settle(w, (d) => d.querySelectorAll(".node").length > 0, `${graph} nodes`); - noError(w); - } -}); - -test("pack", async (t) => { - if (skip) return t.skip(skip); - const w = mount("pack", { root }); - await settle(w, (d) => d.querySelectorAll("select option").length > 2, "pack formats"); - noError(w); -}); diff --git a/webui/tsconfig.json b/webui/tsconfig.json index 0ab6899..7ce9774 100644 --- a/webui/tsconfig.json +++ b/webui/tsconfig.json @@ -1,14 +1,13 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": true, - "noEmit": true, "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", "lib": ["ES2022", "DOM"], - "strict": false, - "noImplicitAny": false, + "strict": true, + "noEmit": true, "skipLibCheck": true, "types": [] }, - "include": ["js/**/*.js", "js/types.d.ts"] + "include": ["src/**/*.ts"] }