From 5a99736e7e01895af96cc05560b5b0a74f22097e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 10:48:39 +0900 Subject: [PATCH 01/31] feat(languageservice): read mcppls payloads by mcppls S6, versions 4 and 5 A version 5 payload (mcppls 0.0.20) names each platform's runner under platforms..runner; 0.2.1 took server.runner only and ran bin/mcppls.okb itself. The backend now takes this system's entry, the one platform of a version 4 payload, and refuses a payload that does not serve this system, naming the platforms it does serve; with nothing else found the refusal is the status's detail. The mapping from this system to mcppls's platform key is the backend's alone. CLion makes every runner a payload names executable again. The tests read the published 0.0.19 and 0.0.20 manifests. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../mcppcommunity/mcppclion/backend/Locate.kt | 27 +-- .../mcppcommunity/mcppclion/BackendTest.kt | 8 + src/languageservice.cppm | 100 +++++++-- tests/payloads/mcppls-0.0.19-linux-x64.json | 71 +++++++ tests/payloads/mcppls-0.0.20.json | 197 ++++++++++++++++++ tests/test_languageservice.cpp | 57 +++++ 6 files changed, 433 insertions(+), 27 deletions(-) create mode 100644 tests/payloads/mcppls-0.0.19-linux-x64.json create mode 100644 tests/payloads/mcppls-0.0.20.json diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt index 8d9b5a0..267ca63 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Locate.kt @@ -46,20 +46,23 @@ object Locate { } /** - * A payload of mcppls (`payload.json` beside `bin/`), with the program its `server` starts made - * executable again: the runner, else a program server. A plugin's install may not keep the bit; - * mcppls-clion puts it back when it starts its server, and the backend may run that program - * before, to read mcppls's version. Only a relative path with no `..` part is followed. + * A payload of mcppls (`payload.json` beside `bin/`), with the programs that start its server made + * executable again (mcppls specification S6): every platform's runner of a version 5 payload, the + * runner of a version 4 one, else a program server. A plugin's install may not keep the bit; + * mcppls-clion puts it back when it starts its server, and the backend, which chooses this system's + * entry, may run that program before, to read mcppls's version. Only a relative path with no `..` + * part is followed. */ fun runnablePayload(payload: File): File { - val server = runCatching { - JsonParser.parseString(File(payload, "payload.json").readText()).asJsonObject.getAsJsonObject("server") - }.getOrNull() - val named = listOf("runner", "path").firstNotNullOfOrNull { key -> - server?.get(key)?.takeIf { it.isJsonPrimitive }?.asString - } - named?.takeIf { !File(it).isAbsolute && it.split('/', '\\').none { part -> part == ".." } } - ?.let { File(payload, it) }?.takeIf { it.isFile }?.let(::runnable) + val manifest = runCatching { JsonParser.parseString(File(payload, "payload.json").readText()).asJsonObject }.getOrNull() + val server = manifest?.get("server")?.takeIf { it.isJsonObject }?.asJsonObject + fun named(obj: com.google.gson.JsonObject?, key: String): String? = + obj?.get(key)?.takeIf { it.isJsonPrimitive }?.asString + val runners = manifest?.get("platforms")?.takeIf { it.isJsonObject }?.asJsonObject?.entrySet() + ?.mapNotNull { (_, entry) -> entry.takeIf { it.isJsonObject }?.asJsonObject?.let { named(it, "runner") } } + ?: listOfNotNull(named(server, "runner") ?: named(server, "path")) + runners.filter { !File(it).isAbsolute && it.split('/', '\\').none { part -> part == ".." } } + .map { File(payload, it) }.filter { it.isFile }.forEach { runnable(it) } return payload } diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt index c3ccd60..e8e718f 100644 --- a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/BackendTest.kt @@ -66,6 +66,14 @@ class BackendTest { Locate.runnablePayload(payload) assertTrue("outside the payload", !outside.canExecute()) outside.delete() + // Version 5 (mcppls 0.0.20 on): every platform's runner, whichever this system's entry is. + val linux = File(payload, "libexec/okrun/linux-x86_64/okrun").apply { parentFile.mkdirs(); writeText(""); setExecutable(false) } + val mac = File(payload, "libexec/okrun/macos-aarch64/okrun").apply { parentFile.mkdirs(); writeText(""); setExecutable(false) } + File(payload, "payload.json").writeText("""{"payload-version": 5, "server": {"path": "bin/mcppls.okb"}, "platforms": { + "linux-x64": {"runner": "libexec/okrun/linux-x86_64/okrun"}, "darwin-arm64": {"runner": "libexec/okrun/macos-aarch64/okrun"}}}""") + Locate.runnablePayload(payload) + assertTrue("linux's runner", linux.canExecute()) + assertTrue("macOS's runner", mac.canExecute()) payload.deleteRecursively() } diff --git a/src/languageservice.cppm b/src/languageservice.cppm index 633cbeb..50e77c2 100644 --- a/src/languageservice.cppm +++ b/src/languageservice.cppm @@ -15,10 +15,13 @@ // those releases offered (the frozen table) and the workspace is not known. // // A SERVER IS A COMMAND. mcppls's payload (the VSIX's and the CLion package's `payload/`, the one Zed's -// install puts in the user data directory) carries the server as one okb of both architectures that the -// platform's runner starts: payload.json's `server` names `runner` and `path` (`bin/okrun bin/mcppls.okb`), -// or a program alone. A candidate is a payload directory or a program, and what is asked for its version -// is the command it stands for. +// install puts in the user data directory) carries the server as one okb of both architectures that a +// runner starts. payload.json says which, as mcppls's specification S6 states it: payload version 5 (mcppls +// 0.0.20 and later) names the runner of each platform under `platforms..runner`, and the command is +// that runner and `server.path`; version 4 (0.0.18, 0.0.19) serves the one `platform` it names, with +// `server.runner` and `server.path` (`bin/okrun bin/mcppls.okb`); earlier ones name a program alone. A payload +// that does not serve this system is refused, naming the platforms it does serve. A candidate is a payload +// directory or a program, and what is asked for its version is the command it stands for. export module mcppide.languageservice; import std; @@ -51,13 +54,26 @@ struct Status { struct Candidate { std::vector command; std::string source; + std::string problem; // a payload refused: why (S6 section 4); the command is then empty }; +// mcppls's name for this system (mcppls S6 section 1): the system and the architecture as VS Code names +// extension targets -- linux-x64, linux-arm64, darwin-arm64, darwin-x64, win32-x64, win32-arm64. The one +// place the editors' adapters take it from. +std::string host_platform(); + +// The server of a payload directory for `platform` (S6 section 4): the command, or why there is none. +struct PayloadServer { + std::vector command; + std::string problem; +}; +PayloadServer payload_server(const std::filesystem::path& payload, std::string_view platform = host_platform()); + // The version in `mcppls --version`'s output: the first word that starts with a digit. std::string version_in(std::string_view output); -// The command payload.json's `server` names in a payload directory: the runner and the image, or the -// program. Empty when the directory holds no payload or what it names is not there. +// The command a payload directory's payload.json names for this system: the runner and the image, or the +// program. Empty when the directory holds no payload, serves another system, or what it names is not there. std::vector payload_command(const std::filesystem::path& payload); // The command a path the adapter named stands for: a payload directory's server, or the program itself. @@ -136,7 +152,17 @@ std::filesystem::path within(const std::filesystem::path& payload, const std::st return payload / relative; } -std::vector payload_command(const std::filesystem::path& payload) { +std::string host_platform() { + const bool arm = mcpplibs::os::ARCH == mcpplibs::os::architecture::aarch64; + switch (mcpplibs::os::host()) { + case mcpplibs::os::platform::windows: return arm ? "win32-arm64" : "win32-x64"; + case mcpplibs::os::platform::macos: return arm ? "darwin-arm64" : "darwin-x64"; + default: break; + } + return arm ? "linux-arm64" : "linux-x64"; +} + +PayloadServer payload_server(const std::filesystem::path& payload, std::string_view platform) { std::error_code ec; std::ifstream in(payload / "payload.json", std::ios::binary); if (!in) return {}; @@ -144,12 +170,39 @@ std::vector payload_command(const std::filesystem::path& if (!manifest.is_object() || !manifest.contains("server") || !manifest["server"].is_object()) return {}; const Json& server = manifest["server"]; if (!server.contains("path") || !server["path"].is_string()) return {}; + const int version = manifest.contains("payload-version") && manifest["payload-version"].is_number_integer() + ? manifest["payload-version"].get() : 0; + const auto refused = [&](std::string why) { return PayloadServer{{}, std::format("the mcppls payload {}: {}", payload.string(), why)}; }; + std::optional runner; + if (version >= 6) return refused(std::format("payload-version {} is not one this mcpp-ide reads (4, 5)", version)); + if (version == 5) { + // One payload for every platform: this system's entry names its runner. + if (!manifest.contains("platforms") || !manifest["platforms"].is_object()) return refused("version 5 without platforms"); + const Json& platforms = manifest["platforms"]; + if (!platforms.contains(std::string(platform)) || !platforms[std::string(platform)].is_object()) { + std::string served; + for (const auto& item : platforms.items()) served += (served.empty() ? "" : ", ") + item.key(); + return refused(std::format("it serves {}, and this system is {}", served.empty() ? std::string("no platform") : served, platform)); + } + const Json& entry = platforms[std::string(platform)]; + if (entry.contains("runner") && entry["runner"].is_string()) runner = entry["runner"].get(); + } else { + // Version 4 serves the one platform it names; earlier ones name none. + if (version == 4 && manifest.contains("platform") && manifest["platform"].is_string() && + manifest["platform"].get() != platform) + return refused(std::format("it serves {}, and this system is {}", manifest["platform"].get(), platform)); + if (server.contains("runner") && server["runner"].is_string()) runner = server["runner"].get(); + } std::vector command; - if (server.contains("runner") && server["runner"].is_string()) command.push_back(within(payload, server["runner"].get())); + if (runner) command.push_back(within(payload, *runner)); command.push_back(within(payload, server["path"].get())); for (const auto& part : command) if (part.empty() || !std::filesystem::is_regular_file(part, ec)) return {}; - return command; + return {command, {}}; +} + +std::vector payload_command(const std::filesystem::path& payload) { + return payload_server(payload).command; } std::vector command_of(const std::filesystem::path& named) { @@ -159,9 +212,19 @@ std::vector command_of(const std::filesystem::path& named return {}; } +// A candidate the adapter or the user data directory names: a payload's server, or a program. +Candidate candidate_of(const std::filesystem::path& named, std::string source) { + std::error_code ec; + if (std::filesystem::is_directory(named, ec)) { + auto served = payload_server(named); + return {std::move(served.command), std::move(source), std::move(served.problem)}; + } + return {command_of(named), std::move(source), {}}; +} + std::vector candidates(const std::vector& fromEditor) { std::vector out; - for (const auto& p : fromEditor) if (!p.empty()) out.push_back({command_of(p), "editor"}); + for (const auto& p : fromEditor) if (!p.empty()) out.push_back(candidate_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")) @@ -171,8 +234,9 @@ std::vector candidates(const std::vector& fromEditor) { out.push_back({command_of(*home / ".xlings" / "subos" / "default" / "bin" / exe), "xlings"}); } // The payload Zed's install puts at `/mcppls/payload`. - if (auto data = mcpplibs::os::dirs::data()) out.push_back({payload_command(*data / "mcppls" / "payload"), "payload"}); - std::erase_if(out, [](const Candidate& c) { return c.command.empty(); }); + if (auto data = mcpplibs::os::dirs::data()) out.push_back(candidate_of(*data / "mcppls" / "payload", "payload")); + // A refused payload stays in the list, so that a search that finds nothing else can say why. + std::erase_if(out, [](const Candidate& c) { return c.command.empty() && c.problem.empty(); }); return out; } @@ -192,9 +256,15 @@ Status classify(const std::filesystem::path& program, std::string source, std::s // The first candidate is the one asked: the one the adapter named, else the first found. Status probe(const std::vector& fromEditor) { - const auto found = candidates(fromEditor); - if (found.empty()) return Status{}; - const auto& [command, source] = found.front(); + const auto all = candidates(fromEditor); + std::vector found; + for (const auto& c : all) if (!c.command.empty()) found.push_back(c); + if (found.empty()) { + Status none; + for (const auto& c : all) if (!c.problem.empty()) { none.detail = c.problem; none.source = c.source; break; } + return none; + } + const auto& [command, source, problem] = found.front(); mcpplibs::os::process::spawn_options o; o.program = command.front(); for (std::size_t i = 1; i < command.size(); ++i) o.args.push_back(command[i].string()); diff --git a/tests/payloads/mcppls-0.0.19-linux-x64.json b/tests/payloads/mcppls-0.0.19-linux-x64.json new file mode 100644 index 0000000..9134a16 --- /dev/null +++ b/tests/payloads/mcppls-0.0.19-linux-x64.json @@ -0,0 +1,71 @@ +{ + "build": { + "builtAt": "2026-10-29T07:11:42Z", + "commit": "ad6e09d4f3b70f63987ee728319a1f622dab36da", + "dirty": true, + "source": "from-source" + }, + "clangd": { + "path": "clangd/bin/clangd", + "version": "23.1.0-mcppls.5" + }, + "engines": { + "clangd": { + "identity": { + "build-binary-sha256": "43ba5a35228861eaa92bfff12bf4d7ae216e990cec6506eb2a87c57157e0dba2", + "cmake-cache-sha256": "f4eb50aa3f158501180c92eba3c286b6369a3d1f0e7e42c76f40025f6d0837ee", + "engine-version": "23.1.0-mcppls.5", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions" + ], + "fork-commit": "67228c81b69ff3d60472c60346b13e9227c4e498", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "c7dfd88e157b622412bfa2315f9a135f71329029", + "patch-series-sha256": "6cfe7b1ecd8f52bc65c70fd83e9f705ba8d446b1ebd63dcf9bfbed990b135ae9", + "platform": "linux-x64", + "sha256": "42f5534b9c252b3f4efccaedae526bb9683a692ccf9e8578a37e0224b3be40b4" + }, + "kit": { + "name": "mcppls-kit-libcxx-23.1.0-x86_64-unknown-linux-gnu", + "path": "kit" + }, + "path": "clangd/bin/clangd", + "version": "23.1.0-mcppls.5" + } + }, + "files": { + "clangd/bin/clangd": { + "sha256": "42f5534b9c252b3f4efccaedae526bb9683a692ccf9e8578a37e0224b3be40b4", + "size": 55306936 + }, + "kit/kit.json": { + "sha256": "74c2de97fd10e7927f81c0cc324751ce6b67e36938d72bfcb543b15ade2677b4", + "size": 575 + } + }, + "kit": { + "name": "mcppls-kit-libcxx-23.1.0-x86_64-unknown-linux-gnu", + "path": "kit" + }, + "payload-version": 4, + "platform": "linux-x64", + "server": { + "path": "bin/mcppls.okb", + "runner": "bin/okrun", + "version": "0.0.19" + } +} diff --git a/tests/payloads/mcppls-0.0.20.json b/tests/payloads/mcppls-0.0.20.json new file mode 100644 index 0000000..62fe189 --- /dev/null +++ b/tests/payloads/mcppls-0.0.20.json @@ -0,0 +1,197 @@ +{ + "build": { + "builtAt": "2026-11-06T13:20:33Z", + "commit": "8923698cde330586bca99534dedea012d161dcf7", + "dirty": true, + "source": "from-source" + }, + "files": { + "kit/kit.json": { + "sha256": "39e6c240ad25179152f7c922f8077acf7e22b46707d4230668ef198624d1b479", + "size": 3885 + } + }, + "kit": { + "name": "mcppls-kit-openkal-0.24.0", + "path": "kit" + }, + "payload-version": 5, + "platforms": { + "darwin-arm64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/macos-aarch64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/macos-aarch64/okrun" + }, + "linux-arm64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/linux-aarch64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/linux-aarch64/okrun" + }, + "linux-x64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/linux-x86_64/okrun", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/linux-x86_64/okrun" + }, + "win32-x64": { + "engines": { + "clangd": { + "identity": { + "engine-version": "23.1.0-mcppls.9", + "features": [ + "semantic-tokens-range", + "format-style-mcpp", + "module-directive-diagnostic-ranges", + "module-directive-recovery", + "const-correctness-views", + "unresolved-import-recovery", + "partial-prerequisite-modules", + "module-rebuild-reasons", + "bounded-module-wait", + "managed-input-roots", + "persistent-module-memos", + "retained-asts-option", + "module-input-recheck", + "interactive-actions", + "owned-module-cache" + ], + "fork-commit": "fa43e9343b35dafa167b097919f515230bce2265", + "llvm-base-version": "23.1.0", + "llvm-commit": "ea7d852a70e8bdfaf601d6626a760f9771b2c4b4", + "llvm-tree-commit": "7ea4299986aedf696b9eec63e0f149bb0aec8167", + "patch-series-sha256": "d051d9cfc408e69eed8302180ded39a51e49f4531af96e329fcf2e27203cb043", + "platform": "openkal", + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e" + }, + "path": "clangd/openkal/bin/clangd.okb", + "runner": "libexec/okrun/windows-x86_64/okrun.exe", + "version": "23.1.0-mcppls.9" + } + }, + "files": { + "clangd/openkal/bin/clangd.okb": { + "sha256": "9044251bc7ea2ca01a38bdd233c7fe1384e7418f388e47847d8774cb6af4f09e", + "size": 152766523 + } + }, + "runner": "libexec/okrun/windows-x86_64/okrun.exe" + } + }, + "server": { + "path": "bin/mcppls.okb", + "version": "0.0.20" + } +} diff --git a/tests/test_languageservice.cpp b/tests/test_languageservice.cpp index 85d88da..6967e21 100644 --- a/tests/test_languageservice.cpp +++ b/tests/test_languageservice.cpp @@ -50,6 +50,63 @@ int main() { write(payload / "payload.json", "not json"); CHECK(ls::payload_command(payload).empty()); CHECK(ls::command_of(payload / "missing").empty()); + // mcppls S6: version 5 names each platform's runner; version 4 serves the one platform it names. A + // payload that does not serve this system is refused, naming the platforms it does serve. + const auto host = ls::host_platform(); + CHECK(host == "linux-x64" || host == "linux-arm64" || host == "darwin-arm64" || host == "darwin-x64" || + host == "win32-x64" || host == "win32-arm64"); + std::filesystem::create_directories(payload / "libexec" / "okrun" / "this"); + write(payload / "libexec" / "okrun" / "this" / "okrun", "runner"); + write(payload / "bin" / "mcppls.okb", "okb"); + write(payload / "payload.json", std::format(R"({{"payload-version": 5, "server": {{"path": "bin/mcppls.okb", "version": "0.0.20"}}, + "kit": {{"name": "k", "path": "kit"}}, "files": {{}}, + "platforms": {{"{}": {{"runner": "libexec/okrun/this/okrun", "engines": {{}}, "files": {{}}}}, + "haiku-x64": {{"runner": "libexec/okrun/haiku/okrun", "engines": {{}}, "files": {{}}}}}}}})", host)); + const std::vector fromEntry{payload / "libexec" / "okrun" / "this" / "okrun", payload / "bin" / "mcppls.okb"}; + CHECK(ls::payload_command(payload) == fromEntry); + const auto other = ls::payload_server(payload, "plan9-x64"); + CHECK(other.command.empty() && other.problem.contains("serves") && other.problem.contains("haiku-x64") && other.problem.contains("plan9-x64")); + write(payload / "payload.json", R"({"payload-version": 4, "platform": "plan9-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); + write(payload / "bin" / "okrun", "runner"); + CHECK(ls::payload_command(payload).empty()); + CHECK(ls::payload_server(payload).problem.contains("plan9-x64")); + write(payload / "payload.json", std::format(R"({{"payload-version": 4, "platform": "{}", "server": {{"path": "bin/mcppls.okb", "runner": "bin/okrun"}}}})", host)); + CHECK(ls::payload_command(payload) == imaged); + write(payload / "payload.json", R"({"payload-version": 6, "server": {"path": "bin/mcppls.okb"}})"); + CHECK(ls::payload_server(payload).problem.contains("payload-version 6")); + // The manifests of the published payloads (tests/payloads: mcppls 0.0.19's for linux-x64, version 4, + // and 0.0.20's, version 5, as mcppls's S6 examples hold them, their file lists cut short). + { + const auto published = [&](std::string_view name) { + std::ifstream in(testsupport::repo() / "tests" / "payloads" / std::string(name), std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); + }; + const auto served = std::filesystem::temp_directory_path() / std::format("mcppide-published-{}", std::random_device{}()); + std::filesystem::create_directories(served / "bin"); + for (const auto* system : {"linux-x86_64", "linux-aarch64", "macos-aarch64", "windows-x86_64"}) { + std::filesystem::create_directories(served / "libexec" / "okrun" / system); + write(served / "libexec" / "okrun" / system / (std::string(system).starts_with("windows") ? "okrun.exe" : "okrun"), "runner"); + } + write(served / "bin" / "mcppls.okb", "okb"); + write(served / "bin" / "okrun", "runner"); + write(served / "payload.json", published("mcppls-0.0.20.json")); + const std::pair runners[] = { + {"linux-x64", "linux-x86_64/okrun"}, {"linux-arm64", "linux-aarch64/okrun"}, + {"darwin-arm64", "macos-aarch64/okrun"}, {"win32-x64", "windows-x86_64/okrun.exe"}}; + for (const auto& [platform, runner] : runners) { + const auto s = ls::payload_server(served, platform); + CHECK(s.command == std::vector({served / "libexec" / "okrun" / std::filesystem::path(runner), served / "bin" / "mcppls.okb"})); + } + CHECK(ls::payload_server(served, "win32-arm64").problem.contains("darwin-arm64, linux-arm64, linux-x64, win32-x64")); + write(served / "payload.json", published("mcppls-0.0.19-linux-x64.json")); + CHECK(ls::payload_server(served, "linux-x64").command == std::vector({served / "bin" / "okrun", served / "bin" / "mcppls.okb"})); + CHECK(ls::payload_server(served, "darwin-arm64").problem.contains("serves linux-x64")); + std::filesystem::remove_all(served); + } + // A refused payload named by the adapter is said, when nothing else is found. + write(payload / "payload.json", R"({"payload-version": 5, "server": {"path": "bin/mcppls.okb"}, "platforms": {"plan9-x64": {"runner": "bin/okrun"}}})"); + const auto refused = ls::candidates({payload.string()}); + CHECK(!refused.empty() && refused.front().command.empty() && refused.front().problem.contains("plan9-x64")); // The adapter's payload is the candidate asked first, ahead of PATH and xlings. write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); const auto found = ls::candidates({payload.string()}); From 9a813fad1c989b9e856971a3a067f27b3618713a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 10:48:39 +0900 Subject: [PATCH 02/31] feat: one series for the backend and every plugin, 1.2 A version is X.Y.Z: X.Y is the series the backend and the VS Code, CLion and Neovim plugins share, Z each component's own patch (design 2026-11-07, D15). Every component is 1.2.0. The backend states its series in the initialize answer; each plugin checks protocol 2 and its own series, and refuses another, naming both versions and the install that matches. VS Code did not check the protocol before. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mcpp.toml | 2 +- mide-plugins/clion/gradle.properties | 2 +- .../mcppcommunity/mcppclion/McppSession.kt | 9 ++++- .../mcppclion/backend/Protocol.kt | 19 +++++++++-- .../mcppcommunity/mcppclion/ProtocolTest.kt | 32 +++++++++++++----- mide-plugins/nvim/lua/mcpp/session.lua | 33 +++++++++++++++++-- mide-plugins/nvim/tests/smoke.lua | 11 +++++++ mide-plugins/vscode/data/i18n/zh-cn.json | 4 ++- .../vscode/l10n/bundle.l10n.zh-cn.json | 4 ++- mide-plugins/vscode/package-lock.json | 4 +-- mide-plugins/vscode/package.json | 2 +- mide-plugins/vscode/src/ide/compat.ts | 31 +++++++++++++++++ mide-plugins/vscode/src/ide/session.ts | 18 ++++++++++ mide-plugins/vscode/test/ide/compat.test.ts | 28 ++++++++++++++++ src/server.cppm | 8 ++++- 15 files changed, 185 insertions(+), 22 deletions(-) create mode 100644 mide-plugins/vscode/src/ide/compat.ts create mode 100644 mide-plugins/vscode/test/ide/compat.test.ts diff --git a/mcpp.toml b/mcpp.toml index a305707..d49ee24 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -9,7 +9,7 @@ # M2/M3), where the system it runs upon is answered when the image is loaded. [package] name = "mcpp-ide" -version = "0.2.1" +version = "1.2.0" description = "The IDE backend for mcpp: sessions, jobs, records and view models over mcpp's control contract" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/mide-plugins/clion/gradle.properties b/mide-plugins/clion/gradle.properties index 1255026..fc9d16f 100644 --- a/mide-plugins/clion/gradle.properties +++ b/mide-plugins/clion/gradle.properties @@ -2,7 +2,7 @@ # where it installs (2026.1 and later); the verifier checks both ends. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.2.1 +pluginVersion = 1.2.0 # The mcppls CLion plugin this one depends on (io.github.sunrisepeak.mcppls): # this machine's package of the release, fetched for the sandbox, the tests and the verifier. mcpplsPluginVersion = 0.0.19 diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 1817d37..2036b39 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -131,6 +131,13 @@ class McppSession(private val project: Project) : Disposable { com.intellij.ide.plugins.PluginManagerCore.isLoaded(id) }.getOrDefault(false) + /** This plugin's version, as its descriptor states it: the series the backend must share (D15). */ + private fun pluginVersion(): String = runCatching { + (McppSession::class.java.classLoader as? com.intellij.ide.plugins.cl.PluginAwareClassLoader)?.pluginDescriptor?.version + }.getOrNull() ?: runCatching { + com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId(PLUGIN_ID))?.version + }.getOrNull() ?: "unknown" + /** The plugin's directory: the parent of the lib/ holding this class's jar. */ private fun pluginRoot(): File? = pluginDirectory() @@ -191,7 +198,7 @@ class McppSession(private val project: Project) : Disposable { var protocol = "unknown" b.request("initialize", params).thenCompose { init -> protocol = init.asJsonObject["protocol"]?.toString() ?: "missing" - requireProtocol(init.asJsonObject) + requireProtocol(init.asJsonObject, pluginVersion()) log.info("mcpp-ide: initialized (source=${located.source}, protocol=$protocol)") initialized = init.asJsonObject b.notify("initialized", JsonObject()) diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt index e26ec49..a175aca 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/backend/Protocol.kt @@ -3,8 +3,16 @@ package io.github.mcppcommunity.mcppclion.backend import com.google.gson.JsonObject import java.math.BigDecimal -/** Check the handshake before issuing any namespaced extension requests. */ -fun requireProtocol(initialized: JsonObject) { +/** The series of a version `X.Y.Z`: `X.Y`, shared by the backend and every plugin (mcpp design 2026-11-07, D15). */ +fun seriesOf(version: String?): String? = + version?.let { Regex("""^(\d+)\.(\d+)(\.\d+)?""").find(it.trim()) }?.let { "${it.groupValues[1]}.${it.groupValues[2]}" } + +/** + * Check the handshake before issuing any namespaced extension requests: protocol 2, and a backend of + * this plugin's series. A backend before 1.2 states no series; its version's `X.Y` is taken, and it is + * another series. + */ +fun requireProtocol(initialized: JsonObject, pluginVersion: String) { val value = initialized["protocol"] val compatible = runCatching { value != null && value.isJsonPrimitive && value.asJsonPrimitive.isNumber && @@ -13,4 +21,11 @@ fun requireProtocol(initialized: JsonObject) { check(compatible) { "The selected mcpp-ide backend does not support protocol 2. Update the backend or clear mcpp.ide.backendPath to use the bundled backend." } + val backendVersion = runCatching { initialized.getAsJsonObject("serverInfo")["version"].asString }.getOrNull() ?: "unknown" + val backendSeries = runCatching { initialized["series"].asString }.getOrNull() ?: seriesOf(backendVersion) + val own = seriesOf(pluginVersion) + check(own != null && backendSeries == own) { + "The mcpp-ide backend $backendVersion is of series ${backendSeries ?: "unknown"}, and mcpp-clion $pluginVersion of series $own. " + + "Install the mcpp-ide backend $own (xlings install mcpp-ide@$own.0), or clear mcpp.ide.backendPath to use the bundled backend." + } } diff --git a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt index 95d39c3..18a8951 100644 --- a/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt +++ b/mide-plugins/clion/src/test/kotlin/io/github/mcppcommunity/mcppclion/ProtocolTest.kt @@ -1,22 +1,38 @@ package io.github.mcppcommunity.mcppclion import com.google.gson.JsonObject +import com.google.gson.JsonParser +import io.github.mcppcommunity.mcppclion.backend.requireProtocol +import io.github.mcppcommunity.mcppclion.backend.seriesOf +import org.junit.Assert.assertEquals import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue import org.junit.Test class ProtocolTest { + private fun answer(protocol: String, version: String = "1.2.0", series: String? = "1.2") = + JsonParser.parseString("""{"protocol":$protocol,"serverInfo":{"name":"mcpp-ide","version":"$version"}${series?.let { ",\"series\":\"$it\"" } ?: ""}}""").asJsonObject + @Test fun theHandshakeRejectsIncompatibleProtocolsBeforeCustomRequests() { for (protocol in listOf("1", "3", "null", "\"2\"", "{}", "[]", "true", "2.5")) { - val initialized = com.google.gson.JsonParser.parseString("""{"protocol":$protocol}""").asJsonObject - assertThrows(IllegalStateException::class.java) { - io.github.mcppcommunity.mcppclion.backend.requireProtocol(initialized) - } - } - assertThrows(IllegalStateException::class.java) { - io.github.mcppcommunity.mcppclion.backend.requireProtocol(JsonObject()) + assertThrows(IllegalStateException::class.java) { requireProtocol(answer(protocol), "1.2.0") } } - io.github.mcppcommunity.mcppclion.backend.requireProtocol(JsonObject().apply { addProperty("protocol", 2) }) + assertThrows(IllegalStateException::class.java) { requireProtocol(JsonObject(), "1.2.0") } + requireProtocol(answer("2"), "1.2.0") } + // D15: one series, any patch of either side. + @Test + fun theHandshakeAcceptsOneSeriesAndRefusesAnother() { + requireProtocol(answer("2", "1.2.0"), "1.2.3") + requireProtocol(answer("2", "1.2.4"), "1.2.0") + requireProtocol(answer("2", "1.2.1", series = null), "1.2.0") + val older = assertThrows(IllegalStateException::class.java) { requireProtocol(answer("2", "0.2.1", series = null), "1.2.0") } + assertTrue(older.message!!.contains("0.2.1") && older.message!!.contains("series 0.2") && older.message!!.contains("mcpp-ide@1.2.0")) + assertThrows(IllegalStateException::class.java) { requireProtocol(answer("2", "1.3.0", "1.3"), "1.2.0") } + assertEquals("1.2", seriesOf("1.2.0")) + assertEquals("1.2", seriesOf("1.2")) + assertEquals(null, seriesOf("unknown")) + } } diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index 0212255..4edc941 100644 --- a/mide-plugins/nvim/lua/mcpp/session.lua +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -13,7 +13,7 @@ local uv = vim.uv or vim.loop local is_windows = vim.fn.has('win32') == 1 -- mcpp-nvim's version: mcpp-ide's, whose release carries it. -M.version = '0.2.1' +M.version = '1.2.0' -- Neovim's version, as its release names it. function M.nvim_version() @@ -428,10 +428,37 @@ function M.attach(buf) vim.lsp.buf_attach_client(buf, client.id) end +-- `X.Y` of a version `X.Y` or `X.Y.Z`: the series, shared by the backend and every editor plugin +-- (mcpp design 2026-11-07, D15); nil for anything else. +function M.series_of(version) + if type(version) ~= 'string' then + return nil + end + local x, y = version:match('^%s*(%d+)%.(%d+)') + return x and (x .. '.' .. y) or nil +end + +-- Why the backend that answered `info` does not work with this plugin, or nil: protocol 2, and this +-- plugin's series (a backend before 1.2 states none; its version's `X.Y` is taken). +function M.incompatibility(info, own) + if info.protocol ~= 2 then + return 'the mcpp-ide backend does not speak protocol 2; update it, or point mcpp.ide.backendPath at one that does' + end + local backend = type(info.serverInfo) == 'table' and info.serverInfo.version or 'unknown' + local series = type(info.series) == 'string' and info.series or M.series_of(backend) or 'unknown' + local mine = M.series_of(own) or 'unknown' + if series == mine and mine ~= 'unknown' then + return nil + end + return string.format('the mcpp-ide backend %s is of series %s, and mcpp-nvim %s of series %s; install the mcpp-ide ' + .. 'backend %s (`xlings install mcpp-ide@%s.0`)', backend, series, own, mine, mine, mine) +end + -- Runs once the client is initialized: `info` is the backend's initialize answer. local function initialize(info) - if not info then - vim.notify('mcpp-ide: initialize failed', vim.log.levels.ERROR) + local refused = info and M.incompatibility(info, M.version) + if not info or refused then + vim.notify(refused and ('mcpp: ' .. refused) or 'mcpp-ide: initialize failed', vim.log.levels.ERROR) local client = state.client state.client = nil if client then diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index 7fbf43e..37cf4a3 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -103,6 +103,17 @@ local function first(items) return items[1] end vim.opt.runtimepath:prepend(plugin) local mcppide = require('mcpp') local session = require('mcpp.session') + +-- ── the series (mcpp design 2026-11-07, D15) ─────────────────────────────────────────────────── +check('one series works together, whatever the patches', + session.incompatibility({ protocol = 2, series = '1.2', serverInfo = { version = '1.2.4' } }, '1.2.0') == nil + and session.incompatibility({ protocol = 2, serverInfo = { version = '1.2.1' } }, '1.2.3') == nil) +local older = session.incompatibility({ protocol = 2, serverInfo = { version = '0.2.1' } }, '1.2.0') +check('another series is refused, naming both versions and what installs this one', + older ~= nil and older:find('0.2.1', 1, true) ~= nil and older:find('series 0.2', 1, true) ~= nil + and older:find('mcpp-ide@1.2.0', 1, true) ~= nil, older) +check('another protocol is refused', session.incompatibility({ protocol = 1, series = '1.2' }, '1.2.0') ~= nil) +check('the plugin is of the backend series this repository builds', session.series_of(session.version) == '1.2') local diagnostics = require('mcpp.diagnostics') local views = require('mcpp.views') diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json index 6dca853..cf763b1 100644 --- a/mide-plugins/vscode/data/i18n/zh-cn.json +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -659,5 +659,7 @@ "Build settings for the targets a selector matches": "选择器匹配的目标的构建设置", "Dependencies for the targets a selector matches": "选择器匹配的目标的依赖", "Dependencies of one index namespace": "一个索引命名空间的依赖", - "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。" + "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。", + "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。" } diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json index 6dca853..cf763b1 100644 --- a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -659,5 +659,7 @@ "Build settings for the targets a selector matches": "选择器匹配的目标的构建设置", "Dependencies for the targets a selector matches": "选择器匹配的目标的依赖", "Dependencies of one index namespace": "一个索引命名空间的依赖", - "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。" + "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。", + "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。" } diff --git a/mide-plugins/vscode/package-lock.json b/mide-plugins/vscode/package-lock.json index 24a086d..966eaee 100644 --- a/mide-plugins/vscode/package-lock.json +++ b/mide-plugins/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "1.1.1", + "version": "1.2.0", "license": "Apache-2.0", "devDependencies": { "@mermaid-js/tiny": "12.2.0", diff --git a/mide-plugins/vscode/package.json b/mide-plugins/vscode/package.json index ebeb948..d9a4bd6 100644 --- a/mide-plugins/vscode/package.json +++ b/mide-plugins/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "%displayName%", "description": "%description%", - "version": "1.1.1", + "version": "1.2.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "media/logo.png", diff --git a/mide-plugins/vscode/src/ide/compat.ts b/mide-plugins/vscode/src/ide/compat.ts new file mode 100644 index 0000000..167a023 --- /dev/null +++ b/mide-plugins/vscode/src/ide/compat.ts @@ -0,0 +1,31 @@ +// WHETHER THIS EXTENSION AND A BACKEND WORK TOGETHER (mcpp design 2026-11-07, D15). A version is +// `X.Y.Z`: `X.Y` is the series, shared by the backend and every editor plugin, and `Z` each +// component's own patch. The handshake asks protocol 2 and this extension's series; a backend before +// 1.2 states no series, and its version's `X.Y` is taken. + +export interface Handshake { + protocol?: unknown; + series?: unknown; + serverInfo?: { version?: unknown }; +} + +export type Incompatibility = + | { kind: "protocol"; protocol: unknown } + | { kind: "series"; backendVersion: string; backendSeries: string; ownVersion: string; ownSeries: string }; + +/** `X.Y` of a version `X.Y` or `X.Y.Z`; undefined for anything else. */ +export function seriesOf(version: unknown): string | undefined { + if (typeof version !== "string") return undefined; + const m = /^(\d+)\.(\d+)(\.\d+)?/.exec(version.trim()); + return m ? `${m[1]}.${m[2]}` : undefined; +} + +/** Why the backend that answered `init` does not work with this extension at `ownVersion`, or undefined. */ +export function incompatibility(init: Handshake, ownVersion: string): Incompatibility | undefined { + if (init.protocol !== 2) return { kind: "protocol", protocol: init.protocol }; + const backendVersion = typeof init.serverInfo?.version === "string" ? init.serverInfo.version : "unknown"; + const backendSeries = (typeof init.series === "string" ? init.series : undefined) ?? seriesOf(backendVersion) ?? "unknown"; + const ownSeries = seriesOf(ownVersion) ?? "unknown"; + if (backendSeries === ownSeries && ownSeries !== "unknown") return undefined; + return { kind: "series", backendVersion, backendSeries, ownVersion, ownSeries }; +} diff --git a/mide-plugins/vscode/src/ide/session.ts b/mide-plugins/vscode/src/ide/session.ts index bf142e5..0596f75 100644 --- a/mide-plugins/vscode/src/ide/session.ts +++ b/mide-plugins/vscode/src/ide/session.ts @@ -16,6 +16,12 @@ import { backendUnavailableHere, locateBackend, okrunDir, type Located } from ". import { mcpplsCandidates } from "./backend/mcppls"; import { MCPPLS_EXTENSION } from "./mapping"; import type { CommandEntry, InitializeResult, LanguageServiceStatus } from "./protocol"; +import { incompatibility, type Handshake } from "./compat"; + +/** This extension's version, as its manifest states it: the series the backend must share (D15). */ +function ownVersion(): string { + return (vscode.extensions.getExtension("mcpp-community.mcpp-vscode")?.packageJSON as { version?: string } | undefined)?.version ?? "unknown"; +} // The language client, bundled into dist/vendor (tools/bundle-languageclient.mjs): the VSIX carries // no node_modules. @@ -237,6 +243,18 @@ export class Session implements vscode.Disposable { try { await client.start(); this.initResult = client.initializeResult as unknown as InitializeResult; + const refused = incompatibility(this.initResult as unknown as Handshake, ownVersion()); + if (refused) { + const message = refused.kind === "protocol" + ? t("The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.") + : t("The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.", + refused.backendVersion, refused.backendSeries, refused.ownVersion, refused.ownSeries); + this.output.appendLine(`mcpp: ${message}`); + void vscode.window.showErrorMessage(`mcpp: ${message}`); + this.client = undefined; + await client.stop().catch(() => undefined); + return false; + } this.commandTable = (await this.request<{ commands: CommandEntry[] }>("commands/list", { editor: "vscode" })).commands; if (this.initResult.languageService) { this.languageServiceNow = this.initResult.languageService; diff --git a/mide-plugins/vscode/test/ide/compat.test.ts b/mide-plugins/vscode/test/ide/compat.test.ts new file mode 100644 index 0000000..29aada0 --- /dev/null +++ b/mide-plugins/vscode/test/ide/compat.test.ts @@ -0,0 +1,28 @@ +// D15: the extension works with a backend of its own series, whatever either patch is; it refuses +// another series, or another protocol, naming both versions. +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { incompatibility, seriesOf } from "../../src/ide/compat"; + +test("one series works together, whatever the patches", () => { + assert.equal(incompatibility({ protocol: 2, series: "1.2", serverInfo: { version: "1.2.0" } }, "1.2.3"), undefined); + assert.equal(incompatibility({ protocol: 2, series: "1.2", serverInfo: { version: "1.2.4" } }, "1.2.0"), undefined); + assert.equal(incompatibility({ protocol: 2, serverInfo: { version: "1.2.1" } }, "1.2.0"), undefined); +}); + +test("another series or protocol is refused, naming both versions", () => { + assert.deepEqual(incompatibility({ protocol: 2, serverInfo: { version: "0.2.1" } }, "1.2.0"), + { kind: "series", backendVersion: "0.2.1", backendSeries: "0.2", ownVersion: "1.2.0", ownSeries: "1.2" }); + assert.equal(incompatibility({ protocol: 2, series: "1.3", serverInfo: { version: "1.3.0" } }, "1.2.0")?.kind, "series"); + assert.equal(incompatibility({ protocol: 1, series: "1.2", serverInfo: { version: "1.2.0" } }, "1.2.0")?.kind, "protocol"); + assert.equal(incompatibility({}, "1.2.0")?.kind, "protocol"); + assert.equal(incompatibility({ protocol: 2, series: "1.2" }, "unknown")?.kind, "series"); +}); + +test("the series of a version", () => { + assert.equal(seriesOf("1.2.0"), "1.2"); + assert.equal(seriesOf("1.2"), "1.2"); + assert.equal(seriesOf("v1.2.0"), undefined); + assert.equal(seriesOf(undefined), undefined); +}); diff --git a/src/server.cppm b/src/server.cppm index ce7584a..8840b5e 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -33,7 +33,12 @@ export namespace mcppide::server { using Json = nlohmann::json; -inline constexpr std::string_view kVersion = "0.2.1"; +inline constexpr std::string_view kVersion = "1.2.0"; +// THE SERIES (mcpp design 2026-11-07, D15): `X.Y` of `X.Y.Z`, shared by the backend and every editor +// plugin; components of one series work together, and `Z` is each component's own patch. A plugin +// refuses a backend of another series, naming both versions. +inline constexpr std::string_view kSeries = "1.2"; +static_assert(kVersion.starts_with(kSeries) && kVersion.size() > kSeries.size() && kVersion[kSeries.size()] == '.'); inline constexpr int kProtocol = 2; // The methods `install` registers, for the protocol schema's test: a method @@ -615,6 +620,7 @@ std::expected Session::initialize(const Json& p) { return Json{{"capabilities", documents::server_capabilities()}, {"serverInfo", Json{{"name", "mcpp-ide"}, {"version", kVersion}}}, {"protocol", kProtocol}, + {"series", kSeries}, {"mcpp", check["mcpp"]}, {"languageService", check["languageService"]}, {"checks", check["checks"]}, From ced8cd3cd24089758cf0a0dc56c8cf50c53a70b7 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:33:05 +0900 Subject: [PATCH 03/31] ci(release): a tag publishes its components (D15) vX.Y.0 releases the backend, the VS Code extension, the CLion plugin and the Neovim plugin; backend-, vscode-, clion- and nvim-vX.Y.Z release one component's patch. .github/scripts/release-plan.sh checks that the tag's version is the component's and that every component states one series; CI runs it on every change. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/scripts/release-plan.sh | 57 ++++++++++++++++++ .github/workflows/ci.yml | 8 +++ .github/workflows/publish-jetbrains.yml | 7 ++- .github/workflows/release.yml | 79 +++++++++++++++++++------ 4 files changed, 129 insertions(+), 22 deletions(-) create mode 100755 .github/scripts/release-plan.sh diff --git a/.github/scripts/release-plan.sh b/.github/scripts/release-plan.sh new file mode 100755 index 0000000..83318ce --- /dev/null +++ b/.github/scripts/release-plan.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# release-plan.sh [TAG] -- the components a release tag publishes, and its version (design 2026-11-07, D15). +# +# A version is X.Y.Z: X.Y, the series, is shared by the backend and every editor plugin; Z is each +# component's own patch. Two kinds of tag: +# +# vX.Y.0 a series release: every component, each at X.Y.0 +# -vX.Y.Z one component's patch: backend, vscode, clion or nvim, at X.Y.Z +# +# Prints `components=...`, `version=...` and `title=...` for $GITHUB_OUTPUT. Without a tag it only +# checks that every component states one series and the backend's two statements agree (CI). +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +declare -A version +version[backend]="$(sed -n 's/^version *= *"\(.*\)"/\1/p' "$root/mcpp.toml" | head -1)" +version[vscode]="$(sed -n 's/^ "version": "\(.*\)",$/\1/p' "$root/mide-plugins/vscode/package.json" | head -1)" +version[clion]="$(sed -n 's/^pluginVersion *= *\(.*\)$/\1/p' "$root/mide-plugins/clion/gradle.properties" | head -1)" +version[nvim]="$(sed -n "s/^M.version = '\(.*\)'$/\1/p" "$root/mide-plugins/nvim/lua/mcpp/session.lua" | head -1)" +backend_source="$(sed -n 's/^inline constexpr std::string_view kVersion = "\(.*\)";$/\1/p' "$root/src/server.cppm" | head -1)" +series_source="$(sed -n 's/^inline constexpr std::string_view kSeries = "\(.*\)";$/\1/p' "$root/src/server.cppm" | head -1)" +fail() { echo "release-plan: $*" >&2; exit 1; } +series() { [[ "$1" =~ ^([0-9]+)\.([0-9]+)\.[0-9]+$ ]] && echo "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"; } + +for c in backend vscode clion nvim; do + [ -n "${version[$c]}" ] && [ -n "$(series "${version[$c]}")" ] || fail "$c states no X.Y.Z version (${version[$c]:-none})" +done +[ "$backend_source" = "${version[backend]}" ] || fail "src/server.cppm says $backend_source and mcpp.toml ${version[backend]}" +the_series="$(series "${version[backend]}")" +[ "$series_source" = "$the_series" ] || fail "src/server.cppm's kSeries is $series_source, the backend's series $the_series" +for c in vscode clion nvim; do + [ "$(series "${version[$c]}")" = "$the_series" ] \ + || fail "$c is ${version[$c]}, of another series than the backend's ${version[backend]}" +done + +tag="${1:-}" +if [ -z "$tag" ]; then + echo "one series $the_series: backend ${version[backend]}, vscode ${version[vscode]}, clion ${version[clion]}, nvim ${version[nvim]}" + exit 0 +fi +if [[ "$tag" =~ ^v([0-9]+\.[0-9]+\.0)$ ]]; then + v="${BASH_REMATCH[1]}" + for c in backend vscode clion nvim; do + [ "${version[$c]}" = "$v" ] || fail "the series release $tag needs every component at $v; $c is ${version[$c]}" + done + echo "components=backend vscode clion nvim" + echo "version=$v" + echo "title=mcpp-ide $v" +elif [[ "$tag" =~ ^(backend|vscode|clion|nvim)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + c="${BASH_REMATCH[1]}"; v="${BASH_REMATCH[2]}" + [ "${version[$c]}" = "$v" ] || fail "the tag $tag names $c $v, and $c is ${version[$c]}" + declare -A names=([backend]=mcpp-ide [vscode]=mcpp-vscode [clion]=mcpp-clion [nvim]=mcpp-nvim) + echo "components=$c" + echo "version=$v" + echo "title=${names[$c]} $v" +else + fail "$tag is neither vX.Y.0 nor -vX.Y.Z" +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a34844..193d15f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,14 @@ env: NODE_VERSION: 22 jobs: + # Every component one series (design 2026-11-07, D15): the backend's two statements agree, and the + # VS Code, CLion and Neovim plugins share the backend's X.Y. + versions: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - run: bash .github/scripts/release-plan.sh + # The backend on each system the editors run on: build every program (the backend, the mock mcpp, # the dev tool), run the tests -- the protocol against its schema and the fixtures against mcpp's # contract among them -- and check the translations. The `pack` leg then packs the backend as the diff --git a/.github/workflows/publish-jetbrains.yml b/.github/workflows/publish-jetbrains.yml index 3d79a1d..ce5ed47 100644 --- a/.github/workflows/publish-jetbrains.yml +++ b/.github/workflows/publish-jetbrains.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: tag: - description: Published GitHub release tag, e.g. v0.2.1 + description: Published GitHub release tag, vX.Y.0 or clion-vX.Y.Z required: true workflow_call: inputs: @@ -38,8 +38,9 @@ jobs: python3 - <<'PY' import os, re from pathlib import Path - match = re.fullmatch(r'v(\d+\.\d+\.\d+)', os.environ['TAG']) - assert match, 'Expected a versioned release tag' + # A series release (vX.Y.0) or the CLion plugin's patch (clion-vX.Y.Z), design 2026-11-07, D15. + match = re.fullmatch(r'(?:clion-)?v(\d+\.\d+\.\d+)', os.environ['TAG']) + assert match, 'Expected vX.Y.0 or clion-vX.Y.Z' with Path(os.environ['GITHUB_ENV']).open('a') as out: out.write('VERSION=' + match[1] + '\n') PY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b92bee..1ef98a8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,29 +1,56 @@ name: Release # A release: CI on the commit (which builds the backend's ok-image bundle, its universal package with -# every system's okrun, and both editor packages carrying the bundle), then a GitHub release with the -# editor packages, the bundle, the universal package and their checksums. Open VSX and -# JetBrains consume the exact published archives independently after that release exists. +# every system's okrun, and both editor packages carrying the bundle), then a GitHub release of what +# the tag publishes, with its checksums. Open VSX and JetBrains consume the exact published archives +# independently after that release exists. +# +# WHAT A TAG PUBLISHES (design 2026-11-07, D15; .github/scripts/release-plan.sh): a version is X.Y.Z, +# X.Y the series every component shares and Z each component's own patch. +# +# vX.Y.0 a series release: the backend, the VS Code extension, the CLion plugin and +# the Neovim plugin (a source tree, which xim takes from the tag's archive) +# backend-vX.Y.Z the backend's ok-image bundle and package (xim's mcpp-ide) +# vscode-vX.Y.Z the VSIX (Open VSX) +# clion-vX.Y.Z the CLion ZIP (JetBrains Marketplace) +# nvim-vX.Y.Z the Neovim plugin: the tag's source archive (xim's mcpp-ide-nvim) +# +# The tag's version must be the component's own, and every component one series. on: workflow_dispatch: push: - tags: ['v*'] + tags: ['v*', 'backend-v*', 'vscode-v*', 'clion-v*', 'nvim-v*'] permissions: contents: read jobs: + plan: + runs-on: ubuntu-24.04 + outputs: + components: ${{ steps.plan.outputs.components }} + version: ${{ steps.plan.outputs.version }} + title: ${{ steps.plan.outputs.title }} + steps: + - uses: actions/checkout@v5 + - id: plan + shell: bash # -eo pipefail: a refused tag fails the step through tee + run: bash .github/scripts/release-plan.sh "$GITHUB_REF_NAME" | tee -a "$GITHUB_OUTPUT" + ci: + needs: plan uses: ./.github/workflows/ci.yml release: - needs: ci + needs: [plan, ci] runs-on: ubuntu-24.04 permissions: contents: write - outputs: - version: ${{ steps.publish.outputs.version }} + env: + COMPONENTS: ${{ needs.plan.outputs.components }} + VERSION: ${{ needs.plan.outputs.version }} + TITLE: ${{ needs.plan.outputs.title }} defaults: run: shell: bash @@ -38,20 +65,33 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" mkdir -p dist - cp artifacts/mcpp-vscode-vsix/*.vsix artifacts/mcpp-clion-zip/*.zip dist/ - cp artifacts/backend/mcpp-ide.okb "dist/mcpp-ide-${version}.okb" - cp "artifacts/backend/mcpp-ide-${version}"-openkal*.tar.gz dist/ - (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS) - gh release create "v${version}" dist/* --target "$GITHUB_SHA" --title "mcpp-ide ${version}" --notes-file CHANGELOG.md - echo "version=$version" >> "$GITHUB_OUTPUT" + backend="$(sed -n 's/^version *= *"\(.*\)"/\1/p' mcpp.toml | head -1)" + for c in $COMPONENTS; do + case "$c" in + backend) + cp artifacts/backend/mcpp-ide.okb "dist/mcpp-ide-${backend}.okb" + cp "artifacts/backend/mcpp-ide-${backend}"-openkal*.tar.gz dist/ ;; + vscode) cp artifacts/mcpp-vscode-vsix/*.vsix dist/ ;; + clion) cp artifacts/mcpp-clion-zip/*.zip dist/ ;; + nvim) ;; # the tag's source archive is the plugin + esac + done + notes="$RUNNER_TEMP/notes.md" + printf '%s\n\n' "$TITLE" > "$notes"; cat CHANGELOG.md >> "$notes" + if [ -n "$(ls dist)" ]; then + (cd dist && sha256sum * > SHA256SUMS && cat SHA256SUMS) + gh release create "$GITHUB_REF_NAME" dist/* --target "$GITHUB_SHA" --title "$TITLE" --notes-file "$notes" + else + gh release create "$GITHUB_REF_NAME" --target "$GITHUB_SHA" --title "$TITLE" --notes-file "$notes" + fi openvsx: - needs: release + needs: [plan, release] + if: contains(needs.plan.outputs.components, 'vscode') runs-on: ubuntu-24.04 env: - VERSION: ${{ needs.release.outputs.version }} + VERSION: ${{ needs.plan.outputs.version }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 @@ -62,7 +102,7 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - gh release download "v$VERSION" -p 'mcpp-vscode-*.vsix' -p SHA256SUMS -D published + gh release download "$GITHUB_REF_NAME" -p 'mcpp-vscode-*.vsix' -p SHA256SUMS -D published (cd published && sha256sum -c --ignore-missing SHA256SUMS) - name: Publish to Open VSX env: @@ -80,9 +120,10 @@ jobs: (cd mide-plugins/vscode && npx ovsx publish "$GITHUB_WORKSPACE/$vsix" --pat "$OPENVSX_TOKEN") jetbrains: - needs: release + needs: [plan, release] + if: contains(needs.plan.outputs.components, 'clion') uses: ./.github/workflows/publish-jetbrains.yml with: - tag: v${{ needs.release.outputs.version }} + tag: ${{ github.ref_name }} secrets: JETBRAINS_TOKEN: ${{ secrets.JETBRAINS_TOKEN }} From c0ab66ea980ddcfbf271fc8e008a44ef66ccca90 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:40:01 +0900 Subject: [PATCH 04/31] feat(tests): mcpp's test statuses value for value; every result with its output and place The stream mapping read fail and skip, which mcpp never writes, so run_fail and compile_fail were errored and a timeout was never seen. Statuses follow mcpp's contract: pass, run_fail (timedOut), compile_fail (compileFailed), not_run (skipped with mcpp's reason) and built (builtOnly). Every test result carries the program's whole output, passed or not, its head and tail past 1 MiB with outputTruncated, and a failure's location: the first file:line its output names. A case run divides the program's output among its cases by GoogleTest's boundaries, and each case result carries its own output and place (D16). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- protocol/protocol.schema.json | 11 ++++- src/server.cppm | 28 ++++++++++-- src/testing.cppm | 45 +++++++++++++++++++ src/views.cppm | 85 ++++++++++++++++++++++++++++++++--- tests/test_testing.cpp | 12 +++++ tests/test_views.cpp | 33 +++++++++++++- 6 files changed, 201 insertions(+), 13 deletions(-) diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index 3478b60..84417fc 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -441,15 +441,21 @@ } }, "mcpp-ide/test/result": { + "description": "One test program's result, mcpp's status value for value: pass is passed; run_fail failed, or timedOut when mcpp says timed_out; compile_fail failed with compileFailed; not_run skipped with mcpp's reason; built skipped with builtOnly. Every result carries the program's whole output (compile, then run), passed or not, the head and the tail past 1 MiB with outputTruncated; a failure's location is the first file:line its output names, or null.", "params": { "type": "object", - "required": ["jobId", "test", "status"], + "required": ["jobId", "test", "status", "output", "outputTruncated"], "properties": { "test": { "type": "string" }, "member": { "type": "string" }, "status": { "enum": ["passed", "failed", "timedOut", "skipped", "errored"] }, + "timedOut": { "type": "boolean" }, + "compileFailed": { "type": "boolean" }, + "builtOnly": { "type": "boolean" }, "durationMs": { "type": "integer" }, "output": { "type": "string" }, + "outputTruncated": { "type": "boolean" }, + "location": { "$ref": "#/$defs/failureLocation" }, "reason": { "type": "string" } } } @@ -459,10 +465,11 @@ "mcpp-ide/manifest/changed": { "params": { "type": "object", "required": ["root"] } }, "mcpp-ide/manifest/diagnostics": { "params": { "type": "object", "required": ["root", "diagnostics"] } }, "mcpp-ide/project/mcppChanged": { "description": "A job's envelope named another mcpp than the project's was asked as: it is asked again on its next use.", "params": { "type": "object", "required": ["root", "version"] } }, - "mcpp-ide/test/caseResult": { "description": "One case's outcome, read from the framework's report when a test/runCases job ended.", "params": { "type": "object", "required": ["jobId", "root", "test", "case", "status"], "properties": { "status": { "enum": ["passed", "failed", "skipped"] }, "durationMs": { "type": "integer" }, "message": { "type": "string" } } } }, + "mcpp-ide/test/caseResult": { "description": "One case's outcome, read from the framework's report when a test/runCases job ended, with the case's own output (GoogleTest's console between [ RUN ] and its result line; empty for a framework whose console names no case) and a failure's location.", "params": { "type": "object", "required": ["jobId", "root", "test", "case", "status", "output", "outputTruncated"], "properties": { "status": { "enum": ["passed", "failed", "skipped"] }, "durationMs": { "type": "integer" }, "message": { "type": "string" }, "output": { "type": "string" }, "outputTruncated": { "type": "boolean" }, "location": { "$ref": "#/$defs/failureLocation" } } } }, "mcpp-ide/languageService/changed": { "description": "mcppls answered differently from the last time it was asked: another program or version, other actions, or another state of the workspace. An editor that lists the commands asks for them again.", "params": { "type": "object", "required": ["root", "state", "workspaceState"] } } }, "$defs": { + "failureLocation": { "description": "Where a failure is: the first file:line (or file(line):) its output names, line 0-based; null when it names none.", "oneOf": [{ "type": "null" }, { "type": "object", "required": ["path", "line"], "properties": { "path": { "type": "string" }, "line": { "type": "integer", "minimum": 0 } } }] }, "settingValues": { "type": "object", "additionalProperties": true }, "resolvedSettings": { "type": "object", "required": ["values", "rejected", "renames", "deprecated"] }, "rooted": { "type": "object", "required": ["root"], "properties": { "root": { "type": "string" } } }, diff --git a/src/server.cppm b/src/server.cppm index 8840b5e..43f3be9 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -185,6 +185,9 @@ private: std::map buildDatabases_; // The project and kind of each job, for the notifications its records become. std::map, std::less<>> jobOf_; + // The whole output of the last test record of each running test job, for a case run to divide + // among its cases when the job ends (test/runCases). + std::map> programOutput_; // The configuration (and platforms) the last plan of each project named. std::map plans_; // What `mcpp pack` accepts, read from its help once per mcpp (SPEC-014: `--pkg-format` names the @@ -247,6 +250,7 @@ Session::Session(rpc::Server& server) : rpc_(server) { root = it->second.first; jobOf_.erase(it); } + if (auto it = programOutput_.find(id); it != programOutput_.end()) programOutput_.erase(it); } rpc_.notify("mcpp-ide/job/finished", s); // Every envelope names the mcpp that wrote it: another version than the one probed (a @@ -404,6 +408,10 @@ void Session::on_record(std::string_view jobId, const Json& record) { } else if (type == "artifact") { rpc_.notify("mcpp-ide/job/artifact", n); } else if (type == "test") { + { + std::lock_guard lock(mutex_); + programOutput_[std::string(jobId)] = record.value("compile_output", "") + record.value("run_output", ""); + } rpc_.notify("mcpp-ide/test/result", n); } else if (type == "summary") { rpc_.notify("mcpp-ide/test/summary", n); @@ -1539,11 +1547,25 @@ std::expected Session::test_run_cases(const Json& p) { std::ifstream in(report, std::ios::binary); const std::string text(std::istreambuf_iterator(in), {}); in.close(); - if (fw) - for (const auto& o : testing::read_report(*fw, text)) + std::string programOutput; + { + std::lock_guard lock(mutex_); + if (auto it = programOutput_.find(jobId); it != programOutput_.end()) programOutput = it->second; + } + if (fw) { + const auto outputs = testing::case_outputs(*fw, programOutput); + for (const auto& o : testing::read_report(*fw, text)) { + // Each case with its own output (passed or not) and a failure's place (D16). + const auto own = outputs.find(o.name); + bool truncated = false; + auto output = views::bounded_output(own != outputs.end() ? own->second : std::string_view{}, truncated); + const Json location = o.status == "failed" ? views::failure_location(o.message.empty() ? output : o.message) : Json(nullptr); rpc_.notify("mcpp-ide/test/caseResult", Json{{"jobId", jobId}, {"root", root.generic_string()}, {"test", test}, {"case", o.name}, {"status", o.status}, {"durationMs", o.durationMs}, - {"message", o.message}}); + {"message", o.message}, {"output", std::move(output)}, + {"outputTruncated", truncated}, {"location", location}}); + } + } std::error_code ignored; std::filesystem::remove_all(dir, ignored); }); diff --git a/src/testing.cppm b/src/testing.cppm index 302c369..337e926 100644 --- a/src/testing.cppm +++ b/src/testing.cppm @@ -77,6 +77,13 @@ struct Outcome { std::string message; }; +// Each case's own output in its program's output, by the framework's boundaries (design 2026-11-07, +// D16): GoogleTest prints `[ RUN ] Suite.Name` before a case and `[ OK ]`, `[ FAILED ]` or +// `[ SKIPPED ]` with the name after it, and the case's output is those lines and what is between +// them. Empty for a framework whose console names no case boundary (Catch2 and doctest report a case +// in their report; a failure's expression and place are its message). +std::map case_outputs(const Framework& f, std::string_view output); + // A report the framework wrote. std::vector read_report(const Framework& f, std::string_view text); @@ -428,6 +435,44 @@ std::vector filter_args(const Framework& f, const std::vector case_outputs(const Framework& f, std::string_view output) { + std::map out; + if (f.id != "gtest") return out; + std::string current, text; + std::size_t at = 0; + while (at < output.size()) { + auto end = output.find('\n', at); + const auto stop = end == std::string_view::npos ? output.size() : end + 1; + const auto line = output.substr(at, stop - at); + at = stop; + // `[ RUN ] Suite.Name`; the result lines name it again, then ` (N ms)`. + const auto tagged = [&](std::string_view tag) -> std::optional { + if (!line.starts_with(tag)) return std::nullopt; + auto name = line.substr(tag.size()); + while (!name.empty() && (name.back() == '\n' || name.back() == '\r')) name.remove_suffix(1); + if (const auto paren = name.find(" ("); paren != std::string_view::npos) name = name.substr(0, paren); + return std::string(name); + }; + if (auto started = tagged("[ RUN ] ")) { + current = *started; + text = std::string(line); + continue; + } + if (current.empty()) continue; + text += line; + for (std::string_view tag : {"[ OK ] ", "[ FAILED ] ", "[ SKIPPED ] "}) + if (auto ended = tagged(tag); ended && *ended == current) { + out[current] = std::move(text); + current.clear(); + text.clear(); + break; + } + } + // A case the program ended in (a crash): what it printed until then. + if (!current.empty()) out[current] = std::move(text); + return out; +} + std::vector read_report(const Framework& f, std::string_view text) { if (f.reportFormat == "junit") return junit_report(text); const Json doc = Json::parse(text, nullptr, false); diff --git a/src/views.cppm b/src/views.cppm index 057c0c5..02f7acd 100644 --- a/src/views.cppm +++ b/src/views.cppm @@ -59,8 +59,23 @@ std::string platform_text(const Json& platform); // One record of a `--message-format json` stream (a build's events, a test's records), as the // adapters consume it: {"type": "plan" | "progress" | "diagnostic" | "artifact" | "test" | "summary" | // "other", ...}. +// +// A TEST'S STATUS IS mcpp's, VALUE FOR VALUE (mcpp docs/50, the test record; design 2026-11-07, D16): +// `pass` is passed; `run_fail` failed, or timedOut when `timed_out`; `compile_fail` failed, with +// `compileFailed`; `not_run` skipped, with mcpp's reason; `built` skipped, with `builtOnly` (`--no-run`). +// Any other value is errored and logged once. Every record carries the test's whole output +// (`compile_output` then `run_output`), passed or not, up to `kOutputLimit` bytes: past it the head +// and the tail are kept and `outputTruncated` is set. A failure's `location` is the first `file:line:` +// the output names, or null. Json stream_record(const Json& record); +// The most of a test's or a case's output one record carries. +inline constexpr std::size_t kOutputLimit = 1u << 20; +// `text` within `limit` bytes: the head and the tail, with a line saying what was left out between. +std::string bounded_output(std::string_view text, bool& truncated, std::size_t limit = kOutputLimit); +// The first `file:line:` (or `file(line):`) at the start of a line of `output`: {"path", "line"} (0-based), or null. +Json failure_location(std::string_view output); + // "1.5 GiB" -- binary units, one decimal. std::string format_bytes(std::int64_t bytes); @@ -476,6 +491,45 @@ Json targets(const Json& envelope, std::string_view selected, bool sets) { return result; } +std::string bounded_output(std::string_view text, bool& truncated, std::size_t limit) { + truncated = text.size() > limit; + if (!truncated) return std::string(text); + const std::size_t half = limit / 2; + // Whole lines at either end: the head up to its last newline, the tail from its first. + std::size_t head = half; + if (const auto nl = text.substr(0, half).rfind('\n'); nl != std::string_view::npos) head = nl + 1; + std::size_t tail = text.size() - half; + if (const auto nl = text.find('\n', tail); nl != std::string_view::npos && nl + 1 < text.size()) tail = nl + 1; + return std::format("{}[... {} bytes of output left out ...]\n{}", text.substr(0, head), tail - head, text.substr(tail)); +} + +Json failure_location(std::string_view output) { + std::size_t at = 0; + while (at < output.size()) { + auto end = output.find('\n', at); + if (end == std::string_view::npos) end = output.size(); + auto line = output.substr(at, end - at); + at = end + 1; + while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) line.remove_prefix(1); + // `path:LINE:` (GCC, Clang, GoogleTest, Catch2) or `path(LINE):` (MSVC). The path holds a + // dot (a file name), and a drive's `C:` is not its line. + for (std::size_t i = line.size() > 2 && line[1] == ':' ? 2 : 0; i < line.size(); ++i) { + const char c = line[i]; + if (c != ':' && c != '(') continue; + std::size_t j = i + 1, digits = 0; + while (j < line.size() && std::isdigit(static_cast(line[j]))) { ++j; ++digits; } + if (digits == 0) continue; + const bool closes = c == '(' ? (j < line.size() && line[j] == ')' && j + 1 < line.size() && line[j + 1] == ':') + : (j == line.size() || line[j] == ':'); + const auto path = line.substr(0, i); + if (!closes || path.find('.') == std::string_view::npos || path.find(' ') != std::string_view::npos) break; + const int number = std::stoi(std::string(line.substr(i + 1, digits))); + return Json{{"path", std::string(path)}, {"line", number > 0 ? number - 1 : 0}}; + } + } + return nullptr; +} + Json stream_record(const Json& r) { if (!r.is_object()) return Json{{"type", "other"}, {"record", r}}; // `mcpp test --message-format json`: one record per test, then the summary. @@ -487,14 +541,33 @@ Json stream_record(const Json& r) { } if (r.contains("test") && r["test"].is_string()) { const auto raw = r.value("status", ""); - const std::string status = raw == "pass" ? "passed" - : raw == "fail" ? (r.value("timed_out", false) ? "timedOut" : "failed") - : raw == "skip" || raw == "not_run" ? "skipped" : "errored"; + const bool timedOut = r.value("timed_out", false); + std::string status; + std::string reason = r.value("reason", ""); + Json out{{"type", "test"}, {"member", r.value("member", "")}, {"test", r["test"]}}; + if (raw == "pass") status = "passed"; + else if (raw == "run_fail") status = timedOut ? "timedOut" : "failed"; + else if (raw == "compile_fail") { status = "failed"; out["compileFailed"] = true; } + else if (raw == "not_run") status = "skipped"; + else if (raw == "built") { status = "skipped"; out["builtOnly"] = true; if (reason.empty()) reason = "built, not run (--no-run)"; } + else { + status = "errored"; + static std::once_flag said; + std::call_once(said, [&] { std::println(std::cerr, "mcpp-ide: a test record's status `{}` is not mcpp's contract", raw); }); + } std::string output = r.value("compile_output", ""); output += r.value("run_output", ""); - return Json{{"type", "test"}, {"member", r.value("member", "")}, {"test", r["test"]}, {"status", status}, - {"durationMs", r.value("duration_ms", 0)}, {"exitCode", r.contains("exit_code") ? r["exit_code"] : Json(nullptr)}, - {"reason", r.value("reason", "")}, {"output", output}}; + bool truncated = false; + output = bounded_output(output, truncated); + out["status"] = status; + out["timedOut"] = timedOut; + out["durationMs"] = r.value("duration_ms", 0); + out["exitCode"] = r.contains("exit_code") ? r["exit_code"] : Json(nullptr); + out["reason"] = reason; + out["location"] = status == "failed" || status == "timedOut" ? failure_location(output) : Json(nullptr); + out["output"] = std::move(output); + out["outputTruncated"] = truncated; + return out; } const auto event = r.value("event", ""); if (r.value("kind", "") == "mcpp.event") { diff --git a/tests/test_testing.cpp b/tests/test_testing.cpp index 96cdd80..b1f41a7 100644 --- a/tests/test_testing.cpp +++ b/tests/test_testing.cpp @@ -133,6 +133,18 @@ TEST(Math, CHECK(d.size() == 3 && d[0].status == "passed" && d[0].durationMs == 1); CHECK(d[1].name == "a & b" && d[1].status == "failed" && d[1].message.find("NOT correct") != std::string::npos); CHECK(d[2].status == "skipped"); + + // Each case's own output, by GoogleTest's boundaries (D16); none for a framework without them. + const auto outputs = testing::case_outputs(fw("gtest"), "Running main() from gtest_main.cc\n" + "[==========] Running 3 tests from 1 test suite.\n" + "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n" + "[ RUN ] Math.Spans\na.cpp:3: Failure\nExpected 2\n[ FAILED ] Math.Spans (0 ms)\n" + "[ RUN ] Math.Crashes\nhalf way\n"); + CHECK(outputs.size() == 3); + CHECK(outputs.at("Math.Adds") == "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n"); + CHECK(outputs.at("Math.Spans").contains("a.cpp:3: Failure") && outputs.at("Math.Spans").ends_with("[ FAILED ] Math.Spans (0 ms)\n")); + CHECK(outputs.at("Math.Crashes") == "[ RUN ] Math.Crashes\nhalf way\n"); + CHECK(testing::case_outputs(fw("catch2"), "[ RUN ] x\n").empty()); } // source/entries and test/cases over a project on disk (no mcpp: the plan's packages are not diff --git a/tests/test_views.cpp b/tests/test_views.cpp index 61b98be..0d6cfa2 100644 --- a/tests/test_views.cpp +++ b/tests/test_views.cpp @@ -124,7 +124,36 @@ int main() { std::getline(test, first); auto r = views::stream_record(Json::parse(first)); CHECK(r["type"] == "test" && r["status"] == "passed" && r["test"] == "test_smoke"); - auto failed = views::stream_record(Json{{"test", "t"}, {"status", "fail"}, {"timed_out", true}}); - CHECK(failed["status"] == "timedOut"); + // mcpp's statuses, value for value (D16): pass, run_fail (timed_out), compile_fail, not_run, built. + auto timedOut = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, {"timed_out", true}}); + CHECK(timedOut["status"] == "timedOut" && timedOut["timedOut"] == true); + auto failed = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, + {"run_output", "running\ntests/t.cpp:12: assertion failed\n"}}); + CHECK(failed["status"] == "failed" && failed["location"]["path"] == "tests/t.cpp" && failed["location"]["line"] == 11); + auto compileFailed = views::stream_record(Json{{"test", "t"}, {"status", "compile_fail"}, {"compile_output", "tests/t.cpp:3:5: error: x\n"}}); + CHECK(compileFailed["status"] == "failed" && compileFailed["compileFailed"] == true && compileFailed["location"]["line"] == 2); + auto notRun = views::stream_record(Json{{"test", "t"}, {"status", "not_run"}, {"reason", "no runner for riscv64"}}); + CHECK(notRun["status"] == "skipped" && notRun["reason"] == "no runner for riscv64" && notRun["location"].is_null()); + auto built = views::stream_record(Json{{"test", "t"}, {"status", "built"}}); + CHECK(built["status"] == "skipped" && built["builtOnly"] == true && !built["reason"].get().empty()); + CHECK(views::stream_record(Json{{"test", "t"}, {"status", "fail"}})["status"] == "errored"); + // A passed test keeps its output. + auto passed = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", "hello\n"}}); + CHECK(passed["status"] == "passed" && passed["output"] == "hello\n" && passed["outputTruncated"] == false); + // Past the limit, the head and the tail are kept, and the record says so. + std::string big = "first line\n"; + while (big.size() < 3 * views::kOutputLimit) big += "a line of output\n"; + big += "last line\n"; + auto long_ = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", big}}); + const auto kept = long_["output"].get(); + CHECK(long_["outputTruncated"] == true && kept.size() <= views::kOutputLimit + 100); + CHECK(kept.starts_with("first line\n") && kept.ends_with("last line\n") && kept.contains("bytes of output left out")); + // Where a failure is: GCC/Clang, MSVC and a drive; nothing that only looks like it. + CHECK(views::failure_location("src/a.cpp:3:5: error: x")["line"] == 2); + CHECK(views::failure_location("C:\\p\\a.cpp(12): error C2065")["path"] == "C:\\p\\a.cpp"); + CHECK(views::failure_location("C:\\p\\a.cpp(12): error C2065")["line"] == 11); + CHECK(views::failure_location("C:/p/b.cpp:7: Failure")["path"] == "C:/p/b.cpp"); + CHECK(views::failure_location("started at 12:30: ok").is_null()); + CHECK(views::failure_location("no location here").is_null()); std::println("test_views: ok"); } From f10ad6202abf34d566c7137b9c3c23717520483a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:43:25 +0900 Subject: [PATCH 05/31] feat(vscode): run entries with icons; each test's own output and place (D16) The lenses above main are $(play) Run and $(debug-alt) Debug, and while the active editor is a target's entry the editor title's Run menu runs and debugs it (mcpp.runThisFile, mcpp.debugThisFile). Every test and case result says its output to its own item, passed or not, with a note when the record cut it short; a failure's message sits at the place its output names, else at the item's line (a test's main, a case's macro). A timeout and a compile failure say so. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mide-plugins/vscode/data/i18n/zh-cn.json | 5 +- .../vscode/l10n/bundle.l10n.zh-cn.json | 5 +- mide-plugins/vscode/package.json | 24 ++++++++ mide-plugins/vscode/package.nls.json | 2 + mide-plugins/vscode/package.nls.zh-cn.json | 2 + mide-plugins/vscode/src/commands/ids.ts | 3 + mide-plugins/vscode/src/ide/codeLens.ts | 54 ++++++++++++++++-- mide-plugins/vscode/src/ide/protocol.ts | 17 ++++++ mide-plugins/vscode/src/ide/testing.ts | 55 ++++++++++++++++--- mide-plugins/vscode/test/artifacts.test.ts | 22 ++++++-- 10 files changed, 172 insertions(+), 17 deletions(-) diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json index cf763b1..d2cff01 100644 --- a/mide-plugins/vscode/data/i18n/zh-cn.json +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -661,5 +661,8 @@ "Dependencies of one index namespace": "一个索引命名空间的依赖", "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。", "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", - "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。" + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", + "The test timed out.": "测试超时。", + "The test did not compile.": "测试没有编译通过。" } diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json index cf763b1..d2cff01 100644 --- a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -661,5 +661,8 @@ "Dependencies of one index namespace": "一个索引命名空间的依赖", "The mcpp-ide backend does not run on this platform ({0}); build, run, test and the project pages go on without it.": "mcpp-ide 后端不在本平台({0})上运行;构建、运行、测试与项目页面照常可用。", "The mcpp-ide backend does not speak protocol 2. Update it, or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端不支持协议 2。请更新后端,或清空 mcpp.ide.backendPath 以使用内置后端。", - "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。" + "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", + "The test timed out.": "测试超时。", + "The test did not compile.": "测试没有编译通过。" } diff --git a/mide-plugins/vscode/package.json b/mide-plugins/vscode/package.json index d9a4bd6..798a617 100644 --- a/mide-plugins/vscode/package.json +++ b/mide-plugins/vscode/package.json @@ -89,6 +89,18 @@ "when": "mcpp.inProject && mcpp.editorTitleButtons" } ], + "editor/title/run": [ + { + "command": "mcpp.runThisFile", + "group": "mcpp@1", + "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" + }, + { + "command": "mcpp.debugThisFile", + "group": "mcpp@2", + "when": "mcpp.activeEntryDebug" + } + ], "view/title": [ { "command": "mcpp.refreshCacheStats", @@ -165,6 +177,18 @@ "category": "%category%", "icon": "$(beaker)" }, + { + "command": "mcpp.runThisFile", + "title": "%command.mcpp.runThisFile.title%", + "category": "%category%", + "icon": "$(play)" + }, + { + "command": "mcpp.debugThisFile", + "title": "%command.mcpp.debugThisFile.title%", + "category": "%category%", + "icon": "$(debug-alt)" + }, { "command": "mcpp.clean", "title": "%command.mcpp.clean.title%", diff --git a/mide-plugins/vscode/package.nls.json b/mide-plugins/vscode/package.nls.json index 7503047..0e456e6 100644 --- a/mide-plugins/vscode/package.nls.json +++ b/mide-plugins/vscode/package.nls.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", "command.mcpp.run.title": "Run", + "command.mcpp.runThisFile.title": "Run This File's Entry", + "command.mcpp.debugThisFile.title": "Debug This File's Entry", "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", "command.mcpp.selfCheck.title": "Environment Self-check", "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", diff --git a/mide-plugins/vscode/package.nls.zh-cn.json b/mide-plugins/vscode/package.nls.zh-cn.json index 7ede0f2..bfb007f 100644 --- a/mide-plugins/vscode/package.nls.zh-cn.json +++ b/mide-plugins/vscode/package.nls.zh-cn.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "刷新缓存统计", "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", "command.mcpp.run.title": "运行", + "command.mcpp.runThisFile.title": "运行此文件的入口", + "command.mcpp.debugThisFile.title": "调试此文件的入口", "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", "command.mcpp.selfCheck.title": "环境自检", "command.mcpp.showCacheEntry.title": "查看缓存条目详情", diff --git a/mide-plugins/vscode/src/commands/ids.ts b/mide-plugins/vscode/src/commands/ids.ts index 523d9f8..46d6b9b 100644 --- a/mide-plugins/vscode/src/commands/ids.ts +++ b/mide-plugins/vscode/src/commands/ids.ts @@ -33,6 +33,9 @@ export const CLI_COMMANDS = { showCachePanel: "mcpp.showCachePanel", /** Build in the editor's title bar, with an icon (mcpp.build has none in 0.9.0's manifest). */ editorBuild: "mcpp.editor.build", + /** The editor title's Run menu: the active file's entry, run or debugged (1.2.0, D16). */ + runThisFile: "mcpp.runThisFile", + debugThisFile: "mcpp.debugThisFile", } as const; /** The language-service entries that existed in 0.4.x (kept as aliases, see below). */ diff --git a/mide-plugins/vscode/src/ide/codeLens.ts b/mide-plugins/vscode/src/ide/codeLens.ts index ecc303c..2867184 100644 --- a/mide-plugins/vscode/src/ide/codeLens.ts +++ b/mide-plugins/vscode/src/ide/codeLens.ts @@ -7,9 +7,16 @@ * * A program's Run and Debug are mcpp.run and mcpp.debug. A test's Run is its program in the Testing * view; its Debug is offered where the project's mcpp describes a test's launch (`test-launch`). + * + * THE ENTRY HAS AN ICON (design 2026-11-07, D16): the lenses are `$(play) Run` and `$(debug-alt) Debug`, + * and while the active editor is a target's entry the editor title's Run menu (`editor/title/run`) + * offers its Run and Debug (`mcpp.runThisFile`, `mcpp.debugThisFile`), by the context key + * `mcpp.activeEntry` ("program", "test" or "") and `mcpp.activeEntryDebug`. A test's cases have the + * Testing view's gutter icons, each with its last result. */ import * as vscode from "vscode"; +import { CLI_COMMANDS } from "../commands/ids"; import { read } from "../config/access"; import { t } from "../i18n/t"; import { mainLineOf } from "./mapping"; @@ -20,6 +27,8 @@ import type { Testing } from "./testing"; export const CODELENS_COMMANDS = { runTest: "mcpp.codeLens.runTest", debugTest: "mcpp.codeLens.debugTest", + runThisFile: CLI_COMMANDS.runThisFile, + debugThisFile: CLI_COMMANDS.debugThisFile, } as const; export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable { @@ -45,7 +54,42 @@ export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable const folder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(root)); await vscode.debug.startDebugging(folder, { type: "mcpp", request: "launch", name: `mcpp: debug ${test}`, root, test }); }), + // The editor title's Run menu: the active editor's entry, run or debugged as its lens would. + vscode.commands.registerCommand(CODELENS_COMMANDS.runThisFile, async () => { + const entry = await this.activeEntry(); + if (entry?.kind === "program") await vscode.commands.executeCommand("mcpp.run"); + else if (entry?.kind === "test" && entry.root) await vscode.commands.executeCommand(CODELENS_COMMANDS.runTest, entry.root, entry.test ?? ""); + }), + vscode.commands.registerCommand(CODELENS_COMMANDS.debugThisFile, async () => { + const entry = await this.activeEntry(); + if (entry?.kind === "program") await vscode.commands.executeCommand("mcpp.debug"); + else if (entry?.kind === "test" && entry.root) await vscode.commands.executeCommand(CODELENS_COMMANDS.debugTest, entry.root, entry.test ?? ""); + }), + vscode.window.onDidChangeActiveTextEditor(() => void this.updateContext()), + vscode.workspace.onDidSaveTextDocument((doc) => { if (doc === vscode.window.activeTextEditor?.document) void this.updateContext(); }), + session.onDidStart.event(() => void this.updateContext()), ); + void this.updateContext(); + } + + /** What the active editor's file is to its project, or undefined. */ + private async activeEntry(): Promise { + const document = vscode.window.activeTextEditor?.document; + if (!document || document.languageId !== "cpp" || document.uri.scheme !== "file" || !this.session.running) return undefined; + try { + return await this.session.request("source/entries", { file: document.uri.fsPath, text: document.getText() }); + } catch { + return undefined; + } + } + + /** `mcpp.activeEntry` and `mcpp.activeEntryDebug`, for the editor title's Run menu. */ + private async updateContext(): Promise { + const entry = await this.activeEntry(); + const kind = entry && entry.kind !== "none" && entry.root !== undefined ? entry.kind : ""; + const debug = kind === "program" || (kind === "test" && !!entry?.root && !!this.projectMcpp(entry.root)?.features.includes("test-launch")); + await vscode.commands.executeCommand("setContext", "mcpp.activeEntry", kind); + await vscode.commands.executeCommand("setContext", "mcpp.activeEntryDebug", debug); } async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { @@ -70,15 +114,17 @@ export class McppCodeLens implements vscode.CodeLensProvider, vscode.Disposable const range = new vscode.Range(line, 0, line, 0); const tooltip = approximate ? t("The line of main was read from the text; C++ Modules gives its position when it serves this file.") : undefined; const root = entries.root; + const run = `$(play) ${t("Run")}`; + const debug = `$(debug-alt) ${t("Debug")}`; if (entries.kind === "program") return [ - new vscode.CodeLens(range, { title: t("Run"), command: "mcpp.run", tooltip }), - new vscode.CodeLens(range, { title: t("Debug"), command: "mcpp.debug", tooltip }), + new vscode.CodeLens(range, { title: run, command: "mcpp.run", tooltip }), + new vscode.CodeLens(range, { title: debug, command: "mcpp.debug", tooltip }), ]; const test = entries.test ?? ""; - const lenses = [new vscode.CodeLens(range, { title: t("Run"), command: CODELENS_COMMANDS.runTest, arguments: [root, test], tooltip })]; + const lenses = [new vscode.CodeLens(range, { title: run, command: CODELENS_COMMANDS.runTest, arguments: [root, test], tooltip })]; if (this.projectMcpp(root)?.features.includes("test-launch")) - lenses.push(new vscode.CodeLens(range, { title: t("Debug"), command: CODELENS_COMMANDS.debugTest, arguments: [root, test], tooltip })); + lenses.push(new vscode.CodeLens(range, { title: debug, command: CODELENS_COMMANDS.debugTest, arguments: [root, test], tooltip })); return lenses; } diff --git a/mide-plugins/vscode/src/ide/protocol.ts b/mide-plugins/vscode/src/ide/protocol.ts index c6ab7f9..2cbe24d 100644 --- a/mide-plugins/vscode/src/ide/protocol.ts +++ b/mide-plugins/vscode/src/ide/protocol.ts @@ -192,6 +192,12 @@ export interface TestCases { } /** `test/caseResult`: one case's outcome, read from the framework's report. */ +/** Where a failure is: the first `file:line` its output names, the line 0-based. */ +export interface FailureLocation { + path: string; + line: number; +} + export interface CaseResult { jobId: string; root: string; @@ -200,15 +206,26 @@ export interface CaseResult { status: "passed" | "failed" | "skipped"; durationMs: number; message: string; + /** The case's own output (GoogleTest's console between `[ RUN ]` and its result); empty without boundaries. */ + output?: string; + outputTruncated?: boolean; + location?: FailureLocation | null; } +/** One program's result, mcpp's status value for value (design 2026-11-07, D16). */ export interface TestResult { jobId: string; root: string; member: string; test: string; status: "passed" | "failed" | "timedOut" | "skipped" | "errored"; + timedOut?: boolean; + compileFailed?: boolean; + builtOnly?: boolean; durationMs: number; + /** The program's whole output, compile then run, passed or not. */ output: string; + outputTruncated?: boolean; + location?: FailureLocation | null; reason: string; } diff --git a/mide-plugins/vscode/src/ide/testing.ts b/mide-plugins/vscode/src/ide/testing.ts index b0fdcc5..f298cc4 100644 --- a/mide-plugins/vscode/src/ide/testing.ts +++ b/mide-plugins/vscode/src/ide/testing.ts @@ -17,7 +17,32 @@ import type { Jobs } from "./jobs"; import { baseCaseName, caseId, parseCaseId, parseTestId, testId } from "./mapping"; import type { IdeProjects } from "./projects"; import type { Session } from "./session"; -import type { CaseResult, JobFinished, TestCases, TestResult } from "./protocol"; +import type { CaseResult, FailureLocation, JobFinished, TestCases, TestResult } from "./protocol"; + +/** Text for the test terminal, whose lines end in `\r\n`. */ +function terminal(text: string): string { + return text.replace(/\r?\n/g, "\r\n"); +} + +/** + * Where a failure is shown (D16): the place its output names, in the program's source when the + * name is relative to the project; else the item's own line (a test's `main`, a case's macro). + */ +function failureAt(root: string, location: FailureLocation | null | undefined, item: vscode.TestItem): vscode.Location | undefined { + if (location) { + const file = path.isAbsolute(location.path) ? location.path : path.join(root, location.path); + return new vscode.Location(vscode.Uri.file(file), new vscode.Position(location.line, 0)); + } + return item.uri && item.range ? new vscode.Location(item.uri, item.range) : undefined; +} + +/** The output a result carries, said to its item, with a note when the record cut it short. */ +function attribute(run: vscode.TestRun, item: vscode.TestItem, output: string | undefined, truncated: boolean | undefined, + at: vscode.Location | undefined): void { + if (!output) return; + run.appendOutput(terminal(output.endsWith("\n") ? output : `${output}\n`), at, item); + if (truncated) run.appendOutput(terminal(`${t("(the output was longer; its head and tail are shown)")}\n`), at, item); +} interface Program { root: string; @@ -149,14 +174,21 @@ export class Testing implements vscode.Disposable { const handle = await this.jobs.start( { root: program.root, test: program.test, framework: program.framework, cases: names, color: true }, { - output: (text) => run.appendOutput(text.replace(/\r?\n/g, "\r\n")), + output: (text) => run.appendOutput(terminal(text)), caseResult: (r: CaseResult) => { const item = byName.get(r.case) ?? byName.get(baseCaseName(program.framework, r.case)); if (!item) return; seen.add(item); + // Every case's own output reaches its item, passed or not (D16). + const at = r.status === "failed" ? failureAt(program.root, r.location, item) : undefined; + attribute(run, item, r.output, r.outputTruncated, at); if (r.status === "passed") run.passed(item, r.durationMs); else if (r.status === "skipped") run.skipped(item); - else run.failed(item, new vscode.TestMessage(r.message || r.status), r.durationMs); + else { + const message = new vscode.TestMessage(r.message || r.output || r.status); + message.location = at; + run.failed(item, message, r.durationMs); + } }, }, "test/runCases", @@ -177,15 +209,24 @@ export class Testing implements vscode.Disposable { const index = new Map(items.map((i) => [parseTestId(i.id)!.test, i])); const filter = items.length === 1 && request.include ? parseTestId(items[0].id)!.test : undefined; const handle = await this.jobs.start({ kind: "test", root, filter, color: true }, { - output: (text) => run.appendOutput(text.replace(/\r?\n/g, "\r\n")), + output: (text) => run.appendOutput(terminal(text)), testResult: (r: TestResult) => { const item = index.get(r.test); if (!item) return; const ms = r.durationMs; + const failing = r.status === "failed" || r.status === "timedOut" || r.status === "errored"; + const at = failing ? failureAt(root, r.location, item) : undefined; + // The program's output reaches its item whatever the result (D16). + attribute(run, item, r.output, r.outputTruncated, at); if (r.status === "passed") run.passed(item, ms); - else if (r.status === "skipped") run.skipped(item); - else { - const message = new vscode.TestMessage(r.output || r.reason || r.status); + else if (r.status === "skipped") { + if (r.reason) run.appendOutput(terminal(`${r.reason}\n`), undefined, item); + run.skipped(item); + } else { + const headline = r.status === "timedOut" ? t("The test timed out.") + : r.compileFailed ? t("The test did not compile.") : undefined; + const message = new vscode.TestMessage([headline, r.output || r.reason || r.status].filter(Boolean).join("\n")); + message.location = at; if (r.status === "errored") run.errored(item, message, ms); else run.failed(item, message, ms); } }, diff --git a/mide-plugins/vscode/test/artifacts.test.ts b/mide-plugins/vscode/test/artifacts.test.ts index 67d7b43..be8543f 100644 --- a/mide-plugins/vscode/test/artifacts.test.ts +++ b/mide-plugins/vscode/test/artifacts.test.ts @@ -369,6 +369,18 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.equal(commands.find((command) => command.command === "mcpp.test")?.icon, "$(beaker)"); assert.equal(commands.find((command) => command.command === "mcpp.editor.build")?.icon, "$(tools)"); assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); + // D16: while the active editor is a target's entry, the editor title's Run menu runs and debugs it, + // and the lenses above `main` carry the same icons. + const menus = manifest.contributes?.menus as Record> | undefined; + assert.deepEqual(menus?.["editor/title/run"], [ + { command: "mcpp.runThisFile", group: "mcpp@1", when: "mcpp.activeEntry == program || mcpp.activeEntry == test" }, + { command: "mcpp.debugThisFile", group: "mcpp@2", when: "mcpp.activeEntryDebug" }, + ]); + assert.equal(commands.find((command) => command.command === "mcpp.runThisFile")?.icon, "$(play)"); + assert.equal(commands.find((command) => command.command === "mcpp.debugThisFile")?.icon, "$(debug-alt)"); + const lens = readFileSync(path.join(root, "src", "ide", "codeLens.ts"), "utf8"); + assert.match(lens, /const run = `\$\(play\) \$\{t\("Run"\)\}`/); + assert.match(lens, /const debug = `\$\(debug-alt\) \$\{t\("Debug"\)\}`/); }); test("build.mcpp is a cpp document, not a language of its own", () => { @@ -634,7 +646,9 @@ const workflows = path.join(root, "..", "..", ".github", "workflows"); test("a release publishes the VSIX CI built, with its checksum", () => { const workflow = readFileSync(path.join(workflows, "release.yml"), "utf8"); - assert.match(workflow, /push:\s*\n\s+tags: \['v\*'\]/); + // A series release (vX.Y.0) and each component's patch tag (D15); vscode-vX.Y.Z publishes the VSIX alone. + assert.match(workflow, /push:\s*\n\s+tags: \['v\*', 'backend-v\*', 'vscode-v\*', 'clion-v\*', 'nvim-v\*'\]/); + assert.match(workflow, /release-plan\.sh "\$GITHUB_REF_NAME"/); assert.match(workflow, /contents: write/); // The release publishes what CI on the commit built and tested, not a second build. assert.match(workflow, /uses: \.\/\.github\/workflows\/ci\.yml/); @@ -665,9 +679,9 @@ test("a release verifies separate Open VSX and JetBrains uploads", () => { // Open VSX is a required destination: a missing token fails the release. assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); - assert.match(workflow, /openvsx:\s*\n\s+needs: release/); - assert.match(workflow, /gh release download "v\$VERSION" -p 'mcpp-vscode-\*\.vsix' -p SHA256SUMS/); - assert.match(workflow, /jetbrains:\s*\n\s+needs: release/); + assert.match(workflow, /openvsx:\s*\n\s+needs: \[plan, release\]\s*\n\s+if: contains\(needs\.plan\.outputs\.components, 'vscode'\)/); + assert.match(workflow, /gh release download "\$GITHUB_REF_NAME" -p 'mcpp-vscode-\*\.vsix' -p SHA256SUMS/); + assert.match(workflow, /jetbrains:\s*\n\s+needs: \[plan, release\]\s*\n\s+if: contains\(needs\.plan\.outputs\.components, 'clion'\)/); assert.match(workflow, /uses: \.\/\.github\/workflows\/publish-jetbrains\.yml/); assert.match(workflow, /JETBRAINS_TOKEN: \$\{\{ secrets\.JETBRAINS_TOKEN \}\}/); const jetbrains = readFileSync(path.join(workflows, "publish-jetbrains.yml"), "utf8"); From ca90ae6084972d2aec67828e6d5c163f98d1732c Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:47:31 +0900 Subject: [PATCH 06/31] feat(clion, nvim): each test's own output, place and sign (D16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLion: every test and case sends its own output as testStdOut, passed or not; a failure's details start with the place its output names, which the console filter links; a timeout and a compile failure say so; a file without run entries is logged with why. Neovim: :Mcpp test writes each test and case to mcpp://tests with its status and whole output, puts the failures in the quickfix list at their places, and the sign column shows a program's and a test's main and each case with a ▶, which a result turns into ✓ or ✗. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../mcppcommunity/mcppclion/McppSession.kt | 13 +- .../mcppcommunity/mcppclion/run/McppStates.kt | 47 ++++- mide-plugins/nvim/lua/mcpp/commands.lua | 14 +- mide-plugins/nvim/lua/mcpp/init.lua | 27 +++ mide-plugins/nvim/lua/mcpp/session.lua | 7 +- mide-plugins/nvim/lua/mcpp/tests.lua | 182 ++++++++++++++++++ mide-plugins/nvim/lua/mcpp/views.lua | 6 +- mide-plugins/nvim/tests/smoke.lua | 23 +++ spec/i18n/runtime.zh-cn.json | 7 +- 9 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 mide-plugins/nvim/lua/mcpp/tests.lua diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 2036b39..77b8f5d 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -304,8 +304,17 @@ class McppSession(private val project: Project) : Disposable { } fun refreshEntries(path: String) { - request("source/entries", JsonObject().apply { addProperty("file", path) }).thenAccept { r -> - sources[path] = r.asJsonObject + request("source/entries", JsonObject().apply { addProperty("file", path) }).whenComplete { r, error -> + // Why the gutter has no ▶ for a file (D16): the backend could not say what the file is. + if (error != null) { + val cause = (error as? java.util.concurrent.CompletionException)?.cause ?: error + log.info("mcpp-ide: no run entries for $path: ${cause.message}") + return@whenComplete + } + val entries = r.asJsonObject + if (entries["kind"]?.asString !in setOf("program", "test")) + log.debug("mcpp-ide: $path is no target's entry (${entries["kind"]?.asString ?: "unknown"})") + sources[path] = entries restartMarkers() } } diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt index 249deb3..7e1241a 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt @@ -120,10 +120,36 @@ class McppJobState(private val c: McppRunConfiguration, env: ExecutionEnvironmen /** * A test job whose results fill the IDE's test tree: each `test/result` becomes the TeamCity * service messages the test runner reads, located at the file that holds the test's `main`. + * + * EVERY TEST AND CASE HAS ITS OWN OUTPUT (design 2026-11-07, D16): `testStdOut` carries a program's + * whole output and a case's own (GoogleTest's console between `[ RUN ]` and its result), passed or + * not; a failure's `details` start with the place its output names (`file:line`, which the console + * filter links), then the output. */ class TestJobProcessHandler(session: McppSession, params: JsonObject, private val mains: Map, method: String = "job/start") : JobProcessHandler(session, params, method) { private fun escape(s: String) = s.replace("|", "||").replace("'", "|'").replace("\n", "|n").replace("\r", "|r").replace("[", "|[").replace("]", "|]") + + /** `path:line` of a result's `location` (its line 0-based), or "". */ + private fun place(p: JsonObject): String = runCatching { + val l = p.getAsJsonObject("location") + "${l["path"].asString}:${l["line"].asInt + 1}" + }.getOrDefault("") + + /** The output a result carries, as its test's standard output, with a note when it was cut short. */ + private fun stdout(out: StringBuilder, name: String, p: JsonObject) { + val output = p["output"]?.takeIf { it.isJsonPrimitive }?.asString ?: "" + if (output.isEmpty()) return + val note = if (p["outputTruncated"]?.asBoolean == true) "\n(the output was longer; its head and tail are shown)\n" else "" + out.append("##teamcity[testStdOut name='$name' out='${escape(output + note)}']\n") + } + + /** `testFailed` with the failure's place first in its details, then what the result says. */ + private fun failed(out: StringBuilder, name: String, message: String, p: JsonObject, details: String) { + val at = place(p) + val text = listOf(at, details).filter { it.isNotBlank() }.joinToString("\n") + out.append("##teamcity[testFailed name='$name' message='${escape(message)}' details='${escape(text)}']\n") + } /** The program whose cases are reported, once its first case is (test/runCases). */ private var suite: String? = null @@ -143,10 +169,14 @@ class TestJobProcessHandler(session: McppSession, params: JsonObject, private va val hint = caseUrl(session, p["root"]?.asString ?: "", program, p["case"].asString, mains[program]) ?.let { " locationHint='${escape(it)}'" } ?: "" out.append("##teamcity[testStarted name='$name'$hint]\n") + stdout(out, name, p) when (p["status"]?.asString) { "passed" -> {} "skipped" -> out.append("##teamcity[testIgnored name='$name']\n") - else -> out.append("##teamcity[testFailed name='$name' message='${escape(p["message"]?.asString ?: "")}']\n") + else -> { + val message = p["message"]?.asString ?: "" + failed(out, name, message.lineSequence().firstOrNull() ?: "", p, message) + } } out.append("##teamcity[testFinished name='$name' duration='${p["durationMs"]?.asLong ?: 0}']\n") notifyTextAvailable(out.toString(), ProcessOutputTypes.STDOUT) @@ -159,12 +189,19 @@ class TestJobProcessHandler(session: McppSession, params: JsonObject, private va val name = escape(p["test"].asString) val hint = mains[p["test"].asString]?.let { " locationHint='file://${escape(it)}'" } ?: "" val out = StringBuilder("##teamcity[testStarted name='$name'$hint]\n") - val output = p["output"]?.asString ?: "" - if (output.isNotEmpty()) out.append("##teamcity[testStdOut name='$name' out='${escape(output)}']\n") + stdout(out, name, p) + val reason = p["reason"]?.asString?.ifBlank { null } when (p["status"]?.asString) { "passed" -> {} - "skipped" -> out.append("##teamcity[testIgnored name='$name']\n") - else -> out.append("##teamcity[testFailed name='$name' message='${escape(p["reason"]?.asString?.ifBlank { null } ?: p["status"].asString)}']\n") + "skipped" -> out.append("##teamcity[testIgnored name='$name'${reason?.let { " message='${escape(it)}'" } ?: ""}]\n") + else -> { + val message = when { + p["timedOut"]?.asBoolean == true -> "The test timed out" + p["compileFailed"]?.asBoolean == true -> "The test did not compile" + else -> reason ?: "The test failed (${p["status"].asString})" + } + failed(out, name, message, p, "") + } } out.append("##teamcity[testFinished name='$name' duration='${p["durationMs"]?.asLong ?: 0}']\n") notifyTextAvailable(out.toString(), ProcessOutputTypes.STDOUT) diff --git a/mide-plugins/nvim/lua/mcpp/commands.lua b/mide-plugins/nvim/lua/mcpp/commands.lua index 3f47dc0..a19aeba 100644 --- a/mide-plugins/nvim/lua/mcpp/commands.lua +++ b/mide-plugins/nvim/lua/mcpp/commands.lua @@ -6,6 +6,7 @@ local session = require('mcpp.session') local flow = require('mcpp.flow') local views = require('mcpp.views') local diagnostics = require('mcpp.diagnostics') +local tests = require('mcpp.tests') local M = {} local t = session.t @@ -57,10 +58,19 @@ local actions = {} function actions.job(row, args, root) local params = { kind = row.action.job, root = root } - if row.action.job == 'test' and args[1] then + local testing = row.action.job == 'test' + if testing and args[1] then params.filter = args[1] end - local done = views.job('job/start', params, row.action.job) + -- A test job: each result in mcpp://tests, its failures in the quickfix list, its signs (D16). + if testing then + tests.begin(root) + end + local done = views.job('job/start', params, row.action.job, testing and { test = tests.add } or nil) + if testing then + tests.finish() + tests.reveal() + end if done and row.action['then'] == 'languageService' then actions.languageService() end diff --git a/mide-plugins/nvim/lua/mcpp/init.lua b/mide-plugins/nvim/lua/mcpp/init.lua index 23d9033..32e1947 100644 --- a/mide-plugins/nvim/lua/mcpp/init.lua +++ b/mide-plugins/nvim/lua/mcpp/init.lua @@ -124,6 +124,33 @@ function M.setup(opts) end, }) vim.api.nvim_create_autocmd('User', { group = group, pattern = 'McppStatus', callback = on_started }) + -- The ▶ of a program's or a test's main and of each case, or its last result (D16). + vim.api.nvim_create_autocmd({ 'BufReadPost', 'BufWritePost' }, { + group = group, pattern = { '*.cpp', '*.cc', '*.cxx', '*.cppm', '*.ixx', '*.mpp' }, + callback = function(a) + require('mcpp.tests').signs(a.buf) + end, + }) + -- Once the backend is ready (McppStatus fires on every change of the statusline; the signs are + -- asked for when the session starts, and after a read or a write). + local signed = false + vim.api.nvim_create_autocmd('User', { + group = group, pattern = 'McppStatus', + callback = function() + if session.state.ready == signed then + return + end + signed = session.state.ready + if not signed then + return + end + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + require('mcpp.tests').signs(buf) + end + end + end, + }) -- mcppls attached: it serves build.mcpp now, and the backend leaves it what it answers better. vim.api.nvim_create_autocmd('LspAttach', { group = group, diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index 4edc941..a9b7b17 100644 --- a/mide-plugins/nvim/lua/mcpp/session.lua +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -208,6 +208,8 @@ local function deliver(owner, method, params) on.output(params.text or '') elseif method == 'job/started' and on.started then on.started(params) + elseif (method == 'test/result' or method == 'test/caseResult') and on.test then + on.test(method, params) elseif method == 'job/finished' then state.claimed[params.jobId] = nil owner.handle.done = params @@ -251,11 +253,12 @@ end local JOB_EVENTS = { ['job/started'] = true, ['job/output'] = true, ['job/plan'] = true, ['job/progress'] = true, ['job/diagnostic'] = true, ['job/artifact'] = true, ['job/record'] = true, ['job/finished'] = true, - ['test/result'] = true, ['test/summary'] = true, + ['test/result'] = true, ['test/summary'] = true, ['test/caseResult'] = true, } -- Inside a flow: asks for a job (`job/start`, or another method that answers a job id) and follows --- it. `events`: output(text), started(params); the handle's wait() answers its end. Returns the +-- it. `events`: output(text), started(params), test(method, result) for each `test/result` and +-- `test/caseResult`; the handle's wait() answers its end. Returns the -- handle, or nil and the error. function M.job(method, params, events) local result, err = M.call(method, params) diff --git a/mide-plugins/nvim/lua/mcpp/tests.lua b/mide-plugins/nvim/lua/mcpp/tests.lua new file mode 100644 index 0000000..09a0fdf --- /dev/null +++ b/mide-plugins/nvim/lua/mcpp/tests.lua @@ -0,0 +1,182 @@ +-- What `:Mcpp test` says about each test (design 2026-11-07, D16): a results buffer (`mcpp://tests`) +-- with one section per test and per case, its status and its whole output, passed or not; the +-- failures, at the place their output names, in the quickfix list; and in the sign column of a +-- test's source a ▶ on its `main` and each case, which a result turns into ✓ or ✗. A program's `main` +-- has its ▶ too. The places and the statuses are the backend's (`source/entries`, `test/cases`, +-- `test/result`, `test/caseResult`). +local session = require('mcpp.session') + +local M = {} +local t = session.t + +M.RESULTS = 'mcpp://tests' +local NS = vim.api.nvim_create_namespace('mcpp_tests') +local results_buf +-- The last result of each test and case, by `root\0test` and `root\0test\0case`. +local last = {} + +local SIGN = { entry = '▶', passed = '✓', failed = '✗', timedOut = '✗', errored = '✗', skipped = '–' } +local HL = { entry = 'DiagnosticInfo', passed = 'DiagnosticOk', failed = 'DiagnosticError', timedOut = 'DiagnosticError', + errored = 'DiagnosticError', skipped = 'DiagnosticHint' } + +local function results_buffer() + if results_buf and vim.api.nvim_buf_is_valid(results_buf) then + return results_buf + end + results_buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(results_buf, M.RESULTS) + vim.bo[results_buf].bufhidden = 'hide' + vim.bo[results_buf].filetype = 'markdown' + vim.bo[results_buf].modifiable = false + return results_buf +end + +local function write(lines) + local buf = results_buffer() + vim.bo[buf].modifiable = true + local count = vim.api.nvim_buf_line_count(buf) + local empty = count == 1 and (vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or '') == '' + vim.api.nvim_buf_set_lines(buf, empty and 0 or count, -1, false, lines) + vim.bo[buf].modifiable = false +end + +local function output_lines(text) + local lines = vim.split((text or ''):gsub('\r\n', '\n'):gsub('\27%[[%d;]*[A-Za-z]', ''), '\n', { plain = true }) + if lines[#lines] == '' then + table.remove(lines) + end + return lines +end + +local function status_text(r) + if r.status == 'timedOut' then + return t('timed out') + elseif r.compileFailed then + return t('did not compile') + elseif r.builtOnly then + return t('built, not run') + end + return r.status +end + +-- The file a result's location names: as printed, else beneath the project. +local function located(root, location) + if type(location) ~= 'table' or type(location.path) ~= 'string' then + return nil + end + local path = location.path + if not (path:match('^/') or path:match('^%a:[/\\]')) then + path = (root or '') .. '/' .. path + end + return { filename = vim.fs.normalize(path), lnum = (location.line or 0) + 1 } +end + +-- A new run: the buffer and the quickfix list start over. +function M.begin(root) + local buf = results_buffer() + vim.bo[buf].modifiable = true + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { '# ' .. t('mcpp test in {0}', root or '?') }) + vim.bo[buf].modifiable = false + M.failures = {} +end + +-- One result (`test/result` or `test/caseResult`) said in the buffer, kept for the signs, and its +-- failure put in the quickfix list. +function M.add(method, r) + local name = method == 'test/caseResult' and (r.test .. ' › ' .. r.case) or r.test + local key = (r.root or '') .. '\0' .. r.test .. (method == 'test/caseResult' and ('\0' .. r.case) or '') + last[key] = r.status + local lines = { '', string.format('## %s %s — %s (%d ms)', SIGN[r.status] or '?', name, status_text(r), r.durationMs or 0) } + if r.reason and r.reason ~= '' then + lines[#lines + 1] = r.reason + end + if method == 'test/caseResult' and r.message and r.message ~= '' and r.status == 'failed' then + for _, l in ipairs(output_lines(r.message)) do + lines[#lines + 1] = '> ' .. l + end + end + local out = output_lines(r.output) + if #out > 0 then + lines[#lines + 1] = '```' + vim.list_extend(lines, out) + lines[#lines + 1] = '```' + end + if r.outputTruncated then + lines[#lines + 1] = t('(the output was longer; its head and tail are shown)') + end + write(lines) + if r.status == 'failed' or r.status == 'timedOut' or r.status == 'errored' then + local item = located(r.root, r.location) + if item then + item.text = name .. ': ' .. status_text(r) + M.failures[#M.failures + 1] = item + end + end +end + +-- The run ended: the quickfix list holds its failures, and the signs show the results. +function M.finish() + vim.fn.setqflist({}, ' ', { title = 'mcpp test', items = M.failures or {} }) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + M.signs(buf) + end + end +end + +function M.reveal() + local buf = results_buffer() + if vim.fn.bufwinid(buf) == -1 then + local from = vim.api.nvim_get_current_win() + vim.cmd('botright 15split') + vim.api.nvim_win_set_buf(0, buf) + vim.api.nvim_set_current_win(from) + end +end + +local function place(buf, line, status, text) + pcall(vim.api.nvim_buf_set_extmark, buf, NS, line, 0, { + sign_text = SIGN[status] or SIGN.entry, sign_hl_group = HL[status] or HL.entry, priority = 20, + virt_text = text and { { text, 'Comment' } } or nil, virt_text_pos = text and 'eol' or nil, + }) +end + +-- The signs of a buffer: ▶ on a program's or a test's `main` and on each case, or the last result. +function M.signs(buf) + local name = vim.api.nvim_buf_get_name(buf) + if name == '' or vim.bo[buf].buftype ~= '' or not session.state.ready then + return + end + session.request('source/entries', { file = name }, function(err, entries) + if err or not entries or (entries.kind ~= 'program' and entries.kind ~= 'test') or not vim.api.nvim_buf_is_valid(buf) then + return + end + vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1) + local key = (entries.root or '') .. '\0' .. (entries.test or '') + if entries.main and entries.main.line then + place(buf, entries.main.line, entries.kind == 'test' and last[key] or 'entry') + end + if entries.kind ~= 'test' then + return + end + session.request('test/cases', { root = entries.root, test = entries.test }, function(cerr, cases) + if cerr or not cases or not vim.api.nvim_buf_is_valid(buf) then + return + end + for _, c in ipairs(cases.cases or {}) do + if c.line then + place(buf, c.line, last[key .. '\0' .. c.name] or 'entry') + end + end + end) + end) +end + +-- What the sign of a key would show: the last result, or ▶ (the tests). +function M.sign_of(root, test, case) + local status = last[(root or '') .. '\0' .. test .. (case and ('\0' .. case) or '')] + return SIGN[status or 'entry'] +end + +M.namespace = NS +return M diff --git a/mide-plugins/nvim/lua/mcpp/views.lua b/mide-plugins/nvim/lua/mcpp/views.lua index da4f101..436e21e 100644 --- a/mide-plugins/nvim/lua/mcpp/views.lua +++ b/mide-plugins/nvim/lua/mcpp/views.lua @@ -70,8 +70,9 @@ end -- Inside a flow: a job, its narration streamed into the output, its end said there and, when it -- did not succeed, as a notification. mcpp.task.clearTerminal and mcpp.task.revealTerminal apply --- as they do to mcpp-vscode's terminal. Returns `job/finished`, or nil when it did not start. -function M.job(method, params, what) +-- as they do to mcpp-vscode's terminal. `events.test(method, result)` is given each test result. +-- Returns `job/finished`, or nil when it did not start. +function M.job(method, params, what, events) if session.value('mcpp.task.clearTerminal') ~= false then M.clear() end @@ -85,6 +86,7 @@ function M.job(method, params, what) M.line('$ ' .. table.concat(p.argv or {}, ' ')) end, output = M.append, + test = events and events.test or nil, }) if not handle then M.line(t('mcpp: {0}', err.message)) diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index 37cf4a3..3da9610 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -195,6 +195,29 @@ check('its narration is streamed into mcpp://output', output():find('$ ', 1, tru check('its end is said there', output():find('mcpp build: ok', 1, true) ~= nil) check('the statusline shows the last build', mcppide.status():find('build ok', 1, true) ~= nil, mcppide.status()) +-- ── a test run (D16) ───────────────────────────────────────────────────────────────────────── +before = session.state.last +vim.cmd('Mcpp test') +done = finished('test', before) +check('a test run finishes ok', done and done.status == 'ok', done and done.status) +local tests_buf = vim.fn.bufnr('mcpp://tests') +local said = tests_buf ~= -1 and table.concat(vim.api.nvim_buf_get_lines(tests_buf, 0, -1, false), '\n') or '' +check('mcpp://tests has a section for the test, with its status and its output, though it passed', + said:find('test_smoke', 1, true) ~= nil and said:find('passed', 1, true) ~= nil and said:find('test_smoke: ok', 1, true) ~= nil, + said:sub(1, 300)) +check('a run without failures leaves the quickfix list empty', #vim.fn.getqflist() == 0, #vim.fn.getqflist()) +local tests_mod = require('mcpp.tests') +check('the sign of a test that passed is ✓', tests_mod.sign_of(root, 'test_smoke') == '✓', tests_mod.sign_of(root, 'test_smoke')) +-- A failure lands in the quickfix list at the place its output names. +tests_mod.begin(root) +tests_mod.add('test/result', { root = root, test = 't', status = 'failed', durationMs = 1, output = 'tests/t.cpp:3: failed\n', + location = { path = 'tests/t.cpp', line = 2 } }) +tests_mod.finish() +local qf_items = vim.fn.getqflist() +check('a failure is put in the quickfix list at its place', #qf_items == 1 and qf_items[1].lnum == 3 + and vim.api.nvim_buf_get_name(qf_items[1].bufnr):find('tests/t.cpp', 1, true) ~= nil, vim.inspect(qf_items[1])) +check('and its sign is ✗', tests_mod.sign_of(root, 't') == '✗') + -- ── diagnostics ─────────────────────────────────────────────────────────────────────────────── write('src/main.cpp', 'int main() { return undeclared_name; }\n') write('.mock-mcpp-fail', 'build\n') diff --git a/spec/i18n/runtime.zh-cn.json b/spec/i18n/runtime.zh-cn.json index e06e0ec..07d478b 100644 --- a/spec/i18n/runtime.zh-cn.json +++ b/spec/i18n/runtime.zh-cn.json @@ -808,5 +808,10 @@ "{0} days or more": "{0} 天及以上", "{0} in {1} entries": "{0},共 {1} 项", "{0} incomplete entries": "{0} 项不完整", - "{0} · {1} entries": "{0} · {1} 项" + "{0} · {1} entries": "{0} · {1} 项", + "timed out": "超时", + "did not compile": "没有编译通过", + "built, not run": "已构建,未运行", + "mcpp test in {0}": "{0} 中的 mcpp test", + "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)" } From 581c02014094fb6f64d39fd0a93a989ed2d5f997 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:51:25 +0900 Subject: [PATCH 07/31] build: the platform as the series ^0.24 in mcpp.lock, os ^0.3.1 (A4) The payloads' platform is openkal-platform@^0.24, its newest patch this mcpp admits, kept in mcpp.lock (0.24.2, whose C++ run-time keeps the volume of a normalized name); os is the compatible range ^0.3.1. Both need mcpp 2026.11.7.1. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- mcpp.lock | 36 ++++++++++++++++++------------------ mcpp.toml | 18 ++++++++++-------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/mcpp.lock b/mcpp.lock index b2d2dce..9c3b15a 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -30,9 +30,9 @@ targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.5.0" -source = "index+mcpplibs@0.5.0" -hash = "fnv1a:e71cc672802c1551" +version = "0.5.1" +source = "index+mcpplibs@0.5.1" +hash = "fnv1a:e71cc572802c139e" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] @@ -44,41 +44,41 @@ hash = "fnv1a:34912a331a4386f5" [package."openkal-llvm-runtime"] namespace = "mcpplibs" -version = "0.19.0" -source = "index+mcpplibs@0.19.0" -hash = "fnv1a:3e60b633793b7850" +version = "0.19.2" +source = "index+mcpplibs@0.19.2" +hash = "fnv1a:3e60b833793b7bb6" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-musl"] namespace = "mcpplibs" -version = "0.28.0" -source = "index+mcpplibs@0.28.0" -hash = "fnv1a:a96808df668801b3" +version = "0.28.1" +source = "index+mcpplibs@0.28.1" +hash = "fnv1a:a96807df66880000" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.24.0" -source = "index+mcpplibs@0.24.0" -hash = "fnv1a:8527602a77391921" +version = "0.24.2" +source = "index+mcpplibs@0.24.2" +hash = "fnv1a:85275e2a773915bb" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.5.0" -source = "index+mcpplibs@0.5.0" -hash = "fnv1a:17b68973cd803e4e" +version = "0.5.1" +source = "index+mcpplibs@0.5.1" +hash = "fnv1a:17b68a73cd804001" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."mcpplibs.os"] namespace = "mcpplibs" -version = "0.3.0" -source = "index+mcpplibs@0.3.0" -hash = "fnv1a:c8fc75a4fc0fce2a" +version = "0.3.1" +source = "index+mcpplibs@0.3.1" +hash = "fnv1a:c8fc76a4fc0fcfdd" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."nlohmann.json"] diff --git a/mcpp.toml b/mcpp.toml index d49ee24..f2c59d6 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -14,7 +14,7 @@ description = "The IDE backend for mcpp: sessions, jobs, records and view models license = "Apache-2.0" authors = ["mcpp-community"] repo = "https://github.com/mcpp-community/mcpp-ide" -mcpp = ">=2026.11.3.1" +mcpp = ">=2026.11.7.1" [language] standard = "c++23" @@ -33,11 +33,12 @@ toolchain = "llvm@23.1.3" [target.'cfg(windows)'] toolchain = "llvm@23.1.3" -# The editor payloads use the coherent OpenKal 0.24 source group: OS 0.3, -# platform 0.24, musl 0.28, runtime 0.19 and image/tools 0.5. The platform -# supplies the image toolchain, hardening, seal and runner. +# The platform of the editor payloads, as a series (design 2026-11-07, A4): its newest 0.24 patch +# this mcpp admits, kept in mcpp.lock until `mcpp update` (mcpp 2026.11.7.1, SPEC-013 §14). The +# platform supplies the image toolchain, hardening, seal and runner, and its version group names +# OpenKal 0.24, musl 0.28, runtime 0.19 and image/tools 0.5. [target.'cfg(os = "openkal")'] -platform = "openkal-platform@0.24.0" +platform = "openkal-platform@^0.24" # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] @@ -74,9 +75,10 @@ kind = "bin" main = "src/bin/dev.cpp" [dependencies.mcpplibs] -# OS 0.3 names OpenKal 0.24 and its matching per-system backend versions. -# The image C library, C++ runtime and loader remain the platform's. -os = "0.3.0" +# A compatible range: 0.3.1 reads a name in Windows' long spelling as the name it spells; the +# graph and the platform's version group choose the release. The image C library, C++ runtime and +# loader remain the platform's. +os = "^0.3.1" [dependencies.nlohmann] json = "3.12.0" From 29c0a13a536bf848c4508dca93e58dcbb3ebc90e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 11:55:17 +0900 Subject: [PATCH 08/31] docs: 1.2.0, the series rule, and the release by tag Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ README.md | 15 +++++++++++---- README.zh-CN.md | 6 +++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0f3b46..1c1f00e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,41 @@ # Changelog +The changelog is kept by series (`X.Y`); each section lists its components' releases. + +## 1.2 + +### 1.2.0 + +The first series of one version for every component: the backend, mcpp-vscode, mcpp-clion and +mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). + +- **One series.** A version is `X.Y.Z`; `X.Y` is shared by the backend and every plugin, `Z` is each + component's own patch. The backend states its series in the initialize answer, and each plugin + checks protocol 2 and its series and refuses another, naming both versions and the install that + matches. A series release is tagged `vX.Y.0`; `backend-`, `vscode-`, `clion-` and `nvim-vX.Y.Z` + publish one component's patch, and CI checks that every component states one series. +- **mcppls payloads by their specification.** The backend reads `payload.json` as mcppls's S6 states + it: version 5 (mcppls 0.0.20 and later) takes this system's entry under `platforms` and starts + ` `; version 4 serves the one `platform` it names. A payload that does not + serve this system is refused, naming the platforms it does serve. 0.2.1 ran a version 5 payload's + `bin/mcppls.okb` itself and failed. CLion makes every runner a payload names executable again. +- **Test results as mcpp states them.** Statuses follow mcpp's test records value for value: + `run_fail` is failed, or timed out when mcpp says so; `compile_fail` is failed and says it did not + compile; `not_run` is skipped with mcpp's reason; `built` is skipped as built only. 0.2.1 mapped + values mcpp never writes, so every failure was "errored" and a timeout was never seen. +- **Every test's own output.** Each test and each case carries its whole output, passed or not + (head and tail past 1 MiB), and a failure carries the place its output names. A case run divides + the program's output among its cases by GoogleTest's boundaries. VS Code says the output to the + test's own item and shows a failure at its place, else at the test's `main` or the case's macro; + CLion sends it to the test tree, with the place first in a failure's details; Neovim writes + `mcpp://tests`, one section per test and case, and puts the failures in the quickfix list. +- **Run entries with icons.** VS Code's lenses above `main` are `$(play) Run` and `$(debug-alt) + Debug`, and while the active editor is a target's entry the editor title's Run menu runs and + debugs it. Neovim's sign column shows ▶ on a program's and a test's `main` and on each case, and a + result turns it into ✓ or ✗. CLion logs why a file has no run entry. +- **The platform as a series.** The payloads' platform is `openkal-platform@^0.24`, kept in + `mcpp.lock` (0.24.2), and os is `^0.3.1`; mcpp 2026.11.7.1 or later builds them. + ## 0.2.1 mcpp-vscode 1.1.1, mcpp-clion 0.2.1 and mcpp-nvim 0.2.1. diff --git a/README.md b/README.md index f7652a6..88c68e8 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,16 @@ the editor I use (VS Code, CLion or Neovim) from its latest release, and check i | Agents, other clients | `mcpp-ide--openkal-bundle-all.tar.gz` (both images; okrun for Linux x64/arm64, macOS arm64, Windows x64), `mcpp-ide-.okb` with okrun, or `xlings install mcpp-ide` | run `bin/mcpp-ide serve` (`bin\mcpp-ide.exe` on Windows), or `okrun mcpp-ide-.okb serve` | Each editor package serves every system: it carries the backend as one ok-image bundle -(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.24.0) and okrun for +(`backend/mcpp-ide.okb`, the x86_64 and aarch64 images on openkal-platform 0.24) and okrun for Linux x64/arm64, macOS arm64 and Windows x64. The Neovim plugin carries none: it starts the `mcpp-ide` command xlings installs (okrun on the same bundle). VS Code and CLion check for the local build tool only when a user starts an action that needs it. + +**Versions.** A version is `X.Y.Z`. `X.Y`, the series, is shared by the backend and every plugin: a +plugin works with any backend of its series and refuses another, naming both versions and the +install that matches. `Z` is each component's own patch, so VS Code 1.2.3 and CLion 1.2.1 both work +with backend 1.2.0. A series release is tagged `vX.Y.0`; a component's patch is tagged +`backend-`, `vscode-`, `clion-` or `nvim-vX.Y.Z` and publishes that component alone. If `mcpp` is absent, they show one notification linking to the [mcpp repository](https://github.com/mcpp-community/mcpp). @@ -107,9 +113,10 @@ The backend is C++23 with modules, built by mcpp; the repository's tools are a p files are not committed. A change to what mcpp reports starts with `mcpp-ide-dev capture` and the diff in `protocol/fixtures`. -A release runs the repository CI, publishes the checked archives and -`SHA256SUMS` on GitHub, then independently submits the same VSIX to Open VSX -and the same CLion ZIP to JetBrains Marketplace. The JetBrains job checks the +A release runs the repository CI, publishes the checked archives of what its +tag names (`.github/scripts/release-plan.sh`) and `SHA256SUMS` on GitHub, then +independently submits the same VSIX to Open VSX and the same CLion ZIP to +JetBrains Marketplace when the release carries them. The JetBrains job checks the archive digest, plugin XML ID and version before upload and retains a receipt; Marketplace availability follows JetBrains review. `OPENVSX_TOKEN` and `JETBRAINS_TOKEN` are repository secrets. The Visual Studio Marketplace upload diff --git a/README.zh-CN.md b/README.zh-CN.md index 4be14c7..bf507b5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,12 +61,16 @@ CLion 的 **Settings | Tools | mcpp** 在保存表单前检查数值是否有限 | Neovim 0.10+ | release 源码树中的 `mide-plugins/nvim` | `xlings install mcpp-ide-nvim` 与 `xlings install mcpp-ide`,再 `require('mcpp').setup()`;或把该目录加入 runtimepath([其 README](mide-plugins/nvim/README.md)) | | Agent、其他客户端 | `mcpp-ide--openkal-bundle-all.tar.gz`(两个架构的映像;Linux x64/arm64、macOS arm64、Windows x64 的 okrun),携带 okrun 的 `mcpp-ide-.okb`,或 `xlings install mcpp-ide` | 运行 `bin/mcpp-ide serve`(Windows 上为 `bin\mcpp-ide.exe`),或 `okrun mcpp-ide-.okb serve` | -每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.24.0 上的 +每个编辑器包都服务全部系统:后端以一个 ok 映像包(`backend/mcpp-ide.okb`,openkal-platform 0.24 上的 x86_64 与 aarch64 映像)加上 Linux x64/arm64、macOS arm64、Windows x64 的 okrun 携带。Neovim 插件不携带后端: 它启动 xlings 安装的 `mcpp-ide` 命令(由 okrun 运行同一个映像包)。 VS Code 和 CLion 仅在用户主动执行依赖本地构建工具的操作时检查 `mcpp`;未安装时只提示一次,并提供 [mcpp 开源仓库](https://github.com/mcpp-community/mcpp)链接。 +**版本。** 版本为 `X.Y.Z`。`X.Y` 是系列,由后端与全部插件共享:插件与同系列的任何后端配合,遇到其他系列时拒绝, +并写出双方的版本与对应的安装方法。`Z` 是各组件自己的补丁,所以 VS Code 1.2.3 与 CLion 1.2.1 都与后端 1.2.0 +兼容。系列发布的标签为 `vX.Y.0`;组件补丁的标签为 `backend-`、`vscode-`、`clion-` 或 `nvim-vX.Y.Z`,只发布该组件。 + ### 从源码构建 ```bash From a6d4322621bed975733fd0b545710616f5b77b9e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 13:58:23 +0900 Subject: [PATCH 09/31] ci: the backend's tests run as ok images on every system The backend is distributed only as an ok image. backend-openkal builds its tests as ok images of each system's architecture and runs them through the runner; the native rows other than Linux x64 run with `native`. The mcppls status test names its mock server by a link on POSIX: an ok image creates no executable file. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 45 +++++++++++++++++++++++++++++++++++- CHANGELOG.md | 4 ++++ tests/test_mcppls_status.cpp | 8 ++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 193d15f..9e02d57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,19 @@ on: branches: [main] pull_request: workflow_dispatch: + inputs: + native: + description: "Also build and test the backend natively on every system (it is distributed only as an ok image)" + type: boolean + required: false + default: false workflow_call: + inputs: + native: + description: "Also build and test the backend natively on every system" + type: boolean + required: false + default: false concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -31,7 +43,7 @@ jobs: # universal self-contained package (both images; okrun for Linux, macOS and Windows, each built # over the platform's hosted arrangement) whose okrun the packages take. The `host-platform` row # builds and tests with the build programs (build.mcpp, the spec/ generators) as ok images run by - # okrun. + # okrun. The native rows of the other systems run only with `native`: what is distributed is the image. backend: name: backend (${{ matrix.platform }}${{ matrix.host-platform && format(', host platform {0}', matrix.host-platform) || '' }}) runs-on: ${{ matrix.os }} @@ -45,6 +57,9 @@ jobs: - { os: macos-15, platform: darwin-arm64, llvm: 'true' } - { os: windows-2022, platform: win32-x64, llvm: 'true' } - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', host-platform: 'openkal-platform@0.24.0' } + # The backend is distributed only as the ok image the `pack` leg packs, and `backend-openkal` tests that + # image on every system. The native backend of the other systems is built and tested in a run with `native`. + exclude: ${{ inputs.native && fromJSON('[]') || fromJSON('[{"platform":"linux-arm64"},{"platform":"darwin-arm64"},{"platform":"win32-x64"}]') }} defaults: run: shell: bash @@ -92,6 +107,34 @@ jobs: name: backend path: backend + # The backend as users run it, on each system the editors run on: its tests built as ok images of the + # system's architecture and run through the platform's runner. The mock mcpp, the mock mcppls and the dev + # tool they start are test tools, built natively first. + backend-openkal: + name: backend (${{ matrix.platform }}, openkal) + runs-on: ${{ matrix.os }} + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-24.04, platform: linux-x64, arch: x86_64, llvm: 'false' } + - { os: ubuntu-24.04-arm, platform: linux-arm64, arch: aarch64, llvm: 'false' } + - { os: macos-15, platform: darwin-arm64, arch: aarch64, llvm: 'true' } + - { os: windows-2022, platform: win32-x64, arch: x86_64, llvm: 'true' } + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + with: + llvm: ${{ matrix.llvm }} + - name: The test tools + run: mcpp build + - name: The tests, as ok images, through the runner + run: mcpp test --profile release --target ${{ matrix.arch }}-openkal + # The VS Code extension (mide-plugins/vscode), mcpp-vscode 0.9.0 over the backend. Its gates # (the registry, the translations, the unit suite) on the three desktop systems; the manifest # generated from spec/ with no drift, and the snapshots generated from mcpp's source; the parity diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c1f00e..da2dba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). result turns it into ✓ or ✗. CLion logs why a file has no run entry. - **The platform as a series.** The payloads' platform is `openkal-platform@^0.24`, kept in `mcpp.lock` (0.24.2), and os is `^0.3.1`; mcpp 2026.11.7.1 or later builds them. +- **Tested as it is run.** The backend is distributed only as an ok image, and CI tests it as one on + every system: its tests are built as ok images of the system's architecture and run through the + platform's runner (`backend-openkal`). The native backend is built and tested on Linux x64, and on + every system in a manual CI run with `native`. ## 0.2.1 diff --git a/tests/test_mcppls_status.cpp b/tests/test_mcppls_status.cpp index f56590b..c842163 100644 --- a/tests/test_mcppls_status.cpp +++ b/tests/test_mcppls_status.cpp @@ -8,6 +8,7 @@ import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.languageservice; +import mcpplibs.os; using namespace mcppide; using Json = nlohmann::json; @@ -72,7 +73,12 @@ int main() { const auto root = base / "project"; std::filesystem::create_directories(payload / "bin"); std::filesystem::create_directories(root); - std::filesystem::copy_file(mock, payload / "bin" / mock.filename()); + // The mock as the payload's server. An ok image creates no executable file (openkal states no + // permission bits), so where a program needs its executable bit the payload names it by a link. + if (mcpplibs::os::host() == mcpplibs::os::platform::windows) + std::filesystem::copy_file(mock, payload / "bin" / mock.filename()); + else + std::filesystem::create_symlink(mock, payload / "bin" / mock.filename()); std::ofstream(payload / "payload.json") << std::format(R"({{"server": {{"path": "bin/{}"}}}})", mock.filename().string()); std::ofstream(root / "mcpp.toml") << "[package]\nname = \"p\"\nversion = \"0.1.0\"\n"; From 46c48283dc75e8adef19214d072fd3d7ddba4410 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 15:12:03 +0900 Subject: [PATCH 10/31] ci: build with mcpp 2026.11.7.1 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/versions.env | 4 ++-- .xlings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/versions.env b/.github/versions.env index b2cf440..4d8a06b 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,12 +1,12 @@ # The versions CI builds and tests with, in one file; .github/actions/setup-mcpp exports them. # The published bootstrap; the project explicitly selects platform 0.24. -MCPP_VERSION=2026.11.5.1 +MCPP_VERSION=2026.11.7.1 # A ref of mcpp-community/mcpp CI builds mcpp from and uses instead, for a contract not yet released. # Empty: the release above is used. MCPP_SOURCE_REF= # The LLVM the backend builds with on macOS and Windows (the targets mcpp reaches there with it). LLVM_VERSION=23.1.3 -# The xlings mcpp 2026.11.5.1 bundles, which reads the current index. +# The xlings mcpp 2026.11.7.1 bundles, which reads the current index. XLINGS_VERSION=v2026.10.26.1 # The oldest VS Code the extension supports (engines.vscode), which its tests run in. VSCODE_VERSION=1.91.0 diff --git a/.xlings.json b/.xlings.json index f3a65ae..5e9f1aa 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.11.5.1" + "mcpp": "2026.11.7.1" } } From fd1be7699b983a986e070d4d7d41207a5522c475 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 16:30:37 +0900 Subject: [PATCH 11/31] feat: a Run builds through a launch job, with mcpp's narration and progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend's launch job builds a program as mcpp run does (with mcpp's launch-stream, its events) and ends with the launch in job/finished. CLion's Run and Debug, Neovim's run and VS Code's build for the debugger follow it: mcpp's own narration as it is written, the steps in the status bar, and no fixed 'building' line. CLion's tool window is on the left and its button is placed on the stripe once. Design 2026-11-07 §4.9 (D19). Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 8 + .../mcppcommunity/mcppclion/McppStartup.kt | 22 +++ .../mcppcommunity/mcppclion/McppStatusBar.kt | 10 +- .../mcppclion/run/McppDebugRunner.kt | 19 ++- .../mcppcommunity/mcppclion/run/McppStates.kt | 138 ++++++++++++++---- .../src/main/resources/META-INF/plugin.xml | 2 +- mide-plugins/nvim/README.md | 2 +- mide-plugins/nvim/lua/mcpp/commands.lua | 15 +- mide-plugins/nvim/lua/mcpp/init.lua | 7 + mide-plugins/nvim/lua/mcpp/session.lua | 8 + mide-plugins/nvim/lua/mcpp/views.lua | 2 +- mide-plugins/nvim/tests/smoke.lua | 15 +- mide-plugins/vscode/data/i18n/zh-cn.json | 3 +- .../vscode/l10n/bundle.l10n.zh-cn.json | 3 +- mide-plugins/vscode/src/extension.ts | 2 +- mide-plugins/vscode/src/ide/debug.ts | 29 +++- mide-plugins/vscode/src/ide/protocol.ts | 2 + protocol/protocol.schema.json | 10 +- src/bin/mockmcpp.cpp | 12 +- src/jobs.cppm | 5 +- src/server.cppm | 45 ++++-- tests/test_protocol.cpp | 32 ++++ 22 files changed, 324 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da2dba4..e1f8ef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,14 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). result turns it into ✓ or ✗. CLion logs why a file has no run entry. - **The platform as a series.** The payloads' platform is `openkal-platform@^0.24`, kept in `mcpp.lock` (0.24.2), and os is `^0.3.1`; mcpp 2026.11.7.1 or later builds them. +- **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's + own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, + and the build's steps in the status bar (CLion, VS Code while it builds for the debugger, the + Neovim statusline), as for a Build. With mcpp 2026.11.7.2 an unchanged program's launch is + described on mcpp's fast path, 0.01 s where it was 0.1 to 0.3 s, so the program starts at once. + CLion records how long the build and the program's start took in `idea.log`. +- **The mcpp tool window beside Project.** CLion's mcpp tool window is on the left, where VS Code + shows mcpp, and the first mcpp project opened puts its button on the stripe. - **Tested as it is run.** The backend is distributed only as an ok image, and CI tests it as one on every system: its tests are built as ok images of the system's architecture and run through the platform's runner (`backend-openkal`). The native backend is built and tested on Linux x64, and on diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt index 97654fe..93a35ff 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt @@ -3,19 +3,41 @@ // let CLion's compilation-database support take the project over from mcppls, is pointed out. package io.github.mcppcommunity.mcppclion +import com.intellij.ide.util.PropertiesComponent import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity +import com.intellij.openapi.wm.ToolWindowManager import io.github.mcppcommunity.mcppclion.actions.Dispatch import java.io.File +/** + * The mcpp tool window on the left stripe, beside Project, as VS Code's activity bar carries mcpp + * (design 2026-11-07 §4.9). The new UI puts a plugin's tool window under "More Tool Windows" until + * the user places it, so the first mcpp project opened places it once; what the user does with the + * button afterwards is kept. + */ +private fun showToolWindowButton(project: Project) { + val placed = "io.github.mcppcommunity.mcppclion.toolWindowPlaced" + val properties = PropertiesComponent.getInstance() + if (properties.getBoolean(placed)) return + ApplicationManager.getApplication().invokeLater { + if (project.isDisposed) return@invokeLater + val window = ToolWindowManager.getInstance(project).getToolWindow("mcpp") ?: return@invokeLater + window.isShowStripeButton = true + properties.setValue(placed, true) + } +} + class McppStartup : ProjectActivity { override suspend fun execute(project: Project) { val base = project.basePath ?: return if (!File(base, "mcpp.toml").isFile) return val session = McppSession.get(project) + showToolWindowButton(project) // mcpp.toml and build.mcpp reach the backend's rules layer as they are opened and edited. io.github.mcppcommunity.mcppclion.editor.McppDocuments.get(project).install() session.start().thenAccept { init -> diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt index 5628955..283bce3 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStatusBar.kt @@ -33,6 +33,7 @@ class McppStatusBarWidget(private val project: Project) : StatusBarWidget, Statu @Volatile private var version: String = "" @Volatile private var program: String = "" @Volatile private var running: String = "" + @Volatile private var kind: String = "" private val subscriptions = mutableListOf<() -> Unit>() override fun ID(): String = McppStatusBarFactory.ID @@ -41,7 +42,14 @@ class McppStatusBarWidget(private val project: Project) : StatusBarWidget, Statu override fun install(statusBar: StatusBar) { bar = statusBar val session = McppSession.get(project) - subscriptions += session.on("job/started") { p -> running = p.asJsonObject["kind"]?.asString ?: "job"; update() } + subscriptions += session.on("job/started") { p -> kind = p.asJsonObject["kind"]?.asString ?: "job"; running = kind; update() } + // The build's progress, as mcpp counts its steps (`job/progress`), beside the job that builds. + subscriptions += session.on("job/progress") { p -> + val o = p.asJsonObject + val done = o["finished"]?.takeIf { !it.isJsonNull }?.asInt + val total = o["total"]?.takeIf { !it.isJsonNull }?.asInt + if (done != null && total != null && running.isNotEmpty()) { running = "$kind $done/$total"; update() } + } subscriptions += session.on("job/finished") { running = ""; update() } subscriptions += session.on("project/mcppChanged") { ask() } ask() diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt index 5f9b736..4436f1f 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppDebugRunner.kt @@ -13,6 +13,7 @@ import com.intellij.execution.filters.TextConsoleBuilderFactory import com.intellij.execution.runners.ExecutionEnvironment import com.intellij.execution.runners.GenericProgramRunner import com.intellij.execution.ui.RunContentDescriptor +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.progress.ProgressManager import com.intellij.xdebugger.XDebugProcess import com.intellij.xdebugger.XDebugProcessStarter @@ -27,6 +28,7 @@ import com.jetbrains.cidr.execution.debugger.backend.DebuggerDriver import com.jetbrains.cidr.execution.debugger.backend.lldb.LLDBDriver import com.jetbrains.cidr.execution.debugger.backend.lldb.LLDBDriverConfiguration import io.github.mcppcommunity.mcppclion.McppSession +import java.util.concurrent.TimeUnit class McppDebugRunner : GenericProgramRunner() { override fun getRunnerId(): String = "McppDebugRunner" @@ -44,8 +46,21 @@ class McppDebugRunner : GenericProgramRunner() { // itself creates it. val driver = createDriverConfiguration(project, CPPEnvironment(toolchain)) val engine = if (driver is LLDBDriverConfiguration) "lldb" else "gdb" - val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously( - { launch(c, engine).get() }, session.t("mcpp: building for the debugger…"), true, project) + // The build first, as a launch job: mcpp's narration and its progress on the indicator; then + // the debugger's configuration of what it built, which mcpp describes on its fast path. + val launch = ProgressManager.getInstance().runProcessWithProgressSynchronously({ + val indicator = ProgressManager.getInstance().progressIndicator + val params = launchParams(c).apply { addProperty("kind", "launch"); addProperty("color", false) } + val job = FollowedJob(session, params, + { text -> text.lines().lastOrNull { it.isNotBlank() }?.let { indicator?.text2 = it.trim() } }, + { done, total -> indicator?.isIndeterminate = false; indicator?.fraction = done.toDouble() / total.coerceAtLeast(1) }).start() + while (!job.finished.isDone) { + if (indicator?.isCanceled == true) { job.cancel(); throw ProcessCanceledException() } + runCatching { job.finished.get(100, TimeUnit.MILLISECONDS) } + } + launchOf(job.finished.get(), session) + debugLaunch(c, engine).get() + }, session.t("mcpp: building for the debugger…"), true, project) val throughRunner = launch["via"]?.isJsonNull == false val parameters = TrivialRunParameters(driver, commandLine(launch, pty = false)) return XDebuggerManager.getInstance(project).newSessionBuilder(object : XDebugProcessStarter() { diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt index 7e1241a..d9395a4 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/run/McppStates.kt @@ -240,25 +240,96 @@ class McppTestState(private val c: McppRunConfiguration, env: ExecutionEnvironme } } -/** The backend's answer to "how does the program start" for a configuration (mcpp run --print-launch). */ -internal fun launch(c: McppRunConfiguration, debugEngine: String? = null): CompletableFuture { - val session = McppSession.get(c.project) - val params = JsonObject().apply { - addProperty("root", c.root) - if (c.options.target.isNotBlank()) addProperty("target", c.options.target) - if (c.options.profile.isNotBlank()) addProperty("profile", c.options.profile) - if (c.options.bin.isNotBlank()) addProperty("bin", c.options.bin) - add("args", JsonArray().apply { c.programArgs.forEach { add(it) } }) - if (debugEngine != null) addProperty("engine", debugEngine) +/** The parameters of the launch of a configuration: its program, its arguments, and the debugger's engine when debugging. */ +internal fun launchParams(c: McppRunConfiguration, debugEngine: String? = null): JsonObject = JsonObject().apply { + addProperty("root", c.root) + if (c.options.target.isNotBlank()) addProperty("target", c.options.target) + if (c.options.profile.isNotBlank()) addProperty("profile", c.options.profile) + if (c.options.bin.isNotBlank()) addProperty("bin", c.options.bin) + add("args", JsonArray().apply { c.programArgs.forEach { add(it) } }) + if (debugEngine != null) addProperty("engine", debugEngine) +} + +/** + * A backend job followed to its end (design 2026-11-07 §4.9): mcpp's narration and its progress as + * they arrive, then the job's `job/finished`. A launch job (`kind: launch`) builds a program as + * `mcpp run` does and ends with the program's launch (`launch`), so a Run shows the build as the + * terminal shows it rather than a fixed line, and the program starts the moment it is built. + */ +internal class FollowedJob( + private val session: McppSession, + private val params: JsonObject, + private val onOutput: (String) -> Unit, + private val onProgress: (Int, Int) -> Unit = { _, _ -> }, +) { + val finished = CompletableFuture() + @Volatile var jobId: String? = null + private set + private val early = mutableListOf>() + private val subscriptions = mutableListOf<() -> Unit>() + + fun start(): FollowedJob { + for (m in listOf("job/output", "job/progress", "job/finished")) + subscriptions += session.on(m) { p -> route(m, p.asJsonObject) } + val requested = System.nanoTime() + session.ensureMcppForAction().thenCompose { ready -> + if (ready) session.request("job/start", params) + else CompletableFuture.failedFuture(IllegalStateException("mcpp is not installed")) + }.whenComplete { r, e -> + if (e != null) { close(); finished.completeExceptionally(e.cause ?: e); return@whenComplete } + val id = r.asJsonObject["jobId"].asString + LOG.info("mcpp ${params["kind"]?.asString} job $id accepted in ${(System.nanoTime() - requested) / 1_000_000} ms") + val replay: List> + synchronized(early) { jobId = id; replay = early.filter { it.second["jobId"]?.asString == id }; early.clear() } + replay.forEach { (m, p) -> handle(m, p) } + } + return this + } + + fun cancel() { + val id = jobId ?: run { close(); finished.cancel(false); return } + session.request("job/cancel", JsonObject().apply { addProperty("jobId", id) }) + } + + private fun route(method: String, p: JsonObject) { + synchronized(early) { if (jobId == null) { early += method to p; return } } + if (p["jobId"]?.asString == jobId) handle(method, p) + } + + private fun handle(method: String, p: JsonObject) { + when (method) { + "job/output" -> onOutput(p["text"].asString) + "job/progress" -> { + val done = p["finished"]?.takeIf { !it.isJsonNull }?.asInt + val total = p["total"]?.takeIf { !it.isJsonNull }?.asInt + if (done != null && total != null) onProgress(done, total) + } + "job/finished" -> { close(); finished.complete(p) } + } + } + + private fun close() { + subscriptions.forEach { it() } + subscriptions.clear() } - return session.ensureMcppForAction().thenCompose { ready -> - if (ready) session.request(if (debugEngine == null) "run/launch" else "debug/configuration", params) - else CompletableFuture.failedFuture(IllegalStateException("mcpp is not installed")) - }.thenApply { - if (debugEngine == null) it.asJsonObject else it.asJsonObject.getAsJsonObject("launch") + + companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(FollowedJob::class.java) } } +/** The launch of a finished launch job, or why there is none: the status and mcpp's diagnostics. */ +internal fun launchOf(finished: JsonObject, session: McppSession): JsonObject { + val launch = finished["launch"]?.takeIf { it.isJsonObject }?.asJsonObject + if (finished["status"]?.asString == "ok" && launch != null) return launch + val messages = finished.getAsJsonArray("diagnostics")?.mapNotNull { it.asJsonObject["message"]?.asString }.orEmpty() + throw IllegalStateException(messages.firstOrNull() ?: session.t("the program could not be built")) +} + +/** The debugger's configuration of the program a launch job built (debug/configuration; mcpp takes its fast path). */ +internal fun debugLaunch(c: McppRunConfiguration, engine: String): CompletableFuture = + McppSession.get(c.project).request("debug/configuration", launchParams(c, engine)).thenApply { it.asJsonObject.getAsJsonObject("launch") } + internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { val argv = l.getAsJsonArray("argv").map { it.asString } val cmd = if (pty) PtyCommandLine(argv) else GeneralCommandLine(argv) @@ -269,26 +340,30 @@ internal fun commandLine(l: JsonObject, pty: Boolean): GeneralCommandLine { } /** - * The program: built and described by the backend, then started in a PTY so it reads input and - * colours its output as in a terminal. The build happens before the program's own process exists, - * so this handler stands for both. + * The program: built by a launch job, whose narration is this console's output as it is written, + * then started in a PTY so it reads input and colours its output as in a terminal. The build happens + * before the program's own process exists, so this handler stands for both. */ -class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHandler() { +class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHandler(), AnsiEscapeDecoder.ColoredTextAcceptor { @Volatile private var inner: ProcessHandler? = null @Volatile private var cancelled = false + @Volatile private var job: FollowedJob? = null + private val decoder = AnsiEscapeDecoder() + + override fun coloredTextAvailable(text: String, attributes: Key<*>) = notifyTextAvailable(text, attributes) override fun startNotify() { super.startNotify() val session = McppSession.get(c.project) - notifyTextAvailable("${session.t("mcpp: building…")}\n", ProcessOutputTypes.SYSTEM) - launch(c).whenComplete { l, e -> - if (e != null) { - val cause = e.cause ?: e - val narration = (cause as? io.github.mcppcommunity.mcppclion.backend.BackendError)?.data?.takeIf { it.isJsonObject } - ?.asJsonObject?.get("narration")?.asString - if (narration != null) notifyTextAvailable(narration, ProcessOutputTypes.STDERR) - notifyTextAvailable("${cause.message}\n", ProcessOutputTypes.STDERR) - notifyProcessTerminated(1) + val started = System.nanoTime() + val params = launchParams(c).apply { addProperty("kind", "launch"); addProperty("color", true) } + val followed = FollowedJob(session, params, { text -> decoder.escapeText(text.replace("\r\n", "\n"), ProcessOutputTypes.SYSTEM, this) }) + job = followed + followed.start().finished.whenComplete { finished, e -> + val built = System.nanoTime() + val l = runCatching { if (e != null) throw (e.cause ?: e) else launchOf(finished, session) }.getOrElse { + notifyTextAvailable("${it.message}\n", ProcessOutputTypes.STDERR) + notifyProcessTerminated(finished?.get("exitCode")?.takeIf { x -> !x.isJsonNull }?.asInt?.takeIf { x -> x != 0 } ?: 1) return@whenComplete } if (cancelled) { notifyProcessTerminated(130); return@whenComplete } @@ -303,12 +378,13 @@ class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHand }) inner = handler handler.startNotify() + LOG.info("mcpp run: built in ${(built - started) / 1_000_000} ms, program started ${(System.nanoTime() - built) / 1_000_000} ms later") } } override fun destroyProcessImpl() { cancelled = true - inner?.destroyProcess() ?: notifyProcessTerminated(130) + inner?.destroyProcess() ?: run { job?.cancel(); notifyProcessTerminated(130) } } override fun detachProcessImpl() { @@ -318,6 +394,10 @@ class LaunchingProcessHandler(private val c: McppRunConfiguration) : ProcessHand override fun detachIsDefault(): Boolean = false override fun getProcessInput(): OutputStream? = inner?.processInput + + companion object { + private val LOG = com.intellij.openapi.diagnostic.Logger.getInstance(LaunchingProcessHandler::class.java) + } } class McppProgramState(private val c: McppRunConfiguration, env: ExecutionEnvironment) : CommandLineState(env) { diff --git a/mide-plugins/clion/src/main/resources/META-INF/plugin.xml b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml index 8a73fde..e82a0b2 100644 --- a/mide-plugins/clion/src/main/resources/META-INF/plugin.xml +++ b/mide-plugins/clion/src/main/resources/META-INF/plugin.xml @@ -36,7 +36,7 @@ - diff --git a/mide-plugins/nvim/README.md b/mide-plugins/nvim/README.md index 6e4a44c..bbbd6c8 100644 --- a/mide-plugins/nvim/README.md +++ b/mide-plugins/nvim/README.md @@ -96,7 +96,7 @@ mapped onto Neovim: | Action | In Neovim | Subcommands | |---|---|---| | job, rpc | mcpp's narration streamed into `mcpp://output`, its end said there and, when it fails, as a notification | `build`, `test [filter]`, `autoConfigureModules`, `refreshCompilationDatabase`, `library.updateIndex` | -| run | `run/launch` builds; its `argv`, `cwd` and `env` start in a terminal buffer | `run [bin]` | +| run | a launch job builds, its narration in `mcpp://output` and its progress in the statusline; the launch's `argv`, `cwd` and `env` start in a terminal buffer | `run [bin]` | | debug | `debug/configuration` handed to nvim-dap; offered only when nvim-dap is loaded | `debug [bin]` | | cache | the backend's plan, confirmed at the level it asks for (`vim.ui.select`), applied as a job | `clean`, `cleanStaleArtifacts`, `cleanProjectArtifacts`, `gcGlobalCache`, `pruneGlobalCache`, `verifyGlobalCache`, `cleanLegacyCache` | | panel | the view model as text in a floating window, or as choices | `showTargets`, `showToolchains`, `showCachePanel`, `refreshCacheStats`, `project.showDependencyGraph`, `project.showModuleDependencyGraph`; `library.search [keyword]` (choose a package and a version: `mcpp add`); `pack` (format, mode, linkage: `mcpp pack`) | diff --git a/mide-plugins/nvim/lua/mcpp/commands.lua b/mide-plugins/nvim/lua/mcpp/commands.lua index a19aeba..bf143cd 100644 --- a/mide-plugins/nvim/lua/mcpp/commands.lua +++ b/mide-plugins/nvim/lua/mcpp/commands.lua @@ -76,17 +76,14 @@ function actions.job(row, args, root) end end +-- The program, built by a launch job (design 2026-11-07 §4.9): mcpp's narration in mcpp://output as +-- it is written and its progress in the statusline, then the program in a terminal of its own. function actions.run(_, args, root) - vim.api.nvim_echo({ { t('mcpp: building…') } }, false, {}) - local launch, err = session.call('run/launch', { root = root, args = session.value('mcpp.task.runArgs') or {}, bin = args[1] }) - if err then - if err.data and err.data.narration then - views.append(err.data.narration) - views.reveal() - end - return report(err) + local params = { kind = 'launch', root = root, args = session.value('mcpp.task.runArgs') or {}, bin = args[1] } + local done = views.job('job/start', params, 'run') + if done and done.status == 'ok' and type(done.launch) == 'table' then + views.terminal(done.launch) end - views.terminal(launch) end function actions.debug(_, args, root) diff --git a/mide-plugins/nvim/lua/mcpp/init.lua b/mide-plugins/nvim/lua/mcpp/init.lua index 32e1947..edea0e2 100644 --- a/mide-plugins/nvim/lua/mcpp/init.lua +++ b/mide-plugins/nvim/lua/mcpp/init.lua @@ -190,6 +190,13 @@ function M.status() end local text = 'mcpp ' .. s.target if s.running > 0 then + -- A build reports its steps: the job and its count, as mcpp counts them. + for id, p in pairs(session.state.progress) do + local job = session.state.running[id] + if job then + return text .. ' · ' .. (job.kind or '') .. ' ' .. p[1] .. '/' .. p[2] + end + end return text .. ' · ' .. t('{0} job(s) running', s.running) end if s.last then diff --git a/mide-plugins/nvim/lua/mcpp/session.lua b/mide-plugins/nvim/lua/mcpp/session.lua index a9b7b17..ace26d3 100644 --- a/mide-plugins/nvim/lua/mcpp/session.lua +++ b/mide-plugins/nvim/lua/mcpp/session.lua @@ -39,6 +39,8 @@ local state = { claimed = {}, unclaimed = {}, running = {}, + -- The build's progress of each running job (`job/progress`): { finished, total }. + progress = {}, last = nil, plans = {}, stopping = false, @@ -227,8 +229,14 @@ local function route(method, params) if method == 'job/started' then state.running[id] = params changed() + elseif method == 'job/progress' then + if params.finished and params.total then + state.progress[id] = { params.finished, params.total } + changed() + end elseif method == 'job/finished' then state.running[id] = nil + state.progress[id] = nil state.last = params changed() elseif method == 'job/plan' then diff --git a/mide-plugins/nvim/lua/mcpp/views.lua b/mide-plugins/nvim/lua/mcpp/views.lua index 436e21e..0c0d9b7 100644 --- a/mide-plugins/nvim/lua/mcpp/views.lua +++ b/mide-plugins/nvim/lua/mcpp/views.lua @@ -108,7 +108,7 @@ function M.job(method, params, what, events) return done end --- The program `run/launch` describes, in a terminal buffer of its own (argv, its working directory, +-- The program a launch job built (its `launch`), in a terminal buffer of its own (argv, its working directory, -- the environment mcpp adds), where it reads its input. function M.terminal(launch) local from = vim.api.nvim_get_current_win() diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index 3da9610..54b054c 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -186,8 +186,10 @@ end local before = session.state.last vim.cmd('Mcpp build') -check('a build runs (the statusline says so)', vim.wait(10000, function() return mcppide.status():find('1 job', 1, true) ~= nil end, 20), - mcppide.status()) +check('a build runs (the statusline says so)', vim.wait(10000, function() + local said = mcppide.status() + return said:find('1 job', 1, true) ~= nil or said:find('build %d+/%d+') ~= nil +end, 20), mcppide.status()) local done = finished('build', before) check('the build finishes ok', done and done.status == 'ok', done and done.status) check('its narration is streamed into mcpp://output', output():find('$ ', 1, true) and output():find('build', 1, true) @@ -218,6 +220,15 @@ check('a failure is put in the quickfix list at its place', #qf_items == 1 and q and vim.api.nvim_buf_get_name(qf_items[1].bufnr):find('tests/t.cpp', 1, true) ~= nil, vim.inspect(qf_items[1])) check('and its sign is ✗', tests_mod.sign_of(root, 't') == '✗') +-- ── a run (D19): a launch job builds as mcpp run does, its narration in mcpp://output ──────────── +before = session.state.last +vim.cmd('Mcpp run') +done = finished('launch', before) +check('a run is a launch job that ends with the launch', done and done.status == 'ok' and type(done.launch) == 'table' + and type(done.launch.argv) == 'table' and #done.launch.argv > 0, vim.inspect(done and done.launch)) +check("the launch job's narration is in mcpp://output", output():find('Finished', 1, true) ~= nil, output():sub(-300)) +pcall(vim.cmd, 'silent! only') + -- ── diagnostics ─────────────────────────────────────────────────────────────────────────────── write('src/main.cpp', 'int main() { return undeclared_name; }\n') write('.mock-mcpp-fail', 'build\n') diff --git a/mide-plugins/vscode/data/i18n/zh-cn.json b/mide-plugins/vscode/data/i18n/zh-cn.json index d2cff01..413475b 100644 --- a/mide-plugins/vscode/data/i18n/zh-cn.json +++ b/mide-plugins/vscode/data/i18n/zh-cn.json @@ -664,5 +664,6 @@ "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", "The test timed out.": "测试超时。", - "The test did not compile.": "测试没有编译通过。" + "The test did not compile.": "测试没有编译通过。", + "the program could not be built": "程序未能构建" } diff --git a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json index d2cff01..413475b 100644 --- a/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json +++ b/mide-plugins/vscode/l10n/bundle.l10n.zh-cn.json @@ -664,5 +664,6 @@ "The mcpp-ide backend {0} is of series {1}, and mcpp-vscode {2} of series {3}. Install the mcpp-ide backend {3} (`xlings install mcpp-ide@{3}.0`), or clear mcpp.ide.backendPath to use the bundled backend.": "mcpp-ide 后端 {0} 属于系列 {1},mcpp-vscode {2} 属于系列 {3}。请安装 mcpp-ide 后端 {3}(`xlings install mcpp-ide@{3}.0`),或清空 mcpp.ide.backendPath 以使用内置后端。", "(the output was longer; its head and tail are shown)": "(输出更长,只显示开头和结尾)", "The test timed out.": "测试超时。", - "The test did not compile.": "测试没有编译通过。" + "The test did not compile.": "测试没有编译通过。", + "the program could not be built": "程序未能构建" } diff --git a/mide-plugins/vscode/src/extension.ts b/mide-plugins/vscode/src/extension.ts index c207742..d4a455b 100644 --- a/mide-plugins/vscode/src/extension.ts +++ b/mide-plugins/vscode/src/extension.ts @@ -346,7 +346,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push( session, jobs, - new Debugging(session, ideProjects, ensureMcppForAction), + new Debugging(session, ideProjects, ensureMcppForAction, jobs), testing, new McppCodeLens(session, testing, (root) => projectMcpp.get(root)), jobs.onDidChangeRunning.event((n) => void vscode.commands.executeCommand("setContext", "mcpp.jobRunning", n > 0)), diff --git a/mide-plugins/vscode/src/ide/debug.ts b/mide-plugins/vscode/src/ide/debug.ts index 355ef1a..a23d8e1 100644 --- a/mide-plugins/vscode/src/ide/debug.ts +++ b/mide-plugins/vscode/src/ide/debug.ts @@ -9,13 +9,14 @@ import * as vscode from "vscode"; import { t } from "../i18n/t"; import { availableDebuggers } from "./mapping"; import type { IdeProjects } from "./projects"; +import type { Jobs } from "./jobs"; import type { Session } from "./session"; export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; constructor(private readonly session: Session, private readonly projects: IdeProjects, - private readonly ensureMcpp: () => Promise) { + private readonly ensureMcpp: () => Promise, private readonly jobs?: Jobs) { this.disposables.push( vscode.debug.registerDebugConfigurationProvider("mcpp", this), vscode.debug.registerDebugConfigurationProvider("mcpp", { provideDebugConfigurations: () => [{ type: "mcpp", request: "launch", name: "mcpp: debug" }] }, @@ -43,7 +44,28 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp try { const answer = await vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: t("mcpp: building for the debugger…") }, - () => this.session.request<{ engine: string; configuration: vscode.DebugConfiguration & { mcppNote?: string } }>("debug/configuration", { + async (progress) => { + // The build first, as a launch job (design 2026-11-07 §4.9): mcpp's narration in the output and + // its steps on the progress; then the debugger's configuration, which mcpp describes on its fast path. + if (this.jobs && !config.test) { + let reported = 0; + const job = await this.jobs.start({ + kind: "launch", root: project.root, + ...(config.target !== undefined ? { target: config.target } : {}), + ...(config.profile ? { profile: config.profile } : {}), + args: Array.isArray(config.args) ? config.args : [], color: false, + }, { + output: (text) => this.session.output.append(text), + progress: ({ finished, total }) => { + const now = total > 0 ? Math.floor((finished / total) * 100) : 0; + progress.report({ message: `${finished}/${total}`, increment: Math.max(0, now - reported) }); + reported = Math.max(reported, now); + }, + }); + const built = await job.done; + if (built.status !== "ok") throw new Error(t("the program could not be built")); + } + return this.session.request<{ engine: string; configuration: vscode.DebugConfiguration & { mcppNote?: string } }>("debug/configuration", { root: project.root, // A test's program (mcpp test --print-launch), where the project's mcpp describes one. ...(config.test ? { test: config.test } : {}), @@ -54,7 +76,8 @@ export class Debugging implements vscode.DebugConfigurationProvider, vscode.Disp args: Array.isArray(config.args) ? config.args : [], available, platform: process.platform === "darwin" ? "macos" : process.platform === "win32" ? "windows" : "linux", - })); + }); + }); const { mcppNote, ...resolved } = answer.configuration; if (mcppNote) this.session.output.appendLine(`mcpp: ${mcppNote}`); return Object.assign({}, resolved, { name: config.name || resolved.name }); diff --git a/mide-plugins/vscode/src/ide/protocol.ts b/mide-plugins/vscode/src/ide/protocol.ts index 2cbe24d..26037e5 100644 --- a/mide-plugins/vscode/src/ide/protocol.ts +++ b/mide-plugins/vscode/src/ide/protocol.ts @@ -168,6 +168,8 @@ export interface JobFinished { envelope?: { kind?: string; data?: unknown }; diagnostics?: Diagnostic[]; artifacts?: { kind: string; name: string; path: string }[]; + /** A launch job's program, described (mcpp's `mcpp.launch` data). */ + launch?: { argv: string[]; cwd: string; env?: Record; program?: string }; } /** `source/entries` (design 2026-10-30 part 2 §6.3): what a C++ file is to its project. */ diff --git a/protocol/protocol.schema.json b/protocol/protocol.schema.json index 84417fc..a951cca 100644 --- a/protocol/protocol.schema.json +++ b/protocol/protocol.schema.json @@ -167,13 +167,16 @@ "result": { "type": ["object", "null"] } }, "mcpp-ide/job/start": { - "description": "Queues a job on its project's lane: one at a time per project, in order; a build or test asked for again replaces the waiting one, and `supersede` cancels the running one.", + "description": "Queues a job on its project's lane: one at a time per project, in order; a build or test asked for again replaces the waiting one, and `supersede` cancels the running one. A `launch` job builds a program as mcpp run does (`mcpp run --print-launch`, with mcpp's launch-stream its build events), its program arguments in `args`, and ends with the program's launch in job/finished.", "params": { "type": "object", "required": ["kind", "root"], "properties": { - "kind": { "enum": ["build", "test", "pack", "toolchain", "add", "command"] }, + "kind": { "enum": ["build", "test", "pack", "toolchain", "add", "launch", "command"] }, "root": { "type": "string" }, + "bin": { "type": "string", "description": "A launch job's program; absent: the project's one." }, + "test": { "type": "string", "description": "A launch job's test (mcpp test --print-launch)." }, + "runner": { "type": "string", "description": "A launch job's named runner." }, "target": { "type": "string", "description": "The target triple; absent: the mcpp.build.target setting; empty: this machine." }, "profile": { "enum": ["dev", "release"] }, "filter": { "type": "string" }, @@ -436,7 +439,8 @@ "kind": { "type": "string" }, "envelope": { "type": "object" }, "diagnostics": { "type": "array", "items": { "$ref": "#/$defs/diagnostic" } }, - "artifacts": { "type": "array" } + "artifacts": { "type": "array" }, + "launch": { "type": "object", "description": "A launch job's program, described: the data of mcpp's mcpp.launch." } } } }, diff --git a/src/bin/mockmcpp.cpp b/src/bin/mockmcpp.cpp index c88e113..9b3912d 100644 --- a/src/bin/mockmcpp.cpp +++ b/src/bin/mockmcpp.cpp @@ -176,7 +176,17 @@ int main(int argc, char** argv) { // A second launch can deliberately return a different configuration. if (has("--runner")) if (auto selected = here_file(".mock-mcpp-runner-launch"); !selected.empty()) text = selected; - out(text.empty() ? fixture("launch.json") : text); + if (text.empty()) text = fixture("launch.json"); + // `--message-format json` (mcpp's launch-stream): the build's events, then the envelope as the last line. + if (has("--message-format")) { + std::string line; + for (char c : text) if (c != '\n' && c != '\r') line += c; + out("{\"schemaVersion\":1,\"kind\":\"mcpp.event\",\"event\":\"progress\",\"finished\":1,\"total\":2}\n" + "{\"schemaVersion\":1,\"kind\":\"mcpp.event\",\"event\":\"progress\",\"finished\":2,\"total\":2}\n" + line + "\n"); + } else { + out(text); + } + err(" Compiling app v0.1.0 (.)\n Finished dev [unoptimized + debuginfo] in 0.01s\n"); return 0; } if (first(0) == "test") { out(fixture(has("--list") ? "test-list.ndjson" : "test.ndjson")); return 0; } diff --git a/src/jobs.cppm b/src/jobs.cppm index 69211fb..ad0aa46 100644 --- a/src/jobs.cppm +++ b/src/jobs.cppm @@ -23,7 +23,7 @@ export namespace mcppide::jobs { using Json = nlohmann::json; -enum class Kind { build, test, pack, clean, toolchain, add, command }; +enum class Kind { build, test, pack, clean, toolchain, add, launch, command }; // What standard output carries: narration for a person, NDJSON records (a // `--message-format json` stream), or one envelope printed at the end. @@ -109,13 +109,14 @@ std::string_view kind_name(Kind k) { case Kind::clean: return "clean"; case Kind::toolchain: return "toolchain"; case Kind::add: return "add"; + case Kind::launch: return "launch"; case Kind::command: return "command"; } return "command"; } std::optional kind_from(std::string_view s) { - for (auto k : {Kind::build, Kind::test, Kind::pack, Kind::clean, Kind::toolchain, Kind::add, Kind::command}) + for (auto k : {Kind::build, Kind::test, Kind::pack, Kind::clean, Kind::toolchain, Kind::add, Kind::launch, Kind::command}) if (kind_name(k) == s) return k; return std::nullopt; } diff --git a/src/server.cppm b/src/server.cppm index 43f3be9..e409489 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -132,6 +132,7 @@ private: mcpp::ProjectMcpp probe_of(const std::filesystem::path& root); Json probe_json(const std::filesystem::path& root, const mcpp::ProjectMcpp& probe) const; bool has_feature(const std::filesystem::path& root, std::string_view feature); + std::vector launch_args(const Json& p, bool stream, bool programArgs); // A line of the backend's log, at a level (0 error, 1 warning, 2 info, 3 debug): written to // standard error, which every editor keeps in its log, when mcpp.backend.logLevel lets it // through, and kept for the IDE report either way (§3.2). @@ -240,6 +241,9 @@ Session::Session(rpc::Server& server) : rpc_(server) { const auto& env = summary["envelope"]; if (env.is_object() && env.contains("data") && env["data"].is_object() && env["data"].contains("artifacts")) s["artifacts"] = env["data"]["artifacts"]; + // A launch job ends with the program it built, described (`mcpp.launch`). + if (env.is_object() && env.value("kind", "") == "mcpp.launch" && env.contains("data")) + s["launch"] = env["data"]; } std::filesystem::path root; { @@ -728,11 +732,20 @@ std::expected Session::job_start(const Json& p, std::function< r.args = {"add", dep}; break; } + case jobs::Kind::launch: { + // A Run's or a Debug's build, reported as it goes: mcpp's narration, and with + // `launch-stream` its events, then the launch in `job/finished` (design 2026-11-07 §4.9). + const bool stream = p.value("test", "").empty() && has_feature(root, "launch-stream"); + r.args = launch_args(p, stream, true); + r.mode = stream ? jobs::Mode::ndjson : jobs::Mode::envelope; + break; + } case jobs::Kind::command: r.args = {}; break; } - for (const auto& a : extra) r.args.push_back(a); + if (*kind != jobs::Kind::launch) + for (const auto& a : extra) r.args.push_back(a); if (r.args.empty()) return std::unexpected(invalid("a command job names its arguments")); r.after = std::move(after); const auto id = jobs_->submit(std::move(r)); @@ -740,13 +753,17 @@ std::expected Session::job_start(const Json& p, std::function< return Json{{"jobId", id}}; } -std::expected Session::run_launch(const Json& p, const rpc::Context& ctx) { - const auto root = root_of(p); - if (root.empty()) return std::unexpected(invalid("run/launch names its project's root")); - // A test's program is described by `mcpp test --print-launch` (mcpp's `test-launch`), - // with its framework's filter after `--` when cases are asked for. +// The mcpp command that builds a program and describes its launch: `mcpp run --print-launch`, or +// `mcpp test --print-launch` for a test (mcpp's `test-launch`), with its framework's filter +// after `--` when cases are asked for. `stream` asks a run for the build's events before the +// envelope (mcpp's `launch-stream`), and `programArgs` are the program's own arguments, after `--`. +std::vector Session::launch_args(const Json& p, bool stream, bool programArgs) { const auto test = p.value("test", ""); std::vector args{test.empty() ? "run" : "test", "--print-launch"}; + if (stream && test.empty()) { + args.push_back("--message-format"); + args.push_back("json"); + } const auto profile = p.value("profile", setting("mcpp.build.profile").is_string() ? setting("mcpp.build.profile").get() : "dev"); if (profile == "release") args.push_back("--release"); else if (!profile.empty() && profile != "dev") { @@ -765,10 +782,20 @@ std::expected Session::run_launch(const Json& p, const rpc::Co args.push_back("--"); for (const auto& a : filter) args.push_back(a); } - } else if (auto bin = p.value("bin", ""); !bin.empty()) { - args.push_back(bin); + } else { + if (auto bin = p.value("bin", ""); !bin.empty()) args.push_back(bin); + if (const auto program = strings_of(p.value("args", Json::array())); programArgs && !program.empty()) { + args.push_back("--"); + args.insert(args.end(), program.begin(), program.end()); + } } - auto r = query(root, std::move(args)); + return args; +} + +std::expected Session::run_launch(const Json& p, const rpc::Context& ctx) { + const auto root = root_of(p); + if (root.empty()) return std::unexpected(invalid("run/launch names its project's root")); + auto r = query(root, launch_args(p, false, false)); if (!r) return std::unexpected(r.error()); if (ctx.cancelled()) return std::unexpected(rpc::Error{rpc::REQUEST_CANCELLED, "cancelled"}); if (r->exitCode != 0 || !r->envelope.is_object() || r->envelope.value("kind", "") != "mcpp.launch") diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index c0c8d7b..89e1b1e 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -332,6 +332,38 @@ int main() { // The plan a build sent is the project's configuration from then on. auto after = result_of("mcpp-ide/project/summary", rooted); CHECK(after.contains("configuration")); + + // A launch job (design 2026-11-07 §4.9): mcpp's narration while it builds, and the launch in + // job/finished, the program's arguments after `--`. With mcpp's launch-stream, the build's progress too. + const auto streamed = root.parent_path() / (root.filename().string() + "-stream"); + std::filesystem::create_directories(streamed); + std::ofstream(streamed / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + std::ofstream(streamed / ".mock-mcpp-features") << "launch-stream"; + const auto finishedBefore = wire.notifications("mcpp-ide/job/finished").size(); + const auto progressBefore = wire.notifications("mcpp-ide/job/progress").size(); + const auto plain = result_of("mcpp-ide/job/start", Json{{"kind", "launch"}, {"root", root.generic_string()}, {"args", Json::array({"one"})}}); + const auto live = result_of("mcpp-ide/job/start", Json{{"kind", "launch"}, {"root", streamed.generic_string()}, {"args", Json::array({"two"})}}); + for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/job/finished").size() < finishedBefore + 2; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto ends = wire.notifications("mcpp-ide/job/finished"); + CHECK(ends.size() == finishedBefore + 2); + for (const auto& id : {plain["jobId"], live["jobId"]}) { + const auto end = std::ranges::find_if(ends, [&](const Json& n) { return n["jobId"] == id; }); + CHECK(end != ends.end() && (*end)["status"] == "ok" && (*end)["kind"] == "launch"); + if (end != ends.end()) CHECK((*end).contains("launch") && !(*end)["launch"]["argv"].empty()); + const auto outputs = wire.notifications("mcpp-ide/job/output"); + CHECK(std::ranges::any_of(outputs, [&](const Json& n) { + return n["jobId"] == id && n["text"].get().find("Finished") != std::string::npos; })); + } + const auto progress = wire.notifications("mcpp-ide/job/progress"); + CHECK(progress.size() > progressBefore); + CHECK(std::ranges::all_of(progress.begin() + static_cast(progressBefore), progress.end(), + [&](const Json& n) { return n["jobId"] == live["jobId"]; })); + for (const auto& name : {"mcpp-ide/job/finished", "mcpp-ide/job/progress", "mcpp-ide/job/output"}) { + const auto& def = protocol["notifications"][name]["params"]; + for (const auto& n : wire.notifications(name)) CHECK(conforms(n, def, protocol, name)); + } + std::filesystem::remove_all(streamed); std::filesystem::remove_all(root); std::println("test_protocol: ok"); } From ffbe8e233d940bfcf1107a4ea303905f8a20cc89 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 18:36:02 +0900 Subject: [PATCH 12/31] fix: editor and host names in one spelling; this-file commands from spec; smoke over the real mcpp The backend, an ok image, reads a root, a working directory, a parent and a payload an editor names (C:\Users\x\...) through mcpplibs.os into the generic spelling. The tests take their own path and the temporary directory the same way (found running them as images on Windows). mcpp.runThisFile and mcpp.debugThisFile are declared in spec/commands.json, so the generated manifest keeps them. The Neovim smoke's test prints its line, and a run's terminal is closed before the next run. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 5 +++++ mide-plugins/nvim/tests/smoke.lua | 8 +++++++- mide-plugins/vscode/package.json | 20 ++++++++++++++------ spec/commands.json | 12 ++++++++++++ spec/i18n/manifest.en.json | 2 ++ spec/i18n/manifest.zh-cn.json | 2 ++ src/languageservice.cppm | 6 +++++- src/server.cppm | 17 +++++++++++++---- tests/support.hpp | 11 ++++++++++- tests/test_jobs.cpp | 2 +- tests/test_languageservice.cpp | 4 ++-- tests/test_mcppls_status.cpp | 2 +- tests/test_probe.cpp | 2 +- tests/test_protocol.cpp | 2 +- tests/test_testing.cpp | 2 +- 15 files changed, 77 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f8ef6..09857eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,11 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). Neovim statusline), as for a Build. With mcpp 2026.11.7.2 an unchanged program's launch is described on mcpp's fast path, 0.01 s where it was 0.1 to 0.3 s, so the program starts at once. CLion records how long the build and the program's start took in `idea.log`. +- **Windows names reach the backend in one spelling.** The backend runs as an ok image, whose names + are `/`-separated on every system; a project root, a working directory, a parent directory or a + payload an editor names as `C:\Users\x\…` is read through mcpplibs.os into its generic spelling + before anything is built from it, so it is absolute and its parts are components. Found by the + backend's tests running as images on Windows. - **The mcpp tool window beside Project.** CLion's mcpp tool window is on the left, where VS Code shows mcpp, and the first mcpp project opened puts its button on the stripe. - **Tested as it is run.** The backend is distributed only as an ok image, and CI tests it as one on diff --git a/mide-plugins/nvim/tests/smoke.lua b/mide-plugins/nvim/tests/smoke.lua index 54b054c..919316b 100644 --- a/mide-plugins/nvim/tests/smoke.lua +++ b/mide-plugins/nvim/tests/smoke.lua @@ -66,7 +66,7 @@ if not mock then end write('mcpp.toml', manifest) write('src/main.cpp', 'int main() { return 0; }\n') -write('tests/test_smoke.cpp', 'int main() { return 0; }\n') +write('tests/test_smoke.cpp', '#include \nint main() { std::puts("test_smoke: ok"); return 0; }\n') -- The mock's builds take a moment, so one can be cancelled while it runs. if mock then vim.env.MOCK_MCPP_SLOW_MS = '1200' @@ -227,6 +227,12 @@ done = finished('launch', before) check('a run is a launch job that ends with the launch', done and done.status == 'ok' and type(done.launch) == 'table' and type(done.launch.argv) == 'table' and #done.launch.argv > 0, vim.inspect(done and done.launch)) check("the launch job's narration is in mcpp://output", output():find('Finished', 1, true) ~= nil, output():sub(-300)) +-- The program's terminal is closed with its buffer, so the run below opens one of its own. +for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.bo[buf].buftype == 'terminal' and vim.b[buf].mcpp_program then + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end +end pcall(vim.cmd, 'silent! only') -- ── diagnostics ─────────────────────────────────────────────────────────────────────────────── diff --git a/mide-plugins/vscode/package.json b/mide-plugins/vscode/package.json index 798a617..75f1eb4 100644 --- a/mide-plugins/vscode/package.json +++ b/mide-plugins/vscode/package.json @@ -133,6 +133,14 @@ "command": "mcpp.debug", "when": "mcpp.inProject" }, + { + "command": "mcpp.runThisFile", + "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" + }, + { + "command": "mcpp.debugThisFile", + "when": "mcpp.activeEntryDebug" + }, { "command": "mcpp.pack", "when": "mcpp.inProject" @@ -171,12 +179,6 @@ "category": "%category%", "icon": "$(debug-alt)" }, - { - "command": "mcpp.test", - "title": "%command.mcpp.test.title%", - "category": "%category%", - "icon": "$(beaker)" - }, { "command": "mcpp.runThisFile", "title": "%command.mcpp.runThisFile.title%", @@ -189,6 +191,12 @@ "category": "%category%", "icon": "$(debug-alt)" }, + { + "command": "mcpp.test", + "title": "%command.mcpp.test.title%", + "category": "%category%", + "icon": "$(beaker)" + }, { "command": "mcpp.clean", "title": "%command.mcpp.clean.title%", diff --git a/spec/commands.json b/spec/commands.json index 5cf4994..e1d357c 100644 --- a/spec/commands.json +++ b/spec/commands.json @@ -26,6 +26,18 @@ "action": { "kind": "run" }, "quickMenu": true }, { "id": "mcpp.debug", "paletteWhen": "mcpp.inProject", "group": "project", "icon": { "vscode": "$(debug-alt)", "jetbrains": "AllIcons.Actions.StartDebugger" }, "when": "mcpp.inProject", "placements": { "title": 3, "context": 2, "gutter": 2 }, "action": { "kind": "debug" }, "quickMenu": true }, + { "id": "mcpp.runThisFile", "group": "project", "paletteWhen": "mcpp.activeEntry == program || mcpp.activeEntry == test", "editors": ["vscode"], + "omitted": { "jetbrains": "CLion runs a file's entry from its gutter and its context menu (mcpp.run).", + "nvim": "Neovim runs a file's entry from :Mcpp run and its signs." }, + "icon": { "vscode": "$(play)" }, "when": "mcpp.inProject", + "menus": [ { "menu": "editor/title/run", "group": "mcpp@1", "when": "mcpp.activeEntry == program || mcpp.activeEntry == test" } ], + "action": { "kind": "run" } }, + { "id": "mcpp.debugThisFile", "group": "project", "paletteWhen": "mcpp.activeEntryDebug", "editors": ["vscode"], + "omitted": { "jetbrains": "CLion debugs a file's entry from its gutter and its context menu (mcpp.debug).", + "nvim": "Neovim debugs a file's entry from :Mcpp debug." }, + "icon": { "vscode": "$(debug-alt)" }, "when": "mcpp.inProject", + "menus": [ { "menu": "editor/title/run", "group": "mcpp@2", "when": "mcpp.activeEntryDebug" } ], + "action": { "kind": "debug" } }, { "id": "mcpp.test", "group": "project", "icon": { "vscode": "$(beaker)", "jetbrains": "AllIcons.RunConfigurations.TestState.Run" }, "when": "mcpp.inProject", "keybinding": { "key": "ctrl+alt+t", "mac": "cmd+alt+t" }, "menus": [ { "menu": "editor/title", "group": "navigation@2", "when": "mcpp.inProject && mcpp.editorTitleButtons" } ], diff --git a/spec/i18n/manifest.en.json b/spec/i18n/manifest.en.json index 7503047..0e456e6 100644 --- a/spec/i18n/manifest.en.json +++ b/spec/i18n/manifest.en.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "Refresh Cache Statistics", "command.mcpp.refreshCompilationDatabase.title": "Refresh the Module Build Description", "command.mcpp.run.title": "Run", + "command.mcpp.runThisFile.title": "Run This File's Entry", + "command.mcpp.debugThisFile.title": "Debug This File's Entry", "command.mcpp.selectDefaultToolchain.title": "Select the Global Default Toolchain", "command.mcpp.selfCheck.title": "Environment Self-check", "command.mcpp.showCacheEntry.title": "Show Cache Entry Details", diff --git a/spec/i18n/manifest.zh-cn.json b/spec/i18n/manifest.zh-cn.json index 7ede0f2..bfb007f 100644 --- a/spec/i18n/manifest.zh-cn.json +++ b/spec/i18n/manifest.zh-cn.json @@ -42,6 +42,8 @@ "command.mcpp.refreshCacheStats.title": "刷新缓存统计", "command.mcpp.refreshCompilationDatabase.title": "刷新模块构建描述", "command.mcpp.run.title": "运行", + "command.mcpp.runThisFile.title": "运行此文件的入口", + "command.mcpp.debugThisFile.title": "调试此文件的入口", "command.mcpp.selectDefaultToolchain.title": "选择全局默认工具链", "command.mcpp.selfCheck.title": "环境自检", "command.mcpp.showCacheEntry.title": "查看缓存条目详情", diff --git a/src/languageservice.cppm b/src/languageservice.cppm index 50e77c2..ccdbe55 100644 --- a/src/languageservice.cppm +++ b/src/languageservice.cppm @@ -224,7 +224,11 @@ Candidate candidate_of(const std::filesystem::path& named, std::string source) { std::vector candidates(const std::vector& fromEditor) { std::vector out; - for (const auto& p : fromEditor) if (!p.empty()) out.push_back(candidate_of(p, "editor")); + // An editor names a payload in the host's spelling (`C:\Users\x\payload`); its generic one is the + // name every path built from it extends (mcpplibs.os). + for (const auto& p : fromEditor) + if (!p.empty()) + out.push_back(candidate_of(mcpplibs::os::fs::from_utf8(mcpplibs::os::fs::generic(mcpplibs::os::fs::from_utf8(p))), "editor")); if (auto found = mcpplibs::os::env::find_executable("mcppls")) out.push_back({{*found}, "PATH"}); const auto exe = std::string("mcppls") + std::string(mcpplibs::os::conventions().exe_suffix); if (auto xh = mcpplibs::os::env::get("XLINGS_HOME")) diff --git a/src/server.cppm b/src/server.cppm index e409489..fb5755c 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -212,8 +212,17 @@ namespace mcppide::server { namespace { rpc::Error invalid(std::string message) { return rpc::Error{rpc::INVALID_PARAMS, std::move(message)}; } +// A name an editor or the environment spells in the host's rules (`C:\Users\x` on Windows), in the +// generic spelling every name the backend builds, compares and reports uses: `/` between components +// (mcpplibs.os). The backend runs as an ok image, whose names are `/`-separated on every system; a +// `\` left in a name is read as part of a component there, so `C:\a\b` would be one relative name. +std::filesystem::path host_path(std::string_view text) { + if (text.empty()) return {}; + namespace fs = mcpplibs::os::fs; + return fs::from_utf8(fs::generic(fs::from_utf8(text))); +} std::filesystem::path root_of(const Json& p) { - return p.contains("root") && p["root"].is_string() ? std::filesystem::path(p["root"].get()) : std::filesystem::path{}; + return p.contains("root") && p["root"].is_string() ? host_path(p["root"].get()) : std::filesystem::path{}; } Json project_json(const projects::Project& p) { return Json{{"root", p.root.generic_string()}, {"workspaceRoot", p.workspaceRoot.generic_string()}, @@ -459,7 +468,7 @@ std::expected Session::mcpp_exec(const Json& p) { if (!a.is_string()) return std::unexpected(rpc::Error{rpc::INVALID_PARAMS, "args: an array of strings"}); o.args.push_back(a.get()); } - o.cwd = p.contains("cwd") && p["cwd"].is_string() ? std::filesystem::path(p["cwd"].get()) : std::filesystem::path{}; + o.cwd = p.contains("cwd") && p["cwd"].is_string() ? host_path(p["cwd"].get()) : std::filesystem::path{}; o.deny = deny(); o.client = client_label(); const auto timeoutMs = p.contains("timeoutMs") && p["timeoutMs"].is_number_integer() && p["timeoutMs"].get() > 0 @@ -1007,7 +1016,7 @@ std::expected Session::project_summary(const Json& p) { } std::expected Session::project_new(const Json& p) { - const auto parent = std::filesystem::path(p.value("parent", "")); + const auto parent = host_path(p.value("parent", "")); const auto name = p.value("name", ""); if (parent.empty() || name.empty()) return std::unexpected(invalid("project/new names a parent directory and a name")); if (name.find_first_of("/\\") != std::string::npos || name == "." || name == "..") @@ -1558,7 +1567,7 @@ std::expected Session::test_run_cases(const Json& p) { const auto* f = testing::framework(p.value("framework", "")); if (root.empty() || test.empty() || !f) return std::unexpected(invalid("test/runCases names a project, a test and its framework")); const auto cases = strings_of(p.value("cases", Json::array())); - const auto dir = std::filesystem::temp_directory_path() / + const auto dir = host_path(mcpplibs::os::fs::utf8(mcpplibs::os::dirs::temp())) / std::format("mcpp-ide-cases-{}-{}", std::random_device{}(), std::chrono::steady_clock::now().time_since_epoch().count()); std::error_code ec; std::filesystem::create_directories(dir, ec); diff --git a/tests/support.hpp b/tests/support.hpp index 39b72ad..9b09df6 100644 --- a/tests/support.hpp +++ b/tests/support.hpp @@ -17,8 +17,17 @@ #include namespace testsupport { +// A name in the generic spelling: `/` between components. The tests run as ok images too, whose +// names are `/`-separated on every system, while a Windows host spells this file's own path and the +// temporary directory with `\`. +inline std::filesystem::path generic(std::string text) { + for (auto& c : text) if (c == '\\') c = '/'; + return std::filesystem::path(text); +} // The repository, from this file's own path. -inline std::filesystem::path repo() { return std::filesystem::path(__FILE__).parent_path().parent_path(); } +inline std::filesystem::path repo() { return generic(__FILE__).parent_path().parent_path(); } +// The temporary directory, in the generic spelling. +inline std::filesystem::path temp() { return generic(std::filesystem::temp_directory_path().string()); } // A path as the `file:` URI an LSP client names it by. inline std::string file_uri(const std::filesystem::path& p) { const auto g = p.generic_string(); diff --git a/tests/test_jobs.cpp b/tests/test_jobs.cpp index 6238642..dc3a8b5 100644 --- a/tests/test_jobs.cpp +++ b/tests/test_jobs.cpp @@ -27,7 +27,7 @@ int main() { auto protocol = mcppide::mcpp::handshake(mock, fixtures); CHECK(protocol.has_value() && protocol->contract); - const auto root = std::filesystem::temp_directory_path() / std::format("mcppide-jobs-{}", mcpplibs::os::process::self()->id); + const auto root = testsupport::temp() / std::format("mcppide-jobs-{}", mcpplibs::os::process::self()->id); std::filesystem::create_directories(root); Seen seen; jobs::Sink sink; diff --git a/tests/test_languageservice.cpp b/tests/test_languageservice.cpp index 6967e21..714bc7a 100644 --- a/tests/test_languageservice.cpp +++ b/tests/test_languageservice.cpp @@ -24,7 +24,7 @@ int main() { CHECK(s.program != std::filesystem::path("/no/such/mcppls")); CHECK(ls::state_name(ls::State::outdated) == "outdated"); - const auto payload = std::filesystem::temp_directory_path() / std::format("mcppide-payload-{}", std::random_device{}()); + const auto payload = testsupport::temp() / std::format("mcppide-payload-{}", std::random_device{}()); std::filesystem::create_directories(payload / "bin"); auto write = [&](const std::filesystem::path& file, std::string_view text) { std::ofstream(file, std::ios::binary) << text; }; write(payload / "bin" / "okrun", "runner"); @@ -81,7 +81,7 @@ int main() { std::ifstream in(testsupport::repo() / "tests" / "payloads" / std::string(name), std::ios::binary); return std::string(std::istreambuf_iterator(in), {}); }; - const auto served = std::filesystem::temp_directory_path() / std::format("mcppide-published-{}", std::random_device{}()); + const auto served = testsupport::temp() / std::format("mcppide-published-{}", std::random_device{}()); std::filesystem::create_directories(served / "bin"); for (const auto* system : {"linux-x86_64", "linux-aarch64", "macos-aarch64", "windows-x86_64"}) { std::filesystem::create_directories(served / "libexec" / "okrun" / system); diff --git a/tests/test_mcppls_status.cpp b/tests/test_mcppls_status.cpp index c842163..88cf933 100644 --- a/tests/test_mcppls_status.cpp +++ b/tests/test_mcppls_status.cpp @@ -68,7 +68,7 @@ int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcppls"); CHECK(!mock.empty()); - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-mcppls-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + const auto base = testsupport::temp() / std::format("mcppide-mcppls-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto payload = base / "payload"; const auto root = base / "project"; std::filesystem::create_directories(payload / "bin"); diff --git a/tests/test_probe.cpp b/tests/test_probe.cpp index 9dc84a5..040b469 100644 --- a/tests/test_probe.cpp +++ b/tests/test_probe.cpp @@ -60,7 +60,7 @@ std::filesystem::path project(const std::filesystem::path& base, std::string_vie int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); CHECK(!mock.empty()); - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + const auto base = testsupport::temp() / std::format("mcppide-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto old = project(base, "old", {{".mock-mcpp-version", "2026.10.29.1"}}); const auto sets = project(base, "sets", {{".mock-mcpp-version", "2026.10.30.1"}}); const auto stated = project(base, "stated", {{".mock-mcpp-version", "2026.10.31.1"}, diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 89e1b1e..18061d6 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -99,7 +99,7 @@ int main() { // A session over the mock mcpp. const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); CHECK(!mock.empty()); - const auto root = std::filesystem::temp_directory_path() / std::format("mcppide-protocol-{}", std::chrono::steady_clock::now().time_since_epoch().count()); + const auto root = testsupport::temp() / std::format("mcppide-protocol-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(root / "src"); std::ofstream(root / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; Wire wire; diff --git a/tests/test_testing.cpp b/tests/test_testing.cpp index b1f41a7..347c97e 100644 --- a/tests/test_testing.cpp +++ b/tests/test_testing.cpp @@ -150,7 +150,7 @@ TEST(Math, // source/entries and test/cases over a project on disk (no mcpp: the plan's packages are not // known, so no framework is claimed, and the static entries still answer). { - const auto base = std::filesystem::temp_directory_path() / std::format("mcppide-testing-{}", std::random_device{}()); + const auto base = testsupport::temp() / std::format("mcppide-testing-{}", std::random_device{}()); std::filesystem::create_directories(base / "src"); std::filesystem::create_directories(base / "tests" / "unit"); std::ofstream(base / "mcpp.toml") << "[package]\nname = \"p\"\nversion = \"0.1.0\"\n"; From 6ee6a775f45e0a9bb3333a670f27230afd90e8a4 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 18:57:08 +0900 Subject: [PATCH 13/31] build: openkal-platform 0.24.3 as one release The backend each version ships is built over one version group (design 2026-11-07, D20), whose okrun reserves a thread's stack for an image's main thread on Windows. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 5 +++-- mcpp.lock | 18 +++++++++--------- mcpp.toml | 10 +++++----- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09857eb..15dd47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,9 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). Debug`, and while the active editor is a target's entry the editor title's Run menu runs and debugs it. Neovim's sign column shows ▶ on a program's and a test's `main` and on each case, and a result turns it into ✓ or ✗. CLion logs why a file has no run entry. -- **The platform as a series.** The payloads' platform is `openkal-platform@^0.24`, kept in - `mcpp.lock` (0.24.2), and os is `^0.3.1`; mcpp 2026.11.7.1 or later builds them. +- **The platform as one release.** The payloads' platform is `openkal-platform@0.24.3`, whose okrun + reserves a thread's stack for an image's main thread on Windows, and os is `^0.3.1`; mcpp + 2026.11.7.1 or later builds them. - **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, and the build's steps in the status bar (CLion, VS Code while it builds for the debugger, the diff --git a/mcpp.lock b/mcpp.lock index 9c3b15a..45e7e70 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -30,9 +30,9 @@ targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.5.1" -source = "index+mcpplibs@0.5.1" -hash = "fnv1a:e71cc572802c139e" +version = "0.5.2" +source = "index+mcpplibs@0.5.2" +hash = "fnv1a:e71cc472802c11eb" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] @@ -60,17 +60,17 @@ targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.24.2" -source = "index+mcpplibs@0.24.2" -hash = "fnv1a:85275e2a773915bb" +version = "0.24.3" +source = "index+mcpplibs@0.24.3" +hash = "fnv1a:85275d2a77391408" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.5.1" -source = "index+mcpplibs@0.5.1" -hash = "fnv1a:17b68a73cd804001" +version = "0.5.2" +source = "index+mcpplibs@0.5.2" +hash = "fnv1a:17b68773cd803ae8" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] diff --git a/mcpp.toml b/mcpp.toml index f2c59d6..5fce7c9 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -33,12 +33,12 @@ toolchain = "llvm@23.1.3" [target.'cfg(windows)'] toolchain = "llvm@23.1.3" -# The platform of the editor payloads, as a series (design 2026-11-07, A4): its newest 0.24 patch -# this mcpp admits, kept in mcpp.lock until `mcpp update` (mcpp 2026.11.7.1, SPEC-013 §14). The -# platform supplies the image toolchain, hardening, seal and runner, and its version group names -# OpenKal 0.24, musl 0.28, runtime 0.19 and image/tools 0.5. +# The platform of the editor payloads, one release (design 2026-11-07, D20): the backend each version +# ships is built over one version group. The platform supplies the image toolchain, hardening, seal +# and runner; 0.24.3 names OpenKal 0.24, musl 0.28.1, runtime 0.19.2 and image 0.5.2, whose okrun +# reserves a thread's stack for an image's main thread on Windows. [target.'cfg(os = "openkal")'] -platform = "openkal-platform@^0.24" +platform = "openkal-platform@0.24.3" # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] From 6c039cab4b058674d5ce56068c44b0c0fcfb4065 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 19:10:12 +0900 Subject: [PATCH 14/31] ci: the backend's native rows from a computed matrix An exclude on an include-only matrix left no backend job at all. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e02d57..79350b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,22 +44,33 @@ jobs: # over the platform's hosted arrangement) whose okrun the packages take. The `host-platform` row # builds and tests with the build programs (build.mcpp, the spec/ generators) as ok images run by # okrun. The native rows of the other systems run only with `native`: what is distributed is the image. + # The native rows of the backend job: Linux x64, which packs the image and tests with the host graph's + # platform, and the other systems only in a run with `native` -- the backend is distributed as the ok + # image, which `backend-openkal` tests on every system (design 2026-11-07, D18). + matrix: + runs-on: ubuntu-24.04 + outputs: + backend: ${{ steps.pick.outputs.backend }} + steps: + - id: pick + env: + NATIVE: ${{ inputs.native && 'yes' || 'no' }} + run: | + rows='{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","pack":true},{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","host-platform":"openkal-platform@0.24.3"}' + if [ "$NATIVE" = yes ]; then + rows="$rows"',{"os":"ubuntu-24.04-arm","platform":"linux-arm64","llvm":"false"},{"os":"macos-15","platform":"darwin-arm64","llvm":"true"},{"os":"windows-2022","platform":"win32-x64","llvm":"true"}' + fi + echo "backend=[$rows]" | tee -a "$GITHUB_OUTPUT" + backend: name: backend (${{ matrix.platform }}${{ matrix.host-platform && format(', host platform {0}', matrix.host-platform) || '' }}) + needs: matrix runs-on: ${{ matrix.os }} timeout-minutes: 75 strategy: fail-fast: false matrix: - include: - - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', pack: true } - - { os: ubuntu-24.04-arm, platform: linux-arm64, llvm: 'false' } - - { os: macos-15, platform: darwin-arm64, llvm: 'true' } - - { os: windows-2022, platform: win32-x64, llvm: 'true' } - - { os: ubuntu-24.04, platform: linux-x64, llvm: 'false', host-platform: 'openkal-platform@0.24.0' } - # The backend is distributed only as the ok image the `pack` leg packs, and `backend-openkal` tests that - # image on every system. The native backend of the other systems is built and tested in a run with `native`. - exclude: ${{ inputs.native && fromJSON('[]') || fromJSON('[{"platform":"linux-arm64"},{"platform":"darwin-arm64"},{"platform":"win32-x64"}]') }} + include: ${{ fromJSON(needs.matrix.outputs.backend) }} defaults: run: shell: bash From 3e807cb96bcb0b12dc045043141298cc1b4e77ef Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 20:25:02 +0900 Subject: [PATCH 15/31] fix(clion): the plugin's version through the public plugin API Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 77b8f5d..09fc73c 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -135,7 +135,7 @@ class McppSession(private val project: Project) : Disposable { private fun pluginVersion(): String = runCatching { (McppSession::class.java.classLoader as? com.intellij.ide.plugins.cl.PluginAwareClassLoader)?.pluginDescriptor?.version }.getOrNull() ?: runCatching { - com.intellij.ide.plugins.PluginManagerCore.getPlugin(com.intellij.openapi.extensions.PluginId.getId(PLUGIN_ID))?.version + com.intellij.ide.plugins.PluginManager.getPluginByClass(McppSession::class.java)?.version }.getOrNull() ?: "unknown" /** The plugin's directory: the parent of the lib/ holding this class's jar. */ From 7766690d4e5756704274095033120c5f867b4280 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 21:13:31 +0900 Subject: [PATCH 16/31] fix(clion): the plugin's version from its class loader alone; no internal API Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../io/github/mcppcommunity/mcppclion/McppSession.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt index 09fc73c..0a94d9f 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppSession.kt @@ -131,11 +131,12 @@ class McppSession(private val project: Project) : Disposable { com.intellij.ide.plugins.PluginManagerCore.isLoaded(id) }.getOrDefault(false) - /** This plugin's version, as its descriptor states it: the series the backend must share (D15). */ + /** + * This plugin's version, as its descriptor states it: the series the backend must share (D15). The + * platform loads every plugin class through the plugin's own class loader, which names the descriptor. + */ private fun pluginVersion(): String = runCatching { (McppSession::class.java.classLoader as? com.intellij.ide.plugins.cl.PluginAwareClassLoader)?.pluginDescriptor?.version - }.getOrNull() ?: runCatching { - com.intellij.ide.plugins.PluginManager.getPluginByClass(McppSession::class.java)?.version }.getOrNull() ?: "unknown" /** The plugin's directory: the parent of the lib/ holding this class's jar. */ From cee40b06968e02950cc15ad9f68d78426295e25e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 23:05:29 +0900 Subject: [PATCH 17/31] ci: build with mcpp 2026.11.7.2 Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/versions.env | 4 ++-- .xlings.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/versions.env b/.github/versions.env index 4d8a06b..d956dab 100644 --- a/.github/versions.env +++ b/.github/versions.env @@ -1,12 +1,12 @@ # The versions CI builds and tests with, in one file; .github/actions/setup-mcpp exports them. # The published bootstrap; the project explicitly selects platform 0.24. -MCPP_VERSION=2026.11.7.1 +MCPP_VERSION=2026.11.7.2 # A ref of mcpp-community/mcpp CI builds mcpp from and uses instead, for a contract not yet released. # Empty: the release above is used. MCPP_SOURCE_REF= # The LLVM the backend builds with on macOS and Windows (the targets mcpp reaches there with it). LLVM_VERSION=23.1.3 -# The xlings mcpp 2026.11.7.1 bundles, which reads the current index. +# The xlings mcpp 2026.11.7.2 bundles, which reads the current index. XLINGS_VERSION=v2026.10.26.1 # The oldest VS Code the extension supports (engines.vscode), which its tests run in. VSCODE_VERSION=1.91.0 diff --git a/.xlings.json b/.xlings.json index 5e9f1aa..4c1f3ff 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.11.7.1" + "mcpp": "2026.11.7.2" } } From ae9d2f58013e7784f88b0268f1a21751c4c37f0e Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sat, 7 Nov 2026 23:35:41 +0900 Subject: [PATCH 18/31] build: every dependency one release, named by its identity Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- mcpp.toml | 14 ++++++-------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dd47a..845c1ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,8 +34,8 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). debugs it. Neovim's sign column shows ▶ on a program's and a test's `main` and on each case, and a result turns it into ✓ or ✗. CLion logs why a file has no run entry. - **The platform as one release.** The payloads' platform is `openkal-platform@0.24.3`, whose okrun - reserves a thread's stack for an image's main thread on Windows, and os is `^0.3.1`; mcpp - 2026.11.7.1 or later builds them. + reserves a thread's stack for an image's main thread on Windows, and every dependency is one release + named by its identity (`mcpplibs.os = "0.3.1"`); mcpp 2026.11.7.1 or later builds them. - **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, and the build's steps in the status bar (CLion, VS Code while it builds for the debugger, the diff --git a/mcpp.toml b/mcpp.toml index 5fce7c9..28349cb 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -74,11 +74,9 @@ main = "src/bin/mockmcppls.cpp" kind = "bin" main = "src/bin/dev.cpp" -[dependencies.mcpplibs] -# A compatible range: 0.3.1 reads a name in Windows' long spelling as the name it spells; the -# graph and the platform's version group choose the release. The image C library, C++ runtime and -# loader remain the platform's. -os = "^0.3.1" - -[dependencies.nlohmann] -json = "3.12.0" +# Each dependency is one release, named by its identity (namespace, then name). +[dependencies] +# 0.3.1 reads a name in Windows' long spelling as the name it spells. The image C library, C++ +# runtime and loader remain the platform's. +mcpplibs.os = "0.3.1" +nlohmann.json = "3.12.0" From 42ec706f7646d50f6793cb9f15765298c573fe1a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 00:13:01 +0900 Subject: [PATCH 19/31] fix(clion): the no-JCEF explanation wraps in the left tool window and names itself whole Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 3 ++- .../mcppcommunity/mcppclion/ui/GraphPage.kt | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 0852360..5fec4f2 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -143,7 +143,8 @@ class GraphInteractionTest { record("protocol2-startup") if (disableJcef) { invokeAction("mcpp.project.showModuleDependencyGraph") - val explanation = ui.x("//div[contains(@visible_text,'This IDE runs without JCEF') or contains(@text,'This IDE runs without JCEF')]") + // The sentence wraps in the narrow tool window; its accessible name is whole. + val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") val explained = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) while (!explanation.present() && System.nanoTime() < explained) Thread.sleep(100) assertTrue(explanation.present(), "the graph explains why drawing is unavailable") diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt index 1d82a18..6ebd527 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/GraphPage.kt @@ -14,18 +14,18 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.ui.JBColor -import com.intellij.ui.components.JBLabel +import com.intellij.ui.components.JBTextArea import com.intellij.ui.jcef.JBCefApp import com.intellij.ui.jcef.JBCefBrowser import com.intellij.ui.jcef.JBCefBrowserBase import com.intellij.ui.jcef.JBCefJSQuery +import com.intellij.util.ui.JBUI import io.github.mcppcommunity.mcppclion.McppSession import org.cef.browser.CefBrowser import org.cef.browser.CefFrame import org.cef.handler.CefLoadHandlerAdapter import java.io.File import javax.swing.JComponent -import javax.swing.SwingConstants class GraphPage(private val project: Project, private val kind: String) : McppView, Disposable { override val id = "graph:$kind" @@ -34,7 +34,9 @@ class GraphPage(private val project: Project, private val kind: String) : McppVi private val query: JBCefJSQuery? = browser?.let { JBCefJSQuery.create(it as JBCefBrowserBase) } @Volatile private var loaded = false private var pending: String? = null - val component: JComponent = browser?.component ?: JBLabel(session.t("This IDE runs without JCEF, which draws the graph."), SwingConstants.CENTER) + // Without JCEF the page says why. The tool window sits on the left beside Project, narrower than + // the sentence, so the text wraps; its accessible name is the whole sentence. + val component: JComponent = browser?.component ?: unavailable(session.t("This IDE runs without JCEF, which draws the graph.")) init { if (browser != null && query != null) { @@ -105,3 +107,12 @@ class GraphPage(private val project: Project, private val kind: String) : McppVi override fun dispose() {} } + +private fun unavailable(text: String): JComponent = JBTextArea(text).apply { + isEditable = false + lineWrap = true + wrapStyleWord = true + isOpaque = false + border = JBUI.Borders.empty(12) + accessibleContext.accessibleName = text +} From 126272664b6b6f412aa865c1e5cf935fcbe9821a Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 00:45:02 +0900 Subject: [PATCH 20/31] test(clion): the no-JCEF graph check reports what the window shows when the explanation is absent Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 5fec4f2..81cc4a6 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -147,6 +147,16 @@ class GraphInteractionTest { val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") val explained = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) while (!explanation.present() && System.nanoTime() < explained) Thread.sleep(100) + if (!explanation.present()) { + // What the window shows instead, for the report. + val window = utility().getInstance(singleProject()).getToolWindow("mcpp") + val state = withContext(OnDispatcher.EDT) { + if (window == null) "no mcpp tool window" + else "visible=" + window.isVisible() + " selected=" + + window.getContentManager().getSelectedContent()?.getDisplayName() + } + Files.writeString(output.resolve("no-jcef-window.txt"), state + "\n") + } assertTrue(explanation.present(), "the graph explains why drawing is unavailable") record("no-jcef-explanation-visible") } else { From f5686acd7f725ffb9475b04e2f6a39d1d39d32af Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 00:48:25 +0900 Subject: [PATCH 21/31] test: a test-support module in place of support.hpp; check-sources reports a header, a preprocessor branch and os::is_ Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 5 + mcpp.toml | 4 + src/bin/dev.cpp | 48 +++++- tests/test_jobs.cpp | 26 +-- tests/test_languageservice.cpp | 68 ++++---- tests/test_mcppls_status.cpp | 48 +++--- tests/test_policy.cpp | 22 +-- tests/test_probe.cpp | 46 ++--- tests/test_protocol.cpp | 162 +++++++++--------- tests/test_rpc.cpp | 20 ++- tests/test_rules.cpp | 18 +- tests/test_settings.cpp | 20 ++- tests/test_testing.cpp | 100 +++++------ tests/test_views.cpp | 112 ++++++------ testsupport/mcpp.toml | 13 ++ .../src/testsupport.cppm | 36 ++-- 17 files changed, 420 insertions(+), 330 deletions(-) create mode 100644 testsupport/mcpp.toml rename tests/support.hpp => testsupport/src/testsupport.cppm (65%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79350b2..779a91e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,6 +88,8 @@ jobs: run: mcpp test - name: Every string has its translation run: mcpp run mcpp-ide-dev -- check-i18n + - name: No header, no preprocessor branch, no compile-time platform question + run: mcpp run mcpp-ide-dev -- check-sources - name: The backend as the editor packages carry it if: matrix.pack run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 845c1ec..7ecfec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,11 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). payload an editor names as `C:\Users\x\…` is read through mcpplibs.os into its generic spelling before anything is built from it, so it is absolute and its parts are components. Found by the backend's tests running as images on Windows. +- **No header in the backend's sources or tests.** The tests share a module (`mcppide.testsupport`, + in `testsupport/`) where they included `support.hpp`, and a failed check names its place through + `std::source_location`. `mcpp-ide-dev check-sources`, run by CI, reports a header, a preprocessor + branch and a compile-time platform question (`os::is_`, which an ok image answers false) + in the backend, its tests and the test support. - **The mcpp tool window beside Project.** CLion's mcpp tool window is on the left, where VS Code shows mcpp, and the first mcpp project opened puts its button on the stripe. - **Tested as it is run.** The backend is distributed only as an ok image, and CI tests it as one on diff --git a/mcpp.toml b/mcpp.toml index 28349cb..b1a0671 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -80,3 +80,7 @@ main = "src/bin/dev.cpp" # runtime and loader remain the platform's. mcpplibs.os = "0.3.1" nlohmann.json = "3.12.0" + +# What every backend test shares (testsupport/): a module, as the backend's units are. +[dev-dependencies] +mcpp-ide-testsupport = { path = "testsupport" } diff --git a/src/bin/dev.cpp b/src/bin/dev.cpp index de1fc37..7482092 100644 --- a/src/bin/dev.cpp +++ b/src/bin/dev.cpp @@ -18,6 +18,11 @@ // Every runtime string the backend, the panels and the plugins pass to t(...) has a Chinese // entry in spec/i18n/runtime.zh-cn.json; every editor string a Chinese one in // manifest.zh-cn.json. +// mcpp-ide-dev check-sources +// No C++ source of the backend, its tests or its test support includes a header or branches on +// the platform in the preprocessor, and none asks `os::is_`: the system is +// mcpplibs.os's, and every platform branch of a program shipped as an ok image is a run-time +// one (`os::can_run_on` and `os::host()`; design 2026-11-08 S3, S4). // // The VS Code declarations are kept in the repository (mcpp-vscode packs its own directory, and its // gates read the manifest); CI checks they are spec/'s. The CLion declarations go where its build @@ -511,24 +516,63 @@ int check_i18n(std::span) { return missing.empty() && missingEditor.empty() ? 0 : 1; } +// ── check-sources ─────────────────────────────────────────────────────────── + +int check_sources(std::span) { + const auto root = repo(); + std::size_t files = 0; + std::vector problems; + std::error_code ec; + for (const char* dir : {"src", "tests", "testsupport"}) + for (fs::recursive_directory_iterator it(root / dir, ec), end; !ec && it != end; it.increment(ec)) { + if (!it->is_regular_file(ec)) continue; + const auto ext = it->path().extension().string(); + if (ext != ".cpp" && ext != ".cppm" && ext != ".hpp" && ext != ".h") continue; + ++files; + const auto rel = it->path().lexically_relative(root).generic_string(); + if (ext == ".hpp" || ext == ".h") { problems.push_back(std::format("{}: a header; a module states what it shares", rel)); continue; } + const auto text = slurp(it->path()); + std::size_t line = 0; + for (std::size_t at = 0; at <= text.size();) { + const auto nl = std::min(text.find('\n', at), text.size()); + const std::string_view row(text.data() + at, nl - at); + ++line; + const auto first = row.find_first_not_of(" \t"); + const auto code = first == std::string_view::npos ? std::string_view{} : row.substr(first); + if (code.starts_with("#include")) problems.push_back(std::format("{}:{}: includes a header", rel, line)); + else if (code.starts_with("#if") || code.starts_with("#elif")) problems.push_back(std::format("{}:{}: a preprocessor branch", rel, line)); + // This program's own rule names what it looks for. + else if (it->path().filename() != "dev.cpp") + for (std::string_view name : {"is_windows", "is_linux", "is_macos", "is_posix"}) + if (row.find(std::format("os::{}", name)) != std::string_view::npos) + problems.push_back(std::format("{}:{}: asks os::{}, which an ok image answers false; ask os::host()", rel, line, name)); + at = nl + 1; + } + } + for (const auto& p : problems) std::println(std::cerr, "{}", p); + std::println("check-sources: {} files, {} problems", files, problems.size()); + return problems.empty() ? 0 : 1; +} + } // namespace int main(int argc, char** argv) { std::vector args(argv + 1, argv + argc); try { if (args.empty() || args[0] == "--help") { - std::println("mcpp-ide-dev capture | gen vscode | gen jetbrains --out DIR | check-i18n"); + std::println("mcpp-ide-dev capture | gen vscode | gen jetbrains --out DIR | check-i18n | check-sources"); return args.empty() ? 2 : 0; } const std::span rest(args.begin() + 1, args.end()); if (args[0] == "capture") return capture(rest); if (args[0] == "check-i18n") return check_i18n(rest); + if (args[0] == "check-sources") return check_sources(rest); if (args[0] == "gen" && !rest.empty()) { const std::span more(rest.begin() + 1, rest.end()); if (rest[0] == "vscode") return gen_vscode(more); if (rest[0] == "jetbrains") return gen_jetbrains(more); } - std::println(std::cerr, "mcpp-ide-dev: unknown command (capture, gen vscode|jetbrains, check-i18n)"); + std::println(std::cerr, "mcpp-ide-dev: unknown command (capture, gen vscode|jetbrains, check-i18n, check-sources)"); return 2; } catch (const std::exception& e) { std::println(std::cerr, "mcpp-ide-dev: {}", e.what()); diff --git a/tests/test_jobs.cpp b/tests/test_jobs.cpp index dc3a8b5..eec4e03 100644 --- a/tests/test_jobs.cpp +++ b/tests/test_jobs.cpp @@ -1,12 +1,14 @@ // mcppide.jobs over the mock mcpp: a build streams its records and ends `ok`, a failed build ends // `failed` with its diagnostics, and a cancelled one ends `cancelled` with mcpp's record saying so. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcpplibs.os; import mcppide.mcpp; import mcppide.jobs; +using testsupport::check; + namespace jobs = mcppide::jobs; using Json = nlohmann::json; @@ -21,11 +23,11 @@ struct Seen { int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); + check(!mock.empty()); // The mock reads its fixtures and its behaviour from the environment of each run. const mcppide::mcpp::Environment fixtures{{"MCPPIDE_FIXTURES", testsupport::fixtures().generic_string()}}; auto protocol = mcppide::mcpp::handshake(mock, fixtures); - CHECK(protocol.has_value() && protocol->contract); + check(protocol.has_value() && protocol->contract); const auto root = testsupport::temp() / std::format("mcppide-jobs-{}", mcpplibs::os::process::self()->id); std::filesystem::create_directories(root); @@ -41,14 +43,14 @@ int main() { runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished.contains(ok)); - CHECK(seen.finished[ok]["status"] == "ok"); - CHECK(!seen.records.empty()); + check(seen.finished.contains(ok)); + check(seen.finished[ok]["status"] == "ok"); + check(!seen.records.empty()); if (!seen.finished[ok].contains("envelope")) { std::println(std::cerr, "no envelope; the summary: {}", seen.finished[ok].dump()); for (const auto& r : seen.records) std::println(std::cerr, "record: {}", r.dump().substr(0, 200)); } - CHECK(seen.finished[ok].contains("envelope")); + check(seen.finished[ok].contains("envelope")); } auto failing = build; @@ -57,7 +59,7 @@ int main() { runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished[failed]["status"] == "failed"); + check(seen.finished[failed]["status"] == "failed"); } // A build that takes its time, cancelled while it runs. @@ -65,18 +67,18 @@ int main() { slowly.env.push_back({"MOCK_MCPP_SLOW_MS", "20000"}); const auto slow = runner.submit(slowly); std::this_thread::sleep_for(std::chrono::milliseconds(500)); - CHECK(runner.cancel(slow)); + check(runner.cancel(slow)); runner.drain(); { std::lock_guard g(seen.lock); - CHECK(seen.finished[slow]["status"] == "cancelled"); + check(seen.finished[slow]["status"] == "cancelled"); } // mcpp leaves its record `cancelled` when the system delivers the request to end (SIGTERM). On // Windows the program is ended outright (openkal-windows: TerminateProcess), so the record is // the last one mcpp wrote, never `ok`. const auto record = testsupport::read(root / "target" / ".last-result.json"); - if (mcpplibs::os::host() == mcpplibs::os::platform::windows) CHECK(record.find("\"ok\"") == std::string::npos); - else CHECK(record.find("\"cancelled\"") != std::string::npos); + if (mcpplibs::os::host() == mcpplibs::os::platform::windows) check(record.find("\"ok\"") == std::string::npos); + else check(record.find("\"cancelled\"") != std::string::npos); std::error_code ec; std::filesystem::remove_all(root, ec); diff --git a/tests/test_languageservice.cpp b/tests/test_languageservice.cpp index 714bc7a..e83426a 100644 --- a/tests/test_languageservice.cpp +++ b/tests/test_languageservice.cpp @@ -2,27 +2,29 @@ // read the result record, an older one is `outdated`, and output with no version is `unreadable`. A // payload directory stands for the command its payload.json's `server` names: the runner and the okb // (mcppls 0.0.18 and later), or a program. -#include "support.hpp" import std; +import mcppide.testsupport; import mcppide.languageservice; +using testsupport::check; + using namespace mcppide; namespace ls = mcppide::languageservice; int main() { - CHECK(ls::version_in("0.0.16\n") == "0.0.16"); - CHECK(ls::version_in("mcppls 0.0.18\n") == "0.0.18"); - CHECK(ls::version_in("mcppls 0.0.19,\n") == "0.0.19"); - CHECK(ls::version_in("mcppls\n").empty()); - CHECK(ls::classify("/x/mcppls", "PATH", "0.0.16\n").state == ls::State::outdated); - CHECK(ls::classify("/x/mcppls", "PATH", "mcppls 0.0.18\n").state == ls::State::ok); - CHECK(ls::classify("/x/mcppls", "PATH", "0.1.0").state == ls::State::ok); - CHECK(ls::classify("/x/mcppls", "PATH", "usage: mcppls").state == ls::State::unreadable); + check(ls::version_in("0.0.16\n") == "0.0.16"); + check(ls::version_in("mcppls 0.0.18\n") == "0.0.18"); + check(ls::version_in("mcppls 0.0.19,\n") == "0.0.19"); + check(ls::version_in("mcppls\n").empty()); + check(ls::classify("/x/mcppls", "PATH", "0.0.16\n").state == ls::State::outdated); + check(ls::classify("/x/mcppls", "PATH", "mcppls 0.0.18\n").state == ls::State::ok); + check(ls::classify("/x/mcppls", "PATH", "0.1.0").state == ls::State::ok); + check(ls::classify("/x/mcppls", "PATH", "usage: mcppls").state == ls::State::unreadable); // A candidate that does not exist is passed over; with nothing found the state is `missing` or // whatever the machine's own mcppls answers -- never an error. const auto s = ls::probe({"/no/such/mcppls"}); - CHECK(s.program != std::filesystem::path("/no/such/mcppls")); - CHECK(ls::state_name(ls::State::outdated) == "outdated"); + check(s.program != std::filesystem::path("/no/such/mcppls")); + check(ls::state_name(ls::State::outdated) == "outdated"); const auto payload = testsupport::temp() / std::format("mcppide-payload-{}", std::random_device{}()); std::filesystem::create_directories(payload / "bin"); @@ -31,29 +33,29 @@ int main() { write(payload / "bin" / "mcppls.okb", "okb"); write(payload / "payload.json", R"({"platform": "linux-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); const std::vector imaged{payload / "bin" / "okrun", payload / "bin" / "mcppls.okb"}; - CHECK(ls::payload_command(payload) == imaged); - CHECK(ls::command_of(payload) == imaged); - CHECK(ls::command_of(payload / "bin" / "okrun") == std::vector{payload / "bin" / "okrun"}); + check(ls::payload_command(payload) == imaged); + check(ls::command_of(payload) == imaged); + check(ls::command_of(payload / "bin" / "okrun") == std::vector{payload / "bin" / "okrun"}); std::filesystem::remove(payload / "bin" / "mcppls.okb"); - CHECK(ls::payload_command(payload).empty()); // what it names is not there + check(ls::payload_command(payload).empty()); // what it names is not there write(payload / "bin" / "mcppls", "program"); write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); - CHECK(ls::payload_command(payload) == std::vector{payload / "bin" / "mcppls"}); + check(ls::payload_command(payload) == std::vector{payload / "bin" / "mcppls"}); // What payload.json names stays inside the payload. const std::filesystem::path outside = payload.string() + "-outside"; write(outside, "program"); write(payload / "payload.json", R"({"server": {"path": "bin/mcppls", "runner": "../)" + outside.filename().string() + R"("}})"); - CHECK(ls::payload_command(payload).empty()); + check(ls::payload_command(payload).empty()); write(payload / "payload.json", R"({"server": {"path": ")" + (payload / "bin" / "mcppls").generic_string() + R"("}})"); - CHECK(ls::payload_command(payload).empty()); + check(ls::payload_command(payload).empty()); std::filesystem::remove(outside); write(payload / "payload.json", "not json"); - CHECK(ls::payload_command(payload).empty()); - CHECK(ls::command_of(payload / "missing").empty()); + check(ls::payload_command(payload).empty()); + check(ls::command_of(payload / "missing").empty()); // mcppls S6: version 5 names each platform's runner; version 4 serves the one platform it names. A // payload that does not serve this system is refused, naming the platforms it does serve. const auto host = ls::host_platform(); - CHECK(host == "linux-x64" || host == "linux-arm64" || host == "darwin-arm64" || host == "darwin-x64" || + check(host == "linux-x64" || host == "linux-arm64" || host == "darwin-arm64" || host == "darwin-x64" || host == "win32-x64" || host == "win32-arm64"); std::filesystem::create_directories(payload / "libexec" / "okrun" / "this"); write(payload / "libexec" / "okrun" / "this" / "okrun", "runner"); @@ -63,17 +65,17 @@ int main() { "platforms": {{"{}": {{"runner": "libexec/okrun/this/okrun", "engines": {{}}, "files": {{}}}}, "haiku-x64": {{"runner": "libexec/okrun/haiku/okrun", "engines": {{}}, "files": {{}}}}}}}})", host)); const std::vector fromEntry{payload / "libexec" / "okrun" / "this" / "okrun", payload / "bin" / "mcppls.okb"}; - CHECK(ls::payload_command(payload) == fromEntry); + check(ls::payload_command(payload) == fromEntry); const auto other = ls::payload_server(payload, "plan9-x64"); - CHECK(other.command.empty() && other.problem.contains("serves") && other.problem.contains("haiku-x64") && other.problem.contains("plan9-x64")); + check(other.command.empty() && other.problem.contains("serves") && other.problem.contains("haiku-x64") && other.problem.contains("plan9-x64")); write(payload / "payload.json", R"({"payload-version": 4, "platform": "plan9-x64", "server": {"path": "bin/mcppls.okb", "runner": "bin/okrun"}})"); write(payload / "bin" / "okrun", "runner"); - CHECK(ls::payload_command(payload).empty()); - CHECK(ls::payload_server(payload).problem.contains("plan9-x64")); + check(ls::payload_command(payload).empty()); + check(ls::payload_server(payload).problem.contains("plan9-x64")); write(payload / "payload.json", std::format(R"({{"payload-version": 4, "platform": "{}", "server": {{"path": "bin/mcppls.okb", "runner": "bin/okrun"}}}})", host)); - CHECK(ls::payload_command(payload) == imaged); + check(ls::payload_command(payload) == imaged); write(payload / "payload.json", R"({"payload-version": 6, "server": {"path": "bin/mcppls.okb"}})"); - CHECK(ls::payload_server(payload).problem.contains("payload-version 6")); + check(ls::payload_server(payload).problem.contains("payload-version 6")); // The manifests of the published payloads (tests/payloads: mcppls 0.0.19's for linux-x64, version 4, // and 0.0.20's, version 5, as mcppls's S6 examples hold them, their file lists cut short). { @@ -95,22 +97,22 @@ int main() { {"darwin-arm64", "macos-aarch64/okrun"}, {"win32-x64", "windows-x86_64/okrun.exe"}}; for (const auto& [platform, runner] : runners) { const auto s = ls::payload_server(served, platform); - CHECK(s.command == std::vector({served / "libexec" / "okrun" / std::filesystem::path(runner), served / "bin" / "mcppls.okb"})); + check(s.command == std::vector({served / "libexec" / "okrun" / std::filesystem::path(runner), served / "bin" / "mcppls.okb"})); } - CHECK(ls::payload_server(served, "win32-arm64").problem.contains("darwin-arm64, linux-arm64, linux-x64, win32-x64")); + check(ls::payload_server(served, "win32-arm64").problem.contains("darwin-arm64, linux-arm64, linux-x64, win32-x64")); write(served / "payload.json", published("mcppls-0.0.19-linux-x64.json")); - CHECK(ls::payload_server(served, "linux-x64").command == std::vector({served / "bin" / "okrun", served / "bin" / "mcppls.okb"})); - CHECK(ls::payload_server(served, "darwin-arm64").problem.contains("serves linux-x64")); + check(ls::payload_server(served, "linux-x64").command == std::vector({served / "bin" / "okrun", served / "bin" / "mcppls.okb"})); + check(ls::payload_server(served, "darwin-arm64").problem.contains("serves linux-x64")); std::filesystem::remove_all(served); } // A refused payload named by the adapter is said, when nothing else is found. write(payload / "payload.json", R"({"payload-version": 5, "server": {"path": "bin/mcppls.okb"}, "platforms": {"plan9-x64": {"runner": "bin/okrun"}}})"); const auto refused = ls::candidates({payload.string()}); - CHECK(!refused.empty() && refused.front().command.empty() && refused.front().problem.contains("plan9-x64")); + check(!refused.empty() && refused.front().command.empty() && refused.front().problem.contains("plan9-x64")); // The adapter's payload is the candidate asked first, ahead of PATH and xlings. write(payload / "payload.json", R"({"server": {"path": "bin/mcppls"}})"); const auto found = ls::candidates({payload.string()}); - CHECK(!found.empty() && found.front().source == "editor" && found.front().command.back() == payload / "bin" / "mcppls"); + check(!found.empty() && found.front().source == "editor" && found.front().command.back() == payload / "bin" / "mcppls"); std::filesystem::remove_all(payload); std::println("test_languageservice: ok"); } diff --git a/tests/test_mcppls_status.cpp b/tests/test_mcppls_status.cpp index 88cf933..ec6cb35 100644 --- a/tests/test_mcppls_status.cpp +++ b/tests/test_mcppls_status.cpp @@ -2,14 +2,16 @@ // the frozen table for 0.0.18 and 0.0.19; mcppls's actions offered in each editor under the id // mcppls gives that editor, and none where it gives none; a change of state told once. The mock // mcppls sits in a payload made here, as an editor's own payload would. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.languageservice; import mcpplibs.os; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; namespace ls = mcppide::languageservice; @@ -58,16 +60,16 @@ int main() { { const auto r = ls::parse_status(R"({"schema": 1, "capabilities": ["status", 7], "workspace": {"state": "ready"}, "actions": [{"id": "restart", "vscode": "mcppls.restartServer"}, {"title": "no id"}]})"); - CHECK(r.source == "status"); - CHECK(r.capabilities == Json::array({"status"})); - CHECK(r.actions.size() == 1 && r.workspace["state"] == "ready"); + check(r.source == "status"); + check(r.capabilities == Json::array({"status"})); + check(r.actions.size() == 1 && r.workspace["state"] == "ready"); const auto broken = ls::parse_status("mcppls: unknown command status"); - CHECK(broken.source == "frozen" && !broken.detail.empty()); - CHECK(broken.actions == ls::frozen_actions()); + check(broken.source == "frozen" && !broken.detail.empty()); + check(broken.actions == ls::frozen_actions()); } const auto mock = testsupport::built("mcpp-ide-mock-mcppls"); - CHECK(!mock.empty()); + check(!mock.empty()); const auto base = testsupport::temp() / std::format("mcppide-mcppls-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto payload = base / "payload"; const auto root = base / "project"; @@ -88,7 +90,7 @@ int main() { auto result = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); return answer["result"]; }; const Json candidates = Json::array({payload.generic_string()}); @@ -99,38 +101,38 @@ int main() { // 0.0.19: no `status`; the actions are the frozen table's, and the workspace is not known. std::ofstream(payload / "mock-version") << "0.0.19\n"; auto old = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(old["state"] == "ok" && old["version"] == "0.0.19"); - CHECK(old["statusSource"] == "frozen" && old["workspace"].is_null()); - CHECK(old["serverActions"] == ls::frozen_actions()); + check(old["state"] == "ok" && old["version"] == "0.0.19"); + check(old["statusSource"] == "frozen" && old["workspace"].is_null()); + check(old["serverActions"] == ls::frozen_actions()); // 0.0.20: the status document. std::ofstream(payload / "mock-version") << "0.0.20\n"; std::ofstream(root / ".mock-mcppls-state") << "starting\n"; auto now = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(now["statusSource"] == "status"); - CHECK(now["workspace"]["state"] == "starting"); - CHECK(now["capabilities"] == Json::array({"status"})); - CHECK(now["serverActions"].size() == 3); + check(now["statusSource"] == "status"); + check(now["workspace"]["state"] == "starting"); + check(now["capabilities"] == Json::array({"status"})); + check(now["serverActions"].size() == 3); // The commands each editor is offered: mcppls's action under that editor's id, or not at all. auto jetbrains = result("mcpp-ide/commands/list", Json{{"editor", "jetbrains"}}); - CHECK(command(jetbrains, "mcpp.languageServer.restart")["action"]["command"] == "community.mcpp.mcppls.RestartServer"); - CHECK(command(jetbrains, "mcpp.languageServer.restartEngine").is_null()); + check(command(jetbrains, "mcpp.languageServer.restart")["action"]["command"] == "community.mcpp.mcppls.RestartServer"); + check(command(jetbrains, "mcpp.languageServer.restartEngine").is_null()); auto nvim = result("mcpp-ide/commands/list", Json{{"editor", "nvim"}}); - CHECK(command(nvim, "mcpp.languageServer.restart")["action"]["command"] == "McpplsRestart"); + check(command(nvim, "mcpp.languageServer.restart")["action"]["command"] == "McpplsRestart"); auto vscode = result("mcpp-ide/commands/list", Json{{"editor", "vscode"}}); - CHECK(command(vscode, "mcpp.languageServer.openLogFolder")["action"]["arguments"] == Json::array({"logs"})); - CHECK(command(vscode, "mcpp.languageServer.restartEngine")["action"]["command"] == "mcppls.restartClangd"); + check(command(vscode, "mcpp.languageServer.openLogFolder")["action"]["arguments"] == Json::array({"logs"})); + check(command(vscode, "mcpp.languageServer.restartEngine")["action"]["command"] == "mcppls.restartClangd"); auto cli = result("mcpp-ide/commands/list", Json{{"editor", "cli"}}); - CHECK(std::ranges::none_of(cli["commands"], [](const Json& c) { return c["action"].value("kind", "") == "forward"; })); + check(std::ranges::none_of(cli["commands"], [](const Json& c) { return c["action"].value("kind", "") == "forward"; })); // A change of state is told once; asking again with nothing changed tells nothing. const int before = wire.count("mcpp-ide/languageService/changed"); std::ofstream(root / ".mock-mcppls-state") << "ready\n"; auto ready = result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(ready["workspace"]["state"] == "ready"); + check(ready["workspace"]["state"] == "ready"); (void)result("mcpp-ide/languageService/status", Json{{"candidates", candidates}, {"root", root.generic_string()}}); - CHECK(wire.count("mcpp-ide/languageService/changed") == before + 1); + check(wire.count("mcpp-ide/languageService/changed") == before + 1); std::error_code ignored; std::filesystem::remove_all(base, ignored); diff --git a/tests/test_policy.cpp b/tests/test_policy.cpp index c10bc02..78aace4 100644 --- a/tests/test_policy.cpp +++ b/tests/test_policy.cpp @@ -1,27 +1,29 @@ // The cleanup table: every action's argv and its level, and that an action // needing a preview or an acknowledgement is not confirmed without it. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.policy; +using testsupport::check; + using namespace mcppide; int main() { policy::CleanOptions o; - for (auto a : policy::kActions) CHECK(policy::plan(a, o).has_value()); - CHECK(!policy::plan("everything", o)); + for (auto a : policy::kActions) check(policy::plan(a, o).has_value()); + check(!policy::plan("everything", o)); auto project = *policy::plan("project", o); - CHECK(project.level == 1 && project.argv == std::vector{"clean"} && project.requiresTrust); + check(project.level == 1 && project.argv == std::vector{"clean"} && project.requiresTrust); auto stale = *policy::plan("stale", o); - CHECK(stale.level == 2 && stale.previewSource == "mcpp" && stale.previewArgv.back() == "--dry-run"); + check(stale.level == 2 && stale.previewSource == "mcpp" && stale.previewArgv.back() == "--dry-run"); auto all = *policy::plan("cacheAll", o); - CHECK(all.level == 3 && all.acknowledge && all.previewSource == "inventory"); + check(all.level == 3 && all.acknowledge && all.previewSource == "inventory"); o.budgetGiB = 2; auto gc = *policy::plan("cacheGc", o); - CHECK((gc.argv == std::vector{"cache", "gc", "--max-size", "2GiB"})); - CHECK(!policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}})); - CHECK(policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}, {"acknowledged", true}})); - CHECK(policy::confirmed(*policy::plan("cacheVerify", o), nullptr)); + check((gc.argv == std::vector{"cache", "gc", "--max-size", "2GiB"})); + check(!policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}})); + check(policy::confirmed(all, nlohmann::json{{"confirmed", true}, {"previewShown", true}, {"acknowledged", true}})); + check(policy::confirmed(*policy::plan("cacheVerify", o), nullptr)); std::println("test_policy: ok"); } diff --git a/tests/test_probe.cpp b/tests/test_probe.cpp index 040b469..6e862a1 100644 --- a/tests/test_probe.cpp +++ b/tests/test_probe.cpp @@ -3,13 +3,15 @@ // XLINGS_PROJECT_DIR removed; features as mcpp states them, else from the frozen table; a pinned // version that is not installed; an untrusted workspace; and a job whose envelope names another // version, after which the project is asked again. The mock mcpp plays the shim per directory. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.mcpp; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -59,7 +61,7 @@ std::filesystem::path project(const std::filesystem::path& base, std::string_vie int main() { const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); + check(!mock.empty()); const auto base = testsupport::temp() / std::format("mcppide-probe-{}", std::chrono::steady_clock::now().time_since_epoch().count()); const auto old = project(base, "old", {{".mock-mcpp-version", "2026.10.29.1"}}); const auto sets = project(base, "sets", {{".mock-mcpp-version", "2026.10.30.1"}}); @@ -73,7 +75,7 @@ int main() { auto result = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); return answer["result"]; }; result("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, @@ -82,32 +84,32 @@ int main() { // Before features: the frozen table, by the version the project's mcpp answers. auto a = result("mcpp-ide/project/mcpp", Json{{"root", old.generic_string()}}); - CHECK(a["state"] == "ok" && a["version"] == "2026.10.29.1"); - CHECK(a["features"].empty() && a["featuresFrozen"] == true); - CHECK(a["fromSetting"] == true); + check(a["state"] == "ok" && a["version"] == "2026.10.29.1"); + check(a["features"].empty() && a["featuresFrozen"] == true); + check(a["fromSetting"] == true); auto b = result("mcpp-ide/project/mcpp", Json{{"root", sets.generic_string()}}); - CHECK(b["version"] == "2026.10.30.1" && b["featuresFrozen"] == true); - CHECK(b["features"] == Json::array({"target-sets", "pkg-format-list"})); + check(b["version"] == "2026.10.30.1" && b["featuresFrozen"] == true); + check(b["features"] == Json::array({"target-sets", "pkg-format-list"})); // Stated: taken at mcpp's word, a name the table does not hold included. auto c = result("mcpp-ide/project/mcpp", Json{{"root", stated.generic_string()}}); - CHECK(c["version"] == "2026.10.31.1" && c["featuresFrozen"] == false); - CHECK(c["features"] == Json::array({"target-sets", "pkg-format-list", "polyglot"})); + check(c["version"] == "2026.10.31.1" && c["featuresFrozen"] == false); + check(c["features"] == Json::array({"target-sets", "pkg-format-list", "polyglot"})); // A pinned version that is not installed: the shim's words and its install command. auto d = result("mcpp-ide/project/mcpp", Json{{"root", missing.generic_string()}}); - CHECK(d["state"] == "pinned-missing"); - CHECK(d["install"] == "xlings install mcpp@2026.8.17.1"); - CHECK(d["message"].get().find("not found") != std::string::npos); + check(d["state"] == "pinned-missing"); + check(d["install"] == "xlings install mcpp@2026.8.17.1"); + check(d["message"].get().find("not found") != std::string::npos); // Why a feature is not offered: the party, the capability and the version that has it. auto why = result("mcpp-ide/capabilities/explain", Json{{"root", old.generic_string()}}); bool explained = false; for (const auto& f : why["features"]) if (f["id"] == "targets.sets") { - CHECK(f["available"] == false); - CHECK(f["reason"]["source"] == "mcpp" && f["reason"]["need"] == "2026.10.30.1"); + check(f["available"] == false); + check(f["reason"]["source"] == "mcpp" && f["reason"]["need"] == "2026.10.30.1"); explained = true; } - CHECK(explained); + check(explained); // No run carries another project's pin: every run of mcpp removes XLINGS_PROJECT_DIR, which an // editor started from another project's terminal inherits (mcpp docs/50). The removal is the @@ -116,21 +118,21 @@ int main() { { mcpp::Client client(mock, mcpp::Protocol{}); const auto o = client.spawn_options(mcpp::RunOptions{}); - CHECK(std::ranges::any_of(o.env, [](const auto& kv) { return kv.first == "XLINGS_PROJECT_DIR" && !kv.second; })); + check(std::ranges::any_of(o.env, [](const auto& kv) { return kv.first == "XLINGS_PROJECT_DIR" && !kv.second; })); } // A job's envelope names another version: the project is asked again, and the editor told. std::ofstream(old / ".mock-mcpp-version") << "2026.10.31.1\n"; auto job = result("mcpp-ide/job/start", Json{{"kind", "build"}, {"root", old.generic_string()}}); for (int i = 0; i < 2000 && !wire.saw("mcpp-ide/project/mcppChanged"); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(wire.saw("mcpp-ide/project/mcppChanged")); + check(wire.saw("mcpp-ide/project/mcppChanged")); auto again = result("mcpp-ide/project/mcpp", Json{{"root", old.generic_string()}}); - CHECK(again["version"] == "2026.10.31.1"); + check(again["version"] == "2026.10.31.1"); (void)job; // The report names every project asked, with no secret in it. auto report = result("mcpp-ide/report/collect", Json::object()); - CHECK(report["kind"] == "mcpp-ide.report" && report["projects"].size() >= 4); + check(report["kind"] == "mcpp-ide.report" && report["projects"].size() >= 4); // An untrusted workspace: the pinned program is not run. Wire untrusted; @@ -139,9 +141,9 @@ int main() { auto answer = untrusted.call("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"trusted", false}, {"settings", {{"mcpp.path", mock.generic_string()}}}}}}); - CHECK(answer.contains("result")); + check(answer.contains("result")); auto e = untrusted.call("mcpp-ide/project/mcpp", Json{{"root", missing.generic_string()}}); - CHECK(e.contains("result") && e["result"]["state"] == "not-probed"); + check(e.contains("result") && e["result"]["state"] == "not-probed"); std::error_code ignored; std::filesystem::remove_all(base, ignored); diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 18061d6..e438aca 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -5,13 +5,15 @@ // protocol/protocol.schema.json, both ways; // - a session over the mock mcpp: each answer against its method's `result`, and // every notification a build and a test send against its `params`. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; import mcppide.server; import mcppide.schema; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -66,7 +68,7 @@ int main() { for (const auto& f : index["fixtures"]) { const auto path = testsupport::fixtures() / f["file"].get(); if (!std::filesystem::exists(path)) { - CHECK(f.value("optional", false)); + check(f.value("optional", false)); continue; } const auto text = testsupport::read(path); @@ -76,12 +78,12 @@ int main() { int n = 0; for (std::string line; std::getline(lines, line);) { if (line.empty()) continue; - CHECK(conforms(Json::parse(line), def, contract, std::format("{}:{}", f["file"].get(), ++n))); + check(conforms(Json::parse(line), def, contract, std::format("{}:{}", f["file"].get(), ++n))); } - CHECK(n > 0); + check(n > 0); } else { const Json def{{"$ref", "#/$defs/" + f["schema"].get()}}; - CHECK(conforms(Json::parse(text), def, contract, f["file"].get())); + check(conforms(Json::parse(text), def, contract, f["file"].get())); } } @@ -93,12 +95,12 @@ int main() { std::set sent(server::kNotifications.begin(), server::kNotifications.end()); for (const auto& m : methods) if (!registered.contains(m)) std::println(std::cerr, "schema method not registered: {}", m); for (const auto& m : registered) if (!methods.contains(m)) std::println(std::cerr, "registered method not in the schema: {}", m); - CHECK(methods == registered); - CHECK(notifications == sent); + check(methods == registered); + check(notifications == sent); // A session over the mock mcpp. const auto mock = testsupport::built("mcpp-ide-mock-mcpp"); - CHECK(!mock.empty()); + check(!mock.empty()); const auto root = testsupport::temp() / std::format("mcppide-protocol-{}", std::chrono::steady_clock::now().time_since_epoch().count()); std::filesystem::create_directories(root / "src"); std::ofstream(root / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; @@ -108,16 +110,16 @@ int main() { auto result_of = [&](std::string_view method, Json params) { auto answer = wire.call(method, std::move(params)); if (!answer.contains("result")) std::println(std::cerr, "{}: {}", method, answer.dump()); - CHECK(answer.contains("result")); + check(answer.contains("result")); const auto& def = protocol["methods"][std::string(method)]["result"]; - CHECK(conforms(answer["result"], def, protocol, method)); + check(conforms(answer["result"], def, protocol, method)); return answer["result"]; }; auto init = result_of("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(root)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"settings", {{"mcpp.path", mock.generic_string()}}}}}}); - CHECK(init["projects"].size() == 1); - CHECK(init["mcpp"].is_object() && init["mcpp"]["contract"] == true); - CHECK(init["capabilities"]["textDocumentSync"]["change"] == 1 && init["capabilities"]["hoverProvider"] == true); + check(init["projects"].size() == 1); + check(init["mcpp"].is_object() && init["mcpp"]["contract"] == true); + check(init["capabilities"]["textDocumentSync"]["change"] == 1 && init["capabilities"]["hoverProvider"] == true); // The rules layer as LSP: a manifest opened has its diagnostics published (0-based, UTF-16), and // completion, hover and the outline answer for it. @@ -126,64 +128,64 @@ int main() { wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didOpen"}, {"params", {{"textDocument", {{"uri", tomlUri}, {"languageId", "mcpp-toml"}, {"version", 1}, {"text", toml}}}}}}.dump()); auto published = wire.notifications("textDocument/publishDiagnostics"); - CHECK(!published.empty() && published.back()["uri"] == tomlUri && published.back()["diagnostics"].size() == 1); - CHECK(published.back()["diagnostics"][0]["range"]["start"] == Json({{"line", 2}, {"character", 0}})); - CHECK(published.back()["diagnostics"][0]["code"] == "mcpp.manifest.unknownKey"); + check(!published.empty() && published.back()["uri"] == tomlUri && published.back()["diagnostics"].size() == 1); + check(published.back()["diagnostics"][0]["range"]["start"] == Json({{"line", 2}, {"character", 0}})); + check(published.back()["diagnostics"][0]["code"] == "mcpp.manifest.unknownKey"); auto at = [&](int line, int character) { return Json{{"textDocument", {{"uri", tomlUri}}}, {"position", {{"line", line}, {"character", character}}}}; }; auto list = result_of("textDocument/completion", at(4, 16)); std::set labels; for (const auto& i : list["items"]) labels.insert(i["label"].get()); - CHECK(labels.contains("auto") && labels.contains("on") && labels.contains("off")); + check(labels.contains("auto") && labels.contains("on") && labels.contains("off")); auto hover = result_of("textDocument/hover", at(4, 2)); - CHECK(hover["contents"]["value"].get().find("bmi_schedule") != std::string::npos); + check(hover["contents"]["value"].get().find("bmi_schedule") != std::string::npos); auto outline = result_of("textDocument/documentSymbol", Json{{"textDocument", {{"uri", tomlUri}}}}); - CHECK(outline.size() == 2 && outline[1]["name"] == "[build]" && outline[1]["children"].size() == 1); + check(outline.size() == 2 && outline[1]["name"] == "[build]" && outline[1]["children"].size() == 1); // A change is the whole text; closing clears the document's diagnostics. wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didChange"}, {"params", {{"textDocument", {{"uri", tomlUri}, {"version", 2}}}, {"contentChanges", Json::array({{{"text", "[package]\n"}}})}}}}.dump()); - CHECK(wire.notifications("textDocument/publishDiagnostics").back()["diagnostics"].empty()); + check(wire.notifications("textDocument/publishDiagnostics").back()["diagnostics"].empty()); wire.server.dispatch(Json{{"jsonrpc", "2.0"}, {"method", "textDocument/didClose"}, {"params", {{"textDocument", {{"uri", tomlUri}}}}}}.dump()); - CHECK(result_of("textDocument/hover", at(0, 2)).is_null()); + check(result_of("textDocument/hover", at(0, 2)).is_null()); const Json rooted{{"root", root.generic_string()}}; result_of("mcpp-ide/project/summary", rooted); auto tests = result_of("mcpp-ide/test/list", rooted); - CHECK(tests["tests"].size() == 1 && tests["tests"][0]["test"] == "test_smoke"); + check(tests["tests"].size() == 1 && tests["tests"][0]["test"] == "test_smoke"); auto targets = result_of("mcpp-ide/targets/list", rooted); - CHECK(!targets["rows"].empty()); + check(!targets["rows"].empty()); auto cmds = result_of("mcpp-ide/commands/list", Json{{"editor", "jetbrains"}}); - for (const auto& c : cmds["commands"]) CHECK(c["action"]["kind"] != "forward"); + for (const auto& c : cmds["commands"]) check(c["action"]["kind"] != "forward"); // Neovim's table: no forward to mcppls's commands (mcppls-nvim has its own), no panel it has no // text for, its own quickfix command, and each row's subcommand (`:Mcpp `). const Json nvimInit{{"clientInfo", {{"name", "Neovim"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(root)}}})}, {"initializationOptions", Json{{"editor", "nvim"}, {"locale", "en_US.UTF-8"}, {"trusted", true}, {"settings", Json::object()}}}}; - CHECK(conforms(nvimInit, protocol["methods"]["initialize"]["params"], protocol, "initialize params (nvim)")); + check(conforms(nvimInit, protocol["methods"]["initialize"]["params"], protocol, "initialize params (nvim)")); std::map nvim; for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "nvim"}})["commands"]) { const auto kind = c["action"]["kind"].get(); - CHECK(kind != "forward" && kind != "editor"); - CHECK(!(kind == "panel" && c["action"]["panel"] == "settings")); + check(kind != "forward" && kind != "editor"); + check(!(kind == "panel" && c["action"]["panel"] == "settings")); nvim[c["subcommand"].get()] = c["id"].get(); } - CHECK(nvim["cancel"] == "mcpp.cancelJob" && nvim["build"] == "mcpp.build" && nvim["problems"] == "mcpp.problems"); - CHECK(nvim.contains("showMenu") && nvim.contains("library.search") && !nvim.contains("openSettings")); - for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "vscode"}})["commands"]) CHECK(c["id"] != "mcpp.problems"); + check(nvim["cancel"] == "mcpp.cancelJob" && nvim["build"] == "mcpp.build" && nvim["problems"] == "mcpp.problems"); + check(nvim.contains("showMenu") && nvim.contains("library.search") && !nvim.contains("openSettings")); + for (const auto& c : result_of("mcpp-ide/commands/list", Json{{"editor", "vscode"}})["commands"]) check(c["id"] != "mcpp.problems"); // A row that leaves Neovim out says why. for (const auto& c : load(testsupport::repo() / "spec" / "commands.json")["commands"]) { if (!c.contains("editors") || std::ranges::find(c["editors"], Json("nvim")) != c["editors"].end()) continue; if (c["action"]["kind"] == "editor") continue; const auto reason = c.contains("omitted") ? c["omitted"].value("nvim", "") : std::string{}; if (reason.empty()) std::println(std::cerr, "{} leaves nvim out without saying why", c["id"].get()); - CHECK(!reason.empty()); + check(!reason.empty()); } result_of("mcpp-ide/menu/quick", rooted); result_of("mcpp-ide/languageService/status", Json{{"candidates", Json::array()}}); // The pack panel's choices: the closures SPEC-014 names, and linkages only where mcpp takes them. auto pack = result_of("mcpp-ide/pack/formats", rooted); - CHECK(pack["modes"].size() == 4); + check(pack["modes"].size() == 4); const bool helpNamesLinkage = testsupport::read(testsupport::fixtures() / "pack-help.txt").find("--linkage") != std::string::npos; - CHECK(pack["linkages"].empty() != helpNamesLinkage); + check(pack["linkages"].empty() != helpNamesLinkage); result_of("mcpp-ide/graph/packages", rooted); result_of("mcpp-ide/graph/modules", rooted); // A failed database is an error, not an empty graph or a cached answer. @@ -196,21 +198,21 @@ int main() { const Json badRoot{{"root", badDatabase.generic_string()}}; for (const auto* method : {"mcpp-ide/graph/packages", "mcpp-ide/graph/modules"}) { const auto failed = wire.call(method, badRoot); - CHECK(failed.contains("error")); - CHECK(failed["error"]["code"] == rpc::REQUEST_FAILED); - CHECK(failed["error"]["data"]["exitCode"] == 1); - CHECK(!failed["error"]["data"]["diagnostics"].empty()); - CHECK(failed["error"]["message"].get().find("emit build-database") != std::string::npos); + check(failed.contains("error")); + check(failed["error"]["code"] == rpc::REQUEST_FAILED); + check(failed["error"]["data"]["exitCode"] == 1); + check(!failed["error"]["data"]["diagnostics"].empty()); + check(failed["error"]["message"].get().find("emit build-database") != std::string::npos); } std::filesystem::remove(badDatabase / ".mock-mcpp-fail"); - CHECK(!result_of("mcpp-ide/graph/packages", badRoot)["nodes"].empty()); - CHECK(!result_of("mcpp-ide/graph/modules", badRoot)["nodes"].empty()); + check(!result_of("mcpp-ide/graph/packages", badRoot)["nodes"].empty()); + check(!result_of("mcpp-ide/graph/modules", badRoot)["nodes"].empty()); result_of("mcpp-ide/manifest/check", rooted); result_of("mcpp-ide/settings/registry", Json::object()); result_of("mcpp-ide/selfCheck", Json::object()); result_of("mcpp-ide/cache/inventory", Json::object()); auto launch = result_of("mcpp-ide/run/launch", rooted); - CHECK(!launch["argv"].empty()); + check(!launch["argv"].empty()); result_of("mcpp-ide/debug/configuration", Json{{"root", root.generic_string()}, {"available", Json::array({"lldb"})}}); // Debug selection follows the configuration of the actual launch, not a // separately queried default profile or the first leg of a target set. @@ -243,8 +245,8 @@ int main() { {"triple", "x86_64-openkal"}, {"profile", "inspect"}, {"runners", Json::array({"debug"})}}; reset_launch(first, first); auto debug = result_of("mcpp-ide/debug/configuration", debugParams); - CHECK(commands().size() == 1); - CHECK(debug["launch"]["argv"] == Json::array({"okrun", "app.oke", "with spaces", ""})); + check(commands().size() == 1); + check(debug["launch"]["argv"] == Json::array({"okrun", "app.oke", "with spaces", ""})); } ordinary["data"]["configuration"] = {{"triple", "x86_64-openkal"}, {"profile", "inspect"}, {"platform", nullptr}, {"runners", Json::array({"debug", "debuggee"})}}; @@ -257,15 +259,15 @@ int main() { if (test) params["test"] = "unit/math"; auto debug = result_of("mcpp-ide/debug/configuration", params); auto calls = commands(); - CHECK(calls.size() == 2); - CHECK(calls[0].find("--target all ") != std::string::npos); - CHECK(calls[1].find("--target x86_64-openkal ") != std::string::npos); - CHECK(calls[1].find("--profile inspect ") != std::string::npos); - CHECK(calls[1].find("--runner debuggee ") != std::string::npos); - CHECK(calls[1].starts_with(test ? "test " : "run ")); - if (test) CHECK(calls[1].find("unit/math ") != std::string::npos); - CHECK(debug["launch"]["argv"] == Json::array({"okrun", "--debugger", "app.oke", "with spaces", ""})); - CHECK(debug["configuration"]["initCommands"] == Json::array({"settings set plugin.jit-loader.gdb.enable on"})); + check(calls.size() == 2); + check(calls[0].find("--target all ") != std::string::npos); + check(calls[1].find("--target x86_64-openkal ") != std::string::npos); + check(calls[1].find("--profile inspect ") != std::string::npos); + check(calls[1].find("--runner debuggee ") != std::string::npos); + check(calls[1].starts_with(test ? "test " : "run ")); + if (test) check(calls[1].find("unit/math ") != std::string::npos); + check(debug["launch"]["argv"] == Json::array({"okrun", "--debugger", "app.oke", "with spaces", ""})); + check(debug["configuration"]["initCommands"] == Json::array({"settings set plugin.jit-loader.gdb.enable on"})); } // Explicit runner choices, including an explicit default, are preserved. for (const auto* runner : {"", "monitor"}) { @@ -273,65 +275,65 @@ int main() { auto params = debugParams; params["runner"] = runner; result_of("mcpp-ide/debug/configuration", params); - CHECK(commands().size() == 1); + check(commands().size() == 1); } auto missing = ordinary; missing["data"]["configuration"].erase("profile"); reset_launch(missing, selected); - CHECK(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); - CHECK(commands().size() == 1); + check(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); + check(commands().size() == 1); for (const auto* field : {"triple", "profile", "platform"}) { auto changed = selected; changed["data"]["configuration"][field] = "different"; reset_launch(ordinary, changed); - CHECK(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); - CHECK(commands().size() == 2); + check(wire.call("mcpp-ide/debug/configuration", debugParams).contains("error")); + check(commands().size() == 2); } reset_launch(ordinary, selected); auto custom = rooted; custom["profile"] = "inspect"; result_of("mcpp-ide/run/launch", custom); - CHECK(commands().size() == 1 && commands()[0].find("--profile inspect ") != std::string::npos); + check(commands().size() == 1 && commands()[0].find("--profile inspect ") != std::string::npos); for (const auto& path : {launchFile, selectedFile, commandLog}) std::filesystem::remove(path); // What mcpp-vscode's own parsers read: one command's output as it printed it, and a project's // files with its source count. auto exec = result_of("mcpp-ide/mcpp/exec", Json{{"args", Json::array({"--version"})}, {"cwd", root.generic_string()}}); auto availability = result_of("mcpp-ide/mcpp/availability", Json::object()); - CHECK(availability["available"] == true && availability["missing"] == false); - CHECK(exec["exitCode"] == 0 && exec["stdout"].get().find("mcpp") != std::string::npos); - CHECK(exec["timedOut"] == false && exec["truncated"] == false); + check(availability["available"] == true && availability["missing"] == false); + check(exec["exitCode"] == 0 && exec["stdout"].get().find("mcpp") != std::string::npos); + check(exec["timedOut"] == false && exec["truncated"] == false); auto bounded = result_of("mcpp-ide/mcpp/exec", Json{{"args", Json::array({"--version"})}, {"maxOutputBytes", 3}}); - CHECK(bounded["truncated"] == true && bounded["stdout"].get().size() <= 3); - CHECK(wire.call("mcpp-ide/mcpp/exec", Json{{"args", "--version"}}).contains("error")); + check(bounded["truncated"] == true && bounded["stdout"].get().size() <= 3); + check(wire.call("mcpp-ide/mcpp/exec", Json{{"args", "--version"}}).contains("error")); // mcppls's own commands run as the command the status found (here a program that answers --version). result_of("mcpp-ide/languageService/status", Json{{"candidates", Json::array({mock.generic_string()})}}); auto ls = result_of("mcpp-ide/languageService/exec", Json{{"args", Json::array({"--version"})}}); - CHECK(ls["exitCode"] == 0 && !ls["stdout"].get().empty()); + check(ls["exitCode"] == 0 && !ls["stdout"].get().empty()); std::ofstream(root / "src" / "main.cpp") << "int main() {}\n"; std::ofstream(root / "src" / "notes.txt") << "not a source\n"; std::filesystem::create_directories(root / "target" / "obj"); std::ofstream(root / "target" / "obj" / "gen.cpp") << "// build output\n"; auto files = result_of("mcpp-ide/project/files", rooted); - CHECK(files["manifest"].get().starts_with("[package]") && files["lock"].is_null()); - CHECK(files["sourceFiles"] == 1); + check(files["manifest"].get().starts_with("[package]") && files["lock"].is_null()); + check(files["sourceFiles"] == 1); result_of("mcpp-ide/job/start", Json{{"kind", "build"}, {"root", root.generic_string()}}); result_of("mcpp-ide/job/start", Json{{"kind", "test"}, {"root", root.generic_string()}}); for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/job/finished").size() < 2; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(wire.notifications("mcpp-ide/job/finished").size() == 2); + check(wire.notifications("mcpp-ide/job/finished").size() == 2); for (const auto& name : server::kNotifications) { const auto& def = protocol["notifications"][std::string(name)]["params"]; - for (const auto& n : wire.notifications(name)) CHECK(conforms(n, def, protocol, name)); + for (const auto& n : wire.notifications(name)) check(conforms(n, def, protocol, name)); } - CHECK(!wire.notifications("mcpp-ide/job/plan").empty()); - CHECK(!wire.notifications("mcpp-ide/job/artifact").empty()); + check(!wire.notifications("mcpp-ide/job/plan").empty()); + check(!wire.notifications("mcpp-ide/job/artifact").empty()); const auto results = wire.notifications("mcpp-ide/test/result"); - CHECK(results.size() == 1 && results[0]["status"] == "passed"); - CHECK(wire.notifications("mcpp-ide/test/summary").size() == 1); + check(results.size() == 1 && results[0]["status"] == "passed"); + check(wire.notifications("mcpp-ide/test/summary").size() == 1); // The plan a build sent is the project's configuration from then on. auto after = result_of("mcpp-ide/project/summary", rooted); - CHECK(after.contains("configuration")); + check(after.contains("configuration")); // A launch job (design 2026-11-07 §4.9): mcpp's narration while it builds, and the launch in // job/finished, the program's arguments after `--`. With mcpp's launch-stream, the build's progress too. @@ -346,22 +348,22 @@ int main() { for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/job/finished").size() < finishedBefore + 2; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); const auto ends = wire.notifications("mcpp-ide/job/finished"); - CHECK(ends.size() == finishedBefore + 2); + check(ends.size() == finishedBefore + 2); for (const auto& id : {plain["jobId"], live["jobId"]}) { const auto end = std::ranges::find_if(ends, [&](const Json& n) { return n["jobId"] == id; }); - CHECK(end != ends.end() && (*end)["status"] == "ok" && (*end)["kind"] == "launch"); - if (end != ends.end()) CHECK((*end).contains("launch") && !(*end)["launch"]["argv"].empty()); + check(end != ends.end() && (*end)["status"] == "ok" && (*end)["kind"] == "launch"); + if (end != ends.end()) check((*end).contains("launch") && !(*end)["launch"]["argv"].empty()); const auto outputs = wire.notifications("mcpp-ide/job/output"); - CHECK(std::ranges::any_of(outputs, [&](const Json& n) { + check(std::ranges::any_of(outputs, [&](const Json& n) { return n["jobId"] == id && n["text"].get().find("Finished") != std::string::npos; })); } const auto progress = wire.notifications("mcpp-ide/job/progress"); - CHECK(progress.size() > progressBefore); - CHECK(std::ranges::all_of(progress.begin() + static_cast(progressBefore), progress.end(), + check(progress.size() > progressBefore); + check(std::ranges::all_of(progress.begin() + static_cast(progressBefore), progress.end(), [&](const Json& n) { return n["jobId"] == live["jobId"]; })); for (const auto& name : {"mcpp-ide/job/finished", "mcpp-ide/job/progress", "mcpp-ide/job/output"}) { const auto& def = protocol["notifications"][name]["params"]; - for (const auto& n : wire.notifications(name)) CHECK(conforms(n, def, protocol, name)); + for (const auto& n : wire.notifications(name)) check(conforms(n, def, protocol, name)); } std::filesystem::remove_all(streamed); std::filesystem::remove_all(root); diff --git a/tests/test_rpc.cpp b/tests/test_rpc.cpp index 112ac33..b24b58b 100644 --- a/tests/test_rpc.cpp +++ b/tests/test_rpc.cpp @@ -1,11 +1,13 @@ // The wire: frames split anywhere reassemble, a header without a length is // skipped, a request before initialize is refused, an unknown method answers // METHOD_NOT_FOUND, a cancelled request answers REQUEST_CANCELLED. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.rpc; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -17,10 +19,10 @@ int main() { const auto all = "Bogus: 1\r\n\r\n" + a + b; for (char c : all) r.feed(std::string_view(&c, 1)); auto first = r.next(); - CHECK(first && Json::parse(*first)["id"] == 1); + check(first && Json::parse(*first)["id"] == 1); auto second = r.next(); - CHECK(second && Json::parse(*second)["id"] == 2); - CHECK(!r.next()); + check(second && Json::parse(*second)["id"] == 2); + check(!r.next()); } { std::mutex m; @@ -42,20 +44,20 @@ int main() { return Json{{"done", true}}; }); s.dispatch(R"({"jsonrpc":"2.0","id":1,"method":"slow"})"); - CHECK(out.back()["error"]["code"] == rpc::NOT_INITIALIZED); + check(out.back()["error"]["code"] == rpc::NOT_INITIALIZED); s.dispatch(R"({"jsonrpc":"2.0","id":2,"method":"initialize"})"); - CHECK(out.back()["result"]["ok"] == true); + check(out.back()["result"]["ok"] == true); s.dispatch(R"({"jsonrpc":"2.0","id":3,"method":"nope"})"); - CHECK(out.back()["error"]["code"] == rpc::METHOD_NOT_FOUND); + check(out.back()["error"]["code"] == rpc::METHOD_NOT_FOUND); s.dispatch(R"({"jsonrpc":"2.0","id":4,"method":"slow"})"); s.dispatch(R"({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":4}})"); s.drain(); bool cancelled = false; for (const auto& msg : out) cancelled = cancelled || (msg.value("id", 0) == 4 && msg.contains("error") && msg["error"]["code"] == rpc::REQUEST_CANCELLED); - CHECK(!cancelled); // the handler answered (it saw the flag) with a result, which is kept + check(!cancelled); // the handler answered (it saw the flag) with a result, which is kept s.dispatch("not json"); - CHECK(out.back()["error"]["code"] == rpc::PARSE_ERROR); + check(out.back()["error"]["code"] == rpc::PARSE_ERROR); } std::println("test_rpc: ok"); } diff --git a/tests/test_rules.cpp b/tests/test_rules.cpp index b768353..9e8fd28 100644 --- a/tests/test_rules.cpp +++ b/tests/test_rules.cpp @@ -3,14 +3,16 @@ // scanner's structure and cursor contexts, the completions, hovers, jumps, version contexts and // diagnostics equal tests/rules/golden, which that implementation wrote (with the documents of its // parser tests, golden/snippets.json). A difference is printed with its file and position. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.text; import mcppide.manifest; import mcppide.buildscript; import mcppide.toml; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -279,7 +281,7 @@ int main() { std::vector files; for (const auto& e : std::filesystem::directory_iterator(root / "corpus")) files.push_back(e.path()); std::ranges::sort(files); - CHECK(!files.empty()); + check(!files.empty()); for (const auto& f : files) { const auto golden = Json::parse(testsupport::read(root / "golden" / (f.filename().string() + ".json"))); if (f.extension() == ".toml") check_manifest(f, golden); @@ -297,19 +299,19 @@ int main() { Json{{"name", "zlibx"}, {"namespace", "x"}, {"versions", {"9.0.0"}}}, })}}}}; const auto versions = manifest::catalogue_versions(envelope, "zlib"); - CHECK(versions.size() == 3 && versions[0].version == "1.3.1" && versions[2].version == "1.2.13"); - CHECK(versions[0].summary && *versions[0].summary == "A compression library"); + check(versions.size() == 3 && versions[0].version == "1.3.1" && versions[2].version == "1.2.13"); + check(versions[0].summary && *versions[0].summary == "A compression library"); const auto lines = text::lines_of("[dependencies]\nzlib = \"1.\n"); const auto at = manifest::dependency_version_context(lines, 1, 9); - CHECK(at && at->name == "zlib" && at->insideString); + check(at && at->name == "zlib" && at->insideString); const auto suggestions = manifest::version_suggestions(*at, versions); - CHECK(suggestions.size() == 3 && suggestions[0].insertSnippet == "1.3.1" && suggestions[0].kind == "version"); + check(suggestions.size() == 3 && suggestions[0].insertSnippet == "1.3.1" && suggestions[0].kind == "version"); // A suggestion's detail in the user's language. const auto zh = manifest::completions(text::lines_of("[dependencies]\n"), 1, 0, "zh-cn"); - CHECK(!zh.empty() && zh[0].detail != "SemVer version dependency"); + check(!zh.empty() && zh[0].detail != "SemVer version dependency"); if (failures) std::println(std::cerr, "{} mismatch(es)", failures); - CHECK(failures == 0); + check(failures == 0); std::println("rules: the goldens hold"); return 0; } diff --git a/tests/test_settings.cpp b/tests/test_settings.cpp index 8bfe7b9..0b15db3 100644 --- a/tests/test_settings.cpp +++ b/tests/test_settings.cpp @@ -1,28 +1,30 @@ // The registry: defaults, rejections, renames, presets, and the runtime strings. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.settings; import mcppide.i18n; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; int main() { auto r = settings::resolve(Json{{"mcpp.build.profile", "fast"}, {"mcpp.cache.topN", 3}, {"mcpp.task.buildArgs", Json::array({1})}}); - CHECK(r["values"]["mcpp.build.profile"] == "dev"); - CHECK(r["values"]["mcpp.cache.topN"] == 3 || r["values"].contains("mcpp.views.cache.topN")); + check(r["values"]["mcpp.build.profile"] == "dev"); + check(r["values"]["mcpp.cache.topN"] == 3 || r["values"].contains("mcpp.views.cache.topN")); bool enumRejected = false, itemsRejected = false; for (const auto& x : r["rejected"]) { enumRejected = enumRejected || (x["key"] == "mcpp.build.profile" && x["reason"] == "enum"); itemsRejected = itemsRejected || (x["key"] == "mcpp.task.buildArgs" && x["reason"] == "items"); } - CHECK(enumRejected && itemsRejected); + check(enumRejected && itemsRejected); auto reg = settings::registry(); - CHECK(reg["settings"].size() > 60 && reg["presets"].size() >= 3); - CHECK(settings::preset("quiet").has_value() && !settings::preset("nope")); - CHECK(i18n::normalize_locale("zh_CN") == "zh-cn" && i18n::normalize_locale("de") == "en"); - CHECK(i18n::translate("{0} and {1}", "en", {"a", "b"}) == "a and b"); - CHECK(i18n::translate("Actions", "zh-cn") == "操作"); + check(reg["settings"].size() > 60 && reg["presets"].size() >= 3); + check(settings::preset("quiet").has_value() && !settings::preset("nope")); + check(i18n::normalize_locale("zh_CN") == "zh-cn" && i18n::normalize_locale("de") == "en"); + check(i18n::translate("{0} and {1}", "en", {"a", "b"}) == "a and b"); + check(i18n::translate("Actions", "zh-cn") == "操作"); std::println("test_settings: ok"); } diff --git a/tests/test_testing.cpp b/tests/test_testing.cpp index 347c97e..0a0fe6e 100644 --- a/tests/test_testing.cpp +++ b/tests/test_testing.cpp @@ -3,14 +3,16 @@ // and its macros; cases named as each framework names them, listed names attached to them, the // filter escaped by the framework's rules, and the three report formats read back. And the // backend's source/entries and test/cases over a project on disk. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.scan; import mcppide.testing; import mcppide.rpc; import mcppide.server; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -18,7 +20,7 @@ namespace { const testing::Framework& fw(std::string_view id) { const auto* f = testing::framework(id); - CHECK(f != nullptr); + check(f != nullptr); return *f; } @@ -37,9 +39,9 @@ namespace n { int main(); } int main(int argc, char** argv) { return 0; } )", {}); - CHECK(r.mainLine && *r.mainLine == 8); - CHECK(!scan::scan("void f() { obj.main(); std::main(); }", {}).mainLine); - CHECK(scan::scan("export auto main() -> int { return 0; }", {}).mainLine == 0); + check(r.mainLine && *r.mainLine == 8); + check(!scan::scan("void f() { obj.main(); std::main(); }", {}).mainLine); + check(scan::scan("export auto main() -> int { return 0; }", {}).mainLine == 0); } // Macro calls at the start of a declaration, with their arguments. const auto macros = testing::macros(Json::object()); @@ -52,62 +54,62 @@ void helper() { auto x = TEST(1, 2); } TEST(Math, Spans) {} )", macros); - CHECK(r.calls.size() == 3); - CHECK(r.calls[0].name == "TEST" && r.calls[0].line == 1 && r.calls[0].arguments == std::vector({"Math", "Adds"})); - CHECK(r.calls[1].name == "TEST_P" && r.calls[1].line == 3); - CHECK(r.calls[2].line == 5 && r.calls[2].arguments[1] == "Spans"); - CHECK(scan::literal_value(R"("a \"b\"" " c")") == "a \"b\" c"); + check(r.calls.size() == 3); + check(r.calls[0].name == "TEST" && r.calls[0].line == 1 && r.calls[0].arguments == std::vector({"Math", "Adds"})); + check(r.calls[1].name == "TEST_P" && r.calls[1].line == 3); + check(r.calls[2].line == 5 && r.calls[2].arguments[1] == "Spans"); + check(scan::literal_value(R"("a \"b\"" " c")") == "a \"b\" c"); } // A program's framework: its package in the plan and its macros in its source. { const auto gtestSource = scan::scan("TEST(A, B) {}\nTEST_F(F, C) {}\n", macros); - CHECK(testing::framework_of({"app", "compat.gtest"}, gtestSource, Json::object()) == &fw("gtest")); - CHECK(testing::framework_of({"app"}, gtestSource, Json::object()) == nullptr); + check(testing::framework_of({"app", "compat.gtest"}, gtestSource, Json::object()) == &fw("gtest")); + check(testing::framework_of({"app"}, gtestSource, Json::object()) == nullptr); const auto ownMain = scan::scan("int main() { return 0; }\n", macros); - CHECK(testing::framework_of({"compat.gtest"}, ownMain, Json::object()) == nullptr); + check(testing::framework_of({"compat.gtest"}, ownMain, Json::object()) == nullptr); // A macro of the project's own, named by mcpp.test.macros. const Json extra{{"gtest", Json::array({"MY_TEST"})}}; const auto own = scan::scan("MY_TEST(S, N) {}\n", testing::macros(extra)); - CHECK(testing::framework_of({"compat.gtest"}, own, extra) == &fw("gtest")); + check(testing::framework_of({"compat.gtest"}, own, extra) == &fw("gtest")); const auto cases = testing::static_cases(fw("gtest"), own, extra); - CHECK(cases.size() == 1 && cases[0].name == "S.N" && cases[0].line == 0); + check(cases.size() == 1 && cases[0].name == "S.N" && cases[0].line == 0); // Catch2 and doctest share TEST_CASE; the package decides. const auto both = scan::scan("TEST_CASE(\"adds\", \"[math]\") {}\nSCENARIO(\"grows\") {}\n", macros); - CHECK(testing::framework_of({"compat.catch2"}, both, Json::object()) == &fw("catch2")); - CHECK(testing::framework_of({"compat.doctest"}, both, Json::object()) == &fw("doctest")); + check(testing::framework_of({"compat.catch2"}, both, Json::object()) == &fw("catch2")); + check(testing::framework_of({"compat.doctest"}, both, Json::object()) == &fw("doctest")); const auto catchCases = testing::static_cases(fw("catch2"), both, Json::object()); - CHECK(catchCases.size() == 2 && catchCases[0].name == "adds" && catchCases[1].name == "Scenario: grows"); + check(catchCases.size() == 2 && catchCases[0].name == "adds" && catchCases[1].name == "Scenario: grows"); const auto doctestCases = testing::static_cases(fw("doctest"), both, Json::object()); - CHECK(doctestCases[1].name == " Scenario: grows"); + check(doctestCases[1].name == " Scenario: grows"); } // Listings, base names and the merge. { const auto names = testing::listed(fw("gtest"), "Math.\n Adds\n Spans\nInst/Param.\n Works/0 # GetParam() = 1\n Works/1 # GetParam() = 2\nTyped/0. # TypeParam = int\n Holds\n"); - CHECK(names == std::vector({"Math.Adds", "Math.Spans", "Inst/Param.Works/0", "Inst/Param.Works/1", "Typed/0.Holds"})); - CHECK(testing::base_name(fw("gtest"), "Inst/Param.Works/1") == "Param.Works"); - CHECK(testing::base_name(fw("gtest"), "Typed/0.Holds") == "Typed.Holds"); + check(names == std::vector({"Math.Adds", "Math.Spans", "Inst/Param.Works/0", "Inst/Param.Works/1", "Typed/0.Holds"})); + check(testing::base_name(fw("gtest"), "Inst/Param.Works/1") == "Param.Works"); + check(testing::base_name(fw("gtest"), "Typed/0.Holds") == "Typed.Holds"); const std::vector statics{{"Math.Adds", 1, "static"}, {"Param.Works", 3, "static"}, {"Math.Spans", 5, "static"}}; const auto merged = testing::merge(statics, names, fw("gtest")); - CHECK(merged[0].name == "Math.Adds" && merged[0].source == "listed"); - CHECK(merged[1].name == "Inst/Param.Works/0" && merged[1].line == 3); - CHECK(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Inst/Param.Works/1" && c.line == 3; })); - CHECK(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Typed/0.Holds" && c.line == -1; })); + check(merged[0].name == "Math.Adds" && merged[0].source == "listed"); + check(merged[1].name == "Inst/Param.Works/0" && merged[1].line == 3); + check(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Inst/Param.Works/1" && c.line == 3; })); + check(std::ranges::any_of(merged, [](const auto& c) { return c.name == "Typed/0.Holds" && c.line == -1; })); const auto listedCatch = testing::listed(fw("catch2"), R"({"version": 1, "listings": {"tests": [{"name": "adds"}, {"name": "a, b"}]}})"); - CHECK(listedCatch == std::vector({"adds", "a, b"})); + check(listedCatch == std::vector({"adds", "a, b"})); const auto listedDoctest = testing::listed(fw("doctest"), "[doctest] doctest version is \"2.4.12\"\n===\nadds\n Scenario: grows\n===\n[doctest] unskipped test cases passing the current filters: 2\n"); - CHECK(listedDoctest == std::vector({"adds", " Scenario: grows"})); + check(listedDoctest == std::vector({"adds", " Scenario: grows"})); } // The arguments after `--`: the filter escaped by the framework's rules, and the report. { const auto g = testing::run_args(fw("gtest"), {"Math.Adds", "Inst/Param.Works/0"}, "/r/out"); - CHECK(g == std::vector({"--gtest_filter=Math.Adds:*/Math.Adds/*:Math/*.Adds:Inst/Param.Works/0", "--gtest_output=json:/r/out"})); + check(g == std::vector({"--gtest_filter=Math.Adds:*/Math.Adds/*:Math/*.Adds:Inst/Param.Works/0", "--gtest_output=json:/r/out"})); const auto c = testing::run_args(fw("catch2"), {"a, b", "x[1]"}, "/r/out"); - CHECK(c == std::vector({"a\\, b,x\\[1\\]", "--reporter", "json::out=/r/out"})); + check(c == std::vector({"a\\, b,x\\[1\\]", "--reporter", "json::out=/r/out"})); const auto d = testing::run_args(fw("doctest"), {}, "/r/out"); - CHECK(d == std::vector({"--reporters=junit", "--out=/r/out"})); + check(d == std::vector({"--reporters=junit", "--out=/r/out"})); } // Reports. @@ -116,23 +118,23 @@ TEST(Math, {"name": "Adds", "status": "RUN", "result": "COMPLETED", "time": "0.002s"}, {"name": "Spans", "status": "RUN", "result": "COMPLETED", "time": "0s", "failures": [{"failure": "a.cpp:3\nExpected 2"}]}, {"name": "Later", "status": "RUN", "result": "SKIPPED", "time": "0s"}]}]})"); - CHECK(g.size() == 3 && g[0].name == "Math.Adds" && g[0].status == "passed" && g[0].durationMs == 2); - CHECK(g[1].status == "failed" && g[1].message.find("Expected 2") != std::string::npos); - CHECK(g[2].status == "skipped"); + check(g.size() == 3 && g[0].name == "Math.Adds" && g[0].status == "passed" && g[0].durationMs == 2); + check(g[1].status == "failed" && g[1].message.find("Expected 2") != std::string::npos); + check(g[2].status == "skipped"); const auto c = testing::read_report(fw("catch2"), R"({"version": 1, "test-run": {"test-cases": [ {"test-info": {"name": "adds"}, "runs": [], "totals": {"assertions": {"passed": 1, "failed": 0}}}, {"test-info": {"name": "grows"}, "runs": [{"path": [{"kind": "assertion", "status": false, "source-location": {"filename": "g.cpp", "line": 7}}]}], "totals": {"assertions": {"passed": 0, "failed": 1}}}]}})"); - CHECK(c.size() == 2 && c[0].status == "passed" && c[1].status == "failed" && c[1].message.starts_with("g.cpp:7")); + check(c.size() == 2 && c[0].status == "passed" && c[1].status == "failed" && c[1].message.starts_with("g.cpp:7")); const auto d = testing::read_report(fw("doctest"), R"( - + )"); - CHECK(d.size() == 3 && d[0].status == "passed" && d[0].durationMs == 1); - CHECK(d[1].name == "a & b" && d[1].status == "failed" && d[1].message.find("NOT correct") != std::string::npos); - CHECK(d[2].status == "skipped"); + check(d.size() == 3 && d[0].status == "passed" && d[0].durationMs == 1); + check(d[1].name == "a & b" && d[1].status == "failed" && d[1].message.find("NOT correct") != std::string::npos); + check(d[2].status == "skipped"); // Each case's own output, by GoogleTest's boundaries (D16); none for a framework without them. const auto outputs = testing::case_outputs(fw("gtest"), "Running main() from gtest_main.cc\n" @@ -140,11 +142,11 @@ TEST(Math, "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n" "[ RUN ] Math.Spans\na.cpp:3: Failure\nExpected 2\n[ FAILED ] Math.Spans (0 ms)\n" "[ RUN ] Math.Crashes\nhalf way\n"); - CHECK(outputs.size() == 3); - CHECK(outputs.at("Math.Adds") == "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n"); - CHECK(outputs.at("Math.Spans").contains("a.cpp:3: Failure") && outputs.at("Math.Spans").ends_with("[ FAILED ] Math.Spans (0 ms)\n")); - CHECK(outputs.at("Math.Crashes") == "[ RUN ] Math.Crashes\nhalf way\n"); - CHECK(testing::case_outputs(fw("catch2"), "[ RUN ] x\n").empty()); + check(outputs.size() == 3); + check(outputs.at("Math.Adds") == "[ RUN ] Math.Adds\nadding\n[ OK ] Math.Adds (2 ms)\n"); + check(outputs.at("Math.Spans").contains("a.cpp:3: Failure") && outputs.at("Math.Spans").ends_with("[ FAILED ] Math.Spans (0 ms)\n")); + check(outputs.at("Math.Crashes") == "[ RUN ] Math.Crashes\nhalf way\n"); + check(testing::case_outputs(fw("catch2"), "[ RUN ] x\n").empty()); } // source/entries and test/cases over a project on disk (no mcpp: the plan's packages are not @@ -173,19 +175,19 @@ TEST(Math, answers.clear(); wire.dispatch(Json{{"jsonrpc", "2.0"}, {"id", 1}, {"method", method}, {"params", std::move(params)}}.dump()); for (int i = 0; i < 2000 && answers.empty(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); - CHECK(!answers.empty() && answers.back().contains("result")); + check(!answers.empty() && answers.back().contains("result")); return answers.back()["result"]; }; (void)call("initialize", Json{{"clientInfo", {{"name", "test"}}}, {"workspaceFolders", Json::array({Json{{"uri", testsupport::file_uri(base)}}})}, {"initializationOptions", Json{{"editor", "vscode"}, {"trusted", false}, {"settings", {{"mcpp.path", "/no/such/mcpp"}}}}}}); auto program = call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "main.cpp").generic_string()}}); - CHECK(program["kind"] == "program" && program["main"]["line"] == 2 && program["main"]["approximate"] == true); - CHECK(call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "lib.cpp").generic_string()}})["kind"] == "none"); + check(program["kind"] == "program" && program["main"]["line"] == 2 && program["main"]["approximate"] == true); + check(call("mcpp-ide/source/entries", Json{{"file", (base / "src" / "lib.cpp").generic_string()}})["kind"] == "none"); auto test = call("mcpp-ide/source/entries", Json{{"file", (base / "tests" / "unit" / "math.cpp").generic_string()}}); - CHECK(test["kind"] == "test" && test["test"] == "unit/math" && !test.contains("framework")); + check(test["kind"] == "test" && test["test"] == "unit/math" && !test.contains("framework")); auto cases = call("mcpp-ide/test/cases", Json{{"root", base.generic_string()}, {"test", "unit/math"}}); - CHECK(cases["framework"].is_null() && cases["cases"].empty()); + check(cases["framework"].is_null() && cases["cases"].empty()); std::error_code ignored; std::filesystem::remove_all(base, ignored); } diff --git a/tests/test_views.cpp b/tests/test_views.cpp index 0d6cfa2..73e2399 100644 --- a/tests/test_views.cpp +++ b/tests/test_views.cpp @@ -1,10 +1,12 @@ // The view models, from the golden fixtures: what each panel and tree shows. -#include "support.hpp" import std; +import mcppide.testsupport; import nlohmann.json; import mcppide.views; import mcppide.policy; +using testsupport::check; + using namespace mcppide; using Json = nlohmann::json; @@ -15,145 +17,145 @@ int main() { views::CacheOptions o; o.topN = 3; auto inv = views::cache_inventory(cache, o); - CHECK(inv["totalEntries"].get() == static_cast(cache["data"]["entries"].size())); - CHECK(inv["topLabels"].size() <= 3); + check(inv["totalEntries"].get() == static_cast(cache["data"]["entries"].size())); + check(inv["topLabels"].size() <= 3); std::int64_t sum = 0; for (const auto& b : inv["ageBuckets"]) sum += b["entries"].get(); - CHECK(sum <= inv["totalEntries"].get()); - CHECK(inv["ageBuckets"].size() == 4); - for (const auto& k : inv["byKind"]) CHECK(k["bytesText"].is_string()); - for (const auto& b : inv["ageBuckets"]) CHECK(b["bytesText"].is_string()); + check(sum <= inv["totalEntries"].get()); + check(inv["ageBuckets"].size() == 4); + for (const auto& k : inv["byKind"]) check(k["bytesText"].is_string()); + for (const auto& b : inv["ageBuckets"]) check(b["bytesText"].is_string()); policy::CleanOptions co; auto all = views::cache_preview(cache, *policy::plan("cacheAll", co), co); - CHECK(all["count"].get() == cache["data"]["entries"].size()); + check(all["count"].get() == cache["data"]["entries"].size()); auto pkgs = views::cache_preview(cache, *policy::plan("cacheDeps", co), co); - for (const auto& e : pkgs["entries"]) CHECK(e["kind"] == "pkg"); + for (const auto& e : pkgs["entries"]) check(e["kind"] == "pkg"); co.budgetGiB = 0.000001; auto gc = views::cache_preview(cache, *policy::plan("cacheGc", co), co); - CHECK(gc["count"].get() >= 0); + check(gc["count"].get() >= 0); auto tc = views::toolchains(fixture("toolchain-list.json")); - CHECK(!tc["rows"].empty() && tc["defaults"].size() >= 1); + check(!tc["rows"].empty() && tc["defaults"].size() >= 1); for (const auto& r : tc["rows"]) { const auto spec = r["spec"].get(); - if (r["toolchain"].get().find(' ') == std::string::npos) CHECK(spec.empty()); - else CHECK(spec.find('@') != std::string::npos); + if (r["toolchain"].get().find(' ') == std::string::npos) check(spec.empty()); + else check(spec.find('@') != std::string::npos); } auto search = fixture("search.json"); auto lib = views::library(search, views::LibraryQuery{}); - CHECK(lib["total"].get() == search["data"]["packages"].size()); + check(lib["total"].get() == search["data"]["packages"].size()); const auto firstId = lib["packages"][0]["id"].get(); - CHECK(!views::library_detail(search, firstId).is_null()); + check(!views::library_detail(search, firstId).is_null()); auto filtered = views::library(search, views::LibraryQuery{"zzzz-no-such-package"}); - CHECK(filtered["total"] == 0); + check(filtered["total"] == 0); auto db = fixture("build-database.json"); auto pg = views::package_graph(db); - CHECK(pg["nodes"].size() >= 2 && !pg["edges"].empty()); + check(pg["nodes"].size() >= 2 && !pg["edges"].empty()); auto mg = views::module_graph(db); bool std_node = false; for (const auto& n : mg["nodes"]) std_node = std_node || n["id"] == "std"; - CHECK(std_node); + check(std_node); auto summary = views::project_summary(db, Json{{"record", fixture("result-ok.json")["data"]}}); - CHECK(summary["targets"].size() == 1 && summary["tests"].size() == 1); - CHECK(summary["lastBuild"]["status"] == "ok" && !summary["artifacts"].empty()); - CHECK(views::format_bytes(1536) == "1.5 KiB"); + check(summary["targets"].size() == 1 && summary["tests"].size() == 1); + check(summary["lastBuild"]["status"] == "ok" && !summary["artifacts"].empty()); + check(views::format_bytes(1536) == "1.5 KiB"); // Targets: one row per target, the selected one marked, and the platform a row names (the shape // of mcpp's platform object, design 2026-10-28 §9) carried and rendered. auto list = fixture("toolchain-list.json"); auto t = views::targets(list, ""); - CHECK(!t["rows"].empty()); + check(!t["rows"].empty()); std::set seen; int selected = 0; for (const auto& r : t["rows"]) { - CHECK(seen.insert(r["target"].get()).second); - if (r["selected"].get()) { ++selected; CHECK(r["target"] == t["host"]); } - CHECK(r["platform"].is_null() == r["platformText"].get().empty()); + check(seen.insert(r["target"].get()).second); + if (r["selected"].get()) { ++selected; check(r["target"] == t["host"]); } + check(r["platform"].is_null() == r["platformText"].get().empty()); } - CHECK(selected == 1); + check(selected == 1); // A row names its platform as the target table does, a package spec (mcpp SPEC-013); the view // renders it as a plan's platform object. bool found = false; for (const auto& r : t["rows"]) if (r["target"] == "x86_64-openkal") { found = true; - CHECK(r["platform"]["name"] == "mcpplibs.openkal-platform"); - CHECK(r["platformText"].get().starts_with("mcpplibs.openkal-platform 0.17")); - CHECK(r["platformText"].get().ends_with("(target table)")); + check(r["platform"]["name"] == "mcpplibs.openkal-platform"); + check(r["platformText"].get().starts_with("mcpplibs.openkal-platform 0.17")); + check(r["platformText"].get().ends_with("(target table)")); } - CHECK(found); + check(found); list["data"]["hostPlatform"] = Json{{"name", ""}, {"version", nullptr}, {"source", "built-in"}}; auto withPlatform = views::targets(list, "x86_64-openkal"); for (const auto& r : withPlatform["rows"]) - if (r["target"] == "x86_64-openkal") CHECK(r["selected"] == true); - CHECK(withPlatform["hostPlatformText"] == "host conventions (built-in)"); + if (r["target"] == "x86_64-openkal") check(r["selected"] == true); + check(withPlatform["hostPlatformText"] == "host conventions (built-in)"); // Target sets (mcpp SPEC-015): offered only when the installed mcpp names them; one row per // group of targets of one platform that differ only in the architecture, before its members. - for (const auto& r : t["rows"]) CHECK(!r.contains("set")); + for (const auto& r : t["rows"]) check(!r.contains("set")); auto withSets = views::targets(list, "openkal", true); std::size_t setAt = 0, memberAt = 0, i = 0; bool setSelected = false; for (const auto& r : withSets["rows"]) { if (r["target"] == "openkal") { setAt = i; - CHECK(r["set"] == true); - CHECK(r["members"].size() >= 2); + check(r["set"] == true); + check(r["members"].size() >= 2); // The members in the target table's order. - CHECK(r["label"] == "openkal (aarch64, x86_64)"); + check(r["label"] == "openkal (aarch64, x86_64)"); setSelected = r["selected"].get(); } if (r["target"] == "x86_64-openkal" && memberAt == 0) memberAt = i; ++i; } - CHECK(setSelected && setAt < memberAt); + check(setSelected && setAt < memberAt); // A planned row is no member: linux-musl is x86_64 and aarch64, not the planned riscv64. for (const auto& r : withSets["rows"]) - if (r["target"] == "linux-musl") CHECK(r["members"].size() == 2); + if (r["target"] == "linux-musl") check(r["members"].size() == 2); // Stream records: each line of a build and of a test, normalised. std::istringstream build(testsupport::read(testsupport::fixtures() / "build-failed.ndjson")); std::set types; for (std::string line; std::getline(build, line);) if (!line.empty()) types.insert(views::stream_record(Json::parse(line))["type"].get()); - CHECK(types.contains("plan") && types.contains("progress") && types.contains("diagnostic")); + check(types.contains("plan") && types.contains("progress") && types.contains("diagnostic")); std::istringstream test(testsupport::read(testsupport::fixtures() / "test.ndjson")); std::string first; std::getline(test, first); auto r = views::stream_record(Json::parse(first)); - CHECK(r["type"] == "test" && r["status"] == "passed" && r["test"] == "test_smoke"); + check(r["type"] == "test" && r["status"] == "passed" && r["test"] == "test_smoke"); // mcpp's statuses, value for value (D16): pass, run_fail (timed_out), compile_fail, not_run, built. auto timedOut = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, {"timed_out", true}}); - CHECK(timedOut["status"] == "timedOut" && timedOut["timedOut"] == true); + check(timedOut["status"] == "timedOut" && timedOut["timedOut"] == true); auto failed = views::stream_record(Json{{"test", "t"}, {"status", "run_fail"}, {"run_output", "running\ntests/t.cpp:12: assertion failed\n"}}); - CHECK(failed["status"] == "failed" && failed["location"]["path"] == "tests/t.cpp" && failed["location"]["line"] == 11); + check(failed["status"] == "failed" && failed["location"]["path"] == "tests/t.cpp" && failed["location"]["line"] == 11); auto compileFailed = views::stream_record(Json{{"test", "t"}, {"status", "compile_fail"}, {"compile_output", "tests/t.cpp:3:5: error: x\n"}}); - CHECK(compileFailed["status"] == "failed" && compileFailed["compileFailed"] == true && compileFailed["location"]["line"] == 2); + check(compileFailed["status"] == "failed" && compileFailed["compileFailed"] == true && compileFailed["location"]["line"] == 2); auto notRun = views::stream_record(Json{{"test", "t"}, {"status", "not_run"}, {"reason", "no runner for riscv64"}}); - CHECK(notRun["status"] == "skipped" && notRun["reason"] == "no runner for riscv64" && notRun["location"].is_null()); + check(notRun["status"] == "skipped" && notRun["reason"] == "no runner for riscv64" && notRun["location"].is_null()); auto built = views::stream_record(Json{{"test", "t"}, {"status", "built"}}); - CHECK(built["status"] == "skipped" && built["builtOnly"] == true && !built["reason"].get().empty()); - CHECK(views::stream_record(Json{{"test", "t"}, {"status", "fail"}})["status"] == "errored"); + check(built["status"] == "skipped" && built["builtOnly"] == true && !built["reason"].get().empty()); + check(views::stream_record(Json{{"test", "t"}, {"status", "fail"}})["status"] == "errored"); // A passed test keeps its output. auto passed = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", "hello\n"}}); - CHECK(passed["status"] == "passed" && passed["output"] == "hello\n" && passed["outputTruncated"] == false); + check(passed["status"] == "passed" && passed["output"] == "hello\n" && passed["outputTruncated"] == false); // Past the limit, the head and the tail are kept, and the record says so. std::string big = "first line\n"; while (big.size() < 3 * views::kOutputLimit) big += "a line of output\n"; big += "last line\n"; auto long_ = views::stream_record(Json{{"test", "t"}, {"status", "pass"}, {"run_output", big}}); const auto kept = long_["output"].get(); - CHECK(long_["outputTruncated"] == true && kept.size() <= views::kOutputLimit + 100); - CHECK(kept.starts_with("first line\n") && kept.ends_with("last line\n") && kept.contains("bytes of output left out")); + check(long_["outputTruncated"] == true && kept.size() <= views::kOutputLimit + 100); + check(kept.starts_with("first line\n") && kept.ends_with("last line\n") && kept.contains("bytes of output left out")); // Where a failure is: GCC/Clang, MSVC and a drive; nothing that only looks like it. - CHECK(views::failure_location("src/a.cpp:3:5: error: x")["line"] == 2); - CHECK(views::failure_location("C:\\p\\a.cpp(12): error C2065")["path"] == "C:\\p\\a.cpp"); - CHECK(views::failure_location("C:\\p\\a.cpp(12): error C2065")["line"] == 11); - CHECK(views::failure_location("C:/p/b.cpp:7: Failure")["path"] == "C:/p/b.cpp"); - CHECK(views::failure_location("started at 12:30: ok").is_null()); - CHECK(views::failure_location("no location here").is_null()); + check(views::failure_location("src/a.cpp:3:5: error: x")["line"] == 2); + check(views::failure_location("C:\\p\\a.cpp(12): error C2065")["path"] == "C:\\p\\a.cpp"); + check(views::failure_location("C:\\p\\a.cpp(12): error C2065")["line"] == 11); + check(views::failure_location("C:/p/b.cpp:7: Failure")["path"] == "C:/p/b.cpp"); + check(views::failure_location("started at 12:30: ok").is_null()); + check(views::failure_location("no location here").is_null()); std::println("test_views: ok"); } diff --git a/testsupport/mcpp.toml b/testsupport/mcpp.toml new file mode 100644 index 0000000..12c6593 --- /dev/null +++ b/testsupport/mcpp.toml @@ -0,0 +1,13 @@ +[package] +name = "mcpp-ide-testsupport" +version = "0.1.0" +description = "What every backend test of mcpp-ide shares: a check that says where it failed, and the paths of the fixtures and the built programs" +license = "Apache-2.0" + +[language] +standard = "c++23" +modules = true +import_std = true + +[targets.mcpp-ide-testsupport] +kind = "lib" diff --git a/tests/support.hpp b/testsupport/src/testsupport.cppm similarity index 65% rename from tests/support.hpp rename to testsupport/src/testsupport.cppm index 9b09df6..1d5decd 100644 --- a/tests/support.hpp +++ b/testsupport/src/testsupport.cppm @@ -1,22 +1,19 @@ -// What every backend test shares: a check that says where it failed, and the -// paths of the fixtures and the mock mcpp. -#pragma once -#include -#include +// mcppide.testsupport: what every backend test shares -- a check that says where it failed, and the +// paths of the fixtures and the built programs. A module, as the backend's own units are: no test +// includes a header (mcpp-ide-dev check-sources). +export module mcppide.testsupport; -#define CHECK(cond) \ - do { \ - if (!(cond)) { \ - std::fprintf(stderr, "%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); \ - std::exit(1); \ - } \ - } while (0) +import std; -#include -#include -#include +export namespace testsupport { + +// A failed check names its place and ends the test. +inline void check(bool cond, std::source_location at = std::source_location::current()) { + if (cond) return; + std::println(std::cerr, "{}:{}: check failed in {}", at.file_name(), at.line(), at.function_name()); + std::exit(1); +} -namespace testsupport { // A name in the generic spelling: `/` between components. The tests run as ok images too, whose // names are `/`-separated on every system, while a Windows host spells this file's own path and the // temporary directory with `\`. @@ -24,8 +21,10 @@ inline std::filesystem::path generic(std::string text) { for (auto& c : text) if (c == '\\') c = '/'; return std::filesystem::path(text); } -// The repository, from this file's own path. -inline std::filesystem::path repo() { return generic(__FILE__).parent_path().parent_path(); } +// The repository, from this file's own path (testsupport/src/testsupport.cppm). +inline std::filesystem::path repo() { + return generic(std::source_location::current().file_name()).parent_path().parent_path().parent_path(); +} // The temporary directory, in the generic spelling. inline std::filesystem::path temp() { return generic(std::filesystem::temp_directory_path().string()); } // A path as the `file:` URI an LSP client names it by. @@ -52,4 +51,5 @@ inline std::filesystem::path built(const std::string& name) { } return best; } + } // namespace testsupport From d34c9d7f58fd7658237a525f9488335c1f5483fe Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 01:21:56 +0900 Subject: [PATCH 22/31] ci(clion): the no-JCEF window report is kept with the stable results Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/clion-stable.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/clion-stable.yml b/.github/workflows/clion-stable.yml index e71f4e6..8158610 100644 --- a/.github/workflows/clion-stable.yml +++ b/.github/workflows/clion-stable.yml @@ -132,4 +132,5 @@ jobs: ${{ runner.temp }}/clion-stable-result/graphs/checks.json ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/result.json ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/checks.json + ${{ runner.temp }}/clion-stable-result/graphs-no-jcef/no-jcef-window.txt if-no-files-found: warn From 18beb612b3f7899425dfd879e33d705bc8e3da67 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 01:59:00 +0900 Subject: [PATCH 23/31] test(clion): the no-JCEF graph is asked for once the project's layout is on screen The window state the stable runs recorded (visible=false, selected=Module Graph) is the graph opened and then covered by Project, which shares the left side since 1.2.0. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 81cc4a6..134f954 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -142,6 +142,12 @@ class GraphInteractionTest { assertEquals(2, startup.get().get("protocol").getAsInt()) record("protocol2-startup") if (disableJcef) { + // The mcpp tool window shares the left side with Project, which the IDE opens as + // the project's layout is restored: an action before that finishes is shown and + // then covered. The person asks once the project is on screen; so does the test. + val projectView = utility().getInstance(singleProject()).getToolWindow("Project") + val restored = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (projectView != null && !projectView.isVisible() && System.nanoTime() < restored) Thread.sleep(100) invokeAction("mcpp.project.showModuleDependencyGraph") // The sentence wraps in the narrow tool window; its accessible name is whole. val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") From 82bac752d63e825345b95f31622cea132d92bf34 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 02:36:59 +0900 Subject: [PATCH 24/31] fix(clion): the tool window's stripe button is placed as it is registered, not by a startup activity that raced an early activation Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../mcppcommunity/mcppclion/McppStartup.kt | 22 ------------------- .../mcppclion/ui/McppToolWindow.kt | 15 +++++++++++++ 2 files changed, 15 insertions(+), 22 deletions(-) diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt index 93a35ff..97654fe 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/McppStartup.kt @@ -3,41 +3,19 @@ // let CLion's compilation-database support take the project over from mcppls, is pointed out. package io.github.mcppcommunity.mcppclion -import com.intellij.ide.util.PropertiesComponent import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationGroupManager import com.intellij.notification.NotificationType -import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.startup.ProjectActivity -import com.intellij.openapi.wm.ToolWindowManager import io.github.mcppcommunity.mcppclion.actions.Dispatch import java.io.File -/** - * The mcpp tool window on the left stripe, beside Project, as VS Code's activity bar carries mcpp - * (design 2026-11-07 §4.9). The new UI puts a plugin's tool window under "More Tool Windows" until - * the user places it, so the first mcpp project opened places it once; what the user does with the - * button afterwards is kept. - */ -private fun showToolWindowButton(project: Project) { - val placed = "io.github.mcppcommunity.mcppclion.toolWindowPlaced" - val properties = PropertiesComponent.getInstance() - if (properties.getBoolean(placed)) return - ApplicationManager.getApplication().invokeLater { - if (project.isDisposed) return@invokeLater - val window = ToolWindowManager.getInstance(project).getToolWindow("mcpp") ?: return@invokeLater - window.isShowStripeButton = true - properties.setValue(placed, true) - } -} - class McppStartup : ProjectActivity { override suspend fun execute(project: Project) { val base = project.basePath ?: return if (!File(base, "mcpp.toml").isFile) return val session = McppSession.get(project) - showToolWindowButton(project) // mcpp.toml and build.mcpp reach the backend's rules layer as they are opened and edited. io.github.mcppcommunity.mcppclion.editor.McppDocuments.get(project).install() session.start().thenAccept { init -> diff --git a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt index 4aca84e..f02efe3 100644 --- a/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt +++ b/mide-plugins/clion/src/main/kotlin/io/github/mcppcommunity/mcppclion/ui/McppToolWindow.kt @@ -91,6 +91,21 @@ object McppPanels { class Factory : ToolWindowFactory, DumbAware { override suspend fun isApplicableAsync(project: Project): Boolean = project.basePath?.let { File(it, "mcpp.toml").isFile } ?: false + /** + * The button on the left stripe, beside Project, as VS Code's activity bar carries mcpp + * (design 2026-11-07 §4.9). The new UI keeps a plugin's tool window under "More Tool Windows" + * until it is placed; it is placed once, here, as the window is registered -- before anything + * can show it -- and what the user does with the button afterwards is kept. Placing it from a + * startup activity raced an early `activate`: the window was shown and then hidden again. + */ + override fun init(toolWindow: ToolWindow) { + val placed = "io.github.mcppcommunity.mcppclion.toolWindowPlaced" + val properties = com.intellij.ide.util.PropertiesComponent.getInstance() + if (properties.getBoolean(placed)) return + toolWindow.isShowStripeButton = true + properties.setValue(placed, true) + } + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { val session = McppSession.get(project) fun add(view: McppView, component: javax.swing.JComponent, title: String): Content { From d5ced7a09ac0469a75f9302a80a9b5409c9d3178 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 03:21:13 +0900 Subject: [PATCH 25/31] test(clion): the no-JCEF graph test reports which window is on screen, as it changes Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 29 +++++++++++++++---- .../src/test/kotlin/PackagedPluginTest.kt | 7 +++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 134f954..6b2ec25 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -151,17 +151,36 @@ class GraphInteractionTest { invokeAction("mcpp.project.showModuleDependencyGraph") // The sentence wraps in the narrow tool window; its accessible name is whole. val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") - val explained = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) - while (!explanation.present() && System.nanoTime() < explained) Thread.sleep(100) + // Which window is on screen, each time that changes, for the report: the mcpp window + // shares the left side with Project, and only one of the two is shown at a time. + val windows = utility().getInstance(singleProject()) + val began = System.nanoTime() + val timeline = StringBuilder() + var last = "" + val explained = began + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (!explanation.present() && System.nanoTime() < explained) { + val now = withContext(OnDispatcher.EDT) { + val mcpp = windows.getToolWindow("mcpp") + "mcpp=" + (mcpp?.isVisible() ?: "absent") + " project=" + (projectView?.isVisible() ?: "absent") + } + if (now != last) { + timeline.append("%.1fs %s\n".format((System.nanoTime() - began) / 1e9, now)) + last = now + } + Thread.sleep(100) + } if (!explanation.present()) { // What the window shows instead, for the report. - val window = utility().getInstance(singleProject()).getToolWindow("mcpp") + val window = windows.getToolWindow("mcpp") val state = withContext(OnDispatcher.EDT) { if (window == null) "no mcpp tool window" - else "visible=" + window.isVisible() + " selected=" + + else "visible=" + window.isVisible() + " active=" + window.isActive() + + " type=" + runCatching { window.getType().name() }.getOrElse { "?" } + + " autoHide=" + window.isAutoHide() + " split=" + window.isSplitMode() + + " stripe=" + window.isShowStripeButton() + " selected=" + window.getContentManager().getSelectedContent()?.getDisplayName() } - Files.writeString(output.resolve("no-jcef-window.txt"), state + "\n") + Files.writeString(output.resolve("no-jcef-window.txt"), state + "\n" + timeline) } assertTrue(explanation.present(), "the graph explains why drawing is unavailable") record("no-jcef-explanation-visible") diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 5cf66b4..9a0f164 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -114,8 +114,15 @@ interface ToolWindowManagerRemote { fun getToolWindow(id: String): ToolWindowRem @Remote("com.intellij.openapi.wm.ToolWindow") interface ToolWindowRemote { fun isVisible(): Boolean + fun isActive(): Boolean + fun isAutoHide(): Boolean + fun isSplitMode(): Boolean + fun isShowStripeButton(): Boolean + fun getType(): ToolWindowTypeRemote fun getContentManager(): ContentManagerRemote } +@Remote("com.intellij.openapi.wm.ToolWindowType") +interface ToolWindowTypeRemote { fun name(): String } @Remote("com.intellij.ui.content.ContentManager") interface ContentManagerRemote { fun getContentCount(): Int From d829b471e062aa6877b952f41fd379755f97b812 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 03:46:05 +0900 Subject: [PATCH 26/31] test(clion): the no-JCEF graph test waits for the Project window to be registered and shown, as CLion 2026.2 registers it after the project opens Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 6b2ec25..640078b 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -145,23 +145,26 @@ class GraphInteractionTest { // The mcpp tool window shares the left side with Project, which the IDE opens as // the project's layout is restored: an action before that finishes is shown and // then covered. The person asks once the project is on screen; so does the test. - val projectView = utility().getInstance(singleProject()).getToolWindow("Project") - val restored = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) - while (projectView != null && !projectView.isVisible() && System.nanoTime() < restored) Thread.sleep(100) + // CLion 2026.2 registers the Project window after the project reports open, so it is + // looked up again until it exists and is shown, not asked once. + val windows = utility().getInstance(singleProject()) + val restored = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(60) + while (windows.getToolWindow("Project")?.isVisible() != true && System.nanoTime() < restored) Thread.sleep(100) + val before = "before the action: project=" + (windows.getToolWindow("Project")?.isVisible() ?: "absent") + + " after " + "%.1fs".format((System.nanoTime() - restored) / 1e9 + 60) + "\n" invokeAction("mcpp.project.showModuleDependencyGraph") // The sentence wraps in the narrow tool window; its accessible name is whole. val explanation = ui.x("//div[contains(@accessiblename,'This IDE runs without JCEF')]") // Which window is on screen, each time that changes, for the report: the mcpp window // shares the left side with Project, and only one of the two is shown at a time. - val windows = utility().getInstance(singleProject()) val began = System.nanoTime() - val timeline = StringBuilder() + val timeline = StringBuilder(before) var last = "" val explained = began + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) while (!explanation.present() && System.nanoTime() < explained) { val now = withContext(OnDispatcher.EDT) { val mcpp = windows.getToolWindow("mcpp") - "mcpp=" + (mcpp?.isVisible() ?: "absent") + " project=" + (projectView?.isVisible() ?: "absent") + "mcpp=" + (mcpp?.isVisible() ?: "absent") + " project=" + (windows.getToolWindow("Project")?.isVisible() ?: "absent") } if (now != last) { timeline.append("%.1fs %s\n".format((System.nanoTime() - began) / 1e9, now)) From 77469275d4f56a48e97315c76f756c50757dec4f Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 04:32:27 +0900 Subject: [PATCH 27/31] test(clion): the IDE tests act once the IDE has settled, and wait for the Debug window as the session starts The failures alternated between CLion 2026.1 and 2026.2 run to run, and took the platform's own Debug window as well as the mcpp one: a window shown before the IDE's startup work ends can be hidden by it. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .../src/test/kotlin/GraphInteractionTest.kt | 4 ++++ .../src/test/kotlin/PackagedPluginTest.kt | 11 +++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt index 640078b..74070ee 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/GraphInteractionTest.kt @@ -5,6 +5,7 @@ import com.intellij.driver.model.OnDispatcher import com.intellij.driver.sdk.invokeAction import com.intellij.driver.sdk.singleProject import com.intellij.driver.sdk.waitForProjectOpen +import com.intellij.driver.sdk.waitForIndicators import com.intellij.driver.sdk.ui.ui import com.intellij.driver.sdk.ui.remote.Component import com.intellij.ide.starter.ci.CIServer @@ -124,6 +125,9 @@ class GraphInteractionTest { } context.runIdeWithDriver().useDriverAndCloseIde { waitForProjectOpen() + // The IDE settles after the project opens (its layout restored, its startup activities run): a tool + // window shown before then can be hidden again by them, on 2026.1 and 2026.2 alike. + waitForIndicators(kotlin.time.Duration.parse("3m")) withContext { assertEquals(version, utility().getInstance().getFullVersion()) System.getProperty("mcpp.clion.backendPath")?.let { backend -> diff --git a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt index 9a0f164..a9eff6b 100644 --- a/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt +++ b/mide-plugins/clion/integration-tests/src/test/kotlin/PackagedPluginTest.kt @@ -12,6 +12,7 @@ import com.intellij.driver.sdk.ui.ui import com.intellij.driver.model.OnDispatcher import com.intellij.driver.sdk.singleProject import com.intellij.driver.sdk.waitForProjectOpen +import com.intellij.driver.sdk.waitForIndicators import com.intellij.ide.starter.ci.CIServer import com.intellij.ide.starter.ci.NoCIServer import com.intellij.ide.starter.di.di @@ -350,6 +351,8 @@ class PackagedPluginTest { } context.runIdeWithDriver(runTimeout = 3.minutes).useDriverAndCloseIde { waitForProjectOpen() + // As GraphInteractionTest: windows shown before the IDE settles can be hidden again. + waitForIndicators(kotlin.time.Duration.parse("3m")) withContext { val application = utility().getInstance() assertEquals(version, application.getFullVersion(), "the requested stable IDE actually runs") @@ -441,8 +444,12 @@ class PackagedPluginTest { assertNotNull(utility().getInstance().getDefaultToolchain(), "CLion has a debugger toolchain") val debug = execute("debug", "debug-native-launch") assertTrue(debug.getProcessHandler()!!.getClass().getName().contains("CidrDebugProcess"), "CLion's native debugger owns the process") - val debugWindow = utility().getInstance(singleProject()).getToolWindow("Debug") - assertTrue(debugWindow?.isVisible() == true, "the native debugger view is visible") + // The Debug window is shown as the session starts, after the process does. + val windows = utility().getInstance(singleProject()) + val shown = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(30) + while (windows.getToolWindow("Debug")?.isVisible() != true && System.nanoTime() < shown) Thread.sleep(100) + assertTrue(windows.getToolWindow("Debug")?.isVisible() == true, "the native debugger view is visible") + val debugWindow = windows.getToolWindow("Debug") record("native-debugger-process-and-view") val debugger = utility().getInstance(singleProject()) val debugFile = requireNotNull(utility().getInstance().refreshAndFindFileByPath(source.toString())) From 91b1437e3a03df37db8056fbd92c237df24df6aa Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 04:35:43 +0900 Subject: [PATCH 28/31] ci: a pull request runs the jobs of the components it changes; a plugin's are tested over the backend main built from the same inputs Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/components.tsv | 29 +++++++ .github/scripts/changes.sh | 80 +++++++++++++++++ .github/scripts/test_changes.sh | 37 ++++++++ .github/workflows/ci.yml | 148 +++++++++++++++++++++++++++++++- README.md | 6 +- README.zh-CN.md | 3 + 6 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 .github/components.tsv create mode 100755 .github/scripts/changes.sh create mode 100755 .github/scripts/test_changes.sh diff --git a/.github/components.tsv b/.github/components.tsv new file mode 100644 index 0000000..18d1e99 --- /dev/null +++ b/.github/components.tsv @@ -0,0 +1,29 @@ +# The components of this repository, by path (README "Versions"; .github/scripts/changes.sh). One +# `\t` line each; a pattern is a shell `case` glob over the path from the +# repository's root, in which `*` crosses `/`. The first line that matches a path names its component; +# a path no line matches, and the component `all`, run every job. +# +# `@core `: the component whose change reaches every other (each plugin is tested over it). +@core backend +# backend: the program every editor package carries, its protocol, the editors' declarations spec/ +# generates, the graph page, and their tests. +backend src/* +backend protocol/* +backend spec/* +backend webui/* +backend testsupport/* +backend tests/* +backend mcpp.toml +backend mcpp.lock +backend build.mcpp +# The editor plugins, each tested over the backend of the change it is made against. +vscode mide-plugins/vscode/* +clion mide-plugins/clion/* +nvim mide-plugins/nvim/* +# What only people read. +docs docs/* +docs agents/* +docs *.md +docs LICENSE +# The workflows, the scripts and the pins every job runs with. +all .github/* diff --git a/.github/scripts/changes.sh b/.github/scripts/changes.sh new file mode 100755 index 0000000..2deaa0b --- /dev/null +++ b/.github/scripts/changes.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# The components a change reaches, from .github/components.tsv (README "Versions"). +# +# changes.sh prints `=true|false`, `full=`, each flag, and `digest=` +# +# EVENT, BASE_REF and HEAD_REF (the workflow's event and a pull request's branches) decide a full run: +# anything but a pull request, and a pull request from or into a release branch, reaches every component. +# Otherwise each path reaches the component of the first line of the table that matches it; a path no +# line matches, or one of `all`, reaches every component; and a change of the `@core` component reaches +# every other, since each is tested over it. A `@flag` is a pull request's question: true when a changed +# path matches it, or when the pull request is from or into a release branch; false for any other event. +# +# `digest` names the core's inputs (the blobs of every path of the core and of `all`), so a run that built +# the core from the same inputs can lend what it built to a run that did not change them. +set -euo pipefail +root="$(cd "$(dirname "$0")/../.." && pwd)" +table="$root/.github/components.tsv" +changed="${1:?the file of changed paths}" + +rows=(); flags=(); core=""; components=() +while IFS= read -r line; do + case "$line" in ''|'#'*) continue ;; esac + case "$line" in + @core$'\t'*) core="${line#*$'\t'}" ;; + @flag\ *) flags+=("${line#@flag }") ;; + *) rows+=("$line") + name="${line%%$'\t'*}" + [ "$name" = all ] || [[ " ${components[*]} " == *" $name "* ]] || components+=("$name") ;; + esac +done < "$table" + +declare -A reached=() flagged=() printed=() +full=false +release=false +case "${EVENT:-}" in pull_request) ;; *) full=true ;; esac +case "${BASE_REF:-}" in release/*) full=true; release=true ;; esac +case "${HEAD_REF:-}" in release/*) full=true; release=true ;; esac + +while IFS= read -r path; do + [ -n "$path" ] || continue + component="" + for row in "${rows[@]}"; do + name="${row%%$'\t'*}"; pattern="${row#*$'\t'}" + # shellcheck disable=SC2254 + case "$path" in $pattern) component="$name"; break ;; esac + done + if [ -z "$component" ] || [ "$component" = all ]; then + echo "every component: $path" >&2 + full=true + else + reached[$component]=true + fi + for flag in "${flags[@]}"; do + name="${flag%%$'\t'*}"; pattern="${flag#*$'\t'}" + # shellcheck disable=SC2254 + case "$path" in $pattern) flagged[$name]=true ;; esac + done +done < "$changed" + +if [ "$full" = true ] || { [ -n "$core" ] && [ "${reached[$core]:-}" = true ]; }; then + for c in "${components[@]}"; do reached[$c]=true; done +fi +for c in "${components[@]}"; do echo "$c=${reached[$c]:-false}"; done +echo "full=$full" +for flag in "${flags[@]}"; do + name="${flag%%$'\t'*}" + [ "$release" = true ] && flagged[$name]=true + [ "${EVENT:-}" = pull_request ] || flagged[$name]=false + [ -n "${printed[$name]:-}" ] && continue + printed[$name]=1 + echo "$name=${flagged[$name]:-false}" +done + +# The core's inputs: every tracked path the table gives to the core or to `all`. +mapfile -t inputs < <(for row in "${rows[@]}"; do + name="${row%%$'\t'*}"; pattern="${row#*$'\t'}" + if [ "$name" = "$core" ] || [ "$name" = all ]; then echo "${pattern%%\**}"; fi +done | sort -u) +digest=$(cd "$root" && git ls-files -s -- "${inputs[@]}" | sha256sum | cut -c1-24) +echo "digest=$digest" diff --git a/.github/scripts/test_changes.sh b/.github/scripts/test_changes.sh new file mode 100755 index 0000000..6213e91 --- /dev/null +++ b/.github/scripts/test_changes.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# The classification of .github/scripts/changes.sh, on lists of paths. +set -euo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +failures=0 +check() { # check -- + local what="$1" event="$2"; shift 2 + local want=() + while [ "$1" != -- ]; do want+=("$1"); shift; done + shift + local list; list=$(mktemp) + printf '%s\n' "$@" > "$list" + local base=main + [ "$event" = pull_request_release ] && { event=pull_request; base=release/1.2.0; } + local got; got=$(EVENT="$event" BASE_REF="$base" HEAD_REF=topic bash "$here/changes.sh" "$list" 2>/dev/null) + rm -f "$list" + for kv in "${want[@]}"; do + if ! grep -qx "$kv" <<<"$got"; then + echo "FAIL: $what: wanted $kv, got: $(tr '\n' ' ' <<<"$got")" + failures=$((failures + 1)) + fi + done +} +check "a plugin alone" pull_request backend=false clion=true vscode=false nvim=false full=false -- mide-plugins/clion/src/main/kotlin/X.kt +check "its README is the plugin's" pull_request vscode=true docs=false backend=false -- mide-plugins/vscode/README.md +check "documentation alone" pull_request docs=true backend=false vscode=false -- docs/x.md README.md +check "the backend reaches every plugin" pull_request backend=true vscode=true clion=true nvim=true -- src/main.cpp +check "spec/ is the backend's" pull_request backend=true vscode=true -- spec/test-frameworks.json +check "a workflow reaches everything" pull_request full=true backend=true -- .github/workflows/ci.yml +check "a path of no component reaches everything" pull_request full=true -- newdir/x +check "a push reaches everything" push full=true backend=true -- docs/x.md +check "a release branch reaches everything" pull_request_release full=true -- docs/x.md +list=$(mktemp); echo docs/x.md > "$list" +d=$(EVENT=pull_request bash "$here/changes.sh" "$list" 2>/dev/null | sed -n 's/^digest=//p'); rm -f "$list" +[ "${#d}" -eq 24 ] || { echo "FAIL: no digest"; failures=$((failures + 1)); } +[ "$failures" -eq 0 ] && echo "test_changes: every case held" +exit "$failures" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779a91e..462c99b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,73 @@ env: NODE_VERSION: 22 jobs: + # WHAT THE CHANGE REACHES (README "Versions"; .github/components.tsv). A pull request runs the jobs of the + # components it changes: the backend's change runs everything, a plugin's runs that plugin's jobs over the + # backend main built from the same inputs (`lend`, a run whose `backend-digest-` marker is kept), the + # documentation's its checks. A push, a dispatch, a release and a pull request from or into a release branch + # run everything. When no main run lends the backend, it is built as well. + changes: + name: what the change reaches + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + actions: read + outputs: + backend: ${{ steps.classify.outputs.backend }} + vscode: ${{ steps.classify.outputs.vscode }} + clion: ${{ steps.classify.outputs.clion }} + nvim: ${{ steps.classify.outputs.nvim }} + digest: ${{ steps.classify.outputs.digest }} + lend: ${{ steps.classify.outputs.lend }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 2 + - name: The classification's own cases + run: bash .github/scripts/test_changes.sh + - id: classify + env: + EVENT: ${{ github.event_name }} + BASE_REF: ${{ github.base_ref }} + HEAD_REF: ${{ github.head_ref }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + changed="$RUNNER_TEMP/changed.txt" + if [ "$EVENT" = pull_request ]; then git diff --name-only HEAD^1 HEAD > "$changed"; else : > "$changed"; fi + bash .github/scripts/changes.sh "$changed" > "$RUNNER_TEMP/reached.txt" + cat "$RUNNER_TEMP/reached.txt" + get() { sed -n "s/^$1=//p" "$RUNNER_TEMP/reached.txt"; } + lend="" + if [ "$(get backend)" = false ] && { [ "$(get vscode)" = true ] || [ "$(get clion)" = true ] || [ "$(get nvim)" = true ]; }; then + lend=$(gh api "repos/${{ github.repository }}/actions/artifacts?name=backend-digest-$(get digest)&per_page=20" \ + --jq '[.artifacts[] | select(.expired == false and .workflow_run.head_branch == "main")][0].workflow_run.id // ""') + if [ -z "$lend" ]; then + echo "no main run lends the backend of these inputs: it is built as well" + sed -i 's/^\(backend\|vscode\|clion\|nvim\)=.*/\1=true/' "$RUNNER_TEMP/reached.txt" + else + echo "the backend of run $lend, built from the same inputs" + fi + fi + cat "$RUNNER_TEMP/reached.txt" >> "$GITHUB_OUTPUT" + echo "lend=$lend" >> "$GITHUB_OUTPUT" + + # The repository's source and translation checks when the backend is not built (the backend job runs them). + checks: + name: sources and translations + needs: changes + if: needs.changes.outputs.backend != 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + - uses: ./.github/actions/setup-mcpp + - run: | + set -euo pipefail + mcpp run mcpp-ide-dev -- check-i18n + mcpp run mcpp-ide-dev -- check-sources + # Every component one series (design 2026-11-07, D15): the backend's two statements agree, and the # VS Code, CLion and Neovim plugins share the backend's X.Y. versions: @@ -48,6 +115,8 @@ jobs: # platform, and the other systems only in a run with `native` -- the backend is distributed as the ok # image, which `backend-openkal` tests on every system (design 2026-11-07, D18). matrix: + needs: changes + if: needs.changes.outputs.backend == 'true' runs-on: ubuntu-24.04 outputs: backend: ${{ steps.pick.outputs.backend }} @@ -125,6 +194,8 @@ jobs: # tool they start are test tools, built natively first. backend-openkal: name: backend (${{ matrix.platform }}, openkal) + needs: changes + if: needs.changes.outputs.backend == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 75 strategy: @@ -156,6 +227,8 @@ jobs: # install that resolves the language service; the graph page in Chromium. vscode-gates: name: VS Code gates (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.vscode == 'true' strategy: fail-fast: false matrix: @@ -178,6 +251,8 @@ jobs: vscode-drift: name: VS Code manifest and settings documentation + needs: changes + if: needs.changes.outputs.vscode == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: @@ -205,7 +280,11 @@ jobs: vscode: name: VS Code extension - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} + permissions: + contents: read + actions: read runs-on: ubuntu-24.04 timeout-minutes: 45 defaults: @@ -223,6 +302,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: Build the backend and its test programs run: | chmod +x backend/okrun/*/okrun* @@ -316,7 +397,11 @@ jobs: # exactly what differs between them. vscode-e2e: name: VS Code Extension Host (${{ matrix.os }}) - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.vscode == 'true' }} + permissions: + contents: read + actions: read strategy: fail-fast: false matrix: @@ -338,6 +423,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - run: npm ci - run: npm run compile - name: The backend the package carries @@ -425,6 +512,8 @@ jobs: vscode-graph-browser: name: VS Code graph page in Chromium + needs: changes + if: needs.changes.outputs.vscode == 'true' runs-on: ubuntu-latest timeout-minutes: 30 defaults: @@ -455,7 +544,11 @@ jobs: # CLion it claims (2026.1) and the one it is built on. clion: name: CLion plugin - needs: backend + needs: [changes, backend] + if: ${{ !cancelled() && !failure() && needs.changes.outputs.clion == 'true' }} + permissions: + contents: read + actions: read runs-on: ubuntu-24.04 timeout-minutes: 90 defaults: @@ -482,6 +575,8 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: Build the backend and its test programs run: | chmod +x backend/okrun/*/okrun* @@ -560,6 +655,8 @@ jobs: # installed and selected with `xlings use`, as mcppls's CI provisions it. nvim: name: Neovim plugin + needs: changes + if: needs.changes.outputs.nvim == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 env: @@ -607,7 +704,11 @@ jobs: # universal package started by its launcher. packages: name: packages on ${{ matrix.os }} - needs: [vscode, clion] + needs: [changes, vscode, clion] + if: ${{ !cancelled() && !failure() && (needs.changes.outputs.vscode == 'true' || needs.changes.outputs.clion == 'true') }} + permissions: + contents: read + actions: read runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: @@ -628,9 +729,13 @@ jobs: with: name: backend path: backend + run-id: ${{ needs.changes.outputs.lend || github.run_id }} + github-token: ${{ github.token }} - name: The VS Code extension + if: needs.changes.outputs.vscode == 'true' run: .github/scripts/check-package.sh packages/mcpp-vscode-*.vsix extension - name: The CLion plugin + if: needs.changes.outputs.clion == 'true' run: .github/scripts/check-package.sh packages/mcpp-clion-*.zip mcpp-clion - name: The backend's universal package run: | @@ -640,3 +745,38 @@ jobs: root="$(echo "$dir"/mcpp-ide-*-openkal*)" "$root/bin/mcpp-ide" --version | tee said.txt grep -q '^mcpp-ide ' said.txt + + # Main lends what it built: a marker named after the backend's inputs, which a pull request that changes + # only a plugin finds (`changes`), and whose run's backend it then tests over. + backend-digest: + name: lend the backend of these inputs + needs: [changes, backend] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.changes.outputs.backend == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - run: echo '${{ needs.changes.outputs.digest }}' > "$RUNNER_TEMP/digest.txt" + - uses: actions/upload-artifact@v5 + with: + name: backend-digest-${{ needs.changes.outputs.digest }} + path: ${{ runner.temp }}/digest.txt + retention-days: 90 + + # THE ONE CHECK A BRANCH REQUIRES. The jobs a change does not reach are skipped, so none of them can be + # required; this one is, and it fails when any job that ran failed or was cancelled. + ci: + name: ci + if: always() + needs: [changes, checks, versions, matrix, backend, backend-openkal, vscode-gates, vscode-drift, vscode, vscode-e2e, + vscode-isolated-install, vscode-graph-browser, clion, clion-stable, nvim, packages] + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + set -euo pipefail + echo "$RESULTS" + if echo "$RESULTS" | grep -Eq '"(failure|cancelled)"'; then + echo "::error::a job this change reaches failed or was cancelled"; exit 1 + fi diff --git a/README.md b/README.md index 88c68e8..1c759cc 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,11 @@ VS Code and CLion check for the local build tool only when a user starts an acti plugin works with any backend of its series and refuses another, naming both versions and the install that matches. `Z` is each component's own patch, so VS Code 1.2.3 and CLion 1.2.1 both work with backend 1.2.0. A series release is tagged `vX.Y.0`; a component's patch is tagged -`backend-`, `vscode-`, `clion-` or `nvim-vX.Y.Z` and publishes that component alone. +`backend-`, `vscode-`, `clion-` or `nvim-vX.Y.Z` and publishes that component alone. A pull request runs the jobs of +the components it changes ([`.github/components.tsv`](.github/components.tsv)): a change of the backend runs +everything, a change of one plugin runs that plugin's jobs over the backend main built from the same inputs, +a change of the documentation its checks; `.github/` and any path the table does not name run everything. +The one required check is `ci`. If `mcpp` is absent, they show one notification linking to the [mcpp repository](https://github.com/mcpp-community/mcpp). diff --git a/README.zh-CN.md b/README.zh-CN.md index bf507b5..23e4919 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -70,6 +70,9 @@ VS Code 和 CLion 仅在用户主动执行依赖本地构建工具的操作时 **版本。** 版本为 `X.Y.Z`。`X.Y` 是系列,由后端与全部插件共享:插件与同系列的任何后端配合,遇到其他系列时拒绝, 并写出双方的版本与对应的安装方法。`Z` 是各组件自己的补丁,所以 VS Code 1.2.3 与 CLion 1.2.1 都与后端 1.2.0 兼容。系列发布的标签为 `vX.Y.0`;组件补丁的标签为 `backend-`、`vscode-`、`clion-` 或 `nvim-vX.Y.Z`,只发布该组件。 +拉取请求只运行它所改动的组件的作业([`.github/components.tsv`](.github/components.tsv)):后端的改动运行全部作业; +一个插件的改动只运行该插件的作业,所用的后端是 main 以相同输入构建的那一份;文档的改动只运行文档的检查;`.github/` +与表中没有列出的路径运行全部作业。唯一必需的检查是 `ci`。 ### 从源码构建 From ddda72454abdddb6a4e11744a5caf83c82182c23 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 06:03:21 +0900 Subject: [PATCH 29/31] The platform is openkal-platform 0.24.4 openkal-image 0.5.3 enters an image in its convention's floating-point state (openkal SPEC 12.2): on Windows a thread began with the x87 at double precision, under which the image's C library read a decimal number otherwise. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 5 +++-- mcpp.lock | 18 +++++++++--------- mcpp.toml | 6 +++--- 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 462c99b..c9b5a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,7 +125,7 @@ jobs: env: NATIVE: ${{ inputs.native && 'yes' || 'no' }} run: | - rows='{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","pack":true},{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","host-platform":"openkal-platform@0.24.3"}' + rows='{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","pack":true},{"os":"ubuntu-24.04","platform":"linux-x64","llvm":"false","host-platform":"openkal-platform@0.24.4"}' if [ "$NATIVE" = yes ]; then rows="$rows"',{"os":"ubuntu-24.04-arm","platform":"linux-arm64","llvm":"false"},{"os":"macos-15","platform":"darwin-arm64","llvm":"true"},{"os":"windows-2022","platform":"win32-x64","llvm":"true"}' fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecfec1..8c9a0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,8 +33,9 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). Debug`, and while the active editor is a target's entry the editor title's Run menu runs and debugs it. Neovim's sign column shows ▶ on a program's and a test's `main` and on each case, and a result turns it into ✓ or ✗. CLion logs why a file has no run entry. -- **The platform as one release.** The payloads' platform is `openkal-platform@0.24.3`, whose okrun - reserves a thread's stack for an image's main thread on Windows, and every dependency is one release +- **The platform as one release.** The payloads' platform is `openkal-platform@0.24.4`, whose okrun + enters an image in its convention's floating-point state (a decimal number is read on Windows as on + the other systems), and every dependency is one release named by its identity (`mcpplibs.os = "0.3.1"`); mcpp 2026.11.7.1 or later builds them. - **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, diff --git a/mcpp.lock b/mcpp.lock index 45e7e70..02635df 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -30,9 +30,9 @@ targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."openkal-ld"] namespace = "mcpplibs" -version = "0.5.2" -source = "index+mcpplibs@0.5.2" -hash = "fnv1a:e71cc472802c11eb" +version = "0.5.3" +source = "index+mcpplibs@0.5.3" +hash = "fnv1a:e71cc372802c1038" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] @@ -60,17 +60,17 @@ targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-platform"] namespace = "mcpplibs" -version = "0.24.3" -source = "index+mcpplibs@0.24.3" -hash = "fnv1a:85275d2a77391408" +version = "0.24.4" +source = "index+mcpplibs@0.24.4" +hash = "fnv1a:8527642a77391fed" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] [package."openkal-tools"] namespace = "mcpplibs" -version = "0.5.2" -source = "index+mcpplibs@0.5.2" -hash = "fnv1a:17b68773cd803ae8" +version = "0.5.3" +source = "index+mcpplibs@0.5.3" +hash = "fnv1a:17b68873cd803c9b" origin = "platform" targets = ["aarch64-openkal", "x86_64-openkal"] diff --git a/mcpp.toml b/mcpp.toml index b1a0671..afb0c99 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -35,10 +35,10 @@ toolchain = "llvm@23.1.3" # The platform of the editor payloads, one release (design 2026-11-07, D20): the backend each version # ships is built over one version group. The platform supplies the image toolchain, hardening, seal -# and runner; 0.24.3 names OpenKal 0.24, musl 0.28.1, runtime 0.19.2 and image 0.5.2, whose okrun -# reserves a thread's stack for an image's main thread on Windows. +# and runner; 0.24.4 names OpenKal 0.24, musl 0.28.1, runtime 0.19.2 and image 0.5.3, whose okrun +# enters an image in its convention's floating-point state. [target.'cfg(os = "openkal")'] -platform = "openkal-platform@0.24.3" +platform = "openkal-platform@0.24.4" # An arm64 Linux machine's own programs: the xlings gcc payload has no aarch64-hosted C++ frontend. [target.aarch64-linux-gnu] From cfc482a4f052af7b441505ac510f17ccd0477c93 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 07:33:41 +0900 Subject: [PATCH 30/31] os 0.4.0; the source check names os::on

for a platform module Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 2 +- mcpp.lock | 6 +++--- mcpp.toml | 2 +- src/bin/dev.cpp | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c9a0c6..637a37d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,7 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). - **The platform as one release.** The payloads' platform is `openkal-platform@0.24.4`, whose okrun enters an image in its convention's floating-point state (a decimal number is read on Windows as on the other systems), and every dependency is one release - named by its identity (`mcpplibs.os = "0.3.1"`); mcpp 2026.11.7.1 or later builds them. + named by its identity (`mcpplibs.os = "0.4.0"`); mcpp 2026.11.7.1 or later builds them. - **A Run shows its build as the terminal does.** Run and Debug build through a launch job: mcpp's own narration (Compiling, Finished) appears as it is written, in place of a fixed "building" line, and the build's steps in the status bar (CLion, VS Code while it builds for the debugger, the diff --git a/mcpp.lock b/mcpp.lock index 02635df..7cced65 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -76,9 +76,9 @@ targets = ["aarch64-openkal", "x86_64-openkal"] [package."mcpplibs.os"] namespace = "mcpplibs" -version = "0.3.1" -source = "index+mcpplibs@0.3.1" -hash = "fnv1a:c8fc76a4fc0fcfdd" +version = "0.4.0" +source = "index+mcpplibs@0.4.0" +hash = "fnv1a:c06e4aa4f73f1f27" targets = ["aarch64-openkal", "x86_64-linux-gnu", "x86_64-openkal"] [package."nlohmann.json"] diff --git a/mcpp.toml b/mcpp.toml index afb0c99..f8075dc 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -78,7 +78,7 @@ main = "src/bin/dev.cpp" [dependencies] # 0.3.1 reads a name in Windows' long spelling as the name it spells. The image C library, C++ # runtime and loader remain the platform's. -mcpplibs.os = "0.3.1" +mcpplibs.os = "0.4.0" nlohmann.json = "3.12.0" # What every backend test shares (testsupport/): a module, as the backend's units are. diff --git a/src/bin/dev.cpp b/src/bin/dev.cpp index 7482092..eb42ac7 100644 --- a/src/bin/dev.cpp +++ b/src/bin/dev.cpp @@ -545,7 +545,7 @@ int check_sources(std::span) { else if (it->path().filename() != "dev.cpp") for (std::string_view name : {"is_windows", "is_linux", "is_macos", "is_posix"}) if (row.find(std::format("os::{}", name)) != std::string_view::npos) - problems.push_back(std::format("{}:{}: asks os::{}, which an ok image answers false; ask os::host()", rel, line, name)); + problems.push_back(std::format("{}:{}: asks os::{}, which an ok image answers false; ask os::host(), or os::on

for a platform module", rel, line, name)); at = nl + 1; } } From ddf5995921dce96910736e39ef17ae0e2bc18261 Mon Sep 17 00:00:00 2001 From: Sunrisepeak Date: Sun, 8 Nov 2026 08:23:41 +0900 Subject: [PATCH 31/31] tests by the test protocol: listed by mcpp test --cases, run by --case, results from mcpp's record A program that implements the mtest test protocol is listed and run through mcpp's records whatever framework wrote it; a file of mtest's cases shows its "name"_test literals with their suites before a build. The frameworks without the protocol keep spec/test-frameworks.json. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- CHANGELOG.md | 6 ++ protocol/fixtures/test-cases.ndjson | 2 + protocol/fixtures/test-protocol.ndjson | 2 + spec/test-frameworks.json | 17 +++++- src/bin/mockmcpp.cpp | 8 ++- src/scan.cppm | 57 ++++++++++++++++-- src/server.cppm | 82 ++++++++++++++++++++++++-- src/testing.cppm | 62 +++++++++++++++++++ tests/test_protocol.cpp | 29 +++++++++ tests/test_testing.cpp | 43 ++++++++++++++ 10 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 protocol/fixtures/test-cases.ndjson create mode 100644 protocol/fixtures/test-protocol.ndjson diff --git a/CHANGELOG.md b/CHANGELOG.md index 637a37d..60a4f69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,12 @@ mcpp-nvim are 1.2.0 (from 0.2.1, 1.1.1, 0.2.1 and 0.2.1). `run_fail` is failed, or timed out when mcpp says so; `compile_fail` is failed and says it did not compile; `not_run` is skipped with mcpp's reason; `built` is skipped as built only. 0.2.1 mapped values mcpp never writes, so every failure was "errored" and a timeout was never seen. +- **Cases by the test protocol.** A test program that implements the mtest test protocol (mcpp + 2026.11.8.1, feature `test-protocol`) is listed by `mcpp test --cases` and its cases run by + `mcpp test --case`, whatever framework wrote it; each case's result, duration and first failure's + place come from mcpp's record. A file of mtest's cases shows its `"name"_test` literals, named + with their suites (`parsing/an empty line`), before anything is built. GoogleTest, Catch2 and + doctest without the protocol are listed and run as before (`spec/test-frameworks.json`). - **Every test's own output.** Each test and each case carries its whole output, passed or not (head and tail past 1 MiB), and a failure carries the place its output names. A case run divides the program's output among its cases by GoogleTest's boundaries. VS Code says the output to the diff --git a/protocol/fixtures/test-cases.ndjson b/protocol/fixtures/test-cases.ndjson new file mode 100644 index 0000000..8823bfe --- /dev/null +++ b/protocol/fixtures/test-cases.ndjson @@ -0,0 +1,2 @@ +{"member":"","test":"unit/parse","status":"built","exit_code":0,"signal":null,"duration_ms":0,"timed_out":false,"compile_output":"","run_output":"","reason":"","build_ms":12,"protocol":1,"listed":[{"id":"parsing/an empty line","name":"an empty line","file":"tests/unit/parse.cpp","line":4},{"id":"parsing/a field","name":"a field","file":"tests/unit/parse.cpp","line":5}]} +{"summary":{"member":"","passed":0,"failed":0,"not_run":0,"not_run_reason":"","built":1,"elapsed_ms":20,"build_ms":12,"run_ms":0}} diff --git a/protocol/fixtures/test-protocol.ndjson b/protocol/fixtures/test-protocol.ndjson new file mode 100644 index 0000000..480c3aa --- /dev/null +++ b/protocol/fixtures/test-protocol.ndjson @@ -0,0 +1,2 @@ +{"member":"","test":"unit/parse","status":"run_fail","exit_code":1,"signal":null,"duration_ms":4,"timed_out":false,"compile_output":"","run_output":"","reason":"","build_ms":0,"protocol":1,"cases":[{"id":"parsing/an empty line","status":"pass","duration_ms":1},{"id":"parsing/a field","status":"fail","duration_ms":2,"failures":[{"file":"tests/unit/parse.cpp","line":7,"message":"1 == 2","expected":"2","actual":"1"}]}]} +{"summary":{"member":"","passed":0,"failed":1,"not_run":0,"not_run_reason":"","built":0,"elapsed_ms":9,"build_ms":0,"run_ms":4}} diff --git a/spec/test-frameworks.json b/spec/test-frameworks.json index 21f4b63..48bc055 100644 --- a/spec/test-frameworks.json +++ b/spec/test-frameworks.json @@ -1,6 +1,21 @@ { - "description": "The test frameworks whose cases the editors show (mcpp design 2026-10-30 part 2 §7.2). A test program belongs to a framework when its plan holds one of the framework's packages and its source calls one of the framework's macros. `name` says how a case's full name is made from the macro's arguments; `list` lists the cases (run only for a program whose source calls a macro); `filter` selects cases, `{}` being the cases joined by `join`, each with the characters of `escape` preceded by a backslash (a GoogleTest name is two identifiers, which hold none of its filter's special characters); `report` writes the results to the file `{}`, read as `reportFormat`. boost-ext.ut is not here: its runner takes no filter from the command line, so its tests run as programs.", + "description": "The test frameworks whose cases the editors show (mcpp design 2026-10-30 part 2 §7.2). A test program belongs to a framework when its plan holds one of the framework's packages and its source calls one of the framework's macros. `name` says how a case's full name is made from the macro's arguments; `list` lists the cases (run only for a program whose source calls a macro); `filter` selects cases, `{}` being the cases joined by `join`, each with the characters of `escape` preceded by a backslash (a GoogleTest name is two identifiers, which hold none of its filter's special characters); `report` writes the results to the file `{}`, read as `reportFormat`. A framework with `protocol` implements the mtest test protocol (mcpp 2026.11.8.1, feature `test-protocol`): its cases are its `\"name\"_test` literals, listed by `mcpp test --cases`, run by `mcpp test --case` and reported in mcpp's records; a program of any framework that implements the protocol (GoogleTest through mcpplibs.mtest.gtest) is listed and run so. boost-ext.ut is not here: its runner takes no filter from the command line, so its tests run as programs.", "frameworks": [ + { + "id": "mtest", + "title": "mtest", + "packages": ["mcpplibs.mtest"], + "protocol": 1, + "macros": [], + "name": "literal", + "list": [], + "listFormat": "lines", + "filter": [], + "join": ",", + "escape": "", + "report": [], + "reportFormat": "" + }, { "id": "gtest", "title": "GoogleTest", diff --git a/src/bin/mockmcpp.cpp b/src/bin/mockmcpp.cpp index 9b3912d..2ba39ef 100644 --- a/src/bin/mockmcpp.cpp +++ b/src/bin/mockmcpp.cpp @@ -189,7 +189,13 @@ int main(int argc, char** argv) { err(" Compiling app v0.1.0 (.)\n Finished dev [unoptimized + debuginfo] in 0.01s\n"); return 0; } - if (first(0) == "test") { out(fixture(has("--list") ? "test-list.ndjson" : "test.ndjson")); return 0; } + // A program that implements the test protocol: its listing (`--cases`) and a run of a selection + // (`--case`), each a record with the protocol's fields (mcpp docs/50 §8). + if (first(0) == "test") { + out(fixture(has("--list") ? "test-list.ndjson" : has("--cases") ? "test-cases.ndjson" + : has("--case") ? "test-protocol.ndjson" : "test.ndjson")); + return 0; + } if (first(0) == "search") { out(fixture("search.json")); return 0; } if (first(0) == "index" && first(1) == "list") { out(fixture("index-list.json")); return 0; } if (first(0) == "pack" && has("--help")) { out(fixture("pack-help.txt")); return 0; } diff --git a/src/scan.cppm b/src/scan.cppm index 69955d7..0d0cc41 100644 --- a/src/scan.cppm +++ b/src/scan.cppm @@ -25,9 +25,18 @@ struct Call { std::vector arguments; }; +// A case of the mtest library (`"name"_test`): its identifier -- the names of the suites it is +// declared in and its own, joined by `/` -- and where its name is (0-based). +struct Literal { + std::string id; + int line = 0; + int column = 0; +}; + struct Result { std::optional mainLine; // 0-based std::vector calls; + std::vector cases; // `"name"_test`, in `suite x{"name", ...}` scopes }; // Scans `text` for `main` and for calls of the macros named in `macros`. @@ -155,6 +164,17 @@ Result scan(std::string_view text, const std::vector& macros) { bool atDeclaration = true; // the next identifier starts a declaration bool lineHasCode = false; // a `#` is a directive only first on its line std::string previous; // the previous identifier, for `int main(` + // A suite of the mtest library: `suite {"", ...}`, whose `{` opens the scope its + // cases' identifiers are prefixed in. `suiteState` follows the declaration: 1 after `suite`, + // 2 after its variable's name, 3 after the `{` (the next literal is the suite's identifier). + struct Scope { std::string name; int depth; }; + std::vector suites; + int suiteState = 0, suiteDepth = 0; + auto prefix = [&] { + std::string out; + for (const auto& s : suites) if (!s.name.empty()) out += s.name + "/"; + return out; + }; while (!c.done()) { const char ch = c.peek(); if (ch == '\n') { lineHasCode = false; c.step(); continue; } @@ -164,7 +184,22 @@ Result scan(std::string_view text, const std::vector& macros) { if (ch == '#' && !lineHasCode) { c.skip_directive(); atDeclaration = true; previous.clear(); continue; } lineHasCode = true; if (c.on_raw()) { c.skip_raw(); atDeclaration = false; previous.clear(); continue; } - if (ch == '"' || ch == '\'') { c.skip_quoted(ch); atDeclaration = false; previous.clear(); continue; } + if (ch == '"') { + const auto from = c.at; + const int line = c.line, column = c.column(); + c.skip_quoted(ch); + const auto value = literal_value(text.substr(from, c.at - from)); + if (suiteState == 3) { + suites.push_back({value, suiteDepth}); + suiteState = 0; + } else if (text.substr(c.at, 5) == "_test" && !(c.at + 5 < text.size() && ident_char(text[c.at + 5]))) { + r.cases.push_back(Literal{prefix() + value, line, column}); + } + atDeclaration = false; + previous.clear(); + continue; + } + if (ch == '\'') { c.skip_quoted(ch); atDeclaration = false; previous.clear(); continue; } if (ident_start(ch)) { const auto from = c.at; const int line = c.line, column = c.column(); @@ -179,6 +214,10 @@ Result scan(std::string_view text, const std::vector& macros) { break; } const bool call = look.peek() == '('; + // `suite`, or `mtest::suite` (the identifier before `::` is kept in `previous`). + if (word == "suite" && (atDeclaration || previous == "mtest")) suiteState = 1; + else if (suiteState == 1) suiteState = 2; + else suiteState = 0; if (call && atDeclaration && std::ranges::find(macros, word) != macros.end()) { c = look; r.calls.push_back(Call{word, line, column, arguments(c)}); @@ -192,10 +231,18 @@ Result scan(std::string_view text, const std::vector& macros) { atDeclaration = false; continue; } - if (ch == '{') { ++depth; atDeclaration = true; } - else if (ch == '}') { if (depth > 0) --depth; atDeclaration = true; } - else if (ch == ';') atDeclaration = true; - else atDeclaration = false; + if (ch == '{') { + if (suiteState == 2) { suiteState = 3; suiteDepth = depth; } + else suiteState = 0; + ++depth; + atDeclaration = true; + } else if (ch == '}') { + if (depth > 0) --depth; + while (!suites.empty() && suites.back().depth >= depth) suites.pop_back(); + atDeclaration = true; + } + else if (ch == ';') { atDeclaration = true; suiteState = 0; } + else { atDeclaration = false; suiteState = 0; } // `std::main`, `x.main`: not the program's. if (ch != ':' ) previous.clear(); c.step(); diff --git a/src/server.cppm b/src/server.cppm index fb5755c..c0912c3 100644 --- a/src/server.cppm +++ b/src/server.cppm @@ -189,6 +189,9 @@ private: // The whole output of the last test record of each running test job, for a case run to divide // among its cases when the job ends (test/runCases). std::map> programOutput_; + // The last test record of each running test job, whole: a program that implements the test + // protocol reports its cases in it (test/runCases). + std::map> testRecord_; // The configuration (and platforms) the last plan of each project named. std::map plans_; // What `mcpp pack` accepts, read from its help once per mcpp (SPEC-014: `--pkg-format` names the @@ -264,6 +267,7 @@ Session::Session(rpc::Server& server) : rpc_(server) { jobOf_.erase(it); } if (auto it = programOutput_.find(id); it != programOutput_.end()) programOutput_.erase(it); + if (auto it = testRecord_.find(id); it != testRecord_.end()) testRecord_.erase(it); } rpc_.notify("mcpp-ide/job/finished", s); // Every envelope names the mcpp that wrote it: another version than the one probed (a @@ -424,6 +428,7 @@ void Session::on_record(std::string_view jobId, const Json& record) { { std::lock_guard lock(mutex_); programOutput_[std::string(jobId)] = record.value("compile_output", "") + record.value("run_output", ""); + testRecord_[std::string(jobId)] = record; } rpc_.notify("mcpp-ide/test/result", n); } else if (type == "summary") { @@ -1523,10 +1528,55 @@ std::expected Session::test_cases(const Json& p) { trusted = trusted_; } const auto* f = testing::framework_of(plan_packages(root, trusted), found, extra); + std::vector cases; + if (f) { + out["framework"] = f->id; + cases = testing::static_cases(*f, found, extra); + } + // A PROGRAM THAT IMPLEMENTS THE TEST PROTOCOL LISTS ITS OWN CASES (mcpp's `test-protocol`): + // `mcpp test --cases`, whatever framework wrote it; its cases then run by `--case`. + if (p.value("list", false) && trusted && has_feature(root, "test-protocol")) { + mcpp::Client client; + { + std::lock_guard lock(mutex_); + client = client_; + } + if (client.ready()) { + mcpp::RunOptions o; + o.cwd = root; + o.args = {"test", "--message-format", "json"}; + for (const auto& a : target_args(p)) o.args.push_back(a); + o.args.push_back(test); + o.args.push_back("--cases"); + o.deny = deny(); + o.client = client_label(); + if (auto r = client.records(o)) + for (const auto& rec : r->records) { + if (!rec.is_object() || rec.value("test", "") != test || !rec.contains("listed")) continue; + const auto* protocol = testing::framework("mtest"); + if (!protocol || !rec["protocol"].is_number()) continue; + std::vector listed; + for (const auto& c : testing::protocol_listing(rec)) { + auto known = std::ranges::find_if(cases, [&](const testing::Case& s) { return s.name == c.id; }); + listed.push_back(testing::Case{c.id, known != cases.end() && known->line >= 0 ? known->line : c.line, "listed"}); + } + cases = std::move(listed); + out["framework"] = protocol->id; + out["listed"] = true; + f = protocol; + } + } + } if (!f) return out; - out["framework"] = f->id; - auto cases = testing::static_cases(*f, found, extra); - if (p.value("list", false) && trusted) { + if (out.value("listed", false)) { + for (const auto& c : cases) { + Json row{{"name", c.name}, {"source", c.source}}; + if (c.line >= 0) row["line"] = c.line; + out["cases"].push_back(row); + } + return out; + } + if (p.value("list", false) && trusted && !f->protocol) { mcpp::Client client; { std::lock_guard lock(mutex_); @@ -1573,8 +1623,14 @@ std::expected Session::test_run_cases(const Json& p) { std::filesystem::create_directories(dir, ec); if (ec) return std::unexpected(rpc::Error{rpc::REQUEST_FAILED, ec.message()}); const auto report = dir / "report"; - Json args = Json::array({"--"}); - for (const auto& a : testing::run_args(*f, cases, report)) args.push_back(a); + Json args = Json::array(); + if (f->protocol) { + // The protocol selects by identifier, and the record reports each case (mcpp docs/08). + for (const auto& c : cases) { args.push_back("--case"); args.push_back(c); } + } else { + args.push_back("--"); + for (const auto& a : testing::run_args(*f, cases, report)) args.push_back(a); + } Json job{{"kind", "test"}, {"root", root.generic_string()}, {"filter", test}, {"args", args}, {"color", p.value("color", true)}}; for (const auto* key : {"profile", "target"}) if (p.contains(key)) job[key] = p[key]; const std::string framework = f->id; @@ -1588,7 +1644,21 @@ std::expected Session::test_run_cases(const Json& p) { std::lock_guard lock(mutex_); if (auto it = programOutput_.find(jobId); it != programOutput_.end()) programOutput = it->second; } - if (fw) { + if (fw && fw->protocol) { + Json record; + { + std::lock_guard lock(mutex_); + if (auto it = testRecord_.find(jobId); it != testRecord_.end()) record = it->second; + } + bool truncated = false; + for (const auto& o : testing::protocol_outcomes(record)) { + auto output = views::bounded_output(o.outcome.status == "failed" ? std::string_view(o.outcome.message) : std::string_view{}, truncated); + rpc_.notify("mcpp-ide/test/caseResult", Json{{"jobId", jobId}, {"root", root.generic_string()}, {"test", test}, + {"case", o.outcome.name}, {"status", o.outcome.status}, {"durationMs", o.outcome.durationMs}, + {"message", o.outcome.message}, {"output", std::move(output)}, + {"outputTruncated", truncated}, {"location", o.location}}); + } + } else if (fw) { const auto outputs = testing::case_outputs(*fw, programOutput); for (const auto& o : testing::read_report(*fw, text)) { // Each case with its own output (passed or not) and a failure's place (D16). diff --git a/src/testing.cppm b/src/testing.cppm index 337e926..b563e23 100644 --- a/src/testing.cppm +++ b/src/testing.cppm @@ -30,6 +30,10 @@ struct Framework { std::string listFormat; // "gtest-list" | "catch2-json" | "lines" std::vector filter, report; std::string join, escape, reportFormat; + // The version of the mtest test protocol the framework's programs implement (0: none). Their + // cases are their `"name"_test` literals in the source, listed by `mcpp test --cases`, run by + // `mcpp test --case` and reported in mcpp's records (mcpp's feature `test-protocol`). + int protocol = 0; }; // spec/test-frameworks.json, in its order. @@ -87,6 +91,22 @@ std::map case_outputs(const Framework& f, std::string_ // A report the framework wrote. std::vector read_report(const Framework& f, std::string_view text); +// The cases of a test program that implements the test protocol, as a record of `mcpp test +// --message-format json` states them (mcpp docs/50 §8): `listed` (from `--cases`), each with its +// place (0-based line); and each case's outcome from `cases`, with its first failure's message and +// place. +struct ProtocolCase { + std::string id; + std::string file; + int line = -1; +}; +std::vector protocol_listing(const Json& record); +struct ProtocolOutcome { + Outcome outcome; + Json location; // {path, line} or null +}; +std::vector protocol_outcomes(const Json& record); + } // namespace mcppide::testing namespace mcppide::testing { @@ -269,6 +289,7 @@ const std::vector& frameworks() { v.join = f.value("join", ","); v.escape = f.value("escape", ""); v.reportFormat = f.value("reportFormat", ""); + v.protocol = f.value("protocol", 0); out.push_back(std::move(v)); } return out; @@ -295,6 +316,7 @@ const Framework* framework_of(const std::vector& packages, const sc return std::ranges::any_of(packages, [&](const std::string& in) { return in == p || in.starts_with(p + ":"); }); }); if (!linked) continue; + if (f.protocol && !found.cases.empty()) return &f; const auto names = macros_of(f, extra); if (std::ranges::any_of(found.calls, [&](const scan::Call& c) { return std::ranges::find(names, c.name) != names.end(); })) return &f; @@ -304,6 +326,10 @@ const Framework* framework_of(const std::vector& packages, const sc std::vector static_cases(const Framework& f, const scan::Result& found, const Json& extra) { std::vector out; + if (f.protocol) { + for (const auto& c : found.cases) out.push_back(Case{c.id, c.line, "static"}); + return out; + } const auto names = macros_of(f, extra); for (const auto& call : found.calls) { if (std::ranges::find(names, call.name) == names.end()) continue; @@ -473,6 +499,42 @@ std::map case_outputs(const Framework& f, std::string_ return out; } +std::vector protocol_listing(const Json& record) { + std::vector out; + for (const auto& c : record.value("listed", Json::array())) { + if (!c.is_object()) continue; + const int line = c.value("line", 0); + out.push_back({c.value("id", ""), c.value("file", ""), line > 0 ? line - 1 : -1}); + } + return out; +} + +std::vector protocol_outcomes(const Json& record) { + std::vector out; + for (const auto& c : record.value("cases", Json::array())) { + if (!c.is_object()) continue; + ProtocolOutcome o; + o.outcome.name = c.value("id", ""); + const auto status = c.value("status", ""); + o.outcome.status = status == "pass" || status == "xfail" ? "passed" : status == "skip" ? "skipped" : "failed"; + o.outcome.durationMs = static_cast(c.value("duration_ms", 0.0)); + o.location = nullptr; + const auto failures = c.value("failures", Json::array()); + if (!failures.empty() && failures[0].is_object()) { + const auto& f = failures[0]; + o.outcome.message = std::format("{}:{}: {}", f.value("file", ""), f.value("line", 0), f.value("message", "")); + if (f.contains("expected")) + o.outcome.message += std::format(" (expected {}, actual {})", f.value("expected", ""), f.value("actual", "")); + const int line = f.value("line", 0); + o.location = Json{{"path", f.value("file", "")}, {"line", line > 0 ? line - 1 : 0}}; + } else { + o.outcome.message = c.value("reason", ""); + } + out.push_back(std::move(o)); + } + return out; +} + std::vector read_report(const Framework& f, std::string_view text) { if (f.reportFormat == "junit") return junit_report(text); const Json doc = Json::parse(text, nullptr, false); diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index e438aca..1d54c72 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -365,6 +365,35 @@ int main() { const auto& def = protocol["notifications"][name]["params"]; for (const auto& n : wire.notifications(name)) check(conforms(n, def, protocol, name)); } + // A program that implements the test protocol (mcpp's `test-protocol`): its cases listed by + // `mcpp test --cases`, a selection run by `--case`, and each case's result from the record. + const auto protocolRoot = root.parent_path() / (root.filename().string() + "-cases"); + std::filesystem::create_directories(protocolRoot / "tests" / "unit"); + std::ofstream(protocolRoot / "mcpp.toml") << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n"; + std::ofstream(protocolRoot / ".mock-mcpp-features") << "test-protocol"; + std::ofstream(protocolRoot / "tests" / "unit" / "parse.cpp") + << "import mcpplibs.mtest;\nusing namespace mcpplibs::mtest;\nsuite parsing{\"parsing\", [] {\n" + " \"an empty line\"_test = [] {};\n \"a field\"_test = [] {};\n}};\n"; + const auto listing = result_of("mcpp-ide/test/cases", Json{{"root", protocolRoot.generic_string()}, {"test", "unit/parse"}, {"list", true}}); + check(listing["framework"] == "mtest" && listing.value("listed", false)); + check(listing["cases"].size() == 2 && listing["cases"][0]["name"] == "parsing/an empty line" && listing["cases"][0]["line"] == 3); + const auto caseResultsBefore = wire.notifications("mcpp-ide/test/caseResult").size(); + const auto run = result_of("mcpp-ide/test/runCases", Json{{"root", protocolRoot.generic_string()}, {"test", "unit/parse"}, {"framework", "mtest"}, + {"cases", Json::array({"parsing/an empty line", "parsing/a field"})}}); + check(run.contains("jobId")); + for (int i = 0; i < 2000 && wire.notifications("mcpp-ide/test/caseResult").size() < caseResultsBefore + 2; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + const auto caseResults = wire.notifications("mcpp-ide/test/caseResult"); + check(caseResults.size() == caseResultsBefore + 2); + if (caseResults.size() == caseResultsBefore + 2) { + check(caseResults[caseResultsBefore]["case"] == "parsing/an empty line" && caseResults[caseResultsBefore]["status"] == "passed"); + const auto& failed = caseResults[caseResultsBefore + 1]; + check(failed["status"] == "failed" && failed["location"]["line"] == 6 + && failed["message"].get().contains("expected 2, actual 1")); + for (const auto& n : caseResults) + check(conforms(n, protocol["notifications"]["mcpp-ide/test/caseResult"]["params"], protocol, "mcpp-ide/test/caseResult")); + } + std::filesystem::remove_all(protocolRoot); std::filesystem::remove_all(streamed); std::filesystem::remove_all(root); std::println("test_protocol: ok"); diff --git a/tests/test_testing.cpp b/tests/test_testing.cpp index 0a0fe6e..e23ae4d 100644 --- a/tests/test_testing.cpp +++ b/tests/test_testing.cpp @@ -149,6 +149,49 @@ TEST(Math, check(testing::case_outputs(fw("catch2"), "[ RUN ] x\n").empty()); } + // mtest's cases: `"name"_test` literals, prefixed by the suites they are declared in; never a + // literal in a comment or one not followed by `_test`. The framework is claimed by its package + // and its cases, and a record of `mcpp test` lists and reports them (the test protocol). + { + const auto r = scan::scan(R"(import mcpplibs.mtest; +using namespace mcpplibs::mtest; +// "commented"_test +suite parsing{"parsing", [] { + "an empty line"_test = [] { expect(true); }; + suite fields{"fields", [] { + "one field"_test[tag{"x"}] = [] {}; + }}; + const char* s = "not a case"; +}}; +mcpplibs::mtest::suite other{"other", [] { "after"_test = [] {}; }}; +int main() { "top"_test = [] {}; return report(); } +)", {}); + check(r.cases.size() == 4); + check(r.cases.size() == 4 && r.cases[0].id == "parsing/an empty line" && r.cases[0].line == 4); + check(r.cases.size() == 4 && r.cases[1].id == "parsing/fields/one field"); + check(r.cases.size() == 4 && r.cases[2].id == "other/after" && r.cases[3].id == "top"); + check(testing::framework_of({"app", "mcpplibs.mtest"}, r, Json::object()) == &fw("mtest")); + check(testing::framework_of({"app"}, r, Json::object()) == nullptr); + const auto cases = testing::static_cases(fw("mtest"), r, Json::object()); + check(cases.size() == 4 && cases[0].name == "parsing/an empty line" && cases[0].line == 4); + + const auto record = Json::parse(R"({"test":"parse","protocol":1, + "listed":[{"id":"parsing/an empty line","file":"tests/parse.cpp","line":5}], + "cases":[{"id":"a","status":"pass","duration_ms":1.5}, + {"id":"b","status":"fail","duration_ms":2,"failures":[{"file":"tests/parse.cpp","line":9,"message":"1 == 2","expected":"2","actual":"1"}]}, + {"id":"c","status":"skip","duration_ms":0,"reason":"later"}, + {"id":"d","status":"xfail","duration_ms":0}]})"); + const auto listed = testing::protocol_listing(record); + check(listed.size() == 1 && listed[0].id == "parsing/an empty line" && listed[0].line == 4); + const auto outcomes = testing::protocol_outcomes(record); + check(outcomes.size() == 4); + check(outcomes.size() == 4 && outcomes[0].outcome.status == "passed" && outcomes[0].outcome.durationMs == 1); + check(outcomes.size() == 4 && outcomes[1].outcome.status == "failed" && outcomes[1].location["line"] == 8 + && outcomes[1].outcome.message.contains("expected 2, actual 1")); + check(outcomes.size() == 4 && outcomes[2].outcome.status == "skipped" && outcomes[2].outcome.message == "later"); + check(outcomes.size() == 4 && outcomes[3].outcome.status == "passed"); + } + // source/entries and test/cases over a project on disk (no mcpp: the plan's packages are not // known, so no framework is claimed, and the static entries still answer). {