diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed5c7b9..e72d05c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,7 +83,7 @@ jobs: name: backend path: backend - # The VS Code extension: unit tests (the wire, the mappings) and the shared panels in a DOM, both + # 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. vscode: @@ -101,7 +101,7 @@ jobs: with: node-version: 22 cache: npm - cache-dependency-path: vscode/package-lock.json + cache-dependency-path: mide-plugins/vscode/package-lock.json - uses: actions/download-artifact@v5 with: name: backend @@ -115,19 +115,19 @@ jobs: echo "MCPP_IDE_OKRUN=$GITHUB_WORKSPACE/backend/okrun" } >> "$GITHUB_ENV" - name: Install - working-directory: vscode + working-directory: mide-plugins/vscode run: npm ci - name: Unit and panel tests - working-directory: vscode + working-directory: mide-plugins/vscode run: npm test - name: The panels type-check - working-directory: vscode + working-directory: mide-plugins/vscode run: npm run check:webui - name: The extension in VS Code ${{ env.VSCODE_VERSION }} - working-directory: vscode + working-directory: mide-plugins/vscode run: xvfb-run -a npm run test:e2e - name: Package - working-directory: vscode + working-directory: mide-plugins/vscode run: | npm run package (cd dist/extension && npx vsce ls --no-dependencies) | tee files.txt @@ -138,12 +138,13 @@ jobs: - uses: actions/upload-artifact@v5 with: name: mcpp-vscode-vsix - path: vscode/dist/*.vsix + path: mide-plugins/vscode/dist/*.vsix - # The CLion plugin: 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 - # (2025.2) and the one it is built on. - jetbrains: + # 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. + clion: name: CLion plugin needs: backend runs-on: ubuntu-24.04 @@ -159,7 +160,7 @@ jobs: - uses: actions/cache@v5 with: path: ~/.gradle/caches/modules-2 - key: gradle-clion-${{ hashFiles('jetbrains/gradle.properties', 'jetbrains/build.gradle.kts', 'jetbrains/settings.gradle.kts') }} + key: gradle-clion-${{ hashFiles('mide-plugins/clion/gradle.properties', 'mide-plugins/clion/build.gradle.kts', 'mide-plugins/clion/settings.gradle.kts') }} - uses: actions/download-artifact@v5 with: name: backend @@ -178,13 +179,13 @@ jobs: echo "MCPP_IDE_OKRUN=$GITHUB_WORKSPACE/backend/okrun" } >> "$GITHUB_ENV" - name: Test and package - working-directory: jetbrains + working-directory: mide-plugins/clion run: gradle --no-daemon test buildPlugin - name: Plugin Verifier - working-directory: jetbrains + working-directory: mide-plugins/clion run: gradle --no-daemon verifyPlugin - name: The archive carries the bundle and every system's okrun - working-directory: jetbrains + working-directory: mide-plugins/clion run: | unzip -l build/distributions/mcpp-clion-*.zip | tee files.txt grep -q "mcpp-clion/backend/mcpp-ide.okb" files.txt @@ -194,14 +195,62 @@ jobs: - uses: actions/upload-artifact@v5 with: name: mcpp-clion-zip - path: jetbrains/build/distributions/*.zip + path: mide-plugins/clion/build/distributions/*.zip + + # The Neovim plugin (mide-plugins/nvim) in a headless Neovim, against the backend built here: over + # the mock mcpp on the oldest and the newest Neovim it supports, then over the real mcpp, which + # builds the test's project. Neovim comes from xlings (`nvim` in xim-pkgindex), each version + # installed and selected with `xlings use`, as mcppls's CI provisions it. + nvim: + name: Neovim plugin + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + NVIM_VERSIONS: 0.10.4 0.12.5 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + - name: Build the backend and the mock mcpp + run: mcpp build + - name: Neovim from xlings + run: | + set -euo pipefail + for version in $NVIM_VERSIONS; do + for attempt in 1 2 3; do + if xlings install "nvim@$version" -y -g; then break; fi + [ "$attempt" = 3 ] && { echo "::error::nvim@$version could not be installed"; exit 1; } + sleep 30 + done + done + # Neovim runs outside the repository: inside it, xlings resolves tools through the project's + # own subos (.xlings.json), which holds mcpp only. The suite finds the backend by its own path. + - name: The plugin on each Neovim, over the mock mcpp + working-directory: ${{ runner.temp }} + run: | + set -euo pipefail + failed='' + for version in $NVIM_VERSIONS; do + xlings use nvim "$version" + nvim --version | head -1 | tee said.txt + grep -qF "v$version" said.txt + echo "::group::Neovim $version" + nvim --headless --clean -l "$GITHUB_WORKSPACE/mide-plugins/nvim/tests/smoke.lua" || failed="$failed $version" + echo "::endgroup::" + done + if [ -n "$failed" ]; then echo "::error::the Neovim plugin failed on:$failed"; exit 1; fi + - name: The plugin over the real mcpp + working-directory: ${{ runner.temp }} + run: MCPP_NVIM_MCPP="$(command -v mcpp)" nvim --headless --clean -l "$GITHUB_WORKSPACE/mide-plugins/nvim/tests/smoke.lua" # The packages, opened on every system they serve: the one bundle each editor package carries, # started by the okrun it carries for that system (scripts/check-package.sh), and the backend's own # universal package started by its launcher. packages: name: packages on ${{ matrix.os }} - needs: [vscode, jetbrains] + needs: [vscode, clion] runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: diff --git a/.gitignore b/.gitignore index e56e62f..708cb65 100644 --- a/.gitignore +++ b/.gitignore @@ -1,16 +1,16 @@ target/ .mcpp/ node_modules/ -vscode/dist/ -vscode/out/ -vscode/*.vsix -vscode/bin/ -jetbrains/build/ -jetbrains/.gradle/ -jetbrains/.intellijPlatform/ +mide-plugins/vscode/dist/ +mide-plugins/vscode/out/ +mide-plugins/vscode/*.vsix +mide-plugins/vscode/bin/ +mide-plugins/clion/build/ +mide-plugins/clion/.gradle/ +mide-plugins/clion/.intellijPlatform/ +mide-plugins/clion/.kotlin/ webui/dist/ *.log compile_commands.json dist-upload/ -jetbrains/.kotlin/ .xlings/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d1184b6..3553332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 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. + +- **mcppls is found as the command that starts it.** mcppls 0.0.18 and later carry the server as an + okb that the platform's runner starts (`payload.json`'s `server`: `bin/okrun bin/mcppls.okb`). The + backend ran the okb itself for `--version`, so the VS Code extension's mcppls was reported as not + reporting its version (`permission denied`), and the payload in the user data directory was looked + for as `bin/mcppls`. A candidate is now a payload directory, whose `server` is started as it says, + or a program; `languageService/status` reports the `command` beside the `program`. What + `server` names is followed only inside the payload (a relative path with no `..` part), as mcppls + reads it. +- **VS Code**: the candidate is the mcppls extension's payload directory (`MCPPLS_PAYLOAD`, else + `/payload`), and `MCPPLS_SERVER`. +- **CLion**: the candidate is the payload the mcppls CLion plugin carries (mcppls 0.0.19 ships one + package per system, each with its server), with its runner made executable again when the + plugin's install dropped the bit, and `MCPPLS_SERVER`. *Install mcppls* and *Update + mcppls* open Settings | Plugins, where the Marketplace gives this system's package. +- **CLion 2026.1 and later** (since-build 261), as the mcppls plugin mcpp-clion depends on; the + Plugin Verifier checks 2026.1 and 2026.2.3, and the build loads this machine's mcppls 0.0.19 + package. +- **The editor plugins are `mide-plugins/`**: `mide-plugins/vscode` (mcpp-vscode), + `mide-plugins/clion` (mcpp-clion; the protocol's editor id stays `jetbrains`, the IntelliJ + platform's) and `mide-plugins/nvim` (mcpp-nvim). The backend, the panels, `spec/`, `protocol/` and + the tests stay at the root; both earlier adapters build and test as before. +- **mcpp-nvim, mcpp's project features in Neovim 0.10 and later**, over the same backend through + Neovim's own JSON-RPC client: `:Mcpp ` with completion, from the backend's command + table for the editor `nvim`; builds, tests, cleanups and packs as jobs streamed into + `mcpp://output`, cancelled with `:Mcpp cancel`; the program in a terminal buffer; debugging through + nvim-dap when it is present; targets, toolchains, new projects and libraries through + `vim.ui.select` and `vim.ui.input`; targets and platforms, the cache and the graphs as text in + floating windows; mcpp's diagnostics in the namespaces `mcpp` and `mcpp.toml`, and + `:Mcpp problems` in the quickfix list; `require('mcpp').status()` for the statusline. It registers + no language service: mcppls comes with mcppls-nvim (`xlings install mcppls-nvim`), which the plugin + names when mcppls is missing or older. The backend is `mcpp-ide` on PATH + (`xlings install mcpp-ide`) or setup()'s `backend`; `xlings install mcpp-ide-nvim` installs the + plugin with the backend and sets it up at Neovim's start (`vim.g.mcpp_auto_start = false` opts + out). A backend that ends is said; the requests and jobs waiting on it fail, and the next command + starts another, which watches the same projects. A headless suite runs it against the backend on + Neovim 0.10.4 and 0.12.5, over the mock mcpp and over the real one. +- **The protocol's editor `nvim`**, and each row of `commands/list` names its `subcommand` (the id + after `mcpp.`, or the row's own: `mcpp.cancelJob` is `cancel`). `spec/commands.json` says why an + editor is left out of a row (`omitted`); Neovim's table has `mcpp.problems` (the action `problems`) + and the quick menu, and leaves out the settings panel and the panels' entry pages. Which rows an + editor offers is one function, shared by the backend and `mcpp-ide-dev gen`. +- **The cache inventory's rows carry their size as text** (`bytesText`), for an editor that shows it + as text. + ## 0.1.0 The first release of mcpp-ide: one backend for mcpp's editor integrations, the VS Code extension diff --git a/README.md b/README.md index 165ec15..c2b8294 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # mcpp-ide - the IDE layer of mcpp > One backend for every editor of [mcpp](https://github.com/mcpp-community/mcpp) projects: VS Code -> (mcpp-vscode) and CLion (mcpp-clion) build, run, test, debug and pack alike +> (mcpp-vscode), CLion (mcpp-clion) and Neovim (mcpp-nvim) build, run, test, debug and pack alike **English** | [简体中文](README.zh-CN.md) @@ -16,20 +16,22 @@ 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. Language features of C++ files come from -[mcppls](https://github.com/Sunrisepeak/mcpp-language-server); mcpp-ide registers none. +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. ## Features -| | VS Code (mcpp-vscode) | CLion (mcpp-clion) | -|---|---|---| -| Build, run, test | Tasks in a pseudoterminal showing mcpp's output | "mcpp" run configurations, PTY console, ▶ beside `main` | -| Test tree, re-run one test | Testing view | IDE test runner | -| Debug | CodeLLDB, C/C++ (gdb, lldb), lldb-dap | CLion's GDB or LLDB | -| Diagnostics | Problems, from mcpp's located diagnostics | Console links to the reported location | -| mcpp.toml | Problems from mcpp; completion, hover, outline from mcpp's key registry | Problems from mcpp | -| Targets and platforms, libraries, graphs, cache, pack, settings | Shared web panels | The same panels (JCEF) | -| Language features | mcppls (installed with the extension) | mcppls CLion plugin (a dependency) | +| | VS Code (mcpp-vscode) | CLion (mcpp-clion) | Neovim (mcpp-nvim) | +|---|---|---|---| +| Build, run, test | Tasks in a pseudoterminal showing mcpp's output | "mcpp" run configurations, PTY console, ▶ beside `main` | `:Mcpp build`, `test`: jobs streamed into a buffer; `:Mcpp run` in a terminal buffer | +| 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()` | +| 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 - **Only mcpp's contract** -- no manifest parsing, no dependency resolution of its own @@ -43,8 +45,8 @@ UI and share one set of web panels. Language features of C++ files come from ``` Read agents/README.md of https://github.com/mcpp-community/mcpp-ide, then install mcpp-ide for -the editor I use (VS Code or CLion) from its latest release, and check it with `mcpp-ide self-check` -on my mcpp project. +the editor I use (VS Code, CLion or Neovim) from its latest release, and check it with +`mcpp-ide self-check` on my mcpp project. ``` ### From a release @@ -53,21 +55,24 @@ on my mcpp project. |---|---|---| | VS Code | the Marketplace (`mcpp-community.mcpp-vscode`), or `mcpp-vscode-.vsix` | search *mcpp*, or `code --install-extension mcpp-vscode-.vsix` | | VS Code-compatible editors | Open VSX, or the `.vsix` | install the `.vsix` from the extensions view | -| CLion 2025.2+ | JetBrains Marketplace (`mcpp-clion`), or `mcpp-clion-.zip` | Settings → Plugins → Install Plugin from Disk; the mcppls CLion plugin first | +| 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` | 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 -Linux x64/arm64, macOS arm64 and Windows x64. +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). ### From source ```bash -mcpp build # the backend, the mock mcpp, mcpp-ide-dev -mcpp test # the backend's tests -cd vscode && npm ci && npm test # the extension's and the panels' tests -npm run package # dist/mcpp-vscode-.vsix -cd ../jetbrains && gradle buildPlugin verifyPlugin # build/distributions/mcpp-clion-.zip +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 +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 ``` `xlings install gradle` brings Gradle and its JDK. @@ -77,8 +82,10 @@ cd ../jetbrains && gradle buildPlugin verifyPlugin # build/distributions/mcpp- - [docs/architecture.md](docs/architecture.md) -- the three layers and their boundaries - [protocol/protocol.schema.json](protocol/protocol.schema.json) -- the backend's methods and notifications - [protocol/mcpp-contract.schema.json](protocol/mcpp-contract.schema.json) -- the part of mcpp's contract it reads -- [spec/](spec) -- settings, commands, translations: the source of both editors' declarations -- [vscode/README.md](vscode/README.md), [jetbrains/README.md](jetbrains/README.md) +- [spec/](spec) -- settings, commands, translations: the source of the editors' declarations and command tables +- [mide-plugins/vscode/README.md](mide-plugins/vscode/README.md), + [mide-plugins/clion/README.md](mide-plugins/clion/README.md), + [mide-plugins/nvim/README.md](mide-plugins/nvim/README.md) ## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 4d760b3..58218bc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,7 +1,7 @@ # mcpp-ide - mcpp 的 IDE 层 -> 所有编辑器共用一个后端来对待 [mcpp](https://github.com/mcpp-community/mcpp) 工程:VS Code(mcpp-vscode) -> 与 CLion(mcpp-clion)的构建、运行、测试、调试、打包行为一致 +> 所有编辑器共用一个后端来对待 [mcpp](https://github.com/mcpp-community/mcpp) 工程:VS Code(mcpp-vscode)、 +> CLion(mcpp-clion)与 Neovim(mcpp-nvim)的构建、运行、测试、调试、打包行为一致 [English](README.md) | **简体中文** @@ -12,20 +12,21 @@ 每个编辑器各写一遍,这些决定就会分歧;从一个编辑器换到另一个,用户遇到的就是另一个工具。 mcpp-ide 只做一次。它的后端 `mcpp-ide serve` 是一个 C++ 程序,只读取 mcpp 的机器契约(事件流、结果记录、启动描述、 -JSON 查询),通过 JSON-RPC 回答编辑器。VS Code 扩展与 CLion 插件把这些回答映射到各自的界面,并共用一套网页面板。 +JSON 查询),通过 JSON-RPC 回答编辑器。VS Code 扩展与 CLion 插件把这些回答映射到各自的界面,并共用一套网页面板; +Neovim 插件把它们映射到缓冲区、浮动窗口与 quickfix 列表。各编辑器插件位于本仓库的 `mide-plugins/`。 C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-language-server);mcpp-ide 不注册任何语言服务。 ## 功能 -| | VS Code(mcpp-vscode) | CLion(mcpp-clion) | -|---|---|---| -| 构建、运行、测试 | 伪终端中的任务,显示 mcpp 自身的输出 | "mcpp" 运行配置、PTY 控制台、`main` 旁的 ▶ | -| 测试树、单个测试重跑 | 测试视图 | IDE 测试运行器 | -| 调试 | CodeLLDB、C/C++(gdb、lldb)、lldb-dap | CLion 的 GDB 或 LLDB | -| 诊断 | 问题面板,来自 mcpp 带位置的诊断 | 控制台链接到报告的位置 | -| mcpp.toml | 问题来自 mcpp;补全、悬停、大纲来自 mcpp 的键注册表 | 问题来自 mcpp | -| 目标与平台、库、依赖图、缓存、打包、设置 | 共享网页面板 | 同一套面板(JCEF) | -| 语言功能 | mcppls(随扩展一并安装) | mcppls CLion 插件(依赖) | +| | VS Code(mcpp-vscode) | CLion(mcpp-clion) | Neovim(mcpp-nvim) | +|---|---|---|---| +| 构建、运行、测试 | 伪终端中的任务,显示 mcpp 自身的输出 | "mcpp" 运行配置、PTY 控制台、`main` 旁的 ▶ | `:Mcpp build`、`test`:任务输出流入缓冲区;`:Mcpp run` 在终端缓冲区中运行 | +| 测试树、单个测试重跑 | 测试视图 | 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()` 中 | +| 语言功能 | mcppls(随扩展一并安装) | mcppls CLion 插件(依赖) | mcppls-nvim(`xlings install mcppls-nvim`) | - **处处同一答案**——构建运行什么、测试如何报告、清理的代价:都由后端决定 - **只读 mcpp 的契约**——不自行解析清单,不自行解析依赖 @@ -38,7 +39,7 @@ C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-lan ### 让 Agent 安装 ``` -阅读 https://github.com/mcpp-community/mcpp-ide 的 agents/README.md,为我使用的编辑器(VS Code 或 CLion) +阅读 https://github.com/mcpp-community/mcpp-ide 的 agents/README.md,为我使用的编辑器(VS Code、CLion 或 Neovim) 从最新发布安装 mcpp-ide,并在我的 mcpp 工程上用 `mcpp-ide self-check` 检查。 ``` @@ -48,20 +49,23 @@ C++ 文件的语言功能来自 [mcppls](https://github.com/Sunrisepeak/mcpp-lan |---|---|---| | VS Code | Marketplace(`mcpp-community.mcpp-vscode`),或 `mcpp-vscode-.vsix` | 搜索 *mcpp*,或 `code --install-extension mcpp-vscode-.vsix` | | 兼容 VS Code 的编辑器 | Open VSX,或 `.vsix` | 在扩展视图中安装 `.vsix` | -| CLion 2025.2+ | JetBrains Marketplace(`mcpp-clion`),或 `mcpp-clion-.zip` | Settings → Plugins → Install Plugin from Disk;先安装 mcppls CLion 插件 | +| 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` | 每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.17.3 上的 -x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 的 okrun 携带。 +x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 的 okrun 携带。Neovim 插件不携带后端: +它启动 xlings 安装的 `mcpp-ide` 命令(由 okrun 运行同一个映像包)。 ### 从源码构建 ```bash -mcpp build # 后端、mock mcpp、mcpp-ide-dev -mcpp test # 后端测试 -cd vscode && npm ci && npm test # 扩展与面板的测试 -npm run package # dist/mcpp-vscode-.vsix -cd ../jetbrains && gradle buildPlugin verifyPlugin # build/distributions/mcpp-clion-.zip +mcpp build # 后端、mock mcpp、mcpp-ide-dev +mcpp test # 后端测试 +cd mide-plugins/vscode && npm ci && npm test # 扩展与面板的测试 +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 插件的测试 ``` `xlings install gradle` 会一并带来 Gradle 及其 JDK。 @@ -71,8 +75,9 @@ cd ../jetbrains && gradle buildPlugin verifyPlugin # build/distributions/mcpp- - [docs/architecture.md](docs/architecture.md)——三层及其边界 - [protocol/protocol.schema.json](protocol/protocol.schema.json)——后端的方法与通知 - [protocol/mcpp-contract.schema.json](protocol/mcpp-contract.schema.json)——后端读取的 mcpp 契约部分 -- [spec/](spec)——设置、命令、翻译:两个编辑器声明的唯一来源 -- [vscode/README.md](vscode/README.md)、[jetbrains/README.md](jetbrains/README.md) +- [spec/](spec)——设置、命令、翻译:各编辑器声明与命令表的唯一来源 +- [mide-plugins/vscode/README.md](mide-plugins/vscode/README.md)、[mide-plugins/clion/README.md](mide-plugins/clion/README.md)、 + [mide-plugins/nvim/README.md](mide-plugins/nvim/README.md) ## 参与贡献 diff --git a/docs/README.md b/docs/README.md index 8e40105..25d3983 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,5 +3,6 @@ - [architecture.md](architecture.md) -- the three layers, what each holds, and how a request travels - [../protocol/protocol.schema.json](../protocol/protocol.schema.json) -- the backend's protocol - [../agents/README.md](../agents/README.md) -- driving the backend from an agent -- [../vscode/README.md](../vscode/README.md) -- the VS Code extension -- [../jetbrains/README.md](../jetbrains/README.md) -- the CLion plugin +- [../mide-plugins/vscode/README.md](../mide-plugins/vscode/README.md) -- the VS Code extension +- [../mide-plugins/clion/README.md](../mide-plugins/clion/README.md) -- the CLion plugin +- [../mide-plugins/nvim/README.md](../mide-plugins/nvim/README.md) -- the Neovim plugin diff --git a/docs/architecture.md b/docs/architecture.md index c27ac14..e901a75 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,17 +6,36 @@ |---|---|---| | 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 (`vscode/`, `jetbrains/`) | declarations generated from `spec/`, the protocol mapped onto native UI, the panels' host, the backend process, finding the backend, workspace trust | any decision | +| 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 both editors 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). +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). + +## 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/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 +forward to mcppls's commands only VS Code) and, where an editor is left out, why (`omitted`). The +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 +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. The backend reads `mcppls --version`: 0.0.18 and later refresh after any +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. ## A build, end to end @@ -27,7 +46,8 @@ build through mcpp's result record, and an older or missing mcppls is reported w 3. mcpp's narration becomes `job/output`; each event becomes `job/plan`, `job/progress`, `job/diagnostic` or `job/artifact`; the closing envelope arrives in `job/finished`. 4. VS Code shows the narration in a pseudoterminal and the diagnostics in Problems; CLion shows the - narration in a console and its locations as links. + narration in a console and its locations as links; Neovim streams it into `mcpp://output` and + the diagnostics into the namespace `mcpp`. 5. mcpp writes the result record; the backend's watcher sends `result/changed`; mcppls reads the same record and refreshes. @@ -44,7 +64,10 @@ editor package carries that bundle and the runner that starts it on each system: | `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 | 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. CI packs the +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 system and starts what they carry for it (`.github/scripts/check-package.sh`). @@ -52,13 +75,16 @@ 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; -`mcpp-ide-dev gen jetbrains` writes the plugin's actions and message bundles. The backend embeds the -same files (build.mcpp), so the commands an editor registers at run time are the ones it declared. +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 +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 +backend's table holds. ## Fixtures `protocol/fixtures` holds mcpp's real output, captured by `mcpp-ide-dev capture`. The backend's tests check each fixture against `protocol/mcpp-contract.schema.json`, replay them through the mock mcpp, and check the backend's answers against `protocol/protocol.schema.json`; the adapters' tests run on -the same mock. +the same mock (the Neovim plugin's headless suite also over a real mcpp). diff --git a/mcpp.toml b/mcpp.toml index d588935..02c90d2 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ # mcpp-ide: the IDE backend for mcpp (one C++ program, `mcpp-ide serve`, JSON-RPC # on its standard streams), the shared panels, and the editor adapters that host -# it (vscode/, jetbrains/). This file builds the backend. +# it (mide-plugins/: vscode, clion, nvim). This file builds the backend. # # The operating system is reached through mcpplibs.os (processes, the # environment, the standard streams, a request to end), which is openkal @@ -9,7 +9,7 @@ # M2/M3), where the system it runs upon is answered when the image is loaded. [package] name = "mcpp-ide" -version = "0.1.0" +version = "0.1.1" description = "The IDE backend for mcpp: sessions, jobs, records and view models over mcpp's control contract" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/jetbrains/README.md b/mide-plugins/clion/README.md similarity index 92% rename from jetbrains/README.md rename to mide-plugins/clion/README.md index c1822d8..45c6371 100644 --- a/jetbrains/README.md +++ b/mide-plugins/clion/README.md @@ -1,6 +1,6 @@ # mcpp-clion -mcpp's project features in CLion 2025.2 and later, over the mcpp-ide backend: +mcpp's project features in CLion 2026.1 and later, over the mcpp-ide backend: - the **mcpp** run configuration type: run (the program in a PTY console), test (the IDE's test tree) or build, for a target and a profile; a ▶ beside `main` runs the project, or the test whose @@ -21,7 +21,7 @@ registers no language service. xlings install gradle # Gradle and its JDK mcpp build # in the repository root: the backend and mcpp-ide-dev gradle buildPlugin # build/distributions/mcpp-clion-.zip -gradle verifyPlugin # CLion 2025.2 and the version in gradle.properties +gradle verifyPlugin # CLion 2026.1 and the version in gradle.properties ``` What the archive carries of the backend: `MCPP_IDE_OKB` (the ok-image bundle, as diff --git a/jetbrains/build.gradle.kts b/mide-plugins/clion/build.gradle.kts similarity index 74% rename from jetbrains/build.gradle.kts rename to mide-plugins/clion/build.gradle.kts index 1c35268..2e58ddf 100644 --- a/jetbrains/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, and the shared panels (../../webui) -- 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 +// language service. 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. // @@ -20,6 +20,9 @@ plugins { group = "io.github.mcpp-community" version = providers.gradleProperty("pluginVersion").get() +// The repository: this plugin is mide-plugins/clion of it. +val repositoryRoot: File = rootDir.resolve("../..").canonicalFile + repositories { mavenCentral() intellijPlatform { defaultRepositories() } @@ -27,12 +30,23 @@ repositories { // The mcppls CLion plugin, from its release: the dependency the sandbox, the tests and the verifier // load beside this plugin. Fetched once, when the build is configured, because the platform's -// dependency resolution reads it then. +// dependency resolution reads it then. mcppls ships one package per platform, each carrying that +// platform's server (mcppls 0.0.19); this machine's is the one loaded here. val mcpplsVersion = providers.gradleProperty("mcpplsPluginVersion").get() -val mcpplsArchive: File = layout.buildDirectory.file("mcppls/mcppls-clion-$mcpplsVersion.zip").get().asFile +val hostPlatform: String = run { + val os = System.getProperty("os.name").lowercase() + val system = when { + os.startsWith("windows") -> "win32" + os.startsWith("mac") -> "darwin" + else -> "linux" + } + val arch = System.getProperty("os.arch").lowercase() + "$system-${if (arch == "aarch64" || arch == "arm64") "arm64" else "x64"}" +} +val mcpplsArchive: File = layout.buildDirectory.file("mcppls/mcppls-clion-$mcpplsVersion-$hostPlatform.zip").get().asFile if (!mcpplsArchive.exists()) { mcpplsArchive.parentFile.mkdirs() - val url = "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/Sunrisepeak/mcpp-language-server/releases/download/v$mcpplsVersion/mcppls-clion-$mcpplsVersion.zip" + val url = "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/Sunrisepeak/mcpp-language-server/releases/download/v$mcpplsVersion/mcppls-clion-$mcpplsVersion-$hostPlatform.zip" val partial = File(mcpplsArchive.path + ".part") uri(url).toURL().openStream().use { input -> partial.outputStream().use { input.copyTo(it) } } partial.renameTo(mcpplsArchive) @@ -57,27 +71,27 @@ dependencies { kotlin { jvmToolchain(21) - // Built against the current CLion, run on 2025.2 too: no platform default method is copied - // into this plugin's classes as a call to `super` that 2025.2 lacks. + // Built against the current CLion, run on 2026.1 too: no platform default method is copied + // into this plugin's classes as a call to `super` that 2026.1 lacks. compilerOptions { jvmDefault = JvmDefaultMode.NO_COMPATIBILITY } } -// ../spec -> the generated resources: the actions (META-INF/mcpp-actions.xml, included by plugin.xml) +// ../../spec -> the generated resources: the actions (META-INF/mcpp-actions.xml, included by plugin.xml) // and the message bundles. `mcpp-ide-dev` is the backend package's program; MCPP_IDE_DEV names a // built one, else `mcpp run` builds it. val generatedResources = layout.buildDirectory.dir("generated/mcpp-ide") val generateDeclarations = tasks.register("generateDeclarations") { val out = generatedResources.get().asFile - inputs.dir(rootDir.resolve("../spec")) + inputs.dir(repositoryRoot.resolve("spec")) outputs.dir(out) - workingDir = rootDir.resolve("..") + workingDir = repositoryRoot val dev = System.getenv("MCPP_IDE_DEV") 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. +// ../../webui, as it is: the panels JCEF loads. val copyWebui = tasks.register("copyWebui") { - from(rootDir.resolve("../webui")) { exclude("tests/**", "tsconfig.json", "js/types.d.ts") } + from(repositoryRoot.resolve("webui")) { exclude("tests/**", "tsconfig.json", "js/types.d.ts") } into(layout.buildDirectory.dir("generated/webui/webui")) } sourceSets { @@ -107,22 +121,24 @@ intellijPlatform { id = "io.github.mcpp-community.mcpp-clion" name = "mcpp" version = providers.gradleProperty("pluginVersion") + // 2026.1, as the mcppls plugin it depends on: the first IDE the JetBrains Marketplace gives + // the mcppls package of its own system. ideaVersion { - sinceBuild = "252" + sinceBuild = "261" untilBuild = provider { null } } } // `gradle verifyPlugin`: the oldest CLion the plugin claims and the one it is built on. pluginVerification { ides { - create(IntelliJPlatformType.CLion, "2025.2") + create(IntelliJPlatformType.CLion, "2026.1") create(IntelliJPlatformType.CLion, providers.gradleProperty("platformVersion").get()) } } } tasks.test { - systemProperty("mcppide.fixtures", rootDir.resolve("../protocol/fixtures").absolutePath) + systemProperty("mcppide.fixtures", repositoryRoot.resolve("protocol/fixtures").absolutePath) providers.environmentVariable("MCPP_IDE_BACKEND").orNull?.let { systemProperty("mcppide.backend", it) } providers.environmentVariable("MCPP_IDE_MOCK_MCPP").orNull?.let { systemProperty("mcppide.mockMcpp", it) } systemProperty("patch.engine.backend.freeze.timeout", "0") diff --git a/jetbrains/gradle.properties b/mide-plugins/clion/gradle.properties similarity index 65% rename from jetbrains/gradle.properties rename to mide-plugins/clion/gradle.properties index b4ba3b3..f36b18e 100644 --- a/jetbrains/gradle.properties +++ b/mide-plugins/clion/gradle.properties @@ -1,11 +1,11 @@ # The CLion build the plugin is compiled and tested against. sinceBuild in build.gradle.kts gates -# where it installs (2025.2 and later); the verifier checks both ends. +# where it installs (2026.1 and later); the verifier checks both ends. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.1.0 +pluginVersion = 0.1.1 # The mcppls CLion plugin this one depends on (io.github.sunrisepeak.mcppls): -# the release archive, fetched for the sandbox, the tests and the verifier. -mcpplsPluginVersion = 0.0.17 +# this machine's package of the release, fetched for the sandbox, the tests and the verifier. +mcpplsPluginVersion = 0.0.19 org.gradle.jvmargs = -Xmx2g # The IDE ships the Kotlin standard library; a plugin bundles no second copy. kotlin.stdlib.default.dependency = false diff --git a/jetbrains/settings.gradle.kts b/mide-plugins/clion/settings.gradle.kts similarity index 100% rename from jetbrains/settings.gradle.kts rename to mide-plugins/clion/settings.gradle.kts diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppIcons.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppIcons.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppIcons.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppIcons.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt similarity index 92% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 03dab98..fce8163 100644 --- a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -12,6 +12,7 @@ import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationInfo +import com.intellij.openapi.application.PathManager import com.intellij.openapi.components.Service import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.project.Project @@ -63,11 +64,16 @@ class McppSession(private val project: Project) : Disposable { private fun pluginRoot(): File? = pluginDirectory() /** - * The servers to ask first. The mcppls CLion plugin starts mcppls from PATH or from the payload its - * `--install` puts in the user data directory, which the backend looks at itself; MCPPLS_SERVER - * names another. + * The servers to ask first: MCPPLS_SERVER, then the payload the mcppls CLion plugin carries + * (`/mcppls-clion/payload`, whose payload.json's `server` the backend starts: the runner + * and the okb, the runner made executable again; looking a plugin up by its id is the platform's + * internal API). PATH and xlings are the backend's own to look at. */ - private fun mcpplsCandidates(): JsonArray = JsonArray().apply { System.getenv("MCPPLS_SERVER")?.let { add(it) } } + private fun mcpplsCandidates(): JsonArray = JsonArray().apply { + System.getenv("MCPPLS_SERVER")?.let { add(it) } + runCatching { PathManager.getPluginsDir().resolve("mcppls-clion/payload").toFile() }.getOrNull() + ?.takeIf { it.resolve("payload.json").isFile }?.let { add(Locate.runnablePayload(it).path) } + } fun start(): CompletableFuture { started?.let { if (backend?.running == true || !it.isDone) return it } diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt similarity index 97% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt index 7afd5a6..8bc8e52 100644 --- a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/actions/Dispatch.kt @@ -8,7 +8,6 @@ import com.intellij.execution.ProgramRunnerUtil import com.intellij.execution.RunManager import com.intellij.execution.executors.DefaultDebugExecutor import com.intellij.execution.executors.DefaultRunExecutor -import com.intellij.ide.BrowserUtil import com.intellij.ide.impl.ProjectUtil import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager @@ -222,11 +221,9 @@ object Dispatch { } fun languageServiceAction(project: Project, id: String) { - // The mcppls CLion plugin: its page on the Marketplace when it is listed there, else its releases. - if (id == "install" || id == "update") { - ShowSettingsUtil.getInstance().showSettingsDialog(project, "Plugins") - BrowserUtil.browse("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/Sunrisepeak/mcpp-language-server/releases") - } + // 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. + if (id == "install" || id == "update") ShowSettingsUtil.getInstance().showSettingsDialog(project, "Plugins") } private fun languageService(project: Project) { diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Backend.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt similarity index 76% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt index 3b5c449..8d9b5a0 100644 --- a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt @@ -7,6 +7,7 @@ // 3. the mcpp-ide xlings installed, then PATH. package io.github.mcppcommunity.mcppclion.backend +import com.google.gson.JsonParser import java.io.File object Locate { @@ -44,6 +45,24 @@ object Locate { return f } + /** + * A payload of mcppls (`payload.json` beside `bin/`), with the program its `server` starts made + * executable again: the runner, else a program server. A plugin's install may not keep the bit; + * mcppls-clion puts it back when it starts its server, and the backend may run that program + * before, to read mcppls's version. Only a relative path with no `..` part is followed. + */ + fun runnablePayload(payload: File): File { + val server = runCatching { + JsonParser.parseString(File(payload, "payload.json").readText()).asJsonObject.getAsJsonObject("server") + }.getOrNull() + val named = listOf("runner", "path").firstNotNullOfOrNull { key -> + server?.get(key)?.takeIf { it.isJsonPrimitive }?.asString + } + named?.takeIf { !File(it).isAbsolute && it.split('/', '\\').none { part -> part == ".." } } + ?.let { File(payload, it) }?.takeIf { it.isFile }?.let(::runnable) + return payload + } + fun backend(configured: String, pluginRoot: File?, path: String? = System.getenv("PATH"), os: String = System.getProperty("os.name"), arch: String = System.getProperty("os.arch"), home: String = System.getProperty("user.home")): Located? { diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppConsoleFilter.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppConsoleFilter.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppConsoleFilter.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppConsoleFilter.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppProducers.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppRunConfiguration.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppConfigurable.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppSettings.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppSettings.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppSettings.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/settings/McppSettings.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt similarity index 100% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/McppPanels.kt diff --git a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt similarity index 99% rename from jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt rename to mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt index 38751fc..b2b7061 100644 --- a/jetbrains/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/webui/WebuiPanel.kt @@ -1,4 +1,4 @@ -// The shared panels (../webui) in a JCEF browser. The page speaks the backend's protocol through +// 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 diff --git a/jetbrains/src/main/resources/META-INF/mcpp-jcef.xml b/mide-plugins/clion/src/main/resources/META-INF/mcpp-jcef.xml similarity index 100% rename from jetbrains/src/main/resources/META-INF/mcpp-jcef.xml rename to mide-plugins/clion/src/main/resources/META-INF/mcpp-jcef.xml diff --git a/jetbrains/src/main/resources/META-INF/mcpp-testrunner.xml b/mide-plugins/clion/src/main/resources/META-INF/mcpp-testrunner.xml similarity index 100% rename from jetbrains/src/main/resources/META-INF/mcpp-testrunner.xml rename to mide-plugins/clion/src/main/resources/META-INF/mcpp-testrunner.xml diff --git a/jetbrains/src/main/resources/META-INF/plugin.xml b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml similarity index 100% rename from jetbrains/src/main/resources/META-INF/plugin.xml rename to mide-plugins/clion/src/main/resources/META-INF/plugin.xml diff --git a/jetbrains/src/main/resources/META-INF/pluginIcon.svg b/mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg similarity index 100% rename from jetbrains/src/main/resources/META-INF/pluginIcon.svg rename to mide-plugins/clion/src/main/resources/META-INF/pluginIcon.svg diff --git a/jetbrains/src/main/resources/icons/mcpp.svg b/mide-plugins/clion/src/main/resources/icons/mcpp.svg similarity index 100% rename from jetbrains/src/main/resources/icons/mcpp.svg rename to mide-plugins/clion/src/main/resources/icons/mcpp.svg diff --git a/jetbrains/src/main/resources/icons/mcpp_dark.svg b/mide-plugins/clion/src/main/resources/icons/mcpp_dark.svg similarity index 100% rename from jetbrains/src/main/resources/icons/mcpp_dark.svg rename to mide-plugins/clion/src/main/resources/icons/mcpp_dark.svg diff --git a/jetbrains/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt similarity index 79% rename from jetbrains/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt rename to mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt index f83f07f..416acdf 100644 --- a/jetbrains/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt @@ -48,6 +48,26 @@ class BackendTest { root.deleteRecursively() } + @Test + fun aPayloadsRunnerIsMadeExecutableAgain() { + assumeTrue("the executable bit is a POSIX file mode", !Locate.windows) + val payload = Files.createTempDirectory("mcppclion-payload-").toFile() + File(payload, "bin").mkdirs() + val runner = File(payload, "bin/okrun").apply { writeText(""); setExecutable(false) } + val image = File(payload, "bin/mcppls.okb").apply { writeText(""); setExecutable(false) } + File(payload, "payload.json").writeText("""{"server": {"runner": "bin/okrun", "path": "bin/mcppls.okb"}}""") + assertEquals(payload, Locate.runnablePayload(payload)) + assertTrue("the runner", runner.canExecute()) + assertTrue("the image is not a program", !image.canExecute()) + // A path that leaves the payload is not followed. + val outside = File(payload.path + "-outside").apply { writeText(""); setExecutable(false) } + File(payload, "payload.json").writeText("""{"server": {"runner": "../${outside.name}", "path": "bin/mcppls.okb"}}""") + Locate.runnablePayload(payload) + assertTrue("outside the payload", !outside.canExecute()) + outside.delete() + payload.deleteRecursively() + } + @Test fun sessionOverTheMockMcpp() { val backend = System.getProperty("mcppide.backend") diff --git a/mide-plugins/nvim/README.md b/mide-plugins/nvim/README.md new file mode 100644 index 0000000..426b977 --- /dev/null +++ b/mide-plugins/nvim/README.md @@ -0,0 +1,166 @@ +# mcpp-nvim + +mcpp's project features in Neovim 0.10 and later, over the mcpp-ide backend (`mcpp-ide serve`): +builds, tests, cleanups and packs as jobs whose output streams into a buffer, the program in a +terminal, mcpp's diagnostics, targets and platforms, toolchains, libraries, the build cache and the +graphs, and a statusline component. It is the Neovim adapter of +[mcpp-ide](https://github.com/mcpp-community/mcpp-ide), beside mcpp-vscode and mcpp-clion: what a +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). + +| | | +|---|---| +| Plugin | mcpp-nvim, this directory (`mide-plugins/nvim` of mcpp-ide); its version is mcpp-ide's | +| Lua module | `mcpp` (`require('mcpp')`) | +| Command | `:Mcpp [subcommand]` | +| Diagnostic namespaces | `mcpp` (a build's problems), `mcpp.toml` (the manifest's) | +| Autocommand | `User McppStatus` | +| xlings package | `mcpp-ide-nvim` | + +## Install + +```bash +xlings install mcpp-ide-nvim # the plugin, on Neovim's runtimepath, and the backend mcpp-ide +xlings install mcppls-nvim # mcppls and its plugin, for C++ files +``` + +`mcpp-ide-nvim` installs this directory from the source tree of the mcpp-ide release, as +`mcppls-nvim` installs `editors/nvim` of mcppls's, and depends on `mcpp-ide`, the package of the +backend. It also sets the plugin up: `require('mcpp').setup()` runs at Neovim's start, unless the +user's init.lua has loaded the module already (its own `setup()` keeps its options) or sets +`vim.g.mcpp_auto_start = false`. Without xlings, put the directory on the runtimepath: + +```lua +-- lazy.nvim +{ + 'mcpp-community/mcpp-ide', + config = function(plugin) + vim.opt.rtp:append(plugin.dir .. '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/mide-plugins/nvim') + require('mcpp').setup() + end, +} + +-- without a plugin manager, after: git clone https://github.com/mcpp-community/mcpp-ide ~/.local/share/mcpp-ide +vim.opt.rtp:append(vim.fn.expand('~/.local/share/mcpp-ide/mide-plugins/nvim')) +require('mcpp').setup() +``` + +## Setup + +```lua +require('mcpp').setup({ + backend = nil, -- the mcpp-ide program, an ok-image bundle (*.okb, started by okrun on PATH), + -- or an argv; default: mcpp-ide on PATH + settings = { -- mcpp's settings (spec/settings.json), validated by the backend + -- ['mcpp.path'] = '/path/to/mcpp', + -- ['mcpp.build.target'] = 'x86_64-linux-musl', + -- ['mcpp.build.onSave'] = true, + }, + trusted = true, -- false: mcpp runs with the effects mcpp.trust.denyEffects lists denied +}) +``` + +**The backend.** `backend`, else `mcpp-ide` on `PATH`, which `xlings install mcpp-ide` provides +(okrun starting its ok-image bundle). When neither is found the plugin says so, naming +`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 +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` +and `exit` when Neovim exits. + +**Trust.** Neovim has no workspace trust. A project is trusted, as Neovim runs what its user opens, +unless `trusted = false`, which denies the effects `mcpp.trust.denyEffects` lists (`mcpp --deny`). + +**Settings read here.** `mcpp.task.clearTerminal`, `mcpp.task.revealTerminal` (`always`, +`onFailure`, `never`) and `mcpp.task.focusTerminal` govern the output window; `mcpp.task.runArgs` +the program's arguments; `mcpp.build.onSave` a build on saving a source, `mcpp.toml` or +`build.mcpp`; `mcpp.toml.diagnostics.enabled` the manifest's problems; +`mcpp.diagnostics.selfCheckOnStartup` the self-check at start; `mcpp.ui.statusBar.show` the +statusline component. Every other setting is the backend's. + +## Commands + +`:Mcpp ` offers the backend's command table for Neovim (`commands/list` with the editor +`nvim`), with completion; a subcommand is a command's id after `mcpp.` (`mcpp.build` is +`:Mcpp build`), or the name its row gives. `:Mcpp` alone opens the quick menu. Each row's action is +mapped onto Neovim: + +| Action | In Neovim | Subcommands | +|---|---|---| +| job, rpc | mcpp's narration streamed into `mcpp://output`, its end said there and, when it fails, as a notification | `build`, `test [filter]`, `autoConfigureModules`, `refreshCompilationDatabase`, `library.updateIndex` | +| run | `run/launch` builds; its `argv`, `cwd` and `env` start in a terminal buffer | `run [bin]` | +| debug | `debug/configuration` handed to nvim-dap; offered only when nvim-dap is loaded | `debug [bin]` | +| cache | the backend's plan, confirmed at the level it asks for (`vim.ui.select`), applied as a job | `clean`, `cleanStaleArtifacts`, `cleanProjectArtifacts`, `gcGlobalCache`, `pruneGlobalCache`, `verifyGlobalCache`, `cleanLegacyCache` | +| panel | the view model as text in a floating window, or as choices | `showTargets`, `showToolchains`, `showCachePanel`, `refreshCacheStats`, `project.showDependencyGraph`, `project.showModuleDependencyGraph`; `library.search [keyword]` (choose a package and a version: `mcpp add`); `pack` (format, mode, linkage: `mcpp pack`) | +| cancel | `job/cancel` for every running job | `cancel` | +| selectTarget, installToolchain, selectDefaultToolchain, newProject | `vim.ui.select` and `vim.ui.input` | the same names | +| quickMenu | `menu/quick` in `vim.ui.select` | `:Mcpp`, `showMenu` | +| selfCheck, languageService | a notification | `selfCheck`, `languageServer.refreshState` | +| problems | both namespaces in the quickfix list | `problems` | + +The settings panel is not offered: here the settings are `setup()`'s, which the backend validates. +A forward to mcppls's own commands is not offered either: mcppls-nvim has its own (`:McpplsStatus`, +`:McpplsRestart`, ...). `spec/commands.json` says, per row, why Neovim leaves it out (`omitted`). + +A target chosen with `:Mcpp selectTarget` holds for the Neovim session; `settings` in `setup()` +keeps one. + +**Debugging** needs [nvim-dap](https://github.com/mfussenegger/nvim-dap). The adapters the plugin +recognises, by the name they are registered under, and the configuration the backend writes for +each: `codelldb` (CodeLLDB's), `lldb-dap` or `lldb` (lldb-dap's), `cppdbg` (the C/C++ extension's +OpenDebugAD7), `gdb` (GDB's own DAP, GDB 14 and later). `mcpp.debug.engine` chooses among those +present. A program that runs through another (an ok image under okrun) is debugged as that runner. + +## Diagnostics + +A build's problems, located where mcpp reports them, are the namespace `mcpp`; a build or test of a +project clears that project's. The manifest's problems, from mcpp (`manifest/diagnostics` when it +changes, `manifest/check` when it is saved), are the namespace `mcpp.toml`, on `mcpp.toml`. +`:Mcpp problems` puts both in the quickfix list. + +## Statusline + +`require('mcpp').status()` is the target the project builds for and the jobs running or the last +one's end: `mcpp x86_64-linux-gnu · build ok`, `mcpp this machine · 1 job(s) running`. It is empty +before the backend runs and outside a project. `require('mcpp').status_data()` is the same as data +(with the platform the last plan named and the language service's state); `User McppStatus` fires +when it changes. + +```lua +vim.o.statusline = '%f %= %{v:lua.require("mcpp").status()} ' +``` + +## mcppls alongside + +```lua +require('mcppls').setup() -- C++ files: xlings install mcppls-nvim +require('mcpp').setup() -- mcpp projects +``` + +mcppls 0.0.18 and later refresh after any build through mcpp's result record, so a build started +here needs nothing forwarded. When the mcppls found is missing or older, the plugin says so once at +start, with `xlings install mcppls-nvim`; `:Mcpp languageServer.refreshState` asks again. + +## Tests + +```bash +mcpp build # the backend and the mock mcpp +nvim --headless --clean -l mide-plugins/nvim/tests/smoke.lua # over the mock mcpp +MCPP_NVIM_MCPP="$(command -v mcpp)" nvim --headless --clean -l mide-plugins/nvim/tests/smoke.lua +``` + +Against the backend this repository built (`MCPP_IDE_BACKEND` names another): what the plugin says +without a backend, the start and `initialize`, the command table, `:Mcpp` completion, a build +streamed into the output, a failed build's problem in the namespace and the quickfix list, the +manifest's problem, the views, a run in a terminal, nvim-dap's configuration, a cleanup, the quick +menu, the language service, the self-check, a cancelled build, a library added, a pack, the +target, and the end of the session. Over the mock mcpp the build replays protocol/fixtures; with +`MCPP_NVIM_MCPP` a real mcpp builds the project (with `MCPP_NVIM_TOOLCHAIN`, default `gcc@16.1.0`). +CI runs it on Neovim 0.10.4 and 0.12.5. diff --git a/mide-plugins/nvim/lua/mcpp/commands.lua b/mide-plugins/nvim/lua/mcpp/commands.lua new file mode 100644 index 0000000..ce51cff --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/commands.lua @@ -0,0 +1,463 @@ +-- `:Mcpp `: the backend's command table for Neovim (commands/list, editor `nvim`), each +-- row doing what its `action` says in Neovim's terms. Which argv runs, what a cleanup costs, which +-- target a project builds for and what a menu offers are the backend's answers; this module maps +-- them onto buffers, floating windows, vim.ui.select and vim.ui.input. +local session = require('mcpp.session') +local flow = require('mcpp.flow') +local views = require('mcpp.views') +local diagnostics = require('mcpp.diagnostics') + +local M = {} +local t = session.t +local unpack = table.unpack or unpack + +-- The project a command is for: the nearest mcpp.toml upward from the current file (kept with its +-- buffer), else from the working directory, else the folder the session started for. +function M.root() + if vim.b.mcpp_root then + return vim.b.mcpp_root + end + local name = vim.bo.buftype == '' and vim.api.nvim_buf_get_name(0) or '' + local from = { vim.fn.getcwd() } + if name ~= '' then + table.insert(from, 1, vim.fs.dirname(name)) + end + for i, dir in ipairs(from) do + local found = vim.fs.find('mcpp.toml', { upward = true, path = dir, type = 'file' })[1] + if found then + local root = vim.fs.dirname(found) + if name ~= '' and i == 1 then + vim.b.mcpp_root = root + end + return root + end + end + return session.state.folder +end + +local function report(err) + vim.notify(t('mcpp: {0}', err and err.message or '?'), vim.log.levels.ERROR) +end + +-- nvim-dap's adapters, named as they are usually registered, and the backend's debug engine each +-- one speaks: CodeLLDB's own configuration, lldb-dap's (registered as `lldb` too), the C/C++ +-- extension's OpenDebugAD7 (`cppdbg`), GDB's own DAP (`gdb -i dap`, GDB 14 and later). +local DAP_ADAPTERS = { + { 'codelldb', 'lldb' }, { 'lldb-dap', 'lldb-dap' }, { 'lldb', 'lldb-dap' }, { 'cppdbg', 'cppdbg' }, { 'gdb', 'gdb' }, +} + +local function dap() + local ok, module = pcall(require, 'dap') + return ok and module or nil +end + +-- What each action kind does. Every handler runs in a flow, with the row and the arguments typed +-- after the subcommand. +local actions = {} + +function actions.job(row, args, root) + local params = { kind = row.action.job, root = root } + if row.action.job == 'test' and args[1] then + params.filter = args[1] + end + local done = views.job('job/start', params, row.action.job) + if done and row.action['then'] == 'languageService' then + actions.languageService() + end +end + +function actions.run(_, args, root) + vim.api.nvim_echo({ { t('mcpp: building…') } }, false, {}) + local launch, err = session.call('run/launch', { root = root, args = session.value('mcpp.task.runArgs') or {}, bin = args[1] }) + if err then + if err.data and err.data.narration then + views.append(err.data.narration) + views.reveal() + end + return report(err) + end + views.terminal(launch) +end + +function actions.debug(_, args, root) + local module = dap() + if not module then + return vim.notify(t('Debugging in Neovim needs nvim-dap.'), vim.log.levels.WARN) + end + local available, adapter = {}, {} + for _, pair in ipairs(DAP_ADAPTERS) do + if module.adapters[pair[1]] and not adapter[pair[2]] then + available[#available + 1], adapter[pair[2]] = pair[2], pair[1] + end + end + local platform = vim.fn.has('mac') == 1 and 'macos' or vim.fn.has('win32') == 1 and 'windows' or 'linux' + local r, err = session.call('debug/configuration', { root = root, available = available, platform = platform, bin = args[1] }) + if err then + return report(err) + end + local config = r.configuration + config.type = adapter[r.engine] + if not config.type then + local names = table.concat(vim.tbl_map(function(pair) return pair[1] end, DAP_ADAPTERS), ', ') + return vim.notify(t('nvim-dap has no adapter for {0}; register one under one of these names: {1}.', r.engine, names), vim.log.levels.WARN) + end + if config.mcppNote then + vim.notify('mcpp: ' .. config.mcppNote, vim.log.levels.INFO) + config.mcppNote = nil + end + module.run(config) +end + +-- plan -> the confirmation the plan's level asks for -> apply. +function actions.cache(row, _, root) + local plan, err = session.call('cache/plan', { action = row.action.plan, root = root }) + if err then + return report(err) + end + if plan.refusedUntrusted then + return vim.notify(t('this action needs a trusted workspace'), vim.log.levels.WARN) + end + local confirmation = {} + if (plan.level or 0) > 0 then + local preview = plan.preview or {} + local text = preview.source == 'mcpp' and (preview.text or '') + or (preview.count and t('About {0} would be freed ({1} entries).', preview.bytesText, preview.count) or '') + local prompt = table.concat(vim.tbl_filter(function(s) return s ~= '' end, { plan.title or '', plan.detail or '', text }), '\n\n') + if not flow.confirm(prompt, t('Continue'), t('Cancel')) then + return + end + confirmation = { confirmed = true, previewShown = plan.preview ~= nil } + if plan.acknowledge then + local ack = t('I understand this affects every mcpp project on this machine') + if not flow.confirm(plan.title, ack, t('Cancel')) then + return + end + confirmation.acknowledged = true + end + end + views.job('cache/apply', { action = row.action.plan, root = root, confirmation = session.object(confirmation) }, row.subcommand) +end + +-- A panel's view model, as Neovim has it: text, a choice, or a job. +local panels = {} + +function panels.targets(row, _, root) + views.show(row.title, 'targets/list', { root = root }, views.targets_text) +end + +function panels.cache(row, _, root) + views.show(row.title, 'cache/inventory', { root = root }, views.cache_text) +end + +function panels.graph(row, _, root) + views.show(row.title, row.action.graph == 'modules' and 'graph/modules' or 'graph/packages', { root = root }, views.graph_text) +end + +-- The pack panel's choices one after another: the format, the closure, the linkage where mcpp takes +-- one; then the pack, and what it wrote. +function panels.pack(row, _, root) + local r, err = session.call('pack/formats', { root = root }) + if err then + return report(err) + end + local default = t("the project's default") + local formats = { { label = default } } + for _, f in ipairs(r.formats or {}) do + local label = f.builtin and f.name or string.format('%s (%s)', f.name, table.concat(f.providers or {}, ', ')) + formats[#formats + 1] = { name = f.name, label = label } + end + local format = flow.select(formats, { prompt = t('Format'), format_item = function(f) return f.label end }) + if not format then + return + end + local mode = flow.select({ default, unpack(r.modes or {}) }, { prompt = t('Mode') }) + if not mode then + return + end + local linkage = default + if r.linkages and #r.linkages > 0 then + linkage = flow.select({ default, unpack(r.linkages) }, { prompt = t('Linkage') }) + if not linkage then + return + end + end + local done = views.job('pack/start', { + root = root, pkgFormat = format.name, mode = mode ~= default and mode or nil, linkage = linkage ~= default and linkage or nil, + }, row.subcommand) + local data = done and done.envelope and done.envelope.data or {} + for _, out in ipairs(data.outputs or data.artifacts or {}) do + views.line(' ' .. (type(out) == 'table' and (out.path or vim.inspect(out)) or tostring(out))) + end +end + +-- The libraries panel as a search: the packages that match, then the version to add (`mcpp add`). +function panels.libraries(_, args, root) + local keyword = args[1] or flow.input({ prompt = t('Search packages') .. ': ' }) + if keyword == nil then + return + end + local r, err = session.call('library/search', { keyword = keyword, limit = 300, root = root }) + if err then + return report(err) + end + if #(r.packages or {}) == 0 then + return vim.notify(t('No package matches.'), vim.log.levels.INFO) + end + local pkg = flow.select(r.packages, { + prompt = t('{0} packages', r.total), + format_item = function(p) + return string.format('%s %s %s', p.id, p.latest or '', p.description or '') + end, + }) + if not pkg then + return + end + if not root then + return vim.notify(t('No mcpp project in this workspace'), vim.log.levels.WARN) + end + local version = flow.select(pkg.versions or {}, { prompt = t('Add to mcpp.toml') .. ': ' .. (pkg.dependencyName or pkg.name) }) + if not version then + return + end + views.job('library/add', { root = root, dependency = pkg.dependencyName or pkg.name, version = version }, 'add') +end + +function actions.panel(row, args, root) + local panel = panels[row.action.panel] + if not panel then + return vim.notify(t('This command is not available in this IDE.'), vim.log.levels.INFO) + end + panel(row, args, root) +end + +function actions.cancel() + local ids = vim.tbl_keys(session.state.running) + if #ids == 0 then + return vim.notify(t('No mcpp job is running.'), vim.log.levels.INFO) + end + for _, id in ipairs(ids) do + session.call('job/cancel', { jobId = id }) + end +end + +function actions.selectTarget(_, _, root) + local r, err = session.call('targets/list', { root = root }) + if err then + return report(err) + end + local pick = flow.select(r.rows or {}, { + prompt = t('Select the build target'), + format_item = function(row) + return views.targets_text({ rows = { row } })[1] + end, + }) + if not pick then + return + end + session.set_setting('mcpp.build.target', pick.host and '' or pick.target) + if not pick.installed and (pick.pin or '') ~= '' then + if flow.confirm(t('No toolchain is installed for {0}.', pick.target), t('Install'), t('Cancel')) then + views.job('toolchains/install', { spec = pick.pin, target = pick.target, root = root }, 'toolchain') + end + end +end + +function actions.installToolchain(_, args, root) + local spec = args[1] or flow.input({ prompt = t('Install an mcpp toolchain') .. ' (gcc@16.1.0, llvm@23.1.3): ' }) + if spec and spec ~= '' then + views.job('toolchains/install', { spec = spec, root = root }, 'toolchain') + end +end + +function actions.selectDefaultToolchain() + local r, err = session.call('toolchains/list', {}) + if err then + return report(err) + end + local choices = vim.tbl_filter(function(x) return x.target == r.host and (x.spec or '') ~= '' end, r.rows or {}) + local pick = flow.select(choices, { + prompt = t('Choose the mcpp global default toolchain'), + format_item = function(x) return string.format('%s%s (%s)', x.default and '✓ ' or ' ', x.spec, x.status or '') end, + }) + if not pick then + return + end + local _, failed = session.call('toolchains/setDefault', { spec = pick.spec }) + if failed then + return report(failed) + end + vim.notify(t('Global default: {0}', pick.spec), vim.log.levels.INFO) +end + +function actions.newProject(_, args) + local ask = t('New mcpp project (1/2)') .. ' ' .. t("The project's name, which is also its directory") .. ': ' + local name = args[1] or flow.input({ prompt = ask }) + if not name or name == '' then + return + end + ask = t('Choose the project location (2/2)') .. ': ' + local parent = flow.input({ prompt = ask, default = vim.fn.getcwd(), completion = 'dir' }) + if not parent or parent == '' then + return + end + parent = vim.fs.normalize(vim.fn.fnamemodify(parent, ':p')) + parent = parent:match('^(.-)/*$') + local done = views.job('project/new', { parent = parent, name = name }, 'new') + if done and done.status == 'ok' and flow.confirm(t('Created {0}.', name), t('Open'), t('Cancel')) then + vim.cmd.edit(vim.fn.fnameescape(parent .. '/' .. name .. '/mcpp.toml')) + end +end + +function actions.quickMenu(_, _, root) + local r, err = session.call('menu/quick', { root = root, editor = 'nvim' }) + if err then + return report(err) + end + local item = flow.select(r.items or {}, { + prompt = t('Open the mcpp project and toolchain quick menu'), + format_item = function(i) + local note = i.enabled and (i.detail or '') or (i.reason or '') + return string.format('%-40s %s', i.title, note) + end, + }) + if item and item.enabled then + M.dispatch(M.find_id(item.command), {}) + end +end + +function actions.selfCheck() + local r, err = session.call('selfCheck', {}) + if err then + return report(err) + end + local lines = { + 'mcpp: ' .. t('Environment Self-check'), + string.format('mcpp-nvim %s · Neovim %s · mcpp-ide %s', session.version, session.nvim_version(), + r.backend and r.backend.version or '?'), + } + local level = vim.log.levels.INFO + for _, check in ipairs(r.checks or {}) do + lines[#lines + 1] = string.format('%-8s %s', check.status, check.message) + if check.status == 'error' then + level = vim.log.levels.ERROR + elseif check.status == 'warning' and level ~= vim.log.levels.ERROR then + level = vim.log.levels.WARN + end + end + vim.notify(table.concat(lines, '\n'), level) +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) + if s.state == 'ok' then + return vim.notify('mcpp: ' .. s.message, vim.log.levels.INFO) + end + vim.notify('mcpp: ' .. s.message .. '\n' .. t('mcppls for Neovim: `xlings install mcppls-nvim`'), vim.log.levels.WARN) +end + +function actions.languageService() + local s, err = session.call('languageService/status', { candidates = session.mcppls_candidates() }) + if err then + return report(err) + end + M.language_service(s) +end + +function actions.rpc(row, _, root) + views.job(row.action.method, { root = root }, row.subcommand) +end + +function actions.problems() + diagnostics.quickfix() +end + +-- ── the table ───────────────────────────────────────────────────────────────────────────────── + +-- The rows Neovim offers now: not deprecated, not hidden from a palette, and the debugger only when +-- nvim-dap is there. +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 + out[#out + 1] = row + end + end + return out +end + +function M.find(subcommand) + for _, row in ipairs(M.offered()) do + if row.subcommand == subcommand then + return row + end + end +end + +-- A row by its id, offered now or not: the quick menu names rows by id, and a row Neovim leaves out +-- of its table now (the debugger without nvim-dap) says why when it is chosen. +function M.find_id(id) + for _, row in ipairs(session.state.commands) do + if row.id == id then + return row + end + end +end + +-- Inside a flow: does what the row says. +function M.dispatch(row, args) + if not row then + return + end + local root = M.root() + if (row.when or ''):find('mcpp.inProject', 1, true) and not root then + return vim.notify(t('No mcpp project in this workspace'), vim.log.levels.WARN) + end + local action = actions[row.action.kind] + if not action then + return vim.notify(t('This command is not available in this IDE.'), vim.log.levels.INFO) + end + action(row, args, root) +end + +-- `:Mcpp [subcommand] [arguments]`; with no subcommand, the quick menu. +function M.run(subcommand, args) + flow.run(function() + if not session.ensure(M.root()) then + return + end + local row + if not subcommand or subcommand == '' then + row = vim.tbl_filter(function(r) return r.action.kind == 'quickMenu' end, M.offered())[1] + else + row = M.find(subcommand) + end + if not row then + return vim.notify(t('There is no mcpp command {0}.', subcommand or ''), vim.log.levels.ERROR) + end + M.dispatch(row, args or {}) + end) +end + +-- The subcommands, for `:Mcpp `: the backend's table, read once it has started (completion waits +-- for it a little, starting it when it is not running). +function M.complete(lead, line, pos) + if #vim.split(line:sub(1, pos), '%s+') > 2 then + return {} + end + if not session.state.ready then + session.start(M.root()) + vim.wait(5000, function() return session.state.ready or not session.state.starting end, 20) + end + local names = {} + for _, row in ipairs(M.offered()) do + if vim.startswith(row.subcommand, lead) then + names[#names + 1] = row.subcommand + end + end + table.sort(names) + return names +end + +return M diff --git a/mide-plugins/nvim/lua/mcpp/diagnostics.lua b/mide-plugins/nvim/lua/mcpp/diagnostics.lua new file mode 100644 index 0000000..6ceaf3b --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/diagnostics.lua @@ -0,0 +1,126 @@ +-- Problems from mcpp, never from a pattern over its output: each `job/diagnostic` of a build goes +-- into the namespace `mcpp` where mcpp locates it, a build or test of a project clears that +-- project's, and the manifest's own problems (`manifest/diagnostics`, `manifest/check`) are the +-- namespace `mcpp.toml`'s, on mcpp.toml. `:Mcpp problems` lists both in the quickfix list. +local session = require('mcpp.session') + +local M = {} +local t = session.t + +M.build = vim.api.nvim_create_namespace('mcpp') +M.manifest = vim.api.nvim_create_namespace('mcpp.toml') + +local S = vim.diagnostic.severity +-- mcpp's severities (as mcpp-vscode reads them): a note or a help is a hint, anything else not named +-- an information. +local SEVERITY = { error = S.ERROR, warning = S.WARN, note = S.HINT, help = S.HINT } + +-- The files each project's last build reported problems in: root -> { [bufnr] = diagnostics }. +local byRoot = {} + +-- mcpp's positions are 1-based lines and columns; Neovim's 0-based. A point is widened to one column. +function M.item(d) + local range = d.range or {} + local start = range.start or { line = 1, column = 1 } + local stop = range['end'] or start + local col = math.max(0, start.column - 1) + return { + lnum = math.max(0, start.line - 1), + col = col, + end_lnum = math.max(0, stop.line - 1), + end_col = math.max(stop.column - 1, col + 1), + severity = SEVERITY[d.severity] or S.INFO, + message = d.message, + source = d.source or 'mcpp', + code = d.code, + } +end + +-- The buffer of a file, without loading it. +local function buffer(path) + return vim.fn.bufadd(vim.fs.normalize(path)) +end + +local function absolute(root, path) + if path:sub(1, 1) == '/' or path:match('^%a:[/\\]') then + return path + end + return root .. '/' .. path +end + +function M.add(root, d) + if not d.path or not root then + return + end + local files = byRoot[root] or {} + byRoot[root] = files + local buf = buffer(absolute(root, d.path)) + local list = files[buf] or {} + files[buf] = list + local item = M.item(d) + for _, seen in ipairs(list) do + if seen.message == item.message and seen.lnum == item.lnum then + return + end + end + list[#list + 1] = item + vim.diagnostic.set(M.build, buf, list) +end + +function M.clear(root) + for buf in pairs(byRoot[root] or {}) do + vim.diagnostic.reset(M.build, buf) + end + byRoot[root] = nil +end + +function M.set_manifest(root, diagnostics) + local enabled = session.value('mcpp.toml.diagnostics.enabled') ~= false + vim.diagnostic.set(M.manifest, buffer(root .. '/mcpp.toml'), enabled and vim.tbl_map(M.item, diagnostics or {}) or {}) +end + +-- The manifest's problems, asked for (on a save of mcpp.toml). +function M.check_manifest(root) + session.request('manifest/check', { root = root }, function(err, r) + if not err and r then + M.set_manifest(r.root, r.diagnostics) + end + end) +end + +-- Both namespaces in the quickfix list, which opens when it holds anything. +function M.quickfix() + local all = vim.diagnostic.get(nil, { namespace = M.build }) + vim.list_extend(all, vim.diagnostic.get(nil, { namespace = M.manifest })) + local items = vim.diagnostic.toqflist(all) + vim.fn.setqflist({}, ' ', { title = 'mcpp', items = items }) + if #items > 0 then + vim.cmd('botright copen') + else + vim.notify('mcpp: ' .. t('No problems were reported.'), vim.log.levels.INFO) + end + return items +end + +function M.attach() + session.on('job/started', function(p) + if p.kind == 'build' or p.kind == 'test' then + M.clear(p.root) + end + end) + session.on('job/diagnostic', function(p) + M.add(p.root, p) + end) + -- The envelope's own diagnostics that the stream did not carry (a manifest error ends a build + -- before any event). + session.on('job/finished', function(p) + for _, d in ipairs(p.diagnostics or {}) do + M.add(p.root, d) + end + end) + session.on('manifest/diagnostics', function(p) + M.set_manifest(p.root, p.diagnostics) + end) +end + +return M diff --git a/mide-plugins/nvim/lua/mcpp/flow.lua b/mide-plugins/nvim/lua/mcpp/flow.lua new file mode 100644 index 0000000..3274a5c --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/flow.lua @@ -0,0 +1,60 @@ +-- A command as one sequence: ask the backend, then the user, then the backend again. Each step that +-- answers through a callback (a request, vim.ui.select, vim.ui.input, a job's end) resumes the +-- coroutine the command runs in, so the command reads in the order it happens. +local M = {} + +local unpack = table.unpack or unpack + +-- Resumes a flow; an error it raises is said, not lost in a callback. +local function resume(co, ...) + local ok, err = coroutine.resume(co, ...) + if not ok then + vim.notify('mcpp: ' .. tostring(err), vim.log.levels.ERROR) + end +end + +-- Runs fn(...) as a flow. +function M.run(fn, ...) + resume(coroutine.create(fn), ...) +end + +-- Inside a flow: starts an operation that takes a callback, and returns what the callback is given. +-- A callback that runs at once (before the flow waits) is answered without waiting. +function M.wait(start) + local co = coroutine.running() + assert(co, 'mcpp: flow.wait outside a flow') + local results, waiting + start(function(...) + if results then + return + end + results = { n = select('#', ...), ... } + if waiting then + resume(co) + end + end) + if not results then + waiting = true + coroutine.yield() + end + return unpack(results, 1, results.n) +end + +function M.select(items, opts) + return M.wait(function(done) + vim.ui.select(items, opts, done) + end) +end + +function M.input(opts) + return M.wait(function(done) + vim.ui.input(opts, done) + end) +end + +-- Whether the user goes on: `yes` chosen over Cancel. +function M.confirm(prompt, yes, cancel) + return M.select({ yes, cancel }, { prompt = prompt }) == yes +end + +return M diff --git a/mide-plugins/nvim/lua/mcpp/init.lua b/mide-plugins/nvim/lua/mcpp/init.lua new file mode 100644 index 0000000..aecc703 --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/init.lua @@ -0,0 +1,169 @@ +-- mcpp-nvim: mcpp's project features in Neovim (0.10 and later) over the mcpp-ide backend, +-- `mcpp-ide serve` -- builds, tests, cleanups and packs as jobs streamed into a buffer, the program +-- 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). +-- +-- require('mcpp').setup({ backend = ..., settings = { ['mcpp.build.target'] = ... }, trusted = true }) +-- :Mcpp [subcommand] the backend's command table; no subcommand: the quick menu +-- require('mcpp').status() a statusline component +local session = require('mcpp.session') +local commands = require('mcpp.commands') +local diagnostics = require('mcpp.diagnostics') + +local M = {} +local t = session.t + +local defaults = { + -- The mcpp-ide program, an ok-image bundle (`*.okb`, run by okrun on PATH) or an argv; default: + -- `mcpp-ide` on PATH (`xlings install mcpp-ide`). + backend = nil, + -- mcpp's settings (spec/settings.json: `mcpp.path`, `mcpp.build.target`, `mcpp.task.runArgs`, ...), + -- validated by the backend. + settings = {}, + -- Neovim has no workspace trust: a project is trusted unless this is false, which denies what + -- `mcpp.trust.denyEffects` lists. + trusted = true, +} + +-- The files whose save builds their project when mcpp.build.onSave asks for it (as mcpp-vscode). +local BUILT_ON_SAVE = { '*.cppm', '*.cpp', '*.cc', '*.cxx', '*.ixx', '*.mpp', '*.h', '*.hpp', 'mcpp.toml', 'build.mcpp' } + +local attached = false +local said = false + +-- What the backend said at its start that the user should hear once (whichever started it): an +-- mcppls that is not what it should be, and the self-check when mcpp.diagnostics.selfCheckOnStartup +-- asks for it. +local function on_started() + if said or not session.state.ready then + return + end + said = true + local ls = session.state.info and session.state.info.languageService + if ls and ls.state ~= 'ok' then + commands.language_service(ls) + end + if session.value('mcpp.diagnostics.selfCheckOnStartup') then + commands.run('selfCheck') + end +end + +-- The projects the backend watches (their manifests' problems, their result records). When it ends, +-- the next backend, however it is started, learns them again once it is initialized. +local known = {} +local relearn = {} + +local function learn(root) + known[root] = true + session.request('projects/forFile', { path = root .. '/mcpp.toml' }, function() end) +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 root = commands.root() + if not root or known[root] then + return + end + if session.state.ready then + learn(root) + elseif not session.state.starting and not session.state.failed then + session.start(root, function(ok) + known[root] = ok or nil + end) + end +end + +function M.setup(opts) + local options = vim.tbl_deep_extend('force', defaults, opts or {}) + session.configure(options) + -- A backend that could not start is looked for again with the options given now. + session.state.failed = false + if not attached then + attached = true + diagnostics.attach() + session.on('ended', function() + relearn = vim.tbl_extend('force', relearn, known) + known = {} + end) + session.on('started', function() + local folder = session.state.folder + if folder then + known[folder] = true + end + for root in pairs(relearn) do + if not known[root] then + learn(root) + end + end + relearn = {} + end) + end + vim.api.nvim_create_user_command('Mcpp', function(c) + commands.run(c.fargs[1], vim.list_slice(c.fargs, 2)) + end, { nargs = '*', complete = commands.complete, desc = 'mcpp: the mcpp-ide command table' }) + + local group = vim.api.nvim_create_augroup('mcpp', { clear = true }) + vim.api.nvim_create_autocmd({ 'VimEnter', 'BufEnter', 'DirChanged' }, { group = group, callback = enter }) + vim.api.nvim_create_autocmd('BufWritePost', { + group = group, pattern = 'mcpp.toml', + callback = function(a) + diagnostics.check_manifest(vim.fs.dirname(vim.fs.normalize(vim.api.nvim_buf_get_name(a.buf)))) + end, + }) + vim.api.nvim_create_autocmd('BufWritePost', { + group = group, pattern = BUILT_ON_SAVE, + callback = function() + if session.state.ready and session.value('mcpp.build.onSave') then + commands.run('build') + end + end, + }) + vim.api.nvim_create_autocmd('User', { group = group, pattern = 'McppStatus', callback = on_started }) + vim.api.nvim_create_autocmd('VimLeavePre', { group = group, callback = session.stop }) + if session.state.ready then + session.push_settings() + elseif vim.v.vim_did_enter == 1 then + enter() + end +end + +-- The statusline component: the target the project builds for, and the jobs running or the last +-- one's end. Empty before the backend runs, outside a project, or with mcpp.ui.statusBar.show off. +-- +-- vim.o.statusline = '%f %= %{v:lua.require("mcpp").status()} ' +function M.status() + local s = M.status_data() + if not s.ready or not s.root or session.value('mcpp.ui.statusBar.show') == false then + return '' + end + local text = 'mcpp ' .. s.target + if s.running > 0 then + return text .. ' · ' .. t('{0} job(s) running', s.running) + end + if s.last then + text = text .. ' · ' .. (s.last.kind or '') .. ' ' .. (s.last.status or '') + end + return text +end + +-- What the statusline shows, as data; `User McppStatus` fires when it changes. +function M.status_data() + local st = session.state + local root = commands.root() + local target = session.value('mcpp.build.target') + local plan = root and st.plans[root] or nil + return { + ready = st.ready, + root = root, + target = (target and target ~= '') and target or t('this machine'), + platform = plan and plan.platformText or nil, + running = #vim.tbl_keys(st.running), + last = st.last and { kind = st.last.kind, status = st.last.status, root = st.last.root } or nil, + languageService = st.info and st.info.languageService or nil, + } +end + +return M diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua new file mode 100644 index 0000000..d8da8b2 --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -0,0 +1,504 @@ +-- 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. +local flow = require('mcpp.flow') + +local M = {} + +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' + +-- Neovim's version, as its release names it. +function M.nvim_version() + local v = vim.version() + return string.format('%d.%d.%d', v.major, v.minor, v.patch) +end + +-- What one session holds. `waiters` are called once it is initialized (or cannot be). +local state = { + options = { settings = {}, trusted = true }, + client = nil, + folder = nil, + ready = false, + starting = false, + failed = false, + waiters = {}, + info = nil, + strings = {}, + commands = {}, + values = {}, + listeners = {}, + pending = {}, + claimed = {}, + unclaimed = {}, + running = {}, + last = nil, + plans = {}, + stopping = false, +} +M.state = state + +-- A runtime string in the session's language (the backend's bundle), `{0}`, `{1}` replaced. Before +-- the backend answers, the English text. +function M.t(key, ...) + local text = state.strings[key] or key + for i = 1, select('#', ...) do + text = text:gsub('{' .. (i - 1) .. '}', (tostring(select(i, ...)):gsub('%%', '%%%%'))) + end + return text +end +local t = M.t + +-- JSON's null as nothing: Neovim decodes it as vim.NIL (0.12), or as nil only in objects (0.10). +local function denull(value) + if value == vim.NIL then + return nil + end + if type(value) == 'table' then + for k, v in pairs(value) do + value[k] = denull(v) + end + end + return value +end + +-- An empty table as the JSON object `{}`, not `[]`. +local function object(value) + if value == nil or (type(value) == 'table' and next(value) == nil) then + return vim.empty_dict() + end + return value +end +M.object = object + +function M.configure(options) + state.options = options +end + +-- Where the backend is, as the command that starts it: setup's `backend` (a program, an ok-image +-- bundle `*.okb` that okrun on PATH starts, or an argv), else `mcpp-ide` on PATH, which +-- `xlings install mcpp-ide` puts there (okrun on its bundle). Nil when neither is found. +function M.locate(backend) + if type(backend) == 'table' and #backend > 0 then + return vim.deepcopy(backend) + end + local program + if type(backend) == 'string' and backend ~= '' then + program = vim.fn.expand(backend) + if program:match('%.okb$') then + local okrun = vim.fn.exepath('okrun') + return okrun ~= '' and { okrun, program } or nil + end + program = vim.fn.exepath(program) ~= '' and vim.fn.exepath(program) or program + else + program = vim.fn.exepath('mcpp-ide') + if program == '' then + return nil + end + end + -- A batch file (the xlings command on Windows, `mcpp-ide.cmd`) runs through the command interpreter. + local extension = program:lower():match('%.(%w+)$') + if is_windows and (extension == 'cmd' or extension == 'bat') then + return { 'cmd.exe', '/c', program } + end + return { program } +end + +-- The language: mcpp.ui.language when it names one, else Neovim's (v:lang). +local function locale() + local chosen = state.options.settings['mcpp.ui.language'] + if type(chosen) == 'string' and chosen ~= '' and chosen ~= 'auto' then + return chosen + end + return vim.v.lang ~= '' and vim.v.lang or 'en' +end + +-- Neovim has no workspace trust: a project is trusted unless setup says `trusted = false`, which +-- denies what mcpp.trust.denyEffects lists. +local function trusted() + return state.options.trusted ~= false +end + +-- The mcppls Neovim starts for C++ (mcppls-nvim's client): its payload directory when it runs a +-- payload's image, else its program; then MCPPLS_SERVER and MCPPLS_PAYLOAD. PATH, xlings and the user +-- data directory's payload are the backend's to try after these. +function M.mcppls_candidates() + local out = {} + local get = vim.lsp.get_clients or vim.lsp.get_active_clients + for _, client in ipairs(get({ name = 'mcppls' })) do + local cmd = type(client.config.cmd) == 'table' and client.config.cmd or {} + local image = vim.tbl_filter(function(part) + return type(part) == 'string' and part:match('%.okb$') ~= nil + end, cmd)[1] + if image then + -- An image is its payload's (payload.json says what starts it); one outside a payload is left + -- to the backend's own search, since its runner is not mcppls. + local payload = vim.fs.dirname(vim.fs.dirname(image)) + if uv.fs_stat(payload .. '/payload.json') then + out[#out + 1] = payload + end + elseif type(cmd[1]) == 'string' then + out[#out + 1] = vim.fn.exepath(cmd[1]) ~= '' and vim.fn.exepath(cmd[1]) or cmd[1] + end + end + for _, name in ipairs({ 'MCPPLS_SERVER', 'MCPPLS_PAYLOAD' }) do + local value = os.getenv(name) + if value and value ~= '' then + out[#out + 1] = value + end + end + return out +end + +-- A listener for a notification of the backend (`job/output`, `manifest/diagnostics`, ...), or for +-- the session's own events, whose names have no `/`: `started` once the backend is initialized, +-- `ended` when it has ended. +function M.on(method, fn) + state.listeners[method] = state.listeners[method] or {} + state.listeners[method][fn] = true + return function() + state.listeners[method][fn] = nil + end +end + +local function emit(method, params) + for fn in pairs(state.listeners[method] or {}) do + local ok, err = pcall(fn, params) + if not ok then + vim.notify(string.format('mcpp: %s: %s', method, err), vim.log.levels.ERROR) + end + end +end + +-- The statusline's data changed. +local function changed() + pcall(vim.api.nvim_exec_autocmds, 'User', { pattern = 'McppStatus', modeline = false }) + pcall(vim.cmd.redrawstatus) +end +M.changed = changed + +-- ── jobs ────────────────────────────────────────────────────────────────────────────────────── +-- A job's notifications can arrive before the reply that names its id (the job's thread and the +-- reply race), so what carries an id not yet claimed is kept, and replayed to the handle that +-- claims it. + +local Handle = {} +Handle.__index = Handle + +-- Inside a flow: the job's `job/finished`. +function Handle:wait() + if self.done then + return self.done + end + return flow.wait(function(done) + self.waiters[#self.waiters + 1] = done + end) +end + +local function deliver(owner, method, params) + local on = owner.events + if method == 'job/output' and on.output then + on.output(params.text or '') + elseif method == 'job/started' and on.started then + on.started(params) + elseif method == 'job/finished' then + state.claimed[params.jobId] = nil + owner.handle.done = params + for _, done in ipairs(owner.handle.waiters) do + done(params) + end + end +end + +local function route(method, params) + local id = params.jobId + if not id then + return + end + if method == 'job/started' then + state.running[id] = params + changed() + elseif method == 'job/finished' then + state.running[id] = nil + state.last = params + changed() + elseif method == 'job/plan' then + state.plans[params.root or ''] = params + end + local owner = state.claimed[id] + if owner then + deliver(owner, method, params) + return + end + local kept = state.unclaimed[id] or {} + kept[#kept + 1] = { method, params } + state.unclaimed[id] = kept + -- A job nobody claims (one an agent or another command started) is kept only until it ends. + if method == 'job/finished' then + vim.defer_fn(function() + state.unclaimed[id] = nil + end, 30000) + end +end + +local JOB_EVENTS = { + ['job/started'] = true, ['job/output'] = true, ['job/plan'] = true, ['job/progress'] = true, + ['job/diagnostic'] = true, ['job/artifact'] = true, ['job/record'] = true, ['job/finished'] = true, + ['test/result'] = true, ['test/summary'] = true, +} + +-- Inside a flow: asks for a job (`job/start`, or another method that answers a job id) and follows +-- it. `events`: output(text), started(params); the handle's wait() answers its end. Returns the +-- handle, or nil and the error. +function M.job(method, params, events) + local result, err = M.call(method, params) + if not result then + return nil, err + end + local handle = setmetatable({ id = result.jobId, result = result, waiters = {} }, Handle) + local owner = { events = events or {}, handle = handle } + state.claimed[handle.id] = owner + for _, kept in ipairs(state.unclaimed[handle.id] or {}) do + deliver(owner, kept[1], kept[2]) + end + state.unclaimed[handle.id] = nil + return handle +end + +-- ── the wire ────────────────────────────────────────────────────────────────────────────────── + +local function not_running() + return { code = -32099, message = t('the mcpp-ide backend is not running') } +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). +function M.request(method, params, callback) + local client = state.client + if not client or client.is_closing() then + callback(not_running()) + return + end + local entry = { callback = callback } + state.pending[entry] = true + local ok = client.request(method, object(params), function(err, result) + if state.pending[entry] then + state.pending[entry] = nil + callback(denull(err), denull(result)) + end + end) + if not ok then + state.pending[entry] = nil + callback(not_running()) + end +end + +-- Inside a flow: a request's result and its error (one of them nil; a result may be null too). +function M.call(method, params) + local err, result = flow.wait(function(done) + M.request(method, params, done) + end) + return result, err +end + +-- A setting as the backend resolved it (spec/settings.json's default when not set). +function M.value(key) + local v = state.values[key] + if v == nil then + v = state.options.settings[key] + end + return v +end + +-- settings/update's parameters: setup()'s settings, the trust, the language. +local function settings_params() + return { settings = object(state.options.settings), trusted = trusted(), locale = locale() } +end + +-- The settings changed (setup again, a target chosen): the backend validates and resolves them. +function M.push_settings() + if not state.ready then + return + end + M.request('settings/update', settings_params(), function(err, resolved) + if not err and resolved then + state.values = resolved.values or {} + changed() + end + end) +end + +function M.set_setting(key, value) + state.options.settings[key] = value + M.push_settings() +end + +local function finish_start(ok) + state.starting = false + state.ready = ok + state.failed = not ok + local waiters = state.waiters + state.waiters = {} + if ok then + emit('started', {}) + end + for _, waiter in ipairs(waiters) do + waiter(ok) + end + changed() +end + +-- The settings the backend did not accept, said once: their defaults are used. +local function report_rejected(resolved) + for _, r in ipairs(resolved.rejected or {}) do + local why = t('Not accepted ({0}); expected {1}. The default is used.', r.reason, r.expectation) + vim.notify(string.format('mcpp: %s: %s', r.key, why), vim.log.levels.WARN) + 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() }, + }) + if not info then + vim.notify('mcpp-ide: initialize failed: ' .. tostring(err and err.message), vim.log.levels.ERROR) + local client = state.client + state.client = nil + if client then + client.terminate() + end + return finish_start(false) + end + state.info = info + state.strings = M.call('i18n/bundle', {}) or {} + local listed = M.call('commands/list', { editor = 'nvim' }) + state.commands = listed and listed.commands or {} + local resolved = M.call('settings/update', settings_params()) + if resolved then + state.values = resolved.values or {} + report_rejected(resolved) + end + -- What cannot work, said once; the language service is said with what installs it (init.lua). + for _, check in ipairs(info.checks or {}) do + if check.status == 'error' and check.id ~= 'languageService' then + vim.notify('mcpp: ' .. check.message, vim.log.levels.ERROR) + end + end + -- A backend that ended while it was asked these has not started. + finish_start(state.client ~= nil) +end + +local function on_exit(client, code, signal) + vim.schedule(function() + if state.client ~= client then + return + end + state.client, state.ready, state.running = nil, false, {} + -- What waits on it is answered: the requests in flight fail, every job followed ends as failed, + -- and a start still initializing fails. + local pending, claimed = state.pending, state.claimed + state.pending, state.claimed, state.unclaimed = {}, {}, {} + for entry in pairs(pending) do + entry.callback(not_running()) + end + for id, owner in pairs(claimed) do + deliver(owner, 'job/finished', { jobId = id, status = 'failed' }) + end + if state.starting then + finish_start(false) + end + emit('ended', {}) + changed() + if not state.stopping then + vim.notify(string.format('mcpp: %s (%s)', t('The mcpp-ide backend ended.'), signal ~= 0 and ('signal ' .. signal) or code), + vim.log.levels.WARN) + end + end) +end + +-- Starts the backend for `folder` (a project root, or nil). `done(ok)` once it is initialized. +function M.start(folder, done) + done = done or function() end + if state.ready and state.client and not state.client.is_closing() then + return done(true) + end + state.waiters[#state.waiters + 1] = done + if state.starting then + return + end + local cmd = M.locate(state.options.backend) + if not cmd then + vim.notify(t("The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or name it with setup()'s `backend`."), + vim.log.levels.ERROR) + return finish_start(false) + end + state.starting, state.stopping, state.folder = true, false, folder + cmd[#cmd + 1] = 'serve' + 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) + end) + if not ok then + vim.notify(t('The mcpp-ide backend could not be started: {0}', tostring(err)), vim.log.levels.ERROR) + return finish_start(false) + end + 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 +-- cannot start (what is missing has been said). +function M.ensure(folder) + local ok = flow.wait(function(done) + M.start(folder, done) + end) + return ok and M or nil +end + +-- Ends the backend as the protocol asks: shutdown, then exit. Neovim waits briefly for the answer. +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() + return client.is_closing() + end, 10) + if not client.is_closing() then + client.terminate() + end + state.client, state.ready = nil, false +end + +return M diff --git a/mide-plugins/nvim/lua/mcpp/views.lua b/mide-plugins/nvim/lua/mcpp/views.lua new file mode 100644 index 0000000..da4f101 --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/views.lua @@ -0,0 +1,249 @@ +-- What mcpp-nvim shows: a job's narration in one scratch buffer (`mcpp://output`), a program in a +-- terminal buffer, and the backend's view models (targets and platforms, the cache, the graphs) as +-- text in a floating window. The texts are the backend's; this module only lays them out. +local session = require('mcpp.session') + +local M = {} +local t = session.t + +M.OUTPUT = 'mcpp://output' +local output + +local function output_buffer() + if output and vim.api.nvim_buf_is_valid(output) then + return output + end + output = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(output, M.OUTPUT) + vim.bo[output].bufhidden = 'hide' + vim.bo[output].modifiable = false + return output +end + +-- Shows the output in a window at the bottom, unless one shows it already; the cursor stays where it +-- is unless mcpp.task.focusTerminal asks for it. +function M.reveal() + local buf = output_buffer() + if vim.fn.bufwinid(buf) == -1 then + local from = vim.api.nvim_get_current_win() + vim.cmd('botright 12split') + vim.api.nvim_win_set_buf(0, buf) + if not session.value('mcpp.task.focusTerminal') then + vim.api.nvim_set_current_win(from) + end + end +end + +-- Appends text as it came (a piece may end mid-line), and keeps every window on it at its end. +function M.append(text) + local buf = output_buffer() + local pieces = vim.split((text:gsub('\r\n', '\n'):gsub('\27%[[%d;]*[A-Za-z]', '')), '\n', { plain = true }) + local last = vim.api.nvim_buf_line_count(buf) + local tail = vim.api.nvim_buf_get_lines(buf, last - 1, last, false)[1] or '' + pieces[1] = tail .. pieces[1] + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, last - 1, last, false, pieces) + vim.bo[buf].modifiable = false + local lines = vim.api.nvim_buf_line_count(buf) + for _, win in ipairs(vim.fn.win_findbuf(buf)) do + vim.api.nvim_win_set_cursor(win, { lines, 0 }) + end +end + +-- A line of its own. +function M.line(text) + local buf = output_buffer() + local last = vim.api.nvim_buf_get_lines(buf, -2, -1, false)[1] or '' + M.append((last ~= '' and '\n' or '') .. text .. '\n') +end + +function M.clear() + local buf = output_buffer() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, {}) + vim.bo[buf].modifiable = false +end + +function M.lines() + return vim.api.nvim_buf_get_lines(output_buffer(), 0, -1, false) +end + +-- Inside a flow: a job, its narration streamed into the output, its end said there and, when it +-- did not succeed, as a notification. mcpp.task.clearTerminal and mcpp.task.revealTerminal apply +-- as they do to mcpp-vscode's terminal. Returns `job/finished`, or nil when it did not start. +function M.job(method, params, what) + if session.value('mcpp.task.clearTerminal') ~= false then + M.clear() + end + local reveal = session.value('mcpp.task.revealTerminal') or 'always' + if reveal == 'always' then + M.reveal() + end + params.color = false + local handle, err = session.job(method, params, { + started = function(p) + M.line('$ ' .. table.concat(p.argv or {}, ' ')) + end, + output = M.append, + }) + if not handle then + M.line(t('mcpp: {0}', err.message)) + if reveal ~= 'never' then + M.reveal() + end + vim.notify(t('mcpp: {0}', err.message), vim.log.levels.ERROR) + return nil + end + local done = handle:wait() + local code = done.status ~= 'ok' and done.exitCode and (' (' .. t('Exit code {0}', done.exitCode) .. ')') or '' + M.line(t('mcpp {0}: {1}', what, done.status) .. code) + if done.status ~= 'ok' then + if reveal == 'onFailure' then + M.reveal() + end + vim.notify(t('mcpp {0}: {1}', what, done.status), vim.log.levels.WARN) + end + return done +end + +-- The program `run/launch` describes, in a terminal buffer of its own (argv, its working directory, +-- the environment mcpp adds), where it reads its input. +function M.terminal(launch) + local from = vim.api.nvim_get_current_win() + vim.cmd('botright 12new') + local opts = { cwd = launch.cwd } + if type(launch.env) == 'table' and next(launch.env) ~= nil then + opts.env = launch.env + end + local buf = vim.api.nvim_get_current_buf() + local ok, job = pcall(function() + if vim.fn.has('nvim-0.11') == 1 then + opts.term = true + return vim.fn.jobstart(launch.argv, opts) + end + return vim.fn.termopen(launch.argv, opts) + end) + if not ok then + vim.cmd('close') + vim.api.nvim_set_current_win(from) + vim.notify(t('mcpp: {0}', tostring(job)), vim.log.levels.ERROR) + return nil + end + vim.b[buf].mcpp_program = launch.program or launch.argv[1] + if not session.value('mcpp.task.focusTerminal') then + vim.api.nvim_set_current_win(from) + end + return buf, job +end + +-- Text in a floating window; q or closes it. +function M.float(title, lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].bufhidden = 'wipe' + local width = 20 + for _, line in ipairs(lines) do + width = math.max(width, vim.fn.strdisplaywidth(line)) + end + width = math.min(width + 2, vim.o.columns - 4) + local height = math.max(1, math.min(#lines, vim.o.lines - 6)) + local win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', style = 'minimal', border = 'rounded', + width = width, height = height, + row = math.floor((vim.o.lines - height) / 2) - 1, col = math.floor((vim.o.columns - width) / 2), + title = ' ' .. title .. ' ', title_pos = 'center', + }) + for _, key in ipairs({ 'q', '' }) do + vim.keymap.set('n', key, 'close', { buffer = buf, nowait = true }) + end + return buf, win +end + +-- ── the view models as text ─────────────────────────────────────────────────────────────────── + +-- targets/list: each target, the platform that configures it, its toolchains. +function M.targets_text(r) + local lines = {} + for _, row in ipairs(r.rows or {}) do + local head = (row.selected and '✓ ' or ' ') .. row.target + if row.host then + head = head .. ' (' .. t('this machine') .. ')' + end + if (row.platformText or '') ~= '' then + head = head .. ' · ' .. row.platformText + end + lines[#lines + 1] = head + local toolchains = vim.tbl_map(function(tc) + return string.format('%s (%s)', tc.spec ~= '' and tc.spec or '—', tc.status or '') + end, row.toolchains or {}) + if #toolchains > 0 then + lines[#lines + 1] = ' ' .. table.concat(toolchains, ', ') + end + end + if (r.hostPlatformText or '') ~= '' then + vim.list_extend(lines, { '', t('Host platform') .. ': ' .. r.hostPlatformText }) + end + return lines +end + +local function rows(into, list, label) + for _, r in ipairs(list or {}) do + into[#into + 1] = string.format(' %-32s %10s %d', label(r), r.bytesText or '', r.entries or 0) + end + if not list or #list == 0 then + into[#into + 1] = ' ' .. t('none') + end +end + +-- cache/inventory: the total, by kind, the largest packages, by last use. +function M.cache_text(inv) + local where = (inv.root or '') ~= '' and (' · ' .. inv.root) or '' + local lines = { inv.totalText .. ' ' .. t('in {0} entries', inv.totalEntries) .. where, '' } + lines[#lines + 1] = t('By kind') + rows(lines, inv.byKind, function(r) return r.kind end) + vim.list_extend(lines, { '', t('Largest packages') }) + rows(lines, inv.topLabels, function(r) return r.label end) + vim.list_extend(lines, { '', t('By last use') }) + rows(lines, inv.ageBuckets, function(b) + return b.toDays == nil and t('older than {0} days', b.fromDays) or t('{0}–{1} days', b.fromDays, b.toDays) + end) + if inv.incomplete and #inv.incomplete > 0 then + vim.list_extend(lines, { '', t('Incomplete entries') }) + for _, e in ipairs(inv.incomplete) do + lines[#lines + 1] = ' ' .. (e.label or e.key or '') + end + end + return lines +end + +-- graph/packages, graph/modules: each node, and what it depends on. +function M.graph_text(g) + local to = {} + for _, e in ipairs(g.edges or {}) do + to[e.from] = to[e.from] or {} + table.insert(to[e.from], e.to) + end + local lines = {} + for _, node in ipairs(g.nodes or {}) do + lines[#lines + 1] = node.id .. (((node.kind or '') ~= '') and (' (' .. node.kind .. ')') or '') + for _, dep in ipairs(to[node.id] or {}) do + lines[#lines + 1] = ' → ' .. dep + end + end + return lines +end + +-- Inside a flow: shows a view model the backend answers, as text. +function M.show(title, method, params, text) + local r, err = session.call(method, params) + if err then + vim.notify(t('mcpp: {0}', err.message), vim.log.levels.ERROR) + return + end + local lines = text(r or {}) + M.float(title, #lines > 0 and lines or { t('none') }) + return lines +end + +return M diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua new file mode 100644 index 0000000..c457c0a --- /dev/null +++ b/mide-plugins/nvim/tests/smoke.lua @@ -0,0 +1,354 @@ +-- mcpp-nvim end to end in a headless Neovim, against the backend this repository built: +-- +-- nvim --headless --clean -l mide-plugins/nvim/tests/smoke.lua +-- +-- The backend is MCPP_IDE_BACKEND, else the newest `mcpp-ide` under target/; mcpp is the mock this +-- repository built (`mcpp-ide-mock-mcpp`, replaying protocol/fixtures), or the real mcpp +-- MCPP_NVIM_MCPP names (then a project is really built, with the toolchain MCPP_NVIM_TOOLCHAIN, +-- default gcc@16.1.0, and what only the mock can play -- a cancel mid-build, an index search, a pack -- +-- is left out). Each check prints PASS or FAIL with what it saw; the exit status is the number of +-- failures. + +local uv = vim.uv or vim.loop +local here = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h') +local plugin = vim.fn.fnamemodify(here, ':h') +local repo = vim.fn.fnamemodify(plugin, ':h:h') +local is_windows = vim.fn.has('win32') == 1 +local exe = is_windows and '.exe' or '' + +-- Straight to standard output, a line each: Neovim's own messages (print) share lines with redraws. +local function say(text) + io.stdout:write(text, '\n') + io.stdout:flush() +end + +local failures = 0 +local function check(name, ok, detail) + say(string.format('%s %s%s', ok and 'PASS' or 'FAIL', name, detail ~= nil and (': ' .. tostring(detail)) or '')) + if not ok then + failures = failures + 1 + end +end + +-- The newest program of that name the build produced (target///bin/). +local function built(name) + local best, when + for _, path in ipairs(vim.fn.glob(repo .. '/target/*/*/bin/' .. name .. exe, false, true)) do + local stat = uv.fs_stat(path) + if stat and (not when or stat.mtime.sec > when) then + best, when = path, stat.mtime.sec + end + end + return best +end + +local backend = os.getenv('MCPP_IDE_BACKEND') or built('mcpp-ide') +local real = os.getenv('MCPP_NVIM_MCPP') +local mcpp = (real and real ~= '') and real or built('mcpp-ide-mock-mcpp') +local mock = not (real and real ~= '') +if not backend or not mcpp then + io.stderr:write('build the backend first (mcpp build), or name it with MCPP_IDE_BACKEND\n') + os.exit(2) +end +say(string.format('backend %s, mcpp %s (%s)', backend, mcpp, mock and 'mock' or 'real')) + +-- A project of its own, so nothing a build writes lands in the repository. +local root = vim.fs.normalize(vim.fn.tempname() .. '-mcpp-nvim') +local function write(path, text) + vim.fn.mkdir(vim.fs.dirname(root .. '/' .. path), 'p') + local f = assert(io.open(root .. '/' .. path, 'wb')) + f:write(text) + f:close() +end +local manifest = '[package]\nname = "app"\nversion = "0.1.0"\n' +if not mock then + manifest = manifest .. string.format('\n[target.\'cfg(linux)\']\ntoolchain = "%s"\n', os.getenv('MCPP_NVIM_TOOLCHAIN') or 'gcc@16.1.0') +end +write('mcpp.toml', manifest) +write('src/main.cpp', 'int main() { return 0; }\n') +write('tests/test_smoke.cpp', 'int main() { return 0; }\n') +-- The mock's builds take a moment, so one can be cancelled while it runs. +if mock then + vim.env.MOCK_MCPP_SLOW_MS = '1200' +end +vim.env.XDG_STATE_HOME = root .. '/.state' +vim.fn.chdir(root) + +-- What the plugin says, and what it asks. +local said = {} +vim.notify = function(message, level) + said[#said + 1] = message + say(string.format(' notify[%s] %s', level or '-', (message:gsub('\n', ' | ')))) +end +local function heard(pattern) + for _, m in ipairs(said) do + if m:find(pattern, 1, true) then + return m + end + end +end +local answers = {} +local asked = {} +vim.ui.select = function(items, opts, done) + asked[#asked + 1] = { prompt = opts.prompt, items = items } + local pick = table.remove(answers, 1) + done(type(pick) == 'function' and pick(items) or pick) +end +vim.ui.input = function(opts, done) + asked[#asked + 1] = { prompt = opts.prompt } + done(table.remove(answers, 1)) +end +local function first(items) return items[1] end + +vim.opt.runtimepath:prepend(plugin) +local mcppide = require('mcpp') +local session = require('mcpp.session') +local diagnostics = require('mcpp.diagnostics') +local views = require('mcpp.views') + +-- ── no backend ──────────────────────────────────────────────────────────────────────────────── +-- PATH without any mcpp-ide: the plugin says what installs it. +local path = vim.env.PATH +vim.env.PATH = table.concat(vim.tbl_filter(function(dir) + return vim.fn.executable(dir .. '/mcpp-ide') == 0 +end, vim.split(path, is_windows and ';' or ':', { plain = true })), is_windows and ';' or ':') +mcppide.setup({ settings = { ['mcpp.path'] = mcpp } }) +vim.cmd('Mcpp build') +vim.wait(2000, function() return heard('xlings install mcpp-ide') ~= nil end, 20) +check('without a backend the plugin names `xlings install mcpp-ide`', heard('xlings install mcpp-ide') ~= nil) +check('and starts nothing', not session.state.ready and session.state.client == nil) +vim.env.PATH = path + +-- ── a backend that ends while it starts ─────────────────────────────────────────────────────── +-- mcpp, which answers `serve` by ending: the start fails, and nothing is left waiting on it. +mcppide.setup({ backend = { mcpp }, settings = { ['mcpp.path'] = mcpp } }) +local started +session.start(root, function(ok) started = ok end) +vim.wait(10000, function() return started ~= nil end, 20) +check('a backend that ends while it starts fails the start', started == false, started) +check('and leaves no start in progress', not session.state.starting and session.state.client == nil and next(session.state.pending) == nil) + +-- ── start, initialize, the command table ────────────────────────────────────────────────────── +mcppide.setup({ backend = backend, settings = { ['mcpp.path'] = mcpp, ['mcpp.task.focusTerminal'] = false } }) +vim.cmd.edit(vim.fn.fnameescape(root .. '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/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('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) + +local by = {} +for _, row in ipairs(session.state.commands) do + by[row.subcommand] = row +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) + +-- ── completion ──────────────────────────────────────────────────────────────────────────────── +local all = vim.fn.getcompletion('Mcpp ', 'cmdline') +check(':Mcpp completes the subcommands', vim.tbl_contains(all, 'build') and vim.tbl_contains(all, 'test') and vim.tbl_contains(all, 'cancel') + and vim.tbl_contains(all, 'problems') and vim.tbl_contains(all, 'library.search'), #all) +check(':Mcpp bu completes build', vim.deep_equal(vim.fn.getcompletion('Mcpp bu', 'cmdline'), { 'build' }), vim.inspect(vim.fn.getcompletion('Mcpp bu', 'cmdline'))) +check('no debug without nvim-dap', not vim.tbl_contains(all, 'debug')) + +-- ── a build ─────────────────────────────────────────────────────────────────────────────────── +local function finished(kind, after) + vim.wait(mock and 20000 or 600000, function() + local last = session.state.last + return last ~= nil and last ~= after and last.kind == kind + end, 50) + return session.state.last +end +local function output() + return table.concat(views.lines(), '\n') +end + +local before = session.state.last +vim.cmd('Mcpp build') +check('a build runs (the statusline says so)', vim.wait(10000, function() return mcppide.status():find('1 job', 1, true) ~= nil end, 20), + mcppide.status()) +local done = finished('build', before) +check('the build finishes ok', done and done.status == 'ok', done and done.status) +check('its narration is streamed into mcpp://output', output():find('$ ', 1, true) and output():find('build', 1, true) + and output():find('Compiling', 1, true) ~= nil, output():sub(1, 200)) +check('its end is said there', output():find('mcpp build: ok', 1, true) ~= nil) +check('the statusline shows the last build', mcppide.status():find('build ok', 1, true) ~= nil, mcppide.status()) + +-- ── diagnostics ─────────────────────────────────────────────────────────────────────────────── +write('src/main.cpp', 'int main() { return undeclared_name; }\n') +write('.mock-mcpp-fail', 'build\n') +vim.cmd('checktime') +before = session.state.last +vim.cmd('Mcpp build') +done = finished('build', before) +check('a build with an error fails', done and done.status == 'failed', done and done.status) +local main = vim.fn.bufnr(root .. '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/src/main.cpp') +local found = vim.diagnostic.get(main, { namespace = diagnostics.build }) +check('its problem lands in the `mcpp` namespace, on main.cpp', #found >= 1 and found[1].message:find('undeclared_name', 1, true) ~= nil + and found[1].lnum == 0 and found[1].severity == vim.diagnostic.severity.ERROR, found[1] and found[1].message) +vim.cmd('Mcpp problems') +local qf +vim.wait(5000, function() + qf = vim.fn.getqflist() + return #qf > 0 +end, 20) +check(':Mcpp problems fills the quickfix list', #qf >= 1 and vim.fn.bufname(qf[1].bufnr):find('main.cpp', 1, true) ~= nil, #qf) + +write('src/main.cpp', 'int main() { return 0; }\n') +write('.mock-mcpp-fail', 'manifest\n') +vim.cmd.edit(vim.fn.fnameescape(root .. '/mcpp.toml')) +vim.api.nvim_buf_set_lines(0, 0, 1, false, { '[package' }) +vim.cmd('silent write') +local toml = vim.api.nvim_get_current_buf() +check('a broken mcpp.toml gets mcpp\'s problem in the `mcpp.toml` namespace', + vim.wait(mock and 10000 or 120000, function() return #vim.diagnostic.get(toml, { namespace = diagnostics.manifest }) > 0 end, 50), + (vim.diagnostic.get(toml, { namespace = diagnostics.manifest })[1] or {}).message) +vim.api.nvim_buf_set_lines(0, 0, 1, false, { '[package]' }) +vim.cmd('silent write') +os.remove(root .. '/.mock-mcpp-fail') +vim.cmd.edit(vim.fn.fnameescape(root .. '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/src/main.cpp')) + +-- ── the backend's views as text ─────────────────────────────────────────────────────────────── +local function float_text() + local text + vim.wait(mock and 5000 or 60000, function() + local win = vim.api.nvim_get_current_win() + if vim.api.nvim_win_get_config(win).relative ~= '' then + text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), '\n') + end + return text ~= nil + end, 20) + if text then + vim.cmd('close') + end + return text or '' +end +vim.cmd('Mcpp showTargets') +local targets = float_text() +check(':Mcpp showTargets lists the targets in a float', targets:find('linux', 1, true) ~= nil, targets:sub(1, 120)) +vim.cmd('Mcpp showCachePanel') +check(':Mcpp showCachePanel shows the cache by kind', float_text():find('By kind', 1, true) ~= nil) +vim.cmd('Mcpp project.showDependencyGraph') +check(':Mcpp project.showDependencyGraph shows the packages', float_text() ~= '') + +-- ── run, debug ──────────────────────────────────────────────────────────────────────────────── +-- The mock's launch names the program its fixture's build wrote; here it is a script that says hello. +if mock and not is_windows then + write('target/x86_64-linux-gnu/77ba4e6c37abd416/bin/app', '#!/bin/sh\necho "hello from app"\n') + uv.fs_chmod(root .. '/target/x86_64-linux-gnu/77ba4e6c37abd416/bin/app', 493) +end +local windows = #vim.api.nvim_list_wins() +vim.cmd('Mcpp run') +local term +vim.wait(mock and 10000 or 300000, function() + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[buf].buftype == 'terminal' and vim.b[buf].mcpp_program then + term = buf + end + end + return term ~= nil +end, 20) +check(':Mcpp run starts the launch in a terminal buffer', term ~= nil and #vim.api.nvim_list_wins() > windows, term and vim.b[term].mcpp_program) +if term and (mock and not is_windows) then + local hello = vim.wait(10000, function() + return table.concat(vim.api.nvim_buf_get_lines(term, 0, -1, false), '\n'):find('hello from app', 1, true) ~= nil + end, 50) + check('the program runs there', hello) +end + +local launched +package.loaded.dap = { adapters = { codelldb = {} }, run = function(config) launched = config end } +check('debug is offered once nvim-dap is there', vim.tbl_contains(vim.fn.getcompletion('Mcpp de', 'cmdline'), 'debug')) +vim.cmd('Mcpp debug') +vim.wait(mock and 10000 or 300000, function() return launched ~= nil end, 20) +check(':Mcpp debug hands nvim-dap the backend\'s configuration, as the adapter registered', + launched and launched.type == 'codelldb' and launched.request == 'launch' and (launched.program or '') ~= '', launched and launched.program) +package.loaded.dap = nil + +-- ── a cleanup, the quick menu, the language service, the self-check ─────────────────────────── +answers = { first, first } +before = session.state.last +vim.cmd('Mcpp clean') +done = finished('clean', before) +check(':Mcpp clean confirms the plan and applies it as a job', done and done.status == 'ok' and #asked > 0, done and done.status) + +answers = { nil } +asked = {} +said = {} +vim.cmd('Mcpp') +vim.wait(5000, function() return #asked > 0 end, 20) +check(':Mcpp alone opens the quick menu', #asked == 1 and #asked[1].items > 5, #asked > 0 and #asked[1].items or 0) + +require('mcpp.commands').language_service({ state = 'missing', message = 'mcppls was not found', actions = { { id = 'install', title = 'Install mcppls' } } }) +check('a missing mcppls is said with `xlings install mcppls-nvim`', heard('xlings install mcppls-nvim') ~= nil) +said = {} +vim.cmd('Mcpp languageServer.refreshState') +vim.wait(10000, function() return heard('mcppls') ~= nil end, 20) +check(':Mcpp languageServer.refreshState says which mcppls answers', heard('mcppls') ~= nil) +said = {} +vim.cmd('Mcpp selfCheck') +vim.wait(20000, function() return heard('Self-check') ~= nil end, 20) +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)) + +-- ── what only the mock plays ────────────────────────────────────────────────────────────────── +if mock then + before = session.state.last + vim.cmd('Mcpp build') + vim.wait(10000, function() return next(session.state.running) ~= nil end, 20) + vim.cmd('Mcpp cancel') + done = finished('build', before) + check(':Mcpp cancel ends the running build', done and done.status == 'cancelled', done and done.status) + + answers = { first, first } + before = session.state.last + vim.cmd('Mcpp library.search boost') + done = finished('add', before) + check(':Mcpp library.search adds the package chosen (mcpp add)', done and done.status == 'ok' and output():find('add', 1, true) ~= nil, + done and done.status) + + answers = { first, first, first } + before = session.state.last + vim.cmd('Mcpp pack') + done = finished('pack', before) + check(':Mcpp pack runs mcpp pack as a job', done ~= nil and done.kind == 'pack', done and done.status) + + -- The backend ends during a build: the build ends as failed, and the next command starts another. + local function count(text) + local n = 0 + for _ in output():gmatch(vim.pesc(text)) do + n = n + 1 + end + return n + end + local failed_before = count('mcpp build: failed') + vim.cmd('Mcpp build') + vim.wait(10000, function() return next(session.state.running) ~= nil end, 20) + session.state.client.terminate() + vim.wait(10000, function() return count('mcpp build: failed') > failed_before end, 20) + check('a backend that ends mid-build ends the build as failed', count('mcpp build: failed') > failed_before, count('mcpp build: failed')) + check('and is said to have ended', heard('The mcpp-ide backend ended.') ~= nil) + before = session.state.last + vim.cmd('Mcpp build') + done = finished('build', before) + check('the next command starts another backend, which builds', session.state.ready and done ~= nil, done and done.status) +end + +-- ── the target, last: a build after it would build for it ───────────────────────────────────── +answers = { function(items) return vim.tbl_filter(function(r) return not r.host end, items)[1] end, nil } +vim.cmd('Mcpp selectTarget') +vim.wait(10000, function() return (session.value('mcpp.build.target') or '') ~= '' end, 20) +local chosen = session.value('mcpp.build.target') or '' +check(':Mcpp selectTarget sets mcpp.build.target, and the statusline names it', chosen ~= '' and mcppide.status():find(chosen, 1, true) ~= nil, + mcppide.status()) + +session.stop() +check('the backend ends on shutdown and exit', session.state.client == nil) +vim.fn.delete(root, 'rf') +say(string.format('%d failure(s)', failures)) +os.exit(failures) diff --git a/vscode/.vscodeignore b/mide-plugins/vscode/.vscodeignore similarity index 100% rename from vscode/.vscodeignore rename to mide-plugins/vscode/.vscodeignore diff --git a/vscode/CHANGELOG.md b/mide-plugins/vscode/CHANGELOG.md similarity index 100% rename from vscode/CHANGELOG.md rename to mide-plugins/vscode/CHANGELOG.md diff --git a/vscode/README.md b/mide-plugins/vscode/README.md similarity index 98% rename from vscode/README.md rename to mide-plugins/vscode/README.md index 4a06afd..18c8212 100644 --- a/vscode/README.md +++ b/mide-plugins/vscode/README.md @@ -8,7 +8,8 @@ mcpp's shared build cache. 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. +machine contract; JetBrains CLion has the same features through mcpp-clion, and Neovim through +mcpp-nvim. ## Language features come from mcppls diff --git a/vscode/media/activity-bar.png b/mide-plugins/vscode/media/activity-bar.png similarity index 100% rename from vscode/media/activity-bar.png rename to mide-plugins/vscode/media/activity-bar.png diff --git a/vscode/media/logo.png b/mide-plugins/vscode/media/logo.png similarity index 100% rename from vscode/media/logo.png rename to mide-plugins/vscode/media/logo.png diff --git a/vscode/package-lock.json b/mide-plugins/vscode/package-lock.json similarity index 99% rename from vscode/package-lock.json rename to mide-plugins/vscode/package-lock.json index bcd9852..19d5e66 100644 --- a/vscode/package-lock.json +++ b/mide-plugins/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "1.0.0", + "version": "1.0.1", "license": "Apache-2.0", "devDependencies": { "@types/jsdom": "^27.0.0", diff --git a/vscode/package.json b/mide-plugins/vscode/package.json similarity index 96% rename from vscode/package.json rename to mide-plugins/vscode/package.json index a1c0cb7..58297ee 100644 --- a/vscode/package.json +++ b/mide-plugins/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "%displayName%", "description": "%description%", - "version": "1.0.0", + "version": "1.0.1", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", @@ -283,11 +283,11 @@ }, "scripts": { "compile": "tsc -p tsconfig.json", - "gen": "cd .. && mcpp run mcpp-ide-dev -- gen vscode --out vscode/dist/extension --stage", - "test": "npm run compile && node --test \"out/test/unit/**/*.test.js\" && node --test \"../webui/tests/*.test.js\"", + "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" + "check:webui": "tsc -p ../../webui/tsconfig.json" }, "devDependencies": { "@types/node": "^24.0.0", diff --git a/vscode/src/backend/client.ts b/mide-plugins/vscode/src/backend/client.ts similarity index 100% rename from vscode/src/backend/client.ts rename to mide-plugins/vscode/src/backend/client.ts diff --git a/vscode/src/backend/locate.ts b/mide-plugins/vscode/src/backend/locate.ts similarity index 100% rename from vscode/src/backend/locate.ts rename to mide-plugins/vscode/src/backend/locate.ts diff --git a/mide-plugins/vscode/src/backend/mcppls.ts b/mide-plugins/vscode/src/backend/mcppls.ts new file mode 100644 index 0000000..2e052b0 --- /dev/null +++ b/mide-plugins/vscode/src/backend/mcppls.ts @@ -0,0 +1,15 @@ +// Where the mcppls VS Code extension keeps its server: its payload directory (MCPPLS_PAYLOAD, else +// `/payload`), whose payload.json's `server` the backend starts -- the runner and the okb, or a +// program -- and MCPPLS_SERVER. The backend reads its version (languageService/status). +import * as fs from "node:fs"; +import * as path from "node:path"; + +export function mcpplsCandidates(extensionPath: string | undefined, env: NodeJS.ProcessEnv = process.env): string[] { + const out: string[] = []; + if (env.MCPPLS_SERVER) out.push(env.MCPPLS_SERVER); + if (!extensionPath) return out; + const payload = env.MCPPLS_PAYLOAD ? path.resolve(env.MCPPLS_PAYLOAD) : path.join(extensionPath, "payload"); + // An installation without a payload answers nothing here; PATH and xlings are tried after. + if (fs.existsSync(path.join(payload, "payload.json"))) out.push(payload); + return out; +} diff --git a/vscode/src/commands.ts b/mide-plugins/vscode/src/commands.ts similarity index 100% rename from vscode/src/commands.ts rename to mide-plugins/vscode/src/commands.ts diff --git a/vscode/src/debug.ts b/mide-plugins/vscode/src/debug.ts similarity index 100% rename from vscode/src/debug.ts rename to mide-plugins/vscode/src/debug.ts diff --git a/vscode/src/diagnostics.ts b/mide-plugins/vscode/src/diagnostics.ts similarity index 100% rename from vscode/src/diagnostics.ts rename to mide-plugins/vscode/src/diagnostics.ts diff --git a/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts similarity index 100% rename from vscode/src/extension.ts rename to mide-plugins/vscode/src/extension.ts diff --git a/vscode/src/jobs.ts b/mide-plugins/vscode/src/jobs.ts similarity index 100% rename from vscode/src/jobs.ts rename to mide-plugins/vscode/src/jobs.ts diff --git a/vscode/src/mapping.ts b/mide-plugins/vscode/src/mapping.ts similarity index 100% rename from vscode/src/mapping.ts rename to mide-plugins/vscode/src/mapping.ts diff --git a/vscode/src/panels.ts b/mide-plugins/vscode/src/panels.ts similarity index 98% rename from vscode/src/panels.ts rename to mide-plugins/vscode/src/panels.ts index 3ff4e94..1c8917e 100644 --- a/vscode/src/panels.ts +++ b/mide-plugins/vscode/src/panels.ts @@ -1,4 +1,4 @@ -// The shared panels (../webui) in VS Code: editor-area panels and the sidebar's webview views. The +// 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"; diff --git a/vscode/src/projectTree.ts b/mide-plugins/vscode/src/projectTree.ts similarity index 100% rename from vscode/src/projectTree.ts rename to mide-plugins/vscode/src/projectTree.ts diff --git a/vscode/src/projects.ts b/mide-plugins/vscode/src/projects.ts similarity index 100% rename from vscode/src/projects.ts rename to mide-plugins/vscode/src/projects.ts diff --git a/vscode/src/protocol.ts b/mide-plugins/vscode/src/protocol.ts similarity index 100% rename from vscode/src/protocol.ts rename to mide-plugins/vscode/src/protocol.ts diff --git a/vscode/src/run.ts b/mide-plugins/vscode/src/run.ts similarity index 100% rename from vscode/src/run.ts rename to mide-plugins/vscode/src/run.ts diff --git a/vscode/src/session.ts b/mide-plugins/vscode/src/session.ts similarity index 100% rename from vscode/src/session.ts rename to mide-plugins/vscode/src/session.ts diff --git a/vscode/src/status.ts b/mide-plugins/vscode/src/status.ts similarity index 100% rename from vscode/src/status.ts rename to mide-plugins/vscode/src/status.ts diff --git a/vscode/src/tasks.ts b/mide-plugins/vscode/src/tasks.ts similarity index 100% rename from vscode/src/tasks.ts rename to mide-plugins/vscode/src/tasks.ts diff --git a/vscode/src/testing.ts b/mide-plugins/vscode/src/testing.ts similarity index 100% rename from vscode/src/testing.ts rename to mide-plugins/vscode/src/testing.ts diff --git a/vscode/src/toml.ts b/mide-plugins/vscode/src/toml.ts similarity index 100% rename from vscode/src/toml.ts rename to mide-plugins/vscode/src/toml.ts diff --git a/vscode/syntaxes/mcpp-modules.tmLanguage.json b/mide-plugins/vscode/syntaxes/mcpp-modules.tmLanguage.json similarity index 100% rename from vscode/syntaxes/mcpp-modules.tmLanguage.json rename to mide-plugins/vscode/syntaxes/mcpp-modules.tmLanguage.json diff --git a/vscode/syntaxes/mcpp-toml.tmLanguage.json b/mide-plugins/vscode/syntaxes/mcpp-toml.tmLanguage.json similarity index 100% rename from vscode/syntaxes/mcpp-toml.tmLanguage.json rename to mide-plugins/vscode/syntaxes/mcpp-toml.tmLanguage.json diff --git a/vscode/test/e2e/runTest.ts b/mide-plugins/vscode/test/e2e/runTest.ts similarity index 93% rename from vscode/test/e2e/runTest.ts rename to mide-plugins/vscode/test/e2e/runTest.ts index 7089dee..bef9bba 100644 --- a/vscode/test/e2e/runTest.ts +++ b/mide-plugins/vscode/test/e2e/runTest.ts @@ -7,7 +7,7 @@ import * as path from "node:path"; import { runTests } from "@vscode/test-electron"; -import { built, repo } from "../unit/harness"; +import { built, extensionDir } from "../unit/harness"; async function main(): Promise { const backend = built("mcpp-ide"), mock = built("mcpp-ide-mock-mcpp"); @@ -30,11 +30,11 @@ async function main(): Promise { })); const executable = process.env.MCPP_E2E_CODE; // MCPP_E2E_EXTENSION: another extension directory (an unpacked .vsix) in place of the staged one. - const extensionDevelopmentPath = process.env.MCPP_E2E_EXTENSION ?? path.join(repo, "vscode", "dist", "extension"); + const extensionDevelopmentPath = process.env.MCPP_E2E_EXTENSION ?? path.join(extensionDir, "dist", "extension"); await runTests({ ...(executable ? { vscodeExecutablePath: executable } : { version: "1.91.0" }), extensionDevelopmentPath, - extensionTestsPath: path.join(repo, "vscode", "out", "test", "e2e", "suite", "index.js"), + extensionTestsPath: path.join(extensionDir, "out", "test", "e2e", "suite", "index.js"), launchArgs: [workspace, "--user-data-dir", path.join(temp, "user-data"), "--extensions-dir", path.join(temp, "extensions"), "--disable-gpu", "--disable-workspace-trust", "--skip-welcome", "--skip-release-notes", // No display (a container, an agent's shell): Chromium's headless platform. diff --git a/vscode/test/e2e/suite/index.ts b/mide-plugins/vscode/test/e2e/suite/index.ts similarity index 100% rename from vscode/test/e2e/suite/index.ts rename to mide-plugins/vscode/test/e2e/suite/index.ts diff --git a/vscode/test/unit/client.test.ts b/mide-plugins/vscode/test/unit/client.test.ts similarity index 100% rename from vscode/test/unit/client.test.ts rename to mide-plugins/vscode/test/unit/client.test.ts diff --git a/vscode/test/unit/harness.ts b/mide-plugins/vscode/test/unit/harness.ts similarity index 75% rename from vscode/test/unit/harness.ts rename to mide-plugins/vscode/test/unit/harness.ts index 6c15e09..d716e09 100644 --- a/vscode/test/unit/harness.ts +++ b/mide-plugins/vscode/test/unit/harness.ts @@ -1,10 +1,13 @@ -// What the unit tests share: the repository, the programs its build produced (the backend and the -// mock mcpp, newest first), and a project directory the mock answers for. +// What the unit tests share: the extension's directory (mide-plugins/vscode), the repository, the +// programs its build produced (the backend and the mock mcpp, newest first), and a project directory +// the mock answers for. import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -export const repo = path.resolve(__dirname, "..", "..", "..", ".."); +// Compiled to out/test/unit. +export const extensionDir = path.resolve(__dirname, "..", "..", ".."); +export const repo = path.resolve(extensionDir, "..", ".."); export function built(name: string): string | undefined { const exe = process.platform === "win32" ? `${name}.exe` : name; diff --git a/vscode/test/unit/locate.test.ts b/mide-plugins/vscode/test/unit/locate.test.ts similarity index 89% rename from vscode/test/unit/locate.test.ts rename to mide-plugins/vscode/test/unit/locate.test.ts index 2b0285d..3741858 100644 --- a/vscode/test/unit/locate.test.ts +++ b/mide-plugins/vscode/test/unit/locate.test.ts @@ -44,10 +44,12 @@ test("PATH lookup appends .exe on Windows", () => { assert.equal(onPath("mcpp-ide", { PATH: dir }, "linux"), undefined); }); -test("mcppls: the extension's payload names its server", () => { +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" } })); - assert.deepEqual(mcpplsCandidates(ext, {}), [path.join(ext, "payload", "bin", "mcppls")]); + 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: "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/s/mcppls" }), ["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/s/mcppls"]); }); diff --git a/vscode/test/unit/mapping.test.ts b/mide-plugins/vscode/test/unit/mapping.test.ts similarity index 100% rename from vscode/test/unit/mapping.test.ts rename to mide-plugins/vscode/test/unit/mapping.test.ts diff --git a/vscode/tsconfig.json b/mide-plugins/vscode/tsconfig.json similarity index 100% rename from vscode/tsconfig.json rename to mide-plugins/vscode/tsconfig.json diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index d8c2331..d1234e5 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -11,12 +11,12 @@ "type": "object", "properties": { "client": { "type": "object", "properties": { "name": { "type": "string" }, "version": { "type": "string" } } }, - "editor": { "enum": ["vscode", "jetbrains", "cli"] }, + "editor": { "enum": ["vscode", "jetbrains", "nvim", "cli"], "description": "The adapter: mcpp-vscode, mcpp-clion (the IntelliJ platform's id), mcpp-nvim, or an agent" }, "locale": { "type": "string" }, "trusted": { "type": "boolean" }, "settings": { "$ref": "#/$defs/settingValues" }, "workspaceFolders": { "type": "array", "items": { "type": "string" } }, - "languageService": { "type": "object", "properties": { "candidates": { "type": "array", "items": { "type": "string" } } } } + "languageService": { "type": "object", "properties": { "candidates": { "type": "array", "items": { "type": "string" }, "description": "Payload directories (payload.json's `server`) or programs, the adapter's mcppls first" } } } } }, "result": { @@ -58,8 +58,8 @@ "result": { "type": "object", "required": ["backend", "checks"], "properties": { "checks": { "type": "array", "items": { "$ref": "#/$defs/check" } } } } }, "commands/list": { - "description": "The command table (spec/commands.json) for one editor, with titles in the session's language.", - "params": { "type": "object", "properties": { "editor": { "enum": ["vscode", "jetbrains", "cli"] } } }, + "description": "The command table (spec/commands.json) for one editor, with titles in the session's language and each command's subcommand.", + "params": { "type": "object", "properties": { "editor": { "enum": ["vscode", "jetbrains", "nvim", "cli"] } } }, "result": { "type": "object", "required": ["commands"], "properties": { "commands": { "type": "array", "items": { "$ref": "#/$defs/command" } } } } }, "menu/quick": { @@ -324,7 +324,8 @@ "required": ["state", "required", "message", "actions"], "properties": { "state": { "enum": ["ok", "outdated", "missing", "unreadable"] }, - "program": { "type": "string" }, + "program": { "type": "string", "description": "The server that answered: the image (an okb its runner starts) or the program" }, + "command": { "type": "array", "items": { "type": "string" }, "description": "What starts it: the runner and the image, or the program" }, "source": { "type": "string" }, "version": { "type": "string" }, "required": { "type": "string" }, @@ -334,7 +335,7 @@ }, "command": { "type": "object", - "required": ["id", "title", "action"], + "required": ["id", "title", "subcommand", "action"], "properties": { "id": { "type": "string" }, "title": { "type": "string" }, @@ -342,7 +343,8 @@ "groupTitle": { "type": "string" }, "when": { "type": "string" }, "quickMenu": { "type": "boolean" }, - "action": { "type": "object", "required": ["kind"], "properties": { "kind": { "enum": ["job", "run", "debug", "cache", "panel", "cancel", "selectTarget", "installToolchain", "selectDefaultToolchain", "newProject", "quickMenu", "selfCheck", "rpc", "languageService", "forward", "editor"] } } } + "subcommand": { "type": "string", "description": "The name a command line types (`:Mcpp ` in Neovim): the row's own, else the id after `mcpp.`" }, + "action": { "type": "object", "required": ["kind"], "properties": { "kind": { "enum": ["job", "run", "debug", "cache", "panel", "cancel", "selectTarget", "installToolchain", "selectDefaultToolchain", "newProject", "quickMenu", "selfCheck", "rpc", "languageService", "forward", "editor", "problems"] } } } } }, "launchParams": { "type": "object", "required": ["root"], "properties": { "root": { "type": "string" }, "target": { "type": "string" }, "profile": { "type": "string" }, "bin": { "type": "string" }, "args": { "type": "array", "items": { "type": "string" } } } }, diff --git a/spec/commands.json b/spec/commands.json index f8bfb44..4997a1f 100644 --- a/spec/commands.json +++ b/spec/commands.json @@ -27,7 +27,8 @@ { "id": "mcpp.pack", "group": "project", "icon": { "vscode": "$(package)", "jetbrains": "AllIcons.Nodes.PpLib" }, "when": "mcpp.inProject", "action": { "kind": "panel", "panel": "pack" }, "quickMenu": true }, { "id": "mcpp.cancelJob", "group": "project", "icon": { "vscode": "$(debug-stop)", "jetbrains": "AllIcons.Actions.Suspend" }, - "when": "mcpp.jobRunning", "action": { "kind": "cancel" } }, + "when": "mcpp.jobRunning", "subcommand": "cancel", "action": { "kind": "cancel" } }, + { "id": "mcpp.problems", "group": "project", "editors": [ "nvim" ], "action": { "kind": "problems" }, "quickMenu": true }, { "id": "mcpp.selectTarget", "group": "toolchain", "icon": { "vscode": "$(target)", "jetbrains": "AllIcons.General.Gear" }, "action": { "kind": "selectTarget" }, "quickMenu": true }, { "id": "mcpp.showTargets", "group": "toolchain", "icon": { "vscode": "$(server-environment)", "jetbrains": "AllIcons.Nodes.Deploy" }, @@ -39,9 +40,10 @@ "action": { "kind": "newProject" }, "quickMenu": true }, { "id": "mcpp.showMenu", "group": "tools", "icon": { "vscode": "$(list-flat)" }, "when": "mcpp.inProject", "keybinding": { "key": "ctrl+alt+m", "mac": "cmd+alt+m" }, - "action": { "kind": "quickMenu" }, "editors": [ "vscode" ] }, + "action": { "kind": "quickMenu" }, "editors": [ "vscode", "nvim" ] }, { "id": "mcpp.openSettings", "group": "tools", "icon": { "vscode": "$(settings-gear)", "jetbrains": "AllIcons.General.Settings" }, - "action": { "kind": "panel", "panel": "settings" }, "quickMenu": true }, + "action": { "kind": "panel", "panel": "settings" }, "quickMenu": true, "editors": [ "vscode", "jetbrains", "cli" ], + "omitted": { "nvim": "Neovim's settings are setup()'s `settings` table, which the backend validates; no panel edits them." } }, { "id": "mcpp.selfCheck", "group": "tools", "icon": { "jetbrains": "AllIcons.Actions.Checked" }, "action": { "kind": "selfCheck" }, "quickMenu": true }, { "id": "mcpp.autoConfigureModules", "group": "languageService", "action": { "kind": "job", "job": "build", "then": "languageService" } }, @@ -51,7 +53,8 @@ "menus": [ { "menu": "view/title", "group": "navigation@2", "when": "view == mcpp.cache" } ], "action": { "kind": "panel", "panel": "cache", "refresh": true } }, { "id": "mcpp.showCachePanel", "group": "cache", "action": { "kind": "panel", "panel": "cache" }, "quickMenu": true }, - { "id": "mcpp.showCacheEntry", "group": "cache", "palette": false, "action": { "kind": "panel", "panel": "cache" } }, + { "id": "mcpp.showCacheEntry", "group": "cache", "palette": false, "action": { "kind": "panel", "panel": "cache" }, + "editors": [ "vscode", "jetbrains", "cli" ], "omitted": { "nvim": "An entry of the cache panel, opened from that panel; Neovim's cache view is text (mcpp.showCachePanel)." } }, { "id": "mcpp.cleanStaleArtifacts", "group": "cache", "action": { "kind": "cache", "plan": "stale" } }, { "id": "mcpp.cleanProjectArtifacts", "group": "cache", "when": "mcpp.inProject", "keybinding": { "key": "ctrl+alt+l", "mac": "cmd+alt+l" }, "action": { "kind": "cache", "plan": "project" } }, @@ -62,7 +65,8 @@ { "id": "mcpp.library.search", "group": "library", "icon": { "vscode": "$(cloud-download)", "jetbrains": "AllIcons.Actions.Find" }, "action": { "kind": "panel", "panel": "libraries" }, "quickMenu": true }, - { "id": "mcpp.library.openDetail", "group": "library", "palette": false, "action": { "kind": "panel", "panel": "library" } }, + { "id": "mcpp.library.openDetail", "group": "library", "palette": false, "action": { "kind": "panel", "panel": "library" }, + "editors": [ "vscode", "jetbrains", "cli" ], "omitted": { "nvim": "A package of the libraries panel, opened from that panel; in Neovim mcpp.library.search lists the packages and adds the one chosen." } }, { "id": "mcpp.library.updateIndex", "group": "library", "icon": { "vscode": "$(cloud-download)", "jetbrains": "AllIcons.Actions.Download" }, "menus": [ { "menu": "view/title", "group": "navigation@2", "when": "view == mcpp.library" } ], "action": { "kind": "rpc", "method": "library/updateIndex" } }, diff --git a/spec/i18n/manifest.en.json b/spec/i18n/manifest.en.json index 597c5dd..3fed35c 100644 --- a/spec/i18n/manifest.en.json +++ b/spec/i18n/manifest.en.json @@ -210,6 +210,7 @@ "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", diff --git a/spec/i18n/manifest.zh-cn.json b/spec/i18n/manifest.zh-cn.json index 61aae95..fafcb9d 100644 --- a/spec/i18n/manifest.zh-cn.json +++ b/spec/i18n/manifest.zh-cn.json @@ -210,6 +210,7 @@ "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": "工程", diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index cc894f7..9978670 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -142,6 +142,7 @@ "Custom index": "自定义索引", "Data source": "数据来源", "Debug": "调试", + "Debugging in Neovim needs nvim-dap.": "在 Neovim 中调试需要 nvim-dap。", "Declaration graph · dashed edges are conditional · lockfile supplies versions": "声明关系图 · 虚线为条件依赖 · 锁文件提供版本", "Declare dependency versions centrally; members inherit them with workspace = true": "集中声明依赖版本,成员用 workspace = true 继承", "Declared": "已声明", @@ -303,6 +304,7 @@ "No local index was read.": "没有读取本地索引。", "No local mcpp-index found": "未找到本地 mcpp-index", "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 job is running.": "没有正在运行的 mcpp 任务。", "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 module relationships were found for this project.": "此项目尚无模块关系。", @@ -310,6 +312,7 @@ "No package matches this search.": "没有匹配的包。", "No package matches.": "没有匹配的包。", "No platform fields reported.": "未报告平台字段。", + "No problems were reported.": "没有报告任何问题。", "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/ 目录", @@ -498,7 +501,9 @@ "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 mcpp-ide backend could not be started: {0}": "无法启动 mcpp-ide 后端:{0}", "The mcpp-ide backend ended.": "mcpp-ide 后端已结束。", + "The mcpp-ide backend was not found. Install it with `xlings install mcpp-ide`, or name it with setup()'s `backend`.": "未找到 mcpp-ide 后端。请用 `xlings install mcpp-ide` 安装,或在 setup() 的 `backend` 中指定。", "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 package descriptor is missing. Reread the local index.": "包描述不存在,请重新读取本地索引。", "The program did not build; see the task terminal for the compiler output.": "程序未构建成功;编译器输出见任务终端。", @@ -528,6 +533,7 @@ "The workspace is not trusted; mcpp itself was not probed.": "工作区未受信任;未探测 mcpp 本身。", "The workspace is not trusted; the other registries were not searched.": "工作区未受信任;未搜索其他 registry。", "The {0} largest cache labels, by bytes.": "按字节数排名的前 {0} 个缓存标签。", + "There is no mcpp command {0}.": "没有名为 {0} 的 mcpp 命令。", "There is no pre-v1 cache to remove.": "没有可清理的 pre-v1 缓存。", "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 会询问只对本工作区还是对所有地方生效。", "This command is not available in this IDE.": "此命令在当前 IDE 中不可用。", @@ -667,12 +673,14 @@ "mcpp: Clean": "mcpp: 清理", "mcpp: Run": "mcpp: 运行", "mcpp: Test": "mcpp: 测试", + "mcpp: building…": "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 at {0} did not report its version: {1}": "{0} 处的 mcppls 未报告版本:{1}", + "mcppls for Neovim: `xlings install mcppls-nvim`": "Neovim 的 mcppls:`xlings install mcppls-nvim`", "mcppls was not found: C++ files have no completion, navigation or diagnostics from mcpp's build. Install the C++ Modules language server ({0} or later).": "未找到 mcppls:C++ 文件没有基于 mcpp 构建的补全、跳转与诊断。请安装 C++ Modules 语言服务({0} 或更新版本)。", "mcppls {0}": "mcppls {0}", "mcppls {0} at {1}": "mcppls {0},位于 {1}", @@ -683,6 +691,7 @@ "no such package": "没有这个包", "none": "无", "not read yet": "尚未读取", + "nvim-dap has no adapter for {0}; register one under one of these names: {1}.": "nvim-dap 中没有 {0} 的适配器;请以下列名字之一注册:{1}。", "older than {0} days": "超过 {0} 天", "one per line": "每行一个", "openkal-compat": "openkal-compat", diff --git a/src/bin/dev.cpp b/src/bin/dev.cpp index 0411bf3..d8d5023 100644 --- a/src/bin/dev.cpp +++ b/src/bin/dev.cpp @@ -6,23 +6,27 @@ // records the backend reads. The mock mcpp and both adapters' tests replay them, so a change in // mcpp's contract shows up as a diff here first. // mcpp-ide-dev gen vscode --out DIR [--stage] [--okb FILE] [--okrun DIR] [--backends DIR] -// The VS Code extension's declarations from spec/: package.json (vscode/package.json with the -// generated commands, keybindings, menus and configuration), package.nls.json and -// package.nls.zh-cn.json. With --stage, also the files the extension ships (vscode/out, media, +// The VS Code extension's declarations from spec/: package.json (mide-plugins/vscode/package.json +// with the generated commands, keybindings, menus and configuration), package.nls.json and +// package.nls.zh-cn.json. With --stage, also the files the extension ships (its out/, media, // syntaxes, the webui, README, CHANGELOG, LICENSE, and the backend: see stage_backend): the // directory `vsce package` packs. // mcpp-ide-dev gen jetbrains --out DIR -// The CLion plugin's declarations from spec/: META-INF/mcpp-actions.xml (included by -// plugin.xml) and the message bundles messages/McppClionBundle[_zh_CN].properties. +// The CLion plugin's declarations from spec/ (the IntelliJ platform's, editor id `jetbrains`): +// META-INF/mcpp-actions.xml (included by plugin.xml) and the message bundles +// messages/McppClionBundle[_zh_CN].properties. // mcpp-ide-dev check-i18n -// Every runtime string the backend and the adapters pass to t(...) has a Chinese entry in -// spec/i18n/runtime.zh-cn.json; every editor string a Chinese one in manifest.zh-cn.json. +// Every runtime string the backend, the panels and the plugins pass to t(...) has a Chinese +// entry in spec/i18n/runtime.zh-cn.json; every editor string a Chinese one in +// manifest.zh-cn.json. // // Nothing generated is written into the repository: the outputs go where the adapters' builds put -// them (vscode/dist, jetbrains/build). +// them (mide-plugins/vscode/dist, mide-plugins/clion/build). The Neovim plugin (mide-plugins/nvim) +// declares nothing statically: it reads the command table from the backend at run time. import std; import nlohmann.json; import mcpplibs.os; +import mcppide.spec; namespace { @@ -62,6 +66,9 @@ fs::path repo() { throw std::runtime_error("run mcpp-ide-dev inside the mcpp-ide repository"); } +// An editor's plugin: mide-plugins/ (vscode, clion, nvim). +fs::path plugin(const fs::path& root, std::string_view editor) { return root / "mide-plugins" / editor; } + std::optional option(std::span args, std::string_view name) { for (std::size_t i = 0; i + 1 < args.size(); ++i) if (args[i] == name) return args[i + 1]; return std::nullopt; @@ -188,13 +195,9 @@ Spec load_spec(const fs::path& root) { read_json(root / "spec" / "i18n" / "manifest.en.json"), read_json(root / "spec" / "i18n" / "manifest.zh-cn.json")}; } -bool offered_in(const Json& c, std::string_view editor) { - if (c.contains("editors") && c["editors"].is_array()) - return std::ranges::any_of(c["editors"], [&](const Json& e) { return e.is_string() && e.get() == editor; }); - const auto kind = c.contains("action") ? c["action"].value("kind", "") : std::string{}; - if (kind == "forward" || kind == "editor") return editor == "vscode"; - return true; -} +// Which rows an editor declares is the backend's rule (spec::offered_in), the one its command table +// answers at run time. +using mcppide::spec::offered_in; // The `contributes` that come from spec/: commands, keybindings, menus, configuration. void vscode_contributes(const Spec& spec, Json& contributes) { @@ -286,9 +289,10 @@ void stage_backend(std::span args, const fs::path& out) { int gen_vscode(std::span args) { const auto root = repo(); - const auto out = fs::path(option(args, "--out").value_or((root / "vscode" / "dist" / "extension").string())); + const auto ext = plugin(root, "vscode"); + const auto out = fs::path(option(args, "--out").value_or((ext / "dist" / "extension").string())); const auto spec = load_spec(root); - auto pkg = read_json(root / "vscode" / "package.json"); + auto pkg = read_json(ext / "package.json"); vscode_contributes(spec, pkg["contributes"]); // npm's own fields stay in the development manifest. pkg.erase("scripts"); @@ -299,13 +303,13 @@ int gen_vscode(std::span args) { for (auto it = spec.zh.begin(); it != spec.zh.end(); ++it) zh[it.key()] = it.value(); spit(out / "package.nls.zh-cn.json", zh.dump(2) + "\n"); if (flag(args, "--stage")) { - copy_tree(root / "vscode" / "out" / "src", out / "out" / "src"); - copy_tree(root / "vscode" / "media", out / "media"); - copy_tree(root / "vscode" / "syntaxes", out / "syntaxes"); + copy_tree(ext / "out" / "src", out / "out" / "src"); + copy_tree(ext / "media", out / "media"); + copy_tree(ext / "syntaxes", out / "syntaxes"); static constexpr std::array webuiSkip{"tests", "tsconfig.json", "js/types.d.ts"}; copy_tree(root / "webui", out / "webui", webuiSkip); for (const auto* f : {"README.md", "CHANGELOG.md", ".vscodeignore"}) - if (fs::exists(root / "vscode" / f)) fs::copy_file(root / "vscode" / f, out / f, fs::copy_options::overwrite_existing); + if (fs::exists(ext / f)) fs::copy_file(ext / f, out / f, fs::copy_options::overwrite_existing); fs::copy_file(root / "LICENSE", out / "LICENSE.txt", fs::copy_options::overwrite_existing); // The runtime strings, for what the extension says before its backend runs. fs::create_directories(out / "i18n"); @@ -357,7 +361,7 @@ std::string xml_escape(std::string_view s) { int gen_jetbrains(std::span args) { const auto root = repo(); - const auto out = fs::path(option(args, "--out").value_or((root / "jetbrains" / "build" / "generated" / "mcpp-ide").string())); + const auto out = fs::path(option(args, "--out").value_or((plugin(root, "clion") / "build" / "generated" / "mcpp-ide").string())); const auto spec = load_spec(root); // The actions: one group per spec group under Tools | mcpp, every command one action of the same // class, which reads what it does from the backend's command table by its id. JetBrains keeps its @@ -453,11 +457,12 @@ int check_i18n(std::span) { const auto zh = read_json(root / "spec" / "i18n" / "runtime.zh-cn.json"); std::set missing; std::size_t seen = 0; - const std::array>, 4> trees{{ + const std::array>, 5> trees{{ {root / "src", {".cppm", ".cpp"}}, - {root / "vscode" / "src", {".ts"}}, + {plugin(root, "vscode") / "src", {".ts"}}, {root / "webui", {".js"}}, - {root / "jetbrains" / "src" / "main" / "kotlin", {".kt"}}, + {plugin(root, "clion") / "src" / "main" / "kotlin", {".kt"}}, + {plugin(root, "nvim") / "lua", {".lua"}}, }}; std::error_code ec; for (const auto& [dir, exts] : trees) diff --git a/src/languageservice.cppm b/src/languageservice.cppm index 9b9e391..73a51c3 100644 --- a/src/languageservice.cppm +++ b/src/languageservice.cppm @@ -7,6 +7,12 @@ // (`target/.last-result.json`, mcpp docs/50 §5.2) and refreshes after any build -- one started here, // in a terminal or by an agent -- so a build through mcpp-ide needs nothing forwarded to it. An older // mcppls is reported with what to do, never degraded around in silence. +// +// A SERVER IS A COMMAND. mcppls's payload (the VSIX's and the CLion package's `payload/`, the one Zed's +// install puts in the user data directory) carries the server as one okb of both architectures that the +// platform's runner starts: payload.json's `server` names `runner` and `path` (`bin/okrun bin/mcppls.okb`), +// or a program alone. A candidate is a payload directory or a program, and what is asked for its version +// is the command it stands for. export module mcppide.languageservice; import std; @@ -26,18 +32,32 @@ std::string_view state_name(State s); struct Status { State state = State::missing; - std::filesystem::path program; // the mcppls that answered (empty when none was found) + std::filesystem::path program; // the mcppls that answered: the image or the program (empty when none was found) + std::vector command; // what starts it: the runner and the image, or the program std::string source; // "editor" (a path the adapter named) | "PATH" | "xlings" | "payload" std::string version; // as it printed it std::string detail; // why the version could not be read }; +// A place mcppls may be, as the command that starts it. +struct Candidate { + std::vector command; + std::string source; +}; + // The version in `mcppls --version`'s output: the first word that starts with a digit. std::string version_in(std::string_view output); +// The command payload.json's `server` names in a payload directory: the runner and the image, or the +// program. Empty when the directory holds no payload or what it names is not there. +std::vector payload_command(const std::filesystem::path& payload); + +// The command a path the adapter named stands for: a payload directory's server, or the program itself. +std::vector command_of(const std::filesystem::path& named); + // The places to look, in order: what the adapter knows (the mcppls extension's or plugin's own -// server), PATH, xlings, and the payload the mcppls CLion plugin installs. -std::vector> candidates(const std::vector& fromEditor); +// payload, or a program), PATH, xlings, and the payload Zed's install puts in the user data directory. +std::vector candidates(const std::vector& fromEditor); // Finds mcppls among the candidates and reads its version. Status probe(const std::vector& fromEditor); @@ -79,19 +99,53 @@ std::string version_in(std::string_view output) { return {}; } -std::vector> candidates(const std::vector& fromEditor) { - std::vector> out; - for (const auto& p : fromEditor) if (!p.empty()) out.push_back({std::filesystem::path(p), "editor"}); - if (auto found = mcpplibs::os::env::find_executable("mcppls")) out.push_back({*found, "PATH"}); +// A file payload.json names, inside the payload: a relative path with no `..` part, as mcppls's own +// payload reader holds it. Empty when it would name something outside. +std::filesystem::path within(const std::filesystem::path& payload, const std::string& named) { + const std::filesystem::path relative(named); + if (relative.empty() || relative.has_root_path() || relative.has_root_name()) return {}; + for (const auto& part : relative) + if (part == "..") return {}; + return payload / relative; +} + +std::vector payload_command(const std::filesystem::path& payload) { + std::error_code ec; + std::ifstream in(payload / "payload.json", std::ios::binary); + if (!in) return {}; + const Json manifest = Json::parse(in, nullptr, false); + if (!manifest.is_object() || !manifest.contains("server") || !manifest["server"].is_object()) return {}; + const Json& server = manifest["server"]; + if (!server.contains("path") || !server["path"].is_string()) return {}; + std::vector command; + if (server.contains("runner") && server["runner"].is_string()) command.push_back(within(payload, server["runner"].get())); + command.push_back(within(payload, server["path"].get())); + for (const auto& part : command) + if (part.empty() || !std::filesystem::is_regular_file(part, ec)) return {}; + return command; +} + +std::vector command_of(const std::filesystem::path& named) { + std::error_code ec; + if (std::filesystem::is_directory(named, ec)) return payload_command(named); + if (std::filesystem::is_regular_file(named, ec)) return {named}; + return {}; +} + +std::vector candidates(const std::vector& fromEditor) { + std::vector out; + for (const auto& p : fromEditor) if (!p.empty()) out.push_back({command_of(p), "editor"}); + if (auto found = mcpplibs::os::env::find_executable("mcppls")) out.push_back({{*found}, "PATH"}); const auto exe = std::string("mcppls") + std::string(mcpplibs::os::conventions().exe_suffix); if (auto xh = mcpplibs::os::env::get("XLINGS_HOME")) - out.push_back({std::filesystem::path(*xh) / "subos" / "current" / "bin" / exe, "xlings"}); + out.push_back({command_of(std::filesystem::path(*xh) / "subos" / "current" / "bin" / exe), "xlings"}); if (auto home = mcpplibs::os::dirs::home()) { - out.push_back({*home / ".xlings" / "subos" / "current" / "bin" / exe, "xlings"}); - out.push_back({*home / ".xlings" / "subos" / "default" / "bin" / exe, "xlings"}); + out.push_back({command_of(*home / ".xlings" / "subos" / "current" / "bin" / exe), "xlings"}); + out.push_back({command_of(*home / ".xlings" / "subos" / "default" / "bin" / exe), "xlings"}); } - // The mcppls CLion plugin's `--install` puts the server at `/mcppls/payload`. - if (auto data = mcpplibs::os::dirs::data()) out.push_back({*data / "mcppls" / "payload" / "bin" / exe, "payload"}); + // The payload Zed's install puts at `/mcppls/payload`. + if (auto data = mcpplibs::os::dirs::data()) out.push_back({payload_command(*data / "mcppls" / "payload"), "payload"}); + std::erase_if(out, [](const Candidate& c) { return c.command.empty(); }); return out; } @@ -109,26 +163,28 @@ Status classify(const std::filesystem::path& program, std::string source, std::s return s; } +// The first candidate is the one asked: the one the adapter named, else the first found. Status probe(const std::vector& fromEditor) { - std::error_code ec; - for (const auto& [program, source] : candidates(fromEditor)) { - if (!std::filesystem::is_regular_file(program, ec)) continue; - mcpplibs::os::process::spawn_options o; - o.program = program; - o.args = {"--version"}; - o.in = mcpplibs::os::process::stdio::null; - auto r = mcpplibs::os::process::run(o); - if (!r) { - Status s; - s.state = State::unreadable; - s.program = program; - s.source = source; - s.detail = r.error().message(); - return s; - } - return classify(program, source, r->out); + const auto found = candidates(fromEditor); + if (found.empty()) return Status{}; + const auto& [command, source] = found.front(); + mcpplibs::os::process::spawn_options o; + o.program = command.front(); + for (std::size_t i = 1; i < command.size(); ++i) o.args.push_back(command[i].string()); + o.args.push_back("--version"); + o.in = mcpplibs::os::process::stdio::null; + auto r = mcpplibs::os::process::run(o); + Status s; + if (!r) { + s.state = State::unreadable; + s.program = command.back(); + s.source = source; + s.detail = r.error().message(); + } else { + s = classify(command.back(), source, r->out); } - return Status{}; + s.command = command; + return s; } } // namespace mcppide::languageservice diff --git a/src/server.cppm b/src/server.cppm index 7230cc1..98e6ee6 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -5,8 +5,11 @@ // THE ADAPTER DECIDES NOTHING. Which argv a build runs, how much confirmation a // clean needs, what a cache panel shows, what a setting may hold, which // commands an editor offers and what a test record means: answered here, so VS -// Code and CLion behave alike and an agent reading the backend's log sees the -// same reasons a person sees in the editor. +// Code, CLion and Neovim behave alike and an agent reading the backend's log +// sees the same reasons a person sees in the editor. +// +// The editors (the protocol's `editor`): vscode (mcpp-vscode), jetbrains +// (mcpp-clion: the IntelliJ platform's id), nvim (mcpp-nvim), cli (an agent). export module mcppide.server; import std; @@ -28,7 +31,7 @@ export namespace mcppide::server { using Json = nlohmann::json; -inline constexpr std::string_view kVersion = "0.1.0"; +inline constexpr std::string_view kVersion = "0.1.1"; inline constexpr int kProtocol = 1; // The methods `install` registers, for the protocol schema's test: a method @@ -146,15 +149,6 @@ std::vector strings_of(const Json& v) { if (v.is_array()) for (const auto& x : v) if (x.is_string()) out.push_back(x.get()); return out; } -// A command's place in an editor: `editors` lists the editors that offer it (all, when absent); a -// forward to mcppls's own commands exists only where mcppls has commands to forward to (VS Code). -bool offered_in(const Json& c, std::string_view editor) { - if (c.contains("editors") && c["editors"].is_array()) - return std::ranges::any_of(c["editors"], [&](const Json& e) { return e.is_string() && e.get() == editor; }); - const auto kind = c.contains("action") ? c["action"].value("kind", "") : std::string{}; - if (kind == "forward" || kind == "editor") return editor == "vscode"; - return true; -} } // namespace Session::Session(rpc::Server& server) : rpc_(server) { @@ -787,8 +781,10 @@ std::expected Session::language_service(const Json& p) { } const auto s = languageservice::probe(candidates); const std::string required(languageservice::kRequired); + Json command = Json::array(); + for (const auto& part : s.command) command.push_back(part.generic_string()); Json out{{"state", languageservice::state_name(s.state)}, {"program", s.program.generic_string()}, - {"source", s.source}, {"version", s.version}, {"required", required}}; + {"command", command}, {"source", s.source}, {"version", s.version}, {"required", required}}; Json actions = Json::array(); switch (s.state) { case languageservice::State::ok: @@ -827,7 +823,8 @@ std::expected Session::targets(const Json& p) { } // The command table for one editor, in the user's language: what the adapter registers at run time -// beside the declarations generated from the same table (spec/commands.json). +// beside the declarations generated from the same table (spec/commands.json), or, in Neovim, which +// declares nothing, all it offers. Each row names its subcommand, the name a command line types. std::expected Session::commands(const Json& p) const { std::string editor, locale; { @@ -843,10 +840,11 @@ std::expected Session::commands(const Json& p) const { for (const auto& g : table["groups"]) groups[g.value("id", "")] = spec::manifest_string(g.value("title", ""), locale); Json out = Json::array(); for (const auto& c : table["commands"]) { - if (!offered_in(c, editor)) continue; + if (!spec::offered_in(c, editor)) continue; Json row = c; const auto id = c.value("id", ""); row["title"] = spec::manifest_string(std::format("command.{}.title", id), locale); + row["subcommand"] = spec::subcommand_of(c); row["groupTitle"] = groups.value(c.value("group", ""), ""); out.push_back(std::move(row)); } diff --git a/src/spec.cppm b/src/spec.cppm index cd94b4e..2de63c7 100644 --- a/src/spec.cppm +++ b/src/spec.cppm @@ -30,8 +30,15 @@ const Json& presets(); // spec/presets.json's "presets" const std::map>& runtime_zh_cn(); const Json& toml_schema(); const Json& buildscript_api(); -// spec/commands.json: the command table both adapters declare (groups, commands). +// spec/commands.json: the command table the editors offer (groups, commands). const Json& commands(); +// Whether an editor (the protocol's `editor`: vscode, jetbrains, nvim, cli) offers a command: the +// row's `editors` when it has them (`omitted` says why an editor is left out), else every editor; +// a forward to mcppls's own commands or an editor's own command only where those exist (VS Code). +bool offered_in(const Json& command, std::string_view editor); +// The name a command line types for a command (`:Mcpp ` in Neovim): the row's `subcommand`, +// else its id after `mcpp.`. +std::string subcommand_of(const Json& command); // The editors' strings (command titles, setting titles, view names): spec/i18n/manifest..json. // "zh-cn" falls back to English key by key. const Json& manifest_strings(std::string_view locale); @@ -106,6 +113,20 @@ const Json& commands() { return c; } +bool offered_in(const Json& c, std::string_view editor) { + if (c.contains("editors") && c["editors"].is_array()) + return std::ranges::any_of(c["editors"], [&](const Json& e) { return e.is_string() && e.get() == editor; }); + const auto kind = c.contains("action") && c["action"].is_object() ? c["action"].value("kind", "") : std::string{}; + if (kind == "forward" || kind == "editor") return editor == "vscode"; + return true; +} + +std::string subcommand_of(const Json& c) { + if (c.contains("subcommand") && c["subcommand"].is_string()) return c["subcommand"].get(); + const auto id = c.value("id", ""); + return id.starts_with("mcpp.") ? id.substr(5) : id; +} + const Json& manifest_strings(std::string_view locale) { static const Json en = Json::parse(generated::kManifestEnJson); static const Json zh = [] { diff --git a/src/views.cppm b/src/views.cppm index 574e4a0..6459452 100644 --- a/src/views.cppm +++ b/src/views.cppm @@ -136,7 +136,8 @@ Json cache_inventory(const Json& envelope, const CacheOptions& options) { if (!e.value("complete", false)) incomplete.push_back(e); } Json kinds = Json::array(); - for (const auto& [k, v] : byKind) kinds.push_back(Json{{"kind", k}, {"entries", v.first}, {"bytes", v.second}}); + for (const auto& [k, v] : byKind) + kinds.push_back(Json{{"kind", k}, {"entries", v.first}, {"bytes", v.second}, {"bytesText", format_bytes(v.second)}}); std::sort(kinds.begin(), kinds.end(), [](const Json& a, const Json& b) { return std::tuple(-a["bytes"].get(), -a["entries"].get(), a["kind"].get()) < std::tuple(-b["bytes"].get(), -b["entries"].get(), b["kind"].get()); @@ -147,10 +148,13 @@ Json cache_inventory(const Json& envelope, const CacheOptions& options) { }); Json top = Json::array(); for (std::size_t i = 0; i < ranked.size() && i < static_cast(std::max(0, options.topN)); ++i) { - Json t{{"label", ranked[i].first}, {"entries", ranked[i].second.entries}, {"bytes", ranked[i].second.bytes}}; + Json t{{"label", ranked[i].first}, {"entries", ranked[i].second.entries}, {"bytes", ranked[i].second.bytes}, + {"bytesText", format_bytes(ranked[i].second.bytes)}}; if (ranked[i].second.oldest) t["oldestAccessed"] = *ranked[i].second.oldest; top.push_back(t); } + // Each row's size as text too, for an editor that shows the inventory as text (Neovim). + for (auto& b : buckets) b["bytesText"] = format_bytes(b["bytes"].get()); Json out{{"root", data.value("root", "")}, {"totalBytes", total}, {"totalEntries", count}, {"totalText", format_bytes(total)}, {"byKind", kinds}, {"topLabels", top}, {"incomplete", incomplete}, {"ageBuckets", buckets}}; diff --git a/tests/test_languageservice.cpp b/tests/test_languageservice.cpp index e1d6866..85d88da 100644 --- a/tests/test_languageservice.cpp +++ b/tests/test_languageservice.cpp @@ -1,5 +1,7 @@ // The mcppls handshake: the version is read from either spelling of `--version`, 0.0.18 and later -// read the result record, an older one is `outdated`, and output with no version is `unreadable`. +// read the result record, an older one is `outdated`, and output with no version is `unreadable`. A +// payload directory stands for the command its payload.json's `server` names: the runner and the okb +// (mcppls 0.0.18 and later), or a program. #include "support.hpp" import std; import mcppide.languageservice; @@ -21,5 +23,37 @@ int main() { const auto s = ls::probe({"/no/such/mcppls"}); CHECK(s.program != std::filesystem::path("/no/such/mcppls")); CHECK(ls::state_name(ls::State::outdated) == "outdated"); + + const auto payload = std::filesystem::temp_directory_path() / std::format("mcppide-payload-{}", std::random_device{}()); + std::filesystem::create_directories(payload / "bin"); + auto write = [&](const std::filesystem::path& file, std::string_view text) { std::ofstream(file, std::ios::binary) << text; }; + write(payload / "bin" / "okrun", "runner"); + write(payload / "bin" / "mcppls.okb", "okb"); + write(payload / "payload.json", R"({"platform": "linux-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); + const std::vector imaged{payload / "bin" / "okrun", payload / "bin" / "mcppls.okb"}; + CHECK(ls::payload_command(payload) == imaged); + CHECK(ls::command_of(payload) == imaged); + CHECK(ls::command_of(payload / "bin" / "okrun") == std::vector{payload / "bin" / "okrun"}); + std::filesystem::remove(payload / "bin" / "mcppls.okb"); + CHECK(ls::payload_command(payload).empty()); // what it names is not there + write(payload / "bin" / "mcppls", "program"); + write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); + CHECK(ls::payload_command(payload) == std::vector{payload / "bin" / "mcppls"}); + // What payload.json names stays inside the payload. + const std::filesystem::path outside = payload.string() + "-outside"; + write(outside, "program"); + write(payload / "payload.json", R"({"server": {"path": "bin/mcppls", "runner": "../)" + outside.filename().string() + R"("}})"); + CHECK(ls::payload_command(payload).empty()); + write(payload / "payload.json", R"({"server": {"path": ")" + (payload / "bin" / "mcppls").generic_string() + R"("}})"); + CHECK(ls::payload_command(payload).empty()); + std::filesystem::remove(outside); + write(payload / "payload.json", "not json"); + CHECK(ls::payload_command(payload).empty()); + CHECK(ls::command_of(payload / "missing").empty()); + // The adapter's payload is the candidate asked first, ahead of PATH and xlings. + write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); + const auto found = ls::candidates({payload.string()}); + CHECK(!found.empty() && found.front().source == "editor" && found.front().command.back() == payload / "bin" / "mcppls"); + std::filesystem::remove_all(payload); std::println("test_languageservice: ok"); } diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 8c8c07e..de670b6 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -126,6 +126,29 @@ int main() { CHECK(!targets["rows"].empty()); auto cmds = result_of("commands/list", Json{{"editor", "jetbrains"}}); for (const auto& c : cmds["commands"]) CHECK(c["action"]["kind"] != "forward"); + // Neovim's table: no forward to mcppls's commands (mcppls-nvim has its own), no panel it has no + // text for, its own quickfix command, and each row's subcommand (`:Mcpp `). + const Json nvimInit{{"editor", "nvim"}, {"locale", "en_US.UTF-8"}, {"trusted", true}, + {"workspaceFolders", Json::array({root.generic_string()})}, {"settings", Json::object()}}; + CHECK(conforms(nvimInit, protocol["methods"]["initialize"]["params"], protocol, "initialize params (nvim)")); + std::map nvim; + for (const auto& c : result_of("commands/list", Json{{"editor", "nvim"}})["commands"]) { + const auto kind = c["action"]["kind"].get(); + CHECK(kind != "forward" && kind != "editor"); + CHECK(!(kind == "panel" && c["action"]["panel"] == "settings")); + nvim[c["subcommand"].get()] = c["id"].get(); + } + CHECK(nvim["cancel"] == "mcpp.cancelJob" && nvim["build"] == "mcpp.build" && nvim["problems"] == "mcpp.problems"); + CHECK(nvim.contains("showMenu") && nvim.contains("library.search") && !nvim.contains("openSettings")); + for (const auto& c : result_of("commands/list", Json{{"editor", "vscode"}})["commands"]) CHECK(c["id"] != "mcpp.problems"); + // A row that leaves Neovim out says why. + for (const auto& c : load(testsupport::repo() / "spec" / "commands.json")["commands"]) { + if (!c.contains("editors") || std::ranges::find(c["editors"], Json("nvim")) != c["editors"].end()) continue; + if (c["action"]["kind"] == "editor") continue; + const auto reason = c.contains("omitted") ? c["omitted"].value("nvim", "") : std::string{}; + if (reason.empty()) std::println(std::cerr, "{} leaves nvim out without saying why", c["id"].get()); + CHECK(!reason.empty()); + } result_of("menu/quick", rooted); result_of("languageService/status", Json{{"candidates", Json::array()}}); // The pack panel's choices: the closures SPEC-014 names, and linkages only where mcpp takes them. diff --git a/tests/test_views.cpp b/tests/test_views.cpp index 2f66a9e..599a404 100644 --- a/tests/test_views.cpp +++ b/tests/test_views.cpp @@ -21,6 +21,8 @@ int main() { for (const auto& b : inv["ageBuckets"]) sum += b["entries"].get(); CHECK(sum <= inv["totalEntries"].get()); CHECK(inv["ageBuckets"].size() == 4); + for (const auto& k : inv["byKind"]) CHECK(k["bytesText"].is_string()); + for (const auto& b : inv["ageBuckets"]) CHECK(b["bytesText"].is_string()); policy::CleanOptions co; auto all = views::cache_preview(cache, *policy::plan("cacheAll", co), co); diff --git a/vscode/src/backend/mcppls.ts b/vscode/src/backend/mcppls.ts deleted file mode 100644 index ae998e9..0000000 --- a/vscode/src/backend/mcppls.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Where the mcppls VS Code extension keeps its server: `/payload/`, or MCPPLS_SERVER. The backend reads its version (languageService/status). -import * as fs from "node:fs"; -import * as path from "node:path"; - -export function mcpplsCandidates(extensionPath: string | undefined, env: NodeJS.ProcessEnv = process.env): string[] { - const out: string[] = []; - if (env.MCPPLS_SERVER) out.push(env.MCPPLS_SERVER); - if (!extensionPath) return out; - const payload = env.MCPPLS_PAYLOAD ? path.resolve(env.MCPPLS_PAYLOAD) : path.join(extensionPath, "payload"); - try { - const manifest = JSON.parse(fs.readFileSync(path.join(payload, "payload.json"), "utf8")) as { server?: { path?: string } }; - if (manifest.server?.path) out.push(path.join(payload, manifest.server.path)); - } catch { - // An installation without a payload answers nothing here; PATH and xlings are tried after. - } - return out; -} diff --git a/webui/tests/panels.test.js b/webui/tests/panels.test.js index 390ee1a..56f3eea 100644 --- a/webui/tests/panels.test.js +++ b/webui/tests/panels.test.js @@ -8,7 +8,8 @@ const path = require("node:path"); const { test, before, after } = require("node:test"); const repo = path.resolve(__dirname, "..", ".."); -const vscodeDir = path.join(repo, "vscode"); +// 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"));