From 1bfd6c83cf4c27c88da39754b36b272e71e6cbc7 Mon Sep 17 00:00:00 2001 From: wellwei Date: Fri, 25 Sep 2026 21:58:58 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E8=AF=AD=E8=A8=80=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=BE=9D=E8=B5=96=E5=88=87=E6=8D=A2=E4=B8=BA=20mcpp-l?= =?UTF-8?q?anguage-server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 C++ 模块语言服务职责从官方 clangd 扩展迁移到 sunrisepeak.mcpp-language-server(mcppls),本扩展只保留 mcpp 的构建、 工具链、任务与 mcpp.toml 编辑支持。 - package.json:extensionDependencies 换为 sunrisepeak.mcpp-language-server, VS Code 下限提升到 ^1.91.0,版本定为 0.4.0 - 新增 src/languageServer.ts:通过 mcppls 公开命令(restartServer / selectContext / showModuleGraph / showLogs)桥接,依赖缺失与命令失败 返回稳定结果,不引入 vscode-languageclient,不 spawn mcppls serve - src/extension.ts 收缩到 CLI + mcpp.toml 补全 + 语言服务桥接 - 删除 clangd 专属实现:analysis.ts、llvmTools.ts、workflow.ts 及其测试 - process.ts 只保留 runProcess;discovery.ts 去掉 CDB 路径与 clangd 候选 - 一键流程改为 mcpp build + mcppls 刷新,不再安装 llvm-tools 或切换 LLVM - 旧 mcpp.configureClangd 保留为弃用 alias,转发到新实现 顺带修复 mcpp.toml 补全与新 mcpp 契约的漂移:移除已被 mcpp 删除的 [xlings.envs] 段,[xlings.workspace] 示例版本改用当前索引可安装的 node。 npm test:179/179 通过。 --- package-lock.json | 8 +- package.json | 46 +- src/analysis.ts | 457 ---------- src/cliController.ts | 152 +--- src/commands.ts | 19 +- src/discovery.ts | 128 +-- src/extension.ts | 1428 +++---------------------------- src/languageServer.ts | 80 ++ src/llvmTools.ts | 173 ---- src/mcppTomlCompletion.ts | 8 +- src/moduleSetup.ts | 159 +--- src/process.ts | 57 -- src/tasks.ts | 6 +- src/workflow.ts | 315 ------- test/analysis.test.ts | 412 --------- test/artifacts.test.ts | 114 +-- test/commands.test.ts | 25 +- test/discovery.test.ts | 124 +-- test/languageServer.test.ts | 107 +++ test/llvmTools.test.ts | 189 ---- test/mcppTomlCompletion.test.ts | 6 + test/mcppTomlContract.test.ts | 8 +- test/moduleSetup.test.ts | 512 +++-------- test/process.test.ts | 70 +- test/tasks.test.ts | 18 +- test/workflow.test.ts | 499 ----------- 26 files changed, 594 insertions(+), 4526 deletions(-) delete mode 100644 src/analysis.ts create mode 100644 src/languageServer.ts delete mode 100644 src/llvmTools.ts delete mode 100644 src/workflow.ts delete mode 100644 test/analysis.test.ts create mode 100644 test/languageServer.test.ts delete mode 100644 test/llvmTools.test.ts delete mode 100644 test/workflow.test.ts diff --git a/package-lock.json b/package-lock.json index 3feca73..fb78b66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,24 @@ { "name": "mcpp-vscode", - "version": "0.3.1", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.3.1", + "version": "0.4.0", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", - "@types/vscode": "^1.90.0", + "@types/vscode": "^1.91.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "mocha": "^11.8.0", "typescript": "^5.9.0" }, "engines": { - "vscode": "^1.90.0" + "vscode": "^1.91.0" } }, "node_modules/@azu/format-text": { diff --git a/package.json b/package.json index 7c681d5..5b43e50 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "mcpp-vscode", "displayName": "mcpp", - "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.3.1", + "description": "mcpp 工程构建、工具链与 C++ Modules 语言服务集成", + "version": "0.4.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", @@ -15,16 +15,17 @@ "url": "https://github.com/mcpp-community/mcpp-vscode/issues" }, "engines": { - "vscode": "^1.90.0" + "vscode": "^1.91.0" }, "extensionDependencies": [ - "llvm-vs-code-extensions.vscode-clangd" + "sunrisepeak.mcpp-language-server" ], "activationEvents": [ "workspaceContains:mcpp.toml", "onLanguage:cpp", "onLanguage:mcpp-build", "onLanguage:mcpp-toml", + "onCommand:mcpp.configureLanguageServer", "onCommand:mcpp.configureClangd", "onCommand:mcpp.refreshCompilationDatabase", "onCommand:mcpp.checkModuleSupport", @@ -37,7 +38,9 @@ "onCommand:mcpp.showToolchains", "onCommand:mcpp.installToolchain", "onCommand:mcpp.selectDefaultToolchain", - "onCommand:mcpp.autoConfigureModules" + "onCommand:mcpp.autoConfigureModules", + "onCommand:mcpp.showModuleGraph", + "onCommand:mcpp.showLanguageServerLogs" ], "categories": [ "Programming Languages", @@ -46,7 +49,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" + "description": "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。" } }, "main": "./dist/src/extension.js", @@ -104,21 +107,33 @@ "command": "mcpp.selectDefaultToolchain", "title": "mcpp: 选择全局默认工具链" }, + { + "command": "mcpp.configureLanguageServer", + "title": "mcpp: 选择 C++ 模块分析上下文" + }, { "command": "mcpp.configureClangd", - "title": "mcpp: 配置 clangd" + "title": "mcpp: [已弃用] 配置 C++ 模块语言服务" }, { "command": "mcpp.refreshCompilationDatabase", - "title": "mcpp: 刷新编译数据库" + "title": "mcpp: 刷新模块构建描述" }, { "command": "mcpp.checkModuleSupport", - "title": "mcpp: 检查模块支持" + "title": "mcpp: 重启 C++ Modules 语言服务" }, { "command": "mcpp.autoConfigureModules", - "title": "mcpp: 一键配置模块代码提示" + "title": "mcpp: 一键构建并刷新模块语言服务" + }, + { + "command": "mcpp.showModuleGraph", + "title": "mcpp: 查看模块图" + }, + { + "command": "mcpp.showLanguageServerLogs", + "title": "mcpp: 打开 C++ Modules 日志" } ], "configuration": { @@ -134,7 +149,8 @@ "type": "string", "default": "", "scope": "resource", - "description": "与 CDB 中 LLVM 编译器来自同一 revision 的 clangd 可执行文件路径;不要求与 mcpp 安装在同一位置。" + "description": "已弃用:C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server,此设置不再生效。", + "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" }, "mcpp.modulesSupport": { "type": "string", @@ -145,13 +161,15 @@ ], "default": "auto", "scope": "resource", - "description": "控制 clangd 的 C++ 模块实验支持参数。" + "description": "已弃用:C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server,此设置不再生效。", + "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" }, "mcpp.configureCppTools": { "type": "boolean", "default": true, "scope": "resource", - "description": "配置 clangd 时,是否询问关闭 Microsoft C/C++ IntelliSense。" + "description": "已弃用:语言服务冲突现由 sunrisepeak.mcpp-language-server 管理,此设置不再生效。", + "deprecationMessage": "C++ 模块语言服务已迁移到 sunrisepeak.mcpp-language-server;此设置不再被 mcpp-vscode 使用。" }, "mcpp.tomlCompletion": { "type": "boolean", @@ -224,7 +242,7 @@ "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "^24.0.0", - "@types/vscode": "^1.90.0", + "@types/vscode": "^1.91.0", "@vscode/test-electron": "^3.1.0", "@vscode/vsce": "^3.9.2", "mocha": "^11.8.0", diff --git a/src/analysis.ts b/src/analysis.ts deleted file mode 100644 index 921beb3..0000000 --- a/src/analysis.ts +++ /dev/null @@ -1,457 +0,0 @@ -import path from "node:path"; - -export type CompilerKind = "llvm" | "gcc" | "msvc" | "unknown"; -export type ModuleCapability = "full" | "syntax-only" | "unavailable"; -export type ModulesSupportMode = "auto" | "on" | "off"; -export type CheckResult = - | "ready" - | "pcm-mismatch" - | "module-unavailable" - | "wrong-language-mode" - | "check-failed"; - -export interface CompilationDatabaseAnalysis { - kind: CompilerKind; - capability: ModuleCapability; - compilerPath?: string; - sourceFile?: string; - directory?: string; - arguments?: string[]; - hasPrebuiltModules?: boolean; - reason: string; -} - -export interface ToolIdentity { - major: number; - minor: number; - patch: number; - revision?: string; -} - -export interface ToolIdentityComparison { - compatible: boolean; - reason: string; -} - -export interface ClangdArgumentOptions { - compilerPath: string; - compilationArguments?: readonly string[]; - modulesSupport: ModulesSupportMode; - clangdIdentity?: ToolIdentity; - platform: NodeJS.Platform; - hasPrebuiltModules?: boolean; - workspaceFolder?: string; -} - -export interface ClangdConfigurationPlan { - path: string; - arguments: string[]; - changed: boolean; -} - -interface CompilationCommand { - directory?: unknown; - file?: unknown; - arguments?: unknown; - command?: unknown; -} - -function unavailable(reason: string): CompilationDatabaseAnalysis { - return { - kind: "unknown", - capability: "unavailable", - reason, - }; -} - -function splitCommand(command: string): string[] { - const result: string[] = []; - let token = ""; - let quote: "'" | '"' | undefined; - - for (let index = 0; index < command.length; index += 1) { - const character = command[index]; - - if (quote !== "'" && character === "\\" && index + 1 < command.length) { - const next = command[index + 1]; - if (next === "\\" || next === "'" || next === '"' || /\s/.test(next)) { - token += next; - index += 1; - continue; - } - token += character; - continue; - } - - if (character === "'" || character === '"') { - if (quote === character) { - quote = undefined; - } else if (quote === undefined) { - quote = character; - } else { - token += character; - } - continue; - } - - if (/\s/.test(character) && quote === undefined) { - if (token.length > 0) { - result.push(token); - token = ""; - } - continue; - } - - token += character; - } - - if (token.length > 0) { - result.push(token); - } - - return result; -} - -function commandArguments(command: CompilationCommand): string[] | undefined { - if ( - Array.isArray(command.arguments) - && command.arguments.length > 0 - && command.arguments.every((argument) => typeof argument === "string") - ) { - return command.arguments; - } - - if (typeof command.command === "string") { - const argumentsFromCommand = splitCommand(command.command); - return argumentsFromCommand.length > 0 ? argumentsFromCommand : undefined; - } - - return undefined; -} - -function compilerKind(compilerPath: string): CompilerKind { - const executable = path.win32.basename(compilerPath).toLowerCase(); - const name = executable.endsWith(".exe") ? executable.slice(0, -4) : executable; - - if (name === "clang" || name === "clang++" || name === "clang-cl") { - return "llvm"; - } - if (name === "gcc" || name === "g++" || name === "c++") { - return "gcc"; - } - if (name === "cl") { - return "msvc"; - } - return "unknown"; -} - -export function analyzeCompilationDatabase(contents: string): CompilationDatabaseAnalysis { - let parsed: unknown; - try { - parsed = JSON.parse(contents); - } catch { - return unavailable("compile_commands.json 不是有效的 JSON"); - } - - if (!Array.isArray(parsed) || parsed.length === 0) { - return unavailable("compile_commands.json 至少需要包含一条编译命令"); - } - - const candidates: CompilationDatabaseAnalysis[] = []; - - for (const value of parsed) { - if (value === null || typeof value !== "object") { - continue; - } - - const command = value as CompilationCommand; - const args = commandArguments(command); - if (args === undefined) { - continue; - } - - const kind = compilerKind(args[0]); - if (kind === "unknown") { - continue; - } - - const hasPrebuiltModules = args.some((argument) => ( - argument.startsWith("-fmodule-file=") - || argument.startsWith("-fprebuilt-module-path=") - )); - const directory = typeof command.directory === "string" ? command.directory : undefined; - const sourceFile = typeof command.file === "string" - ? resolveCompilationSourceFile(directory, command.file) - : undefined; - candidates.push({ - kind, - capability: kind === "llvm" ? "full" : "syntax-only", - compilerPath: args[0], - sourceFile, - directory, - arguments: args, - hasPrebuiltModules, - reason: kind === "llvm" - ? "Clang 编译命令可以由 clangd 使用" - : `${kind.toUpperCase()} 模块产物不能由 clangd 使用`, - }); - } - - if (candidates.length === 0) { - return unavailable("compile_commands.json 不包含受支持的编译器命令"); - } - - const score = (candidate: CompilationDatabaseAnalysis): number => { - const sourceFile = candidate.sourceFile ?? ""; - const moduleInterface = /\.(?:cppm|ixx|mpp|ccm)$/i.test(sourceFile); - const inProject = candidate.directory !== undefined && isWithinDirectory(candidate.directory, sourceFile); - return (inProject ? 200 : 0) - + (candidate.directory !== undefined && isProjectSource(candidate.directory, sourceFile) ? 200 : 0) - + (moduleInterface ? 100 : 0) - + (candidate.hasPrebuiltModules ? 10 : 0); - }; - - return candidates.reduce((best, candidate) => (score(candidate) > score(best) ? candidate : best)); -} - -function isWithinDirectory(directory: string, file: string): boolean { - const windows = /^[A-Za-z]:[\\/]/.test(directory) || directory.includes("\\") - || /^[A-Za-z]:[\\/]/.test(file) || file.includes("\\"); - const pathApi = windows ? path.win32 : path.posix; - const relative = pathApi.relative(pathApi.resolve(directory), pathApi.resolve(file)); - return relative === "" - || (relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)); -} - -function resolveCompilationSourceFile(directory: string | undefined, file: string): string { - if (directory === undefined) { - return file; - } - const windows = /^[A-Za-z]:[\\/]/.test(directory) || directory.includes("\\") - || /^[A-Za-z]:[\\/]/.test(file) || file.includes("\\"); - const pathApi = windows ? path.win32 : path.posix; - return pathApi.isAbsolute(file) ? file : pathApi.resolve(directory, file); -} - -function isProjectSource(directory: string, file: string): boolean { - if (!isWithinDirectory(directory, file)) { - return false; - } - const windows = /^[A-Za-z]:[\\/]/.test(directory) || directory.includes("\\") - || /^[A-Za-z]:[\\/]/.test(file) || file.includes("\\"); - const pathApi = windows ? path.win32 : path.posix; - const relative = pathApi.relative(pathApi.resolve(directory), pathApi.resolve(file)); - const firstComponent = relative.split(pathApi.sep)[0]; - return firstComponent !== ".mcpp" && firstComponent !== "target"; -} - -function removeManagedArguments(arguments_: readonly string[]): string[] { - const result: string[] = []; - - for (let index = 0; index < arguments_.length; index += 1) { - const argument = arguments_[index]; - if (argument === "--experimental-modules-support") { - continue; - } - if (argument === "--query-driver") { - index += 1; - continue; - } - if (argument.startsWith("--query-driver=")) { - continue; - } - result.push(argument); - } - - return result; -} - -function hasExplicitLibcxxPath(arguments_: readonly string[]): boolean { - return arguments_.some((argument, index) => { - const value = argument === "-isystem" - ? arguments_[index + 1] ?? "" - : argument.startsWith("-isystem") - ? argument.slice("-isystem".length) - : ""; - return /[\\/]include[\\/]c\+\+[\\/]v1(?:[\\/]|$)/.test(value); - }); -} - -function isHermeticClangCommand(arguments_: readonly string[] | undefined): boolean { - if (arguments_ === undefined) { - return false; - } - return arguments_.includes("--no-default-config") - && arguments_.includes("-nostdinc++") - && hasExplicitLibcxxPath(arguments_); -} - -function expandWorkspaceVariables(argument: string, workspaceFolder?: string): string { - if (workspaceFolder === undefined) { - return argument; - } - - return argument.replace(/\$\{workspace(?:Folder|Root)\}/g, () => workspaceFolder); -} - -function shouldEnableExperimentalModules(options: ClangdArgumentOptions): boolean { - if (options.modulesSupport === "on") { - return true; - } - if (options.modulesSupport === "off") { - return false; - } - if (options.hasPrebuiltModules) { - return false; - } - - const identity = options.clangdIdentity; - if (identity === undefined || identity.major < 21) { - return false; - } - - return !( - options.platform === "win32" - && identity.major === 20 - && identity.minor === 1 - && identity.patch === 7 - ); -} - -export function buildClangdArguments( - existingArguments: readonly string[], - options: ClangdArgumentOptions, -): string[] { - const result = removeManagedArguments(existingArguments) - .map((argument) => expandWorkspaceVariables(argument, options.workspaceFolder)); - if (!isHermeticClangCommand(options.compilationArguments)) { - result.push(`--query-driver=${options.compilerPath}`); - } - - if (shouldEnableExperimentalModules(options)) { - result.push("--experimental-modules-support"); - } - - return result; -} - -export function buildClangdConfigurationPlan( - currentPath: string, - currentArguments: readonly string[], - resolvedPath: string, - options: ClangdArgumentOptions, -): ClangdConfigurationPlan { - const arguments_ = buildClangdArguments(currentArguments, options); - return { - path: resolvedPath, - arguments: arguments_, - changed: currentPath !== resolvedPath - || arguments_.length !== currentArguments.length - || arguments_.some((argument, index) => argument !== currentArguments[index]), - }; -} - -export function parseToolIdentity(versionOutput: string): ToolIdentity | undefined { - const version = versionOutput.match(/\b(?:clangd|clang)(?:\s+version)?\s+(\d+)\.(\d+)(?:\.(\d+))?/i) - ?? versionOutput.match(/\b(\d+)\.(\d+)(?:\.(\d+))?\b/); - if (version === null) { - return undefined; - } - - const revision = versionOutput.match(/\b[0-9a-f]{7,40}\b/gi)?.at(-1); - return { - major: Number(version[1]), - minor: Number(version[2]), - patch: Number(version[3] ?? 0), - ...(revision === undefined ? {} : { revision: revision.toLowerCase() }), - }; -} - -export function compareToolIdentities( - compiler: ToolIdentity | undefined, - clangd: ToolIdentity | undefined, -): ToolIdentityComparison { - if (compiler === undefined || clangd === undefined) { - return { - compatible: false, - reason: "无法确定两套 LLVM 工具的身份", - }; - } - - if ( - compiler.major !== clangd.major - || compiler.minor !== clangd.minor - || compiler.patch !== clangd.patch - ) { - return { - compatible: false, - reason: "编译器与 clangd 的 LLVM 版本不同", - }; - } - - if (compiler.revision === undefined || clangd.revision === undefined) { - return { - compatible: false, - reason: "LLVM 版本相同,但无法获得精确的 revision", - }; - } - - if ( - !compiler.revision.startsWith(clangd.revision) - && !clangd.revision.startsWith(compiler.revision) - ) { - return { - compatible: false, - reason: "编译器与 clangd 的 LLVM revision 不同", - }; - } - - return { - compatible: true, - reason: "LLVM 版本和 revision 均匹配", - }; -} - -export function classifyCheckResult(exitCode: number, output: string): CheckResult { - if (exitCode === 0) { - return "ready"; - } - - const normalized = output.toLowerCase(); - if ( - normalized.includes("ast_file_different_branch") - || normalized.includes("different branch") - || normalized.includes("pch file uses an older pch format") - || normalized.includes("ast_file_version_too_new") - || normalized.includes("newer format that cannot be read") - || normalized.includes("ast_file_version_too_old") - || normalized.includes("older format that is no longer supported") - ) { - return "pcm-mismatch"; - } - if ( - normalized.includes("don't get the module unit") - || normalized.includes("failed to build module") - || normalized.includes("module file not found") - || /module ['\"].+['\"] not found/.test(normalized) - ) { - return "module-unavailable"; - } - if ( - normalized.includes("unknown type name 'import'") - || normalized.includes('unknown type name "import"') - || normalized.includes("expected unqualified-id") && normalized.includes("import") - ) { - return "wrong-language-mode"; - } - if (normalized.includes("all checks completed")) { - const diagnostics = output - .split(/\r?\n/) - .filter((line) => /^\s*e\[/i.test(line)); - if (diagnostics.length === 0 || diagnostics.every((line) => /\btweak:/i.test(line))) { - return "ready"; - } - } - - return "check-failed"; -} diff --git a/src/cliController.ts b/src/cliController.ts index 0cec3d9..79e9fc9 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -15,24 +15,17 @@ import { type ToolchainItem, } from "./cli"; import type { McppProjectDiscovery } from "./discovery"; -import { runConfigureOnly as runConfigureOnlyProcess } from "./configureOnly"; -import { runProcess, type ProcessResult } from "./process"; +import { runProcess } from "./process"; import { McppOperationRegistry, classifyTaskExit, projectTaskPlan, - shouldReconcileAfterTask, + shouldRefreshLanguageServerAfterTask, type ProjectTaskKind, type TaskCompletion, } from "./tasks"; import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; import { runNewProjectFlow, validateNewProjectName } from "./newProject"; -import { - mcppModuleSetupCommands, - type ModuleSetupCommand, - type ModuleSetupDecision, - type ModuleSetupStepResult, -} from "./moduleSetup"; export interface McppCliControllerOptions { output: vscode.OutputChannel; @@ -53,6 +46,11 @@ interface ToolchainPickItem extends vscode.QuickPickItem { type OperationToken = object; +export interface ProjectTaskRunOptions { + /** Set to false when the caller performs its own post-task action. */ + notify?: boolean; +} + const INSTALL_CUSTOM_LABEL = "$(edit) 输入其他兼容工具链 spec…"; const CONFIRM_INSTALL = "安装"; const CONFIRM_DETECT = "检测"; @@ -119,76 +117,13 @@ export class McppCliController { return this.operations.hasActive(); } - public async runConfigureOnly( - project: McppProjectDiscovery, - ): Promise { - if (!this.options.isTrusted()) { - return undefined; - } - - const token: OperationToken = {}; - if (this.operations.beginProject(project.root, token) !== undefined) { - return undefined; - } - - const executable = this.mcppExecutable(project); - const args = ["build", "--configure-only"]; - try { - const result = await runConfigureOnlyProcess(project.root, executable); - this.appendShortCommand("刷新编译数据库", executable, args, result); - return result; - } finally { - // configure-only 与 build/run/test 共用项目锁,异常时也必须释放。 - this.operations.finishProject(project.root, token); - } - } - - public async runAutomaticModuleSetup( - plan: Extract, - ): Promise { - const project = this.requireProject(); - const commands = mcppModuleSetupCommands(plan); - const firstCommand = commands[0]; - if (project === undefined || !this.requireTrusted() || firstCommand === undefined) { - return { - stage: firstCommand?.stage ?? "build", - state: "failed", - detail: "当前工作区无法执行自动模块配置。", - }; - } - - const token: OperationToken = {}; - if (this.operations.beginGlobal(token) !== undefined) { - return { - stage: firstCommand.stage, - state: "failed", - detail: "已有 mcpp 操作正在运行。", - }; - } - - const executable = this.mcppExecutable(project); - try { - for (const command of commands) { - const result = await this.executeAutomaticModuleSetupCommand( - project, - executable, - command, - ); - if (result.state !== "succeeded") { - return result; - } - } - return { stage: "build", state: "succeeded" }; - } finally { - // 整个 install/default/build 事务共用一个 token,异常时也必须释放。 - this.operations.finishGlobal(token); - } - } - - public async runProjectTask(kind: ProjectTaskKind): Promise { + public async runProjectTask( + kind: ProjectTaskKind, + options: ProjectTaskRunOptions = {}, + ): Promise { const project = this.requireProject(); if (project === undefined || !this.requireTrusted()) { - return; + return undefined; } if (kind === "clean") { @@ -198,7 +133,7 @@ export class McppCliController { CONFIRM_CLEAN, ); if (choice !== CONFIRM_CLEAN) { - return; + return undefined; } } @@ -212,7 +147,7 @@ export class McppCliController { if (choice === SHOW_TASKS) { await vscode.commands.executeCommand("workbench.action.tasks.showTasks"); } - return; + return undefined; } let completion: TaskCompletion | undefined; @@ -229,9 +164,12 @@ export class McppCliController { this.operations.finishProject(project.root, token); } - if (completion !== undefined && shouldReconcileAfterTask(kind, completion)) { + if (options.notify !== false + && completion !== undefined + && shouldRefreshLanguageServerAfterTask(kind, completion)) { await this.options.afterProjectTask(project, kind, completion); } + return completion; } public async showToolchains(): Promise { @@ -557,7 +495,7 @@ export class McppCliController { label: item.label, description: item.group === "project" ? "当前 mcpp 工程" - : item.group === "toolchain" ? "mcpp 工具链管理" : "clangd 与编译数据库", + : item.group === "toolchain" ? "mcpp 工具链管理" : "C++ Modules 语言服务", command: item.command, })); const picked = await vscode.window.showQuickPick(items, { @@ -621,7 +559,7 @@ export class McppCliController { }); } - private guarded(operation: () => Promise): () => Promise { + private guarded(operation: () => Promise): () => Promise { return async () => { try { await operation(); @@ -632,7 +570,7 @@ export class McppCliController { } catch { // 输出频道可能已经在窗口重载时释放。 } - void vscode.window.showErrorMessage(`mcpp:${message}`); + await vscode.window.showErrorMessage(`mcpp:${message}`); } }; } @@ -679,7 +617,7 @@ export class McppCliController { const result = await runProcess(executable, args, workingDirectory(project)); this.appendShortCommand("查看工具链", executable, args, result); if (result.exitCode !== 0) { - void vscode.window.showErrorMessage( + await vscode.window.showErrorMessage( `mcpp toolchain list 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, ); return undefined; @@ -687,7 +625,7 @@ export class McppCliController { const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); if (!inventory.recognized) { - void vscode.window.showErrorMessage( + await vscode.window.showErrorMessage( "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", ); return undefined; @@ -695,50 +633,6 @@ export class McppCliController { return inventory; } - private async executeAutomaticModuleSetupCommand( - project: McppProjectDiscovery, - executable: string, - command: ModuleSetupCommand, - ): Promise { - if (command.mode === "process") { - const result = await runProcess(executable, command.args, project.root); - this.appendShortCommand(`自动模块配置:${command.stage}`, executable, command.args, result); - return { - stage: command.stage, - state: result.exitCode === 0 ? "succeeded" : "failed", - exitCode: result.exitCode, - }; - } - - // build 直接复用任务执行器,避免在 global token 内再次申请 project token。 - let completion: TaskCompletion; - try { - completion = await this.executeTask( - project.root, - executable, - "mcpp: 自动模块配置构建", - command.args, - ); - } catch (error) { - return { - stage: command.stage, - state: "failed", - detail: error instanceof Error ? error.message : String(error), - }; - } - this.appendTaskCompletion( - project.root, - "mcpp: 自动模块配置构建", - command.args, - completion, - ); - return { - stage: command.stage, - state: completion.state, - exitCode: completion.exitCode, - }; - } - private async executeTask( root: string, executable: string, diff --git a/src/commands.ts b/src/commands.ts index a28796c..eb60afe 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -8,7 +8,16 @@ export const CLI_COMMANDS = { showToolchains: "mcpp.showToolchains", installToolchain: "mcpp.installToolchain", selectDefaultToolchain: "mcpp.selectDefaultToolchain", + configureLanguageServer: "mcpp.configureLanguageServer", + refreshCompilationDatabase: "mcpp.refreshCompilationDatabase", + checkModuleSupport: "mcpp.checkModuleSupport", autoConfigureModules: "mcpp.autoConfigureModules", + showModuleGraph: "mcpp.showModuleGraph", + showLanguageServerLogs: "mcpp.showLanguageServerLogs", +} as const; + +export const DEPRECATED_COMMANDS = { + configureClangd: "mcpp.configureClangd", } as const; export const quickMenuStatusText = "$(tools) mcpp: 快捷菜单"; @@ -27,8 +36,10 @@ export const quickMenuItems: readonly QuickMenuItem[] = [ { label: "$(list-unordered) 查看工具链", command: CLI_COMMANDS.showToolchains, group: "toolchain" }, { label: "$(cloud-download) 安装工具链", command: CLI_COMMANDS.installToolchain, group: "toolchain" }, { label: "$(settings-gear) 选择全局默认工具链", command: CLI_COMMANDS.selectDefaultToolchain, group: "toolchain" }, - { label: "$(symbol-interface) 配置 clangd", command: "mcpp.configureClangd", group: "ide" }, - { label: "$(database) 刷新编译数据库", command: "mcpp.refreshCompilationDatabase", group: "ide" }, - { label: "$(check) 检查模块支持", command: "mcpp.checkModuleSupport", group: "ide" }, - { label: "$(rocket) 一键配置模块代码提示", command: CLI_COMMANDS.autoConfigureModules, group: "ide" }, + { label: "$(symbol-interface) 选择 C++ 模块分析上下文", command: CLI_COMMANDS.configureLanguageServer, group: "ide" }, + { label: "$(database) 刷新模块构建描述", command: CLI_COMMANDS.refreshCompilationDatabase, group: "ide" }, + { label: "$(check) 重启 C++ Modules 语言服务", command: CLI_COMMANDS.checkModuleSupport, group: "ide" }, + { label: "$(type-hierarchy) 查看模块图", command: CLI_COMMANDS.showModuleGraph, group: "ide" }, + { label: "$(output) 打开 C++ Modules 日志", command: CLI_COMMANDS.showLanguageServerLogs, group: "ide" }, + { label: "$(rocket) 一键构建并刷新模块语言服务", command: CLI_COMMANDS.autoConfigureModules, group: "ide" }, ]; diff --git a/src/discovery.ts b/src/discovery.ts index 64c461c..c7a08ff 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,39 +1,12 @@ -import { - existsSync, - readFileSync, - statSync, -} from "node:fs"; +import { existsSync, statSync } from "node:fs"; import path from "node:path"; -import { parseMcppToml } from "./mcppTomlParser"; - export interface McppProjectDiscovery { root: string; manifestPath: string; - compilationDatabasePath: string; -} - -export function manifestProjectRoot(manifestPath: string): string { - return path.dirname(path.resolve(manifestPath)); -} - -export function shouldReconcileDeletedManifest( - currentProjectRoot: string | undefined, - manifestPath: string, -): boolean { - return currentProjectRoot !== undefined - && path.resolve(currentProjectRoot) === manifestProjectRoot(manifestPath); -} - -function isWindowsPath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); } -function unique(values: string[]): string[] { - return values.filter((value, index) => values.indexOf(value) === index); -} - -export function isPathWithinProject(candidate: string, root: string): boolean { +function isPathWithin(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return relative === "" || ( relative !== ".." @@ -42,58 +15,6 @@ export function isPathWithinProject(candidate: string, root: string): boolean { ); } -interface ManifestShape { - hasPackage: boolean; - hasWorkspace: boolean; -} - -function readManifestShape(manifestPath: string): ManifestShape { - try { - const document = parseMcppToml(readFileSync(manifestPath, "utf8").split(/\r?\n/)); - let hasPackage = false; - let hasWorkspace = false; - let insideSection = false; - for (const node of document.nodes) { - if (node.type === "section") { - insideSection = true; - if (node.segments.length === 1) { - hasPackage ||= node.segments[0].name === "package"; - hasWorkspace ||= node.segments[0].name === "workspace"; - } - continue; - } - // mcpp 同样接受顶层 dotted key 和 inline table 写法。 - if (!insideSection) { - hasPackage ||= node.keyPath[0]?.name === "package"; - hasWorkspace ||= node.keyPath[0]?.name === "workspace"; - } - } - return { hasPackage, hasWorkspace }; - } catch { - // 无法读取时保留原发现结果,让 mcpp 自己给出清单诊断。 - return { hasPackage: false, hasWorkspace: false }; - } -} - -export function projectAffectedByManifest( - manifestPath: string, - currentProject: McppProjectDiscovery | undefined, - manifestProject: McppProjectDiscovery | undefined, -): McppProjectDiscovery | undefined { - const manifestRoot = manifestProjectRoot(manifestPath); - const shape = readManifestShape(manifestPath); - if ( - shape.hasWorkspace - && currentProject !== undefined - && currentProject.root !== manifestRoot - && isPathWithinProject(currentProject.root, manifestRoot) - ) { - // member 会继承 workspace 根配置,根清单变化必须刷新当前 member 的 CDB。 - return currentProject; - } - return manifestProject; -} - export function findNearestMcppProject( startPath: string, workspaceRoot?: string, @@ -106,29 +27,18 @@ export function findNearestMcppProject( current = path.dirname(current); } } catch { - // 新创建的 VS Code 工作区路径可能尚不存在,此时按目录处理。 + // A newly-created workspace path may not exist yet; treat it as a directory. } - if (boundary !== undefined && !isPathWithinProject(current, boundary)) { + if (boundary !== undefined && !isPathWithin(current, boundary)) { return undefined; } while (true) { const manifestPath = path.join(current, "mcpp.toml"); if (existsSync(manifestPath)) { - // #387 的 virtual workspace fan-out 只在各 member 根发布 CDB; - // 虚拟根不是 clangd 可消费的单一 package 工程。 - const shape = readManifestShape(manifestPath); - if (shape.hasWorkspace && !shape.hasPackage) { - return undefined; - } - return { - root: current, - manifestPath, - compilationDatabasePath: path.join(current, "compile_commands.json"), - }; + return { root: current, manifestPath }; } - if (boundary !== undefined && current === boundary) { return undefined; } @@ -139,31 +49,3 @@ export function findNearestMcppProject( current = parent; } } - -export function deriveClangdCandidates(compilerPath: string): string[] { - const windows = isWindowsPath(compilerPath); - const pathApi = windows ? path.win32 : path.posix; - const compilerName = pathApi.basename(compilerPath); - const extension = /\.exe$/i.test(compilerName) ? ".exe" : ""; - const sibling = pathApi.join(pathApi.dirname(compilerPath), `clangd${extension}`); - const xlingsMatch = compilerPath.match( - /^(.*?)([\\/])xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)(\.exe)?$/i, - ); - - const xlingsCandidate = xlingsMatch === null - ? undefined - : `${xlingsMatch[1]}${xlingsMatch[2]}xim-x-llvm-tools${xlingsMatch[2]}${xlingsMatch[3]}${xlingsMatch[2]}bin${xlingsMatch[2]}clangd${xlingsMatch[4] ?? ""}`; - const userXlingsMatch = compilerPath.match( - /^(.*?)([\\/])\.mcpp[\\/]registry[\\/]data[\\/]xpkgs[\\/]xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)(\.exe)?$/i, - ); - const userXlingsCandidate = userXlingsMatch === null - ? undefined - : `${userXlingsMatch[1]}${userXlingsMatch[2]}.xlings${userXlingsMatch[2]}data${userXlingsMatch[2]}xpkgs${userXlingsMatch[2]}xim-x-llvm-tools${userXlingsMatch[2]}${userXlingsMatch[3]}${userXlingsMatch[2]}bin${userXlingsMatch[2]}clangd${userXlingsMatch[4] ?? ""}`; - - return unique([ - sibling, - ...(xlingsCandidate === undefined ? [] : [xlingsCandidate]), - ...(userXlingsCandidate === undefined ? [] : [userXlingsCandidate]), - "clangd", - ]); -} diff --git a/src/extension.ts b/src/extension.ts index 0d26e3d..b4d875e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,101 +1,23 @@ -import { existsSync, readFileSync } from "node:fs"; -import process from "node:process"; - import * as vscode from "vscode"; -import { - analyzeCompilationDatabase, - buildClangdArguments, - buildClangdConfigurationPlan, - compareToolIdentities, - type CheckResult, - type CompilationDatabaseAnalysis, - type ModulesSupportMode, - type ToolIdentityComparison, -} from "./analysis"; -import { - deriveClangdCandidates, - findNearestMcppProject, - isPathWithinProject, - manifestProjectRoot, - projectAffectedByManifest, - shouldReconcileDeletedManifest, - type McppProjectDiscovery, -} from "./discovery"; -import { - resolveXlingsExecutable, - llvmToolsVersionSpec, - xlingsInstallArgs, -} from "./llvmTools"; -import { CLI_COMMANDS } from "./commands"; import { McppCliController } from "./cliController"; -import { runClangdCheck, runToolVersion, type ProcessResult, type ToolVersionResult } from "./process"; -import { ensureIdeConfigured } from "./ideWorkflow"; -import { - configurationReadyAfterRestart, - configurationAffectsMcppExecution, - configurationAffectsModuleSupport, - createKeyedSingleFlightReconciler, - createLatestOperationTracker, - createSerialExecutor, - describeConfigureOnlyOutcome, - describeRefreshOutcome, - moduleSupportState, - registerCompilationDatabaseReconciliation, - shouldRestartClangd, - shouldCheckModuleSupport, - shouldRenderProjectStatus, - shouldUseWorkspaceClangd, - statusCommandForCapability, - withTimeout, - workspaceAllowsToolExecution, - type ModuleSupportState, -} from "./workflow"; -import { classifyTaskExit, type TaskCompletion } from "./tasks"; +import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "./commands"; +import { findNearestMcppProject, type McppProjectDiscovery } from "./discovery"; import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; +import { + createLanguageServerBridge, + type LanguageServerBridge, + type LanguageServerCommandResult, +} from "./languageServer"; import { computeMcppTomlCompletions } from "./mcppTomlCompletion"; import { buildModuleSetupPlan, executeModuleSetup, moduleSetupConfirmation, - type ModuleSetupBlockedReason, + type ModuleSetupDecision, type ModuleSetupStepResult, } from "./moduleSetup"; - -const COMMAND_CONFIGURE = "mcpp.configureClangd"; -const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; -const COMMAND_CHECK = "mcpp.checkModuleSupport"; - -interface ProjectContext { - project: McppProjectDiscovery; - analysis: CompilationDatabaseAnalysis; -} - -interface ClangdResolution { - path: string; - version: ToolVersionResult; - comparison: ToolIdentityComparison; -} - -interface ProjectReconciliation { - context: ProjectContext | undefined; - databaseFound: boolean; - configured: boolean; -} - -interface ModuleStatus { - state: ModuleSupportState; - message: string; -} - -type ConfigureMode = "automatic" | "interactive"; - -const moduleStatusByProject = new Map(); -const moduleCheckOperations = createLatestOperationTracker(); -// 记录 manifest/settings 变化触发的 IDE 配置请求;CDB watcher 只重读 -// 已发布快照,避免写 CDB 后再次启动 mcpp 形成重入。 -const forceConfigureOnlyByProject = new Set(); -let lastReconciledProjectRoot: string | undefined; +import type { TaskCompletion } from "./tasks"; function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; @@ -112,10 +34,7 @@ function findCurrentProject(): McppProjectDiscovery | undefined { } for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject( - workspaceFolder.uri.fsPath, - workspaceFolder.uri.fsPath, - ); + const project = findNearestMcppProject(workspaceFolder.uri.fsPath); if (project !== undefined) { return project; } @@ -123,665 +42,66 @@ function findCurrentProject(): McppProjectDiscovery | undefined { return undefined; } -function findProjectForUri(uri: vscode.Uri): McppProjectDiscovery | undefined { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(uri); - if (workspaceFolder === undefined) { - return undefined; - } - return findNearestMcppProject(uri.fsPath, workspaceFolder.uri.fsPath); -} - -function findWorkspaceProjects( - currentProject: McppProjectDiscovery | undefined, -): McppProjectDiscovery[] { - const projects = new Map(); - if (currentProject !== undefined) { - projects.set(currentProject.root, currentProject); - } - for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject( - workspaceFolder.uri.fsPath, - workspaceFolder.uri.fsPath, - ); - if (project !== undefined) { - projects.set(project.root, project); - } - } - return [...projects.values()]; -} - -function loadProjectContext(project: McppProjectDiscovery | undefined = findCurrentProject()): ProjectContext | undefined { - if (project === undefined) { - return undefined; - } - - if (!existsSync(project.compilationDatabasePath)) { - return { - project, - analysis: { - kind: "unknown", - capability: "unavailable", - reason: `找不到编译数据库:${project.compilationDatabasePath}`, - }, - }; - } - +function outputText(output: vscode.OutputChannel, line: string): void { try { - return { - project, - analysis: analyzeCompilationDatabase(readFileSync(project.compilationDatabasePath, "utf8")), - }; - } catch (error) { - return { - project, - analysis: { - kind: "unknown", - capability: "unavailable", - reason: error instanceof Error ? error.message : String(error), - }, - }; + output.appendLine(line); + } catch { + // The output channel can close before an async task finishes during shutdown. } } -function hasUsableCompilationDatabase(project: McppProjectDiscovery): boolean { - return loadProjectContext(project)?.analysis.capability !== "unavailable"; +function resultText(output: vscode.OutputChannel, result: LanguageServerCommandResult): void { + outputText(output, `[C++ Modules] ${result.message}`); + if (result.state === "unavailable") { + void vscode.window.showWarningMessage(result.message, "安装扩展").then((choice) => { + if (choice === "安装扩展") { + void vscode.commands.executeCommand("workbench.extensions.search", "@id:sunrisepeak.mcpp-language-server"); + } + }); + } else if (result.state === "failed") { + void vscode.window.showErrorMessage(result.message); + } } -function moduleSetupBlockedMessage(reason: ModuleSetupBlockedReason): string { +function moduleSetupBlockedMessage(reason: Extract["reason"]): string { switch (reason) { - case "project-toolchain-override": - return "当前项目显式固定了非 LLVM 工具链;一键配置不会修改 mcpp.toml,请先手动切换项目工具链。"; case "untrusted": - return "当前工作区未受信任,不会执行外部程序。请先信任工作区。"; + return "当前工作区未受信任,不会执行 mcpp 或刷新 C++ 模块语言服务。请先信任工作区。"; case "busy": return "已有 mcpp 操作正在运行,请等待完成后再试。"; - case "unrecognized-inventory": - return "无法识别 mcpp 工具链状态,请查看 mcpp 输出频道并检查 mcpp 版本。"; - } -} - -function configurationTarget(uri: vscode.Uri): vscode.ConfigurationTarget { - return vscode.workspace.getWorkspaceFolder(uri) === undefined - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.WorkspaceFolder; -} - -function projectConfiguration(project: McppProjectDiscovery): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration("mcpp", vscode.Uri.file(project.root)); -} - -function officialClangdConfiguration(project: McppProjectDiscovery): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration("clangd", vscode.Uri.file(project.root)); -} - -function appendProcessOutput( - output: vscode.OutputChannel, - title: string, - executable: string, - args: string[], - result: { exitCode: number; stdout: string; stderr: string }, -): void { - try { - output.appendLine(`\n[${new Date().toISOString()}] ${title}`); - output.appendLine(`$ ${executable} ${args.join(" ")}`); - if (result.stdout.length > 0) { - output.appendLine(result.stdout.trimEnd()); - } - if (result.stderr.length > 0) { - output.appendLine(result.stderr.trimEnd()); - } - output.appendLine(`[exit ${result.exitCode}]`); - } catch { - // 重载窗口时输出频道可能先于异步检查关闭。 - } -} - -function appendOutputLine(output: vscode.OutputChannel, value: string): void { - try { - output.appendLine(value); - } catch { - // 重载窗口时输出频道可能先于异步操作关闭。 - } -} - -async function resolveClangd(context: ProjectContext): Promise { - const compilerPath = context.analysis.compilerPath; - if (compilerPath === undefined) { - return undefined; - } - - const compilerVersion = await runToolVersion(compilerPath); - const configuredPath = projectConfiguration(context.project).get("clangd.path", "").trim(); - const candidates = configuredPath.length > 0 - ? [configuredPath] - : deriveClangdCandidates(compilerPath); - let fallback: ClangdResolution | undefined; - - for (const candidate of candidates) { - if (candidate !== "clangd" && !existsSync(candidate)) { - continue; - } - - const version = await runToolVersion(candidate); - if (version.exitCode !== 0 || version.identity === undefined) { - continue; - } - - const comparison = compareToolIdentities(compilerVersion.identity, version.identity); - const resolution = { path: candidate, version, comparison }; - if (comparison.compatible) { - return resolution; - } - fallback ??= resolution; - } - - return fallback; -} - -async function maybeDisableCppTools(project: McppProjectDiscovery): Promise { - if (vscode.extensions.getExtension("ms-vscode.cpptools") === undefined) { - return; - } - if (!projectConfiguration(project).get("configureCppTools", true)) { - return; - } - - const uri = vscode.Uri.file(project.root); - const configuration = vscode.workspace.getConfiguration("C_Cpp", uri); - if (configuration.get("intelliSenseEngine") === "disabled") { - return; - } - - const disable = "关闭 IntelliSense"; - const choice = await vscode.window.showWarningMessage( - "clangd 和 Microsoft C/C++ IntelliSense 都可能报告诊断。是否只关闭当前工作区的 cpptools IntelliSense?", - disable, - "保留 IntelliSense", - ); - if (choice === disable) { - await configuration.update( - "intelliSenseEngine", - "disabled", - configurationTarget(uri), - ); - } -} - -const CLANGD_RESTART_TIMEOUT_MS = 15_000; - -async function restartClangd(output: vscode.OutputChannel): Promise { - if (vscode.extensions.getExtension("llvm-vs-code-extensions.vscode-clangd") === undefined) { - return false; - } - try { - const timedOut = {}; - const result = await withTimeout( - vscode.commands.executeCommand("clangd.restart"), - CLANGD_RESTART_TIMEOUT_MS, - timedOut, - ); - if (result === timedOut) { - appendOutputLine(output, `[clangd] 重启命令超过 ${CLANGD_RESTART_TIMEOUT_MS / 1000} 秒未完成,已释放 IDE 操作队列。`); - return false; - } - return true; - } catch { - // clangd 扩展可能尚未激活,但配置仍然已经写入。 - return false; - } -} - -async function configureClangd( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, - mode: ConfigureMode, - forceRestart: boolean = false, -): Promise { - const interactive = mode === "interactive"; - if (context.analysis.capability === "syntax-only") { - if (interactive) { - void vscode.window.showWarningMessage( - `${context.analysis.kind.toUpperCase()} 模块产物无法由 clangd 读取。语法高亮仍然可用,但模块语义诊断需要 LLVM mcpp 工具链。`, - ); - } - updateStatusBar(status, context); - return false; - } - if (context.analysis.capability !== "full" || context.analysis.compilerPath === undefined) { - if (interactive) { - void vscode.window.showWarningMessage( - `${context.analysis.reason} 请先运行“mcpp: 刷新编译数据库”。`, - ); - } - return false; - } - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - const message = "当前工作区未受信任,mcpp 不会执行 CDB 中的编译器或 clangd,也不会接管 clangd 配置。"; - if (interactive) { - void vscode.window.showWarningMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - - const clangd = await resolveClangd(context); - if (clangd === undefined) { - const message = "没有找到可用的 clangd。请安装与 mcpp LLVM 编译器来自同一 revision 的 clangd,或设置 mcpp.clangd.path;clangd 可以来自 xlings llvm-tools,也可以独立安装。"; - if (interactive) { - void vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - if (!clangd.comparison.compatible && !interactive) { - appendOutputLine( - output, - `[自动配置] 跳过不匹配的 clangd ${clangd.path}:${clangd.comparison.reason}。`, - ); - return false; - } - - const clangdConfiguration = officialClangdConfiguration(context.project); - const modulesSupport = projectConfiguration(context.project) - .get("modulesSupport", "auto"); - const plan = buildClangdConfigurationPlan( - clangdConfiguration.get("path", "clangd"), - clangdConfiguration.get("arguments", []), - clangd.path, - { - compilerPath: context.analysis.compilerPath, - compilationArguments: context.analysis.arguments, - modulesSupport, - clangdIdentity: clangd.version.identity, - platform: process.platform, - hasPrebuiltModules: context.analysis.hasPrebuiltModules, - workspaceFolder: context.project.root, - }, - ); - - if (plan.changed) { - // clangd.arguments 不是资源域设置,必须与 clangd.path 一起写到工作区层级。 - await clangdConfiguration.update("path", plan.path, vscode.ConfigurationTarget.Workspace); - await clangdConfiguration.update("arguments", plan.arguments, vscode.ConfigurationTarget.Workspace); - } - if (interactive) { - await maybeDisableCppTools(context.project); - } - const restartRequired = shouldRestartClangd(plan.changed, interactive, forceRestart); - const restartSucceeded = restartRequired ? await restartClangd(output) : false; - if (!configurationReadyAfterRestart(restartRequired, restartSucceeded)) { - const message = "clangd 配置已写入,但无法重启语言服务器。请查看 mcpp 输出频道,或手动执行 clangd 重启命令。"; - if (interactive) { - void vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动配置] ${message}`); - } - return false; - } - updateStatusBar(status, context); - - if (!clangd.comparison.compatible) { - void vscode.window.showWarningMessage( - `clangd 已配置,但 LLVM 身份与 mcpp 编译器不匹配:${clangd.comparison.reason}。`, - ); - } else if (interactive) { - void vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); - } else if (plan.changed) { - appendOutputLine(output, `[自动配置] clangd.path = ${plan.path}`); - } - - return true; -} - -function renderModuleStatus(status: vscode.StatusBarItem, moduleStatus: ModuleStatus): void { - status.command = COMMAND_CHECK; - status.show(); - status.text = moduleStatus.state === "available" - ? "$(pass) mcpp: 模块可用" - : "$(warning) mcpp: 模块不可用"; - status.tooltip = moduleStatus.message; -} - -function invalidateModuleStatus(projectRoot: string): void { - moduleCheckOperations.invalidate(projectRoot); - moduleStatusByProject.delete(projectRoot); -} - -function storeModuleStatus( - status: vscode.StatusBarItem, - context: ProjectContext, - moduleStatus: ModuleStatus, - checkToken?: number, -): boolean { - if ( - checkToken !== undefined - && !moduleCheckOperations.isCurrent(context.project.root, checkToken) - ) { - return false; - } - moduleStatusByProject.set(context.project.root, moduleStatus); - if (shouldRenderProjectStatus(findCurrentProject()?.root, context.project.root)) { - renderModuleStatus(status, moduleStatus); - } - return true; -} - -function updateStatusBar(status: vscode.StatusBarItem, context?: ProjectContext): void { - const currentProjectRoot = findCurrentProject()?.root; - if (context === undefined) { - if (currentProjectRoot === undefined) { - status.hide(); - } - return; - } - - if (context.analysis.capability !== "full") { - invalidateModuleStatus(context.project.root); - } - if (!shouldRenderProjectStatus(currentProjectRoot, context.project.root)) { - return; - } - - status.command = statusCommandForCapability(context.analysis.capability); - status.show(); - if (context.analysis.capability === "full") { - const moduleStatus = moduleStatusByProject.get(context.project.root); - if (moduleStatus !== undefined) { - renderModuleStatus(status, moduleStatus); - return; - } - status.text = "$(symbol-interface) mcpp: LLVM 模块"; - status.tooltip = "正在等待 clangd 检查模块编译命令和 PCM。"; - return; - } - if (context.analysis.capability === "syntax-only") { - status.text = `$(info) mcpp: 仅${context.analysis.kind.toUpperCase()}语法`; - status.tooltip = `${context.analysis.reason} 模块语法高亮仍然可用。`; - return; - } - - status.text = "$(warning) mcpp: 缺少模块 CDB"; - status.tooltip = context.analysis.reason; -} - -function checkResultMessage(classification: CheckResult): string { - switch (classification) { - case "ready": - return "clangd 已加载 mcpp 编译命令和模块产物。"; - case "pcm-mismatch": - return "clangd 与 mcpp 编译器或 PCM 来自不同的 LLVM 构建。建议运行“一键配置模块代码提示”安装匹配的 llvm-tools,或设置 mcpp.clangd.path。"; - case "module-unavailable": - return "clangd 无法加载所需模块产物,请运行“mcpp: 刷新编译数据库”,并检查 CDB 中的 PCM 路径。"; - case "wrong-language-mode": - return "clangd 没有为当前文件读取 C++20 或更高版本的编译命令。"; - default: - return "clangd 模块检查失败,完整诊断请查看 mcpp 输出频道。"; - } -} - -async function runModuleSupportCheck( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, - mode: ConfigureMode, -): Promise { - const interactive = mode === "interactive"; - if (context.analysis.capability === "syntax-only") { - const message = `${context.analysis.kind.toUpperCase()} 模块产物不能由 clangd 消费,模块代码提示不可用。语法高亮仍然可用。`; - updateStatusBar(status, context); - if (interactive) { - const configure = "一键配置"; - const choice = await vscode.window.showWarningMessage( - `${message}\n\n如需启用模块代码提示,请切换到 LLVM 工具链后重新构建。`, - configure, - "关闭", - ); - if (choice === configure) { - await vscode.commands.executeCommand(CLI_COMMANDS.autoConfigureModules); - } - } - return undefined; - } - if ( - context.analysis.capability !== "full" - || context.analysis.compilerPath === undefined - || context.analysis.sourceFile === undefined - ) { - invalidateModuleStatus(context.project.root); - const message = context.analysis.capability === "full" - ? "compile_commands.json 没有可供 clangd 检查的源文件。" - : context.analysis.reason; - if (context.analysis.capability === "full") { - storeModuleStatus(status, context, { state: "unavailable", message }); - } - if (interactive) { - void vscode.window.showWarningMessage(message); - } - return context.analysis.capability === "full" - ? { state: "unavailable", message } - : undefined; - } - - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - invalidateModuleStatus(context.project.root); - const message = "当前工作区未受信任,不会执行 clangd 模块检查。信任工作区后扩展会自动重新检查。"; - const moduleStatus = { state: "unavailable", message } as const; - storeModuleStatus(status, context, moduleStatus); - if (interactive) { - void vscode.window.showWarningMessage(message); - } else { - appendOutputLine(output, `[自动检查] ${message}`); - } - return moduleStatus; - } - - const checkToken = moduleCheckOperations.begin(context.project.root); - moduleStatusByProject.delete(context.project.root); - if (shouldRenderProjectStatus(findCurrentProject()?.root, context.project.root)) { - status.command = COMMAND_CHECK; - status.text = "$(sync~spin) mcpp: 正在检查模块"; - status.tooltip = "正在使用 clangd 检查模块编译命令和 PCM。"; - status.show(); - } - - const clangd = await resolveClangd(context); - if (clangd === undefined) { - const message = "当前 LLVM 工具链没有找到可用的 clangd。请设置 mcpp.clangd.path。"; - const moduleStatus = { state: "unavailable", message } as const; - if (!storeModuleStatus(status, context, moduleStatus, checkToken)) { - return undefined; - } - if (interactive) { - void vscode.window.showErrorMessage(message); - } else { - appendOutputLine(output, `[自动检查] ${message}`); - } - return moduleStatus; - } - - const arguments_ = buildClangdArguments( - officialClangdConfiguration(context.project).get("arguments", []), - { - compilerPath: context.analysis.compilerPath, - compilationArguments: context.analysis.arguments, - modulesSupport: projectConfiguration(context.project) - .get("modulesSupport", "auto"), - clangdIdentity: clangd.version.identity, - platform: process.platform, - hasPrebuiltModules: context.analysis.hasPrebuiltModules, - workspaceFolder: context.project.root, - }, - ); - const result = await runClangdCheck( - clangd.path, - context.analysis.sourceFile, - context.project.root, - arguments_, - ); - appendProcessOutput( - output, - interactive ? "检查模块支持" : "自动检查模块支持", - clangd.path, - [`--check=${context.analysis.sourceFile}`, ...arguments_], - result, - ); - if (interactive) { - output.show(true); } - - const message = checkResultMessage(result.classification); - const moduleStatus = { - state: moduleSupportState(result.classification), - message, - }; - if (!storeModuleStatus(status, context, moduleStatus, checkToken)) { - return undefined; - } - if (interactive) { - if (moduleStatus.state === "available") { - void vscode.window.showInformationMessage(message); - } else { - void vscode.window.showErrorMessage(message); - } - } else { - appendOutputLine(output, `[自动检查] ${message}`); - } - return moduleStatus; -} - -async function updateModuleSupportForContext( - context: ProjectContext, - configured: boolean, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, -): Promise { - if (shouldCheckModuleSupport( - context.analysis.capability, - configured, - context.analysis.sourceFile, - )) { - const moduleStatus = await runModuleSupportCheck(context, status, output, "automatic"); - return moduleStatus?.state === "available"; - } - - if (context.analysis.capability === "full") { - invalidateModuleStatus(context.project.root); - const message = configured - ? "compile_commands.json 没有可供 clangd 检查的源文件。" - : "clangd 未完成配置,模块语义当前不可用。详情请查看 mcpp 输出频道。"; - storeModuleStatus(status, context, { state: "unavailable", message }); - } - return false; } -async function executeXlingsInstallTask( - xlingsPath: string, - args: string[], - cwd: string, -): Promise { - const task = new vscode.Task( - { type: "mcpp-xlings", command: args[0] ?? "xlings" }, - vscode.TaskScope.Workspace, - "mcpp: 安装 llvm-tools", - "mcpp", - new vscode.ProcessExecution(xlingsPath, args, { cwd }), - ); - task.presentationOptions = { - reveal: vscode.TaskRevealKind.Always, - panel: vscode.TaskPanelKind.Dedicated, - focus: true, - clear: true, - showReuseMessage: false, - }; - - let execution: vscode.TaskExecution | undefined; - let earlyCompletion: TaskCompletion | undefined; - let settled = false; - let processEndSubscription: vscode.Disposable | undefined; - let taskEndSubscription: vscode.Disposable | undefined; - const disposeListeners = (): void => { - processEndSubscription?.dispose(); - taskEndSubscription?.dispose(); - }; - const finish = (completion: TaskCompletion): void => { - if (settled) { - return; - } - settled = true; - disposeListeners(); - resolveCompletion?.(completion); - }; - let resolveCompletion: ((completion: TaskCompletion) => void) | undefined; - - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - processEndSubscription = vscode.tasks.onDidEndTaskProcess((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(event.exitCode); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - taskEndSubscription = vscode.tasks.onDidEndTask((event) => { - if (event.execution.task !== task) { - return; - } - const classified = classifyTaskExit(undefined); - if (execution === undefined) { - earlyCompletion ??= classified; - return; - } - finish(classified); - }); - }); - - try { - execution = await vscode.tasks.executeTask(task); - } catch (error) { - disposeListeners(); - throw error; +function taskCompletionText(completion: TaskCompletion): string { + if (completion.state === "cancelled") { + return "构建已取消;C++ 模块语言服务未刷新。"; } - if (earlyCompletion !== undefined) { - finish(earlyCompletion); + if (completion.state === "failed") { + return `mcpp 构建失败(退出码 ${completion.exitCode ?? "未知"});C++ 模块语言服务已重新读取现有构建描述。`; } - return completion; + return "mcpp 构建完成,C++ 模块语言服务已刷新。"; } async function autoConfigureModulesWizard( - context: ProjectContext, - status: vscode.StatusBarItem, - output: vscode.OutputChannel, + bridge: LanguageServerBridge, cliController: McppCliController, + output: vscode.OutputChannel, ): Promise { - appendOutputLine(output, "[一键配置] 开始一键配置模块代码提示..."); - if (!vscode.workspace.isTrusted) { - void vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); + await vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); return; } - const inventory = await cliController.readToolchainInventory(context.project); - if (inventory === undefined) { + if (cliController.isBusy()) { + await vscode.window.showWarningMessage(moduleSetupBlockedMessage("busy")); return; } - const decision = buildModuleSetupPlan( - inventory, - context.analysis.capability, - vscode.workspace.isTrusted, - cliController.isBusy(), - ); + + const decision = buildModuleSetupPlan(true, false); if (decision.kind === "blocked") { - void vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); + await vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); return; } - - const confirmation = moduleSetupConfirmation(context.analysis.capability, decision); + const confirmation = moduleSetupConfirmation(); const choice = await vscode.window.showWarningMessage( confirmation.message, { modal: true, detail: confirmation.detail }, @@ -791,102 +111,47 @@ async function autoConfigureModulesWizard( return; } - let currentContext = context; const outcome = await executeModuleSetup(decision, { - preparePlan: () => cliController.runAutomaticModuleSetup(decision), - reload: async (): Promise => { - const refreshed = loadProjectContext(context.project); - if (refreshed === undefined) { - return { stage: "reload", state: "failed", detail: "无法重新加载 mcpp 工程。" }; - } - currentContext = refreshed; - updateStatusBar(status, currentContext); - if (currentContext.analysis.capability !== "full" || currentContext.analysis.kind !== "llvm") { - return { - stage: "reload", - state: "failed", - detail: "构建后没有得到可供 clangd 使用的 LLVM 编译数据库。", - }; - } - return { stage: "reload", state: "succeeded" }; - }, - ensureClangd: async (): Promise => { - const clangd = await resolveClangd(currentContext); - if (clangd?.comparison.compatible) { - const configured = await configureClangd(currentContext, status, output, "automatic", true); - return configured - ? { stage: "clangd", state: "succeeded" } - : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; - } - - const xlingsPath = await resolveXlingsExecutable( - cliController.mcppExecutable(currentContext.project), - ); - const compilerPath = currentContext.analysis.compilerPath; - if (xlingsPath === undefined || compilerPath === undefined) { + build: async (): Promise => { + const completion = await cliController.runProjectTask("build", { notify: false }); + if (completion === undefined) { return { - stage: "clangd", - state: "failed", - detail: "未找到 xlings 或 LLVM 编译器,无法安装匹配的 llvm-tools。", + stage: "build", + state: "not-started", + detail: "未启动 mcpp 构建。", }; } - const compilerVersion = await runToolVersion(compilerPath); - const installArgs = xlingsInstallArgs( - compilerVersion.identity === undefined ? undefined : llvmToolsVersionSpec(compilerVersion.identity), - ); - const installed = await executeXlingsInstallTask(xlingsPath, installArgs, currentContext.project.root); - appendOutputLine( - output, - `[一键配置] xlings install 完成(退出码 ${installed.exitCode ?? "未知"})`, - ); - if (installed.state !== "succeeded") { - return { - stage: "clangd", - state: installed.state, - exitCode: installed.exitCode, - detail: installed.state === "cancelled" ? "llvm-tools 安装已取消。" : "llvm-tools 安装失败。", - }; - } - - const refreshed = loadProjectContext(currentContext.project); - if (refreshed === undefined) { - return { stage: "clangd", state: "failed", detail: "安装 llvm-tools 后无法重新加载工程。" }; - } - currentContext = refreshed; - updateStatusBar(status, currentContext); - const resolved = await resolveClangd(currentContext); - if (resolved === undefined || !resolved.comparison.compatible) { - return { stage: "clangd", state: "failed", detail: "未找到与 LLVM 编译器匹配的 clangd。" }; - } - const configured = await configureClangd(currentContext, status, output, "automatic", true); - return configured - ? { stage: "clangd", state: "succeeded" } - : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; + return { + stage: "build", + state: completion.state, + exitCode: completion.exitCode, + }; }, - checkModules: async (): Promise => { - const moduleStatus = await runModuleSupportCheck(currentContext, status, output, "automatic"); - return moduleStatus?.state === "available" - ? { stage: "check", state: "succeeded" } - : { stage: "check", state: "failed", detail: "模块支持检查未通过。" }; + refreshLanguageServer: async (): Promise => { + const result = await bridge.refreshLanguageServerAfterBuild(); + resultText(output, result); + return { + stage: "language-server", + state: result.state === "completed" ? "succeeded" : "failed", + detail: result.state === "completed" ? undefined : result.message, + }; }, }); + const buildStep = outcome.steps[0]; if (outcome.state === "succeeded") { - if (outcome.degraded) { - void vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); - } else { - void vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); - } + await vscode.window.showInformationMessage( + taskCompletionText(buildStep?.state === "failed" ? { state: "failed", exitCode: buildStep.exitCode } : { state: "succeeded" }), + ); } else if (outcome.state === "cancelled") { - void vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); + await vscode.window.showWarningMessage(taskCompletionText({ state: "cancelled" })); } else { - void vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); + await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? "C++ 模块语言服务配置失败。"); } } -// mcpp.toml 结构补全:建议由纯函数 computeMcppTomlCompletions 计算,这里只做 -// vscode 类型映射。范围:段头 snippet + 开放词汇段的写法模板;不含字段键/枚举 -// 与依赖数据(分别等上游版本化 schema 与批量 catalog 接口)。 +// mcpp.toml structural completion is deliberately local text analysis. It never executes mcpp or +// the language server, so it remains available in restricted workspaces. const mcppTomlCompletionKinds = { section: vscode.CompletionItemKind.Folder, template: vscode.CompletionItemKind.Snippet, @@ -894,7 +159,6 @@ const mcppTomlCompletionKinds = { const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { provideCompletionItems(document, position) { - // mcpp.toml 结构补全由 mcpp.tomlCompletion 控制,按文档作用域读取。 if (!vscode.workspace.getConfiguration("mcpp", document.uri).get("tomlCompletion", true)) { return undefined; } @@ -926,54 +190,30 @@ const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { }; export async function activate(extensionContext: vscode.ExtensionContext): Promise { - moduleStatusByProject.clear(); - moduleCheckOperations.clear(); - forceConfigureOnlyByProject.clear(); - lastReconciledProjectRoot = undefined; const output = vscode.window.createOutputChannel("mcpp"); - const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); - - const refreshStatus = (): void => updateStatusBar(status, loadProjectContext()); + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, + activateExtension: async (id) => { + await vscode.extensions.getExtension(id)?.activate(); + }, + executeCommand: (command: string, ...args: unknown[]) => vscode.commands.executeCommand(command, ...args), + }); const runGuarded = (operation: () => Promise): (() => Promise) => async () => { try { await operation(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `发生未预期错误:${message}`); + outputText(output, `发生未预期错误:${message}`); void vscode.window.showErrorMessage(`mcpp:${message}`); } }; - const showInteractiveIdeStart = (title: string): void => { - appendOutputLine(output, `\n[IDE] ${title}:已接收,正在等待 IDE 操作队列。`); - output.show(true); - }; - let cliController: McppCliController; - const runConfigureOnlyForProject = async ( - project: McppProjectDiscovery, - interactive: boolean, - ): Promise => { - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - appendOutputLine(output, "[CDB 配置] 工作区未受信任,跳过 mcpp build --configure-only。"); - if (interactive) { - void vscode.window.showWarningMessage( - "当前工作区未受信任,不会刷新编译数据库。请先信任工作区。", - ); - } - return undefined; - } - const result = await cliController.runConfigureOnly(project); - if (result === undefined) { - appendOutputLine(output, "[CDB 配置] 已有 mcpp 操作正在运行,本次刷新已跳过。"); - if (interactive) { - void vscode.window.showWarningMessage( - "已有 mcpp 操作正在运行,暂不能刷新编译数据库。请等待当前操作完成后重试。", - ); - } - } - return result; - }; + const invokeLanguageServer = ( + operation: () => Promise, + ): (() => Promise) => runGuarded(async () => { + resultText(output, await operation()); + }); + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); - const compilationDatabaseWatcher = vscode.workspace.createFileSystemWatcher("**/compile_commands.json"); const inProjectContext = registerInProjectContext({ currentProject: findCurrentProject, setContextValue: (key, value) => vscode.commands.executeCommand("setContext", key, value), @@ -984,268 +224,25 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi manifestWatcher.onDidDelete(listener), ], }); - const executeWithWorkspaceClangd = createSerialExecutor(); - const reconcileProjectContext = async ( - project: McppProjectDiscovery | undefined, - forceRestart: boolean, - forceConfigureOnly: boolean = false, - allowConfigureOnly: boolean = true, - ): Promise => { - let context = loadProjectContext(project); - if (context === undefined) { - updateStatusBar(status, context); - return { - context, - databaseFound: false, - configured: false, - }; - } - - // 缺少可用 CDB 时只运行配置阶段;完整 build/test 后则只重读已有 CDB。 - if (allowConfigureOnly) { - try { - const outcome = await ensureIdeConfigured({ - projectRoot: context.project.root, - compilationDatabasePath: context.project.compilationDatabasePath, - trusted: vscode.workspace.isTrusted, - force: forceConfigureOnly, - databaseValid: () => hasUsableCompilationDatabase(context!.project), - configure: async () => { - const result = await runConfigureOnlyForProject(context!.project, false); - if (result === undefined) { - throw new Error("已有 mcpp 操作正在运行"); - } - if (forceConfigureOnly) { - forceConfigureOnlyByProject.delete(context!.project.root); - } - return result; - }, - }); - if (outcome.state === "configured") { - context = loadProjectContext(context.project) ?? context; - } else if (outcome.state === "failed") { - appendOutputLine(output, `[自动配置] mcpp configure-only 失败(退出码 ${outcome.exitCode})。`); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - } - } - - updateStatusBar(status, context); - - const configured = await configureClangd( - context, - status, - output, - "automatic", - forceRestart, - ); - await updateModuleSupportForContext(context, configured, status, output); - return { - context, - databaseFound: existsSync(context.project.compilationDatabasePath), - configured, - }; - }; - const reconcileProjectByRoot = createKeyedSingleFlightReconciler( - (projectRoot: string, forceRestart) => executeWithWorkspaceClangd( - async () => { - const project = findNearestMcppProject(projectRoot); - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, projectRoot)) { - const context = loadProjectContext(project); - return { - context, - databaseFound: context !== undefined - && existsSync(context.project.compilationDatabasePath), - configured: false, - }; - } - return reconcileProjectContext( - project, - forceRestart, - forceConfigureOnlyByProject.has(projectRoot), - ); - }, - ), - ); - const reconcilePublishedCdbByRoot = createKeyedSingleFlightReconciler( - (projectRoot: string, forceRestart) => executeWithWorkspaceClangd( - async () => { - const project = findNearestMcppProject(projectRoot, projectRoot); - if (project === undefined) { - return { - context: undefined, - databaseFound: false, - configured: false, - }; - } - return reconcileProjectContext(project, forceRestart, false, false); - }, - ), - ); - const reconcileProject = ( - project: McppProjectDiscovery, - forceRestart: boolean = false, - ): Promise => { - invalidateModuleStatus(project.root); - return reconcileProjectByRoot(project.root, forceRestart); - }; - const requestAutomaticReconciliation = ( - compilationDatabase: vscode.Uri, - forceRestart: boolean, - ): void => { - const project = findProjectForUri(compilationDatabase); - if (project === undefined) { - return; - } - invalidateModuleStatus(project.root); - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - // CDB watcher 只重读已经发布的数据库,避免配置阶段写 CDB 后再次启动 mcpp。 - void reconcilePublishedCdbByRoot(project.root, forceRestart).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - }); - }; - const requestCurrentProjectReconciliation = (forceRestart: boolean): void => { - const project = findCurrentProject(); - if (project === undefined) { - lastReconciledProjectRoot = undefined; - refreshStatus(); - return; - } - lastReconciledProjectRoot = project.root; - void reconcileProject(project, forceRestart).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - }); - }; - const requestManifestReconciliation = (manifestUri: vscode.Uri): void => { - refreshStatus(); - cliController.refreshStatus(); - const currentProject = findCurrentProject(); - const project = projectAffectedByManifest( - manifestUri.fsPath, - currentProject, - findProjectForUri(manifestUri), - ); - if (project === undefined) { - return; - } - forceConfigureOnlyByProject.add(project.root); - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - lastReconciledProjectRoot = project.root; - void reconcileProject(project, true).catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - }); - }; - const requestDeletedManifestReconciliation = (manifestUri: vscode.Uri): void => { - refreshStatus(); - cliController.refreshStatus(); - const deletedProjectRoot = manifestProjectRoot(manifestUri.fsPath); - forceConfigureOnlyByProject.delete(deletedProjectRoot); - invalidateModuleStatus(deletedProjectRoot); - if (!shouldReconcileDeletedManifest(lastReconciledProjectRoot, manifestUri.fsPath)) { - return; - } - - lastReconciledProjectRoot = undefined; - const fallbackProject = findCurrentProject(); - if ( - fallbackProject !== undefined - && isPathWithinProject(manifestProjectRoot(manifestUri.fsPath), fallbackProject.root) - ) { - forceConfigureOnlyByProject.add(fallbackProject.root); - } - requestCurrentProjectReconciliation(true); - }; - const configurationWatcher = vscode.workspace.onDidChangeConfiguration((event) => { - const currentProject = findCurrentProject(); - let currentExecutionChanged = false; - for (const project of findWorkspaceProjects(currentProject)) { - const uri = vscode.Uri.file(project.root); - if (!configurationAffectsMcppExecution( - (section) => event.affectsConfiguration(section, uri), - )) { - continue; - } - forceConfigureOnlyByProject.add(project.root); - currentExecutionChanged ||= project.root === currentProject?.root; - } - if (currentExecutionChanged) { - requestCurrentProjectReconciliation(true); - return; - } - if (currentProject === undefined) { - return; - } - const uri = vscode.Uri.file(currentProject.root); - if (configurationAffectsModuleSupport( - (section) => event.affectsConfiguration(section, uri), - )) { - requestCurrentProjectReconciliation(true); - } - }); - const trustWatcher = vscode.workspace.onDidGrantWorkspaceTrust(() => { - const project = findCurrentProject(); - if (project !== undefined) forceConfigureOnlyByProject.add(project.root); - requestCurrentProjectReconciliation(true); - }); const afterProjectTask = async ( - project: McppProjectDiscovery, - kind: "build" | "run" | "test" | "clean", - completion: { state: "succeeded" | "failed" | "cancelled"; exitCode?: number }, + _project: McppProjectDiscovery, + _kind: "build" | "run" | "test" | "clean", + completion: TaskCompletion, ): Promise => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - invalidateModuleStatus(project.root); - refreshStatus(); + if (completion.state === "cancelled") { return; } - - const configureOnlyPending = forceConfigureOnlyByProject.has(project.root); - const reconciled = await executeWithWorkspaceClangd( - () => reconcileProjectContext( - project, - true, - configureOnlyPending, - configureOnlyPending, - ), - ); - if (kind === "build") { - if (reconciled.context?.analysis.capability === "syntax-only") { - const buildMessage = completion.state === "succeeded" ? "mcpp 构建完成" : "mcpp 构建失败"; - void vscode.window.showWarningMessage( - `${buildMessage};${reconciled.context.analysis.reason}。模块语法高亮仍然可用。`, - ); - return; - } - const outcome = describeRefreshOutcome( - completion.exitCode ?? 1, - reconciled.databaseFound, - reconciled.configured, - ); - if (outcome.level === "information") { - void vscode.window.showInformationMessage(outcome.message); - } else if (outcome.level === "warning") { - void vscode.window.showWarningMessage(outcome.message); - } else { - void vscode.window.showErrorMessage(outcome.message); - } - return; - } - - if (completion.state === "succeeded") { - const label = kind === "run" ? "运行" : kind === "test" ? "测试" : "清理"; - void vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); + const result = await bridge.refreshLanguageServerAfterBuild(); + resultText(output, result); + if (completion.state === "succeeded" && result.state === "completed") { + await vscode.window.showInformationMessage(taskCompletionText(completion)); + } else if (completion.state === "failed" && result.state === "completed") { + await vscode.window.showWarningMessage(taskCompletionText(completion)); } }; - cliController = new McppCliController({ + + const cliController = new McppCliController({ output, currentProject: findCurrentProject, afterProjectTask, @@ -1254,233 +251,50 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi extensionContext.subscriptions.push( output, - status, + manifestWatcher, + inProjectContext, ...cliController.register(), vscode.languages.registerCompletionItemProvider( { language: "mcpp-toml" }, mcppTomlCompletionProvider, "[", ), - vscode.commands.registerCommand(COMMAND_CONFIGURE, runGuarded(async () => { - const project = findCurrentProject(); - if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); - return; - } - showInteractiveIdeStart("配置 clangd"); - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const context = loadProjectContext(project); - if (context === undefined) { - return; - } - const configured = await configureClangd(context, status, output, "interactive"); - await updateModuleSupportForContext(context, configured, status, output); - }); - })), - vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { - const project = findCurrentProject(); - if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); - return; - } - showInteractiveIdeStart("刷新编译数据库"); - await executeWithWorkspaceClangd(async () => { - const hadUsableDatabase = hasUsableCompilationDatabase(project); - const result = await runConfigureOnlyForProject(project, true); - if (result === undefined) { - return; - } - const reconciled = await reconcileProjectContext(project, true, false, false); - const databaseValid = reconciled.context?.analysis.capability !== "unavailable"; - const outcome = describeConfigureOnlyOutcome( - result.exitCode, - databaseValid, - hadUsableDatabase && result.exitCode !== 0, - reconciled.configured, - ); - if (outcome.level === "information") { - void vscode.window.showInformationMessage(outcome.message); - } else if (outcome.level === "warning") { - void vscode.window.showWarningMessage(outcome.message); - } else { - void vscode.window.showErrorMessage(outcome.message); - } - }); + vscode.commands.registerCommand(CLI_COMMANDS.configureLanguageServer, invokeLanguageServer( + () => bridge.selectContext(), + )), + vscode.commands.registerCommand(DEPRECATED_COMMANDS.configureClangd, invokeLanguageServer( + () => bridge.selectContext(), + )), + vscode.commands.registerCommand(CLI_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { + await cliController.runProjectTask("build"); })), - vscode.commands.registerCommand(COMMAND_CHECK, runGuarded(async () => { + vscode.commands.registerCommand(CLI_COMMANDS.checkModuleSupport, invokeLanguageServer( + () => bridge.restartLanguageServer(), + )), + vscode.commands.registerCommand(CLI_COMMANDS.showModuleGraph, invokeLanguageServer( + () => bridge.showModuleGraph(), + )), + vscode.commands.registerCommand(CLI_COMMANDS.showLanguageServerLogs, invokeLanguageServer( + () => bridge.showLanguageServerLogs(), + )), + vscode.commands.registerCommand(CLI_COMMANDS.autoConfigureModules, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } - showInteractiveIdeStart("检查模块支持"); - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const ctx = loadProjectContext(project); - if (ctx === undefined) { - return; - } - - // When module support is already known to be unavailable, offer actionable choices - const currentStatus = moduleStatusByProject.get(ctx.project.root); - if (currentStatus?.state === "unavailable" && ctx.analysis.capability === "full") { - interface StatusActionItem extends vscode.QuickPickItem { - action: "auto-configure" | "recheck" | "rebuild"; - } - const choice = await vscode.window.showQuickPick([ - { - label: "$(rocket) 一键配置模块代码提示", - description: "自动安装匹配的 llvm-tools 并配置 clangd", - action: "auto-configure", - }, - { - label: "$(check) 重新检查模块支持", - description: "再次运行 clangd 模块检查", - action: "recheck", - }, - { - label: "$(database) 刷新编译数据库", - description: "重新生成 compile_commands.json", - action: "rebuild", - }, - ], { - title: "mcpp 模块代码提示不可用", - placeHolder: "选择操作以修复模块代码提示", - }); - if (choice === undefined) { - return; - } - switch (choice.action) { - case "auto-configure": - await autoConfigureModulesWizard(ctx, status, output, cliController); - return; - case "recheck": - await runModuleSupportCheck(ctx, status, output, "interactive"); - return; - case "rebuild": - await vscode.commands.executeCommand(COMMAND_REFRESH); - return; - } - } - - // QuickPick for syntax-only (GCC/MSVC) projects — no module code intelligence available - if (ctx.analysis.capability === "syntax-only") { - interface SyntaxOnlyActionItem extends vscode.QuickPickItem { - action: "auto-configure" | "install-toolchain" | "select-default"; - } - const kindLabel = ctx.analysis.kind.toUpperCase(); - const choice = await vscode.window.showQuickPick([ - { - label: "$(rocket) 一键配置模块代码提示", - description: `当前为 ${kindLabel} 工具链,查看如何切换到 LLVM`, - action: "auto-configure", - }, - { - label: "$(cloud-download) 安装 LLVM 工具链", - description: "通过 mcpp 下载并安装 LLVM 工具链", - action: "install-toolchain", - }, - { - label: "$(settings-gear) 选择全局默认工具链", - description: "从已安装的工具链中选择默认", - action: "select-default", - }, - ], { - title: `mcpp: 当前为 ${kindLabel} 工具链,模块代码提示不可用`, - placeHolder: "切换到 LLVM 工具链以启用模块代码提示", - }); - if (choice === undefined) { - return; - } - switch (choice.action) { - case "auto-configure": - await autoConfigureModulesWizard(ctx, status, output, cliController); - return; - case "install-toolchain": - await vscode.commands.executeCommand(CLI_COMMANDS.installToolchain); - return; - case "select-default": - await vscode.commands.executeCommand(CLI_COMMANDS.selectDefaultToolchain); - return; - } - } - - // Original flow: run module check - await runModuleSupportCheck(ctx, status, output, "interactive"); - }); + await autoConfigureModulesWizard(bridge, cliController, output); })), - vscode.commands.registerCommand("mcpp.autoConfigureModules", runGuarded(async () => { - const project = findCurrentProject(); - if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); - return; - } - showInteractiveIdeStart("一键配置模块代码提示"); - await executeWithWorkspaceClangd(async () => { - if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { - return; - } - const ctx = loadProjectContext(project); - if (ctx !== undefined) { - await autoConfigureModulesWizard(ctx, status, output, cliController); - } - }); - })), - configurationWatcher, - trustWatcher, - inProjectContext, - vscode.window.onDidChangeActiveTextEditor(() => { - refreshStatus(); - const current = findCurrentProject(); - if (current !== undefined && current.root !== lastReconciledProjectRoot) { - lastReconciledProjectRoot = current.root; - requestCurrentProjectReconciliation(false); - } - }), - vscode.workspace.onDidChangeWorkspaceFolders(() => { - refreshStatus(); - lastReconciledProjectRoot = undefined; - requestCurrentProjectReconciliation(true); - }), - manifestWatcher, - compilationDatabaseWatcher, + vscode.workspace.onDidGrantWorkspaceTrust(() => cliController.refreshStatus()), ); extensionContext.subscriptions.push( - manifestWatcher.onDidCreate((manifestUri) => requestManifestReconciliation(manifestUri)), - manifestWatcher.onDidChange((manifestUri) => { - requestManifestReconciliation(manifestUri); - }), - manifestWatcher.onDidDelete((manifestUri) => requestDeletedManifestReconciliation(manifestUri)), - ...registerCompilationDatabaseReconciliation( - compilationDatabaseWatcher, - requestAutomaticReconciliation, - ), + manifestWatcher.onDidCreate(() => cliController.refreshStatus()), + manifestWatcher.onDidChange(() => cliController.refreshStatus()), + manifestWatcher.onDidDelete(() => cliController.refreshStatus()), ); - - try { - const project = findCurrentProject(); - if (project === undefined) { - await reconcileProjectContext(undefined, false); - } else { - lastReconciledProjectRoot = project.root; - await reconcileProject(project, false); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); - } } export function deactivate(): void { - moduleStatusByProject.clear(); - moduleCheckOperations.clear(); - forceConfigureOnlyByProject.clear(); - lastReconciledProjectRoot = undefined; - // VS Code 会释放 activate() 注册的所有订阅。 + // VS Code disposes everything registered in activate(). } diff --git a/src/languageServer.ts b/src/languageServer.ts new file mode 100644 index 0000000..509cde8 --- /dev/null +++ b/src/languageServer.ts @@ -0,0 +1,80 @@ +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; + +export const MCPPLS_COMMANDS = { + restart: "mcppls.restartServer", + selectContext: "mcppls.selectContext", + graph: "mcppls.showModuleGraph", + logs: "mcppls.showLogs", +} as const; + +export type LanguageServerCommandState = "completed" | "unavailable" | "failed"; + +export interface LanguageServerCommandResult { + state: LanguageServerCommandState; + message: string; +} + +export interface LanguageServerCommandExecutor { + extensionInstalled(id: string): boolean; + /** Activate the dependency before forwarding a command when VS Code has not activated it yet. */ + activateExtension?(id: string): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; +} + +export interface LanguageServerBridge { + restartLanguageServer(): Promise; + refreshLanguageServerAfterBuild(): Promise; + selectContext(): Promise; + showModuleGraph(): Promise; + showLanguageServerLogs(): Promise; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createLanguageServerBridge( + executor: LanguageServerCommandExecutor, +): LanguageServerBridge { + let refreshInFlight: Promise | undefined; + + async function invoke(command: string, successMessage: string): Promise { + if (!executor.extensionInstalled(MCPPLS_EXTENSION_ID)) { + return { + state: "unavailable", + message: `C++ 模块语言服务依赖未安装或已禁用:${MCPPLS_EXTENSION_ID}。`, + }; + } + try { + await executor.activateExtension?.(MCPPLS_EXTENSION_ID); + await executor.executeCommand(command); + return { state: "completed", message: successMessage }; + } catch (error) { + return { + state: "failed", + message: `C++ Modules 命令执行失败:${errorMessage(error)}`, + }; + } + } + + async function restartLanguageServer(): Promise { + return invoke(MCPPLS_COMMANDS.restart, "C++ 模块语言服务已重启。"); + } + + function refreshLanguageServerAfterBuild(): Promise { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = invoke(MCPPLS_COMMANDS.restart, "C++ 模块语言服务已刷新。") + .finally(() => { refreshInFlight = undefined; }); + return refreshInFlight; + } + + return { + restartLanguageServer, + refreshLanguageServerAfterBuild, + selectContext: () => invoke(MCPPLS_COMMANDS.selectContext, "已打开 C++ 模块上下文选择。"), + showModuleGraph: () => invoke(MCPPLS_COMMANDS.graph, "已打开 C++ 模块图。"), + showLanguageServerLogs: () => invoke(MCPPLS_COMMANDS.logs, "已打开 C++ Modules 日志。"), + }; +} diff --git a/src/llvmTools.ts b/src/llvmTools.ts deleted file mode 100644 index f573f05..0000000 --- a/src/llvmTools.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { existsSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; - -import type { ToolIdentity } from "./analysis"; -import { - runProcess, - type ProcessResult, - type ProcessRunner, -} from "./process"; - -export function llvmToolsVersionSpec(identity: ToolIdentity): string { - return `${identity.major}.${identity.minor}.${identity.patch}`; -} - -const COMPILER_PATH_VERSION_REGEX = /[\\/]xim-x-llvm[\\/](\d+(?:\.\d+){1,3})[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i; - -export function extractVersionFromCompilerPath(compilerPath: string): string | undefined { - const match = compilerPath.match(COMPILER_PATH_VERSION_REGEX); - return match?.[1]; -} - -function isWindowsPath(value: string): boolean { - return /^[A-Za-z]:[\\/]/.test(value) || value.includes("\\"); -} - -export function deriveInstalledClangdPath( - compilerPath: string, - version: string, -): string[] { - const windows = isWindowsPath(compilerPath); - const pathApi = windows ? path.win32 : path.posix; - const candidateMatch = compilerPath.match( - /^(.*?[\\/])xim-x-llvm[\\/]\d+(?:\.\d+){1,3}[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i, - ); - const basePath = candidateMatch?.[1]; - const extension = /\.exe$/i.test(compilerPath) ? ".exe" : ""; - - const candidates: string[] = []; - if (basePath !== undefined) { - candidates.push( - pathApi.join(basePath, "xim-x-llvm-tools", version, "bin", `clangd${extension}`), - ); - } - - // Also try user xlings registry path if compiler is in mcpp registry - const userMatch = compilerPath.match( - /^(.*?)([\\/])\.mcpp[\\/]registry[\\/]data[\\/]xpkgs[\\/]xim-x-llvm[\\/]\d+(?:\.\d+){1,3}[\\/]bin[\\/]clang(?:\+\+|-cl)?(?:\.exe)?$/i, - ); - if (userMatch !== null) { - candidates.push( - `${userMatch[1]}${userMatch[2]}.xlings${userMatch[2]}data${userMatch[2]}xpkgs${userMatch[2]}xim-x-llvm-tools${userMatch[2]}${version}${userMatch[2]}bin${userMatch[2]}clangd${extension}`, - ); - } - - return candidates; -} - -export function xlingsInstallArgs(version?: string): string[] { - if (version !== undefined) { - return ["install", `xim:llvm-tools@${version}`]; - } - return ["update", "llvm-tools"]; -} - -export interface FindXlingsOptions { - /** Override for tests: base home directory instead of os.homedir(). */ - home?: string; - /** Override for tests: environment instead of process.env. */ - env?: NodeJS.ProcessEnv; -} - -export function findXlingsExecutable(options?: FindXlingsOptions): string | undefined { - const home = options?.home ?? os.homedir(); - const env = options?.env ?? process.env; - const knownPaths = [ - path.join(home, ".xlings", "subos", "current", "bin", "xlings"), - path.join(home, ".xlings", "bin", "xlings"), - ]; - if (process.platform === "win32") { - knownPaths.push( - path.join(home, ".xlings", "subos", "current", "bin", "xlings.exe"), - ); - } - - // mcpp (install.sh / AUR / mcpp-m) bundles xlings inside its own registry - // sandbox instead of installing to ~/.xlings. The AUR launcher pins the - // path via MCPP_VENDORED_XLINGS; otherwise it lives at - // $MCPP_HOME/registry/bin/xlings. Without probing both, the one-click - // module setup can never auto-install llvm-tools after a standard install. - const vendored = env.MCPP_VENDORED_XLINGS?.trim(); - if (vendored !== undefined && vendored.length > 0) { - knownPaths.push(vendored); - } - const mcppHome = env.MCPP_HOME?.trim(); - const extension = process.platform === "win32" ? ".exe" : ""; - knownPaths.push( - path.join( - mcppHome !== undefined && mcppHome.length > 0 ? mcppHome : path.join(home, ".mcpp"), - "registry", - "bin", - `xlings${extension}`, - ), - ); - - // Check known install paths first - for (const candidate of knownPaths) { - if (existsSync(candidate)) { - return candidate; - } - } - - // Fall back to PATH, but only when "xlings" actually resolves there. Always - // returning "xlings" hid the not-installed case, so callers could never show - // the "xlings 未安装" guidance. - return xlingsResolvableOnPath(env.PATH) ? "xlings" : undefined; -} - -function xlingsResolvableOnPath(pathValue?: string): boolean { - const names = process.platform === "win32" - ? ["xlings.exe", "xlings.cmd", "xlings.bat"] - : ["xlings"]; - const pathEnv = pathValue ?? ""; - for (const dir of pathEnv.split(path.delimiter)) { - if (dir.length === 0) { - continue; - } - for (const name of names) { - if (existsSync(path.join(dir, name))) { - return true; - } - } - } - return false; -} - -const XLINGS_BINARY_LINE = /^\s*xlings binary\s*=\s*(.+?)\s*$/im; - -// Source of truth is mcpp itself, not the filesystem or PATH: `mcpp self env` -// reports the exact xlings bundled with THIS mcpp (mcpp is a project-level -// environment; it owns its tool paths). Works for install.sh, AUR and any -// custom MCPP_PREFIX layout. Falls back to the historical path heuristics for -// standalone ~/.xlings installs and for mcpp versions without the line. -// -// The subprocess is bounded by MCPP_SELF_ENV_TIMEOUT_MS: the wizard reaches -// this step only after mcpp is initialized (toolchain list / build already -// ran), so 60s is generous while still guarding against an extreme hang. -const MCPP_SELF_ENV_TIMEOUT_MS = 60_000; - -export async function resolveXlingsExecutable( - mcppExecutable: string, - runner: ProcessRunner = runProcess, - options?: FindXlingsOptions, -): Promise { - const result = await runner(mcppExecutable, ["self", "env"], undefined, { - timeoutMs: MCPP_SELF_ENV_TIMEOUT_MS, - }); - const match = `${result.stdout}\n${result.stderr}`.match(XLINGS_BINARY_LINE); - const reported = match?.[1]?.trim(); - if (reported !== undefined && reported.length > 0 && existsSync(reported)) { - return reported; - } - return findXlingsExecutable(options); -} - -export async function runXlingsCommand( - xlingsPath: string, - args: string[], - cwd?: string, -): Promise { - return runProcess(xlingsPath, args, cwd); -} diff --git a/src/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts index 3cc0e16..016af8f 100644 --- a/src/mcppTomlCompletion.ts +++ b/src/mcppTomlCompletion.ts @@ -56,7 +56,6 @@ export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ { group: "toolchain", label: "[toolchain]", header: "[toolchain]", detail: "编译器工具链简写" }, { group: "xlings", label: "[xlings]", header: "[xlings]", detail: "构建环境(xlings 供给)" }, { group: "xlings.workspace", label: "[xlings.workspace]", header: "[xlings.workspace]", detail: "固定工具版本" }, - { group: "xlings.envs", label: "[xlings.envs]", header: "[xlings.envs]", detail: "工具环境的环境变量" }, { group: "target", label: "[target.]", header: "[target.${1:x86_64-linux-gnu}]", detail: "按目标三元组的配置" }, { group: "pack", label: "[pack]", header: "[pack]", detail: "mcpp pack 打包配置" }, { group: "pack.bundle-project", label: "[pack.bundle-project]", header: "[pack.bundle-project]", detail: "vendored 过滤策略微调" }, @@ -134,11 +133,7 @@ const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ ]; const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ - { label: 'tool = "version"', detail: "固定工具版本", insertSnippet: '${1:clang} = "${2:20.1.7}"' }, -]; - -const XLINGS_ENVS_TEMPLATES: readonly TemplateSpec[] = [ - { label: 'NAME = "value"', detail: "应用到工具环境的环境变量", insertSnippet: '${1:NAME} = "${2:value}"' }, + { label: 'tool = "version"', detail: "固定 xlings 工具版本", insertSnippet: '${1:node} = "${2:24.19.0}"' }, ]; const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ @@ -154,7 +149,6 @@ const TEMPLATES_BY_GROUP: Record = { "generated_files": GENERATED_FILE_TEMPLATES, "capabilities": CAPABILITY_TEMPLATES, "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, - "xlings.envs": XLINGS_ENVS_TEMPLATES, "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, }; diff --git a/src/moduleSetup.ts b/src/moduleSetup.ts index 6c6b8a0..cc0557f 100644 --- a/src/moduleSetup.ts +++ b/src/moduleSetup.ts @@ -1,85 +1,22 @@ -import { hostDefaultToolchains, type ToolchainInventory } from "./cli"; - -export type ModuleSetupStage = "install" | "default" | "build" | "reload" | "clangd" | "check"; -export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled"; -export type ModuleSetupBlockedReason = - | "untrusted" - | "busy" - | "project-toolchain-override" - | "unrecognized-inventory"; +export type ModuleSetupStage = "build" | "language-server"; +export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled" | "not-started"; +export type ModuleSetupBlockedReason = "untrusted" | "busy"; export type ModuleSetupDecision = - | { - kind: "ready"; - installLlvm: boolean; - switchDefault: boolean; - } - | { - kind: "blocked"; - reason: ModuleSetupBlockedReason; - }; - -type ModuleSetupPlan = Extract; -type ModuleCapability = "full" | "syntax-only" | "unavailable"; + | { kind: "ready" } + | { kind: "blocked"; reason: ModuleSetupBlockedReason }; export function buildModuleSetupPlan( - inventory: ToolchainInventory, - _capability: ModuleCapability, trusted: boolean, busy: boolean, ): ModuleSetupDecision { if (!trusted) { return { kind: "blocked", reason: "untrusted" }; } - if (!inventory.recognized) { - return { kind: "blocked", reason: "unrecognized-inventory" }; - } if (busy) { return { kind: "blocked", reason: "busy" }; } - - const effectiveIsLlvm = inventory.effective?.family.toLowerCase() === "llvm"; - if (inventory.projectOverridesGlobal && inventory.effective !== undefined && !effectiveIsLlvm) { - return { kind: "blocked", reason: "project-toolchain-override" }; - } - - // target-only payload 不能成为 host 默认工具链,因此必须复用现有筛选规则。 - const installedHostLlvm = hostDefaultToolchains(inventory) - .some((toolchain) => toolchain.family.toLowerCase() === "llvm"); - return { - kind: "ready", - installLlvm: !installedHostLlvm, - switchDefault: !effectiveIsLlvm, - }; -} - -export interface ModuleSetupCommand { - stage: "install" | "default" | "build"; - mode: "task" | "process"; - args: string[]; -} - -export function mcppModuleSetupCommands(plan: ModuleSetupPlan): ModuleSetupCommand[] { - const commands: ModuleSetupCommand[] = []; - if (plan.installLlvm) { - commands.push({ - stage: "install", - mode: "task", - args: ["toolchain", "install", "llvm"], - }); - } - if (plan.switchDefault) { - commands.push({ - stage: "default", - mode: "process", - args: ["toolchain", "default", "llvm"], - }); - } - const buildArgs = plan.installLlvm || plan.switchDefault - ? ["build", "--no-cache"] - : ["build"]; - commands.push({ stage: "build", mode: "task", args: buildArgs }); - return commands; + return { kind: "ready" }; } export interface ModuleSetupConfirmation { @@ -87,24 +24,10 @@ export interface ModuleSetupConfirmation { detail: string; } -export function moduleSetupConfirmation( - capability: ModuleCapability, - plan: ModuleSetupPlan, -): ModuleSetupConfirmation { - const message = capability === "full" - ? "当前已具备 LLVM 模块能力,是否重新构建并配置模块代码提示?" - : capability === "syntax-only" - ? "当前仅提供模块语法高亮,是否切换到 LLVM 并配置模块代码提示?" - : "当前缺少可用的模块编译数据库,是否配置 LLVM 并构建项目?"; - const installDetail = plan.installLlvm - ? "流程可能下载 LLVM/llvm-tools" - : "将复用已安装的 LLVM;补齐配套组件时仍可能下载 LLVM/llvm-tools"; - const defaultDetail = plan.switchDefault - ? "并把 LLVM 设为全局默认工具链" - : "不会修改全局默认工具链"; +export function moduleSetupConfirmation(): ModuleSetupConfirmation { return { - message, - detail: `${installDetail},${defaultDetail};不会编辑项目清单 mcpp.toml。`, + message: "是否构建当前 mcpp 工程并刷新 C++ 模块语言服务?", + detail: "将执行 mcpp build,并由 C++ Modules 扩展重新读取构建描述;不会修改工具链、mcpp.toml 或任何语言服务设置。", }; } @@ -116,11 +39,8 @@ export interface ModuleSetupStepResult { } export interface ModuleSetupOperations { - prepare?: (command: ModuleSetupCommand) => Promise; - preparePlan?: () => Promise; - reload(): Promise; - ensureClangd(): Promise; - checkModules(): Promise; + build(): Promise; + refreshLanguageServer(): Promise; } export type ModuleSetupOutcome = @@ -149,62 +69,25 @@ function stoppedOutcome( } export async function executeModuleSetup( - plan: ModuleSetupPlan, + _decision: Extract, operations: ModuleSetupOperations, ): Promise { const steps: ModuleSetupStepResult[] = []; - let buildFailed = false; - - const commands = mcppModuleSetupCommands(plan); - if (operations.preparePlan !== undefined) { - const result = await operations.preparePlan(); - steps.push(result); - if (result.state !== "succeeded") { - if (result.stage === "build" && result.state === "failed") { - buildFailed = true; - } else { - return stoppedOutcome(result, steps); - } - } - } else { - if (operations.prepare === undefined) { - const firstCommand = commands[0]; - if (firstCommand === undefined) { - return stoppedOutcome({ stage: "build", state: "failed" }, steps); - } - return stoppedOutcome({ stage: firstCommand.stage, state: "failed" }, steps); - } - for (const command of commands) { - const result = await operations.prepare(command); - steps.push(result); - if (result.state === "succeeded") { - continue; - } - if (command.stage === "build" && result.state === "failed") { - buildFailed = true; - break; - } - return stoppedOutcome(result, steps); - } + const buildResult = await operations.build(); + steps.push(buildResult); + if (buildResult.state === "cancelled" || buildResult.state === "not-started") { + return stoppedOutcome(buildResult, steps); } - // 构建失败后仍尝试恢复现有 CDB;只有完整恢复链成功才算降级成功。 - const recoveryOperations: Array<() => Promise> = [ - () => operations.reload(), - () => operations.ensureClangd(), - () => operations.checkModules(), - ]; - for (const operation of recoveryOperations) { - const result = await operation(); - steps.push(result); - if (result.state !== "succeeded") { - return stoppedOutcome(result, steps); - } + const languageServerResult = await operations.refreshLanguageServer(); + steps.push(languageServerResult); + if (languageServerResult.state !== "succeeded") { + return stoppedOutcome(languageServerResult, steps); } return { state: "succeeded", - degraded: buildFailed, + degraded: buildResult.state === "failed", steps, }; } diff --git a/src/process.ts b/src/process.ts index c772b7a..e6740cf 100644 --- a/src/process.ts +++ b/src/process.ts @@ -1,13 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { - classifyCheckResult, - parseToolIdentity, - type CheckResult, - type ToolIdentity, -} from "./analysis"; - const execFileAsync = promisify(execFile); export interface ProcessResult { @@ -27,15 +20,6 @@ export type ProcessRunner = ( options?: ProcessRunOptions, ) => Promise; -export interface ToolVersionResult extends ProcessResult { - identity: ToolIdentity | undefined; -} - -export interface ClangdCheckResult extends ProcessResult { - output: string; - classification: CheckResult; -} - export async function runProcess( executable: string, args: string[], @@ -67,44 +51,3 @@ export async function runProcess( }; } } - -export async function runToolVersion( - executable: string, - versionArguments: string[] = ["--version"], - runner: ProcessRunner = runProcess, -): Promise { - const result = await runner(executable, versionArguments); - return { - ...result, - identity: parseToolIdentity(`${result.stdout}\n${result.stderr}`), - }; -} - -export async function runClangdCheck( - clangdPath: string, - sourceFile: string, - cwd: string, - clangdArguments: string[] = [], - runner: ProcessRunner = runProcess, -): Promise { - const result = await runner( - clangdPath, - [`--check=${sourceFile}`, ...clangdArguments], - cwd, - { timeoutMs: 60_000 }, - ); - const output = `${result.stdout}\n${result.stderr}`; - return { - ...result, - output, - classification: classifyCheckResult(result.exitCode, output), - }; -} - -export function runMcppBuild( - cwd: string, - executable: string = "mcpp", - runner: ProcessRunner = runProcess, -): Promise { - return runner(executable, ["build"], cwd); -} diff --git a/src/tasks.ts b/src/tasks.ts index ffa148c..912e692 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -27,11 +27,11 @@ export function projectTaskPlan(kind: ProjectTaskKind): ProjectTaskPlan { }; } -export function shouldReconcileAfterTask( - _kind: ProjectTaskKind, +export function shouldRefreshLanguageServerAfterTask( + kind: ProjectTaskKind, completion?: TaskCompletion, ): boolean { - return completion?.state !== "cancelled"; + return kind === "build" && completion !== undefined && completion.state !== "cancelled"; } export function classifyTaskExit(exitCode: number | undefined): TaskCompletion { diff --git a/src/workflow.ts b/src/workflow.ts deleted file mode 100644 index 371b501..0000000 --- a/src/workflow.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -import type { CheckResult, ModuleCapability } from "./analysis"; - -export type RefreshOutcomeLevel = "information" | "warning" | "error"; -export type ModuleSupportState = "available" | "unavailable"; - -export interface RefreshOutcome { - level: RefreshOutcomeLevel; - message: string; -} - -export interface CompilationDatabaseWatcher { - onDidCreate(listener: (event: E) => void): T; - onDidChange(listener: (event: E) => void): T; - onDidDelete(listener: (event: E) => void): T; -} - -export async function withTimeout( - operation: PromiseLike, - timeoutMs: number, - fallback: T, -): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - Promise.resolve(operation), - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs); - }), - ]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } -} - -export function registerCompilationDatabaseReconciliation( - watcher: CompilationDatabaseWatcher, - requestReconciliation: (event: E, forceRestart: boolean) => void, -): T[] { - return [ - watcher.onDidCreate((event) => requestReconciliation(event, true)), - watcher.onDidChange((event) => requestReconciliation(event, true)), - watcher.onDidDelete((event) => requestReconciliation(event, false)), - ]; -} - -export function shouldRestartClangd( - configurationChanged: boolean, - interactive: boolean, - forceRestart: boolean, -): boolean { - return configurationChanged || interactive || forceRestart; -} - -export function configurationReadyAfterRestart( - restartRequired: boolean, - restartSucceeded: boolean, -): boolean { - return !restartRequired || restartSucceeded; -} - -export function createSingleFlightReconciler( - operation: (forceRestart: boolean) => Promise, -): (forceRestart?: boolean) => Promise { - let active: Promise | undefined; - let requested = false; - let pendingForceRestart = false; - - return (forceRestart = false): Promise => { - requested = true; - pendingForceRestart ||= forceRestart; - if (active !== undefined) { - return active; - } - - active = (async () => { - let result: T | undefined; - let completed = false; - do { - const currentForceRestart = pendingForceRestart; - requested = false; - pendingForceRestart = false; - try { - result = await operation(currentForceRestart); - completed = true; - } catch (error) { - if (!requested) { - throw error; - } - } - } while (requested); - if (!completed) { - throw new Error("重协调没有产生结果"); - } - return result as T; - })().finally(() => { - active = undefined; - requested = false; - pendingForceRestart = false; - }); - return active as Promise; - }; -} - -export function createKeyedSingleFlightReconciler( - operation: (key: K, forceRestart: boolean) => Promise, -): (key: K, forceRestart?: boolean) => Promise { - const reconcilerByKey = new Map Promise>(); - - return (key: K, forceRestart = false): Promise => { - let reconciler = reconcilerByKey.get(key); - if (reconciler === undefined) { - reconciler = createSingleFlightReconciler( - (pendingForceRestart) => operation(key, pendingForceRestart), - ); - reconcilerByKey.set(key, reconciler); - } - return reconciler(forceRestart); - }; -} - -export function createSerialExecutor(): (operation: () => Promise) => Promise { - let tail: Promise = Promise.resolve(); - // `running` is scoped to the async context of the in-flight operation via - // AsyncLocalStorage instead of a global counter, so only genuinely reentrant - // calls bypass the queue: - // - a call made from inside an operation's own async chain (e.g. - // wizard → build → reconcile → executor) sees `running` and executes - // immediately — the outer operation is awaiting it, so queuing would hang; - // - an unrelated concurrent caller (a separate command/event that has its - // own async context) does not see it and queues behind the in-flight - // operation, which the previous `depth > 0` check would wrongly let run - // concurrently. - const state = { running: false }; - const storage = new AsyncLocalStorage<{ running: boolean }>(); - - return (operation: () => Promise): Promise => { - if (storage.getStore() === state && state.running) { - return operation(); - } - - const wrapped = async (): Promise => { - state.running = true; - try { - return await storage.run(state, operation); - } finally { - state.running = false; - } - }; - - const result = tail.then(wrapped, wrapped); - tail = result.then( - () => undefined, - () => undefined, - ); - return result; - }; -} - -export interface LatestOperationTracker { - begin(key: K): number; - invalidate(key: K): void; - isCurrent(key: K, token: number): boolean; - clear(): void; -} - -export function createLatestOperationTracker(): LatestOperationTracker { - const latestTokenByKey = new Map(); - let nextToken = 0; - - return { - begin(key: K): number { - nextToken += 1; - latestTokenByKey.set(key, nextToken); - return nextToken; - }, - invalidate(key: K): void { - latestTokenByKey.delete(key); - }, - isCurrent(key: K, token: number): boolean { - return latestTokenByKey.get(key) === token; - }, - clear(): void { - latestTokenByKey.clear(); - }, - }; -} - -export function shouldRenderProjectStatus( - currentProjectRoot: string | undefined, - statusProjectRoot: string, -): boolean { - return currentProjectRoot === statusProjectRoot; -} - -export function configurationAffectsModuleSupport( - affectsConfiguration: (section: string) => boolean, -): boolean { - return ["mcpp.clangd.path", "mcpp.modulesSupport"].some(affectsConfiguration); -} - -export function configurationAffectsMcppExecution( - affectsConfiguration: (section: string) => boolean, -): boolean { - return affectsConfiguration("mcpp.path"); -} - -export function workspaceAllowsToolExecution(trusted: boolean): boolean { - return trusted; -} - -export function shouldUseWorkspaceClangd( - currentProjectRoot: string | undefined, - projectRoot: string, -): boolean { - return currentProjectRoot === projectRoot; -} - -export function describeRefreshOutcome( - buildExitCode: number, - databaseFound: boolean, - configured: boolean, -): RefreshOutcome { - if (configured) { - return buildExitCode === 0 - ? { - level: "information", - message: "mcpp 构建完成,clangd 配置已刷新。", - } - : { - level: "warning", - message: "mcpp 构建失败,但检测到可用的编译数据库,clangd 配置已刷新。请继续修复源码构建错误。", - }; - } - - if (!databaseFound) { - return buildExitCode === 0 - ? { - level: "error", - message: "mcpp 构建完成,但没有生成 compile_commands.json。", - } - : { - level: "error", - message: "mcpp 构建失败,且没有生成 compile_commands.json。请查看 mcpp 输出频道。", - }; - } - - return { - level: "error", - message: buildExitCode === 0 - ? "已检测到 compile_commands.json,但 clangd 配置未完成。请查看 mcpp 输出频道。" - : "mcpp 构建失败;已检测到 compile_commands.json,但 clangd 配置未完成。请查看 mcpp 输出频道。", - }; -} - -export function describeConfigureOnlyOutcome( - exitCode: number, - databaseValid: boolean, - retainedExisting: boolean, - clangdConfigured: boolean, -): RefreshOutcome { - if (exitCode === 0 && databaseValid) { - return clangdConfigured - ? { - level: "information", - message: "编译数据库已刷新,clangd 配置已重新加载。", - } - : { - level: "warning", - message: "编译数据库已刷新,但 clangd 配置未完成。请查看 mcpp 输出频道。", - }; - } - if (exitCode === 0) { - return { - level: "error", - message: "mcpp configure-only 已完成,但没有生成可用的 compile_commands.json。", - }; - } - if (databaseValid) { - return retainedExisting - ? { - level: "warning", - message: "mcpp configure-only 失败;继续使用原有编译数据库。请查看 mcpp 输出频道。", - } - : { - level: "warning", - message: "mcpp configure-only 失败,但检测到可用的编译数据库。请查看 mcpp 输出频道。", - }; - } - return { - level: "error", - message: "mcpp configure-only 失败,且没有可用的编译数据库。请确认 mcpp 版本支持 build --configure-only。", - }; -} - -export function statusCommandForCapability(capability: ModuleCapability): string { - return capability === "unavailable" - ? "mcpp.refreshCompilationDatabase" - : "mcpp.checkModuleSupport"; -} - -export function shouldCheckModuleSupport( - capability: ModuleCapability, - configured: boolean, - sourceFile?: string, -): boolean { - return capability === "full" && configured && sourceFile !== undefined && sourceFile.length > 0; -} - -export function moduleSupportState(classification: CheckResult): ModuleSupportState { - return classification === "ready" ? "available" : "unavailable"; -} diff --git a/test/analysis.test.ts b/test/analysis.test.ts deleted file mode 100644 index 521b429..0000000 --- a/test/analysis.test.ts +++ /dev/null @@ -1,412 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - analyzeCompilationDatabase, - buildClangdArguments, - classifyCheckResult, - compareToolIdentities, - parseToolIdentity, -} from "../src/analysis"; -import * as analysisModule from "../src/analysis"; - -function cdb(compiler: string, flags: string[]): string { - return JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [compiler, "-std=c++23", ...flags, "-c", "/work/app/src/main.cpp"], - }, - ]); -} - -test("classifies an mcpp LLVM CDB as full module support", () => { - const result = analyzeCompilationDatabase(cdb("/tools/xim-x-llvm/22.1.8/bin/clang++", [ - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - ])); - - assert.equal(result.kind, "llvm"); - assert.equal(result.capability, "full"); - assert.equal(result.compilerPath, "/tools/xim-x-llvm/22.1.8/bin/clang++"); - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); - assert.equal(result.hasPrebuiltModules, true); -}); - -test("prefers a project module interface over an earlier external command", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/deps/gtest.cc", - arguments: ["/tools/clang++", "-std=c++23", "-c", "/deps/gtest.cc"], - }, - { - directory: "/work/app", - file: "/work/app/src/demo.cppm", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/work/app/src/demo.cppm", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/demo.cppm"); - assert.equal(result.hasPrebuiltModules, true); -}); - -test("prefers a project source over an external module interface", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/deps/fmt_module.cppm", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/deps/fmt_module.cppm", - ], - }, - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fmodule-file=std=/work/app/target/pcm.cache/std.pcm", - "-c", - "/work/app/src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("prefers a project source over an mcpp dependency cache source", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/.mcpp/packages/redis/src/transaction.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/.mcpp/packages/redis/src/transaction.cpp", - ], - }, - { - directory: "/work/app", - file: "/work/app/src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("resolves relative CDB source files from their command directory", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "/work/app", - file: "/work/app/.mcpp/packages/redis/src/transaction.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "/work/app/.mcpp/packages/redis/src/transaction.cpp", - ], - }, - { - directory: "/work/app", - file: "src/main.cpp", - arguments: [ - "/tools/clang++", - "-std=c++23", - "-fprebuilt-module-path=/work/app/target/pcm.cache", - "-c", - "src/main.cpp", - ], - }, - ])); - - assert.equal(result.sourceFile, "/work/app/src/main.cpp"); -}); - -test("keeps Windows separators when reading a command-form CDB entry", () => { - const result = analyzeCompilationDatabase(JSON.stringify([ - { - directory: "C:\\work\\app", - file: "C:\\work\\app\\main.cpp", - command: "C:\\LLVM\\bin\\clang++.exe -std=c++23 -c C:\\work\\app\\main.cpp", - }, - ])); - - assert.equal(result.kind, "llvm"); - assert.equal(result.compilerPath, "C:\\LLVM\\bin\\clang++.exe"); -}); - -test("degrades GCC module databases to syntax-only", () => { - const result = analyzeCompilationDatabase(cdb("/tools/g++", ["-fmodules"])); - - assert.equal(result.kind, "gcc"); - assert.equal(result.capability, "syntax-only"); -}); - -test("degrades MSVC module databases to syntax-only", () => { - const result = analyzeCompilationDatabase(cdb("C:\\LLVM\\cl.exe", [ - "/reference", - "std=C:\\work\\ifc.cache\\std.ifc", - ])); - - assert.equal(result.kind, "msvc"); - assert.equal(result.capability, "syntax-only"); -}); - -test("reports malformed compilation databases without throwing", () => { - const result = analyzeCompilationDatabase("not-json"); - - assert.equal(result.kind, "unknown"); - assert.equal(result.capability, "unavailable"); - assert.match(result.reason, /JSON/i); -}); - -test("auto module arguments enable experimental support for clangd 22", () => { - const args = buildClangdArguments( - ["--background-index", "--query-driver=/old/compiler"], - { - compilerPath: "/tools/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - }, - ); - - assert.deepEqual(args, [ - "--background-index", - "--query-driver=/tools/clang++", - "--experimental-modules-support", - ]); -}); - -test("auto module arguments avoid the known Windows clangd 20 crash", () => { - const args = buildClangdArguments([], { - compilerPath: "C:\\tools\\clang++.exe", - modulesSupport: "auto", - clangdIdentity: { major: 20, minor: 1, patch: 7 }, - platform: "win32", - }); - - assert.deepEqual(args, ["--query-driver=C:\\tools\\clang++.exe"]); -}); - -test("auto consumes explicit mcpp PCMs without invoking clangd's module builder", () => { - const args = buildClangdArguments([], { - compilerPath: "/tools/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - hasPrebuiltModules: true, - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("hermetic mcpp commands do not use query-driver discovery", () => { - const args = buildClangdArguments( - ["--background-index", "--query-driver=/old/compiler"], - { - compilerPath: "/tools/clang++", - compilationArguments: [ - "/tools/clang++", - "--no-default-config", - "-nostdinc++", - "-isystem", - "/tools/llvm/include/c++/v1", - "-isystem/tools/llvm/include/x86_64-unknown-linux-gnu", - "-isystem/tools/llvm/include", - "-c", - "/work/app/src/main.cpp", - ], - modulesSupport: "off", - platform: "darwin", - }, - ); - - assert.deepEqual(args, ["--background-index"]); -}); - -test("non-hermetic commands retain query-driver discovery", () => { - const args = buildClangdArguments([], { - compilerPath: "/tools/clang++", - compilationArguments: [ - "/tools/clang++", - "-std=c++23", - "-c", - "/work/app/src/main.cpp", - ], - modulesSupport: "off", - platform: "darwin", - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("expands workspace folder variables before invoking clangd", () => { - const options = { - compilerPath: "/tools/clang++", - modulesSupport: "off" as const, - platform: "darwin" as const, - workspaceFolder: "/work/app", - }; - const args = buildClangdArguments([ - "--compile-commands-dir=${workspaceFolder}", - "--background-index", - ], options); - - assert.deepEqual(args, [ - "--compile-commands-dir=/work/app", - "--background-index", - "--query-driver=/tools/clang++", - ]); -}); - -test("off removes an existing experimental module flag", () => { - const args = buildClangdArguments(["--experimental-modules-support"], { - compilerPath: "/tools/clang++", - modulesSupport: "off", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "linux", - }); - - assert.deepEqual(args, ["--query-driver=/tools/clang++"]); -}); - -test("plans replacing the default clangd shim with the matched executable", () => { - const planner = (analysisModule as typeof analysisModule & { - buildClangdConfigurationPlan?: ( - currentPath: string, - currentArguments: string[], - resolvedPath: string, - options: { - compilerPath: string; - modulesSupport: "auto"; - clangdIdentity: { major: number; minor: number; patch: number }; - platform: NodeJS.Platform; - hasPrebuiltModules: boolean; - workspaceFolder: string; - }, - ) => { path: string; arguments: string[]; changed: boolean }; - }).buildClangdConfigurationPlan; - - assert.equal(typeof planner, "function"); - const plan = planner?.( - "clangd", - ["--compile-commands-dir=${workspaceFolder}", "--background-index"], - "/tools/xim-x-llvm-tools/22.1.8/bin/clangd", - { - compilerPath: "/tools/xim-x-llvm/22.1.8/bin/clang++", - modulesSupport: "auto", - clangdIdentity: { major: 22, minor: 1, patch: 8 }, - platform: "darwin", - hasPrebuiltModules: true, - workspaceFolder: "/work/app", - }, - ); - - assert.deepEqual(plan, { - path: "/tools/xim-x-llvm-tools/22.1.8/bin/clangd", - arguments: [ - "--compile-commands-dir=/work/app", - "--background-index", - "--query-driver=/tools/xim-x-llvm/22.1.8/bin/clang++", - ], - changed: true, - }); -}); - -test("parses LLVM version and revision identities", () => { - const identity = parseToolIdentity( - "clangd version 22.1.8 (https://github.com/llvm/llvm-project ca7933e47d3a3451d81e72ac174dcb5aa28b59d1)", - ); - - assert.deepEqual(identity, { - major: 22, - minor: 1, - patch: 8, - revision: "ca7933e47d3a3451d81e72ac174dcb5aa28b59d1", - }); -}); - -test("rejects matching semantic versions from different LLVM revisions", () => { - const result = compareToolIdentities( - { major: 22, minor: 1, patch: 8, revision: "aaaa" }, - { major: 22, minor: 1, patch: 8, revision: "bbbb" }, - ); - - assert.equal(result.compatible, false); - assert.match(result.reason, /revision/i); -}); - -test("does not claim exact identity when an LLVM revision is unavailable", () => { - const result = compareToolIdentities( - { major: 22, minor: 1, patch: 8 }, - { major: 22, minor: 1, patch: 8 }, - ); - - assert.equal(result.compatible, false); - assert.match(result.reason, /revision/i); -}); - -test("classifies clangd check failures into actionable states", () => { - assert.equal( - classifyCheckResult(1, "error: ast_file_different_branch"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "error: module file '.../std.pcm' uses a newer format that cannot be read"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "Module file uses a newer format that cannot be read ast_file_version_too_new"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "error: module file uses an older format that is no longer supported"), - "pcm-mismatch", - ); - assert.equal( - classifyCheckResult(1, "Failed to build module std; Don't get the module unit"), - "module-unavailable", - ); - assert.equal( - classifyCheckResult(3, "unknown type name 'import'"), - "wrong-language-mode", - ); - assert.equal( - classifyCheckResult(0, "All checks completed, 0 errors"), - "ready", - ); - assert.equal( - classifyCheckResult(3, "tweak: ExtractFunction ==> FAIL\nAll checks completed, 4 errors"), - "ready", - ); - assert.equal( - classifyCheckResult( - 3, - "E[12:00:00] [undeclared_var_use] Line 4: use of undeclared identifier 'missing'\nAll checks completed, 1 errors", - ), - "check-failed", - ); -}); diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index bf5baba..c3ca208 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -5,10 +5,14 @@ import test from "node:test"; interface PackageManifest { version?: string; + description?: string; icon?: string; + dependencies?: Record; + devDependencies?: Record; repository?: { url?: string }; homepage?: string; bugs?: { url?: string }; + engines?: { vscode?: string }; extensionDependencies?: string[]; activationEvents?: string[]; capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string } }; @@ -24,16 +28,21 @@ interface PackageManifest { const root = path.resolve(process.cwd()); -test("declares the official clangd dependency and mcpp commands", () => { +test("declares mcpp-language-server as the C++ modules language service", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.3.1"); - assert.ok(manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); + assert.equal(manifest.version, "0.4.0"); + assert.equal(manifest.description, "mcpp 工程构建、工具链与 C++ Modules 语言服务集成"); + assert.equal(manifest.engines?.vscode, "^1.91.0"); + assert.deepEqual(manifest.extensionDependencies, ["sunrisepeak.mcpp-language-server"]); + assert.ok(!manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); assert.ok(manifest.activationEvents?.includes("workspaceContains:mcpp.toml")); assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.run")); + assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureLanguageServer")); + assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureClangd")); // deprecated alias assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); assert.equal( manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", + "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。", ); assert.deepEqual( manifest.contributes?.commands?.map((command) => command.command), @@ -47,14 +56,19 @@ test("declares the official clangd dependency and mcpp commands", () => { "mcpp.showToolchains", "mcpp.installToolchain", "mcpp.selectDefaultToolchain", + "mcpp.configureLanguageServer", "mcpp.configureClangd", "mcpp.refreshCompilationDatabase", "mcpp.checkModuleSupport", "mcpp.autoConfigureModules", + "mcpp.showModuleGraph", + "mcpp.showLanguageServerLogs", ], ); assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.path"]); - assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.modulesSupport"]); + assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.tomlCompletion"]); + assert.equal(manifest.dependencies?.["vscode-languageclient"], undefined); + assert.equal(manifest.devDependencies?.["vscode-languageclient"], undefined); assert.deepEqual(manifest.contributes?.configurationDefaults?.["files.associations"], { "*.ccm": "cpp", "*.cppm": "cpp", @@ -63,41 +77,27 @@ test("declares the official clangd dependency and mcpp commands", () => { }); }); -test("自动模块配置使用非交互复合操作和单一全局锁", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); - assert.match(source, /public async readToolchainInventory\s*\(/); - const start = source.indexOf("public async runAutomaticModuleSetup"); - assert.notEqual(start, -1); - const end = source.indexOf("public async ", start + 10); - const method = source.slice(start, end === -1 ? source.length : end); - assert.match(method, /mcppModuleSetupCommands\(plan\)/); - assert.match(method, /beginGlobal\(token\)/); - assert.match(method, /finally\s*\{[\s\S]*finishGlobal\(token\)/); - assert.doesNotMatch(method, /pickInstallSpec|selectDefaultToolchainFromInventory|showQuickPick|showWarningMessage/); -}); - -test("一键向导只有一次确认并在构建后重载上下文", () => { +test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { + const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); const start = source.indexOf("async function autoConfigureModulesWizard"); - const end = source.indexOf("export async function activate", start); + const end = source.indexOf("const mcppTomlCompletionKinds", start); assert.notEqual(start, -1); assert.notEqual(end, -1); const wizard = source.slice(start, end); for (const expected of [ - "readToolchainInventory", "buildModuleSetupPlan", "moduleSetupConfirmation", - "runAutomaticModuleSetup", "executeModuleSetup", - "loadProjectContext", + 'runProjectTask("build", { notify: false })', + "refreshLanguageServerAfterBuild", ]) { - assert.match(wizard, new RegExp(expected)); + assert.ok(wizard.includes(expected), `wizard missing ${expected}`); } assert.match(wizard, /modal:\s*true/); - assert.match(wizard, /let currentContext/); assert.doesNotMatch(wizard, /CLI_COMMANDS\.(installToolchain|selectDefaultToolchain)/); - assert.doesNotMatch(wizard, /刷新编译数据库|showQuickPick|showInputBox|maybeDisableCppTools/); - assert.doesNotMatch(wizard, /configureClangd\([^\n]*"interactive"/); + assert.doesNotMatch(wizard, /readToolchainInventory|runAutomaticModuleSetup|ensureClangd|loadProjectContext/); + assert.doesNotMatch(controller, /runAutomaticModuleSetup|executeAutomaticModuleSetupCommand/); }); test("shows editor title buttons only inside mcpp projects", () => { @@ -113,47 +113,6 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); }); -test("wires configure-only through the existing refresh command", () => { - const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.ok(!manifest.activationEvents?.includes("onCommand:mcpp.configureIde")); - assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.refreshCompilationDatabase")); - - const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); - assert.match(source, /ensureIdeConfigured/); - assert.match(source, /runConfigureOnly/); - assert.doesNotMatch(source, /runIdeConfigure|COMMAND_IDE_CONFIGURE|ide["'],\s*["']configure/); - assert.match(source, /findProjectForUri[\s\S]*findNearestMcppProject\(uri\.fsPath, workspaceFolder\.uri\.fsPath\)/); - assert.match(source, /requestManifestReconciliation\s*=\s*\(manifestUri:[^)]+\)[\s\S]*findProjectForUri\(manifestUri\)/); - assert.match(source, /requestManifestReconciliation[\s\S]*projectAffectedByManifest\([\s\S]*manifestUri\.fsPath/); - assert.match(source, /manifestWatcher\.onDidChange\(\(manifestUri\) => \{[\s\S]*requestManifestReconciliation\(manifestUri\)/); - assert.match(source, /manifestWatcher\.onDidDelete\(\(manifestUri\) => requestDeletedManifestReconciliation\(manifestUri\)\)/); - assert.match(source, /configurationAffectsMcppExecution[\s\S]*forceConfigureOnlyByProject\.add/); - assert.match(source, /reconcilePublishedCdbByRoot[\s\S]*reconcileProjectContext\(project, forceRestart, false, false\)/); - assert.match(source, /requestAutomaticReconciliation[\s\S]*reconcilePublishedCdbByRoot\(project\.root, forceRestart\)/); - assert.match(source, /forceConfigureOnlyByProject\.has\(projectRoot\)/); -}); - -test("configure-only shares the project operation lock", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); - const start = source.indexOf("public async runConfigureOnly"); - assert.notEqual(start, -1); - const end = source.indexOf("public async ", start + 10); - const method = source.slice(start, end === -1 ? source.length : end); - assert.match(method, /beginProject\(project\.root, token\)/); - assert.match(method, /runConfigureOnlyProcess/); - assert.match(method, /finally\s*\{[\s\S]*finishProject\(project\.root, token\)/); -}); - -test("IDE commands expose progress and bound clangd restart waits", () => { - const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); - assert.match(source, /const CLANGD_RESTART_TIMEOUT_MS = 15_000/); - assert.match(source, /withTimeout\([\s\S]*CLANGD_RESTART_TIMEOUT_MS/); - for (const title of ["配置 clangd", "刷新编译数据库", "检查模块支持", "一键配置模块代码提示"]) { - assert.match(source, new RegExp(`showInteractiveIdeStart\\("${title}"\\)`)); - } - assert.match(source, /output\.show\(true\)/); -}); - test("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; const associations = manifest.contributes?.configurationDefaults?.["files.associations"] as @@ -273,7 +232,7 @@ test("设置全局默认后先释放工具链锁再提供立即构建", () => { assert.ok(unlock >= 0 && unlock < immediateBuild); }); -test("项目任务结束后先释放项目锁再重新协调 IDE", () => { +test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", () => { const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); const start = source.indexOf("public async runProjectTask"); const end = source.indexOf("public async showToolchains", start); @@ -282,8 +241,8 @@ test("项目任务结束后先释放项目锁再重新协调 IDE", () => { const method = source.slice(start, end); const unlock = method.indexOf("this.operations.finishProject(project.root, token)"); - const reconcile = method.indexOf("this.options.afterProjectTask(project, kind, completion)"); - assert.ok(unlock >= 0 && unlock < reconcile); + const refresh = method.indexOf("this.options.afterProjectTask(project, kind, completion)"); + assert.ok(unlock >= 0 && unlock < refresh); }); test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", () => { @@ -350,11 +309,16 @@ test("声明 GitHub 仓库和扩展图标", () => { assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); }); -test("README 区分 mcpp 的 MSVC 构建能力和 clangd 的 IFC 限制", () => { +test("README 说明 mcpp 与 mcppls 的职责边界和升级限制", () => { const readme = readFileSync(path.join(root, "README.md"), "utf8"); - assert.match(readme, /0\.0\.90 起 native `cl\.exe` 后端支持/); - assert.match(readme, /mcpp 可以正常构建它们,但 clangd\s+不能直接消费/); - assert.doesNotMatch(readme, /native MSVC\s+构建仍提示/); + assert.match(readme, /sunrisepeak\.mcpp-language-server/); + assert.match(readme, /不启动第二个\s+LSP 客户端/); + assert.match(readme, /不表示本扩展读取用户的 `clangd\.\*` 设置/); + assert.match(readme, /mcpp\.path.*只控制/s); + assert.match(readme, /darwin-x64/); + assert.match(readme, /不再读取或写入它们/); + assert.doesNotMatch(readme, /mcpp\.clangd\.path.*匹配 LLVM/); + assert.doesNotMatch(readme, /当前完整的模块语义能力只支持 LLVM/); }); test("嵌套工程提示不猜测它一定是 mcpp 工作区成员", () => { diff --git a/test/commands.test.ts b/test/commands.test.ts index 843a2fc..f9025b6 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "../src/commands"; +import { CLI_COMMANDS, DEPRECATED_COMMANDS, quickMenuItems, quickMenuStatusText } from "../src/commands"; -test("状态栏快捷菜单名称与模块状态易于区分", () => { +test("状态栏快捷菜单名称与 mcpp 项目状态易于区分", () => { assert.equal(quickMenuStatusText, "$(tools) mcpp: 快捷菜单"); }); -test("CLI 命令覆盖项目、工具链和 IDE", () => { +test("CLI 命令覆盖项目、工具链和 C++ Modules 语言服务", () => { assert.deepEqual(Object.values(CLI_COMMANDS), [ "mcpp.showMenu", "mcpp.newProject", @@ -18,7 +18,12 @@ test("CLI 命令覆盖项目、工具链和 IDE", () => { "mcpp.showToolchains", "mcpp.installToolchain", "mcpp.selectDefaultToolchain", + "mcpp.configureLanguageServer", + "mcpp.refreshCompilationDatabase", + "mcpp.checkModuleSupport", "mcpp.autoConfigureModules", + "mcpp.showModuleGraph", + "mcpp.showLanguageServerLogs", ]); assert.deepEqual( quickMenuItems.map((item) => item.command), @@ -30,11 +35,23 @@ test("CLI 命令覆盖项目、工具链和 IDE", () => { "mcpp.showToolchains", "mcpp.installToolchain", "mcpp.selectDefaultToolchain", - "mcpp.configureClangd", + "mcpp.configureLanguageServer", "mcpp.refreshCompilationDatabase", "mcpp.checkModuleSupport", + "mcpp.showModuleGraph", + "mcpp.showLanguageServerLogs", "mcpp.autoConfigureModules", ], ); assert.ok(quickMenuItems.every((item) => item.label.length > 0)); + assert.ok(quickMenuItems.some((item) => item.label.includes("模块语言服务"))); + assert.ok(quickMenuItems.some((item) => item.label.includes("模块图"))); + assert.ok(quickMenuItems.some((item) => item.label.includes("C++ Modules 日志"))); + assert.ok(quickMenuItems.every((item) => !item.label.includes("clangd"))); +}); + +test("保留 configureClangd 作为仅转发到 mcppls 的弃用别名", () => { + assert.equal(DEPRECATED_COMMANDS.configureClangd, "mcpp.configureClangd"); + assert.ok(!(Object.values(CLI_COMMANDS) as readonly string[]).includes(DEPRECATED_COMMANDS.configureClangd)); + assert.ok(quickMenuItems.every((item) => item.command !== DEPRECATED_COMMANDS.configureClangd)); }); diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 807c126..534c834 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -4,41 +4,24 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { - deriveClangdCandidates, - findNearestMcppProject, - isPathWithinProject, - manifestProjectRoot, - projectAffectedByManifest, - shouldReconcileDeletedManifest, -} from "../src/discovery"; +import { findNearestMcppProject } from "../src/discovery"; -test("finds the nearest mcpp manifest and root compilation database", () => { +test("finds the nearest mcpp manifest and project root", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-")); try { const sourceDirectory = path.join(root, "src", "nested"); mkdirSync(sourceDirectory, { recursive: true }); writeFileSync(path.join(root, "mcpp.toml"), "[package]\nname = 'demo'\n"); - writeFileSync(path.join(root, "compile_commands.json"), "[]\n"); assert.deepEqual(findNearestMcppProject(sourceDirectory), { root, manifestPath: path.join(root, "mcpp.toml"), - compilationDatabasePath: path.join(root, "compile_commands.json"), }); } finally { rmSync(root, { recursive: true, force: true }); } }); -test("routes a deleted manifest only to the project that owned it", () => { - assert.equal(manifestProjectRoot("/work/member/mcpp.toml"), path.join("/work", "member")); - assert.equal(shouldReconcileDeletedManifest("/work/member", "/work/member/mcpp.toml"), true); - assert.equal(shouldReconcileDeletedManifest("/work/other", "/work/member/mcpp.toml"), false); - assert.equal(isPathWithinProject("/work/member", "/work"), true); - assert.equal(isPathWithinProject("/work-other/member", "/work"), false); -}); - test("selects the nearest member inside a multi-member workspace", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-members-")); try { @@ -59,84 +42,6 @@ test("selects the nearest member inside a multi-member workspace", () => { } }); -test("does not treat a virtual workspace root as a package project", () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-virtual-workspace-")); - try { - writeFileSync(path.join(root, "mcpp.toml"), "[workspace]\nmembers = ['A', 'B']\n"); - - // #387 在 virtual workspace 根执行 configure-only 时只发布 member CDB, - // 根目录本身没有可供 clangd 消费的 compile_commands.json。 - assert.equal(findNearestMcppProject(root, root), undefined); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("keeps a rooted workspace as a package project", () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-rooted-workspace-")); - try { - writeFileSync( - path.join(root, "mcpp.toml"), - "[package]\nname = 'root'\n[workspace]\nmembers = ['A']\n", - ); - - assert.equal(findNearestMcppProject(root, root)?.root, root); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("recognizes dotted virtual and inline rooted workspace manifests", () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-toml-forms-")); - try { - writeFileSync(path.join(root, "mcpp.toml"), "workspace.members = ['A']\n"); - assert.equal(findNearestMcppProject(root, root), undefined); - - writeFileSync( - path.join(root, "mcpp.toml"), - "package = { name = 'root', version = '0.1.0' }\n[workspace]\nmembers = ['A']\n", - ); - assert.equal(findNearestMcppProject(root, root)?.root, root); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("routes workspace root manifest changes to the active member", () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-change-")); - try { - const memberRoot = path.join(root, "A"); - const member = { - root: memberRoot, - manifestPath: path.join(memberRoot, "mcpp.toml"), - compilationDatabasePath: path.join(memberRoot, "compile_commands.json"), - }; - const rootProject = { - root, - manifestPath: path.join(root, "mcpp.toml"), - compilationDatabasePath: path.join(root, "compile_commands.json"), - }; - mkdirSync(memberRoot, { recursive: true }); - - writeFileSync(rootProject.manifestPath, "[workspace]\nmembers = ['A']\n"); - assert.deepEqual( - projectAffectedByManifest(rootProject.manifestPath, member, undefined), - member, - ); - - writeFileSync( - rootProject.manifestPath, - "[package]\nname = 'root'\n[workspace]\nmembers = ['A']\n", - ); - assert.deepEqual( - projectAffectedByManifest(rootProject.manifestPath, member, rootProject), - member, - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - test("does not discover an mcpp project outside the opened workspace folder", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-workspace-boundary-")); try { @@ -152,28 +57,3 @@ test("does not discover an mcpp project outside the opened workspace folder", () rmSync(root, { recursive: true, force: true }); } }); - -test("derives sibling, xlings and PATH clangd candidates", () => { - assert.deepEqual( - deriveClangdCandidates("/tools/xim-x-llvm/22.1.8/bin/clang++"), - [ - "/tools/xim-x-llvm/22.1.8/bin/clangd", - "/tools/xim-x-llvm-tools/22.1.8/bin/clangd", - "clangd", - ], - ); -}); - -test("derives the user xlings tools path from an mcpp registry compiler", () => { - assert.deepEqual( - deriveClangdCandidates( - "/Users/demo/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", - ), - [ - "/Users/demo/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clangd", - "/Users/demo/.mcpp/registry/data/xpkgs/xim-x-llvm-tools/22.1.8/bin/clangd", - "/Users/demo/.xlings/data/xpkgs/xim-x-llvm-tools/22.1.8/bin/clangd", - "clangd", - ], - ); -}); diff --git a/test/languageServer.test.ts b/test/languageServer.test.ts new file mode 100644 index 0000000..373e5f7 --- /dev/null +++ b/test/languageServer.test.ts @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createLanguageServerBridge, + MCPPLS_COMMANDS, + MCPPLS_EXTENSION_ID, +} from "../src/languageServer"; + +function harness(installed = true) { + const calls: Array<{ command: string; args: unknown[] }> = []; + const activations: string[] = []; + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => installed && id === MCPPLS_EXTENSION_ID, + activateExtension: async (id) => { activations.push(id); }, + executeCommand: async (command: string, ...args: unknown[]): Promise => { + calls.push({ command, args }); + return undefined as T; + }, + }); + return { bridge, calls, activations }; +} + +test("uses the published mcppls extension and command identifiers", () => { + assert.equal(MCPPLS_EXTENSION_ID, "sunrisepeak.mcpp-language-server"); + assert.deepEqual(MCPPLS_COMMANDS, { + restart: "mcppls.restartServer", + selectContext: "mcppls.selectContext", + graph: "mcppls.showModuleGraph", + logs: "mcppls.showLogs", + }); +}); + +test("forwards the public mcppls UI commands", async () => { + const { bridge, calls, activations } = harness(); + + assert.deepEqual(await bridge.restartLanguageServer(), { + state: "completed", + message: "C++ 模块语言服务已重启。", + }); + await bridge.selectContext(); + await bridge.showModuleGraph(); + await bridge.showLanguageServerLogs(); + + assert.deepEqual(activations, [MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID, MCPPLS_EXTENSION_ID]); + assert.deepEqual(calls, [ + { command: MCPPLS_COMMANDS.restart, args: [] }, + { command: MCPPLS_COMMANDS.selectContext, args: [] }, + { command: MCPPLS_COMMANDS.graph, args: [] }, + { command: MCPPLS_COMMANDS.logs, args: [] }, + ]); +}); + +test("returns a stable unavailable result instead of throwing", async () => { + const { bridge, calls } = harness(false); + + assert.deepEqual(await bridge.restartLanguageServer(), { + state: "unavailable", + message: "C++ 模块语言服务依赖未安装或已禁用:sunrisepeak.mcpp-language-server。", + }); + assert.deepEqual(calls, []); +}); + +test("returns a stable failed result when mcppls rejects a command", async () => { + const calls: string[] = []; + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => id === MCPPLS_EXTENSION_ID, + executeCommand: async (command: string): Promise => { + calls.push(command); + throw new Error("server is not running"); + }, + }); + + assert.deepEqual(await bridge.restartLanguageServer(), { + state: "failed", + message: "C++ Modules 命令执行失败:server is not running", + }); + assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); +}); + +test("coalesces concurrent build refresh requests into one restart", async () => { + let release: (() => void) | undefined; + const calls: string[] = []; + const bridge = createLanguageServerBridge({ + extensionInstalled: (id) => id === MCPPLS_EXTENSION_ID, + executeCommand: async (command: string): Promise => { + calls.push(command); + await new Promise((resolve) => { release = resolve; }); + return undefined as T; + }, + }); + + const first = bridge.refreshLanguageServerAfterBuild(); + const second = bridge.refreshLanguageServerAfterBuild(); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); + release?.(); + assert.deepEqual(await first, { + state: "completed", + message: "C++ 模块语言服务已刷新。", + }); + assert.deepEqual(await second, { + state: "completed", + message: "C++ 模块语言服务已刷新。", + }); + assert.deepEqual(calls, [MCPPLS_COMMANDS.restart]); +}); diff --git a/test/llvmTools.test.ts b/test/llvmTools.test.ts deleted file mode 100644 index 8e967d7..0000000 --- a/test/llvmTools.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import test from "node:test"; - -import { - llvmToolsVersionSpec, - extractVersionFromCompilerPath, - xlingsInstallArgs, - deriveInstalledClangdPath, - findXlingsExecutable, - resolveXlingsExecutable, -} from "../src/llvmTools"; - -// `xlings` on POSIX, `xlings.exe` on Windows — mirrors mcpp's exe_suffix. -const xlingsBinaryName = process.platform === "win32" ? "xlings.exe" : "xlings"; - -test("extracts version string from ToolIdentity", () => { - assert.equal( - llvmToolsVersionSpec({ major: 22, minor: 1, patch: 8, revision: "abc1234" }), - "22.1.8", - ); - assert.equal( - llvmToolsVersionSpec({ major: 20, minor: 1, patch: 7 }), - "20.1.7", - ); -}); - -test("extracts version from mcpp compiler path", () => { - assert.equal( - extractVersionFromCompilerPath("/home/user/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++"), - "22.1.8", - ); - assert.equal( - extractVersionFromCompilerPath("C:\\Users\\test\\.mcpp\\registry\\data\\xpkgs\\xim-x-llvm\\20.1.7\\bin\\clang.exe"), - "20.1.7", - ); -}); - -test("returns undefined for non-xlings compiler paths", () => { - assert.equal(extractVersionFromCompilerPath("/usr/bin/clang++"), undefined); - assert.equal(extractVersionFromCompilerPath("/opt/homebrew/opt/llvm/bin/clang"), undefined); -}); - -test("builds xlings update args without version (auto-detect)", () => { - assert.deepEqual(xlingsInstallArgs(), ["update", "llvm-tools"]); -}); - -test("builds xlings install args with explicit version", () => { - assert.deepEqual(xlingsInstallArgs("22.1.8"), ["install", "xim:llvm-tools@22.1.8"]); -}); - -test("derives clangd path from mcpp registry compiler path", () => { - const paths = deriveInstalledClangdPath( - "/Users/test/.mcpp/registry/data/xpkgs/xim-x-llvm/22.1.8/bin/clang++", - "22.1.8", - ); - assert.ok(paths.some((p) => p.includes("xim-x-llvm-tools"))); - assert.ok(paths.some((p) => p.includes("22.1.8"))); - assert.ok(paths.some((p) => p.endsWith("clangd"))); -}); - -test("derives clangd path with .exe extension on Windows", () => { - const paths = deriveInstalledClangdPath( - "C:\\Users\\test\\.mcpp\\registry\\data\\xpkgs\\xim-x-llvm\\20.1.7\\bin\\clang.exe", - "20.1.7", - ); - assert.ok(paths.every((p) => p.endsWith(".exe"))); - assert.ok(paths.some((p) => p.includes(".xlings") && p.includes("clangd.exe"))); -}); - -test("findXlingsExecutable returns a string or undefined", () => { - const result = findXlingsExecutable(); - // Returns string (PATH fallback or known path) or undefined if xlings not found - assert.ok(result === undefined || typeof result === "string"); -}); - -test("findXlingsExecutable finds the xlings bundled in $MCPP_HOME/registry/bin", () => { - const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-mcpp-home-")); - const registryBin = path.join(home, "registry", "bin"); - const xlingsPath = path.join(registryBin, xlingsBinaryName); - mkdirSync(registryBin, { recursive: true }); - writeFileSync(xlingsPath, "#!/bin/sh\n"); - try { - assert.equal( - findXlingsExecutable({ home, env: { MCPP_HOME: home } }), - xlingsPath, - ); - } finally { - rmSync(home, { recursive: true, force: true }); - } -}); - -test("findXlingsExecutable falls back to $HOME/.mcpp/registry/bin when MCPP_HOME is unset", () => { - const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-home-")); - const registryBin = path.join(home, ".mcpp", "registry", "bin"); - const xlingsPath = path.join(registryBin, xlingsBinaryName); - mkdirSync(registryBin, { recursive: true }); - writeFileSync(xlingsPath, "#!/bin/sh\n"); - try { - assert.equal( - findXlingsExecutable({ home, env: {} }), - xlingsPath, - ); - } finally { - rmSync(home, { recursive: true, force: true }); - } -}); - -test("findXlingsExecutable honors MCPP_VENDORED_XLINGS", () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-vendored-")); - const vendored = path.join(root, "opt-mcpp", "registry", "bin", xlingsBinaryName); - mkdirSync(path.dirname(vendored), { recursive: true }); - writeFileSync(vendored, "#!/bin/sh\n"); - try { - assert.equal( - findXlingsExecutable({ home: root, env: { MCPP_VENDORED_XLINGS: vendored } }), - vendored, - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("resolveXlingsExecutable reads the xlings binary from `mcpp self env`", async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-selfenv-")); - const xlingsPath = path.join(root, "registry", "bin", xlingsBinaryName); - mkdirSync(path.dirname(xlingsPath), { recursive: true }); - writeFileSync(xlingsPath, "#!/bin/sh\n"); - const runner = async () => ({ - exitCode: 0, - stdout: `MCPP_HOME = ${root}\nxlings binary = ${xlingsPath}\nxlings pinned = 2026.8.8.1\n`, - stderr: "", - }); - try { - assert.equal( - await resolveXlingsExecutable("/tools/mcpp", runner), - xlingsPath, - ); - } finally { - rmSync(root, { recursive: true, force: true }); - } -}); - -test("resolveXlingsExecutable passes a timeout to `mcpp self env`", async () => { - let captured: { timeoutMs?: number } | undefined; - const runner = async ( - _executable: string, - _args: string[], - _cwd?: string, - options?: { timeoutMs?: number }, - ) => { - captured = options; - return { exitCode: 0, stdout: "", stderr: "" }; - }; - await resolveXlingsExecutable("/tools/mcpp", runner); - assert.equal(captured?.timeoutMs, 60_000); -}); - -test("resolveXlingsExecutable falls back to path probing when the reported path does not exist", async () => { - const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-missing-")); - const runner = async () => ({ - exitCode: 0, - stdout: "xlings binary = /no/such/xlings\n", - stderr: "", - }); - try { - assert.equal( - await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), - undefined, - ); - } finally { - rmSync(home, { recursive: true, force: true }); - } -}); - -test("resolveXlingsExecutable falls back to path probing when `mcpp self env` fails", async () => { - const home = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-fallback-fail-")); - const runner = async () => ({ exitCode: 1, stdout: "", stderr: "boom\n" }); - try { - assert.equal( - await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), - undefined, - ); - } finally { - rmSync(home, { recursive: true, force: true }); - } -}); diff --git a/test/mcppTomlCompletion.test.ts b/test/mcppTomlCompletion.test.ts index 33983d7..2d2e132 100644 --- a/test/mcppTomlCompletion.test.ts +++ b/test/mcppTomlCompletion.test.ts @@ -27,6 +27,12 @@ test("suggests section headers on a partial bracket line", () => { assert.equal(targets?.insertSnippet, "[targets.${1:name}]"); }); +test("does not suggest the removed xlings.envs section", () => { + const suggestions = computeMcppTomlCompletions(["[xl"], 0, 3); + assert.ok(!labels(suggestions).includes("[xlings.envs]")); + assert.ok(labels(suggestions).includes("[xlings.workspace]")); +}); + test("offers nothing inside [[...]] array-table headers", () => { // mcpp manifest 不使用 TOML 数组表([[...]]):[[ 内不出建议, // 避免把用户意图的数组表悄悄替换成普通段 [x]。 diff --git a/test/mcppTomlContract.test.ts b/test/mcppTomlContract.test.ts index a9506fb..cba1865 100644 --- a/test/mcppTomlContract.test.ts +++ b/test/mcppTomlContract.test.ts @@ -187,17 +187,15 @@ test("模板键契约:[features] 表形式与数组简写被接受", { skip: s // provider 绑定在「无包 require 该能力」时不解析,静默通过;generated_files // 的条目会在源 glob 展开前写入工程树,这里同时验证生成机制生效(编译成功 // 即说明模块文件被正常纳入构建)。 -test("模板实例契约:capabilities / xlings / tools.overrides / generated_files", { skip: skipReason, timeout: 120_000 }, async () => { +test("模板实例契约:capabilities / xlings.workspace / tools.overrides / generated_files", { skip: skipReason, timeout: 120_000 }, async () => { const run = await buildWith( [ "[capabilities]", 'blas = "compat.openblas"', "", "[xlings.workspace]", - 'clang = "20.1.7"', + 'node = "24.19.0"', "", - "[xlings.envs]", - 'FOO = "1"', "", "[tools.overrides]", '"compat.protobuf:protoc" = "/usr/bin/protoc"', @@ -209,7 +207,7 @@ test("模板实例契约:capabilities / xlings / tools.overrides / generated_f "", ].join("\n"), ); - assertClean(run, "模板实例(capabilities/xlings/tools.overrides/generated_files)"); + assertClean(run, "模板实例(capabilities/xlings.workspace/tools.overrides/generated_files)"); }); test("条件段规则:[target.'cfg(windows)'.build] 接受 build inputs", { skip: skipReason, timeout: 120_000 }, async () => { diff --git a/test/moduleSetup.test.ts b/test/moduleSetup.test.ts index 2796c97..489f260 100644 --- a/test/moduleSetup.test.ts +++ b/test/moduleSetup.test.ts @@ -1,441 +1,133 @@ import assert from "node:assert/strict"; import test from "node:test"; -import type { ToolchainInventory, ToolchainItem } from "../src/cli"; import { buildModuleSetupPlan, executeModuleSetup, - mcppModuleSetupCommands, moduleSetupConfirmation, - type ModuleSetupDecision, type ModuleSetupOperations, - type ModuleSetupStage, type ModuleSetupStepResult, - type ModuleSetupStepState, } from "../src/moduleSetup"; -function toolchain(family: string, effective = false): ToolchainItem { - return { - family, - version: "1.0.0", - spec: `${family}@1.0.0`, - source: "managed", - effective, - }; -} - -function inventory( - overrides: Partial = {}, -): ToolchainInventory { - return { - installed: [], - available: [], - targets: [], - effective: undefined, - effectiveTarget: undefined, - globalDefaultSpec: undefined, - projectOverridesGlobal: false, - recognized: true, - rawOutput: "", - ...overrides, - }; -} - -function ready(decision: ModuleSetupDecision): Extract { - if (decision.kind !== "ready") { - assert.fail(`expected ready plan, got ${decision.reason}`); - } - return decision; -} - -test("GCC 生效且没有已安装的 host LLVM 时计划安装并切换默认工具链", () => { - const gcc = toolchain("gcc", true); - const plan = buildModuleSetupPlan( - inventory({ installed: [gcc], effective: gcc }), - "unavailable", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "ready", - installLlvm: true, - switchDefault: true, - }); +test("只信任且不忙时可开始一键配置", () => { + assert.deepEqual(buildModuleSetupPlan(true, false), { kind: "ready" }); + assert.deepEqual(buildModuleSetupPlan(false, false), { kind: "blocked", reason: "untrusted" }); + assert.deepEqual(buildModuleSetupPlan(true, true), { kind: "blocked", reason: "busy" }); }); -test("已安装 host LLVM 但 GCC 生效时只切换默认工具链", () => { - const gcc = toolchain("gcc", true); - const llvm = toolchain("llvm"); - const plan = buildModuleSetupPlan( - inventory({ installed: [gcc, llvm], effective: gcc }), - "syntax-only", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "ready", - installLlvm: false, - switchDefault: true, - }); +test("确认文案不再要求切换 LLVM、下载 llvm-tools 或配置 clangd", () => { + const confirmation = moduleSetupConfirmation(); + assert.match(confirmation.message, /构建.*刷新 C\+\+ 模块语言服务/); + assert.match(confirmation.detail, /mcpp build/); + assert.match(confirmation.detail, /C\+\+ Modules/); + assert.doesNotMatch(confirmation.detail, /LLVM|llvm-tools/i); + assert.doesNotMatch(confirmation.detail, /配置 clangd/i); }); -test("LLVM 已生效时不重复安装或切换", () => { - const llvm = toolchain("llvm", true); - const plan = buildModuleSetupPlan( - inventory({ installed: [llvm], effective: llvm }), - "full", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "ready", - installLlvm: false, - switchDefault: false, - }); -}); - -test("没有 effective 工具链时安装 LLVM 后设置全局默认", () => { - const plan = buildModuleSetupPlan( - inventory(), - "unavailable", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "ready", - installLlvm: true, - switchDefault: true, - }); -}); - -test("available LLVM 不算已安装的 host 工具链", () => { - const gcc = toolchain("gcc", true); - const availableLlvm = toolchain("llvm"); - const plan = buildModuleSetupPlan( - inventory({ installed: [gcc], available: [availableLlvm], effective: gcc }), - "syntax-only", - true, - false, - ); - - assert.equal(ready(plan).installLlvm, true); -}); - -test("只有 target payload 的 LLVM 不算可设为默认的 host 工具链", () => { - const gcc = toolchain("gcc", true); - const llvm = toolchain("llvm"); - const plan = buildModuleSetupPlan( - inventory({ - installed: [gcc, llvm], - effective: gcc, - targets: [{ - target: "x86_64-linux-musl", - note: "static", - toolchainSpec: llvm.spec, - status: "installed", - effective: false, - }], - }), - "syntax-only", - true, - false, - ); - - assert.equal(ready(plan).installLlvm, true); -}); - -test("项目显式固定非 LLVM 时阻止修改全局默认", () => { - const gcc = toolchain("gcc", true); - const plan = buildModuleSetupPlan( - inventory({ - installed: [gcc], - effective: gcc, - projectOverridesGlobal: true, - }), - "syntax-only", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "blocked", - reason: "project-toolchain-override", - }); -}); - -test("项目显式固定 LLVM 时仍允许继续配置", () => { - const llvm = toolchain("llvm", true); - const plan = buildModuleSetupPlan( - inventory({ - installed: [llvm], - effective: llvm, - projectOverridesGlobal: true, - }), - "full", - true, - false, - ); - - assert.deepEqual(plan, { - kind: "ready", - installLlvm: false, - switchDefault: false, - }); -}); - -test("阻止原因按未信任、未知清单、忙碌、项目覆盖的顺序判定", () => { - const gcc = toolchain("gcc", true); - const conflicting = inventory({ - installed: [gcc], - effective: gcc, - projectOverridesGlobal: true, - recognized: false, - }); - - assert.deepEqual( - buildModuleSetupPlan(conflicting, "unavailable", false, true), - { kind: "blocked", reason: "untrusted" }, - ); - assert.deepEqual( - buildModuleSetupPlan(conflicting, "unavailable", true, true), - { kind: "blocked", reason: "unrecognized-inventory" }, - ); - assert.deepEqual( - buildModuleSetupPlan({ ...conflicting, recognized: true }, "unavailable", true, true), - { kind: "blocked", reason: "busy" }, - ); -}); - -test("模块设置命令仅在工具链变化时清缓存", () => { - assert.deepEqual( - mcppModuleSetupCommands({ kind: "ready", installLlvm: true, switchDefault: true }), - [ - { stage: "install", mode: "task", args: ["toolchain", "install", "llvm"] }, - { stage: "default", mode: "process", args: ["toolchain", "default", "llvm"] }, - { stage: "build", mode: "task", args: ["build", "--no-cache"] }, - ], - ); - assert.deepEqual( - mcppModuleSetupCommands({ kind: "ready", installLlvm: false, switchDefault: false }), - [{ stage: "build", mode: "task", args: ["build"] }], - ); -}); - -test("确认文案按能力解释现状并完整说明副作用边界", () => { - const plan = { kind: "ready", installLlvm: true, switchDefault: true } as const; - const confirmations = [ - moduleSetupConfirmation("full", plan), - moduleSetupConfirmation("syntax-only", plan), - moduleSetupConfirmation("unavailable", plan), - ]; - - assert.match(confirmations[0].message, /模块代码提示/); - assert.match(confirmations[1].message, /语法高亮/); - assert.match(confirmations[2].message, /编译数据库/); - assert.equal(new Set(confirmations.map((item) => item.message)).size, 3); - for (const confirmation of confirmations) { - assert.match(confirmation.detail, /LLVM\/llvm-tools/); - assert.match(confirmation.detail, /全局默认/); - assert.match(confirmation.detail, /不会编辑项目.*mcpp\.toml/); - } -}); - -test("确认文案说明将复用已安装的 LLVM", () => { - const confirmation = moduleSetupConfirmation( - "syntax-only", - { kind: "ready", installLlvm: false, switchDefault: true }, - ); - - assert.match(confirmation.detail, /将复用已安装的 LLVM/); - assert.match(confirmation.detail, /补齐配套组件时仍可能下载 LLVM\/llvm-tools/); -}); - -test("确认文案在无需切换时明确不修改全局默认值", () => { - const llvm = toolchain("llvm", true); - const plan = ready(buildModuleSetupPlan( - inventory({ installed: [llvm], effective: llvm }), - "full", - true, - false, - )); - const confirmation = moduleSetupConfirmation( - "unavailable", - plan, - ); - - assert.match(confirmation.detail, /不会修改全局默认工具链/); - assert.doesNotMatch(confirmation.detail, /设为全局默认工具链/); - assert.doesNotMatch(confirmation.detail, /当前 LLVM 已生效/); -}); - -interface OperationFixture { - calls: ModuleSetupStage[]; - operations: ModuleSetupOperations; -} - -function result(stage: ModuleSetupStage, state: ModuleSetupStepState): ModuleSetupStepResult { - return { stage, state }; -} - -function operationsWith( - states: Partial> = {}, -): OperationFixture { - const calls: ModuleSetupStage[] = []; - const run = async (stage: ModuleSetupStage): Promise => { - calls.push(stage); - return result(stage, states[stage] ?? "succeeded"); - }; - - return { - calls, - operations: { - prepare: async (command: { stage: ModuleSetupStage }) => run(command.stage), - reload: async () => run("reload"), - ensureClangd: async () => run("clangd"), - checkModules: async () => run("check"), +test("成功路径严格执行 build 后刷新语言服务", async () => { + const calls: string[] = []; + const operations: ModuleSetupOperations = { + build: async () => { + calls.push("build"); + return { stage: "build", state: "succeeded" }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; }, }; -} -test("成功流程严格按安装、默认、构建、重载、clangd、检查执行", async () => { - const fixture = operationsWith(); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: true, switchDefault: true }, - fixture.operations, - ); - - assert.deepEqual(fixture.calls, ["install", "default", "build", "reload", "clangd", "check"]); - assert.equal(outcome.state, "succeeded"); - assert.equal(outcome.degraded, false); - assert.deepEqual( - outcome.steps.map((step: ModuleSetupStepResult) => step.stage), - fixture.calls, - ); -}); - -test("批量准备操作只执行一次并继续恢复链", async () => { - const fixture = operationsWith(); - let preparePlanCalls = 0; - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: true, switchDefault: true }, - { - ...fixture.operations, - preparePlan: async () => { - preparePlanCalls += 1; - return result("build", "succeeded"); - }, + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, operations); + assert.deepEqual(outcome, { state: "succeeded", degraded: false, steps: [ + { stage: "build", state: "succeeded" }, + { stage: "language-server", state: "succeeded" }, + ] }); + assert.deepEqual(calls, ["build", "language-server"]); +}); + +test("build 失败但未取消时仍刷新已有语言服务描述", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "failed", exitCode: 2 }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; }, - ); + }); - assert.equal(preparePlanCalls, 1); - assert.deepEqual(fixture.calls, ["reload", "clangd", "check"]); assert.equal(outcome.state, "succeeded"); -}); - -test("安装或默认步骤取消、失败后立即停止", async () => { - const cases: Array<{ - stage: "install" | "default"; - state: "cancelled" | "failed"; - plan: Extract; - expectedCalls: ModuleSetupStage[]; - }> = [ - { - stage: "install", - state: "cancelled", - plan: { kind: "ready", installLlvm: true, switchDefault: true }, - expectedCalls: ["install"], - }, - { - stage: "install", - state: "failed", - plan: { kind: "ready", installLlvm: true, switchDefault: true }, - expectedCalls: ["install"], - }, - { - stage: "default", - state: "cancelled", - plan: { kind: "ready", installLlvm: false, switchDefault: true }, - expectedCalls: ["default"], + if (outcome.state === "succeeded") assert.equal(outcome.degraded, true); + assert.deepEqual(calls, ["build", "language-server"]); +}); + +test("build 取消后不刷新语言服务", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "cancelled" }; }, - { - stage: "default", - state: "failed", - plan: { kind: "ready", installLlvm: false, switchDefault: true }, - expectedCalls: ["default"], + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "build", state: "succeeded" }; }, - ]; - - for (const item of cases) { - const fixture = operationsWith({ [item.stage]: item.state }); - const outcome = await executeModuleSetup(item.plan, fixture.operations); - assert.deepEqual(fixture.calls, item.expectedCalls); - assert.equal(outcome.state, item.state); - assert.equal(outcome.degraded, false); - assert.equal(outcome.stage, item.stage); - } -}); - -test("构建取消后停止,构建失败则尝试后续恢复", async () => { - const fixture = operationsWith({ build: "cancelled" }); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: false, switchDefault: false }, - fixture.operations, - ); + }); - assert.deepEqual(fixture.calls, ["build"]); assert.equal(outcome.state, "cancelled"); - assert.equal(outcome.degraded, false); -}); - -test("构建失败后的重载失败会终止恢复流程", async () => { - const fixture = operationsWith({ build: "failed", reload: "failed" }); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: false, switchDefault: false }, - fixture.operations, - ); - - assert.deepEqual(fixture.calls, ["build", "reload"]); - assert.equal(outcome.state, "failed"); - assert.equal(outcome.degraded, false); - assert.equal(outcome.stage, "reload"); -}); - -test("构建失败但 IDE 恢复链成功时返回降级成功", async () => { - const fixture = operationsWith({ build: "failed" }); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: false, switchDefault: false }, - fixture.operations, - ); - - assert.deepEqual(fixture.calls, ["build", "reload", "clangd", "check"]); - assert.equal(outcome.state, "succeeded"); - assert.equal(outcome.degraded, true); -}); - -test("clangd 失败后不检查模块", async () => { - const fixture = operationsWith({ clangd: "failed" }); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: false, switchDefault: false }, - fixture.operations, - ); - - assert.deepEqual(fixture.calls, ["build", "reload", "clangd"]); + assert.equal(outcome.stage, "build"); + assert.deepEqual(calls, ["build"]); +}); + +test("build 未启动时不刷新语言服务", async () => { + const calls: string[] = []; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => { + calls.push("build"); + return { stage: "build", state: "not-started", detail: "未启动 mcpp 构建。" }; + }, + refreshLanguageServer: async () => { + calls.push("language-server"); + return { stage: "language-server", state: "succeeded" }; + }, + }); assert.equal(outcome.state, "failed"); - assert.equal(outcome.stage, "clangd"); + if (outcome.state === "failed") assert.equal(outcome.stage, "build"); + assert.deepEqual(calls, ["build"]); }); -test("模块检查失败时返回检查阶段", async () => { - const fixture = operationsWith({ check: "failed" }); - const outcome = await executeModuleSetup( - { kind: "ready", installLlvm: false, switchDefault: false }, - fixture.operations, - ); +test("语言服务依赖缺失时不伪装成成功", async () => { + const result: ModuleSetupStepResult = { + stage: "language-server", + state: "failed", + detail: "C++ 模块语言服务依赖未安装或已禁用。", + }; + const decision = buildModuleSetupPlan(true, false); + assert.equal(decision.kind, "ready"); + if (decision.kind !== "ready") return; + const outcome = await executeModuleSetup(decision, { + build: async () => ({ stage: "build", state: "succeeded" }), + refreshLanguageServer: async () => result, + }); - assert.deepEqual(fixture.calls, ["build", "reload", "clangd", "check"]); assert.equal(outcome.state, "failed"); - assert.equal(outcome.stage, "check"); + if (outcome.state === "failed") assert.equal(outcome.stage, "language-server"); + assert.deepEqual(outcome.steps, [ + { stage: "build", state: "succeeded" }, + result, + ]); }); diff --git a/test/process.test.ts b/test/process.test.ts index dc2d61d..363d0d0 100644 --- a/test/process.test.ts +++ b/test/process.test.ts @@ -2,12 +2,7 @@ import assert from "node:assert/strict"; import process from "node:process"; import test from "node:test"; -import { - runClangdCheck, - runMcppBuild, - runProcess, - runToolVersion, -} from "../src/process"; +import { runProcess } from "../src/process"; test("captures output and exit status from a real child process", async () => { const result = await runProcess(process.execPath, ["-e", "process.stdout.write('ok')"]); @@ -16,66 +11,3 @@ test("captures output and exit status from a real child process", async () => { assert.equal(result.stdout, "ok"); assert.equal(result.stderr, ""); }); - -test("parses a tool identity from the version command", async () => { - const result = await runToolVersion(process.execPath, [ - "-e", - "process.stdout.write('clangd version 22.1.8 (git ca7933e47d3a3451d81e72ac174dcb5aa28b59d1)')", - ]); - - assert.deepEqual(result.identity, { - major: 22, - minor: 1, - patch: 8, - revision: "ca7933e47d3a3451d81e72ac174dcb5aa28b59d1", - }); -}); - -test("classifies clangd check output at the process boundary", async () => { - const calls: Array<{ executable: string; arguments: string[]; cwd?: string }> = []; - const timeouts: Array = []; - const result = await runClangdCheck( - "/tools/clangd", - "/work/app/src/main.cpp", - "/work/app", - ["--background-index"], - async ( - executable: string, - args: string[], - cwd: string | undefined, - options?: { timeoutMs?: number }, - ) => { - calls.push({ executable, arguments: args, cwd }); - timeouts.push(options?.timeoutMs); - return { - exitCode: 1, - stdout: "", - stderr: "error: ast_file_different_branch", - }; - }, - ); - - assert.deepEqual(calls, [{ - executable: "/tools/clangd", - arguments: ["--check=/work/app/src/main.cpp", "--background-index"], - cwd: "/work/app", - }]); - assert.deepEqual(timeouts, [60_000]); - assert.equal(result.classification, "pcm-mismatch"); -}); - -test("uses the configured mcpp executable for builds", async () => { - const calls: Array<{ executable: string; arguments: string[]; cwd?: string }> = []; - const runner = async (executable: string, args: string[], cwd?: string) => { - calls.push({ executable, arguments: args, cwd }); - return { exitCode: 0, stdout: "", stderr: "" }; - }; - - await Reflect.apply(runMcppBuild, undefined, ["/work/app", "/tools/mcpp", runner]); - - assert.deepEqual(calls, [{ - executable: "/tools/mcpp", - arguments: ["build"], - cwd: "/work/app", - }]); -}); diff --git a/test/tasks.test.ts b/test/tasks.test.ts index 9b9f8b2..86ca012 100644 --- a/test/tasks.test.ts +++ b/test/tasks.test.ts @@ -5,7 +5,7 @@ import { McppOperationRegistry, classifyTaskExit, projectTaskPlan, - shouldReconcileAfterTask, + shouldRefreshLanguageServerAfterTask, } from "../src/tasks"; test("基础项目任务使用固定的 mcpp 参数数组", () => { @@ -31,18 +31,16 @@ test("基础项目任务使用固定的 mcpp 参数数组", () => { }); }); -test("构建类项目任务结束后都需要重新协调 IDE", () => { - for (const kind of ["build", "run", "test", "clean"] as const) { - assert.equal(shouldReconcileAfterTask(kind), true); +test("只有非取消的 build 任务刷新 C++ 模块语言服务", () => { + assert.equal(shouldRefreshLanguageServerAfterTask("build", { state: "succeeded", exitCode: 0 }), true); + assert.equal(shouldRefreshLanguageServerAfterTask("build", { state: "failed", exitCode: 2 }), true); + assert.equal(shouldRefreshLanguageServerAfterTask("build", { state: "cancelled" }), false); + assert.equal(shouldRefreshLanguageServerAfterTask("build"), false); + for (const kind of ["run", "test", "clean"] as const) { + assert.equal(shouldRefreshLanguageServerAfterTask(kind, { state: "succeeded", exitCode: 0 }), false); } }); -test("取消的项目任务不进入成功后的 IDE 重协调", () => { - assert.equal(shouldReconcileAfterTask("build", { state: "succeeded", exitCode: 0 }), true); - assert.equal(shouldReconcileAfterTask("build", { state: "failed", exitCode: 2 }), true); - assert.equal(shouldReconcileAfterTask("build", { state: "cancelled" }), false); -}); - test("退出码区分成功、失败和取消", () => { assert.deepEqual(classifyTaskExit(0), { state: "succeeded", exitCode: 0 }); assert.deepEqual(classifyTaskExit(2), { state: "failed", exitCode: 2 }); diff --git a/test/workflow.test.ts b/test/workflow.test.ts deleted file mode 100644 index 7c3b1c3..0000000 --- a/test/workflow.test.ts +++ /dev/null @@ -1,499 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import * as workflow from "../src/workflow"; -import { - createSerialExecutor, - createSingleFlightReconciler, - configurationAffectsMcppExecution, - configurationAffectsModuleSupport, - describeConfigureOnlyOutcome, - describeRefreshOutcome, - statusCommandForCapability, -} from "../src/workflow"; - -test("selects refresh when the compilation database is unavailable", () => { - assert.equal(statusCommandForCapability("unavailable"), "mcpp.refreshCompilationDatabase"); - assert.equal(statusCommandForCapability("full"), "mcpp.checkModuleSupport"); - assert.equal(statusCommandForCapability("syntax-only"), "mcpp.checkModuleSupport"); -}); - -test("keeps IDE configuration when a source build fails after producing a database", () => { - assert.deepEqual(describeRefreshOutcome(1, true, true), { - level: "warning", - message: "mcpp 构建失败,但检测到可用的编译数据库,clangd 配置已刷新。请继续修复源码构建错误。", - }); - assert.deepEqual(describeRefreshOutcome(0, true, true), { - level: "information", - message: "mcpp 构建完成,clangd 配置已刷新。", - }); - assert.equal(describeRefreshOutcome(0, true, false).level, "error"); - assert.equal(describeRefreshOutcome(1, false, false).level, "error"); -}); - -test("describes configure-only results without treating an old CDB as newly generated", () => { - assert.deepEqual(describeConfigureOnlyOutcome(0, true, false, true), { - level: "information", - message: "编译数据库已刷新,clangd 配置已重新加载。", - }); - assert.deepEqual(describeConfigureOnlyOutcome(0, true, false, false), { - level: "warning", - message: "编译数据库已刷新,但 clangd 配置未完成。请查看 mcpp 输出频道。", - }); - assert.deepEqual(describeConfigureOnlyOutcome(0, false, false, false), { - level: "error", - message: "mcpp configure-only 已完成,但没有生成可用的 compile_commands.json。", - }); - assert.deepEqual(describeConfigureOnlyOutcome(2, true, true, false), { - level: "warning", - message: "mcpp configure-only 失败;继续使用原有编译数据库。请查看 mcpp 输出频道。", - }); - assert.deepEqual(describeConfigureOnlyOutcome(2, true, false, false), { - level: "warning", - message: "mcpp configure-only 失败,但检测到可用的编译数据库。请查看 mcpp 输出频道。", - }); - assert.deepEqual(describeConfigureOnlyOutcome(2, false, false, false), { - level: "error", - message: "mcpp configure-only 失败,且没有可用的编译数据库。请确认 mcpp 版本支持 build --configure-only。", - }); -}); - -test("releases an IDE operation queue when an external operation exceeds its deadline", async () => { - const stalled = new Promise(() => { - // 模拟 clangd.restart 或 mcpp configure-only 永久等待。 - }); - - const result = await workflow.withTimeout(stalled, 10, "timed-out"); - - assert.equal(result, "timed-out"); -}); - -test("serializes reconciliation and merges pending restart requests", async () => { - let releaseFirst: (() => void) | undefined; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - let active = 0; - let maxConcurrent = 0; - const restartRequests: boolean[] = []; - const reconcile = createSingleFlightReconciler(async (forceRestart) => { - restartRequests.push(forceRestart); - active += 1; - maxConcurrent = Math.max(maxConcurrent, active); - try { - if (restartRequests.length === 1) { - await firstGate; - } - return restartRequests.length; - } finally { - active -= 1; - } - }); - - const first = reconcile(false); - const second = reconcile(true); - const third = reconcile(true); - assert.deepEqual(restartRequests, [false]); - assert.ok(releaseFirst); - releaseFirst(); - - assert.deepEqual(await Promise.all([first, second, third]), [2, 2, 2]); - assert.deepEqual(restartRequests, [false, true]); - assert.equal(maxConcurrent, 1); -}); - -test("coalesces reconciliation requests independently for each project", async () => { - const createKeyedReconciler = ( - workflow as typeof workflow & { - createKeyedSingleFlightReconciler?: ( - operation: (key: K, forceRestart: boolean) => Promise, - ) => (key: K, forceRestart?: boolean) => Promise; - } - ).createKeyedSingleFlightReconciler; - assert.equal(typeof createKeyedReconciler, "function"); - assert.ok(createKeyedReconciler); - - let releaseFirstProject: (() => void) | undefined; - const firstProjectGate = new Promise((resolve) => { - releaseFirstProject = resolve; - }); - const calls: string[] = []; - const reconcile = createKeyedReconciler(async (project: string, forceRestart: boolean) => { - calls.push(`${project}:${forceRestart}`); - if (project === "/work/a" && calls.filter((call) => call.startsWith("/work/a")).length === 1) { - await firstProjectGate; - } - return `${project}:${forceRestart}`; - }); - - const firstA = reconcile("/work/a", false); - const secondA = reconcile("/work/a", true); - const firstB = reconcile("/work/b", true); - - assert.equal(await firstB, "/work/b:true"); - assert.deepEqual(calls, ["/work/a:false", "/work/b:true"]); - assert.ok(releaseFirstProject); - releaseFirstProject(); - assert.deepEqual(await Promise.all([firstA, secondA]), ["/work/a:true", "/work/a:true"]); - assert.deepEqual(calls, ["/work/a:false", "/work/b:true", "/work/a:true"]); -}); - -test("serializes operations that share the workspace clangd configuration", async () => { - const createSerialExecutor = ( - workflow as typeof workflow & { - createSerialExecutor?: () => (operation: () => Promise) => Promise; - } - ).createSerialExecutor; - assert.equal(typeof createSerialExecutor, "function"); - assert.ok(createSerialExecutor); - - let releaseFirst: (() => void) | undefined; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const calls: string[] = []; - const execute = createSerialExecutor(); - const first = execute(async () => { - calls.push("a:start"); - await firstGate; - calls.push("a:end"); - return "a"; - }); - const second = execute(async () => { - calls.push("b:start"); - calls.push("b:end"); - return "b"; - }); - - await Promise.resolve(); - assert.deepEqual(calls, ["a:start"]); - assert.ok(releaseFirst); - releaseFirst(); - assert.deepEqual(await Promise.all([first, second]), ["a", "b"]); - assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]); -}); - -test("queues an unrelated concurrent caller behind an in-flight operation", async () => { - let releaseFirst: (() => void) | undefined; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const calls: string[] = []; - let maxConcurrent = 0; - let active = 0; - const execute = createSerialExecutor(); - - const first = execute(async () => { - active += 1; - maxConcurrent = Math.max(maxConcurrent, active); - calls.push("a:start"); - await firstGate; - calls.push("a:end"); - active -= 1; - return "a"; - }); - - // A second, unrelated invocation arrives while the first is still in flight, - // from a separate async context (a macrotask, like a user command event). - let second: Promise | undefined; - const secondArrived = new Promise((resolve) => { - setTimeout(() => { - second = execute(async () => { - active += 1; - maxConcurrent = Math.max(maxConcurrent, active); - calls.push("b:start"); - calls.push("b:end"); - active -= 1; - return "b"; - }); - resolve(); - }, 10); - }); - await secondArrived; - - assert.deepEqual(calls, ["a:start"]); - assert.ok(releaseFirst); - releaseFirst(); - assert.ok(second); - assert.deepEqual(await Promise.all([first, second]), ["a", "b"]); - assert.deepEqual(calls, ["a:start", "a:end", "b:start", "b:end"]); - assert.equal(maxConcurrent, 1); -}); - -test("runs a reentrant call from inside an operation without queuing", async () => { - const calls: string[] = []; - let releaseInner: (() => void) | undefined; - const innerGate = new Promise((resolve) => { - releaseInner = resolve; - }); - const execute = createSerialExecutor(); - - const outer = execute(async () => { - calls.push("outer:start"); - await Promise.resolve(); - const inner = execute(async () => { - calls.push("inner:start"); - await innerGate; - calls.push("inner:end"); - return "inner"; - }); - calls.push("outer:mid"); - const value = await inner; - calls.push("outer:end"); - return value; - }); - - // The outer operation's continuation (which makes the reentrant call) runs in - // the microtask queue, before the timer below fires. - await new Promise((resolve) => setTimeout(resolve, 0)); - assert.ok(releaseInner); - releaseInner(); - assert.equal(await outer, "inner"); - assert.deepEqual(calls, ["outer:start", "inner:start", "outer:mid", "inner:end", "outer:end"]); -}); - -test("retries a queued reconciliation after a transient failure", async () => { - let releaseFirst: (() => void) | undefined; - const firstGate = new Promise((resolve) => { - releaseFirst = resolve; - }); - const restartRequests: boolean[] = []; - const reconcile = createSingleFlightReconciler(async (forceRestart) => { - restartRequests.push(forceRestart); - if (restartRequests.length === 1) { - await firstGate; - throw new Error("temporary CDB read failure"); - } - return 2; - }); - - const first = reconcile(false); - const second = reconcile(true); - assert.ok(releaseFirst); - releaseFirst(); - - assert.deepEqual(await Promise.allSettled([first, second]), [ - { status: "fulfilled", value: 2 }, - { status: "fulfilled", value: 2 }, - ]); - assert.deepEqual(restartRequests, [false, true]); -}); - -test("forces a clangd restart when the compilation database changes", () => { - const shouldRestart = ( - workflow as typeof workflow & { - shouldRestartClangd?: ( - configurationChanged: boolean, - interactive: boolean, - forceRestart: boolean, - ) => boolean; - } - ).shouldRestartClangd; - assert.equal(typeof shouldRestart, "function"); - assert.ok(shouldRestart); - - assert.equal(shouldRestart(false, false, true), true); - assert.equal(shouldRestart(false, false, false), false); - assert.equal(shouldRestart(true, false, false), true); - assert.equal(shouldRestart(false, true, false), true); -}); - -test("does not call clangd configuration ready when a required restart fails", () => { - const configurationReady = ( - workflow as typeof workflow & { - configurationReadyAfterRestart?: ( - restartRequired: boolean, - restartSucceeded: boolean, - ) => boolean; - } - ).configurationReadyAfterRestart; - assert.equal(typeof configurationReady, "function"); - assert.ok(configurationReady); - - assert.equal(configurationReady(false, false), true); - assert.equal(configurationReady(true, true), true); - assert.equal(configurationReady(true, false), false); -}); - -test("maps compilation database file events to reconciliation requests", () => { - type EventName = "create" | "change" | "delete"; - const registerEvents = ( - workflow as typeof workflow & { - registerCompilationDatabaseReconciliation?: ( - watcher: { - onDidCreate(listener: (event: E) => void): T; - onDidChange(listener: (event: E) => void): T; - onDidDelete(listener: (event: E) => void): T; - }, - requestReconciliation: (event: E, forceRestart: boolean) => void, - ) => T[]; - } - ).registerCompilationDatabaseReconciliation; - assert.equal(typeof registerEvents, "function"); - assert.ok(registerEvents); - - const listeners: Partial void>> = {}; - const watcher = { - onDidCreate(listener: (event: string) => void): EventName { - listeners.create = listener; - return "create"; - }, - onDidChange(listener: (event: string) => void): EventName { - listeners.change = listener; - return "change"; - }, - onDidDelete(listener: (event: string) => void): EventName { - listeners.delete = listener; - return "delete"; - }, - }; - const requests: Array<{ event: string; forceRestart: boolean }> = []; - - assert.deepEqual(registerEvents(watcher, (event: string, forceRestart: boolean) => { - requests.push({ event, forceRestart }); - }), [ - "create", - "change", - "delete", - ]); - listeners.create?.("/work/a/compile_commands.json"); - listeners.change?.("/work/b/compile_commands.json"); - listeners.delete?.("/work/c/compile_commands.json"); - - assert.deepEqual(requests, [ - { event: "/work/a/compile_commands.json", forceRestart: true }, - { event: "/work/b/compile_commands.json", forceRestart: true }, - { event: "/work/c/compile_commands.json", forceRestart: false }, - ]); -}); - -test("accepts module status only from the latest check for the current project", () => { - const createTracker = ( - workflow as typeof workflow & { - createLatestOperationTracker?: () => { - begin(key: K): number; - invalidate(key: K): void; - isCurrent(key: K, token: number): boolean; - clear(): void; - }; - } - ).createLatestOperationTracker; - const shouldRender = ( - workflow as typeof workflow & { - shouldRenderProjectStatus?: ( - currentProjectRoot: string | undefined, - statusProjectRoot: string, - ) => boolean; - } - ).shouldRenderProjectStatus; - assert.equal(typeof createTracker, "function"); - assert.equal(typeof shouldRender, "function"); - assert.ok(createTracker); - assert.ok(shouldRender); - - const tracker = createTracker(); - const first = tracker.begin("/work/a"); - const second = tracker.begin("/work/a"); - const other = tracker.begin("/work/b"); - assert.equal(tracker.isCurrent("/work/a", first), false); - assert.equal(tracker.isCurrent("/work/a", second), true); - assert.equal(tracker.isCurrent("/work/b", other), true); - - tracker.invalidate("/work/a"); - assert.equal(tracker.isCurrent("/work/a", second), false); - tracker.clear(); - assert.equal(tracker.isCurrent("/work/b", other), false); - - assert.equal(shouldRender("/work/a", "/work/a"), true); - assert.equal(shouldRender("/work/b", "/work/a"), false); - assert.equal(shouldRender(undefined, "/work/a"), false); -}); - -test("separates mcpp executable changes from clangd-only settings", () => { - assert.equal( - configurationAffectsModuleSupport((section) => section === "mcpp.clangd.path"), - true, - ); - assert.equal( - configurationAffectsModuleSupport((section) => section === "mcpp.modulesSupport"), - true, - ); - assert.equal( - configurationAffectsModuleSupport((section) => section === "mcpp.path"), - false, - ); - assert.equal( - configurationAffectsMcppExecution((section) => section === "mcpp.path"), - true, - ); - assert.equal( - configurationAffectsMcppExecution((section) => section === "mcpp.clangd.path"), - false, - ); - assert.equal(configurationAffectsModuleSupport(() => false), false); - assert.equal(configurationAffectsMcppExecution(() => false), false); -}); - -test("only executes workspace-selected tools in trusted workspaces", () => { - const allowsToolExecution = ( - workflow as typeof workflow & { - workspaceAllowsToolExecution?: (trusted: boolean) => boolean; - } - ).workspaceAllowsToolExecution; - assert.equal(typeof allowsToolExecution, "function"); - assert.ok(allowsToolExecution); - - assert.equal(allowsToolExecution(true), true); - assert.equal(allowsToolExecution(false), false); -}); - -test("only lets the current project own the shared workspace clangd", () => { - const shouldUseWorkspaceClangd = ( - workflow as typeof workflow & { - shouldUseWorkspaceClangd?: ( - currentProjectRoot: string | undefined, - projectRoot: string, - ) => boolean; - } - ).shouldUseWorkspaceClangd; - assert.equal(typeof shouldUseWorkspaceClangd, "function"); - assert.ok(shouldUseWorkspaceClangd); - - assert.equal(shouldUseWorkspaceClangd("/work/a", "/work/a"), true); - assert.equal(shouldUseWorkspaceClangd("/work/b", "/work/a"), false); - assert.equal(shouldUseWorkspaceClangd(undefined, "/work/a"), false); -}); - -test("only runs automatic module checks for configured LLVM source commands", () => { - const shouldCheck = ( - workflow as typeof workflow & { - shouldCheckModuleSupport?: ( - capability: "full" | "syntax-only" | "unavailable", - configured: boolean, - sourceFile?: string, - ) => boolean; - } - ).shouldCheckModuleSupport; - assert.equal(typeof shouldCheck, "function"); - assert.ok(shouldCheck); - - assert.equal(shouldCheck("full", true, "/work/src/main.cpp"), true); - assert.equal(shouldCheck("full", false, "/work/src/main.cpp"), false); - assert.equal(shouldCheck("full", true), false); - assert.equal(shouldCheck("syntax-only", true, "/work/src/main.cpp"), false); - assert.equal(shouldCheck("unavailable", true, "/work/src/main.cpp"), false); -}); - -test("maps clangd check classifications to module availability", () => { - const moduleSupportState = ( - workflow as typeof workflow & { - moduleSupportState?: (classification: string) => "available" | "unavailable"; - } - ).moduleSupportState; - assert.equal(typeof moduleSupportState, "function"); - assert.ok(moduleSupportState); - - assert.equal(moduleSupportState("ready"), "available"); - assert.equal(moduleSupportState("module-unavailable"), "unavailable"); - assert.equal(moduleSupportState("wrong-language-mode"), "unavailable"); -}); From 4940647510a4cb92d01de6877235318039010422 Mon Sep 17 00:00:00 2001 From: wellwei Date: Fri, 25 Sep 2026 21:59:31 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test:=20E2E=20=E8=AF=AD=E8=A8=80=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E4=BE=9D=E8=B5=96=20stub=20=E7=94=B1=20clangd=20?= =?UTF-8?q?=E6=8D=A2=E6=88=90=20mcppls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extension Host 冒烟测试原本安装 clangd stub,与新依赖不符。改为安装 sunrisepeak.mcpp-language-server stub,并断言真实桥接行为: - stub 注册 mcppls.restartServer / selectContext / showModuleGraph / showLogs, 只记录调用、不启动 LSP、不改配置 - E2E 验证扩展激活、依赖扩展同时安装、build 后触发 restart, 以及三个公开转发命令各调用一次 - 运行时 VS Code 版本对齐到 1.91.0 npm run test:e2e 通过。 --- test/e2e/fixtures/clangd-stub/extension.js | 9 -- test/e2e/fixtures/clangd-stub/package.json | 9 -- test/e2e/fixtures/mcppls-stub/extension.js | 27 ++++++ test/e2e/fixtures/mcppls-stub/package.json | 14 +++ test/e2e/runTest.ts | 16 ++-- test/e2e/suite/extension.test.ts | 101 ++++++++++----------- 6 files changed, 96 insertions(+), 80 deletions(-) delete mode 100644 test/e2e/fixtures/clangd-stub/extension.js delete mode 100644 test/e2e/fixtures/clangd-stub/package.json create mode 100644 test/e2e/fixtures/mcppls-stub/extension.js create mode 100644 test/e2e/fixtures/mcppls-stub/package.json diff --git a/test/e2e/fixtures/clangd-stub/extension.js b/test/e2e/fixtures/clangd-stub/extension.js deleted file mode 100644 index 97cc7ef..0000000 --- a/test/e2e/fixtures/clangd-stub/extension.js +++ /dev/null @@ -1,9 +0,0 @@ -const vscode = require("vscode"); - -function activate(context) { - context.subscriptions.push( - vscode.commands.registerCommand("clangd.restart", () => undefined), - ); -} - -module.exports = { activate }; diff --git a/test/e2e/fixtures/clangd-stub/package.json b/test/e2e/fixtures/clangd-stub/package.json deleted file mode 100644 index 19a5588..0000000 --- a/test/e2e/fixtures/clangd-stub/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "vscode-clangd", - "displayName": "clangd stub", - "version": "0.0.0", - "publisher": "llvm-vs-code-extensions", - "engines": { "vscode": "^1.90.0" }, - "main": "./extension.js", - "activationEvents": ["onCommand:clangd.restart"] -} diff --git a/test/e2e/fixtures/mcppls-stub/extension.js b/test/e2e/fixtures/mcppls-stub/extension.js new file mode 100644 index 0000000..3f7f3c1 --- /dev/null +++ b/test/e2e/fixtures/mcppls-stub/extension.js @@ -0,0 +1,27 @@ +const fs = require("node:fs"); +const vscode = require("vscode"); + +function record(command) { + const logPath = process.env.MCPP_E2E_MCPPLS_LOG; + if (typeof logPath === "string") { + fs.appendFileSync(logPath, `${command}\n`); + } +} + +function activate(context) { + for (const command of [ + "mcppls.restartServer", + "mcppls.selectContext", + "mcppls.showModuleGraph", + "mcppls.showLogs", + ]) { + context.subscriptions.push( + vscode.commands.registerCommand(command, () => { + record(command); + return undefined; + }), + ); + } +} + +module.exports = { activate }; diff --git a/test/e2e/fixtures/mcppls-stub/package.json b/test/e2e/fixtures/mcppls-stub/package.json new file mode 100644 index 0000000..177b905 --- /dev/null +++ b/test/e2e/fixtures/mcppls-stub/package.json @@ -0,0 +1,14 @@ +{ + "name": "mcpp-language-server", + "displayName": "C++ Modules Language Server stub", + "version": "0.0.0", + "publisher": "sunrisepeak", + "engines": { "vscode": "^1.91.0" }, + "main": "./extension.js", + "activationEvents": [ + "onCommand:mcppls.restartServer", + "onCommand:mcppls.selectContext", + "onCommand:mcppls.showModuleGraph", + "onCommand:mcppls.showLogs" + ] +} diff --git a/test/e2e/runTest.ts b/test/e2e/runTest.ts index e7d5699..1ec677a 100644 --- a/test/e2e/runTest.ts +++ b/test/e2e/runTest.ts @@ -12,23 +12,24 @@ async function main(): Promise { const extensionsDir = join(tempRoot, "extensions"); const workspaceDir = join(tempRoot, "project"); const fakeMcpp = join(tempRoot, "mcpp"); - const logPath = join(tempRoot, "mcpp.log"); - const clangdStub = join(extensionsDir, "llvm-vs-code-extensions.vscode-clangd-0.0.0"); + const mcppLogPath = join(tempRoot, "mcpp.log"); + const mcpplsLogPath = join(tempRoot, "mcppls.log"); + const mcpplsStub = join(extensionsDir, "sunrisepeak.mcpp-language-server-0.0.0"); mkdirSync(userDataDir, { recursive: true }); mkdirSync(extensionsDir, { recursive: true }); mkdirSync(workspaceDir, { recursive: true }); - mkdirSync(clangdStub, { recursive: true }); + mkdirSync(mcpplsStub, { recursive: true }); copyFileSync(join(fixtureRoot, "fake-mcpp.js"), fakeMcpp); chmodSync(fakeMcpp, 0o755); - copyFileSync(join(fixtureRoot, "clangd-stub/package.json"), join(clangdStub, "package.json")); - copyFileSync(join(fixtureRoot, "clangd-stub/extension.js"), join(clangdStub, "extension.js")); + copyFileSync(join(fixtureRoot, "mcppls-stub/package.json"), join(mcpplsStub, "package.json")); + copyFileSync(join(fixtureRoot, "mcppls-stub/extension.js"), join(mcpplsStub, "extension.js")); copyFileSync(join(fixtureRoot, "project/mcpp.toml"), join(workspaceDir, "mcpp.toml")); copyFileSync(join(fixtureRoot, "project/main.cpp"), join(workspaceDir, "main.cpp")); try { await runTests({ - version: "1.90.2", + version: "1.91.0", extensionDevelopmentPath: repositoryRoot, extensionTestsPath: join(repositoryRoot, "dist/test/e2e/suite/index.js"), launchArgs: [ @@ -43,7 +44,8 @@ async function main(): Promise { ], extensionTestsEnv: { MCPP_E2E_FAKE_MCPP: fakeMcpp, - MCPP_E2E_LOG: logPath, + MCPP_E2E_LOG: mcppLogPath, + MCPP_E2E_MCPPLS_LOG: mcpplsLogPath, }, }); } finally { diff --git a/test/e2e/suite/extension.test.ts b/test/e2e/suite/extension.test.ts index 3f211cb..3dcbe28 100644 --- a/test/e2e/suite/extension.test.ts +++ b/test/e2e/suite/extension.test.ts @@ -1,77 +1,68 @@ import assert from "node:assert/strict"; -import { existsSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; +async function waitForFile(file: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (existsSync(file)) { + const content = readFileSync(file, "utf8"); + if (content.trim().length > 0) { + return content; + } + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); + } + return existsSync(file) ? readFileSync(file, "utf8") : ""; +} + suite("mcpp extension smoke", () => { - test("configures a missing CDB once before executing a full build", async () => { + test("activates, registers commands, builds and refreshes C++ Modules", async () => { const extension = vscode.extensions.getExtension("mcpp-community.mcpp-vscode"); assert.ok(extension, "mcpp extension should be installed in development host"); await extension.activate(); + const languageServer = vscode.extensions.getExtension("sunrisepeak.mcpp-language-server"); + assert.ok(languageServer, "mcppls dependency should be installed next to the extension"); + await languageServer.activate(); + const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("mcpp.build")); - assert.ok(commands.includes("mcpp.autoConfigureModules")); + for (const command of [ + "mcpp.build", + "mcpp.autoConfigureModules", + "mcpp.configureLanguageServer", + "mcpp.configureClangd", + "mcpp.refreshCompilationDatabase", + "mcpp.checkModuleSupport", + "mcpp.showModuleGraph", + "mcpp.showLanguageServerLogs", + ]) { + assert.ok(commands.includes(command), `missing command: ${command}`); + } const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; assert.ok(workspaceFolder, "fixture workspace should be open"); const fakeMcpp = process.env.MCPP_E2E_FAKE_MCPP; - const logPath = process.env.MCPP_E2E_LOG; + const mcppLog = process.env.MCPP_E2E_LOG; + const mcpplsLog = process.env.MCPP_E2E_MCPPLS_LOG; assert.ok(fakeMcpp); - assert.ok(logPath); + assert.ok(mcppLog); + assert.ok(mcpplsLog); await vscode.workspace.getConfiguration("mcpp", workspaceFolder.uri) .update("path", fakeMcpp, vscode.ConfigurationTarget.Workspace); - const configureDeadline = Date.now() + 15_000; - let invocations: string[] = []; - while (Date.now() < configureDeadline) { - if (existsSync(logPath)) { - invocations = readFileSync(logPath, "utf8").trim().split("\n"); - if (invocations.includes("build --configure-only")) { - break; - } - } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); - } - assert.ok(existsSync(logPath), "fake mcpp should have been invoked"); + void vscode.commands.executeCommand("mcpp.build"); + assert.equal((await waitForFile(mcppLog, 15_000)).trim(), "build"); + assert.equal((await waitForFile(mcpplsLog, 15_000)).trim(), "mcppls.restartServer"); - await vscode.commands.executeCommand("mcpp.build"); - const buildDeadline = Date.now() + 15_000; - while (Date.now() < buildDeadline) { - invocations = readFileSync(logPath, "utf8").trim().split("\n"); - if (invocations.includes("build")) { - break; - } - await new Promise((resolvePromise) => setTimeout(resolvePromise, 100)); - } - assert.deepEqual(invocations, ["build --configure-only", "build"]); - assert.ok(existsSync(path.join(workspaceFolder.uri.fsPath, "compile_commands.json"))); + await vscode.commands.executeCommand("mcpp.configureClangd"); + await vscode.commands.executeCommand("mcpp.showModuleGraph"); + await vscode.commands.executeCommand("mcpp.showLanguageServerLogs"); + assert.deepEqual( + (await waitForFile(mcpplsLog, 15_000)).trim().split("\n"), + ["mcppls.restartServer", "mcppls.selectContext", "mcppls.showModuleGraph", "mcppls.showLogs"], + ); assert.equal(path.basename(workspaceFolder.uri.fsPath), "project"); - - const compilationDatabase = path.join(workspaceFolder.uri.fsPath, "compile_commands.json"); - const executeWithinDeadline = async (command: string): Promise => { - let timer: NodeJS.Timeout | undefined; - try { - await Promise.race([ - vscode.commands.executeCommand(command), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`${command} did not finish`)), 5_000); - }), - ]); - } finally { - if (timer !== undefined) { - clearTimeout(timer); - } - } - }; - - rmSync(compilationDatabase); - await executeWithinDeadline("mcpp.refreshCompilationDatabase"); - assert.ok(existsSync(compilationDatabase), "refresh should recreate a missing CDB"); - - rmSync(compilationDatabase); - await executeWithinDeadline("mcpp.configureClangd"); - await executeWithinDeadline("mcpp.checkModuleSupport"); - await executeWithinDeadline("mcpp.autoConfigureModules"); }); }); From 3403deb3b7373d234f6e2d7d73fb2f0e7ffed7d1 Mon Sep 17 00:00:00 2001 From: wellwei Date: Fri, 25 Sep 2026 22:00:11 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E9=87=8D=E5=86=99=20README=20?= =?UTF-8?q?=E4=B8=8E=E6=9B=B4=E6=96=B0=E6=97=A5=E5=BF=97=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=AE=A1=E5=88=92=E4=B8=8E=20CI=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README 改为按职责边界组织:mcpp-vscode 负责工程发现、构建/运行/测试、 工具链与 mcpp.toml;C++ 模块语义交给 mcppls;不再描述 clangd 配置流程 - 记录升级要点:新依赖、VS Code 1.91 下限、mcpp.path 作用域限制、 旧 clangd 设置不再生效、darwin-x64 暂无上游平台包 - CHANGELOG 增加 0.4.0 未发布段落,说明依赖切换、旧层删除与一键流程变更 - 新增迁移实现计划文档,含冻结决策、门禁、9 个任务与执行状态 - CI 增加扩展依赖声明检查,以及“不得引入第二个语言客户端”的打包检查 npm run package 通过,VSIX 归档校验通过。 --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 18 + README.md | 501 ++++--------- ...26-09-25-mcpp-language-server-migration.md | 669 ++++++++++++++++++ 4 files changed, 840 insertions(+), 354 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-25-mcpp-language-server-migration.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330891c..dae166a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: - run: npm run package - name: Verify VSIX archive run: unzip -t mcpp-vscode-*.vsix + - name: Verify extension dependency is declared + run: node -e 'const p=require("./package.json"); if (p.extensionDependencies?.[0] !== "sunrisepeak.mcpp-language-server") process.exit(1)' extension-host-e2e: runs-on: ubuntu-latest @@ -39,3 +41,7 @@ jobs: - run: npm ci - name: Run Extension Host smoke tests run: xvfb-run -a npm run test:e2e + - name: Check packaged extension is not a second language client + run: | + test ! -e dist/src/languageClient.js + ! rg -n 'vscode-languageclient|mcppls\.serve' dist/src package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 831d24c..c439cbda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,24 @@ 显式刷新会分别报告 CDB 生成与 clangd 协调结果。 - 对齐 mcpp #387 的最终 workspace 契约:virtual workspace 根不作为单一 clangd 工程, 扩展消费当前活动 member 根的 CDB;rooted workspace 仍按根 package 处理。 +## 0.4.0(未发布) + +### Changed + +- 将 C++ 模块语言服务依赖从官方 `llvm-vs-code-extensions.vscode-clangd` 切换为 + `sunrisepeak.mcpp-language-server`(C++ Modules Language Server / mcppls);VS Code 最低版本 + 提升到 1.91。 +- 删除本扩展中的 clangd 解析、匹配、配置、PCM 检查、CDB 监听和自动重启逻辑。mcpp-vscode + 不再启动第二个 LSP 客户端,也不再打包语言服务 payload。 +- mcpp build 完成后只通过 mcppls 公开命令刷新语言服务;新增模块图、语言服务日志和分析 + 上下文转发命令。 +- 一键模块配置改为一次确认后执行 `mcpp build` 并刷新 mcppls,不再安装 llvm-tools、切换 + LLVM 全局默认或修改 clangd 配置。 + +### Fixed + +- 移除 mcpp 已删除的 `[xlings.envs]` manifest 补全,并将 `[xlings.workspace]` 示例版本更新为 + 当前索引可用的 Node 条目。 ## 0.2.7 diff --git a/README.md b/README.md index b96ae2a..31cd324 100644 --- a/README.md +++ b/README.md @@ -4,427 +4,220 @@ # mcpp VS Code 扩展 -把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 +把 mcpp 工程的构建、运行、测试、工具链管理和 `mcpp.toml` 编辑支持接入 VS Code。 -当前版本为 `0.3.1`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 -mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 +当前版本为 `0.4.0`。C++20/23 模块的诊断、补全、跳转、引用、状态和模块图由 +[`sunrisepeak.mcpp-language-server`](https://github.com/Sunrisepeak/mcpp-language-server) +(显示名为 **C++ Modules Language Server**,下文简称 **mcppls**)提供。本扩展不启动第二个 +LSP 客户端,也不配置用户安装的 clangd。 -> 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 -> 模块语法高亮、mcpp 命令和工具链管理,但插件不为其提供模块诊断、悬停或跳转。 +> mcppls 会在自己的 payload 中携带固定版本的语义引擎;这不表示 mcpp-vscode 仍依赖官方 +> `llvm-vs-code-extensions.vscode-clangd` 扩展,也不表示本扩展读取用户的 `clangd.*` 设置。 -## 能力矩阵 +## 职责边界 -| 能力 | LLVM/Clang | GCC | MSVC | -| --- | --- | --- | --- | -| 识别 mcpp 工程 | 支持 | 支持 | 支持 | -| `mcpp.toml` TOML 语法高亮 | 支持 | 支持 | 支持 | -| `mcpp.toml` 结构补全(段头 + 写法模板) | 支持 | 支持 | 支持 | -| `build.mcpp`、`.cppm`、`.ixx`、`.mpp`、`.ccm` 文件关联 | 支持 | 支持 | 支持 | -| `module`、`export module`、`import` 语法高亮 | 支持 | 支持 | 支持 | -| 构建、运行、测试、清理命令 | 支持 | 支持 | 支持 | -| 查看、安装或选择 mcpp 工具链 | 支持 | 支持 | 系统检测与选择 | -| 自动配置语言服务 | clangd | 不支持 | 不支持 | -| 消费模块产物 | `.pcm` | 不读取 `.gcm` | 不读取 `.ifc` | -| 模块诊断、悬停、补全和跳转 | 实验性支持 | 不支持 | 不支持 | - -这里的“GCC/MSVC 不支持”只表示插件当前没有对应的模块语义后端,不表示 mcpp -不能使用这些工具链构建项目。mcpp 可以生成 GCC `.gcm` 和 MSVC `.ifc`,但 clangd -不能直接消费这些编译器专用格式。 +| 能力 | 负责方 | +| --- | --- | +| mcpp 工程发现、`mcpp.toml` 语法与结构补全 | mcpp-vscode | +| build / run / test / clean 任务 | mcpp-vscode | +| mcpp 工具链查看、安装、选择和项目操作 | mcpp-vscode | +| C++ 模块诊断、补全、悬停、定义、引用 | mcppls | +| C++ Modules 状态、模块图、分析上下文和语言服务日志 | mcppls | +| 语言服务冲突与其他 C++ 扩展的管理 | mcppls | ## 安装 -从 [GitHub Releases](https://github.com/mcpp-community/mcpp-vscode/releases) 下载对应版本的 +从 [GitHub Releases](https://github.com/mcpp-community/mcpp-vscode/releases) 下载本扩展的 VSIX,然后在 VS Code 中执行 **Extensions: Install from VSIX...**,或者运行: ```sh -code --install-extension /path/to/mcpp-vscode-0.3.1.vsix +code --install-extension /path/to/mcpp-vscode-0.4.0.vsix +``` + +本扩展在 `package.json` 中声明了 `sunrisepeak.mcpp-language-server` 依赖。安装后确认当前 +VS Code profile 中同时存在: + +```text +mcpp-community.mcpp-vscode +sunrisepeak.mcpp-language-server ``` -安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 -`llvm-vs-code-extensions.vscode-clangd`。当前发布流程不会自动发布到 VS Marketplace。 +当前 mcppls 发布包支持 `linux-x64`、`linux-arm64`、`darwin-arm64` 和 `win32-x64`。 +在这些平台之外(例如 `darwin-x64`)先确认上游已提供对应 VSIX;本扩展不会把不匹配的 +payload 伪装成可用。 ## 使用条件 -- VS Code 1.90 或更高版本。 -- 包含 `mcpp.toml` 的 mcpp 工程。 -- 包含 mcpp #387(合并提交 `3f237ed`)的可执行文件;在该能力进入正式版本前, - 可使用对应主线构建。旧版 mcpp 不支持 configure-only 时,扩展会保留原有 CDB 并提示升级。 - 它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 -- 官方 `llvm-vs-code-extensions.vscode-clangd` 扩展。它是本扩展的功能依赖。 -- 要启用模块语义,需要 LLVM mcpp 工具链、工程编译数据库,以及与该工具链来自 - 同一 LLVM revision 的 clangd。 +- VS Code `1.91` 或更高版本。 +- 包含 `mcpp.toml` 的 mcpp 工程,或从 mcpp-vscode 的新建工程命令创建工程。 +- 可执行的 `mcpp`。它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 +- mcppls 扩展及其对应平台包。mcppls 自己负责语言服务进程和语义 payload。 -安装来源彼此独立:mcpp、构建编译器和 clangd 不必位于同一个目录,也不要求都由 -xlings 安装。 +`mcpp.path` 只控制 **mcpp-vscode 执行的 mcpp CLI 命令**。它不会跨扩展写入 mcppls 的 +启动参数;如果 mcppls 从 PATH 找到的 mcpp 与该设置不同,请分别配置并查看两个输出频道。 ## 快速开始 -1. 使用 VS Code 打开包含 `mcpp.toml` 的目录,并按需信任工作区。 -2. 工程没有有效 `compile_commands.json` 时,扩展会运行 - `mcpp build --configure-only` 生成配置阶段数据,不编译普通对象或链接产物。 -3. 扩展会分析编译数据库、选择匹配的 clangd、写入工作区配置并检查模块支持;缺少 - 项目 PCM 或需要验证正式产物时,仍应执行完整构建或使用一键配置向导。 -4. 查看状态栏中的 `mcpp: 模块可用`、`mcpp: 模块不可用` 或 - `mcpp: 缺少模块 CDB` 状态。 - -工程还没有编译数据库时,可以点击“缺少模块 CDB”,或执行 -**mcpp: 刷新编译数据库**。这个命令执行 `mcpp build --configure-only`,只在退出码为 -0 且新 CDB 可解析时将其视为成功;失败时继续保留并使用原有可用 CDB。 +1. 使用 VS Code 打开包含 `mcpp.toml` 的目录。 +2. 打开 C++ 源文件,观察 C++ Modules 语言状态项;它由 mcppls 显示当前工程、工具链和降级原因。 +3. 执行 **mcpp: 构建**,或在终端运行 `mcpp build`。 +4. 构建任务结束后,mcpp-vscode 请求 mcppls 重启并重新读取构建描述。 -virtual workspace 根不对应单一 CDB:mcpp 会把数据库分别发布到各 member 根。扩展 -因此只接管当前活动 member;请先打开该 member 的源码或 `mcpp.toml`,不会把多个 member -的 CDB 合并到虚拟根。 +打开工程本身不会静默执行 `mcpp build`,也不会下载或切换工具链。需要刷新模块语义时, +使用 **mcpp: 刷新模块构建描述** 或 **mcpp: 一键构建并刷新模块语言服务**。 ## 已实现功能 ### 工程发现与自动激活 -- 工作区包含 `mcpp.toml`,或者打开 C++ 文件时激活扩展。 -- 从活动文件或工作区目录查找最近的 mcpp 工程。 -- 监听 `mcpp.toml`、`compile_commands.json`、活动编辑器、工作区和相关设置变化。 -- 打开已有有效 CDB 的 LLVM 工程后自动配置并检查,不需要先点击状态栏或 Reload - Window。 - -扩展激活不会静默运行完整 `mcpp build`,也不会自动下载、安装或切换工具链;受信任 -工程缺少有效 CDB 时会自动运行配置阶段的 `mcpp build --configure-only`。 - -### 语法高亮 - -- 将精确文件名 `mcpp.toml` 识别为独立的 mcpp TOML 语言,内置表、键、字符串、数字、 - 布尔、日期时间和注释高亮。 -- 将精确文件名 `build.mcpp` 识别为独立的 mcpp build language,并复用 C++ TextMate - grammar;`.cppm`、`.ixx`、`.mpp` 和 `.ccm` 继续关联到 VS Code 内置 `cpp` 语言。 -- 向 C++ 源文件注入模块语法规则。 -- 覆盖 `module`、`export module`、`import`、`export import`、模块名和模块分区。 -- 支持尚未输入分号的编辑中间态。 - -语法高亮只负责词法着色。错误模块名、不可见声明等红色诊断来自语言服务器,不能由 -TextMate 语法规则提供。 - -`build.mcpp` 是由 mcpp 宿主执行的构建脚本,不是普通 C++ 翻译单元。当前扩展只为它 -提供 C++ 风格的语法高亮和编辑器括号/注释规则,不启动 clangd 语义诊断;因此 -`import mcpp;` 不会再被误报为缺少 C++ 模块。真正的 mcpp API 补全需要 mcpp 核心 -未来生成宿主 helper 的 CDB 和 PCM 映射。 - -### mcpp.toml 结构补全 - -- 段头补全:26 个段(`[package]`、`[targets.]`、`[dependencies]`、`[build-dependencies]`、 - `[features]`、`[indices]`、`[pack]` 等),参数化段带可跳转占位符。 -- 写法模板:依赖段的依赖写法(版本 / 路径 / git / features / tools)、`[features]` 表形式、 - capabilities / xlings / tools.overrides / generated_files 等开放段的条目形态。 -- 每条建议携带显式替换范围,部分输入(`[dep`、`na`)不会残留无效文本;所有语义规则有真实 - mcpp 契约测试(44 例,无 mcpp 环境自动跳过)。 -- 范围边界:不提供静态字段键/枚举(等上游版本化 manifest schema),不提供依赖包名/版本候选 - (等上游批量 catalog 接口);未知自定义段与 `[[...]]` 数组表不提供建议。 -- 由 `mcpp.tomlCompletion` 设置控制(默认开启);未受信任工作区仅做纯文本分析,不执行任何 - 外部程序。 - -### LLVM 与 clangd 集成 - -扩展读取 mcpp 生成的 `compile_commands.json`,然后: - -1. 识别其中的 LLVM 编译器及待检查源文件。 -2. 从显式设置、编译器目录、匹配的 xlings `llvm-tools` 目录和 `PATH` 中查找 clangd。 -3. 执行编译器和 clangd 的版本命令,比较 LLVM 版本与 revision。 -4. 保留用户已有的 `clangd.arguments`,展开 `${workspaceFolder}` 和 - `${workspaceRoot}`;只有在 CDB 未提供 `--no-default-config`、`-nostdinc++` 和显式 - libc++ 路径时,才添加精确的 `--query-driver`。 -5. 根据 CDB 中是否已有显式 PCM 映射以及 `mcpp.modulesSupport` 设置,决定是否增加 - clangd 实验模块参数。 -6. 必要时重启官方 clangd 扩展,并执行一次最长 60 秒的 `clangd --check`。 - -检查可以区分模块可用、PCM 不匹配、模块产物不可用、语言模式错误和一般检查失败。 -完整命令及输出会写入 `mcpp` 输出频道。 - -**mcpp: 一键配置模块代码提示** 会先读取工具链状态,再显示一次模态确认。确认后, -它可以自动安装 host LLVM、设置 mcpp 全局默认、执行 `mcpp build`,并通过已安装的 -xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB、配置 clangd 和检查 -模块状态,无需 Reload Window。项目在 `mcpp.toml` 或 target 中显式固定 GCC/MSVC 时, -一键流程会停止并提示手动处理,绝不修改项目配置或伪装成全局切换成功。 - -### 自动协调与多根工作区 - -- CDB 创建或变化后自动重新分析、配置、重启和检查;删除后更新为缺少 CDB 状态。 -- 同一工程的重复事件通过 singleflight 合并,共享的 clangd 操作全局串行。 -- 只接受当前工程最新一次模块检查的结果,迟到结果不会覆盖新状态。 -- 多根工作区中只有活动 mcpp 工程接管窗口级 clangd 配置;后台工程只失效缓存,切换 - 过去时再协调。 -- 修改资源域 `mcpp.path` 时按工程标记重新生成 CDB;修改 `mcpp.clangd.path` 或 - `mcpp.modulesSupport` 时只重新配置和检查,不重复运行 mcpp。 -- 完整构建即使因普通源码错误退出,只要留下可用 CDB,扩展仍会尝试恢复 IDE 状态,并把 - “构建失败”和“IDE 数据可用”分别报告。 - -### mcpp CLI 与工具链管理 - -状态栏中的 `$(tools) mcpp: 快捷菜单` 提供: - -- 在当前工程根目录执行 `mcpp build`、`run`、`test` 和 `clean`。 -- 在 VS Code 任务终端中实时显示完整输出。 -- 同一工程同时只运行一个由扩展启动的项目任务。 -- 项目任务结束后先释放任务锁,再重新协调 CDB 和 clangd。 -- 清理当前工程的 `target/`;不会删除 mcpp 全局 BMI 缓存。 -- 读取并展示 `mcpp toolchain list` 的当前有效工具链、全局默认、系统工具链、target - 状态和可安装版本。 -- 安装 mcpp 支持的工具链 spec,或从已安装/系统项中选择全局默认工具链。 - -安装和修改默认值都需要确认。取消操作不会被当成成功,工具链安装、默认值修改和构建 -共享一个全局互斥锁;一键配置只在开始处确认一次,之后不会弹出工具链选择或“是否刷新 -编译数据库”的二次询问。 +- 工作区包含 `mcpp.toml`,或打开 C/C++ 文件时激活。 +- 从活动文件或工作区目录查找最近的 mcpp 工程,并支持工作区成员边界。 +- 监听 `mcpp.toml` 创建、修改和删除,更新当前工程的快捷菜单与运行/测试按钮。 +- 不再监听或解析 `compile_commands.json`,也不再替 mcppls 重启或配置 clangd。 +- 只有用户主动执行 mcpp 任务时才启动 mcpp;任务结束后仅对 build 请求 mcppls 刷新。 -## 命令 +### 语法高亮与 `mcpp.toml` -| 命令 | 行为 | -| --- | --- | -| **mcpp: 打开快捷菜单** | 打开项目、工具链和 IDE 操作入口 | -| **mcpp: 构建** | 在当前工程执行 `mcpp build` | -| **mcpp: 运行** | 在当前工程执行 `mcpp run` | -| **mcpp: 测试** | 在当前工程执行 `mcpp test` | -| **mcpp: 清理 target** | 确认后执行 `mcpp clean` | -| **mcpp: 查看工具链** | 只读展示 `mcpp toolchain list` 的解析结果和原始输出 | -| **mcpp: 安装工具链** | 确认后执行 `mcpp toolchain install ` | -| **mcpp: 选择全局默认工具链** | 确认后执行 `mcpp toolchain default ` | -| **mcpp: 配置 clangd** | 手动重新应用当前 LLVM 工程的 clangd 配置 | -| **mcpp: 刷新编译数据库** | 执行 `mcpp build --configure-only`,验证 CDB 后协调 clangd;失败时保留原有可用 CDB | -| **mcpp: 检查模块支持** | 立即执行 clangd 直接检查并刷新模块状态 | -| **mcpp: 一键配置模块代码提示** | 一次确认后自动安装/切换 host LLVM、执行 `mcpp build`,通过 xlings 补齐匹配 llvm-tools,重载 CDB 并配置 clangd;项目显式固定非 LLVM 工具链时停止 | +- 将精确文件名 `mcpp.toml` 识别为 mcpp TOML 语言。 +- 将精确文件名 `build.mcpp` 识别为 mcpp build language,并复用 C++ TextMate grammar。 +- `.cppm`、`.ixx`、`.mpp`、`.ccm` 文件继续关联为 C++。 +- 注入 `module`、`export module`、`import`、模块名和模块分区语法。 +- 为 `mcpp.toml` 提供段头和写法模板补全;建议只覆盖结构,不猜测动态依赖版本。 +- 补全清单与当前 mcpp 契约测试同步;已被新版 mcpp 删除的 `[xlings.envs]` 段不再建议。 -## 设置 +语法高亮只负责词法着色。错误模块名、不可见声明和跨模块引用诊断由 mcppls 提供。 -```json -{ - "mcpp.path": "/path/to/mcpp", - "mcpp.clangd.path": "/path/to/matching/clangd", - "mcpp.modulesSupport": "auto", - "mcpp.configureCppTools": true, - "mcpp.tomlCompletion": true -} -``` +### 构建、运行与工具链 -| 设置 | 默认值 | 说明 | -| --- | --- | --- | -| `mcpp.path` | 空 | mcpp 可执行文件;空值表示从 VS Code 进程的 `PATH` 查找 | -| `mcpp.clangd.path` | 空 | 与 CDB 中 LLVM 编译器匹配的 clangd;空值表示自动发现 | -| `mcpp.modulesSupport` | `auto` | `auto`、`on` 或 `off`,控制 clangd 实验模块参数 | -| `mcpp.configureCppTools` | `true` | 手动配置 clangd 时,是否询问关闭当前工作区的 cpptools IntelliSense | -| `mcpp.tomlCompletion` | `true` | 为 `mcpp.toml` 提供结构补全:段头与写法模板(snippet);所有建议带显式替换范围,并经真实 mcpp 契约测试验证 | - -`mcpp.path` 只影响插件执行 mcpp CLI 命令。工程实际编译器来自 -`compile_commands.json`,`mcpp.clangd.path` 只指定语言服务器,三者相互独立。 - -`mcpp.modulesSupport=auto` 检测到 CDB 已含 `-fmodule-file` 或 -`-fprebuilt-module-path` 时,直接使用 mcpp 生成的 PCM;否则只为 clangd 21 及以上 -请求实验模块构建。`on` 强制请求该参数,`off` 始终不请求。这个设置不能生成缺失的 -PCM。 - -## 工具链管理边界 - -- mcpp 的规范工具链 family 是 `gcc`、`llvm` 和 `msvc`;target 是独立的 - `arch-os[-env]` 维度。 -- 安装入口接受 family、`family@version`、namespace、部分版本及 mcpp 兼容旧拼写, - 例如 `gcc`、`llvm@20.1.7`、`xim:gcc@16`、`msvc` 和 `mingw`。插件只做单参数 - 规范化,最终合法性和具体版本由 mcpp 判断。 -- 当前 UI 只读展示 target,不提供结构化 `--target` 选择器。target 专用默认值继续 - 使用 mcpp CLI 设置。 -- 选择全局默认值会修改 mcpp 的全局配置;项目或工作区的 `[toolchain]`、 - `[build].target` 和命令行参数仍可按 mcpp 规则覆盖它。 -- MSVC 是 Windows 系统工具链。mcpp 负责检测和构建,不负责下载、升级或删除 Visual - Studio;缺失时只显示官方安装指引。 -- 0.0.90 起 native `cl.exe` 后端支持 `import std`、命名模块、`.ifc`、增量构建和 - 运行。这是 mcpp 的构建能力,不代表当前插件或 clangd 能读取 `.ifc`。 -- 当 mcpp 工程位于 VS Code 工作区目录的子目录时,插件不会仅凭路径关系判断它一定是 - mcpp 工作区成员;实际工具链以 mcpp 在该目录中的解析结果为准。 - -## 当前限制与责任边界 - -### 模块语义仅支持 LLVM - -GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它们,但 clangd -不能直接消费;当前版本没有 GCC 原生模块语言服务器、cpptools 模块后端或 LLVM 影子 -分析副本。因此 GCC/MSVC 工程只保证模块语法高亮和 mcpp 操作。 - -### 首次语义配置仍需要 mcpp 生成的数据 - -当前插件只读取 `compile_commands.json`,不解析 `mcpp.toml`,也不自行推导依赖和 -模块图。受信任工程没有有效 CDB 时,插件会调用 `mcpp build --configure-only`,但不会 -自行生成或改写 mcpp 没有输出的 CDB。完整模块语义仍取决于 CDB 引用的兼容 PCM;正式 -构建、链接和测试仍由普通 `mcpp build`、`run` 或 `test` 负责。 - -### clangd 模块支持仍是实验能力 - -- CDB 必须包含正确语言标准、编译器路径和有效 PCM 映射。 -- clangd、mcpp LLVM 编译器、C++ 标准库和 PCM 应来自同一个 LLVM 构建;只匹配主版本 - 或版本号并不足够。 -- 语法颜色正常不代表语义诊断可用,`clangd --check` 成功也不替代完整构建和测试。 -- clangd 负责编辑期诊断、悬停、补全和跳转;链接、代码生成、运行期和打包错误仍由 - `mcpp build`、`run` 或 `test` 报告。 -- 当前扩展不调用 `clang-tidy` 或 `clang-format`,缺少它们不会影响本扩展的模块检查。 - -### 工作区信任与外部操作 - -未受信任工作区只启用文件关联和语法高亮,不执行 CDB 中的编译器、mcpp、clangd 或 -工程指定程序,也不接管 clangd 配置。授予信任后,扩展会自动重新协调当前工程;仍不 -会在没有用户操作时下载或切换工具链,也不会发起完整构建;缺少有效 CDB 时可以执行 -配置阶段的 configure-only。该命令不是只读操作:mcpp 仍可能执行 `build.mcpp`、解析或 -安装缺失依赖和工具链,并写入 lock、构建目录与相关元数据。 - -## 当前版本能达到的效果 - -在 LLVM 工程已经具有有效 CDB、完整 PCM 映射和匹配 clangd 的前提下,当前版本可以: - -- 打开工程后自动恢复 clangd 配置和模块状态; -- 由 clangd 对错误导入、不可用模块及跨模块声明提供诊断; -- 由 clangd 提供悬停、补全、定义跳转、引用查找和后台索引; -- 在重新构建或 CDB 变化后自动刷新,不要求 Reload Window; -- 通过 VS Code 任务完成常用 mcpp 项目和工具链操作。 - -这些能力受 clangd 实验模块实现和 PCM 兼容性约束。工程没有 CDB 时,扩展可以请求 -mcpp 生成配置阶段 CDB;若 mcpp 版本不支持该命令、配置失败或仍缺少兼容 PCM,则只能 -提供语法高亮、mcpp 命令和引导操作。 - -## 理想最终效果 - -理想目标仍聚焦 LLVM 模块语义。用户打开一个受信任的 mcpp 工程后,不需要先执行 -完整的 `mcpp build`,也不需要 Reload Window: - -1. mcpp 解析工作区、目标、依赖、工具链、生成源和完整模块图。 -2. 按用户授权准备缺失依赖和工具链,并生成专供 IDE 使用的 CDB 和 PCM。 -3. 插件显示可取消进度,成功后原子切换到新 IDE 数据并启动匹配 clangd。 -4. 即使普通实现源码暂时无法编译或链接,编辑器仍能对错误模块名、不可见模块和导入 - 关系给出精确诊断。 -5. 模块导出的声明可以稳定悬停、补全、跳转定义、查找引用和重命名。 -6. 修改源码或 `mcpp.toml` 后增量刷新;离线时复用本机缓存和最后一次可用数据。 -7. 多根工作区中的工程拥有独立状态、进度、取消操作和 last-known-good 数据。 - -这里的“不需要完整构建”是指不编译普通对象文件、不链接最终程序或库。clangd 要进行 -跨模块语义分析,仍然必须由兼容 LLVM 工具链生成 IDE 专用 PCM,因此不等于完全不运行 -编译器。 - -## 达到理想效果所需支持 - -下面按完成程度区分必要工作。`mcpp build --configure-only` 是当前扩展采用的已实现接口; -其余机器可读工程模型和结构化诊断仍是后续协议工作。 - -| 支持项 | 当前程度 | 理想完成标准 | 优先级 | -| --- | --- | --- | --- | -| 机器可读工程模型 | 插件只能读取 CDB,mcpp 主要输出面向人的文本 | mcpp 输出带版本 schema 的成员、目标、源码、模块、依赖、工具链、缓存与诊断 | 必需 | -| IDE 配置阶段 | 已使用 `mcpp build --configure-only` 生成并验证 CDB | 增加结构化进度、取消和能力协商,不把人类 stdout 当协议解析 | 部分完成 | -| IDE 专用模块产物 | PCM 是正式构建的副产物 | 只构建 `std`、依赖和项目模块 PCM,不编译普通对象或链接,并按工具链身份隔离缓存 | 必需 | -| 完整 CDB 与模块图 | LLVM CDB 已有部分显式 PCM 参数 | producer 和 consumer 都使用确定的绝对 PCM 路径,覆盖分区、依赖包、成员和测试目标 | 必需 | -| 结构化诊断 | 未知导入等信息主要来自构建或 clangd 文本 | mcpp 输出文件、行、列、严重级别和稳定错误码,插件可精确定位配置期错误 | 必需 | -| 原子快照与回退 | CDB 变化后直接重新读取 | 新数据完整后原子切换,失败时保留 last-known-good CDB、PCM 和状态 | 必需 | -| 插件生命周期 | 已有监听、singleflight、串行化和工作区信任 | 增加进度、取消、在线/离线/仅缓存模式、过期状态和按工程隔离的配置任务 | 必需 | -| 增量更新 | CDB 变化后整体重新协调 | 只重扫受影响模块并复用兼容 PCM,`mcpp.toml` 变化时增量解析依赖 | 增强 | -| 端到端验证 | 主要是单元测试和直接 `clangd --check` | 在真实 VS Code、LLVM 和示例工程中验证诊断、悬停、补全、跳转、离线及多根行为 | 发布门槛 | -| GCC/MSVC 模块语义 | 仅语法高亮 | 等待成熟原生语言服务,或另行设计 LLVM 影子分析;不属于当前路线 | 暂缓 | - -推荐的职责边界接近 Cargo 与 rust-analyzer:mcpp 提供稳定、可查询的工程模型和 IDE -产物,clangd 消费 CDB 与 PCM,VS Code 扩展只负责授权、生命周期、状态和错误展示。 -插件不应重新实现一套 `mcpp.toml` 解析器、依赖解析器或包管理器。 - -## 路线图 +- 构建、运行、测试、清理 target 使用 VS Code 原生 `ProcessExecution` 和任务终端。 +- 同一工程任务互斥;取消不会被误报为成功。 +- 工具链列表、安装和全局默认选择继续由 mcpp CLI 决定,本扩展不复制版本解析策略。 +- 工具链安装或默认工具链变化不会静默切换语言服务;mcppls 独立读取工程描述。 -### 阶段 1:当前版本 +### C++ Modules 语言服务桥接 -- 缺少有效 CDB 时,在受信任工作区自动执行 `mcpp build --configure-only`;失败时保留 - last-known-good CDB。 -- 使用 configure-only 或用户完整构建产生的 LLVM CDB 和已有 PCM。 -- 自动配置 clangd、检查模块、监听变化并提供 CLI/工具链菜单。 -- 一键自动安装匹配的 llvm-tools 并配置模块代码提示。 -- GCC/MSVC 保持语法高亮和 mcpp 构建操作,不增加语义后端。 +本扩展只调用 mcppls 公开的 VS Code 命令: -### 阶段 2:机器可读工程协议增强 +- 选择 C++ 模块分析上下文:`mcppls.selectContext` +- 重启语言服务:`mcppls.restartServer` +- 查看模块图:`mcppls.showModuleGraph` +- 打开 C++ Modules 日志:`mcppls.showLogs` -- 在 mcpp 核心增加带版本的机器可读工程模型,不改变 configure-only 的退出码 + CDB - 客户端契约。 -- 补充完整模块图、结构化诊断、IDE 专用 PCM 和能力协商。 -- 明确定义工作区成员、配置身份、缓存路径、进度和取消协议。 +build 任务成功后,桥接调用 restart,让 mcppls 重新读取描述。桥接不解析 mcppls 内部 LSP +消息,不读取 `mcppls.*` 设置,也不创建 `vscode-languageclient`。如果依赖未安装或被禁用, +命令会显示明确提示并提供打开扩展搜索的操作,不会静默失败。 -### 阶段 3:完善打开即用的 LLVM 体验 +## 命令 + +| 命令 | 作用 | +| --- | --- | +| **mcpp: 打开快捷菜单** | 汇总工程、工具链和 C++ Modules 操作 | +| **mcpp: 新建工程** | 创建并打开新的 mcpp 工程,不自动构建 | +| **mcpp: 构建** | 执行 `mcpp build`;结束后刷新 mcppls | +| **mcpp: 运行 / 测试 / 清理 target** | 执行对应 mcpp 任务 | +| **mcpp: 查看工具链** | 查看 mcpp 报告的工具链状态 | +| **mcpp: 安装工具链** | 通过 mcpp 安装工具链 | +| **mcpp: 选择全局默认工具链** | 修改 mcpp 的全局默认工具链 | +| **mcpp: 选择 C++ 模块分析上下文** | 转发到 mcppls 的上下文选择 | +| **mcpp: 刷新模块构建描述** | 执行 `mcpp build` 并请求 mcppls 重新加载 | +| **mcpp: 重启 C++ Modules 语言服务** | 转发到 `mcppls.restartServer` | +| **mcpp: 查看模块图** | 转发到 `mcppls.showModuleGraph` | +| **mcpp: 打开 C++ Modules 日志** | 转发到 `mcppls.showLogs` | +| **mcpp: 一键构建并刷新模块语言服务** | 一次确认后 build,再刷新 mcppls | + +旧的 `mcpp.configureClangd` 命令 ID 暂时保留为弃用别名,行为与“选择 C++ 模块分析上下文”相同; +请把自定义 keybinding 迁移到 `mcpp.configureLanguageServer`。 -- 在已有自动 configure-only 基础上增加可取消进度、离线缓存、增量刷新和过期状态。 -- 完善按工程隔离的 last-known-good 数据与多根集成验证。 -- 以真实 VS Code 端到端测试作为模块诊断和跳转能力的发布门槛。 +## 设置 + +### mcpp-vscode 设置 -### 未来重新评估 GCC/MSVC +| 设置 | 默认值 | 作用 | +| --- | --- | --- | +| `mcpp.path` | 空 | mcpp-vscode 执行 mcpp CLI 时使用的可执行文件;空值表示从 VS Code 的 PATH 查找 | +| `mcpp.tomlCompletion` | `true` | 是否为 `mcpp.toml` 提供结构补全 | -当前不适配 GCC/MSVC 模块语义。只有满足以下条件之一时再单独设计: +### 迁移后弃用设置 -- 出现能够稳定消费 `.gcm` 或 `.ifc`,并提供诊断、索引、悬停和跳转的语言服务; -- mcpp 能生成与正式 GCC/MSVC 构建隔离的 LLVM IDE 分析副本,并明确接受两套编译器 - 在宏、扩展和诊断上的差异。 +以下旧设置仍保留在 manifest 中,便于旧工作区平滑升级,但 mcpp-vscode 不再读取或写入它们: -重新评估不会改变当前原则:正式构建结果以项目选择的 mcpp 工具链为准,IDE 分析不能 -代替 `mcpp build` 和 `mcpp test`。 +- `mcpp.clangd.path` +- `mcpp.modulesSupport` +- `mcpp.configureCppTools` + +mcppls 的语言服务、冲突处理和语义引擎设置由 mcppls 自己的 `mcppls.*` 设置管理。不要把 +旧 `clangd.*` 工作区设置当作 mcppls 的配置。 + +## 未受信任工作区 + +Restricted Mode 下,mcpp-vscode 只提供纯文本的模块语法高亮和 `mcpp.toml` 结构补全,不执行 +mcpp CLI,也不接管语言服务配置。mcppls 自身也有受限模式:它不运行工作区指定的构建工具 +或编译器,并使用受限的模块索引/语义工具包。 ## 故障排查 -### 扩展没有激活 +### C++ Modules 没有出现 -- 确认工作区包含 `mcpp.toml`,或已经打开一个识别为 `cpp` 的文件。 -- 执行 `code --list-extensions`,确认当前 VS Code profile 中存在 - `mcpp-community.mcpp-vscode`。 -- 只有扩展目录但没有登记到当前 profile 时,语法注入和自动配置都不会发生。 +1. 确认 mcppls 扩展已安装并启用: -### 找不到 mcpp + ```sh + code --list-extensions --show-versions + ``` -macOS 从图形界面启动 VS Code 时可能没有继承终端 `PATH`。将 `mcpp.path` 设置为实际 -可执行文件,例如 `/opt/homebrew/bin/mcpp`。这不会改变工程编译器或 clangd 路径。 +2. 确认当前 VS Code 平台有 mcppls 对应 VSIX。 +3. 打开 **C++ Modules** 日志,查看 payload、编译器或工作区信任错误。 +4. 确认工作区受信任;未受信任时不会执行 mcpp。 -### 缺少模块 CDB +### mcpp 找不到 -受信任工程缺少有效 CDB 时会自动尝试 `mcpp build --configure-only`,也可以手动执行 -**mcpp: 刷新编译数据库**。若 mcpp 版本不支持该参数、命令失败或生成的 CDB 无法解析, -插件会保持“缺少模块 CDB”状态,并保留原有可用 CDB;请查看 `mcpp` 输出频道。需要 -编译普通对象、生成完整项目产物或验证链接时,再在终端运行 `mcpp build`。 +macOS 从 Dock 或桌面入口启动 VS Code 时,PATH 可能与终端不同。设置 `mcpp.path` 为实际 +可执行文件,例如 `/opt/homebrew/bin/mcpp`。该设置只作用于 mcpp-vscode 自己的 CLI 调用。 -### clangd 未安装或无法匹配 +### mcpp 构建完成但模块描述没有更新 -- 执行 **mcpp: 一键配置模块代码提示**,扩展会自动通过 xlings 安装与当前编译器 - 版本匹配的 llvm-tools(含 clangd)。 -- xlings 可执行文件的定位以 `mcpp self env` 报告为准(覆盖 install.sh、AUR 等 - mcpp 内置安装布局),找不到时再回退到独立 `~/.xlings` 与 `PATH`。 -- `mcpp.clangd.path` 可以指向任意安装来源的真实 clangd,不要求与 mcpp 同目录。 -- 自动发现会尝试编译器同目录、匹配的 xlings `xim-x-llvm-tools//bin` 和 - `PATH`。 -- 独立 mcpp 的 LLVM 编译器可以配合单独安装的 xlings llvm-tools 或其他匹配 clangd。 -- Homebrew clangd 即使版本号相同,也可能与 mcpp 工具链来自不同 revision。 +手动执行 **mcpp: 刷新模块构建描述**,或查看 mcppls 日志。build 失败时,mcppls 可能继续使用 +最后一次可用描述并显示 degraded;这不等于模块语义一定可用。 -### 模块不可用或跳转失败 +### 出现两套 C++ 诊断 -- 查看 `mcpp` 输出频道中的 `clangd --check` 完整输出。 -- 确认 CDB 使用 C++20 或更高标准,并包含当前文件的编译命令。 -- 确认 PCM 路径存在,且 clangd、编译器、标准库和 PCM 来自同一 LLVM 构建。 -- 修改工具链或 CDB 后等待自动协调,必要时执行 **mcpp: 检查模块支持**。 +mcppls 会提示关闭其他 C++ 扩展的语言功能。可使用 mcppls 提供的 +**C++ Modules: Turn Off Other C++ Language Features** 和 +**C++ Modules: Restore Other C++ Language Features**。mcpp-vscode 不再自动修改 +`C_Cpp.intelliSenseEngine` 或官方 clangd 设置。 -### 出现两套诊断 +### 出现旧 clangd 配置提示 -clangd 和 Microsoft C/C++ IntelliSense 可能同时报告诊断。执行 -**mcpp: 配置 clangd** 时,扩展可以询问是否仅在当前工作区关闭 cpptools -IntelliSense;也可以将 `mcpp.configureCppTools` 设为 `false` 保留它。 +旧 `clangd.*` 和 `mcpp.clangd.*` 设置已不再由 mcpp-vscode 使用。请删除不再需要的设置, +并以 mcppls 的状态、日志和设置为准。 -## 开发 +## 开发与验证 ```sh -npm install -npm test +npm ci npm run compile +npm test +npm run package +npm run test:e2e ``` -生成的 `dist/` 是扩展运行时目录。请在 Extension Development Host 中验证真实 VS Code -API、状态栏、任务和 clangd 集成。 +`npm test` 包含纯 Node 单元测试和真实 mcpp 契约测试;没有 mcpp 时契约测试会跳过。 +`npm run test:e2e` 使用隔离的 mcppls stub 验证扩展激活、任务回调和公开命令桥接,不启动真实 +LSP。真实 mcppls 语义验证应在安装对应平台 VSIX 的 Extension Development Host 或发布前 +验收中执行。 ## 发布 -先更新 `package.json`、`package-lock.json` 和更新日志中的版本并提交,再推送与扩展 -版本完全一致的 tag: +先更新 `package.json`、`package-lock.json` 和更新日志中的版本并提交,再推送与扩展版本完全 +一致的 tag: ```sh -git tag -a v0.3.1 -m "mcpp-vscode 0.3.1" -git push origin v0.3.1 +git tag -a v0.4.0 -m "mcpp-vscode 0.4.0" +git push origin v0.4.0 ``` -`.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, -并创建或更新对应的 GitHub Release。工作流当前不会发布到 VS Marketplace。 +`.github/workflows/release.yml` 会校验 tag、运行测试和打包,生成 VSIX 与 SHA-256 文件, +并创建或更新 GitHub Release。发布前必须在干净 extensions 目录安装 VSIX,确认 mcppls +依赖被自动解析,并记录未覆盖的平台。 项目地址:[mcpp-community/mcpp-vscode](https://github.com/mcpp-community/mcpp-vscode) diff --git a/docs/superpowers/plans/2026-09-25-mcpp-language-server-migration.md b/docs/superpowers/plans/2026-09-25-mcpp-language-server-migration.md new file mode 100644 index 0000000..1617eec --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-mcpp-language-server-migration.md @@ -0,0 +1,669 @@ +# mcpp-language-server 迁移实现计划 + +> **执行约定:**按任务顺序实施。每个任务先写失败测试,再写最小实现,最后运行该任务列出的验证命令。不要跨任务顺手改无关功能。 + +**目标:**让 `mcpp-community.mcpp-vscode` 不再安装、配置、探测或启动官方 clangd 扩展;把 C++ 模块语言服务职责交给 `sunrisepeak.mcpp-language-server`,同时保留 mcpp 的构建、工具链、任务和项目 UI。 + +**基线:**本计划编写于 `main@736ecbb`、扩展 `0.2.7`;本次迁移发布版本为 `0.4.0`(远端已占用 v0.3.0/v0.3.1);上游 `sunrisepeak.mcpp-language-server@v0.0.4`(commit `04b186a`)。实现前重新核对上游 tag、VS Code 引擎要求、平台矩阵与 `extensionDependencies` 行为。 + +**架构:**本插件不引入 `vscode-languageclient`,不创建第二个 LanguageClient,不打包 mcppls payload。它只通过 VS Code 命令边界与语言服务依赖协作。语言服务依赖负责 LSP、内置语义引擎、项目描述、诊断、状态和模块请求;本插件负责 mcpp CLI。 + +**技术栈:**TypeScript、VS Code Extension API、Node `node:test`、Mocha + `@vscode/test-electron`、GitHub Actions。 + +## 执行状态(2026-09-25) + +- [x] Task 1:manifest、依赖声明、隔离 VSIX 安装验证 +- [x] Task 2:mcppls 公开命令桥接与命令契约 +- [x] Task 3:build 任务后的语言服务刷新 +- [x] Task 4:一键流程改为 build + mcppls restart +- [x] Task 5:删除 clangd/CDB/PCM 专属实现与测试 +- [x] Task 6:E2E clangd stub 替换为 mcppls stub +- [x] Task 8:弃用设置、README、CHANGELOG 与兼容 alias +- [x] Task 9:版本、CI、残留审计、打包与隔离安装门禁 +- [ ] Task 7:真实 mcppls 跨工具链语义验收(LLVM 22.1.8 单次通过,需 CI 复验;GCC 16 需 Linux runner) + +实现结果:本地 `npm test`、`npm run test:e2e`、`npm run package`、VSIX 隔离安装均通过; +安装的 VSIX 自动解析到 `sunrisepeak.mcpp-language-server@0.0.4`。真实 mcppls 在 LLVM 22.1.8 +mcpp 工程中已完成一次空诊断、定义、悬停、引用和补全验证;重复运行时 references 查询曾超时, +因此 Task 7 仍不是发布门禁。GCC 16 fixture 因当前 macOS 无对应工具链包未完成。当前仍按计划明确 +记录 `darwin-x64` 尚无上游平台包。 + +--- + +## 先冻结决策 + +以下决策在实现期间不再重新讨论,除非上游发生破坏性变化: + +1. **使用独立扩展依赖,不内置 payload。** `extensionDependencies` 只写 `sunrisepeak.mcpp-language-server`。 +2. **不保留官方 clangd 扩展作为 fallback。** 两条语言服务不能并行,否则诊断、跳转与补全重复。 +3. **不 fork mcppls,不在本插件内启动 `mcppls serve`。** 那会复制上游的客户端、状态 UI 和冲突处理。 +4. **不向 `mcppls.*` 写入本插件的 `mcpp.path`。** 配置属于语言服务扩展;本插件只让 `mcpp.path` 作用于自己的 CLI 调用。 +5. **保留稳定 ID 兼容映射。** 新的 mcpp 命令使用 mcppls 语义;旧 `mcpp.configureClangd` 作为弃用 alias 转发到新实现。 +6. **本插件不再拥有 C++ 模块语义状态栏。** mcppls 已提供 C++ Language Status Item;本插件只保留 mcpp 快捷菜单状态栏。 +7. **旧 mcpp 不阻断安装。** mcppls 会从 `compile_commands.json` 或源码扫描降级;本插件只在用户主动构建时执行 mcpp。 +8. **先迁移命令、再删旧设置。** 旧 `mcpp.clangd.*` 与 `clangd.*` 工作区设置在兼容窗口内保留为无效配置,不再被本插件读取或写入。 + +## 不可越过的门禁 + +- 不得在 `package.json` 中保留 `llvm-vs-code-extensions.vscode-clangd`。 +- 不得在 `src/`、`test/`、`README.md` 中保留“本插件启动/匹配/配置 clangd”的行为描述。 +- 不得启动第二个 LSP 客户端,也不得把 mcppls payload 复制进本 VSIX。 +- 不得静默修改用户已有的 `clangd.path`、`clangd.arguments`、`clangd.enable`。 +- 不得删除 `mcpp.path`,直到 Task 8 明确 mcppls 能获得同一个 mcpp 可执行文件,或用户接受它只对 mcpp-vscode 生效。 +- 不得在普通 `npm test` 中下载 Marketplace 扩展。 + +## 暂不处理 + +- 不合并两个 Marketplace 扩展。 +- 不把 mcppls 的 `cxxModules/*` 协议复制成第二套客户端协议。 +- 不处理上游未发布的平台;`darwin-x64` 缺失是明确的支持边界,在上游发布前不宣称支持。 +- 不重写 mcpp.toml 补全、工具链管理、任务互斥、多根工程发现与新工程流程。 +- 不处理与迁移无关的既有失败:`test/mcppTomlContract.test.ts` 的 3 个 `[xlings.envs]` 失败来自本机 mcpp 2026.9.25.1 移除了该段,应独立修复或固定测试用 mcpp 版本。 + +--- + +## File Map + +| 文件 | 计划中的责任 | +| --- | --- | +| `package.json` | 用 mcppls 替换 clangd 依赖;VS Code 下限升到 1.91;声明新命令。旧设置先保留、最终在 Task 8 删除。 | +| `package-lock.json` | 只随 manifest 的必要变化更新;不新增语言客户端依赖。 | +| `src/languageServer.ts` | 纯命令桥接:探测 mcppls 是否存在、调用其公开命令、把失败映射为稳定结果。 | +| `src/extension.ts` | 删除 clangd 生命周期;保留 mcpp.toml provider、CLI controller 与命令注册。 | +| `src/commands.ts` | 统一公开命令 ID;快捷菜单改为 mcppls 语义。 | +| `src/moduleSetup.ts` | 一键流程改为“构建 + 刷新语言服务”,不再修改工具链。 | +| `src/cliController.ts` | 任务完成回调交给语言服务桥接;一键向导直接复用 `runProjectTask("build")`,删除专用 setup 锁与 command 序列。 | +| `src/workflow.ts` | 删除 CDB/clangd 单飞与状态渲染,只保留桥接需要的纯函数。 | +| `src/discovery.ts` | 保留 mcpp 工程发现并删除 `compilationDatabasePath`;删除 clangd 候选推导。 | +| `src/process.ts` | 保留 `runProcess`;删除 `runClangdCheck`、`runToolVersion`。 | +| `test/languageServer.test.ts` | 桥接契约:依赖 ID、命令 ID、参数、失败与去重。 | +| `test/artifacts.test.ts` | manifest、VSIX 依赖、禁止 clangd 行为残留。 | +| `test/e2e/fixtures/mcppls-stub/` | 隔离 Extension Host 测试中的 mcppls 依赖,不启动真实 LSP。 | +| `README.md`、`CHANGELOG.md` | 能力矩阵、依赖说明、升级迁移说明。 | +| `.github/workflows/ci.yml` | 增加 VSIX 依赖安装与桥接 E2E 检查。 | + +--- + +# Task 1:锁定依赖、平台与发布门禁 + +**Files:** +- Modify: `test/artifacts.test.ts` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: 先改 manifest 测试为新契约** + +将“声明官方 clangd 依赖”改成: + +```ts +assert.equal(manifest.engines?.vscode, "^1.91.0"); +assert.deepEqual(manifest.extensionDependencies, ["sunrisepeak.mcpp-language-server"]); +assert.ok(!manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); +assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureLanguageServer")); +assert.ok(!manifest.activationEvents?.includes("onCommand:mcpp.configureClangd")); +``` + +保留 `workspaceContains:mcpp.toml`、`onCommand:mcpp.run`、editor title 按钮和 TOML 补全断言。 + +- [ ] **Step 2: 写禁止行为测试** + +断言以下内容不存在: + +- manifest 中的官方 clangd 扩展 ID; +- `mcpp.clangd.path`、`mcpp.modulesSupport`、`mcpp.configureCppTools` 的新功能描述; +- `vscode-languageclient` 依赖; +- `clangd.restart` 调用; +- `runClangdCheck` / `resolveClangd` 等源码符号。 + +- [ ] **Step 3: 改 manifest** + +- `engines.vscode` 改成 `^1.91.0`; +- `extensionDependencies` 改成 `["sunrisepeak.mcpp-language-server"]`; +- 新增 `mcpp.configureLanguageServer`、`mcpp.showModuleGraph`、`mcpp.showLanguageServerLogs`; +- 新 ID 与旧 ID 的 alias 在 Task 8 完成; +- 暂时保留旧设置,但本插件不再读取。 + +- [ ] **Step 4: 验证本地 VSIX 会拉取依赖** + +```sh +code --extensions-dir "$TMP/extensions" --user-data-dir "$TMP/user-data" \ + --install-extension "$VSIX" +code --extensions-dir "$TMP/extensions" --user-data-dir "$TMP/user-data" \ + --list-extensions --show-versions +``` + +必须同时看到 `sunrisepeak.mcpp-language-server` 与本插件。若自动拉取失败,保留依赖声明,但 README 与发布脚本必须明确先装 mcppls,并将该行为升级为发布阻断。 + +- [ ] **Step 5: 确认平台矩阵** + +Task 1 结束前记录 CI runner 覆盖的 `darwin-arm64`、`linux-x64`、`win32-x64`;`darwin-x64` 明确标为不支持,不能宣称全平台支持。 + +**Gate:** manifest 测试、VSIX 隔离安装、平台结论三项通过。 + +--- + +# Task 2:建立 mcppls 命令桥接 + +**Files:** +- Create: `src/languageServer.ts` +- Create: `test/languageServer.test.ts` +- Modify: `src/commands.ts` +- Modify: `test/commands.test.ts` + +- [ ] **Step 1: 定义与 VS Code 分离的内部接口** + +```ts +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; + +export const MCPPLS_COMMANDS = { + restart: "mcppls.restartServer", + selectContext: "mcppls.selectContext", + graph: "mcppls.showModuleGraph", + logs: "mcppls.showLogs", +} as const; + +export interface LanguageServerCommandResult { + state: "completed" | "unavailable" | "failed"; + message: string; +} + +export interface LanguageServerCommandExecutor { + extensionInstalled(id: string): boolean; + executeCommand(command: string, ...args: unknown[]): Thenable; +} +``` + +刷新构建描述不是上游贡献的 VS Code 命令,而是一次 LSP execute-command 请求;本插件不能直接发起该请求。因此桥接采用下面的有限能力: + +- 可直接调用 `mcppls.restartServer`、`mcppls.selectContext`、`mcppls.showModuleGraph`、`mcppls.showLogs`; +- 构建后调用 `mcppls.restartServer`,不在本插件伪造 `mcppls.reloadBuildDescription`; +- 未来若上游贡献一个公开的刷新命令,再替换 Task 3 的 build 后动作。 + +- [ ] **Step 2: 写失败测试** + +覆盖:依赖存在时调用正确命令;依赖缺失返回 `unavailable`;命令抛错返回 `failed`;同一任务只刷新一次;错误文字包含 mcppls 扩展 ID,方便用户定位。 + +- [ ] **Step 3: 实现最小桥接** + +提供: + +- `restartLanguageServer()`; +- `selectContext()`; +- `showModuleGraph()`; +- `showLanguageServerLogs()`; +- `refreshLanguageServerAfterBuild()`(内部调用 restart)。 + +桥接不解析 mcppls 状态、不读取 `mcppls.*` 设置、不 spawn 子进程。 + +- [ ] **Step 4: 更新命令与 Quick Pick** + +命令 ID: + +```ts +export const CLI_COMMANDS = { + showMenu: "mcpp.showMenu", + newProject: "mcpp.newProject", + build: "mcpp.build", + run: "mcpp.run", + test: "mcpp.test", + clean: "mcpp.clean", + showToolchains: "mcpp.showToolchains", + installToolchain: "mcpp.installToolchain", + selectDefaultToolchain: "mcpp.selectDefaultToolchain", + configureLanguageServer: "mcpp.configureLanguageServer", + refreshCompilationDatabase: "mcpp.refreshCompilationDatabase", + checkModuleSupport: "mcpp.checkModuleSupport", + autoConfigureModules: "mcpp.autoConfigureModules", + showModuleGraph: "mcpp.showModuleGraph", + showLanguageServerLogs: "mcpp.showLanguageServerLogs", +} as const; +``` + +旧 `mcpp.configureClangd` 保留为弃用 alias,迁移说明从本版本开始保留。快捷菜单标题改为“配置 C++ 模块语言服务”“刷新模块构建描述”“查看模块图”“打开 C++ Modules 日志”。 + +- [ ] **Step 5: 验证** + +```sh +npm run compile +node --test dist/test/languageServer.test.js dist/test/commands.test.js +``` + +**Gate:** 桥接测试不依赖真实 VS Code 进程;命令 ID 全部在 `package.json` 与 `src/commands.ts` 一致。 + +--- + +# Task 3:迁移构建任务后的协调 + +**Files:** +- Modify: `src/tasks.ts` +- Modify: `src/cliController.ts` +- Modify: `src/extension.ts` +- Modify: `test/tasks.test.ts` +- Modify: `test/artifacts.test.ts` +- Modify: `test/e2e/suite/extension.test.ts` + +- [ ] **Step 1: 先写任务回调契约** + +- build 成功:调用一次 `refreshLanguageServerAfterBuild`; +- build 失败:也调用一次,使 mcppls 使用最后一次可用描述或显示降级; +- build 取消:不调用; +- run/test/clean:不调用; +- 桥接失败:不把成功的 mcpp build 改成失败,只追加 warning。 + +- [ ] **Step 2: 修改任务层** + +把 `shouldReconcileAfterTask` 改为 `shouldRefreshLanguageServerAfterTask`,并只允许 `kind === "build" && completion.state !== "cancelled"`。 + +- [ ] **Step 3: 修改控制器与扩展** + +`cliController.afterProjectTask` 调用桥接;删除: + +- `executeWithWorkspaceClangd`; +- `reconcileProjectContext` / `reconcileProjectByRoot`; +- `compilationDatabaseWatcher`; +- `configurationWatcher` / `trustWatcher` 中的 clangd 配置重启; +- `lastReconciledProjectRoot`。 + +manifest watcher 仍负责 mcpp 项目状态,不负责语言服务。 + +- [ ] **Step 4: 改用户文案** + +- “clangd/CDB 状态已重新检查” → “C++ 模块语言服务已刷新”; +- “mcpp 构建完成,clangd 配置已刷新” → “mcpp 构建完成,模块语言服务已刷新”; +- “刷新编译数据库”保留 ID,标题改成“刷新模块构建描述”; +- “检查模块支持”改成“重启 C++ Modules 语言服务”,直接调用 mcppls restart。 + +- [ ] **Step 5: 验证** + +```sh +node --test dist/test/tasks.test.js dist/test/artifacts.test.js +``` + +**Gate:** 本插件不再因 build 事件主动读取 `compile_commands.json` 或启动任何语言服务器。 + +--- + +# Task 4:重写一键模块配置 + +**Files:** +- Modify: `src/moduleSetup.ts` +- Modify: `test/moduleSetup.test.ts` +- Modify: `src/extension.ts` +- Modify: `src/cliController.ts` +- Modify: `test/cli.test.ts` +- Modify: `test/artifacts.test.ts` + +- [ ] **Step 1: 先定义新状态机测试** + +```ts +export type ModuleSetupStage = + | "prepare" + | "build" + | "language-server"; +``` + +不再有 `default`、`clangd`、`check` 阶段。测试覆盖: + +- 未受信任 / 忙碌 → blocked; +- 工具链清单无法识别 → blocked; +- GCC/MSVC/LLVM 项目均允许继续; +- `build` 固定执行普通 `mcpp build`; +- build 成功 → 只刷新语言服务一次; +- build 失败且 command 未被取消 → 仍刷新,让 mcppls 显示降级; +- build 失败但已有可用描述 → 结果为 degraded; +- 语言服务依赖缺失 → failed,不自动安装; +- 取消不产生后续调用。 + +- [ ] **Step 2: 简化 planner** + +`buildModuleSetupPlan` 不再分析 CDB 工具链家族,也不再阻塞非 LLVM 项目。计划只决定是否已忙碌;默认无需修改工具链。 + +删除 `installLlvm`、`switchDefault` 与 `project-toolchain-override` 阻塞;一键命令固定执行普通 `mcpp build`,因为 mcppls 能从任意 mcpp 工具链读取构建描述。不得继续切换 LLVM。 + +- [ ] **Step 3: 改执行接口** + +```ts +export interface ModuleSetupOperations { + build(): Promise; + refreshLanguageServer(): Promise; +} +``` + +删除 `preparePlan()`、`runAutomaticModuleSetup()`、专用 `executeAutomaticModuleSetupCommand()` 与独立 clangd check。`build()` 直接调用普通 `runProjectTask("build")` 的任务执行器,避免嵌套项目锁。 + +- [ ] **Step 4: 改向导 UI** + +一次 modal 确认后只执行普通 `mcpp build` → mcppls restart。确认文案明确:不会安装官方 clangd 扩展,不会修改 `clangd.*`。 + +- [ ] **Step 5: 验证** + +```sh +node --test dist/test/moduleSetup.test.js dist/test/artifacts.test.js +``` + +**Gate:** 测试与源码中不再出现 `llvmToolsVersionSpec`、`xlingsInstallArgs`、`ensureClangd`、`runClangdCheck`。 + +--- + +# Task 5:拆除 clangd 专属实现 + +**Files:** +- Delete: `src/analysis.ts` +- Delete: `src/llvmTools.ts` +- Delete: `test/analysis.test.ts` +- Delete: `test/llvmTools.test.ts` +- Modify: `src/process.ts` +- Modify: `test/process.test.ts` +- Modify: `src/discovery.ts` +- Modify: `test/discovery.test.ts` +- Modify: `src/extension.ts` +- Modify: `src/workflow.ts` +- Modify: `test/workflow.test.ts` + +- [ ] **Step 1: 用引用图确认删除范围** + +```sh +rg -n -i 'clangd|compile_commands|PCMs?|ToolIdentity|ModuleCapability|CheckResult' src test package.json README.md +``` + +逐项分类:历史 CHANGELOG、迁移说明可保留;活动代码、旧测试和当前能力描述必须清除。 + +- [ ] **Step 2: 精简 process** + +保留 `runProcess`(`cliController` 与契约测试仍需要);删除 `runToolVersion`、`runClangdCheck`、`runMcppBuild` 及 ToolIdentity 类型。 + +- [ ] **Step 3: 精简 discovery** + +保留 `McppProjectDiscovery`、manifest glob 与 nearest member 逻辑;删除 `compilationDatabasePath` 和 `deriveClangdCandidates` 及对应测试。任务/工具链逻辑只需要 `root`。 + +- [ ] **Step 4: 精简 workflow** + +保留与 CLI 任务/操作注册相关且有非 clangd 用户的函数;删除: + +- `shouldRestartClangd`; +- `configurationReadyAfterRestart`; +- `registerCompilationDatabaseReconciliation`; +- `configurationAffectsModuleSupport`; +- `shouldUseWorkspaceClangd`; +- `describeRefreshOutcome`; +- `statusCommandForCapability`; +- `shouldCheckModuleSupport`; +- `moduleSupportState`。 + +纯 single-flight/serial executor 若没有生产调用者,一并删除;不要为了测试保活死代码。 + +- [ ] **Step 5: 精简 extension** + +删除 ProjectContext、CDB 读取、状态栏、clangd resolver/config、模块检查、CDB watcher、自动重协调与所有相关命令分支。`activate()` 最终只保留: + +- mcpp.toml completion provider; +- mcpp project context; +- `McppCliController`; +- 语言服务桥接命令; +- 新的一键向导; +- 输出频道与资源释放。 + +- [ ] **Step 6: 清理测试** + +删除纯 clangd 测试;重写 `artifacts.test.ts` 和 `workflow.test.ts`,只钉住迁移后仍有生产价值的行为。不得把“源码没有 clangd 字符串”作为唯一断言,行为测试优先。 + +- [ ] **Step 7: 验证** + +```sh +npm run compile +node --test dist/test/process.test.js dist/test/discovery.test.js dist/test/workflow.test.js dist/test/artifacts.test.js +``` + +**Gate:** `rg -n 'llvm-vs-code-extensions.vscode-clangd|clangd.restart|runClangdCheck|resolveClangd|configureClangd' src test package.json` 无结果(历史文档除外)。 + +--- + +# Task 6:替换 E2E clangd stub 为 mcppls stub + +**Files:** +- Delete: `test/e2e/fixtures/clangd-stub/` +- Create: `test/e2e/fixtures/mcppls-stub/package.json` +- Create: `test/e2e/fixtures/mcppls-stub/extension.js` +- Modify: `test/e2e/runTest.ts` +- Modify: `test/e2e/suite/extension.test.ts` + +- [ ] **Step 1: 写 stub 契约** + +stub 扩展: + +- ID 为 `sunrisepeak.mcpp-language-server`; +- 激活时注册 `mcppls.restartServer`、`mcppls.showModuleGraph`、`mcppls.showLogs`; +- 每次调用向环境变量指定的日志文件追加 JSON; +- 不启动 LSP,不读取 workspace,不改配置。 + +- [ ] **Step 2: 改 Extension Host runner** + +隔离目录中复制 mcppls stub,而不是 clangd stub。E2E 断言: + +1. mcpp-vscode 激活; +2. `mcpp.refreshCompilationDatabase` 执行 fake mcpp build; +3. build 结束后调用 mcppls restart; +4. `mcpp.showModuleGraph` 与 `mcpp.showLanguageServerLogs` 只转发一次; +5. build 取消时不调用 restart。 + +- [ ] **Step 3: 加依赖缺失测试** + +用一个不安装 mcppls stub 的显式环境测试桥接返回 `unavailable`,不抛出未处理异常。该测试不得依赖 Marketplace。 + +- [ ] **Step 4: 验证** + +```sh +npm run test:e2e +``` + +在 Linux CI 继续用: + +```sh +xvfb-run -a npm run test:e2e +``` + +**Gate:** E2E 证明任务与 mcppls 命令桥接正确,但不冒充真实 LSP 语义测试。 + +--- + +# Task 7:补真实 mcppls 契约与跨工具链验收 + +**Files:** +- Create: `test/e2e/suite/mcppls-contract.test.ts`(opt-in) +- Modify: `test/e2e/runTest.ts` +- Modify: `package.json` +- Modify: `.github/workflows/ci.yml` +- Create: `test/e2e/fixtures/mcpppls-project/`(如需要) + +- [ ] **Step 1: 建立 opt-in 环境** + +新增环境变量 `MCPP_E2E_REAL_MCPPLS=1`。默认 E2E 不下载、不运行真实 payload;专用 CI job 或人工验收设置该变量。 + +- [ ] **Step 2: 验证 mcpp 工程** + +在 mcpp 工程中验证: + +- Language Status Item 出现并最终 ready/degraded,而非 error; +- `import` 补全、模块定义跳转、hover、references; +- GCC 与 LLVM 至少各一个 fixture; +- build 前后 module graph/status 变化; +- mcpp 任务完成后 mcppls 重启且重新加载。 + +- [ ] **Step 3: 验证降级** + +- 无 `compile_commands.json`; +- mcpp 版本低于 `2026.9.15.1`; +- `mcpp.path` 自定义且 mcppls 仍从 PATH 找 mcpp; +- 非受信任 workspace; +- mcppls 依赖被禁用。 + +每种情况都只验证明确状态/提示,不把环境差异误报成 mcpp-vscode 构建失败。 + +- [ ] **Step 4: 记录平台结论** + +CI 至少覆盖 `ubuntu-24.04 x64` 与 `macos-14 arm64`;Windows 可在 release 前人工/自托管 runner 验证。`darwin-x64` 在上游发布前写“不支持”。 + +**Gate:** 真实 mcppls 测试通过,四个公开转发命令和 build 后重启均可用。 + +--- + +# Task 8:设置、旧命令与兼容迁移 + +**Files:** +- Modify: `package.json` +- Modify: `src/extension.ts` +- Modify: `test/artifacts.test.ts` +- Modify: `README.md` +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 明确 `mcpp.path` 的作用域** + +保留 `mcpp.path`,但文档写清:它只控制 mcpp-vscode 自己的 CLI;mcppls 的 `--mcpp` 当前只能从其自己的启动参数获得,本插件不能跨扩展写 `mcppls.*` 配置。 + +向上游提一个独立 issue/change request:增加 `mcppls.mcpp` 或由 extension 启动时把当前 mcpp-vscode 设置传给 server。上游支持前,不做同步写配置的隐式耦合。 + +- [ ] **Step 2: 移除旧 mcpp 设置的功能描述** + +- `mcpp.clangd.path` → deprecationMessage; +- `mcpp.modulesSupport` → deprecationMessage; +- `mcpp.configureCppTools` → deprecationMessage; +- 不自动删除用户值; +- mcppls 自己管理官方 clangd/cpptools 冲突。 + +- [ ] **Step 3: 决定旧命令 ID 的最终形态** + +本版本注册旧 `mcpp.configureClangd` 为 `mcpp.configureLanguageServer` 的弃用 alias;alias 进入 artifact test,且不得再读取 clangd 配置。 + +- [ ] **Step 4: 写升级说明** + +README 说明: + +- 安装本插件会自动安装 `sunrisepeak.mcpp-language-server`; +- 不再需要官方 `llvm-vs-code-extensions.vscode-clangd`; +- `clangd.*`、`mcpp.clangd.*` 不再生效; +- mcppls 自带服务端与语义 payload; +- mcpp 仍负责 build/run/test/toolchain; +- 旧设置可保留但应手动删除; +- `mcpp.path` 的作用域限制。 + +**Gate:** 新安装、已有 clangd 用户、GCC/MSVC 用户都能从 README 找到下一步。 + +--- + +# Task 9:文档、发布与清理门禁 + +**Files:** +- Modify: `README.md` +- Modify: `CHANGELOG.md` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `test/artifacts.test.ts` + +- [ ] **Step 1: 重写 README 能力矩阵** + +删除“仅 LLVM 支持模块语义”的旧结论。区分两层: + +| 能力 | 负责方 | +| --- | --- | +| mcpp 工程发现、build/run/test、工具链 | mcpp-vscode | +| C++ 模块诊断、补全、跳转、引用、状态、模块图 | mcppls | +| mcpp.toml 补全 | mcpp-vscode | +| 官方 clangd 扩展配置 | 不再由 mcpp-vscode 管理 | + +GCC/MSVC 的支持程度引用 mcppls 自己的当前文档,不在 mcpp-vscode 复制一套可能过时的能力矩阵。 + +- [ ] **Step 2: 更新故障排查** + +删除 clangd revision、PCM mismatch、llvm-tools 安装、`mcpp.clangd.path` 的主流程。新增: + +- mcppls 没安装/被禁用; +- mcppls 平台不受支持; +- mcpp 版本太旧; +- build 后语言服务没有刷新; +- `mcpp.path` 与 mcppls 所见 PATH 不同。 + +- [ ] **Step 3: 发布前完整验证** + +```sh +npm ci +npm run compile +npm test +npm run package +unzip -t "$VSIX" +npm run test:e2e +``` + +真实语义 gate: + +```sh +MCPP_E2E_REAL_MCPPLS=1 npm run test:e2e +``` + +- [ ] **Step 4: 发布依赖完整性** + +在干净 extensions 目录安装打包 VSIX,列出扩展,确认 mcppls 被拉取。记录 mcppls 最低/推荐版本;暂不写死具体 patch 版本,发布时锁定已在 CI 验证的版本。 + +- [ ] **Step 5: 最终残留审计** + +```sh +rg -n -i 'clangd|compile_commands|PCM|llvm-tools' \ + src test package.json README.md CHANGELOG.md +``` + +允许命中:迁移说明、CHANGELOG 历史、mcppls 自带语义引擎的事实说明。禁止命中:活动依赖、命令执行、设置写入、状态判断。 + +- [ ] **Step 6: 发布检查** + +- package version / lock / CHANGELOG / tag 一致; +- VSIX 中无 payload、无第二个 LSP client; +- 官方 clangd 扩展不再是依赖; +- Marketplace/Open VSX 安装路径已验证; +- 四个上游发布平台与本项目 CI 覆盖差异在 release note 中明示; +- `darwin-x64` 有明确支持决定。 + +--- + +## 推荐提交顺序 + +1. `manifest + tests`:只切依赖与引擎版本; +2. `language-server bridge + unit tests`; +3. `task coordination + E2E stub`; +4. `module setup rewrite`; +5. `delete clangd layer`; +6. `real mcppls contract tests`; +7. `docs/settings/release gates`。 + +每个提交保持可编译;不把“删 1000 行 clangd 代码”和“改一键向导”混在同一个提交。 + +## 完成定义 + +只有同时满足以下条件,迁移才算完成: + +- 本插件只依赖 mcppls,不依赖官方 clangd 扩展; +- 本插件不启动第二个 LSP、不打包 payload; +- build/run/test/toolchain/TOML 补全原有行为保持; +- build 后能可靠刷新 mcppls; +- mcppls 缺失时提示明确且不崩溃; +- 旧 clangd 设置不再被读取或写入; +- Node、package、Extension Host、真实 mcppls 跨工具链测试通过; +- 干净 VSIX 安装会自动获得 mcppls; +- README、CHANGELOG、版本与发布资产一致。 + +## 已知阻塞与决策点 + +1. **`darwin-x64` 无上游包。** 在上游发布前,mcpp-vscode 不能宣称支持 Intel macOS。 +2. **上游成熟度低。** `0.0.4` 且协议 Draft;发布门槛必须是真实 mcppls conformance/E2E,而不是只跑 manifest 测试。 +3. **`mcpp.path` 作用域。** 本插件不能可靠传给 mcppls;先公开限制,再推动上游正式设置。 +4. **mcppls 仍内部使用 clangd。** “放弃 clangd 依赖”指放弃官方 clangd 扩展和用户 clangd 配置,不指移除 mcppls payload 内固定语义引擎。 +5. **构建后刷新只能先 restart。** 公开 `mcppls.reloadBuildDescription` VS Code 命令尚未贡献;不要从本插件伪造 LSP 请求。 + +## 后续:Task 7 真实语义验收 + +Task 7 不是代码阻塞项。LLVM 22.1.8 已在当前 darwin-arm64 环境使用真实 +`sunrisepeak.mcpp-language-server@0.0.4` VSIX 和真实 mcpp 工程完成过一次空诊断、定义、悬停、 +引用和补全验证;重复运行时 references 查询曾超时,因此需在 CI 中重复验证。GCC 16 fixture +需要 Linux runner:当前 `xim:gcc@16.1.0` 没有 macOS build,无法在本机诚实宣称跨工具链完成。 +在 GCC/Linux 验收前,不把“LLVM 与桥接通过”等同于“C++ 模块语义已在所有工具链和平台验证通过”。