From c90bf6af2b04bc5a907fe0921f6cb51b98f47fa8 Mon Sep 17 00:00:00 2001 From: Ximiaw Date: Sat, 8 Aug 2026 15:50:43 +0800 Subject: [PATCH 01/27] feat: add structural completion for mcpp.toml Section-header snippets and writing templates for free-vocabulary sections, computed from a tolerant TOML parser that yields cursor contexts with explicit replacement ranges (unterminated input, CRLF, quoted/dotted keys, nested inline tables). Conditional sections such as [target..dependencies] resolve to their base group. No static field keys/enums (waiting for a versioned upstream manifest schema) and no dynamic dependency data (waiting for a batch catalog interface); the completion query layer is provider-dispatch so both can be added back without touching the parser. Contract tests run every registered section header and template key through real mcpp builds (skipped where mcpp is absent). Gated by mcpp.tomlCompletion (default on). --- package.json | 9 +- src/extension.ts | 47 ++ src/mcppTomlCompletion.ts | 223 ++++++++ src/mcppTomlParser.ts | 890 ++++++++++++++++++++++++++++++++ test/artifacts.test.ts | 2 +- test/mcppTomlCompletion.test.ts | 93 ++++ test/mcppTomlContract.test.ts | 228 ++++++++ test/mcppTomlParser.test.ts | 461 +++++++++++++++++ 8 files changed, 1951 insertions(+), 2 deletions(-) create mode 100644 src/mcppTomlCompletion.ts create mode 100644 src/mcppTomlParser.ts create mode 100644 test/mcppTomlCompletion.test.ts create mode 100644 test/mcppTomlContract.test.ts create mode 100644 test/mcppTomlParser.test.ts diff --git a/package.json b/package.json index e83188c..84d4538 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "workspaceContains:mcpp.toml", "onLanguage:cpp", "onLanguage:mcpp-build", + "onLanguage:mcpp-toml", "onCommand:mcpp.configureClangd", "onCommand:mcpp.refreshCompilationDatabase", "onCommand:mcpp.checkModuleSupport", @@ -44,7 +45,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" + "description": "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" } }, "main": "./dist/src/extension.js", @@ -146,6 +147,12 @@ "default": true, "scope": "resource", "description": "配置 clangd 时,是否询问关闭 Microsoft C/C++ IntelliSense。" + }, + "mcpp.tomlCompletion": { + "type": "boolean", + "default": true, + "scope": "resource", + "description": "为 mcpp.toml 提供结构补全:段头与写法模板(snippet)。所有建议带显式替换范围,并经真实 mcpp 契约测试验证。" } } }, diff --git a/src/extension.ts b/src/extension.ts index 802b186..71bd556 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,7 @@ import { } from "./workflow"; import { classifyTaskExit, type TaskCompletion } from "./tasks"; import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; +import { computeMcppTomlCompletions } from "./mcppTomlCompletion"; const COMMAND_CONFIGURE = "mcpp.configureClangd"; const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; @@ -852,6 +853,47 @@ async function autoConfigureModulesWizard( appendOutputLine(output, `[一键配置] 一键配置完成。clangd:${resolvedClangd.path}`); } +// mcpp.toml 结构补全:建议由纯函数 computeMcppTomlCompletions 计算,这里只做 +// vscode 类型映射。范围:段头 snippet + 开放词汇段的写法模板;不含字段键/枚举 +// 与依赖数据(分别等上游版本化 schema 与批量 catalog 接口)。 +const mcppTomlCompletionKinds = { + section: vscode.CompletionItemKind.Folder, + template: vscode.CompletionItemKind.Snippet, +} as const; + +const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { + provideCompletionItems(document, position) { + // mcpp.toml 结构补全由 mcpp.tomlCompletion 控制,按文档作用域读取。 + if (!vscode.workspace.getConfiguration("mcpp", document.uri).get("tomlCompletion", true)) { + return undefined; + } + const lines: string[] = []; + for (let line = 0; line <= position.line; line += 1) { + lines.push(document.lineAt(line).text); + } + return computeMcppTomlCompletions(lines, position.line, position.character).map((suggestion) => { + const item = new vscode.CompletionItem( + suggestion.label, + mcppTomlCompletionKinds[suggestion.kind], + ); + item.detail = suggestion.detail; + if (suggestion.documentation !== undefined) { + item.documentation = new vscode.MarkdownString(suggestion.documentation); + } + if (suggestion.insertSnippet !== undefined) { + item.insertText = new vscode.SnippetString(suggestion.insertSnippet); + } + item.range = new vscode.Range( + position.line, + suggestion.range.startCharacter, + position.line, + suggestion.range.endCharacter, + ); + return item; + }); + }, +}; + export async function activate(extensionContext: vscode.ExtensionContext): Promise { moduleStatusByProject.clear(); moduleCheckOperations.clear(); @@ -1028,6 +1070,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi output, status, ...cliController.register(), + vscode.languages.registerCompletionItemProvider( + { language: "mcpp-toml" }, + mcppTomlCompletionProvider, + "[", + ), vscode.commands.registerCommand(COMMAND_CONFIGURE, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { diff --git a/src/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts new file mode 100644 index 0000000..8694846 --- /dev/null +++ b/src/mcppTomlCompletion.ts @@ -0,0 +1,223 @@ +// mcpp.toml 的代码补全查询层(结构补全版)。 +// +// 范围:段头结构建议 + 开放词汇段的写法模板。每条建议携带显式替换范围。 +// 依赖包名/版本等动态数据补全与静态字段键/枚举补全均不在本版——前者等上游 +// 批量 catalog 接口,后者等版本化 manifest schema(见设计 issue #8 与 +// mcpp RFC #379)。 +// +// 本模块不依赖 vscode API;上下文来自 mcppTomlParser 的 contextAt(容错解析)。 + +import { + contextAt, + type ReplaceRange, + type SectionResolution, +} from "./mcppTomlParser"; + +export type McppTomlSuggestionKind = "section" | "template"; + +export interface McppTomlSuggestion { + label: string; + kind: McppTomlSuggestionKind; + detail: string; + documentation?: string; + /** 插入文本;含 $1 等 snippet 占位符。缺省时插入 label。 */ + insertSnippet?: string; + /** 替换范围(光标所在行的起止列)。 */ + range: ReplaceRange; +} + +export interface SectionHeaderSpec { + group: string; + label: string; + /** snippet 形式的段头(含 ${1:...} 占位)。 */ + header: string; + detail: string; +} + +// 段头结构清单:TOML 结构语法,非字段语义。出处:mcpp 文档 02/03/05/06 +// 与 src/manifest/toml.cppm 的段清单(契约测试用真实 mcpp 逐段验证)。 +export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ + { group: "package", label: "[package]", header: "[package]", detail: "包元数据" }, + { group: "lib", label: "[lib]", header: "[lib]", detail: "库根模块约定" }, + { group: "build", label: "[build]", header: "[build]", detail: "构建配置" }, + { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: "生成文件(路径 → 内容)" }, + { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: "运行时依赖" }, + { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: "开发/测试依赖" }, + { group: "workspace", label: "[workspace]", header: "[workspace]", detail: "工作空间成员声明" }, + { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: "集中声明依赖版本,成员用 workspace = true 继承" }, + { group: "features", label: "[features]", header: "[features]", detail: "feature 定义" }, + { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: "由 feature 拉取的可选依赖" }, + { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: "capability 绑定(provider 选择)" }, + { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: "构建目标" }, + { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: "构建档案" }, + { group: "runtime", label: "[runtime]", header: "[runtime]", detail: "主机运行时能力" }, + { group: "resources", label: "[resources]", header: "[resources]", detail: "编译进产物的元数据与资产(仅 PE 目标)" }, + { 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 过滤策略微调" }, + { group: "indices", label: "[indices]", header: "[indices]", detail: "项目级索引重定向" }, + { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: "host 工具二进制覆盖" }, + { group: "language", label: "[language]", header: "[language]", detail: "旧版兼容字段;新项目请用 [package].standard" }, +]; + +/** 依赖类段(键位置给依赖写法模板)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +interface TemplateSpec { + label: string; + detail: string; + documentation?: string; + insertSnippet: string; +} + +const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'name = "version"', + detail: "SemVer 版本依赖", + documentation: "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", + insertSnippet: '${1:name} = "${2:1.0.0}"', + }, + { + label: "name = { path = ... }", + detail: "路径依赖(本地开发)", + insertSnippet: '${1:name} = { path = "${2:../mylib}" }', + }, + { + label: "name = { git = ..., tag = ... }", + detail: "Git 依赖(tag / branch / rev 三选一)", + insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', + }, + { + label: "name = { version = ..., features = [...] }", + detail: "长式 dep spec:请求该依赖的 feature", + insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', + }, + { + label: "name = { version = ..., tools = [...] }", + detail: "依赖产出的 host 工具(须为该包的 bin target)", + insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', + }, +]; + +const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ + { label: "name = [...]", detail: "数组简写:仅隐含 feature", insertSnippet: "${1:name} = [${2}]" }, + { label: "name = { defines = [...] }", detail: "表形式:激活时贡献包自有宏", insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, + { label: "name = { requires = [...] }", detail: "表形式:需要 capability", insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, + { label: "name = { sources = [...] }", detail: "表形式:feature 门控的源 glob", insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, +]; + +const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"path" = "content"', + detail: "生成文件(相对路径 → 内容,进指纹)", + insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', + }, +]; + +const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'capability = "provider"', + detail: "capability 绑定(等价于 --cap)", + insertSnippet: '${1:blas} = "${2:compat.openblas}"', + }, +]; + +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}"' }, +]; + +const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"pkg:tool" = "path"', + detail: "用已有二进制覆盖 host 工具(跳过构建)", + insertSnippet: '"${1:compat.protobuf:protoc}" = "${2:/usr/bin/protoc}"', + }, +]; + +const TEMPLATES_BY_GROUP: Record = { + "features": FEATURE_TEMPLATES, + "generated_files": GENERATED_FILE_TEMPLATES, + "capabilities": CAPABILITY_TEMPLATES, + "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, + "xlings.envs": XLINGS_ENVS_TEMPLATES, + "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, +}; + +function sectionHeaderSuggestions(range: ReplaceRange): McppTomlSuggestion[] { + return SECTION_HEADERS.map((section) => ({ + label: section.label, + kind: "section", + detail: section.detail, + insertSnippet: section.header, + range, + })); +} + +function templateSuggestions(templates: readonly TemplateSpec[], range: ReplaceRange): McppTomlSuggestion[] { + return templates.map((template) => ({ + label: template.label, + kind: "template", + detail: template.detail, + documentation: template.documentation, + insertSnippet: template.insertSnippet, + range, + })); +} + +/** + * 计算 mcpp.toml 在指定位置的补全建议(结构补全:段头 + 写法模板)。 + */ +export function computeMcppTomlCompletions( + lines: readonly string[], + line: number, + character: number, +): McppTomlSuggestion[] { + const context = contextAt(lines, line, character); + + if (context.kind === "section-header") { + // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", + // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 + // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 + const lineText = (lines[line] ?? "").replace(/\r$/, ""); + const bracket = lineText.indexOf("["); + const firstNonWs = lineText.search(/\S/); + const range = bracket >= 0 && bracket === firstNonWs + ? { startCharacter: bracket, endCharacter: context.replaceRange.endCharacter } + : context.replaceRange; + return sectionHeaderSuggestions(range); + } + + if (context.kind === "key") { + const { section, containerPath, replaceRange } = context; + // 文档顶部(尚无段头):提示段头。未知段:不提供建议 + // (附录 A:不支持包自定义 toml 键)。 + if (section.kind === "top") { + return sectionHeaderSuggestions(replaceRange); + } + if (section.kind !== "known" || containerPath.length > 0) { + return []; + } + if (DEPENDENCY_GROUPS.has(section.group)) { + return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); + } + const templates = TEMPLATES_BY_GROUP[section.group]; + return templates === undefined ? [] : templateSuggestions(templates, replaceRange); + } + + // 值位置:自由格式值不瞎猜(版本候选等动态数据层落地后再说)。 + return []; +} diff --git a/src/mcppTomlParser.ts b/src/mcppTomlParser.ts new file mode 100644 index 0000000..1276eca --- /dev/null +++ b/src/mcppTomlParser.ts @@ -0,0 +1,890 @@ +// mcpp.toml 的容错解析层:为代码补全提供结构化信息。 +// 设计目标是容错而非校验——用户在编辑器里输入到一半(未闭合的 `[`、字符串、 +// 内联表)时不抛异常,而是把已识别的结构连同精确的 0 基行列范围返回。 +// 本模块不依赖 vscode API,可在 node --test 下直接测试;extension.ts 负责 +// 把这里的纯数据上下文映射为 CompletionItem。 +// +// 覆盖的 TOML 子集:段头(含 [[...]] 数组表、单/双引号段)、裸键/引号键/ +// 点分键、字符串(含三引号多行串)、整数、布尔、数组与内联表(可嵌套、 +// 可跨行)、行注释。CRLF 行尾在进入解析前剥除,列号按剥除后的文本计算。 + +/** 0 基行列范围,end 为开区间(与 VS Code Range 同构)。 */ +export interface TomlRange { + startLine: number; + startCharacter: number; + endLine: number; + endCharacter: number; +} + +/** 键/段路径中的一段:已去引号的名字 + 原始 token 的范围(含引号)。 */ +export interface TomlKeySegment { + name: string; + quoted: boolean; + range: TomlRange; +} + +export type TomlValueKind = "string" | "integer" | "boolean" | "array" | "inlineTable" | "unknown"; + +/** 值节点。open = true 表示结构未闭合(输入到一半),range 延伸到文档末尾。 */ +export interface TomlValueNode { + kind: TomlValueKind; + range: TomlRange; + /** 未闭合(字符串缺右引号、数组缺 ]、内联表缺 })。 */ + open: boolean; + /** 标量原文:字符串为去引号内容(未做转义还原),整数/布尔为原文 token。 */ + text?: string; + /** 字符串节点:引号字符。 */ + quote?: '"' | "'"; + /** 字符串节点:是否三引号多行串。 */ + multiline?: boolean; + /** 字符串节点:内容范围(不含引号;未闭合时延伸到扫描终点)。 */ + contentRange?: TomlRange; + /** 数组节点:已解析出的元素。 */ + elements?: TomlValueNode[]; + /** 内联表节点:已解析出的键值条目。 */ + entries?: TomlKeyValueNode[]; +} + +/** 键值对节点:keyPath 为点分键拆出的段数组(如 capi.lua → [capi, lua])。 */ +export interface TomlKeyValueNode { + type: "keyValue"; + keyPath: TomlKeySegment[]; + range: TomlRange; + /** 缺省表示 `=` 之后没有可用值(含尚未输入的情况)。 */ + value?: TomlValueNode; +} + +/** 段头节点。open = true 表示 `]` 尚未输入。 */ +export interface TomlSectionNode { + type: "section"; + /** 段路径段(已去引号,如 [target.'cfg(windows)'.build] → target / cfg(windows) / build)。 */ + segments: TomlKeySegment[]; + /** 是否为 [[...]] 数组表段头。 */ + isArray: boolean; + open: boolean; + range: TomlRange; + /** 段头所在行(0 基)。 */ + line: number; +} + +export type TomlNode = TomlSectionNode | TomlKeyValueNode; + +export interface TomlDocument { + nodes: TomlNode[]; +} + +/** 段归属语义:top = 文档顶部尚无段头;unknown = 未识别的自定义段;known = 已知段组。 */ +export type SectionResolution = + | { kind: "top" } + | { kind: "unknown"; segments: string[] } + | { kind: "known"; group: string }; + +/** 当前 token 的替换范围(与光标同行的起止列),供补全做显式 range。 */ +export interface ReplaceRange { + startCharacter: number; + endCharacter: number; +} + +/** 光标的结构化上下文(纯数据)。 */ +export type TomlCursorContext = + | { + kind: "section-header"; + /** 光标所在 token 之前已解析出的段路径段(去引号)。 */ + segments: string[]; + isArray: boolean; + replaceRange: ReplaceRange; + } + | { + kind: "key"; + section: SectionResolution; + /** 容器路径:顶层语句为 [];内联表内为从语句根键开始的路径(如 simd = { flags = [ { … 深处为 ["simd","flags"])。 */ + containerPath: string[]; + /** 同一行点分键中光标 token 之前已输入的段(如 capi.la| → ["capi"])。 */ + keyPrefix: string[]; + replaceRange: ReplaceRange; + } + | { + kind: "value"; + section: SectionResolution; + /** 所属键的完整路径(含内联表/数组下钻)。 */ + keyPath: string[]; + /** 光标处值的种类;值尚未开始或不可判定时为 undefined。 */ + valueKind: TomlValueKind | undefined; + insideString: boolean; + /** 所在字符串未闭合(不在字符串内时为 false)。 */ + stringOpen: boolean; + replaceRange: ReplaceRange; + }; + +/** 精确匹配的已知段(与 mcpp manifest 层一致)。 */ +const KNOWN_SECTIONS: ReadonlySet = new Set([ + "package", + "lib", + "build", + "generated_files", + "dependencies", + "dev-dependencies", + "build-dependencies", + "features", + "indices", + "capabilities", + "runtime", + "resources", + "toolchain", + "xlings", + "xlings.workspace", + "xlings.envs", + "workspace", + "workspace.dependencies", + "pack", + "pack.bundle-project", + "language", + "tools.overrides", +]); + +/** 参数化段基组:. 归入 (dependencies 系支持命名空间再嵌套)。 */ +const PARAMETERIZED_BASES: ReadonlySet = new Set([ + "targets", + "profile", + "feature-deps", + "dependencies", + "dev-dependencies", +]); + +function resolveGroup(segments: readonly string[]): string | undefined { + const joined = segments.join("."); + if (KNOWN_SECTIONS.has(joined)) { + return joined; + } + const head = segments[0]; + if (head === "target") { + // [target.] 或 [target..<子表>];sel 是三元组或去引号后的 + // cfg 表达式,本身不含未引号点,因此子表部分从第三段开始。 + if (segments.length <= 2) { + return "target"; + } + return resolveGroup(segments.slice(2)); + } + if (head === "runtime" && segments.length === 2) { + // [runtime.""]:带点 capability 名的显式 provider 子表。 + return "runtime.capability"; + } + if (PARAMETERIZED_BASES.has(head) && segments.length >= 2) { + return head; + } + if (head === "workspace" && segments[1] === "dependencies" && segments.length >= 3) { + return "workspace.dependencies"; + } + return undefined; +} + +/** + * 把解析出的段路径规范化为语义组。空 segments 表示文档顶部(尚无段头), + * 未识别的段返回 unknown,由调用方区分这两种情况。 + */ +export function resolveSection(segments: readonly string[]): SectionResolution { + if (segments.length === 0) { + return { kind: "top" }; + } + const group = resolveGroup(segments); + return group === undefined + ? { kind: "unknown", segments: [...segments] } + : { kind: "known", group }; +} + +interface Pos { + line: number; + col: number; +} + +/** 内部控制流信号:光标上下文已捕获,提前结束扫描。解析本身永不抛异常。 */ +const CONTEXT_FOUND = Symbol("mcppTomlContextFound"); + +function isBareKeyChar(ch: string): boolean { + return ( + (ch >= "a" && ch <= "z") || + (ch >= "A" && ch <= "Z") || + (ch >= "0" && ch <= "9") || + ch === "_" || + ch === "-" + ); +} + +/** 逐行容错扫描器。行列均为 0 基,列按剥除 \r 后的文本计算。 */ +class Scanner { + private readonly lines: string[]; + private line = 0; + private col = 0; + private readonly cursor: Pos | undefined; + /** 光标上下文捕获结果。 */ + context: TomlCursorContext | undefined; + /** 扫描过程中最近经过的段头,用于给键/值上下文标注段归属。 */ + private currentSection: TomlSectionNode | undefined; + + constructor(lines: readonly string[], cursor?: Pos) { + this.lines = lines + .map((text) => (text.endsWith("\r") ? text.slice(0, -1) : text)); + if (this.lines.length === 0) { + this.lines.push(""); + } + if (cursor !== undefined) { + // 越界坐标钳制到文档内,保证补全在任意光标位置都能得到上下文。 + const line = Math.min(Math.max(0, cursor.line), this.lines.length - 1); + const col = Math.min(Math.max(0, cursor.col), this.lines[line].length); + this.cursor = { line, col }; + } + } + + // ---- 基础游标操作 ---- + + private pos(): Pos { + return { line: this.line, col: this.col }; + } + + private eof(): boolean { + return this.line >= this.lines.length; + } + + private atEol(): boolean { + return this.eof() || this.col >= this.lines[this.line].length; + } + + private peek(): string { + return this.atEol() ? "" : this.lines[this.line][this.col]; + } + + private peekAt(offset: number): string { + if (this.eof()) { + return ""; + } + const text = this.lines[this.line]; + return this.col + offset < text.length ? text[this.col + offset] : ""; + } + + private advance(): void { + if (this.eof()) { + return; + } + this.col += 1; + if (this.col > this.lines[this.line].length) { + this.line += 1; + this.col = 0; + } + } + + private samePos(a: Pos, b: Pos): boolean { + return a.line === b.line && a.col === b.col; + } + + private cmpPos(a: Pos, b: Pos): number { + return a.line - b.line || a.col - b.col; + } + + private skipInlineWs(): void { + while (this.peek() === " " || this.peek() === "\t") { + this.col += 1; + } + } + + /** 跳过空白、换行与行注释(# 到行尾;字符串内的 # 不会走到这里)。 */ + private skipTrivia(): void { + while (!this.eof()) { + const ch = this.peek(); + if (ch === " " || ch === "\t") { + this.col += 1; + continue; + } + if (this.atEol()) { + this.line += 1; + this.col = 0; + continue; + } + if (ch === "#") { + while (!this.atEol()) { + this.col += 1; + } + continue; + } + break; + } + } + + private rangeFrom(start: Pos): TomlRange { + return { + startLine: start.line, + startCharacter: start.col, + endLine: this.line, + endCharacter: this.col, + }; + } + + private sliceText(a: Pos, b: Pos): string { + if (a.line === b.line) { + return this.lines[a.line].slice(a.col, b.col); + } + const parts = [this.lines[a.line].slice(a.col)]; + for (let l = a.line + 1; l < b.line; l += 1) { + parts.push(this.lines[l]); + } + parts.push(this.lines[b.line].slice(0, b.col)); + return parts.join("\n"); + } + + // ---- 光标上下文捕获 ---- + + private cursorReached(): boolean { + const cursor = this.cursor; + return cursor !== undefined && this.cmpPos(this.pos(), cursor) >= 0; + } + + /** 光标是否落在范围内(端点含尾:补全时光标常贴在 token 末尾)。 */ + private cursorWithin(range: TomlRange): boolean { + const cursor = this.cursor; + if (cursor === undefined) { + return false; + } + if (cursor.line < range.startLine || cursor.line > range.endLine) { + return false; + } + if (cursor.line === range.startLine && cursor.col < range.startCharacter) { + return false; + } + if (cursor.line === range.endLine && cursor.col > range.endCharacter) { + return false; + } + return true; + } + + /** 在光标行上向左/右扩展 token(遇 stop 字符停),用于字符串内容等无预扫描范围的替换区间。 */ + private expandOnLine(stop: (ch: string) => boolean): ReplaceRange { + const cursor = this.cursor; + if (cursor === undefined) { + return { startCharacter: 0, endCharacter: 0 }; + } + const text = this.lines[cursor.line] ?? ""; + const at = Math.min(cursor.col, text.length); + let start = at; + let end = at; + while (start > 0 && !stop(text[start - 1])) { + start -= 1; + } + while (end < text.length && !stop(text[end])) { + end += 1; + } + return { startCharacter: start, endCharacter: end }; + } + + private emptyReplaceRange(): ReplaceRange { + const cursor = this.cursor; + const col = cursor?.col ?? 0; + return { startCharacter: col, endCharacter: col }; + } + + private static tokenReplaceRange(range: TomlRange): ReplaceRange { + // 键/段 token 保证单行(引号键跨行即视为未闭合而截断在行尾)。 + return { startCharacter: range.startCharacter, endCharacter: range.endCharacter }; + } + + /** 单行范围转替换范围;跨行值(数组/多行串)返回 undefined,由调用方回退。 */ + private static singleLineReplaceRange(range: TomlRange): ReplaceRange | undefined { + if (range.startLine !== range.endLine) { + return undefined; + } + return { startCharacter: range.startCharacter, endCharacter: range.endCharacter }; + } + + private sectionResolution(): SectionResolution { + if (this.currentSection === undefined) { + return { kind: "top" }; + } + return resolveSection(this.currentSection.segments.map((segment) => segment.name)); + } + + private capture(context: TomlCursorContext): never { + this.context = context; + throw CONTEXT_FOUND; + } + + private captureKey(containerPath: readonly string[], keyPrefix: readonly string[], token?: TomlRange): never { + this.capture({ + kind: "key", + section: this.sectionResolution(), + containerPath: [...containerPath], + keyPrefix: [...keyPrefix], + replaceRange: token !== undefined ? Scanner.tokenReplaceRange(token) : this.emptyReplaceRange(), + }); + } + + private captureValue( + keyPath: readonly string[], + valueKind: TomlValueKind | undefined, + options?: { insideString?: boolean; stringOpen?: boolean; replaceRange?: ReplaceRange }, + ): never { + this.capture({ + kind: "value", + section: this.sectionResolution(), + keyPath: [...keyPath], + valueKind, + insideString: options?.insideString ?? false, + stringOpen: options?.stringOpen ?? false, + replaceRange: options?.replaceRange ?? this.emptyReplaceRange(), + }); + } + + private captureHeader(segments: readonly TomlKeySegment[], isArray: boolean, token?: TomlRange): never { + this.capture({ + kind: "section-header", + segments: segments.map((segment) => segment.name), + isArray, + replaceRange: token !== undefined ? Scanner.tokenReplaceRange(token) : this.emptyReplaceRange(), + }); + } + + /** 扫描到文档末尾仍未捕获光标上下文时的兜底:当前段内的顶层键位置。 */ + finalContext(): TomlCursorContext { + return { + kind: "key", + section: this.sectionResolution(), + containerPath: [], + keyPrefix: [], + replaceRange: this.emptyReplaceRange(), + }; + } + + // ---- 结构扫描 ---- + + scan(): TomlNode[] { + const nodes: TomlNode[] = []; + while (!this.eof()) { + this.skipTrivia(); + // 光标落在语句之间的空白/注释里:视为顶层键位置(新语句的起点)。 + if (this.cursorReached()) { + this.captureKey([], []); + } + if (this.eof()) { + break; + } + const start = this.pos(); + if (this.peek() === "[") { + nodes.push(this.scanHeader(start)); + } else { + const keyValue = this.scanKeyValue([]); + if (keyValue !== undefined) { + nodes.push(keyValue); + } + } + // 进度保护:任何无法识别的垃圾字符都必须被消费,保证容错扫描必然终止。 + if (this.samePos(this.pos(), start)) { + this.advance(); + } + } + return nodes; + } + + /** 段头:[a.b] / [[a.b]] / 引号段;未闭合(缺 ])时 open = true 并跳过该行剩余内容。 */ + private scanHeader(start: Pos): TomlSectionNode { + this.advance(); // [ + let isArray = false; + if (this.peek() === "[") { + isArray = true; + this.advance(); + } + const segments: TomlKeySegment[] = []; + let open = true; + for (;;) { + this.skipInlineWs(); + // 光标在段之间的空白/点号附近(如 `[targets.` 之后)。 + if (this.cursorReached()) { + this.captureHeader(segments, isArray); + } + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + if (this.cursorWithin(segment.range)) { + this.captureHeader(segments, isArray, segment.range); + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + if (this.cursorReached()) { + this.captureHeader(segments, isArray); + } + if (this.peek() === "]") { + this.advance(); + if (isArray && this.peek() === "]") { + this.advance(); + } + // 容错:[[x] 只写了一个 ] 也视为已闭合。 + open = false; + } + if (open) { + // 未闭合段头:跳过该行剩余内容,避免残余字符被当作下一条语句。 + while (!this.atEol()) { + this.col += 1; + } + } + const node: TomlSectionNode = { + type: "section", + segments, + isArray, + open, + range: this.rangeFrom(start), + line: start.line, + }; + this.currentSection = node; + return node; + } + + /** 单个键/段路径段:裸键或单/双引号键(引号键跨行即按未闭合截断)。 */ + private scanKeySegment(): TomlKeySegment | undefined { + const start = this.pos(); + const quote = this.peek(); + if (quote === '"' || quote === "'") { + this.advance(); + const contentStart = this.pos(); + while (!this.atEol() && this.peek() !== quote) { + // 双引号键内的转义(如 \")不结束键。 + if (quote === '"' && this.peek() === "\\" && this.col + 1 < this.lines[this.line].length) { + this.col += 2; + continue; + } + this.col += 1; + } + const contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + } + return { + name: this.sliceText(contentStart, contentEnd), + quoted: true, + range: this.rangeFrom(start), + }; + } + if (!isBareKeyChar(quote)) { + return undefined; + } + while (isBareKeyChar(this.peek())) { + this.col += 1; + } + return { + name: this.sliceText(start, this.pos()), + quoted: false, + range: this.rangeFrom(start), + }; + } + + /** + * 键值对:点分键 [= 值]。containerPath 是内联表递归时下钻的键路径 + * (顶层语句为 [])。无法识别出键也没有值时返回 undefined。 + */ + private scanKeyValue(containerPath: readonly string[]): TomlKeyValueNode | undefined { + const start = this.pos(); + const segments: TomlKeySegment[] = []; + for (;;) { + this.skipInlineWs(); + // 光标在键槽位的空白处(如 `{ ` 之后、键未开始时)。 + if (this.cursorReached()) { + this.captureKey(containerPath, segments.map((segment) => segment.name)); + } + const segment = this.scanKeySegment(); + if (segment === undefined) { + break; + } + if (this.cursorWithin(segment.range)) { + this.captureKey(containerPath, segments.map((segment) => segment.name), segment.range); + } + segments.push(segment); + this.skipInlineWs(); + if (this.peek() === ".") { + this.advance(); + continue; + } + break; + } + this.skipInlineWs(); + // 光标在键与 = 之间(如 `name |`):仍算键位置。 + if (this.cursorReached()) { + this.captureKey(containerPath, segments.map((segment) => segment.name)); + } + const keyNames = segments.map((segment) => segment.name); + let value: TomlValueNode | undefined; + if (this.peek() === "=") { + this.advance(); + this.skipInlineWs(); + const valuePath = containerPath.concat(keyNames); + // 光标在 = 之后、值未开始(如 `kind = |`)。 + if (this.cursorReached()) { + // 光标后方同行还有值 token(光标恰在 token 首字符、或在 = 与 token + // 之间的空白上)时,替换范围要覆盖整个 token,否则补全插入后原文残留。 + if (!this.atEol() && this.peek() !== "#") { + const ahead = this.scanValue(valuePath); // 光标落在 token 内时由 scanValue 捕获 + this.captureValue(valuePath, ahead.kind, { + replaceRange: Scanner.singleLineReplaceRange(ahead.range) ?? this.emptyReplaceRange(), + }); + } + this.captureValue(valuePath, undefined); + } + // 值必须在同行开始(TOML 本就如此);行尾没有值则按缺失处理。 + if (!this.atEol() && this.peek() !== "#") { + value = this.scanValue(valuePath); + } + } + if (segments.length === 0 && value === undefined) { + return undefined; + } + return { type: "keyValue", keyPath: segments, range: this.rangeFrom(start), value }; + } + + private scanValue(keyPath: readonly string[]): TomlValueNode { + const ch = this.peek(); + if (ch === '"' || ch === "'") { + return this.scanStringValue(keyPath); + } + if (ch === "[") { + return this.scanArrayValue(keyPath); + } + if (ch === "{") { + return this.scanInlineTableValue(keyPath); + } + return this.scanBareValue(keyPath); + } + + /** 字符串:单/双引号单行串与三引号多行串;未闭合时 open = true。 */ + private scanStringValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + const quote = this.peek() as '"' | "'"; + this.advance(); + let multiline = false; + let open = true; + let contentStart = this.pos(); + let contentEnd = this.pos(); + if (this.peek() === quote) { + this.advance(); + if (this.peek() === quote) { + // 三引号多行串。 + multiline = true; + this.advance(); + contentStart = this.pos(); + } else { + // 空字符串 "" / ''。 + open = false; + } + } + let closeStart: Pos | undefined; + if (open) { + while (!this.eof()) { + if (!multiline && this.atEol()) { + break; // 单行串跨行 → 未闭合 + } + const ch = this.peek(); + if (ch === "\\" && quote === '"') { + // 双引号串内的转义:跳过下一个字符(\" 不结束串)。 + this.advance(); + if (!this.eof() && (multiline || !this.atEol())) { + this.advance(); + } + continue; + } + if (ch === quote) { + if (multiline) { + if (this.peekAt(1) === quote && this.peekAt(2) === quote) { + contentEnd = this.pos(); + closeStart = this.pos(); + this.advance(); + this.advance(); + this.advance(); + open = false; + break; + } + this.advance(); + continue; + } + contentEnd = this.pos(); + closeStart = this.pos(); + this.advance(); + open = false; + break; + } + this.advance(); + } + if (open) { + contentEnd = this.pos(); + } + } + const node: TomlValueNode = { + kind: "string", + range: this.rangeFrom(start), + open, + text: this.sliceText(contentStart, contentEnd), + quote, + multiline, + contentRange: { + startLine: contentStart.line, + startCharacter: contentStart.col, + endLine: contentEnd.line, + endCharacter: contentEnd.col, + }, + }; + if (this.cursorWithin(node.range)) { + const cursor = this.cursor; + if (cursor !== undefined) { + const afterOpen = this.cmpPos(cursor, contentStart) >= 0; + const beforeClose = closeStart === undefined || this.cmpPos(cursor, closeStart) <= 0; + const insideString = afterOpen && beforeClose; + // 字符串内容的替换范围:扩展到本行引号边界,并钳制在内容范围内。 + let replaceRange = this.emptyReplaceRange(); + if (insideString) { + const expanded = this.expandOnLine((ch) => ch === quote); + const minCol = contentStart.line === cursor.line ? contentStart.col : 0; + const maxCol = + contentEnd.line === cursor.line ? contentEnd.col : this.lines[cursor.line]?.length ?? 0; + replaceRange = { + startCharacter: Math.max(expanded.startCharacter, minCol), + endCharacter: Math.min(Math.max(expanded.endCharacter, minCol), maxCol), + }; + } + this.captureValue(keyPath, "string", { + insideString, + stringOpen: open && afterOpen, + replaceRange, + }); + } + } + return node; + } + + /** 数组:可跨行,元素递归解析;缺 ] 时 open = true。 */ + private scanArrayValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + this.advance(); // [ + const elements: TomlValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + // 光标在元素槽位(如 `sources = [ ` 之后)。 + if (this.cursorReached()) { + this.captureValue(keyPath, undefined); + } + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "]") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const elementStart = this.pos(); + elements.push(this.scanValue(keyPath)); + if (this.samePos(this.pos(), elementStart)) { + this.advance(); // 进度保护 + } + } + return { kind: "array", range: this.rangeFrom(start), open, elements }; + } + + /** 内联表:可嵌套、可跨行,条目是完整键值对;缺 } 时 open = true。 */ + private scanInlineTableValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + this.advance(); // { + const entries: TomlKeyValueNode[] = []; + let open = true; + for (;;) { + this.skipTrivia(); + // 光标在条目键槽位(如 `simd = { flags = [ { ` 深处)。 + if (this.cursorReached()) { + this.captureKey(keyPath, []); + } + if (this.eof()) { + break; + } + const ch = this.peek(); + if (ch === "}") { + this.advance(); + open = false; + break; + } + if (ch === ",") { + this.advance(); + continue; + } + const entryStart = this.pos(); + const entry = this.scanKeyValue(keyPath); + if (entry !== undefined) { + entries.push(entry); + } + if (this.samePos(this.pos(), entryStart)) { + this.advance(); // 进度保护 + } + } + return { kind: "inlineTable", range: this.rangeFrom(start), open, entries }; + } + + /** 裸值 token:布尔、整数,其余归为 unknown(容错,不校验合法性)。 */ + private scanBareValue(keyPath: readonly string[]): TomlValueNode { + const start = this.pos(); + let end = this.pos(); + while (!this.atEol()) { + const ch = this.peek(); + if (ch === "," || ch === "]" || ch === "}" || ch === "[" || ch === "{" || ch === "#") { + break; + } + this.col += 1; + if (ch !== " " && ch !== "\t") { + end = this.pos(); + } + } + const text = this.sliceText(start, end); + let kind: TomlValueKind = "unknown"; + if (text === "true" || text === "false") { + kind = "boolean"; + } else if (/^[+-]?\d[\d_]*$/.test(text)) { + kind = "integer"; + } + const range: TomlRange = { + startLine: start.line, + startCharacter: start.col, + endLine: end.line, + endCharacter: end.col, + }; + if (this.cursorWithin(range)) { + this.captureValue(keyPath, kind, { replaceRange: Scanner.tokenReplaceRange(range) }); + } + return { kind, range, open: false, text }; + } +} + +/** 容错解析整个文档,产出带 0 基行列范围的节点树。永不抛异常。 */ +export function parseMcppToml(lines: readonly string[]): TomlDocument { + const scanner = new Scanner(lines); + return { nodes: scanner.scan() }; +} + +/** + * 计算光标的结构化上下文。line / character 均为 0 基(character 按剥除 + * \r 后的文本计算);越界坐标会被钳制到文档内。永不抛异常。 + */ +export function contextAt(lines: readonly string[], line: number, character: number): TomlCursorContext { + const scanner = new Scanner(lines, { line, col: character }); + try { + scanner.scan(); + } catch (error) { + if (error === CONTEXT_FOUND && scanner.context !== undefined) { + return scanner.context; + } + throw error; + } + // 扫描结束仍未命中:光标在文档末尾的空白处,视为当前段内的顶层键位置。 + return scanner.finalContext(); +} diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index f2d24cb..840ecce 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -33,7 +33,7 @@ test("declares the official clangd dependency and mcpp commands", () => { assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); assert.equal( manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", + "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。", ); assert.deepEqual( manifest.contributes?.commands?.map((command) => command.command), diff --git a/test/mcppTomlCompletion.test.ts b/test/mcppTomlCompletion.test.ts new file mode 100644 index 0000000..e8924eb --- /dev/null +++ b/test/mcppTomlCompletion.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + computeMcppTomlCompletions, + type McppTomlSuggestion, +} from "../src/mcppTomlCompletion"; + +function labels(suggestions: McppTomlSuggestion[]): string[] { + return suggestions.map((suggestion) => suggestion.label); +} + +test("suggests section headers on a partial bracket line", () => { + const suggestions = computeMcppTomlCompletions(["[dep"], 0, 4); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "section")); + assert.ok(labels(suggestions).includes("[dependencies]")); + assert.ok(labels(suggestions).includes("[workspace]")); + assert.ok(labels(suggestions).includes("[indices]")); + // 每条建议都带显式替换范围(覆盖已输入的 "[dep")。 + for (const suggestion of suggestions) { + assert.deepEqual(suggestion.range, { startCharacter: 0, endCharacter: 4 }); + } + // 参数化段插入 snippet。 + const targets = suggestions.find((suggestion) => suggestion.label === "[targets.]"); + assert.equal(targets?.insertSnippet, "[targets.${1:name}]"); +}); + +test("suggests section headers at the top of the document", () => { + const suggestions = computeMcppTomlCompletions([""], 0, 0); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "section")); +}); + +test("offers nothing in unknown sections", () => { + // 附录 A:不支持包自定义 toml 键;未知段不提供任何建议。 + assert.deepEqual(computeMcppTomlCompletions(["[mytool]", ""], 1, 0), []); + assert.deepEqual(computeMcppTomlCompletions(["[mytool]", "key = "], 1, 6), []); +}); + +test("offers no static field keys (removed, waiting for upstream schema)", () => { + // 静态字段键/枚举刻意不做:等上游版本化 manifest schema。 + assert.deepEqual(computeMcppTomlCompletions(["[package]", ""], 1, 0), []); + assert.deepEqual(computeMcppTomlCompletions(["[package]", "standard = "], 1, 11), []); + assert.deepEqual(computeMcppTomlCompletions(["[targets.app]", "kind = "], 1, 7), []); +}); + +test("suggests dependency writing templates", () => { + const suggestions = computeMcppTomlCompletions(["[dependencies]", ""], 1, 0); + assert.ok(suggestions.length > 0); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "template")); + const names = labels(suggestions); + assert.ok(names.includes('name = "version"')); + assert.ok(names.includes("name = { git = ..., tag = ... }")); + assert.ok(names.includes("name = { version = ..., tools = [...] }")); + assert.deepEqual(suggestions[0].range, { startCharacter: 0, endCharacter: 0 }); +}); + +test("suggests dependency templates in conditional dependency sections", () => { + const suggestions = computeMcppTomlCompletions(["[target.'cfg(windows)'.dependencies]", ""], 1, 0); + assert.ok(labels(suggestions).includes('name = "version"')); +}); + +test("suggests templates in free-key sections", () => { + const features = computeMcppTomlCompletions(["[features]", ""], 1, 0); + assert.ok(features.every((suggestion) => suggestion.kind === "template")); + assert.ok(labels(features).includes("name = { defines = [...] }")); + + const capabilities = computeMcppTomlCompletions(["[capabilities]", ""], 1, 0); + assert.ok(labels(capabilities).includes('capability = "provider"')); + + const generated = computeMcppTomlCompletions(["[generated_files]", ""], 1, 0); + assert.ok(labels(generated).includes('"path" = "content"')); +}); + +test("offers nothing at value positions", () => { + // 版本候选等动态数据层落地前,值位置不出建议。 + assert.deepEqual(computeMcppTomlCompletions(["[dependencies]", 'zlib = "'], 1, 8), []); + assert.deepEqual(computeMcppTomlCompletions(["[package]", 'name = "'], 1, 7), []); +}); + +test("offers nothing inside nested inline tables", () => { + // containerPath 非空(内联表深处)不出建议。 + assert.deepEqual(computeMcppTomlCompletions(["[features]", "simd = { flags = [ { "], 1, 21), []); +}); + +test("replacement range covers a partially typed key", () => { + const suggestions = computeMcppTomlCompletions(["[dependencies]", "na"], 1, 2); + assert.ok(suggestions.length > 0); + for (const suggestion of suggestions) { + assert.deepEqual(suggestion.range, { startCharacter: 0, endCharacter: 2 }); + } +}); diff --git a/test/mcppTomlContract.test.ts b/test/mcppTomlContract.test.ts new file mode 100644 index 0000000..dc1c4e3 --- /dev/null +++ b/test/mcppTomlContract.test.ts @@ -0,0 +1,228 @@ +// mcpp.toml 契约测试:用真实 mcpp 二进制验证扩展手写的 manifest 语义 +// (段头清单、模板键、条件段规则)与权威 parser 一致。 +// +// 没有 mcpp 的环境整个文件 skip。mcpp 调用每次 60s 超时,全程串行。 + +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import test from "node:test"; + +import { SECTION_HEADERS, type SectionHeaderSpec } from "../src/mcppTomlCompletion"; + +/** 探测 mcpp 是否可用;不可用则全部跳过。 */ +function detectMcpp(): string | undefined { + try { + const result = spawnSync("mcpp", ["--version"], { timeout: 10_000 }); + if (result.error !== undefined || result.status !== 0) { + return undefined; + } + return String(result.stdout).trim(); + } catch { + return undefined; + } +} + +const mcppVersion = detectMcpp(); +const skipReason = mcppVersion === undefined ? "未检测到 mcpp 二进制,跳过契约测试" : false; +if (skipReason !== false) { + console.log(skipReason); +} else { + console.log(`契约测试使用 ${mcppVersion}`); +} + +const MCPP_TIMEOUT_MS = 60_000; + +interface McppRun { + code: number; + output: string; +} + +/** 在指定工程目录串行执行 `mcpp build`,汇总 stdout+stderr。 */ +function runMcppBuild(projectDir: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn("mcpp", ["build"], { cwd: projectDir, timeout: MCPP_TIMEOUT_MS }); + let output = ""; + child.stdout.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { output += chunk.toString(); }); + child.on("error", reject); + child.on("close", (code, signal) => { + if (signal !== null) { + reject(new Error(`mcpp build 被信号 ${signal} 终止(疑似超时):\n${output}`)); + return; + } + resolve({ code: code ?? -1, output }); + }); + }); +} + +/** 每次新建唯一临时工程目录,注册到清理列表。 */ +const tempDirs: string[] = []; +function makeProject(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcpp-contract-")); + tempDirs.push(dir); + fs.mkdirSync(path.join(dir, "src"), { recursive: true }); + fs.writeFileSync(path.join(dir, "src", "main.cpp"), "int main() { return 0; }\n"); + return dir; +} + +process.on("exit", () => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +const BASE_MANIFEST = '[package]\nname = "probe"\nversion = "0.1.0"\n'; + +/** 写 manifest(基线 + 附加内容)与额外卖文件,然后跑 mcpp build。 */ +function buildWith(extraManifest: string, files: Record = {}): Promise { + const dir = makeProject(); + fs.writeFileSync(path.join(dir, "mcpp.toml"), `${BASE_MANIFEST}\n${extraManifest}`); + for (const [relative, content] of Object.entries(files)) { + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content); + } + return runMcppBuild(dir); +} + +/** 核心断言:构建成功且输出无 unsupported / error(大小写不敏感)。 */ +function assertClean(run: McppRun, what: string): void { + assert.equal(run.code, 0, `${what}:mcpp build 退出码 ${run.code}\n${run.output}`); + assert.ok( + !/unsupported|error/i.test(run.output), + `${what}:输出含 unsupported/error 诊断\n${run.output}`, + ); +} + +/** + * 把段头注册表条目变成可构建的具体 manifest 片段。 + * snippet 占位符取默认值(如 [targets.${1:name}] → [targets.name])。 + */ +function fixtureFor(entry: SectionHeaderSpec): { manifest: string; files: Record } { + const header = entry.header.replace(/\$\{\d+:([^}]*)\}/g, "$1"); + assert.ok(!header.includes("$"), `段头 ${entry.label} 的占位符未能全部展开`); + if (entry.group === "package") { + // [package] 永远在基线工程里,直接验证基线本身。 + return { manifest: "", files: {} }; + } + if (entry.group === "targets") { + // [targets.] 需要 kind;kind = "lib" 需要 src/.cppm 消除 lib-root 警告。 + return { + manifest: `${header}\nkind = "lib"\n`, + files: { "src/name.cppm": "export module name;\n" }, + }; + } + return { manifest: `${header}\n`, files: {} }; +} + +test("段头注册表形态:数量与关键段齐全(防止测试与实现脱节)", { skip: skipReason }, () => { + assert.ok(SECTION_HEADERS.length >= 20, `段头注册表只有 ${SECTION_HEADERS.length} 条(期望 ≥ 20)`); + const groups = new Set(SECTION_HEADERS.map((entry) => entry.group)); + for (const key of ["package", "dependencies", "workspace", "pack"]) { + assert.ok(groups.has(key), `段头注册表缺少关键段 ${key}`); + } + const labels = SECTION_HEADERS.map((entry) => entry.label); + assert.equal(new Set(labels).size, labels.length, "段头注册表存在重复 label"); +}); + +test("段头清单契约:每个注册段 mcpp 都接受、无诊断", { skip: skipReason, timeout: 600_000 }, async (t) => { + for (const entry of SECTION_HEADERS) { + await t.test(entry.label, async () => { + const fixture = fixtureFor(entry); + const run = await buildWith(fixture.manifest, fixture.files); + assertClean(run, `段 ${entry.label}`); + }); + } +}); + +test("[indices] 带 path 条目被接受(项目级索引重定向)", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith('[indices]\nmyidx = { path = "/tmp/mcpp-contract-nonexistent-index" }\n'); + assertClean(run, "[indices] 索引重定向"); +}); + +// 依赖 spec 的 12 个键:与 src/mcppTomlCompletion.ts 的 DEPENDENCY_TEMPLATES +// 保持同步(模板未逐一列出键名,此处按 mcpp manifest schema 硬编码)。 +// 注意:features/backend/tools/host-module/reexport 不是「锚定键」——单独出现 +// 时 mcpp 会把内联表当成嵌套依赖表报错,必须搭配 version/path/git/workspace +// 之一;tag/branch/rev 则必须搭配 git。写在 [feature-deps.] 下只解析、 +// 不下载(feature 未激活),适合断言「键被接受、无诊断」。 +const DEP_SPEC_CASES: ReadonlyArray = [ + ["version", 'dep = { version = "1.0.0" }'], + ["path", 'dep = { path = "../x" }'], + ["git+tag", 'dep = { git = "https://example.com/r.git", tag = "v1" }'], + ["git+branch", 'dep = { git = "https://example.com/r.git", branch = "main" }'], + ["git+rev", 'dep = { git = "https://example.com/r.git", rev = "abc123" }'], + ["features", 'dep = { version = "1.0.0", features = ["f"] }'], + ["backend", 'dep = { version = "1.0.0", backend = "cmake" }'], + ["tools", 'dep = { version = "1.0.0", tools = ["t"] }'], + ["host-module", 'dep = { version = "1.0.0", host-module = true }'], + ["reexport", 'dep = { version = "1.0.0", reexport = true }'], + ["workspace", "dep = { workspace = true }"], +]; + +test("模板键契约:[feature-deps] 下依赖 spec 各键被接受", { skip: skipReason, timeout: 600_000 }, async (t) => { + for (const [name, spec] of DEP_SPEC_CASES) { + await t.test(name, async () => { + const run = await buildWith(`[feature-deps.f1]\n${spec}\n`); + assertClean(run, `[feature-deps.f1] 依赖 spec 键 ${name}`); + }); + } +}); + +test("模板键契约:[features] 表形式与数组简写被接受", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith([ + "[features]", + 'f1 = { defines = ["X"], implies = [], sources = ["src/**"] }', + 'f2 = ["f1"]', + 'f3 = { requires = ["blas"] }', + "", + ].join("\n")); + assertClean(run, "[features] 表形式键"); +}); + +// 其余写法模板的完整实例(依赖/feature 之外的模板条目)。capabilities 的 +// provider 绑定在「无包 require 该能力」时不解析,静默通过;generated_files +// 的条目会在源 glob 展开前写入工程树,这里同时验证生成机制生效(编译成功 +// 即说明模块文件被正常纳入构建)。 +test("模板实例契约:capabilities / xlings / tools.overrides / generated_files", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith( + [ + "[capabilities]", + 'blas = "compat.openblas"', + "", + "[xlings.workspace]", + 'clang = "20.1.7"', + "", + "[xlings.envs]", + 'FOO = "1"', + "", + "[tools.overrides]", + '"compat.protobuf:protoc" = "/usr/bin/protoc"', + "", + "[generated_files]", + '"src/gen/wrap.cppm" = """', + "export module wrap;", + '"""', + "", + ].join("\n"), + ); + assertClean(run, "模板实例(capabilities/xlings/tools.overrides/generated_files)"); +}); + +test("条件段规则:[target.'cfg(windows)'.build] 接受 build inputs", { skip: skipReason, timeout: 120_000 }, async () => { + const run = await buildWith("[target.'cfg(windows)'.build]\ndefines = [\"A=1\"]\n"); + assertClean(run, "条件段 build inputs"); +}); + +test("条件段规则:反向断言——cache 在条件段必须报 unsupported", { skip: skipReason, timeout: 120_000 }, async () => { + // 钉住「条件段只接受 build inputs」:cache 属于 profile 设置,必须告警。 + const run = await buildWith("[target.'cfg(windows)'.build]\ncache = \"off\"\n"); + assert.equal(run.code, 0, `条件段 cache:mcpp build 退出码 ${run.code}\n${run.output}`); + assert.ok( + /unsupported key 'cache'/i.test(run.output), + `条件段 cache:期望 unsupported-key 警告\n${run.output}`, + ); +}); diff --git a/test/mcppTomlParser.test.ts b/test/mcppTomlParser.test.ts new file mode 100644 index 0000000..fb29820 --- /dev/null +++ b/test/mcppTomlParser.test.ts @@ -0,0 +1,461 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + contextAt, + parseMcppToml, + resolveSection, + type TomlKeyValueNode, + type TomlSectionNode, +} from "../src/mcppTomlParser"; + +function sectionAt(lines: string[], index: number): TomlSectionNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "section"); + return node as TomlSectionNode; +} + +function keyValueAt(lines: string[], index: number): TomlKeyValueNode { + const node = parseMcppToml(lines).nodes[index]; + assert.equal(node?.type, "keyValue"); + return node as TomlKeyValueNode; +} + +// ---- 段头解析 ---- + +test("解析普通段头及其范围", () => { + const section = sectionAt(["[package]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); + assert.equal(section.segments[0].quoted, false); + assert.equal(section.isArray, false); + assert.equal(section.open, false); + assert.equal(section.line, 0); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + assert.deepEqual(section.segments[0].range, { startLine: 0, startCharacter: 1, endLine: 0, endCharacter: 8 }); +}); + +test("解析参数化段头 [targets.myapp]", () => { + const section = sectionAt(["[targets.myapp]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["targets", "myapp"]); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 14 }); +}); + +test("解析单引号段 [target.'cfg(windows)'.build]", () => { + const section = sectionAt(["[target.'cfg(windows)'.build]"], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["target", "cfg(windows)", "build"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 22 }); +}); + +test("解析双引号段 [runtime.\"opengl.glx.driver\"](带点的段名是一段)", () => { + const section = sectionAt(['[runtime."opengl.glx.driver"]'], 0); + assert.deepEqual(section.segments.map((s) => s.name), ["runtime", "opengl.glx.driver"]); + assert.equal(section.segments[1].quoted, true); + assert.deepEqual(section.segments[1].range, { startLine: 0, startCharacter: 9, endLine: 0, endCharacter: 28 }); +}); + +test("解析数组表段头 [[build.flags]]", () => { + const section = sectionAt(["[[build.flags]]"], 0); + assert.equal(section.isArray, true); + assert.equal(section.open, false); + assert.deepEqual(section.segments.map((s) => s.name), ["build", "flags"]); + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 15 }); +}); + +test("未闭合段头解析为 open 状态且不抛错", () => { + const document = parseMcppToml(["[dep", 'name = "x"']); + const section = document.nodes[0] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); + // 后续行继续正常解析。 + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); +}); + +// ---- 键值解析 ---- + +test("解析裸键字符串键值对及其范围", () => { + const keyValue = keyValueAt(["[package]", 'name = "demo"'], 1); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["name"]); + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "demo"); + assert.deepEqual(keyValue.value?.range, { startLine: 1, startCharacter: 7, endLine: 1, endCharacter: 13 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("解析引号键(键名带点仍是一段)", () => { + const keyValue = keyValueAt(['"chriskohlhoff.asio" = "1.28"'], 0); + assert.equal(keyValue.keyPath.length, 1); + assert.equal(keyValue.keyPath[0].name, "chriskohlhoff.asio"); + assert.equal(keyValue.keyPath[0].quoted, true); + assert.equal(keyValue.value?.text, "1.28"); +}); + +test("解析点分键 capi.lua", () => { + const keyValue = keyValueAt(['capi.lua = "1.0"'], 0); + assert.deepEqual(keyValue.keyPath.map((s) => s.name), ["capi", "lua"]); + assert.deepEqual(keyValue.keyPath[1].range, { startLine: 0, startCharacter: 5, endLine: 0, endCharacter: 8 }); +}); + +test("解析整数与布尔值", () => { + const integer = keyValueAt(["opt = 2"], 0); + assert.equal(integer.value?.kind, "integer"); + assert.equal(integer.value?.text, "2"); + assert.deepEqual(integer.value?.range, { startLine: 0, startCharacter: 6, endLine: 0, endCharacter: 7 }); + + const boolean = keyValueAt(["debug = true"], 0); + assert.equal(boolean.value?.kind, "boolean"); + assert.equal(boolean.value?.text, "true"); + assert.deepEqual(boolean.value?.range, { startLine: 0, startCharacter: 8, endLine: 0, endCharacter: 12 }); +}); + +test("解析跨行数组", () => { + const keyValue = keyValueAt(["sources = [", ' "a",', ' "b",', "]"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, false); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 10, endLine: 3, endCharacter: 1 }); + assert.deepEqual(keyValue.value?.elements?.map((element) => element.text), ["a", "b"]); +}); + +test("未闭合数组解析为 open 状态", () => { + const keyValue = keyValueAt(["flags = [ { glob = \"x\" },"], 0); + assert.equal(keyValue.value?.kind, "array"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.elements?.length, 1); + assert.equal(keyValue.value?.elements?.[0].kind, "inlineTable"); +}); + +test("解析嵌套内联表", () => { + const keyValue = keyValueAt(['dep = { version = "1", opts = { a = 1 } }'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, false); + const entries = keyValue.value?.entries ?? []; + assert.deepEqual(entries.map((entry) => entry.keyPath.map((s) => s.name)), [["version"], ["opts"]]); + assert.equal(entries[0].value?.kind, "string"); + assert.equal(entries[1].value?.kind, "inlineTable"); + assert.equal(entries[1].value?.entries?.[0].value?.kind, "integer"); +}); + +test("未闭合内联表解析为 open 状态并保留已输入条目", () => { + const keyValue = keyValueAt(['dep = { version = "1"'], 0); + assert.equal(keyValue.value?.kind, "inlineTable"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.entries?.length, 1); + assert.deepEqual(keyValue.value?.entries?.[0].keyPath.map((s) => s.name), ["version"]); +}); + +// ---- 注释、字符串与行尾 ---- + +test("注释被跳过,字符串内的 # 不算注释", () => { + const keyValue = keyValueAt(["# 顶部注释", 'name = "a # b" # 行尾注释'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.text, "a # b"); +}); + +test("字符串内的转义引号不结束字符串", () => { + const keyValue = keyValueAt(['desc = "a \\"b\\" c"'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, 'a \\"b\\" c'); +}); + +test("解析三引号多行字符串", () => { + const keyValue = keyValueAt(['text = """', "hello", '"""'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.multiline, true); + assert.equal(keyValue.value?.open, false); + assert.equal(keyValue.value?.text, "\nhello\n"); +}); + +test("未闭合字符串解析为 open 状态", () => { + const keyValue = keyValueAt(['name = "hel'], 0); + assert.equal(keyValue.value?.kind, "string"); + assert.equal(keyValue.value?.open, true); + assert.equal(keyValue.value?.text, "hel"); +}); + +test("CRLF 行尾被剥除,列号按剥除后计算", () => { + const document = parseMcppToml(["[package]\r", 'name = "demo"\r']); + const section = document.nodes[0] as TomlSectionNode; + assert.deepEqual(section.range, { startLine: 0, startCharacter: 0, endLine: 0, endCharacter: 9 }); + const keyValue = document.nodes[1] as TomlKeyValueNode; + assert.deepEqual(keyValue.keyPath[0].range, { startLine: 1, startCharacter: 0, endLine: 1, endCharacter: 4 }); + assert.deepEqual(keyValue.value?.contentRange, { startLine: 1, startCharacter: 8, endLine: 1, endCharacter: 12 }); +}); + +test("无法识别的垃圾行不抛错、不中断后续解析", () => { + const document = parseMcppToml(["!!garbage!!", "[package]"]); + const section = document.nodes.find((node) => node.type === "section") as TomlSectionNode; + assert.deepEqual(section.segments.map((s) => s.name), ["package"]); +}); + +// ---- resolveSection 段归属语义 ---- + +test("resolveSection 精确匹配已知段", () => { + assert.deepEqual(resolveSection(["package"]), { kind: "known", group: "package" }); + assert.deepEqual(resolveSection(["build"]), { kind: "known", group: "build" }); + assert.deepEqual(resolveSection(["workspace", "dependencies"]), { kind: "known", group: "workspace.dependencies" }); + assert.deepEqual(resolveSection(["pack", "bundle-project"]), { kind: "known", group: "pack.bundle-project" }); + assert.deepEqual(resolveSection(["tools", "overrides"]), { kind: "known", group: "tools.overrides" }); + assert.deepEqual(resolveSection(["xlings", "envs"]), { kind: "known", group: "xlings.envs" }); +}); + +test("resolveSection 参数化段归入基组", () => { + assert.deepEqual(resolveSection(["targets", "myapp"]), { kind: "known", group: "targets" }); + assert.deepEqual(resolveSection(["profile", "release"]), { kind: "known", group: "profile" }); + assert.deepEqual(resolveSection(["feature-deps", "simd"]), { kind: "known", group: "feature-deps" }); + assert.deepEqual(resolveSection(["dependencies", "capi"]), { kind: "known", group: "dependencies" }); + assert.deepEqual(resolveSection(["dependencies", "capi", "lua"]), { kind: "known", group: "dependencies" }); + assert.deepEqual(resolveSection(["dev-dependencies", "ns"]), { kind: "known", group: "dev-dependencies" }); + assert.deepEqual(resolveSection(["workspace", "dependencies", "ns"]), { kind: "known", group: "workspace.dependencies" }); +}); + +test("resolveSection target 选择器与子表递归", () => { + assert.deepEqual(resolveSection(["target"]), { kind: "known", group: "target" }); + assert.deepEqual(resolveSection(["target", "x86_64-linux-gnu"]), { kind: "known", group: "target" }); + assert.deepEqual(resolveSection(["target", "cfg(windows)", "build"]), { kind: "known", group: "build" }); + assert.deepEqual(resolveSection(["target", "x", "feature-deps", "codegen"]), { kind: "known", group: "feature-deps" }); + assert.deepEqual(resolveSection(["target", "x", "dependencies", "capi"]), { kind: "known", group: "dependencies" }); +}); + +test("resolveSection runtime capability 子表", () => { + assert.deepEqual(resolveSection(["runtime"]), { kind: "known", group: "runtime" }); + assert.deepEqual(resolveSection(["runtime", "opengl.glx.driver"]), { kind: "known", group: "runtime.capability" }); +}); + +test("resolveSection 区分未知段与文档顶部", () => { + assert.deepEqual(resolveSection(["custom"]), { kind: "unknown", segments: ["custom"] }); + assert.deepEqual(resolveSection(["build", "flags"]), { kind: "unknown", segments: ["build", "flags"] }); + assert.deepEqual(resolveSection([]), { kind: "top" }); +}); + +// ---- contextAt 光标上下文 ---- + +test("contextAt 未闭合段头内:段头上下文与替换范围", () => { + const context = contextAt(["[tar"], 0, 4); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, []); + assert.equal(context.isArray, false); + assert.deepEqual(context.replaceRange, { startCharacter: 1, endCharacter: 4 }); +}); + +test("contextAt 段头点号之后:已解析段作为前缀", () => { + const context = contextAt(["[targets."], 0, 9); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, ["targets"]); + assert.deepEqual(context.replaceRange, { startCharacter: 9, endCharacter: 9 }); +}); + +test("contextAt 段头中间段的替换范围覆盖整个 token", () => { + const context = contextAt(["[targets.myapp]"], 0, 11); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.deepEqual(context.segments, ["targets"]); + assert.deepEqual(context.replaceRange, { startCharacter: 9, endCharacter: 14 }); +}); + +test("contextAt 数组表段头刚输入 [[ 时", () => { + const context = contextAt(["[["], 0, 2); + assert.equal(context.kind, "section-header"); + if (context.kind !== "section-header") return; + assert.equal(context.isArray, true); + assert.deepEqual(context.segments, []); +}); + +test("contextAt 已知段内空行:键位置并携带段归属", () => { + const context = contextAt(["[package]", ""], 1, 0); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); + assert.deepEqual(context.containerPath, []); + assert.deepEqual(context.keyPrefix, []); + assert.deepEqual(context.replaceRange, { startCharacter: 0, endCharacter: 0 }); +}); + +test("contextAt 区分文档顶部与未知段", () => { + const top = contextAt([""], 0, 0); + assert.equal(top.kind, "key"); + if (top.kind === "key") { + assert.deepEqual(top.section, { kind: "top" }); + } + + const unknown = contextAt(["[custom]", ""], 1, 0); + assert.equal(unknown.kind, "key"); + if (unknown.kind === "key") { + assert.deepEqual(unknown.section, { kind: "unknown", segments: ["custom"] }); + } +}); + +test("contextAt 点分键中间:keyPrefix 与替换范围", () => { + const context = contextAt(["capi.la"], 0, 7); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.containerPath, []); + assert.deepEqual(context.keyPrefix, ["capi"]); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 7 }); +}); + +test("contextAt 字符串值内部:insideString 与替换范围", () => { + const context = contextAt(['kind = "bin"'], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["kind"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.equal(context.stringOpen, false); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 11 }); +}); + +test("contextAt 未闭合字符串内:stringOpen 为 true", () => { + const context = contextAt(['name = "hel'], 0, 11); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["name"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.equal(context.stringOpen, true); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 11 }); +}); + +test("contextAt 等号后值未开始:valueKind 为 undefined", () => { + const context = contextAt(["kind = "], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["kind"]); + assert.equal(context.valueKind, undefined); + assert.equal(context.insideString, false); + assert.deepEqual(context.replaceRange, { startCharacter: 7, endCharacter: 7 }); +}); + +test("contextAt 数组元素槽位", () => { + const context = contextAt(["sources = [ "], 0, 12); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["sources"]); + assert.equal(context.valueKind, undefined); + assert.deepEqual(context.replaceRange, { startCharacter: 12, endCharacter: 12 }); +}); + +test("contextAt 深度下钻:features 值 → flags 数组 → 内联表条目的键位置", () => { + const context = contextAt(["[features]", "simd = { flags = [ { "], 1, 21); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "features" }); + assert.deepEqual(context.containerPath, ["simd", "flags"]); + assert.deepEqual(context.keyPrefix, []); + assert.deepEqual(context.replaceRange, { startCharacter: 21, endCharacter: 21 }); +}); + +test("contextAt 内联表条目的字符串值:keyPath 含下钻路径", () => { + const context = contextAt(['dep = { version = "1.0" }'], 0, 20); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["dep", "version"]); + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 19, endCharacter: 22 }); +}); + +test("contextAt 数组内内联表条目的值:keyPath 穿过数组", () => { + const context = contextAt(['flags = [ { glob = "src/**" } ]'], 0, 23); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["flags", "glob"]); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 20, endCharacter: 26 }); +}); + +test("contextAt 布尔 token 中间:valueKind 为 boolean 且替换范围覆盖 token", () => { + const context = contextAt(["debug = true"], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 12 }); +}); + +test("contextAt 兼容 CRLF 行尾", () => { + const context = contextAt(["[package]\r", 'name = "demo"\r'], 1, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 8, endCharacter: 12 }); +}); + +test("contextAt 越界坐标被钳制而不抛错", () => { + const context = contextAt(["[package]"], 99, 99); + assert.equal(context.kind, "key"); + if (context.kind !== "key") return; + assert.deepEqual(context.section, { kind: "known", group: "package" }); +}); + +// ---- 回归:值槽位替换范围 / 引号停止符 / 裸值停止字符 ---- + +test("contextAt 光标在 = 与值 token 之间的空白上:替换整个 token", () => { + const context = contextAt(["cmdline = true"], 0, 9); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.deepEqual(context.keyPath, ["cmdline"]); + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 10, endCharacter: 14 }); +}); + +test("contextAt 光标恰在值 token 首字符:替换范围覆盖整个 token", () => { + const context = contextAt(["cmdline = true"], 0, 10); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "boolean"); + assert.deepEqual(context.replaceRange, { startCharacter: 10, endCharacter: 14 }); +}); + +test("contextAt 光标在字符串 token 前的空白上:替换范围覆盖整个带引号 token", () => { + const context = contextAt(['name = "demo"'], 0, 6); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, false); + assert.deepEqual(context.replaceRange, { startCharacter: 7, endCharacter: 13 }); +}); + +test("contextAt 单引号字符串内的双引号不截断替换范围", () => { + const context = contextAt(["s = 'a\"b'"], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.valueKind, "string"); + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 8 }); +}); + +test("contextAt 双引号字符串内的单引号不截断替换范围", () => { + const context = contextAt(["s = \"a'b\""], 0, 7); + assert.equal(context.kind, "value"); + if (context.kind !== "value") return; + assert.equal(context.insideString, true); + assert.deepEqual(context.replaceRange, { startCharacter: 5, endCharacter: 8 }); +}); + +test("裸值不停在 [ / { 之外的后续结构:x = 1 [dep", () => { + const document = parseMcppToml(["x = 1 [dep"]); + const keyValue = document.nodes[0] as TomlKeyValueNode; + assert.equal(keyValue.type, "keyValue"); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "1"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); + const section = document.nodes[1] as TomlSectionNode; + assert.equal(section.type, "section"); + assert.equal(section.open, true); + assert.deepEqual(section.segments.map((s) => s.name), ["dep"]); +}); + +test("裸值在 { 前停止:y = 2 {k = 1}", () => { + const keyValue = keyValueAt(["y = 2 {k = 1}"], 0); + assert.equal(keyValue.value?.kind, "integer"); + assert.equal(keyValue.value?.text, "2"); + assert.deepEqual(keyValue.value?.range, { startLine: 0, startCharacter: 4, endLine: 0, endCharacter: 5 }); +}); From d5b953a525d55ff42c83c98075890e99b9a3e40f Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 23:29:52 +0800 Subject: [PATCH 02/27] =?UTF-8?q?fix:=20PR=20#9=20review=20=E6=89=93?= =?UTF-8?q?=E7=A3=A8=E2=80=94=E2=80=94=E8=A1=A5=20[build-dependencies]=20?= =?UTF-8?q?=E6=AE=B5=E5=A4=B4=E3=80=81[[...]]=20=E6=95=B0=E7=BB=84?= =?UTF-8?q?=E8=A1=A8=E4=B8=8D=E5=87=BA=E5=BB=BA=E8=AE=AE=E3=80=81README=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SECTION_HEADERS 增加 [build-dependencies](mcpp 真实依赖表,契约测试已覆盖) - [[...]] 数组表段头不提供建议,避免把数组表意图悄悄替换成普通段 [x] - 补行为测试(数组表 / build-dependencies 模板)与段头注册表关键组断言 - README:能力矩阵、已实现功能、设置三处补充结构补全说明 --- README.md | 18 +++++++++++++++++- src/mcppTomlCompletion.ts | 6 ++++++ test/mcppTomlCompletion.test.ts | 14 ++++++++++++++ test/mcppTomlContract.test.ts | 2 +- 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 326d8a7..d2884f1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 | --- | --- | --- | --- | | 识别 mcpp 工程 | 支持 | 支持 | 支持 | | `mcpp.toml` TOML 语法高亮 | 支持 | 支持 | 支持 | +| `mcpp.toml` 结构补全(段头 + 写法模板) | 支持 | 支持 | 支持 | | `build.mcpp`、`.cppm`、`.ixx`、`.mpp`、`.ccm` 文件关联 | 支持 | 支持 | 支持 | | `module`、`export module`、`import` 语法高亮 | 支持 | 支持 | 支持 | | 构建、运行、测试、清理命令 | 支持 | 支持 | 支持 | @@ -97,6 +98,19 @@ TextMate 语法规则提供。 `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`,然后: @@ -172,7 +186,8 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB "mcpp.path": "/path/to/mcpp", "mcpp.clangd.path": "/path/to/matching/clangd", "mcpp.modulesSupport": "auto", - "mcpp.configureCppTools": true + "mcpp.configureCppTools": true, + "mcpp.tomlCompletion": true } ``` @@ -182,6 +197,7 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB | `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` 只指定语言服务器,三者相互独立。 diff --git a/src/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts index 8694846..3cc0e16 100644 --- a/src/mcppTomlCompletion.ts +++ b/src/mcppTomlCompletion.ts @@ -43,6 +43,7 @@ export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: "生成文件(路径 → 内容)" }, { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: "运行时依赖" }, { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: "开发/测试依赖" }, + { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: "构建期依赖(仅构建期拉取,运行时不可见)" }, { group: "workspace", label: "[workspace]", header: "[workspace]", detail: "工作空间成员声明" }, { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: "集中声明依赖版本,成员用 workspace = true 继承" }, { group: "features", label: "[features]", header: "[features]", detail: "feature 定义" }, @@ -189,6 +190,11 @@ export function computeMcppTomlCompletions( const context = contextAt(lines, line, character); if (context.kind === "section-header") { + // mcpp manifest 不使用 TOML 数组表([[...]]);[[ 内不提供建议, + // 避免把用户意图的数组表悄悄替换成普通段 [x](未知段会被 mcpp 静默忽略)。 + if (context.isArray) { + return []; + } // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 diff --git a/test/mcppTomlCompletion.test.ts b/test/mcppTomlCompletion.test.ts index e8924eb..33983d7 100644 --- a/test/mcppTomlCompletion.test.ts +++ b/test/mcppTomlCompletion.test.ts @@ -15,6 +15,7 @@ test("suggests section headers on a partial bracket line", () => { assert.ok(suggestions.length > 0); assert.ok(suggestions.every((suggestion) => suggestion.kind === "section")); assert.ok(labels(suggestions).includes("[dependencies]")); + assert.ok(labels(suggestions).includes("[build-dependencies]")); assert.ok(labels(suggestions).includes("[workspace]")); assert.ok(labels(suggestions).includes("[indices]")); // 每条建议都带显式替换范围(覆盖已输入的 "[dep")。 @@ -26,6 +27,13 @@ test("suggests section headers on a partial bracket line", () => { assert.equal(targets?.insertSnippet, "[targets.${1:name}]"); }); +test("offers nothing inside [[...]] array-table headers", () => { + // mcpp manifest 不使用 TOML 数组表([[...]]):[[ 内不出建议, + // 避免把用户意图的数组表悄悄替换成普通段 [x]。 + assert.deepEqual(computeMcppTomlCompletions(["[[dep"], 0, 5), []); + assert.deepEqual(computeMcppTomlCompletions(["[[dependencies]"], 0, 3), []); +}); + test("suggests section headers at the top of the document", () => { const suggestions = computeMcppTomlCompletions([""], 0, 0); assert.ok(suggestions.length > 0); @@ -61,6 +69,12 @@ test("suggests dependency templates in conditional dependency sections", () => { assert.ok(labels(suggestions).includes('name = "version"')); }); +test("suggests dependency templates in build-dependencies", () => { + const suggestions = computeMcppTomlCompletions(["[build-dependencies]", ""], 1, 0); + assert.ok(labels(suggestions).includes('name = "version"')); + assert.ok(suggestions.every((suggestion) => suggestion.kind === "template")); +}); + test("suggests templates in free-key sections", () => { const features = computeMcppTomlCompletions(["[features]", ""], 1, 0); assert.ok(features.every((suggestion) => suggestion.kind === "template")); diff --git a/test/mcppTomlContract.test.ts b/test/mcppTomlContract.test.ts index dc1c4e3..a9506fb 100644 --- a/test/mcppTomlContract.test.ts +++ b/test/mcppTomlContract.test.ts @@ -121,7 +121,7 @@ function fixtureFor(entry: SectionHeaderSpec): { manifest: string; files: Record test("段头注册表形态:数量与关键段齐全(防止测试与实现脱节)", { skip: skipReason }, () => { assert.ok(SECTION_HEADERS.length >= 20, `段头注册表只有 ${SECTION_HEADERS.length} 条(期望 ≥ 20)`); const groups = new Set(SECTION_HEADERS.map((entry) => entry.group)); - for (const key of ["package", "dependencies", "workspace", "pack"]) { + for (const key of ["package", "dependencies", "build-dependencies", "workspace", "pack"]) { assert.ok(groups.has(key), `段头注册表缺少关键段 ${key}`); } const labels = SECTION_HEADERS.map((entry) => entry.label); From 2316fe79c542dea0e859ec7890994d19514cf4d4 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 23:37:11 +0800 Subject: [PATCH 03/27] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=200.2.5=20?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E6=97=A5=E5=BF=97=EF=BC=88=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E5=90=91=E5=AF=BC=20+=20mcpp.toml=20=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E8=A1=A5=E5=85=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a51eb06..0b64012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## 0.2.5 +- 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd + 配置 → 模块检查」链恢复 IDE,支持取消与失败降级;仅在受信任工作区执行(issues #6/#7)。 +- 新增 `mcpp.toml` 结构补全:段头 snippet(26 段)与依赖/feature 等写法模板,所有建议 + 携带显式替换范围并经真实 mcpp 契约测试;由 `mcpp.tomlCompletion` 设置控制(默认开启), + 未受信任工作区仅纯文本分析(PR #9)。 - 编辑器标题栏的 mcpp 运行/测试操作按当前活动文件所属的 mcpp member 作用域执行;工作区外 打开的文件不会显示这些按钮。 - 将状态栏快捷菜单名称明确为 `$(tools) mcpp: 快捷菜单`,与模块可用性状态按钮区分。 From 4367b956c2a5aee0e7d3d576d842cea187d88a28 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 8 Aug 2026 23:42:27 +0800 Subject: [PATCH 04/27] =?UTF-8?q?release:=20mcpp-vscode=200.2.6=EF=BC=88mc?= =?UTF-8?q?pp.toml=20=E7=BB=93=E6=9E=84=E8=A1=A5=E5=85=A8=20+=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=A8=A1=E5=9D=97=E9=85=8D=E7=BD=AE=E5=90=91=E5=AF=BC?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++-- package-lock.json | 4 ++-- package.json | 2 +- test/artifacts.test.ts | 2 +- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b64012..0e4177e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,16 @@ # 更新日志 -## 0.2.5 +## 0.2.6 - 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd - 配置 → 模块检查」链恢复 IDE,支持取消与失败降级;仅在受信任工作区执行(issues #6/#7)。 + 配置 → 模块检查」链恢复 IDE,支持取消与失败降级;仅在受信任工作区执行(issues #6/#7, + PR #10)。 - 新增 `mcpp.toml` 结构补全:段头 snippet(26 段)与依赖/feature 等写法模板,所有建议 携带显式替换范围并经真实 mcpp 契约测试;由 `mcpp.tomlCompletion` 设置控制(默认开启), 未受信任工作区仅纯文本分析(PR #9)。 + +## 0.2.5 + - 编辑器标题栏的 mcpp 运行/测试操作按当前活动文件所属的 mcpp member 作用域执行;工作区外 打开的文件不会显示这些按钮。 - 将状态栏快捷菜单名称明确为 `$(tools) mcpp: 快捷菜单`,与模块可用性状态按钮区分。 diff --git a/package-lock.json b/package-lock.json index ae908dd..6488a5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.2.5", + "version": "0.2.6", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index ff193c0..fe479dc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.2.5", + "version": "0.2.6", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index ebcf44b..2add919 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,7 +26,7 @@ const root = path.resolve(process.cwd()); test("declares the official clangd dependency and mcpp commands", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.2.5"); + assert.equal(manifest.version, "0.2.6"); 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")); From 9e1740cfa02be5dcb53833f227d3c723348c5b9c Mon Sep 17 00:00:00 2001 From: lildengzi <2580862656@qq.com> Date: Sun, 9 Aug 2026 01:20:59 +0800 Subject: [PATCH 05/27] =?UTF-8?q?fix:=20findXlingsExecutable=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=91=E7=8E=B0=20mcpp=20=E5=86=85=E7=BD=AE=20xling?= =?UTF-8?q?s=EF=BC=88$MCPP=5FHOME/registry/bin=20=E4=B8=8E=20MCPP=5FVENDOR?= =?UTF-8?q?ED=5FXLINGS=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/llvmTools.ts | 40 ++++++++++++++++++++++++++++----- test/llvmTools.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/llvmTools.ts b/src/llvmTools.ts index be064f9..9d7785f 100644 --- a/src/llvmTools.ts +++ b/src/llvmTools.ts @@ -60,8 +60,16 @@ export function xlingsInstallArgs(version?: string): string[] { return ["update", "llvm-tools"]; } -export function findXlingsExecutable(): string | undefined { - const home = os.homedir(); +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"), @@ -72,6 +80,26 @@ export function findXlingsExecutable(): string | undefined { ); } + // 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)) { @@ -82,15 +110,15 @@ export function findXlingsExecutable(): string | undefined { // 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() ? "xlings" : undefined; + return xlingsResolvableOnPath(env.PATH) ? "xlings" : undefined; } -function xlingsResolvableOnPath(): boolean { +function xlingsResolvableOnPath(pathValue?: string): boolean { const names = process.platform === "win32" ? ["xlings.exe", "xlings.cmd", "xlings.bat"] : ["xlings"]; - const pathValue = process.env.PATH ?? ""; - for (const dir of pathValue.split(path.delimiter)) { + const pathEnv = pathValue ?? ""; + for (const dir of pathEnv.split(path.delimiter)) { if (dir.length === 0) { continue; } diff --git a/test/llvmTools.test.ts b/test/llvmTools.test.ts index b6348f5..e87750b 100644 --- a/test/llvmTools.test.ts +++ b/test/llvmTools.test.ts @@ -1,4 +1,7 @@ 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 { @@ -68,3 +71,50 @@ test("findXlingsExecutable returns a string or undefined", () => { // 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, "xlings"); + 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, "xlings"); + 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", "xlings"); + 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 }); + } +}); From b11ce64550d7798dfce08e2ae11e7cc80d2faa58 Mon Sep 17 00:00:00 2001 From: lildengzi <2580862656@qq.com> Date: Sun, 9 Aug 2026 01:30:19 +0800 Subject: [PATCH 06/27] =?UTF-8?q?fix:=20xlings=20=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E4=BB=A5=20`mcpp=20self=20env`=20=E4=B8=BA=E6=9D=83=E5=A8=81?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=EF=BC=88=E9=A1=B9=E7=9B=AE=E7=BA=A7=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=EF=BC=89=EF=BC=8C=E8=B7=AF=E5=BE=84=E6=8E=A2=E6=B5=8B?= =?UTF-8?q?=E4=BB=85=E4=BD=9C=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cliController.ts | 2 +- src/extension.ts | 6 ++++-- src/llvmTools.ts | 26 +++++++++++++++++++++++++- test/llvmTools.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 69 insertions(+), 4 deletions(-) diff --git a/src/cliController.ts b/src/cliController.ts index ef77e6a..2416b72 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -630,7 +630,7 @@ export class McppCliController { return false; } - private mcppExecutable(project: McppProjectDiscovery | undefined): string { + public mcppExecutable(project: McppProjectDiscovery | undefined): string { const uri = project === undefined ? vscode.workspace.workspaceFolders?.[0]?.uri : vscode.Uri.file(project.root); diff --git a/src/extension.ts b/src/extension.ts index f16d760..23fb2de 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,7 +19,7 @@ import { type McppProjectDiscovery, } from "./discovery"; import { - findXlingsExecutable, + resolveXlingsExecutable, llvmToolsVersionSpec, xlingsInstallArgs, } from "./llvmTools"; @@ -763,7 +763,9 @@ async function autoConfigureModulesWizard( : { stage: "clangd", state: "failed", detail: "clangd 配置未完成。" }; } - const xlingsPath = findXlingsExecutable(); + const xlingsPath = await resolveXlingsExecutable( + cliController.mcppExecutable(currentContext.project), + ); const compilerPath = currentContext.analysis.compilerPath; if (xlingsPath === undefined || compilerPath === undefined) { return { diff --git a/src/llvmTools.ts b/src/llvmTools.ts index 9d7785f..e268b5a 100644 --- a/src/llvmTools.ts +++ b/src/llvmTools.ts @@ -4,7 +4,11 @@ import path from "node:path"; import process from "node:process"; import type { ToolIdentity } from "./analysis"; -import { runProcess, type ProcessResult } from "./process"; +import { + runProcess, + type ProcessResult, + type ProcessRunner, +} from "./process"; export function llvmToolsVersionSpec(identity: ToolIdentity): string { return `${identity.major}.${identity.minor}.${identity.patch}`; @@ -131,6 +135,26 @@ function xlingsResolvableOnPath(pathValue?: string): boolean { 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. +export async function resolveXlingsExecutable( + mcppExecutable: string, + runner: ProcessRunner = runProcess, +): Promise { + const result = await runner(mcppExecutable, ["self", "env"]); + 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(); +} + export async function runXlingsCommand( xlingsPath: string, args: string[], diff --git a/test/llvmTools.test.ts b/test/llvmTools.test.ts index e87750b..a7528a9 100644 --- a/test/llvmTools.test.ts +++ b/test/llvmTools.test.ts @@ -10,6 +10,7 @@ import { xlingsInstallArgs, deriveInstalledClangdPath, findXlingsExecutable, + resolveXlingsExecutable, } from "../src/llvmTools"; test("extracts version string from ToolIdentity", () => { @@ -118,3 +119,41 @@ test("findXlingsExecutable honors MCPP_VENDORED_XLINGS", () => { 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", "xlings"); + 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 falls back when the reported path does not exist", async () => { + const runner = async () => ({ + exitCode: 0, + stdout: "xlings binary = /no/such/xlings\n", + stderr: "", + }); + const result = await resolveXlingsExecutable("/tools/mcpp", runner); + // Fallback heuristics find nothing in this environment, so the result is + // undefined unless a standalone ~/.xlings or PATH xlings happens to exist. + assert.ok(result === undefined || typeof result === "string"); +}); + +test("resolveXlingsExecutable falls back when `mcpp self env` fails", async () => { + const runner = async () => ({ exitCode: 1, stdout: "", stderr: "boom\n" }); + const result = await resolveXlingsExecutable("/tools/mcpp", runner); + assert.ok(result === undefined || typeof result === "string"); +}); From c2f5507d79b30ae98cfd69da48b6c643aed3f215 Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 01:58:57 +0800 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20PR=20#11=20=E6=89=93=E7=A3=A8?= =?UTF-8?q?=E2=80=94=E2=80=94`mcpp=20self=20env`=20=E5=8A=A0=E8=B6=85?= =?UTF-8?q?=E6=97=B6=E4=BF=9D=E6=8A=A4=E3=80=81=E5=9B=9E=E9=80=80=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B=E6=94=AF=E6=8C=81=E6=B3=A8=E5=85=A5=E3=80=81=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=B9=B3=E5=8F=B0=E5=8F=AF=E7=A7=BB=E6=A4=8D=E3=80=81?= =?UTF-8?q?=E8=A1=A5=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 +++++ src/llvmTools.ts | 13 ++++++++-- test/llvmTools.test.ts | 54 ++++++++++++++++++++++++++++++++---------- 3 files changed, 59 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e4177e..0015cde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## 0.2.7 + +- 修复「一键配置模块代码提示」在标准 mcpp 安装(install.sh / AUR)下无法发现 mcpp 内置 + xlings 的问题:xlings 发现以 `mcpp self env` 为权威来源(项目级契约),路径探测仅作回退; + 为 `mcpp self env` 调用增加超时保护,并补齐测试(PR #11)。 + ## 0.2.6 - 新增 **mcpp: 一键配置模块代码提示** 向导:按「安装/切换工具链 → 构建 → 重载 → clangd diff --git a/src/llvmTools.ts b/src/llvmTools.ts index e268b5a..f573f05 100644 --- a/src/llvmTools.ts +++ b/src/llvmTools.ts @@ -142,17 +142,26 @@ const XLINGS_BINARY_LINE = /^\s*xlings binary\s*=\s*(.+?)\s*$/im; // 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"]); + 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(); + return findXlingsExecutable(options); } export async function runXlingsCommand( diff --git a/test/llvmTools.test.ts b/test/llvmTools.test.ts index a7528a9..8e967d7 100644 --- a/test/llvmTools.test.ts +++ b/test/llvmTools.test.ts @@ -13,6 +13,9 @@ import { 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" }), @@ -76,7 +79,7 @@ test("findXlingsExecutable returns a string or undefined", () => { 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, "xlings"); + const xlingsPath = path.join(registryBin, xlingsBinaryName); mkdirSync(registryBin, { recursive: true }); writeFileSync(xlingsPath, "#!/bin/sh\n"); try { @@ -92,7 +95,7 @@ test("findXlingsExecutable finds the xlings bundled in $MCPP_HOME/registry/bin", 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, "xlings"); + const xlingsPath = path.join(registryBin, xlingsBinaryName); mkdirSync(registryBin, { recursive: true }); writeFileSync(xlingsPath, "#!/bin/sh\n"); try { @@ -107,7 +110,7 @@ test("findXlingsExecutable falls back to $HOME/.mcpp/registry/bin when MCPP_HOME 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", "xlings"); + const vendored = path.join(root, "opt-mcpp", "registry", "bin", xlingsBinaryName); mkdirSync(path.dirname(vendored), { recursive: true }); writeFileSync(vendored, "#!/bin/sh\n"); try { @@ -122,7 +125,7 @@ test("findXlingsExecutable honors MCPP_VENDORED_XLINGS", () => { 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", "xlings"); + const xlingsPath = path.join(root, "registry", "bin", xlingsBinaryName); mkdirSync(path.dirname(xlingsPath), { recursive: true }); writeFileSync(xlingsPath, "#!/bin/sh\n"); const runner = async () => ({ @@ -140,20 +143,47 @@ test("resolveXlingsExecutable reads the xlings binary from `mcpp self env`", asy } }); -test("resolveXlingsExecutable falls back when the reported path does not exist", async () => { +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: "", }); - const result = await resolveXlingsExecutable("/tools/mcpp", runner); - // Fallback heuristics find nothing in this environment, so the result is - // undefined unless a standalone ~/.xlings or PATH xlings happens to exist. - assert.ok(result === undefined || typeof result === "string"); + try { + assert.equal( + await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), + undefined, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); -test("resolveXlingsExecutable falls back when `mcpp self env` fails", async () => { +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" }); - const result = await resolveXlingsExecutable("/tools/mcpp", runner); - assert.ok(result === undefined || typeof result === "string"); + try { + assert.equal( + await resolveXlingsExecutable("/tools/mcpp", runner, { home, env: {} }), + undefined, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } }); From 2fcaf26e8346b56a23f308458dffeeeb5ad50d8a Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 02:06:49 +0800 Subject: [PATCH 08/27] =?UTF-8?q?release:=20mcpp-vscode=200.2.7=EF=BC=88?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=A0=87=E5=87=86=E5=AE=89=E8=A3=85=E4=B8=8B?= =?UTF-8?q?=20xlings=20=E5=8F=91=E7=8E=B0=EF=BC=8C=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=90=91=E5=AF=BC=E9=80=82=E9=85=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- test/artifacts.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6488a5a..4d170ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.2.6", + "version": "0.2.7", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index fe479dc..647824e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.2.6", + "version": "0.2.7", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 2add919..fc7be0c 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,7 +26,7 @@ const root = path.resolve(process.cwd()); test("declares the official clangd dependency and mcpp commands", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.2.6"); + assert.equal(manifest.version, "0.2.7"); 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")); From 2892481a0a33c9d6bb12c7f472974c42b6617dda Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 02:08:27 +0800 Subject: [PATCH 09/27] =?UTF-8?q?docs:=20README=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E8=87=B3=200.2.7=EF=BC=88=E7=89=88=E6=9C=AC=E5=8F=B7=E3=80=81?= =?UTF-8?q?=E4=BB=93=E5=BA=93=E5=9C=B0=E5=9D=80=E8=BF=81=E7=A7=BB=20mcpp-c?= =?UTF-8?q?ommunity=E3=80=81xlings=20=E5=AE=9A=E4=BD=8D=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d2884f1..3b0792d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 -当前版本为 `0.2.5`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 +当前版本为 `0.2.7`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 > 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 @@ -33,11 +33,11 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 ## 安装 -从 [GitHub Releases](https://github.com/wellwei/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.2.5.vsix +code --install-extension /path/to/mcpp-vscode-0.2.7.vsix ``` 安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 @@ -364,6 +364,8 @@ macOS 从图形界面启动 VS Code 时可能没有继承终端 `PATH`。将 `mc - 执行 **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`。 @@ -400,13 +402,13 @@ API、状态栏、任务和 clangd 集成。 版本完全一致的 tag: ```sh -git tag -a v0.2.5 -m "mcpp-vscode 0.2.5" -git push origin v0.2.5 +git tag -a v0.2.7 -m "mcpp-vscode 0.2.7" +git push origin v0.2.7 ``` `.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, 并创建或更新对应的 GitHub Release。工作流当前不会发布到 VS Marketplace。 -项目地址:[wellwei/mcpp-vscode](https://github.com/wellwei/mcpp-vscode) +项目地址:[mcpp-community/mcpp-vscode](https://github.com/mcpp-community/mcpp-vscode) -问题反馈:[GitHub Issues](https://github.com/wellwei/mcpp-vscode/issues) +问题反馈:[GitHub Issues](https://github.com/mcpp-community/mcpp-vscode/issues) From e1eb80e37add55f3d85ec0fd3d22a7847ba8bede Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 02:10:17 +0800 Subject: [PATCH 10/27] =?UTF-8?q?meta:=20package.json=20=E4=BB=93=E5=BA=93?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E8=BF=81=E7=A7=BB=E8=87=B3=20mcpp-c?= =?UTF-8?q?ommunity=EF=BC=88repository/homepage/bugs=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 647824e..d0232c2 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,11 @@ "icon": "images/logo.png", "repository": { "type": "git", - "url": "https://github.com/wellwei/mcpp-vscode.git" + "url": "https://github.com/mcpp-community/mcpp-vscode.git" }, - "homepage": "https://github.com/wellwei/mcpp-vscode#readme", + "homepage": "https://github.com/mcpp-community/mcpp-vscode#readme", "bugs": { - "url": "https://github.com/wellwei/mcpp-vscode/issues" + "url": "https://github.com/mcpp-community/mcpp-vscode/issues" }, "engines": { "vscode": "^1.90.0" From 736ecbb994a3113517292767ed1c0a15f3feeecb Mon Sep 17 00:00:00 2001 From: wellwei Date: Sun, 9 Aug 2026 02:12:05 +0800 Subject: [PATCH 11/27] =?UTF-8?q?test:=20=E5=90=8C=E6=AD=A5=20artifacts=20?= =?UTF-8?q?=E6=96=AD=E8=A8=80=E4=BB=93=E5=BA=93=E5=9C=B0=E5=9D=80=E8=87=B3?= =?UTF-8?q?=20mcpp-community?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/artifacts.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index fc7be0c..72961f7 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -301,9 +301,9 @@ test("新建工程契约是创建并打开,不自动构建", () => { test("声明 GitHub 仓库和扩展图标", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.equal(manifest.icon, "images/logo.png"); - assert.equal(manifest.repository?.url, "https://github.com/wellwei/mcpp-vscode.git"); - assert.equal(manifest.homepage, "https://github.com/wellwei/mcpp-vscode#readme"); - assert.equal(manifest.bugs?.url, "https://github.com/wellwei/mcpp-vscode/issues"); + assert.equal(manifest.repository?.url, "https://github.com/mcpp-community/mcpp-vscode.git"); + assert.equal(manifest.homepage, "https://github.com/mcpp-community/mcpp-vscode#readme"); + assert.equal(manifest.bugs?.url, "https://github.com/mcpp-community/mcpp-vscode/issues"); const icon = readFileSync(path.join(root, manifest.icon)); assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); From 51a5821df0738048804e68c08bdab1e3390435b0 Mon Sep 17 00:00:00 2001 From: wellwei Date: Thu, 6 Aug 2026 17:08:44 +0800 Subject: [PATCH 12/27] feat: bootstrap clangd from mcpp IDE configure --- package.json | 5 ++ src/extension.ts | 105 ++++++++++++++++++++++++++++++++++++++- src/ideProtocol.ts | 97 ++++++++++++++++++++++++++++++++++++ src/ideWorkflow.ts | 40 +++++++++++++++ test/artifacts.test.ts | 11 ++++ test/ideProtocol.test.ts | 72 +++++++++++++++++++++++++++ test/ideWorkflow.test.ts | 78 +++++++++++++++++++++++++++++ 7 files changed, 406 insertions(+), 2 deletions(-) create mode 100644 src/ideProtocol.ts create mode 100644 src/ideWorkflow.ts create mode 100644 test/ideProtocol.test.ts create mode 100644 test/ideWorkflow.test.ts diff --git a/package.json b/package.json index d0232c2..6692245 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "onCommand:mcpp.showToolchains", "onCommand:mcpp.installToolchain", "onCommand:mcpp.selectDefaultToolchain", + "onCommand:mcpp.configureIde", "onCommand:mcpp.autoConfigureModules" ], "categories": [ @@ -104,6 +105,10 @@ "command": "mcpp.selectDefaultToolchain", "title": "mcpp: 选择全局默认工具链" }, + { + "command": "mcpp.configureIde", + "title": "mcpp: 生成 IDE 编译数据库" + }, { "command": "mcpp.configureClangd", "title": "mcpp: 配置 clangd" diff --git a/src/extension.ts b/src/extension.ts index 23fb2de..d658e3a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -26,6 +26,8 @@ import { import { CLI_COMMANDS } from "./commands"; import { McppCliController } from "./cliController"; import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; +import { runIdeConfigure, type IdeSnapshotPublished } from "./ideProtocol"; +import { ensureIdeConfigured } from "./ideWorkflow"; import { configurationReadyAfterRestart, configurationAffectsModuleSupport, @@ -55,6 +57,7 @@ import { } from "./moduleSetup"; const COMMAND_CONFIGURE = "mcpp.configureClangd"; +const COMMAND_IDE_CONFIGURE = "mcpp.configureIde"; const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; const COMMAND_CHECK = "mcpp.checkModuleSupport"; @@ -86,6 +89,43 @@ const moduleStatusByProject = new Map(); const moduleCheckOperations = createLatestOperationTracker(); let lastReconciledProjectRoot: string | undefined; +function mcppExecutable(project: McppProjectDiscovery): string { + const configured = projectConfiguration(project).get("path", "").trim(); + return configured.length === 0 ? "mcpp" : configured; +} + +async function runIdeConfigureForProject( + project: McppProjectDiscovery, + output: vscode.OutputChannel, + interactive: boolean, +): Promise { + if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { + appendOutputLine(output, "[IDE 配置] 工作区未受信任,跳过 mcpp ide configure。"); + if (interactive) { + await vscode.window.showWarningMessage( + "当前工作区未受信任,mcpp 不会执行 IDE 配置。请先信任工作区。", + ); + } + return undefined; + } + + const executable = mcppExecutable(project); + const args = ["ide", "configure", "--format", "ndjson"]; + try { + const result = await runIdeConfigure(project.root, executable); + appendProcessOutput(output, "IDE 配置", executable, args, result); + appendOutputLine(output, `[IDE 配置] 已发布 CDB:${result.compileCommands}`); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[IDE 配置] ${message}`); + if (interactive) { + await vscode.window.showErrorMessage(`mcpp IDE 配置失败:${message}`); + } + return undefined; + } +} + function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; if (activeEditor !== undefined) { @@ -902,9 +942,9 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi project: McppProjectDiscovery | undefined, forceRestart: boolean, ): Promise => { - const context = loadProjectContext(project); - updateStatusBar(status, context); + let context = loadProjectContext(project); if (context === undefined) { + updateStatusBar(status, context); return { context, databaseFound: false, @@ -912,6 +952,32 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }; } + // 首次打开工程时,先发布配置阶段 CDB,再让 clangd 读取它;普通源码 + // 是否能编译成功不参与这个决定。已有 CDB 仍走兼容的旧流程。 + try { + const outcome = await ensureIdeConfigured({ + projectRoot: context.project.root, + compilationDatabasePath: context.project.compilationDatabasePath, + trusted: vscode.workspace.isTrusted, + databaseExists: () => existsSync(context?.project.compilationDatabasePath ?? ""), + configure: async () => { + const published = await runIdeConfigureForProject(context!.project, output, false); + if (published === undefined) { + throw new Error("mcpp ide configure 未发布快照"); + } + return published; + }, + }); + if (outcome.state === "configured") { + context = loadProjectContext(context.project) ?? context; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[自动配置] ${message}`); + } + + updateStatusBar(status, context); + const configured = await configureClangd( context, status, @@ -1068,6 +1134,41 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await updateModuleSupportForContext(context, configured, status, output); }); })), + vscode.commands.registerCommand(COMMAND_IDE_CONFIGURE, runGuarded(async () => { + const project = findCurrentProject(); + if (project === undefined) { + await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); + return; + } + await executeWithWorkspaceClangd(async () => { + const context = loadProjectContext(project); + if (context === undefined) { + return; + } + try { + const outcome = await ensureIdeConfigured({ + projectRoot: project.root, + compilationDatabasePath: project.compilationDatabasePath, + trusted: vscode.workspace.isTrusted, + force: true, + databaseExists: () => existsSync(project.compilationDatabasePath), + configure: async () => { + const published = await runIdeConfigureForProject(project, output, true); + if (published === undefined) { + throw new Error("mcpp ide configure 未发布快照"); + } + return published; + }, + }); + if (outcome.state === "configured") { + await reconcileProjectContext(project, true); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + appendOutputLine(output, `[IDE 配置] ${message}`); + } + }); + })), vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { await cliController.runProjectTask("build"); })), diff --git a/src/ideProtocol.ts b/src/ideProtocol.ts new file mode 100644 index 0000000..5b9faad --- /dev/null +++ b/src/ideProtocol.ts @@ -0,0 +1,97 @@ +import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; + +export interface IdeSnapshotPublished { + phase: "configured" | "ready"; + compileCommands: string; + compatibilityCompileCommands?: string; + snapshotId?: string; + configurationId?: string; +} + +interface IdeEvent { + seq: number; + type: string; + phase?: string; + compileCommands?: unknown; + compatibilityCompileCommands?: unknown; + snapshotId?: unknown; + configurationId?: unknown; + status?: unknown; +} + +function parseEvent(line: string): IdeEvent { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new Error("mcpp ide configure 输出包含无效 JSON"); + } + if (value === null || typeof value !== "object") { + throw new Error("mcpp ide configure 事件不是对象"); + } + const event = value as Partial; + if (typeof event.seq !== "number" || !Number.isSafeInteger(event.seq) + || event.seq <= 0 || typeof event.type !== "string") { + throw new Error("mcpp ide configure 事件缺少有效 seq/type"); + } + return event as IdeEvent; +} + +export function parseIdeConfigureOutput(output: string): IdeSnapshotPublished { + let previousSeq = 0; + let published: IdeSnapshotPublished | undefined; + let finished = false; + for (const line of output.split(/\r?\n/)) { + if (line.trim().length === 0) { + continue; + } + const event = parseEvent(line); + if (event.seq <= previousSeq) { + throw new Error("mcpp ide configure 事件序号倒退"); + } + previousSeq = event.seq; + if (event.type !== "snapshot-published") { + if (event.type === "operation-finished") { + if (event.status !== "success") { + throw new Error("mcpp ide configure 未成功完成"); + } + finished = true; + } + continue; + } + if ((event.phase !== "configured" && event.phase !== "ready") + || typeof event.compileCommands !== "string") { + throw new Error("mcpp ide configure 发布事件缺少 CDB"); + } + published = { + phase: event.phase, + compileCommands: event.compileCommands, + compatibilityCompileCommands: + typeof event.compatibilityCompileCommands === "string" + ? event.compatibilityCompileCommands + : undefined, + snapshotId: typeof event.snapshotId === "string" ? event.snapshotId : undefined, + configurationId: typeof event.configurationId === "string" ? event.configurationId : undefined, + }; + } + if (published === undefined) { + throw new Error("mcpp ide configure 没有发布快照"); + } + if (!finished) { + throw new Error("mcpp ide configure 未成功完成"); + } + return published; +} + +export async function runIdeConfigure( + projectRoot: string, + executable = "mcpp", + runner: ProcessRunner = runProcess, +): Promise { + const args = ["ide", "configure", "--format", "ndjson"]; + const result = await runner(executable, args, projectRoot); + if (result.exitCode !== 0) { + throw new Error(result.stderr.trim() || `mcpp ide configure 失败(退出码 ${result.exitCode})`); + } + return { ...result, ...parseIdeConfigureOutput(result.stdout) }; +} diff --git a/src/ideWorkflow.ts b/src/ideWorkflow.ts new file mode 100644 index 0000000..7763efb --- /dev/null +++ b/src/ideWorkflow.ts @@ -0,0 +1,40 @@ +import type { IdeSnapshotPublished } from "./ideProtocol"; + +export interface IdeConfigurationRequest { + projectRoot: string; + compilationDatabasePath: string; + trusted: boolean; + force?: boolean; + databaseExists: () => boolean; + configure: () => Promise; +} + +export type IdeConfigurationOutcome = + | { state: "configured"; compileCommands: string; snapshot: IdeSnapshotPublished } + | { state: "existing"; compileCommands: string } + | { state: "untrusted"; compileCommands?: string }; + +/** + * 决定是否需要执行 mcpp ide configure;不在这里读写 VS Code 设置, + * 便于 Extension Host 和纯 Node 测试共享同一套生命周期边界。 + */ +export async function ensureIdeConfigured( + request: IdeConfigurationRequest, +): Promise { + const exists = request.databaseExists(); + if (!request.trusted) { + return { + state: "untrusted", + ...(exists ? { compileCommands: request.compilationDatabasePath } : {}), + }; + } + if (exists && !request.force) { + return { state: "existing", compileCommands: request.compilationDatabasePath }; + } + const snapshot = await request.configure(); + return { + state: "configured", + compileCommands: snapshot.compatibilityCompileCommands ?? snapshot.compileCommands, + snapshot, + }; +} diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 72961f7..26d577d 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -47,6 +47,7 @@ test("declares the official clangd dependency and mcpp commands", () => { "mcpp.showToolchains", "mcpp.installToolchain", "mcpp.selectDefaultToolchain", + "mcpp.configureIde", "mcpp.configureClangd", "mcpp.refreshCompilationDatabase", "mcpp.checkModuleSupport", @@ -113,6 +114,16 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); }); +test("wires IDE configure before clangd reconciliation", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.ok(manifest.activationEvents?.includes("onCommand:mcpp.configureIde")); + + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + assert.match(source, /ensureIdeConfigured/); + assert.match(source, /runIdeConfigure/); + assert.match(source, /registerCommand\(COMMAND_IDE_CONFIGURE/); +}); + 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 diff --git a/test/ideProtocol.test.ts b/test/ideProtocol.test.ts new file mode 100644 index 0000000..7fa39ca --- /dev/null +++ b/test/ideProtocol.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseIdeConfigureOutput, runIdeConfigure } from "../src/ideProtocol"; + +test("parses configured snapshot without human-output regexes", () => { + const result = parseIdeConfigureOutput([ + JSON.stringify({ seq: 1, type: "operation-started", operation: "configure" }), + JSON.stringify({ + seq: 2, + type: "snapshot-published", + phase: "configured", + compileCommands: "/work/.mcpp/ide/replies/compile_commands.json", + }), + JSON.stringify({ seq: 3, type: "operation-finished", status: "success" }), + ].join("\n")); + + assert.equal(result.phase, "configured"); + assert.equal(result.compileCommands, "/work/.mcpp/ide/replies/compile_commands.json"); +}); + +test("passes configure through the configured executable and cwd", async () => { + const calls: Array<{ executable: string; args: string[]; cwd?: string }> = []; + const result = await runIdeConfigure( + "/work/app", + "/tools/mcpp", + async (executable, args, cwd) => { + calls.push({ executable, args, cwd }); + return { + exitCode: 0, + stdout: [ + JSON.stringify({ seq: 1, type: "operation-started" }), + JSON.stringify({ + seq: 2, + type: "snapshot-published", + phase: "configured", + compileCommands: "/work/app/compile_commands.json", + }), + JSON.stringify({ seq: 3, type: "operation-finished", status: "success" }), + ].join("\n"), + stderr: "", + }; + }, + ); + + assert.deepEqual(calls, [{ + executable: "/tools/mcpp", + args: ["ide", "configure", "--format", "ndjson"], + cwd: "/work/app", + }]); + assert.equal(result.compileCommands, "/work/app/compile_commands.json"); +}); + +test("rejects a published snapshot without a successful finish event", () => { + assert.throws(() => parseIdeConfigureOutput([ + JSON.stringify({ seq: 1, type: "operation-started", operation: "configure" }), + JSON.stringify({ + seq: 2, + type: "snapshot-published", + phase: "configured", + compileCommands: "/work/app/compile_commands.json", + }), + JSON.stringify({ seq: 3, type: "operation-finished", status: "failed" }), + ].join("\n")), /未成功完成/); +}); + +test("rejects human-readable output mixed into NDJSON", () => { + assert.throws(() => parseIdeConfigureOutput([ + JSON.stringify({ seq: 1, type: "operation-started" }), + "c++fly on clang: enabled", + ].join("\n")), /无效 JSON/); +}); diff --git a/test/ideWorkflow.test.ts b/test/ideWorkflow.test.ts new file mode 100644 index 0000000..80386af --- /dev/null +++ b/test/ideWorkflow.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ensureIdeConfigured } from "../src/ideWorkflow"; + +test("configures a trusted project before build when the CDB is missing", async () => { + let calls = 0; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseExists: () => false, + configure: async () => { + calls += 1; + return { + phase: "configured", + compileCommands: "/work/app/.mcpp/ide/replies/compile_commands-cfg.json", + compatibilityCompileCommands: "/work/app/compile_commands.json", + }; + }, + }); + + assert.equal(calls, 1); + assert.equal(outcome.state, "configured"); + assert.equal(outcome.compileCommands, "/work/app/compile_commands.json"); +}); + +test("keeps an existing CDB without invoking mcpp", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseExists: () => true, + configure: async () => { + called = true; + throw new Error("must not run"); + }, + }); + + assert.equal(called, false); + assert.equal(outcome.state, "existing"); +}); + +test("does not execute mcpp in an untrusted workspace", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: false, + databaseExists: () => false, + configure: async () => { + called = true; + throw new Error("must not run"); + }, + }); + + assert.equal(called, false); + assert.equal(outcome.state, "untrusted"); +}); + +test("force refresh invokes configure even when a CDB already exists", async () => { + let calls = 0; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + force: true, + databaseExists: () => true, + configure: async () => { + calls += 1; + return { phase: "configured", compileCommands: "/work/app/compile_commands.json" }; + }, + }); + + assert.equal(calls, 1); + assert.equal(outcome.state, "configured"); +}); From ff891643842dec8763a40d4b212bba8159e9c7f9 Mon Sep 17 00:00:00 2001 From: wellwei Date: Thu, 6 Aug 2026 17:16:52 +0800 Subject: [PATCH 13/27] fix: refresh IDE snapshot after project changes --- src/extension.ts | 25 ++++++++++++++++++++++++- test/artifacts.test.ts | 1 + 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/extension.ts b/src/extension.ts index d658e3a..f3d126c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -87,6 +87,9 @@ type ConfigureMode = "automatic" | "interactive"; const moduleStatusByProject = new Map(); const moduleCheckOperations = createLatestOperationTracker(); +// 记录 manifest/settings 变化触发的 IDE 配置请求;CDB watcher 只重读 +// 已发布快照,避免写 CDB 后再次启动 mcpp 形成重入。 +const forceIdeConfigureByProject = new Set(); let lastReconciledProjectRoot: string | undefined; function mcppExecutable(project: McppProjectDiscovery): string { @@ -912,6 +915,7 @@ const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { export async function activate(extensionContext: vscode.ExtensionContext): Promise { moduleStatusByProject.clear(); moduleCheckOperations.clear(); + forceIdeConfigureByProject.clear(); const output = vscode.window.createOutputChannel("mcpp"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); @@ -941,6 +945,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const reconcileProjectContext = async ( project: McppProjectDiscovery | undefined, forceRestart: boolean, + forceIdeConfigure: boolean = false, ): Promise => { let context = loadProjectContext(project); if (context === undefined) { @@ -959,6 +964,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi projectRoot: context.project.root, compilationDatabasePath: context.project.compilationDatabasePath, trusted: vscode.workspace.isTrusted, + force: forceIdeConfigure, databaseExists: () => existsSync(context?.project.compilationDatabasePath ?? ""), configure: async () => { const published = await runIdeConfigureForProject(context!.project, output, false); @@ -1005,7 +1011,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi configured: false, }; } - return reconcileProjectContext(project, forceRestart); + return reconcileProjectContext( + project, + forceRestart, + forceIdeConfigureByProject.delete(projectRoot), + ); }, ), ); @@ -1053,10 +1063,13 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (configurationAffectsModuleSupport( (section) => event.affectsConfiguration(section, uri), )) { + forceIdeConfigureByProject.add(project.root); requestCurrentProjectReconciliation(true); } }); const trustWatcher = vscode.workspace.onDidGrantWorkspaceTrust(() => { + const project = findCurrentProject(); + if (project !== undefined) forceIdeConfigureByProject.add(project.root); requestCurrentProjectReconciliation(true); }); @@ -1315,14 +1328,23 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi manifestWatcher.onDidCreate(() => { refreshStatus(); cliController.refreshStatus(); + const project = findCurrentProject(); + if (project !== undefined) forceIdeConfigureByProject.add(project.root); + requestCurrentProjectReconciliation(true); }), manifestWatcher.onDidChange(() => { refreshStatus(); cliController.refreshStatus(); + const project = findCurrentProject(); + if (project !== undefined) forceIdeConfigureByProject.add(project.root); + requestCurrentProjectReconciliation(true); }), manifestWatcher.onDidDelete(() => { refreshStatus(); cliController.refreshStatus(); + const project = findCurrentProject(); + if (project !== undefined) forceIdeConfigureByProject.add(project.root); + requestCurrentProjectReconciliation(true); }), ...registerCompilationDatabaseReconciliation( compilationDatabaseWatcher, @@ -1346,5 +1368,6 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi export function deactivate(): void { moduleStatusByProject.clear(); moduleCheckOperations.clear(); + forceIdeConfigureByProject.clear(); // VS Code 会释放 activate() 注册的所有订阅。 } diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 26d577d..b3622af 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -122,6 +122,7 @@ test("wires IDE configure before clangd reconciliation", () => { assert.match(source, /ensureIdeConfigured/); assert.match(source, /runIdeConfigure/); assert.match(source, /registerCommand\(COMMAND_IDE_CONFIGURE/); + assert.match(source, /manifestWatcher\.onDidChange\(\(\) => \{[\s\S]*requestCurrentProjectReconciliation\(true\)/); }); test("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { From ce56724a83aad6b29cbbc403a4f5704f28898767 Mon Sep 17 00:00:00 2001 From: wellwei Date: Tue, 11 Aug 2026 11:26:38 +0800 Subject: [PATCH 14/27] feat: consume configure-only compile database workflow --- CHANGELOG.md | 11 + README.md | 77 ++++--- package.json | 5 - src/cliController.ts | 27 ++- src/configureOnly.ts | 11 + src/discovery.ts | 77 ++++++- src/extension.ts | 343 ++++++++++++++++++++----------- src/ideProtocol.ts | 97 --------- src/ideWorkflow.ts | 29 +-- src/newProject.ts | 4 +- src/workflow.ts | 46 +++++ test/artifacts.test.ts | 31 ++- test/configureOnly.test.ts | 52 +++++ test/discovery.test.ts | 95 ++++++++- test/e2e/fixtures/fake-mcpp.js | 18 +- test/e2e/suite/extension.test.ts | 27 ++- test/ideProtocol.test.ts | 72 ------- test/ideWorkflow.test.ts | 76 +++++-- test/workflow.test.ts | 59 ++++-- 19 files changed, 764 insertions(+), 393 deletions(-) create mode 100644 src/configureOnly.ts delete mode 100644 src/ideProtocol.ts create mode 100644 test/configureOnly.test.ts delete mode 100644 test/ideProtocol.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0015cde..ed5cdd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 更新日志 +## 未发布 + +- 将编译数据库刷新迁移到 `mcpp build --configure-only`:不解析 stdout 人类文本,以退出码 + 和可解析 CDB 作为成功条件,失败时保留 last-known-good CDB。 +- 删除旧 `mcpp ide configure --format ndjson` 解析层和重复的 `mcpp.configureIde` 命令; + configure-only 与 build/run/test 共用项目操作锁,manifest 与 `mcpp.path` 变化按工程协调。 +- CDB watcher 只重读已发布数据库,不反向触发 configure-only;多根工作区按事件 URI 路由, + 显式刷新会分别报告 CDB 生成与 clangd 协调结果。 +- 对齐 mcpp #387 的最终 workspace 契约:virtual workspace 根不作为单一 clangd 工程, + 扩展消费当前活动 member 根的 CDB;rooted workspace 仍按根 package 处理。 + ## 0.2.7 - 修复「一键配置模块代码提示」在标准 mcpp 安装(install.sh / AUR)下无法发现 mcpp 内置 diff --git a/README.md b/README.md index 3b0792d..68e94e4 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,9 @@ code --install-extension /path/to/mcpp-vscode-0.2.7.vsix - VS Code 1.90 或更高版本。 - 包含 `mcpp.toml` 的 mcpp 工程。 -- 可执行的 mcpp。它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 +- 包含 mcpp #387(合并提交 `3f237ed`)的可执行文件;在该能力进入正式版本前, + 可使用对应主线构建。旧版 mcpp 不支持 configure-only 时,扩展会保留原有 CDB 并提示升级。 + 它可以由 xlings、官方独立安装脚本或其他受支持方式安装。 - 官方 `llvm-vs-code-extensions.vscode-clangd` 扩展。它是本扩展的功能依赖。 - 要启用模块语义,需要 LLVM mcpp 工具链、工程编译数据库,以及与该工具链来自 同一 LLVM revision 的 clangd。 @@ -57,16 +59,21 @@ xlings 安装。 ## 快速开始 -1. 使用 LLVM 工具链构建一次项目,使 mcpp 在工程根目录生成 - `compile_commands.json` 和所需 PCM。 -2. 使用 VS Code 打开包含 `mcpp.toml` 的目录。 -3. 扩展会自动分析编译数据库、选择匹配的 clangd、写入工作区配置并检查模块支持。 +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`,并在任务结束后 -重新协调 CDB 和 clangd。 +**mcpp: 刷新编译数据库**。这个命令执行 `mcpp build --configure-only`,只在退出码为 +0 且新 CDB 可解析时将其视为成功;失败时继续保留并使用原有可用 CDB。 + +virtual workspace 根不对应单一 CDB:mcpp 会把数据库分别发布到各 member 根。扩展 +因此只接管当前活动 member;请先打开该 member 的源码或 `mcpp.toml`,不会把多个 member +的 CDB 合并到虚拟根。 ## 已实现功能 @@ -78,7 +85,8 @@ xlings 安装。 - 打开已有有效 CDB 的 LLVM 工程后自动配置并检查,不需要先点击状态栏或 Reload Window。 -扩展激活本身不会静默运行 `mcpp build`,也不会自动下载、安装或切换工具链。 +扩展激活不会静默运行完整 `mcpp build`,也不会自动下载、安装或切换工具链;受信任 +工程缺少有效 CDB 时会自动运行配置阶段的 `mcpp build --configure-only`。 ### 语法高亮 @@ -141,8 +149,9 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB - 只接受当前工程最新一次模块检查的结果,迟到结果不会覆盖新状态。 - 多根工作区中只有活动 mcpp 工程接管窗口级 clangd 配置;后台工程只失效缓存,切换 过去时再协调。 -- 修改 `mcpp.clangd.path` 或 `mcpp.modulesSupport` 后自动重新配置和检查。 -- 构建即使因普通源码错误退出,只要留下可用 CDB,扩展仍会尝试恢复 IDE 状态,并把 +- 修改资源域 `mcpp.path` 时按工程标记重新生成 CDB;修改 `mcpp.clangd.path` 或 + `mcpp.modulesSupport` 时只重新配置和检查,不重复运行 mcpp。 +- 完整构建即使因普通源码错误退出,只要留下可用 CDB,扩展仍会尝试恢复 IDE 状态,并把 “构建失败”和“IDE 数据可用”分别报告。 ### mcpp CLI 与工具链管理 @@ -175,7 +184,7 @@ xlings 补齐匹配版本的 llvm-tools(含 clangd),最后重新读取 CDB | **mcpp: 安装工具链** | 确认后执行 `mcpp toolchain install ` | | **mcpp: 选择全局默认工具链** | 确认后执行 `mcpp toolchain default ` | | **mcpp: 配置 clangd** | 手动重新应用当前 LLVM 工程的 clangd 配置 | -| **mcpp: 刷新编译数据库** | 执行 `mcpp build`,随后重新读取 CDB 并协调 clangd | +| **mcpp: 刷新编译数据库** | 执行 `mcpp build --configure-only`,验证 CDB 后协调 clangd;失败时保留原有可用 CDB | | **mcpp: 检查模块支持** | 立即执行 clangd 直接检查并刷新模块状态 | | **mcpp: 一键配置模块代码提示** | 一次确认后自动安装/切换 host LLVM、执行 `mcpp build`,通过 xlings 补齐匹配 llvm-tools,重载 CDB 并配置 clangd;项目显式固定非 LLVM 工具链时停止 | @@ -233,11 +242,12 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 不能直接消费;当前版本没有 GCC 原生模块语言服务器、cpptools 模块后端或 LLVM 影子 分析副本。因此 GCC/MSVC 工程只保证模块语法高亮和 mcpp 操作。 -### 首次语义配置仍需要构建数据 +### 首次语义配置仍需要 mcpp 生成的数据 当前插件只读取 `compile_commands.json`,不解析 `mcpp.toml`,也不自行推导依赖和 -模块图。工程没有 CDB 和 PCM 时,必须先显式执行构建或“刷新编译数据库”。插件不会 -因为打开工程就自动构建,也不会自行生成、改写或修复 mcpp 没有输出的 CDB。 +模块图。受信任工程没有有效 CDB 时,插件会调用 `mcpp build --configure-only`,但不会 +自行生成或改写 mcpp 没有输出的 CDB。完整模块语义仍取决于 CDB 引用的兼容 PCM;正式 +构建、链接和测试仍由普通 `mcpp build`、`run` 或 `test` 负责。 ### clangd 模块支持仍是实验能力 @@ -253,7 +263,9 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 未受信任工作区只启用文件关联和语法高亮,不执行 CDB 中的编译器、mcpp、clangd 或 工程指定程序,也不接管 clangd 配置。授予信任后,扩展会自动重新协调当前工程;仍不 -会在没有用户操作时下载工具链或发起构建。 +会在没有用户操作时下载或切换工具链,也不会发起完整构建;缺少有效 CDB 时可以执行 +配置阶段的 configure-only。该命令不是只读操作:mcpp 仍可能执行 `build.mcpp`、解析或 +安装缺失依赖和工具链,并写入 lock、构建目录与相关元数据。 ## 当前版本能达到的效果 @@ -265,8 +277,9 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 - 在重新构建或 CDB 变化后自动刷新,不要求 Reload Window; - 通过 VS Code 任务完成常用 mcpp 项目和工具链操作。 -这些能力受 clangd 实验模块实现和 PCM 兼容性约束。工程没有 CDB 时,当前版本只能 -提供语法高亮、mcpp 命令和引导操作,不能仅凭插件恢复完整模块语义。 +这些能力受 clangd 实验模块实现和 PCM 兼容性约束。工程没有 CDB 时,扩展可以请求 +mcpp 生成配置阶段 CDB;若 mcpp 版本不支持该命令、配置失败或仍缺少兼容 PCM,则只能 +提供语法高亮、mcpp 命令和引导操作。 ## 理想最终效果 @@ -288,12 +301,13 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 ## 达到理想效果所需支持 -下面按完成程度区分必要工作。接口名称只是设计示例,不是当前 mcpp 已提供的命令。 +下面按完成程度区分必要工作。`mcpp build --configure-only` 是当前扩展采用的已实现接口; +其余机器可读工程模型和结构化诊断仍是后续协议工作。 | 支持项 | 当前程度 | 理想完成标准 | 优先级 | | --- | --- | --- | --- | | 机器可读工程模型 | 插件只能读取 CDB,mcpp 主要输出面向人的文本 | mcpp 输出带版本 schema 的成员、目标、源码、模块、依赖、工具链、缓存与诊断 | 必需 | -| IDE 配置阶段 | 必须通过正式构建间接生成 CDB | 提供类似 `mcpp resolve --message-format=json --emit-cdb` 或 `mcpp configure --ide` 的可取消命令 | 必需 | +| IDE 配置阶段 | 已使用 `mcpp build --configure-only` 生成并验证 CDB | 增加结构化进度、取消和能力协商,不把人类 stdout 当协议解析 | 部分完成 | | IDE 专用模块产物 | PCM 是正式构建的副产物 | 只构建 `std`、依赖和项目模块 PCM,不编译普通对象或链接,并按工具链身份隔离缓存 | 必需 | | 完整 CDB 与模块图 | LLVM CDB 已有部分显式 PCM 参数 | producer 和 consumer 都使用确定的绝对 PCM 路径,覆盖分区、依赖包、成员和测试目标 | 必需 | | 结构化诊断 | 未知导入等信息主要来自构建或 clangd 文本 | mcpp 输出文件、行、列、严重级别和稳定错误码,插件可精确定位配置期错误 | 必需 | @@ -311,21 +325,24 @@ GCC `.gcm` 和 MSVC `.ifc` 是编译器专用产物。mcpp 可以正常构建它 ### 阶段 1:当前版本 -- 使用用户显式构建后产生的 LLVM CDB 和 PCM。 +- 缺少有效 CDB 时,在受信任工作区自动执行 `mcpp build --configure-only`;失败时保留 + last-known-good CDB。 +- 使用 configure-only 或用户完整构建产生的 LLVM CDB 和已有 PCM。 - 自动配置 clangd、检查模块、监听变化并提供 CLI/工具链菜单。 - 一键自动安装匹配的 llvm-tools 并配置模块代码提示。 - GCC/MSVC 保持语法高亮和 mcpp 构建操作,不增加语义后端。 -### 阶段 2:mcpp IDE 协议 +### 阶段 2:机器可读工程协议增强 -- 在 mcpp 核心增加带版本的机器可读工程模型和 IDE 配置命令。 -- 生成完整模块图、结构化诊断、IDE CDB 和专用 PCM。 +- 在 mcpp 核心增加带版本的机器可读工程模型,不改变 configure-only 的退出码 + CDB + 客户端契约。 +- 补充完整模块图、结构化诊断、IDE 专用 PCM 和能力协商。 - 明确定义工作区成员、配置身份、缓存路径、进度和取消协议。 -### 阶段 3:打开即用的 LLVM 体验 +### 阶段 3:完善打开即用的 LLVM 体验 -- 插件在工作区信任后自动调用 IDE 配置阶段,而不是完整构建。 -- 支持离线缓存、last-known-good、增量刷新、过期状态和多根隔离。 +- 在已有自动 configure-only 基础上增加可取消进度、离线缓存、增量刷新和过期状态。 +- 完善按工程隔离的 last-known-good 数据与多根集成验证。 - 以真实 VS Code 端到端测试作为模块诊断和跳转能力的发布门槛。 ### 未来重新评估 GCC/MSVC @@ -355,10 +372,10 @@ macOS 从图形界面启动 VS Code 时可能没有继承终端 `PATH`。将 `mc ### 缺少模块 CDB -当前版本不会在打开工程时静默构建。执行 **mcpp: 刷新编译数据库**,或在终端运行 -`mcpp build`。无论构建成功还是失败,只要工程根目录仍没有 -`compile_commands.json`,插件都会保持“缺少模块 CDB”状态;请查看 mcpp 输出和任务 -终端,确认当前构建是否实际生成了 CDB。 +受信任工程缺少有效 CDB 时会自动尝试 `mcpp build --configure-only`,也可以手动执行 +**mcpp: 刷新编译数据库**。若 mcpp 版本不支持该参数、命令失败或生成的 CDB 无法解析, +插件会保持“缺少模块 CDB”状态,并保留原有可用 CDB;请查看 `mcpp` 输出频道。需要 +编译普通对象、生成完整项目产物或验证链接时,再在终端运行 `mcpp build`。 ### clangd 未安装或无法匹配 diff --git a/package.json b/package.json index 6692245..d0232c2 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "onCommand:mcpp.showToolchains", "onCommand:mcpp.installToolchain", "onCommand:mcpp.selectDefaultToolchain", - "onCommand:mcpp.configureIde", "onCommand:mcpp.autoConfigureModules" ], "categories": [ @@ -105,10 +104,6 @@ "command": "mcpp.selectDefaultToolchain", "title": "mcpp: 选择全局默认工具链" }, - { - "command": "mcpp.configureIde", - "title": "mcpp: 生成 IDE 编译数据库" - }, { "command": "mcpp.configureClangd", "title": "mcpp: 配置 clangd" diff --git a/src/cliController.ts b/src/cliController.ts index 2416b72..12cbaf0 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -15,7 +15,8 @@ import { type ToolchainItem, } from "./cli"; import type { McppProjectDiscovery } from "./discovery"; -import { runProcess } from "./process"; +import { runConfigureOnly as runConfigureOnlyProcess } from "./configureOnly"; +import { runProcess, type ProcessResult } from "./process"; import { McppOperationRegistry, classifyTaskExit, @@ -118,6 +119,30 @@ 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 { diff --git a/src/configureOnly.ts b/src/configureOnly.ts new file mode 100644 index 0000000..a677a74 --- /dev/null +++ b/src/configureOnly.ts @@ -0,0 +1,11 @@ +import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; + +export const configureOnlyArguments = ["build", "--configure-only"] as const; + +export function runConfigureOnly( + projectRoot: string, + executable = "mcpp", + runner: ProcessRunner = runProcess, +): Promise { + return runner(executable, [...configureOnlyArguments], projectRoot); +} diff --git a/src/discovery.ts b/src/discovery.ts index e5d0efc..64c461c 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -1,15 +1,30 @@ import { existsSync, + readFileSync, 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("\\"); } @@ -18,7 +33,7 @@ function unique(values: string[]): string[] { return values.filter((value, index) => values.indexOf(value) === index); } -function isPathWithin(candidate: string, root: string): boolean { +export function isPathWithinProject(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return relative === "" || ( relative !== ".." @@ -27,6 +42,58 @@ function isPathWithin(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, @@ -42,13 +109,19 @@ export function findNearestMcppProject( // 新创建的 VS Code 工作区路径可能尚不存在,此时按目录处理。 } - if (boundary !== undefined && !isPathWithin(current, boundary)) { + if (boundary !== undefined && !isPathWithinProject(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, diff --git a/src/extension.ts b/src/extension.ts index f3d126c..fdb7476 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -16,6 +16,10 @@ import { import { deriveClangdCandidates, findNearestMcppProject, + isPathWithinProject, + manifestProjectRoot, + projectAffectedByManifest, + shouldReconcileDeletedManifest, type McppProjectDiscovery, } from "./discovery"; import { @@ -25,15 +29,16 @@ import { } from "./llvmTools"; import { CLI_COMMANDS } from "./commands"; import { McppCliController } from "./cliController"; -import { runClangdCheck, runToolVersion, type ToolVersionResult } from "./process"; -import { runIdeConfigure, type IdeSnapshotPublished } from "./ideProtocol"; +import { runClangdCheck, runToolVersion, type ProcessResult, type ToolVersionResult } from "./process"; import { ensureIdeConfigured } from "./ideWorkflow"; import { configurationReadyAfterRestart, + configurationAffectsMcppExecution, configurationAffectsModuleSupport, createKeyedSingleFlightReconciler, createLatestOperationTracker, createSerialExecutor, + describeConfigureOnlyOutcome, describeRefreshOutcome, moduleSupportState, registerCompilationDatabaseReconciliation, @@ -57,7 +62,6 @@ import { } from "./moduleSetup"; const COMMAND_CONFIGURE = "mcpp.configureClangd"; -const COMMAND_IDE_CONFIGURE = "mcpp.configureIde"; const COMMAND_REFRESH = "mcpp.refreshCompilationDatabase"; const COMMAND_CHECK = "mcpp.checkModuleSupport"; @@ -89,46 +93,9 @@ const moduleStatusByProject = new Map(); const moduleCheckOperations = createLatestOperationTracker(); // 记录 manifest/settings 变化触发的 IDE 配置请求;CDB watcher 只重读 // 已发布快照,避免写 CDB 后再次启动 mcpp 形成重入。 -const forceIdeConfigureByProject = new Set(); +const forceConfigureOnlyByProject = new Set(); let lastReconciledProjectRoot: string | undefined; -function mcppExecutable(project: McppProjectDiscovery): string { - const configured = projectConfiguration(project).get("path", "").trim(); - return configured.length === 0 ? "mcpp" : configured; -} - -async function runIdeConfigureForProject( - project: McppProjectDiscovery, - output: vscode.OutputChannel, - interactive: boolean, -): Promise { - if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { - appendOutputLine(output, "[IDE 配置] 工作区未受信任,跳过 mcpp ide configure。"); - if (interactive) { - await vscode.window.showWarningMessage( - "当前工作区未受信任,mcpp 不会执行 IDE 配置。请先信任工作区。", - ); - } - return undefined; - } - - const executable = mcppExecutable(project); - const args = ["ide", "configure", "--format", "ndjson"]; - try { - const result = await runIdeConfigure(project.root, executable); - appendProcessOutput(output, "IDE 配置", executable, args, result); - appendOutputLine(output, `[IDE 配置] 已发布 CDB:${result.compileCommands}`); - return result; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[IDE 配置] ${message}`); - if (interactive) { - await vscode.window.showErrorMessage(`mcpp IDE 配置失败:${message}`); - } - return undefined; - } -} - function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; if (activeEditor !== undefined) { @@ -144,7 +111,10 @@ function findCurrentProject(): McppProjectDiscovery | undefined { } for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject(workspaceFolder.uri.fsPath); + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + ); if (project !== undefined) { return project; } @@ -152,6 +122,33 @@ 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; @@ -185,6 +182,10 @@ function loadProjectContext(project: McppProjectDiscovery | undefined = findCurr } } +function hasUsableCompilationDatabase(project: McppProjectDiscovery): boolean { + return loadProjectContext(project)?.analysis.capability !== "unavailable"; +} + function moduleSetupBlockedMessage(reason: ModuleSetupBlockedReason): string { switch (reason) { case "project-toolchain-override": @@ -915,7 +916,8 @@ const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { export async function activate(extensionContext: vscode.ExtensionContext): Promise { moduleStatusByProject.clear(); moduleCheckOperations.clear(); - forceIdeConfigureByProject.clear(); + forceConfigureOnlyByProject.clear(); + lastReconciledProjectRoot = undefined; const output = vscode.window.createOutputChannel("mcpp"); const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 50); @@ -929,6 +931,31 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi void vscode.window.showErrorMessage(`mcpp:${message}`); } }; + 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) { + await vscode.window.showWarningMessage( + "当前工作区未受信任,不会刷新编译数据库。请先信任工作区。", + ); + } + return undefined; + } + const result = await cliController.runConfigureOnly(project); + if (result === undefined) { + appendOutputLine(output, "[CDB 配置] 已有 mcpp 操作正在运行,本次刷新已跳过。"); + if (interactive) { + await vscode.window.showWarningMessage( + "已有 mcpp 操作正在运行,暂不能刷新编译数据库。请等待当前操作完成后重试。", + ); + } + } + return result; + }; const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); const compilationDatabaseWatcher = vscode.workspace.createFileSystemWatcher("**/compile_commands.json"); const inProjectContext = registerInProjectContext({ @@ -945,7 +972,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const reconcileProjectContext = async ( project: McppProjectDiscovery | undefined, forceRestart: boolean, - forceIdeConfigure: boolean = false, + forceConfigureOnly: boolean = false, + allowConfigureOnly: boolean = true, ): Promise => { let context = loadProjectContext(project); if (context === undefined) { @@ -957,29 +985,35 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi }; } - // 首次打开工程时,先发布配置阶段 CDB,再让 clangd 读取它;普通源码 - // 是否能编译成功不参与这个决定。已有 CDB 仍走兼容的旧流程。 - try { - const outcome = await ensureIdeConfigured({ - projectRoot: context.project.root, - compilationDatabasePath: context.project.compilationDatabasePath, - trusted: vscode.workspace.isTrusted, - force: forceIdeConfigure, - databaseExists: () => existsSync(context?.project.compilationDatabasePath ?? ""), - configure: async () => { - const published = await runIdeConfigureForProject(context!.project, output, false); - if (published === undefined) { - throw new Error("mcpp ide configure 未发布快照"); - } - return published; - }, - }); - if (outcome.state === "configured") { - context = loadProjectContext(context.project) ?? context; + // 缺少可用 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}`); } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[自动配置] ${message}`); } updateStatusBar(status, context); @@ -1014,11 +1048,26 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return reconcileProjectContext( project, forceRestart, - forceIdeConfigureByProject.delete(projectRoot), + 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, @@ -1030,7 +1079,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi compilationDatabase: vscode.Uri, forceRestart: boolean, ): void => { - const project = findNearestMcppProject(compilationDatabase.fsPath); + const project = findProjectForUri(compilationDatabase); if (project === undefined) { return; } @@ -1038,7 +1087,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; } - void reconcileProject(project, forceRestart).catch((error: unknown) => { + // CDB watcher 只重读已经发布的数据库,避免配置阶段写 CDB 后再次启动 mcpp。 + void reconcilePublishedCdbByRoot(project.root, forceRestart).catch((error: unknown) => { const message = error instanceof Error ? error.message : String(error); appendOutputLine(output, `[自动配置] ${message}`); }); @@ -1046,30 +1096,88 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi 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 configurationWatcher = vscode.workspace.onDidChangeConfiguration((event) => { - const project = findCurrentProject(); + const requestManifestReconciliation = (manifestUri: vscode.Uri): void => { + refreshStatus(); + cliController.refreshStatus(); + const currentProject = findCurrentProject(); + const project = projectAffectedByManifest( + manifestUri.fsPath, + currentProject, + findProjectForUri(manifestUri), + ); if (project === undefined) { return; } - const uri = vscode.Uri.file(project.root); + 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), )) { - forceIdeConfigureByProject.add(project.root); requestCurrentProjectReconciliation(true); } }); const trustWatcher = vscode.workspace.onDidGrantWorkspaceTrust(() => { const project = findCurrentProject(); - if (project !== undefined) forceIdeConfigureByProject.add(project.root); + if (project !== undefined) forceConfigureOnlyByProject.add(project.root); requestCurrentProjectReconciliation(true); }); @@ -1084,7 +1192,15 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi return; } - const reconciled = await reconcileProject(project, true); + 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 构建失败"; @@ -1113,7 +1229,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); } }; - const cliController = new McppCliController({ + cliController = new McppCliController({ output, currentProject: findCurrentProject, afterProjectTask, @@ -1147,44 +1263,35 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await updateModuleSupportForContext(context, configured, status, output); }); })), - vscode.commands.registerCommand(COMMAND_IDE_CONFIGURE, runGuarded(async () => { + vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } await executeWithWorkspaceClangd(async () => { - const context = loadProjectContext(project); - if (context === undefined) { + const hadUsableDatabase = hasUsableCompilationDatabase(project); + const result = await runConfigureOnlyForProject(project, true); + if (result === undefined) { return; } - try { - const outcome = await ensureIdeConfigured({ - projectRoot: project.root, - compilationDatabasePath: project.compilationDatabasePath, - trusted: vscode.workspace.isTrusted, - force: true, - databaseExists: () => existsSync(project.compilationDatabasePath), - configure: async () => { - const published = await runIdeConfigureForProject(project, output, true); - if (published === undefined) { - throw new Error("mcpp ide configure 未发布快照"); - } - return published; - }, - }); - if (outcome.state === "configured") { - await reconcileProjectContext(project, true); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - appendOutputLine(output, `[IDE 配置] ${message}`); + 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") { + await vscode.window.showInformationMessage(outcome.message); + } else if (outcome.level === "warning") { + await vscode.window.showWarningMessage(outcome.message); + } else { + await vscode.window.showErrorMessage(outcome.message); } }); })), - vscode.commands.registerCommand(COMMAND_REFRESH, runGuarded(async () => { - await cliController.runProjectTask("build"); - })), vscode.commands.registerCommand(COMMAND_CHECK, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { @@ -1237,7 +1344,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await runModuleSupportCheck(ctx, status, output, "interactive"); return; case "rebuild": - await cliController.runProjectTask("build"); + await vscode.commands.executeCommand(COMMAND_REFRESH); return; } } @@ -1325,27 +1432,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi ); extensionContext.subscriptions.push( - manifestWatcher.onDidCreate(() => { - refreshStatus(); - cliController.refreshStatus(); - const project = findCurrentProject(); - if (project !== undefined) forceIdeConfigureByProject.add(project.root); - requestCurrentProjectReconciliation(true); - }), - manifestWatcher.onDidChange(() => { - refreshStatus(); - cliController.refreshStatus(); - const project = findCurrentProject(); - if (project !== undefined) forceIdeConfigureByProject.add(project.root); - requestCurrentProjectReconciliation(true); - }), - manifestWatcher.onDidDelete(() => { - refreshStatus(); - cliController.refreshStatus(); - const project = findCurrentProject(); - if (project !== undefined) forceIdeConfigureByProject.add(project.root); - requestCurrentProjectReconciliation(true); + manifestWatcher.onDidCreate((manifestUri) => requestManifestReconciliation(manifestUri)), + manifestWatcher.onDidChange((manifestUri) => { + requestManifestReconciliation(manifestUri); }), + manifestWatcher.onDidDelete((manifestUri) => requestDeletedManifestReconciliation(manifestUri)), ...registerCompilationDatabaseReconciliation( compilationDatabaseWatcher, requestAutomaticReconciliation, @@ -1357,6 +1448,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (project === undefined) { await reconcileProjectContext(undefined, false); } else { + lastReconciledProjectRoot = project.root; await reconcileProject(project, false); } } catch (error) { @@ -1368,6 +1460,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi export function deactivate(): void { moduleStatusByProject.clear(); moduleCheckOperations.clear(); - forceIdeConfigureByProject.clear(); + forceConfigureOnlyByProject.clear(); + lastReconciledProjectRoot = undefined; // VS Code 会释放 activate() 注册的所有订阅。 } diff --git a/src/ideProtocol.ts b/src/ideProtocol.ts deleted file mode 100644 index 5b9faad..0000000 --- a/src/ideProtocol.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; - -export interface IdeSnapshotPublished { - phase: "configured" | "ready"; - compileCommands: string; - compatibilityCompileCommands?: string; - snapshotId?: string; - configurationId?: string; -} - -interface IdeEvent { - seq: number; - type: string; - phase?: string; - compileCommands?: unknown; - compatibilityCompileCommands?: unknown; - snapshotId?: unknown; - configurationId?: unknown; - status?: unknown; -} - -function parseEvent(line: string): IdeEvent { - let value: unknown; - try { - value = JSON.parse(line); - } catch { - throw new Error("mcpp ide configure 输出包含无效 JSON"); - } - if (value === null || typeof value !== "object") { - throw new Error("mcpp ide configure 事件不是对象"); - } - const event = value as Partial; - if (typeof event.seq !== "number" || !Number.isSafeInteger(event.seq) - || event.seq <= 0 || typeof event.type !== "string") { - throw new Error("mcpp ide configure 事件缺少有效 seq/type"); - } - return event as IdeEvent; -} - -export function parseIdeConfigureOutput(output: string): IdeSnapshotPublished { - let previousSeq = 0; - let published: IdeSnapshotPublished | undefined; - let finished = false; - for (const line of output.split(/\r?\n/)) { - if (line.trim().length === 0) { - continue; - } - const event = parseEvent(line); - if (event.seq <= previousSeq) { - throw new Error("mcpp ide configure 事件序号倒退"); - } - previousSeq = event.seq; - if (event.type !== "snapshot-published") { - if (event.type === "operation-finished") { - if (event.status !== "success") { - throw new Error("mcpp ide configure 未成功完成"); - } - finished = true; - } - continue; - } - if ((event.phase !== "configured" && event.phase !== "ready") - || typeof event.compileCommands !== "string") { - throw new Error("mcpp ide configure 发布事件缺少 CDB"); - } - published = { - phase: event.phase, - compileCommands: event.compileCommands, - compatibilityCompileCommands: - typeof event.compatibilityCompileCommands === "string" - ? event.compatibilityCompileCommands - : undefined, - snapshotId: typeof event.snapshotId === "string" ? event.snapshotId : undefined, - configurationId: typeof event.configurationId === "string" ? event.configurationId : undefined, - }; - } - if (published === undefined) { - throw new Error("mcpp ide configure 没有发布快照"); - } - if (!finished) { - throw new Error("mcpp ide configure 未成功完成"); - } - return published; -} - -export async function runIdeConfigure( - projectRoot: string, - executable = "mcpp", - runner: ProcessRunner = runProcess, -): Promise { - const args = ["ide", "configure", "--format", "ndjson"]; - const result = await runner(executable, args, projectRoot); - if (result.exitCode !== 0) { - throw new Error(result.stderr.trim() || `mcpp ide configure 失败(退出码 ${result.exitCode})`); - } - return { ...result, ...parseIdeConfigureOutput(result.stdout) }; -} diff --git a/src/ideWorkflow.ts b/src/ideWorkflow.ts index 7763efb..954ae55 100644 --- a/src/ideWorkflow.ts +++ b/src/ideWorkflow.ts @@ -1,40 +1,45 @@ -import type { IdeSnapshotPublished } from "./ideProtocol"; +import type { ProcessResult } from "./process"; export interface IdeConfigurationRequest { projectRoot: string; compilationDatabasePath: string; trusted: boolean; force?: boolean; - databaseExists: () => boolean; - configure: () => Promise; + databaseValid: () => boolean; + configure: () => Promise; } export type IdeConfigurationOutcome = - | { state: "configured"; compileCommands: string; snapshot: IdeSnapshotPublished } + | { state: "configured"; compileCommands: string } | { state: "existing"; compileCommands: string } + | { state: "failed"; compileCommands?: string; exitCode: number } | { state: "untrusted"; compileCommands?: string }; /** - * 决定是否需要执行 mcpp ide configure;不在这里读写 VS Code 设置, + * 决定是否需要执行 mcpp build --configure-only;不在这里读写 VS Code 设置, * 便于 Extension Host 和纯 Node 测试共享同一套生命周期边界。 */ export async function ensureIdeConfigured( request: IdeConfigurationRequest, ): Promise { - const exists = request.databaseExists(); + const validBefore = request.databaseValid(); if (!request.trusted) { return { state: "untrusted", - ...(exists ? { compileCommands: request.compilationDatabasePath } : {}), + ...(validBefore ? { compileCommands: request.compilationDatabasePath } : {}), }; } - if (exists && !request.force) { + if (validBefore && !request.force) { return { state: "existing", compileCommands: request.compilationDatabasePath }; } - const snapshot = await request.configure(); + const result = await request.configure(); + const validAfter = request.databaseValid(); + if (result.exitCode === 0 && validAfter) { + return { state: "configured", compileCommands: request.compilationDatabasePath }; + } return { - state: "configured", - compileCommands: snapshot.compatibilityCompileCommands ?? snapshot.compileCommands, - snapshot, + state: "failed", + ...(validAfter ? { compileCommands: request.compilationDatabasePath } : {}), + exitCode: result.exitCode, }; } diff --git a/src/newProject.ts b/src/newProject.ts index 07cdead..e7b6f0c 100644 --- a/src/newProject.ts +++ b/src/newProject.ts @@ -10,8 +10,8 @@ export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; /** * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— - * 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程), - * 避免与缺少 CDB 时的 configure 重复执行。 + * 打开后的完整构建交给用户手动触发;新窗口按自身信任状态与 CDB 状态决定是否运行 + * `mcpp build --configure-only`,避免创建流程跨窗口重复启动配置。 */ export async function runNewProjectFlow( projectName: string, diff --git a/src/workflow.ts b/src/workflow.ts index 3def1fb..2d80817 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -182,6 +182,12 @@ export function configurationAffectsModuleSupport( 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; } @@ -230,6 +236,46 @@ export function describeRefreshOutcome( }; } +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" diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index b3622af..f09a8f4 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -47,7 +47,6 @@ test("declares the official clangd dependency and mcpp commands", () => { "mcpp.showToolchains", "mcpp.installToolchain", "mcpp.selectDefaultToolchain", - "mcpp.configureIde", "mcpp.configureClangd", "mcpp.refreshCompilationDatabase", "mcpp.checkModuleSupport", @@ -114,15 +113,35 @@ test("shows editor title buttons only inside mcpp projects", () => { assert.ok(!commands.some((command) => command.command === "mcpp.inProject")); }); -test("wires IDE configure before clangd reconciliation", () => { +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.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, /runIdeConfigure/); - assert.match(source, /registerCommand\(COMMAND_IDE_CONFIGURE/); - assert.match(source, /manifestWatcher\.onDidChange\(\(\) => \{[\s\S]*requestCurrentProjectReconciliation\(true\)/); + 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("ships syntax-only C++ highlighting for the exact build.mcpp filename", () => { diff --git a/test/configureOnly.test.ts b/test/configureOnly.test.ts new file mode 100644 index 0000000..c12c9dd --- /dev/null +++ b/test/configureOnly.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { configureOnlyArguments, runConfigureOnly } from "../src/configureOnly"; + +test("runs mcpp build --configure-only with the configured executable and cwd", async () => { + const calls: Array<{ executable: string; args: string[]; cwd?: string }> = []; + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async (executable, args, cwd) => { + calls.push({ executable, args, cwd }); + return { exitCode: 0, stdout: "Configured 2 compile commands", stderr: "" }; + }, + ); + + assert.deepEqual(calls, [{ + executable: "/tools/mcpp", + args: [...configureOnlyArguments], + cwd: "/work/app", + }]); + assert.equal(result.exitCode, 0); +}); + +test("does not interpret human-readable configure output", async () => { + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async () => ({ + exitCode: 0, + stdout: "Configured 2 compile commands\nFinished dev in 0.02s", + stderr: "", + }), + ); + + assert.equal(result.stdout, "Configured 2 compile commands\nFinished dev in 0.02s"); +}); + +test("returns a non-zero configure-only exit code without parsing stdout", async () => { + const result = await runConfigureOnly( + "/work/app", + "/tools/mcpp", + async () => ({ + exitCode: 2, + stdout: "error: unknown option '--configure-only'", + stderr: "", + }), + ); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, "error: unknown option '--configure-only'"); +}); diff --git a/test/discovery.test.ts b/test/discovery.test.ts index 2a44bae..807c126 100644 --- a/test/discovery.test.ts +++ b/test/discovery.test.ts @@ -4,7 +4,14 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { deriveClangdCandidates, findNearestMcppProject } from "../src/discovery"; +import { + deriveClangdCandidates, + findNearestMcppProject, + isPathWithinProject, + manifestProjectRoot, + projectAffectedByManifest, + shouldReconcileDeletedManifest, +} from "../src/discovery"; test("finds the nearest mcpp manifest and root compilation database", () => { const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-vscode-discovery-")); @@ -24,6 +31,14 @@ test("finds the nearest mcpp manifest and root compilation database", () => { } }); +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 { @@ -44,6 +59,84 @@ 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 { diff --git a/test/e2e/fixtures/fake-mcpp.js b/test/e2e/fixtures/fake-mcpp.js index 76daadf..b7e4b14 100644 --- a/test/e2e/fixtures/fake-mcpp.js +++ b/test/e2e/fixtures/fake-mcpp.js @@ -1,9 +1,23 @@ #!/usr/bin/env node const fs = require("node:fs"); +const path = require("node:path"); const logPath = process.env.MCPP_E2E_LOG; +const command = process.argv.slice(2).join(" "); if (typeof logPath === "string") { - fs.appendFileSync(logPath, `${process.argv.slice(2).join(" ")}\n`); + fs.appendFileSync(logPath, `${command}\n`); } -process.exit(process.argv.slice(2).join(" ") === "build" ? 0 : 1); + +if (command === "build --configure-only") { + // 使用 syntax-only CDB,避免 smoke test 依赖宿主 clangd 或真实编译器。 + const cwd = process.cwd(); + fs.writeFileSync(path.join(cwd, "compile_commands.json"), JSON.stringify([{ + directory: cwd, + file: path.join(cwd, "main.cpp"), + arguments: ["gcc", "-std=c++23", "-c", path.join(cwd, "main.cpp")], + }])); + process.exit(0); +} + +process.exit(command === "build" ? 0 : 1); diff --git a/test/e2e/suite/extension.test.ts b/test/e2e/suite/extension.test.ts index 4859237..d1d088d 100644 --- a/test/e2e/suite/extension.test.ts +++ b/test/e2e/suite/extension.test.ts @@ -4,7 +4,7 @@ import * as path from "node:path"; import * as vscode from "vscode"; suite("mcpp extension smoke", () => { - test("activates, registers commands and executes a configured build", async () => { + test("configures a missing CDB once before executing a full build", async () => { const extension = vscode.extensions.getExtension("mcpp-community.mcpp-vscode"); assert.ok(extension, "mcpp extension should be installed in development host"); await extension.activate(); @@ -22,13 +22,30 @@ suite("mcpp extension smoke", () => { await vscode.workspace.getConfiguration("mcpp", workspaceFolder.uri) .update("path", fakeMcpp, vscode.ConfigurationTarget.Workspace); - void vscode.commands.executeCommand("mcpp.build"); - const deadline = Date.now() + 15_000; - while (!existsSync(logPath) && Date.now() < deadline) { + 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"); - assert.equal(readFileSync(logPath, "utf8").trim(), "build"); + + void 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"))); assert.equal(path.basename(workspaceFolder.uri.fsPath), "project"); }); }); diff --git a/test/ideProtocol.test.ts b/test/ideProtocol.test.ts deleted file mode 100644 index 7fa39ca..0000000 --- a/test/ideProtocol.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { parseIdeConfigureOutput, runIdeConfigure } from "../src/ideProtocol"; - -test("parses configured snapshot without human-output regexes", () => { - const result = parseIdeConfigureOutput([ - JSON.stringify({ seq: 1, type: "operation-started", operation: "configure" }), - JSON.stringify({ - seq: 2, - type: "snapshot-published", - phase: "configured", - compileCommands: "/work/.mcpp/ide/replies/compile_commands.json", - }), - JSON.stringify({ seq: 3, type: "operation-finished", status: "success" }), - ].join("\n")); - - assert.equal(result.phase, "configured"); - assert.equal(result.compileCommands, "/work/.mcpp/ide/replies/compile_commands.json"); -}); - -test("passes configure through the configured executable and cwd", async () => { - const calls: Array<{ executable: string; args: string[]; cwd?: string }> = []; - const result = await runIdeConfigure( - "/work/app", - "/tools/mcpp", - async (executable, args, cwd) => { - calls.push({ executable, args, cwd }); - return { - exitCode: 0, - stdout: [ - JSON.stringify({ seq: 1, type: "operation-started" }), - JSON.stringify({ - seq: 2, - type: "snapshot-published", - phase: "configured", - compileCommands: "/work/app/compile_commands.json", - }), - JSON.stringify({ seq: 3, type: "operation-finished", status: "success" }), - ].join("\n"), - stderr: "", - }; - }, - ); - - assert.deepEqual(calls, [{ - executable: "/tools/mcpp", - args: ["ide", "configure", "--format", "ndjson"], - cwd: "/work/app", - }]); - assert.equal(result.compileCommands, "/work/app/compile_commands.json"); -}); - -test("rejects a published snapshot without a successful finish event", () => { - assert.throws(() => parseIdeConfigureOutput([ - JSON.stringify({ seq: 1, type: "operation-started", operation: "configure" }), - JSON.stringify({ - seq: 2, - type: "snapshot-published", - phase: "configured", - compileCommands: "/work/app/compile_commands.json", - }), - JSON.stringify({ seq: 3, type: "operation-finished", status: "failed" }), - ].join("\n")), /未成功完成/); -}); - -test("rejects human-readable output mixed into NDJSON", () => { - assert.throws(() => parseIdeConfigureOutput([ - JSON.stringify({ seq: 1, type: "operation-started" }), - "c++fly on clang: enabled", - ].join("\n")), /无效 JSON/); -}); diff --git a/test/ideWorkflow.test.ts b/test/ideWorkflow.test.ts index 80386af..0f1f415 100644 --- a/test/ideWorkflow.test.ts +++ b/test/ideWorkflow.test.ts @@ -3,35 +3,35 @@ import test from "node:test"; import { ensureIdeConfigured } from "../src/ideWorkflow"; -test("configures a trusted project before build when the CDB is missing", async () => { +const success = { exitCode: 0, stdout: "Configured 1 compile command", stderr: "" }; + +test("configures a trusted project before clangd when the CDB is missing", async () => { let calls = 0; const outcome = await ensureIdeConfigured({ projectRoot: "/work/app", compilationDatabasePath: "/work/app/compile_commands.json", trusted: true, - databaseExists: () => false, + databaseValid: () => calls > 0, configure: async () => { calls += 1; - return { - phase: "configured", - compileCommands: "/work/app/.mcpp/ide/replies/compile_commands-cfg.json", - compatibilityCompileCommands: "/work/app/compile_commands.json", - }; + return success; }, }); assert.equal(calls, 1); - assert.equal(outcome.state, "configured"); - assert.equal(outcome.compileCommands, "/work/app/compile_commands.json"); + assert.deepEqual(outcome, { + state: "configured", + compileCommands: "/work/app/compile_commands.json", + }); }); -test("keeps an existing CDB without invoking mcpp", async () => { +test("keeps a valid existing CDB without invoking mcpp", async () => { let called = false; const outcome = await ensureIdeConfigured({ projectRoot: "/work/app", compilationDatabasePath: "/work/app/compile_commands.json", trusted: true, - databaseExists: () => true, + databaseValid: () => true, configure: async () => { called = true; throw new Error("must not run"); @@ -39,7 +39,10 @@ test("keeps an existing CDB without invoking mcpp", async () => { }); assert.equal(called, false); - assert.equal(outcome.state, "existing"); + assert.deepEqual(outcome, { + state: "existing", + compileCommands: "/work/app/compile_commands.json", + }); }); test("does not execute mcpp in an untrusted workspace", async () => { @@ -48,7 +51,7 @@ test("does not execute mcpp in an untrusted workspace", async () => { projectRoot: "/work/app", compilationDatabasePath: "/work/app/compile_commands.json", trusted: false, - databaseExists: () => false, + databaseValid: () => false, configure: async () => { called = true; throw new Error("must not run"); @@ -56,23 +59,60 @@ test("does not execute mcpp in an untrusted workspace", async () => { }); assert.equal(called, false); - assert.equal(outcome.state, "untrusted"); + assert.deepEqual(outcome, { state: "untrusted" }); +}); + +test("retains an existing CDB when forced configure-only fails", async () => { + let called = false; + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + force: true, + databaseValid: () => true, + configure: async () => { + called = true; + return { exitCode: 1, stdout: "compile failed", stderr: "" }; + }, + }); + + assert.equal(called, true); + assert.deepEqual(outcome, { + state: "failed", + compileCommands: "/work/app/compile_commands.json", + exitCode: 1, + }); }); -test("force refresh invokes configure even when a CDB already exists", async () => { +test("requires a valid CDB after configure-only succeeds", async () => { + const outcome = await ensureIdeConfigured({ + projectRoot: "/work/app", + compilationDatabasePath: "/work/app/compile_commands.json", + trusted: true, + databaseValid: () => false, + configure: async () => success, + }); + + assert.deepEqual(outcome, { state: "failed", exitCode: 0 }); +}); + +test("force refresh invokes configure-only when a CDB already exists", async () => { let calls = 0; const outcome = await ensureIdeConfigured({ projectRoot: "/work/app", compilationDatabasePath: "/work/app/compile_commands.json", trusted: true, force: true, - databaseExists: () => true, + databaseValid: () => true, configure: async () => { calls += 1; - return { phase: "configured", compileCommands: "/work/app/compile_commands.json" }; + return success; }, }); assert.equal(calls, 1); - assert.equal(outcome.state, "configured"); + assert.deepEqual(outcome, { + state: "configured", + compileCommands: "/work/app/compile_commands.json", + }); }); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index e56019b..ce84c52 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -5,6 +5,9 @@ import * as workflow from "../src/workflow"; import { createSerialExecutor, createSingleFlightReconciler, + configurationAffectsMcppExecution, + configurationAffectsModuleSupport, + describeConfigureOnlyOutcome, describeRefreshOutcome, statusCommandForCapability, } from "../src/workflow"; @@ -28,6 +31,33 @@ test("keeps IDE configuration when a source build fails after producing a databa 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("serializes reconciliation and merges pending restart requests", async () => { let releaseFirst: (() => void) | undefined; const firstGate = new Promise((resolve) => { @@ -369,30 +399,29 @@ test("accepts module status only from the latest check for the current project", assert.equal(shouldRender(undefined, "/work/a"), false); }); -test("reconciles when a module-related mcpp setting changes", () => { - const affectsModuleConfiguration = ( - workflow as typeof workflow & { - configurationAffectsModuleSupport?: ( - affectsConfiguration: (section: string) => boolean, - ) => boolean; - } - ).configurationAffectsModuleSupport; - assert.equal(typeof affectsModuleConfiguration, "function"); - assert.ok(affectsModuleConfiguration); - +test("separates mcpp executable changes from clangd-only settings", () => { assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.clangd.path"), + configurationAffectsModuleSupport((section) => section === "mcpp.clangd.path"), true, ); assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.modulesSupport"), + configurationAffectsModuleSupport((section) => section === "mcpp.modulesSupport"), + true, + ); + assert.equal( + configurationAffectsModuleSupport((section) => section === "mcpp.path"), + false, + ); + assert.equal( + configurationAffectsMcppExecution((section) => section === "mcpp.path"), true, ); assert.equal( - affectsModuleConfiguration((section) => section === "mcpp.path"), + configurationAffectsMcppExecution((section) => section === "mcpp.clangd.path"), false, ); - assert.equal(affectsModuleConfiguration(() => false), false); + assert.equal(configurationAffectsModuleSupport(() => false), false); + assert.equal(configurationAffectsMcppExecution(() => false), false); }); test("only executes workspace-selected tools in trusted workspaces", () => { From 83f4cc88028b3e51f5fe075df28d3f3644aee681 Mon Sep 17 00:00:00 2001 From: wellwei Date: Tue, 11 Aug 2026 11:33:45 +0800 Subject: [PATCH 15/27] release: mcpp-vscode 0.3.0 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- package-lock.json | 4 ++-- package.json | 2 +- test/artifacts.test.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5cdd9..f72d00d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 更新日志 -## 未发布 +## 0.3.0 - 2026-08-11 - 将编译数据库刷新迁移到 `mcpp build --configure-only`:不解析 stdout 人类文本,以退出码 和可解析 CDB 作为成功条件,失败时保留 last-known-good CDB。 diff --git a/README.md b/README.md index 68e94e4..0e2a655 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 -当前版本为 `0.2.7`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 +当前版本为 `0.3.0`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 > 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 @@ -37,7 +37,7 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 VSIX,然后在 VS Code 中执行 **Extensions: Install from VSIX...**,或者运行: ```sh -code --install-extension /path/to/mcpp-vscode-0.2.7.vsix +code --install-extension /path/to/mcpp-vscode-0.3.0.vsix ``` 安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 @@ -419,8 +419,8 @@ API、状态栏、任务和 clangd 集成。 版本完全一致的 tag: ```sh -git tag -a v0.2.7 -m "mcpp-vscode 0.2.7" -git push origin v0.2.7 +git tag -a v0.3.0 -m "mcpp-vscode 0.3.0" +git push origin v0.3.0 ``` `.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, diff --git a/package-lock.json b/package-lock.json index 4d170ad..0033314 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.2.7", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.2.7", + "version": "0.3.0", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index d0232c2..1cb55bc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.2.7", + "version": "0.3.0", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index f09a8f4..486e4b2 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,7 +26,7 @@ const root = path.resolve(process.cwd()); test("declares the official clangd dependency and mcpp commands", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.2.7"); + assert.equal(manifest.version, "0.3.0"); 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")); From 499ba2f7538caec70fd6bfd8862fbf890a086123 Mon Sep 17 00:00:00 2001 From: wellwei Date: Tue, 11 Aug 2026 15:31:41 +0800 Subject: [PATCH 16/27] fix: keep IDE commands responsive after CDB removal --- CHANGELOG.md | 5 ++ src/cliController.ts | 6 +-- src/configureOnly.ts | 9 +++- src/extension.ts | 82 ++++++++++++++++++++------------ src/workflow.ts | 20 ++++++++ test/artifacts.test.ts | 10 ++++ test/configureOnly.test.ts | 12 +++-- test/e2e/suite/extension.test.ts | 30 +++++++++++- test/workflow.test.ts | 10 ++++ 9 files changed, 144 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f72d00d..e83eafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 更新日志 +## 未发布 + +- 修复缺少 CDB 时 IDE 命令看似无响应的问题:配置 clangd、刷新编译数据库、检查模块支持和一键配置现在会立即显示进度并打开 `mcpp` 输出频道;clangd 重启与 `mcpp build --configure-only` 增加超时,避免异步操作永久占用 IDE 队列。 +- 增加删除 `compile_commands.json` 后刷新 CDB、配置 clangd、检查模块支持和一键配置的 Extension Host 回归测试。 + ## 0.3.0 - 2026-08-11 - 将编译数据库刷新迁移到 `mcpp build --configure-only`:不解析 stdout 人类文本,以退出码 diff --git a/src/cliController.ts b/src/cliController.ts index 12cbaf0..0cec3d9 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -632,7 +632,7 @@ export class McppCliController { } catch { // 输出频道可能已经在窗口重载时释放。 } - await vscode.window.showErrorMessage(`mcpp:${message}`); + void vscode.window.showErrorMessage(`mcpp:${message}`); } }; } @@ -679,7 +679,7 @@ export class McppCliController { const result = await runProcess(executable, args, workingDirectory(project)); this.appendShortCommand("查看工具链", executable, args, result); if (result.exitCode !== 0) { - await vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( `mcpp toolchain list 失败(退出码 ${result.exitCode})。请查看 mcpp 输出频道。`, ); return undefined; @@ -687,7 +687,7 @@ export class McppCliController { const inventory = parseToolchainList(`${result.stdout}${result.stderr.length > 0 ? `\n${result.stderr}` : ""}`); if (!inventory.recognized) { - await vscode.window.showErrorMessage( + void vscode.window.showErrorMessage( "无法识别当前 mcpp toolchain list 输出;原始输出已保留在 mcpp 输出频道,请检查 mcpp 版本。", ); return undefined; diff --git a/src/configureOnly.ts b/src/configureOnly.ts index a677a74..cf233ae 100644 --- a/src/configureOnly.ts +++ b/src/configureOnly.ts @@ -1,11 +1,18 @@ import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; export const configureOnlyArguments = ["build", "--configure-only"] as const; +// 配置阶段可能解析工具链和依赖,但不能无限期占住 IDE 操作队列。 +export const configureOnlyTimeoutMs = 5 * 60_000; export function runConfigureOnly( projectRoot: string, executable = "mcpp", runner: ProcessRunner = runProcess, ): Promise { - return runner(executable, [...configureOnlyArguments], projectRoot); + return runner( + executable, + [...configureOnlyArguments], + projectRoot, + { timeoutMs: configureOnlyTimeoutMs }, + ); } diff --git a/src/extension.ts b/src/extension.ts index fdb7476..0d26e3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -47,6 +47,7 @@ import { shouldRenderProjectStatus, shouldUseWorkspaceClangd, statusCommandForCapability, + withTimeout, workspaceAllowsToolExecution, type ModuleSupportState, } from "./workflow"; @@ -306,12 +307,23 @@ async function maybeDisableCppTools(project: McppProjectDiscovery): Promise { +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 { - await vscode.commands.executeCommand("clangd.restart"); + 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 扩展可能尚未激活,但配置仍然已经写入。 @@ -329,7 +341,7 @@ async function configureClangd( const interactive = mode === "interactive"; if (context.analysis.capability === "syntax-only") { if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${context.analysis.kind.toUpperCase()} 模块产物无法由 clangd 读取。语法高亮仍然可用,但模块语义诊断需要 LLVM mcpp 工具链。`, ); } @@ -338,7 +350,7 @@ async function configureClangd( } if (context.analysis.capability !== "full" || context.analysis.compilerPath === undefined) { if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${context.analysis.reason} 请先运行“mcpp: 刷新编译数据库”。`, ); } @@ -347,7 +359,7 @@ async function configureClangd( if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { const message = "当前工作区未受信任,mcpp 不会执行 CDB 中的编译器或 clangd,也不会接管 clangd 配置。"; if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -358,7 +370,7 @@ async function configureClangd( if (clangd === undefined) { const message = "没有找到可用的 clangd。请安装与 mcpp LLVM 编译器来自同一 revision 的 clangd,或设置 mcpp.clangd.path;clangd 可以来自 xlings llvm-tools,也可以独立安装。"; if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -399,11 +411,11 @@ async function configureClangd( await maybeDisableCppTools(context.project); } const restartRequired = shouldRestartClangd(plan.changed, interactive, forceRestart); - const restartSucceeded = restartRequired ? await restartClangd() : false; + const restartSucceeded = restartRequired ? await restartClangd(output) : false; if (!configurationReadyAfterRestart(restartRequired, restartSucceeded)) { const message = "clangd 配置已写入,但无法重启语言服务器。请查看 mcpp 输出频道,或手动执行 clangd 重启命令。"; if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动配置] ${message}`); } @@ -412,11 +424,11 @@ async function configureClangd( updateStatusBar(status, context); if (!clangd.comparison.compatible) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `clangd 已配置,但 LLVM 身份与 mcpp 编译器不匹配:${clangd.comparison.reason}。`, ); } else if (interactive) { - await vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); + void vscode.window.showInformationMessage("mcpp 已为当前工作区配置匹配的 clangd。"); } else if (plan.changed) { appendOutputLine(output, `[自动配置] clangd.path = ${plan.path}`); } @@ -546,7 +558,7 @@ async function runModuleSupportCheck( storeModuleStatus(status, context, { state: "unavailable", message }); } if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } return context.analysis.capability === "full" ? { state: "unavailable", message } @@ -559,7 +571,7 @@ async function runModuleSupportCheck( const moduleStatus = { state: "unavailable", message } as const; storeModuleStatus(status, context, moduleStatus); if (interactive) { - await vscode.window.showWarningMessage(message); + void vscode.window.showWarningMessage(message); } else { appendOutputLine(output, `[自动检查] ${message}`); } @@ -583,7 +595,7 @@ async function runModuleSupportCheck( return undefined; } if (interactive) { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } else { appendOutputLine(output, `[自动检查] ${message}`); } @@ -630,9 +642,9 @@ async function runModuleSupportCheck( } if (interactive) { if (moduleStatus.state === "available") { - await vscode.window.showInformationMessage(message); + void vscode.window.showInformationMessage(message); } else { - await vscode.window.showErrorMessage(message); + void vscode.window.showErrorMessage(message); } } else { appendOutputLine(output, `[自动检查] ${message}`); @@ -751,7 +763,7 @@ async function autoConfigureModulesWizard( appendOutputLine(output, "[一键配置] 开始一键配置模块代码提示..."); if (!vscode.workspace.isTrusted) { - await vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); + void vscode.window.showWarningMessage(moduleSetupBlockedMessage("untrusted")); return; } const inventory = await cliController.readToolchainInventory(context.project); @@ -765,7 +777,7 @@ async function autoConfigureModulesWizard( cliController.isBusy(), ); if (decision.kind === "blocked") { - await vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); + void vscode.window.showWarningMessage(moduleSetupBlockedMessage(decision.reason)); return; } @@ -861,14 +873,14 @@ async function autoConfigureModulesWizard( if (outcome.state === "succeeded") { if (outcome.degraded) { - await vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); + void vscode.window.showWarningMessage("构建失败,语言服务已刷新。请查看任务终端获取构建错误。"); } else { - await vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); + void vscode.window.showInformationMessage("mcpp 模块代码提示一键配置完成。"); } } else if (outcome.state === "cancelled") { - await vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); + void vscode.window.showWarningMessage(`一键配置已取消(${outcome.stage})。`); } else { - await vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); + void vscode.window.showErrorMessage(`一键配置失败(${outcome.stage})。${outcome.steps.at(-1)?.detail ?? "请查看 mcpp 输出频道。"}`); } } @@ -931,6 +943,10 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi 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, @@ -939,7 +955,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (!workspaceAllowsToolExecution(vscode.workspace.isTrusted)) { appendOutputLine(output, "[CDB 配置] 工作区未受信任,跳过 mcpp build --configure-only。"); if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( "当前工作区未受信任,不会刷新编译数据库。请先信任工作区。", ); } @@ -949,7 +965,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (result === undefined) { appendOutputLine(output, "[CDB 配置] 已有 mcpp 操作正在运行,本次刷新已跳过。"); if (interactive) { - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( "已有 mcpp 操作正在运行,暂不能刷新编译数据库。请等待当前操作完成后重试。", ); } @@ -1204,7 +1220,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (kind === "build") { if (reconciled.context?.analysis.capability === "syntax-only") { const buildMessage = completion.state === "succeeded" ? "mcpp 构建完成" : "mcpp 构建失败"; - await vscode.window.showWarningMessage( + void vscode.window.showWarningMessage( `${buildMessage};${reconciled.context.analysis.reason}。模块语法高亮仍然可用。`, ); return; @@ -1215,18 +1231,18 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi reconciled.configured, ); if (outcome.level === "information") { - await vscode.window.showInformationMessage(outcome.message); + void vscode.window.showInformationMessage(outcome.message); } else if (outcome.level === "warning") { - await vscode.window.showWarningMessage(outcome.message); + void vscode.window.showWarningMessage(outcome.message); } else { - await vscode.window.showErrorMessage(outcome.message); + void vscode.window.showErrorMessage(outcome.message); } return; } if (completion.state === "succeeded") { const label = kind === "run" ? "运行" : kind === "test" ? "测试" : "清理"; - await vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); + void vscode.window.showInformationMessage(`mcpp ${label}完成;clangd/CDB 状态已重新检查。`); } }; cliController = new McppCliController({ @@ -1251,6 +1267,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("配置 clangd"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; @@ -1269,6 +1286,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("刷新编译数据库"); await executeWithWorkspaceClangd(async () => { const hadUsableDatabase = hasUsableCompilationDatabase(project); const result = await runConfigureOnlyForProject(project, true); @@ -1284,11 +1302,11 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi reconciled.configured, ); if (outcome.level === "information") { - await vscode.window.showInformationMessage(outcome.message); + void vscode.window.showInformationMessage(outcome.message); } else if (outcome.level === "warning") { - await vscode.window.showWarningMessage(outcome.message); + void vscode.window.showWarningMessage(outcome.message); } else { - await vscode.window.showErrorMessage(outcome.message); + void vscode.window.showErrorMessage(outcome.message); } }); })), @@ -1298,6 +1316,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("检查模块支持"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; @@ -1401,6 +1420,7 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); return; } + showInteractiveIdeStart("一键配置模块代码提示"); await executeWithWorkspaceClangd(async () => { if (!shouldUseWorkspaceClangd(findCurrentProject()?.root, project.root)) { return; diff --git a/src/workflow.ts b/src/workflow.ts index 2d80817..371b501 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -16,6 +16,26 @@ export interface CompilationDatabaseWatcher { 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, diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 486e4b2..59fabb7 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -144,6 +144,16 @@ test("configure-only shares the project operation lock", () => { 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 diff --git a/test/configureOnly.test.ts b/test/configureOnly.test.ts index c12c9dd..890abb9 100644 --- a/test/configureOnly.test.ts +++ b/test/configureOnly.test.ts @@ -4,12 +4,17 @@ import test from "node:test"; import { configureOnlyArguments, runConfigureOnly } from "../src/configureOnly"; test("runs mcpp build --configure-only with the configured executable and cwd", async () => { - const calls: Array<{ executable: string; args: string[]; cwd?: string }> = []; + const calls: Array<{ + executable: string; + args: string[]; + cwd?: string; + timeoutMs?: number; + }> = []; const result = await runConfigureOnly( "/work/app", "/tools/mcpp", - async (executable, args, cwd) => { - calls.push({ executable, args, cwd }); + async (executable, args, cwd, options) => { + calls.push({ executable, args, cwd, timeoutMs: options?.timeoutMs }); return { exitCode: 0, stdout: "Configured 2 compile commands", stderr: "" }; }, ); @@ -18,6 +23,7 @@ test("runs mcpp build --configure-only with the configured executable and cwd", executable: "/tools/mcpp", args: [...configureOnlyArguments], cwd: "/work/app", + timeoutMs: 5 * 60_000, }]); assert.equal(result.exitCode, 0); }); diff --git a/test/e2e/suite/extension.test.ts b/test/e2e/suite/extension.test.ts index d1d088d..3f211cb 100644 --- a/test/e2e/suite/extension.test.ts +++ b/test/e2e/suite/extension.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; @@ -35,7 +35,7 @@ suite("mcpp extension smoke", () => { } assert.ok(existsSync(logPath), "fake mcpp should have been invoked"); - void vscode.commands.executeCommand("mcpp.build"); + await vscode.commands.executeCommand("mcpp.build"); const buildDeadline = Date.now() + 15_000; while (Date.now() < buildDeadline) { invocations = readFileSync(logPath, "utf8").trim().split("\n"); @@ -47,5 +47,31 @@ suite("mcpp extension smoke", () => { assert.deepEqual(invocations, ["build --configure-only", "build"]); assert.ok(existsSync(path.join(workspaceFolder.uri.fsPath, "compile_commands.json"))); 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"); }); }); diff --git a/test/workflow.test.ts b/test/workflow.test.ts index ce84c52..7c3b1c3 100644 --- a/test/workflow.test.ts +++ b/test/workflow.test.ts @@ -58,6 +58,16 @@ test("describes configure-only results without treating an old CDB as newly gene }); }); +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) => { From db80d420640e41091f7f81d47547ecda45f6a222 Mon Sep 17 00:00:00 2001 From: wellwei Date: Tue, 11 Aug 2026 15:34:06 +0800 Subject: [PATCH 17/27] release: mcpp-vscode 0.3.1 --- CHANGELOG.md | 2 +- README.md | 8 ++++---- package-lock.json | 4 ++-- package.json | 2 +- test/artifacts.test.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e83eafc..831d24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # 更新日志 -## 未发布 +## 0.3.1 - 2026-08-11 - 修复缺少 CDB 时 IDE 命令看似无响应的问题:配置 clangd、刷新编译数据库、检查模块支持和一键配置现在会立即显示进度并打开 `mcpp` 输出频道;clangd 重启与 `mcpp build --configure-only` 增加超时,避免异步操作永久占用 IDE 队列。 - 增加删除 `compile_commands.json` 后刷新 CDB、配置 clangd、检查模块支持和一键配置的 Extension Host 回归测试。 diff --git a/README.md b/README.md index 0e2a655..b96ae2a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ 把 mcpp 工程、C++ 模块语法和官方 clangd 扩展接入 VS Code。 -当前版本为 `0.3.0`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 +当前版本为 `0.3.1`。扩展负责工程发现、clangd 配置、模块状态检查以及常用 mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的构建逻辑。 > 当前完整的模块语义能力只支持 LLVM/Clang 工具链。GCC 和 MSVC 工程仍可使用 @@ -37,7 +37,7 @@ mcpp CLI 操作;它不实现新的 C++ 语言服务器,也不替代 mcpp 的 VSIX,然后在 VS Code 中执行 **Extensions: Install from VSIX...**,或者运行: ```sh -code --install-extension /path/to/mcpp-vscode-0.3.0.vsix +code --install-extension /path/to/mcpp-vscode-0.3.1.vsix ``` 安装后确认当前 VS Code profile 中同时存在 `mcpp-community.mcpp-vscode` 和 @@ -419,8 +419,8 @@ API、状态栏、任务和 clangd 集成。 版本完全一致的 tag: ```sh -git tag -a v0.3.0 -m "mcpp-vscode 0.3.0" -git push origin v0.3.0 +git tag -a v0.3.1 -m "mcpp-vscode 0.3.1" +git push origin v0.3.1 ``` `.github/workflows/release.yml` 会校验 tag,执行测试和打包,生成 VSIX 与 SHA-256 文件, diff --git a/package-lock.json b/package-lock.json index 0033314..3feca73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-vscode", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-vscode", - "version": "0.3.0", + "version": "0.3.1", "license": "Apache-2.0", "devDependencies": { "@types/mocha": "^10.0.10", diff --git a/package.json b/package.json index 1cb55bc..7c681d5 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "mcpp-vscode", "displayName": "mcpp", "description": "mcpp 与 C++ 模块的 VS Code 集成", - "version": "0.3.0", + "version": "0.3.1", "publisher": "mcpp-community", "license": "Apache-2.0", "icon": "images/logo.png", diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index 59fabb7..bf5baba 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -26,7 +26,7 @@ const root = path.resolve(process.cwd()); test("declares the official clangd dependency and mcpp commands", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.3.0"); + assert.equal(manifest.version, "0.3.1"); 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")); From 304463d29596a9533ce5ed8e194e6dae1a68155b Mon Sep 17 00:00:00 2001 From: HikariTish <809456206@qq.com> Date: Wed, 12 Aug 2026 00:02:15 +0800 Subject: [PATCH 18/27] fix: preserve PCM when LLVM setup is unchanged --- src/moduleSetup.ts | 5 ++++- test/moduleSetup.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/moduleSetup.ts b/src/moduleSetup.ts index 14d962a..6c6b8a0 100644 --- a/src/moduleSetup.ts +++ b/src/moduleSetup.ts @@ -75,7 +75,10 @@ export function mcppModuleSetupCommands(plan: ModuleSetupPlan): ModuleSetupComma args: ["toolchain", "default", "llvm"], }); } - commands.push({ stage: "build", mode: "task", args: ["build", "--no-cache"] }); + const buildArgs = plan.installLlvm || plan.switchDefault + ? ["build", "--no-cache"] + : ["build"]; + commands.push({ stage: "build", mode: "task", args: buildArgs }); return commands; } diff --git a/test/moduleSetup.test.ts b/test/moduleSetup.test.ts index c6fad77..2796c97 100644 --- a/test/moduleSetup.test.ts +++ b/test/moduleSetup.test.ts @@ -210,7 +210,7 @@ test("阻止原因按未信任、未知清单、忙碌、项目覆盖的顺序 ); }); -test("模块设置命令使用固定 argv 数组且始终构建", () => { +test("模块设置命令仅在工具链变化时清缓存", () => { assert.deepEqual( mcppModuleSetupCommands({ kind: "ready", installLlvm: true, switchDefault: true }), [ @@ -221,7 +221,7 @@ test("模块设置命令使用固定 argv 数组且始终构建", () => { ); assert.deepEqual( mcppModuleSetupCommands({ kind: "ready", installLlvm: false, switchDefault: false }), - [{ stage: "build", mode: "task", args: ["build", "--no-cache"] }], + [{ stage: "build", mode: "task", args: ["build"] }], ); }); From 504eb110e13a706e1b008baa2244b8268492f996 Mon Sep 17 00:00:00 2001 From: HikariTish <809456206@qq.com> Date: Wed, 12 Aug 2026 01:25:41 +0800 Subject: [PATCH 19/27] chore: use workspace TypeScript SDK --- .vscode/settings.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .vscode/settings.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b21fe3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "js/ts.tsdk.path": "node_modules/typescript/lib" +} From 1bfd6c83cf4c27c88da39754b36b272e71e6cbc7 Mon Sep 17 00:00:00 2001 From: wellwei Date: Fri, 25 Sep 2026 21:58:58 +0800 Subject: [PATCH 20/27] =?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 21/27] =?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 22/27] =?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++ 模块语义已在所有工具链和平台验证通过”。 From 20f1076e2c485f0bbd807773d80107caa1df4b1d Mon Sep 17 00:00:00 2001 From: wellwei Date: Sat, 26 Sep 2026 00:51:12 +0800 Subject: [PATCH 23/27] =?UTF-8?q?release:=20mcpp-vscode=200.4.0=EF=BC=88mc?= =?UTF-8?q?ppls=20=E8=AF=AD=E8=A8=80=E6=9C=8D=E5=8A=A1=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c439cbda..797efc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ 显式刷新会分别报告 CDB 生成与 clangd 协调结果。 - 对齐 mcpp #387 的最终 workspace 契约:virtual workspace 根不作为单一 clangd 工程, 扩展消费当前活动 member 根的 CDB;rooted workspace 仍按根 package 处理。 -## 0.4.0(未发布) +## 0.4.0 - 2026-09-26 ### Changed From 26283eac9444fb69f4524514fd43464f208942b4 Mon Sep 17 00:00:00 2001 From: SPeak Date: Sun, 4 Oct 2026 02:15:53 +0800 Subject: [PATCH 24/27] =?UTF-8?q?mcpp-vscode=200.6.0:=20=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=E9=87=8D=E7=BB=84=E3=80=81=E5=BA=93=E7=94=9F=E6=80=81?= =?UTF-8?q?=E3=80=81=E7=BC=93=E5=AD=98=E8=A7=86=E5=9B=BE=E4=B8=8E=E4=BB=93?= =?UTF-8?q?=E5=BA=93=E5=B7=A5=E7=A8=8B=E5=8C=96=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.5.0 基座(注册表单一事实源、纯模块门禁、设置面板、build.mcpp/mcpp.toml 智能、双语)与 0.6.0 侧边栏重组(工程/库生态/缓存三视图、C++ Modules 折叠入工程视图、快捷菜单彩色图标)+ 23 轮作者实测反馈修复 + 外部深度评审修复(工作区信任收口 runMcpp、Windows 参数引号、WebviewDocument 渲染骨架)+ 双市场发布流水线(Open VSX 必发、Marketplace 机会发布)+ README/目录工程化与死代码清理。669 单测,CI 三平台 11/11 绿。 --- .agents/docs/README.md | 32 + .agents/docs/architecture.md | 77 + .../archive/2026-10-02-implementation-plan.md | 342 ++++ .../2026-10-02-plugin-optimisation-plan.md | 1115 ++++++++++++ .../2026-10-02-ui-ux-optimisation-plan.md | 1466 ++++++++++++++++ .../2026-10-03-repo-engineering-plan.md | 279 +++ .agents/docs/mcpp-integration.md | 80 + .agents/docs/mcppls-integration.md | 179 ++ .../plans/2026-07-31-mcpp-cli-integration.md | 0 .../2026-08-01-tag-release-implementation.md | 0 ...-08-05-clangd-hermetic-cdb-build-script.md | 0 .../2026-08-08-issues-6-7-implementation.md | 0 ...26-09-25-mcpp-language-server-migration.md | 0 .../2026-07-31-mcpp-cli-integration-design.md | 0 .../specs/2026-08-01-tag-release-design.md | 0 ...026-08-08-one-click-module-setup-design.md | 0 .../specs/2026-08-08-pr-ci-design.md | 0 .github/workflows/ci.yml | 207 ++- .github/workflows/release.yml | 51 + .gitignore | 9 + .vscodeignore | 6 + CHANGELOG.md | 207 ++- README.md | 255 +-- README.zh-CN.md | 79 + data/buildscript-api.json | 432 +++++ data/config-registry.json | 1072 ++++++++++++ data/i18n/zh-cn.json | 471 +++++ data/toml-schema.json | 822 +++++++++ docs/architecture.md | 130 ++ docs/build-script.md | 137 ++ docs/cache.md | 138 ++ docs/commands.md | 121 ++ docs/compatibility.md | 136 ++ docs/mcpp-toml.md | 134 ++ docs/settings.md | 1023 +++++++++++ docs/troubleshooting.md | 134 ++ images/logo.png | Bin 6719 -> 0 bytes l10n/bundle.l10n.json | 1 + l10n/bundle.l10n.zh-cn.json | 464 +++++ media/activity-bar.png | Bin 0 -> 2426 bytes media/cache.css | 570 +++++++ media/library.css | 632 +++++++ media/logo.png | Bin 0 -> 31749 bytes media/quick-menu/archive--purple--dark.svg | 1 + media/quick-menu/archive--purple--light.svg | 1 + media/quick-menu/beaker--purple--dark.svg | 1 + media/quick-menu/beaker--purple--light.svg | 1 + media/quick-menu/chip--yellow--dark.svg | 1 + media/quick-menu/chip--yellow--light.svg | 1 + .../quick-menu/cloud-download--blue--dark.svg | 1 + .../cloud-download--blue--light.svg | 1 + .../cloud-download--yellow--dark.svg | 1 + .../cloud-download--yellow--light.svg | 1 + .../debug-restart--neutral--dark.svg | 1 + .../debug-restart--neutral--light.svg | 1 + media/quick-menu/file-zip--neutral--dark.svg | 1 + media/quick-menu/file-zip--neutral--light.svg | 1 + media/quick-menu/folder--neutral--dark.svg | 1 + media/quick-menu/folder--neutral--light.svg | 1 + .../folder-opened--neutral--dark.svg | 1 + .../folder-opened--neutral--light.svg | 1 + media/quick-menu/heart--green--dark.svg | 1 + media/quick-menu/heart--green--light.svg | 1 + media/quick-menu/library--blue--dark.svg | 1 + media/quick-menu/library--blue--light.svg | 1 + media/quick-menu/output--neutral--dark.svg | 1 + media/quick-menu/output--neutral--light.svg | 1 + media/quick-menu/play--green--dark.svg | 1 + media/quick-menu/play--green--light.svg | 1 + media/quick-menu/refresh--blue--dark.svg | 1 + media/quick-menu/refresh--blue--light.svg | 1 + media/quick-menu/refresh--green--dark.svg | 1 + media/quick-menu/refresh--green--light.svg | 1 + media/quick-menu/report--neutral--dark.svg | 1 + media/quick-menu/report--neutral--light.svg | 1 + .../settings-gear--neutral--dark.svg | 1 + .../settings-gear--neutral--light.svg | 1 + media/quick-menu/star-full--yellow--dark.svg | 1 + media/quick-menu/star-full--yellow--light.svg | 1 + .../symbol-interface--neutral--dark.svg | 1 + .../symbol-interface--neutral--light.svg | 1 + media/quick-menu/tools--blue--dark.svg | 1 + media/quick-menu/tools--blue--light.svg | 1 + media/quick-menu/trash--neutral--dark.svg | 1 + media/quick-menu/trash--neutral--light.svg | 1 + media/quick-menu/trash--red--dark.svg | 1 + media/quick-menu/trash--red--light.svg | 1 + .../type-hierarchy--neutral--dark.svg | 1 + .../type-hierarchy--neutral--light.svg | 1 + media/quick-menu/verified--green--dark.svg | 1 + media/quick-menu/verified--green--light.svg | 1 + media/settings.css | 318 ++++ package-lock.json | 1303 +++++++++++++- package.json | 1046 +++++++++++- package.nls.json | 207 +++ package.nls.zh-cn.json | 207 +++ src/buildscript/analysis.ts | 674 ++++++++ src/buildscript/api.ts | 111 ++ src/buildscript/modules.ts | 294 ++++ src/buildscript/providers.ts | 221 +++ src/buildscript/settings.ts | 70 + src/buildscript/snippets.ts | 116 ++ src/cache/cachePanel.ts | 393 +++++ src/cache/cachePanelHtml.ts | 949 ++++++++++ src/cache/cacheState.ts | 188 ++ src/cache/cacheView.ts | 762 +++++++++ src/cli/artifacts.ts | 197 +++ src/cli/cache.ts | 293 ++++ src/cli/clean.ts | 242 +++ src/cli/controller.ts | 1160 +++++++++++++ src/cli/errors.ts | 156 ++ src/cli/labels.ts | 43 + src/{ => cli}/newProject.ts | 26 +- src/cli/process.ts | 120 ++ src/cli/protocol.ts | 259 +++ src/cli/search.ts | 175 ++ src/cli/selfCheck.ts | 98 ++ src/cli/statusBar.ts | 45 + src/{ => cli}/tasks.ts | 36 +- src/{cli.ts => cli/toolchain.ts} | 0 src/cliController.ts | 759 -------- src/commands.ts | 45 - src/commands/ids.ts | 114 ++ src/commands/menu.ts | 103 ++ src/config/access.ts | 133 ++ src/config/migrate.ts | 66 + src/config/panel.ts | 403 +++++ src/config/panelHtml.ts | 612 +++++++ src/config/presets.ts | 110 ++ src/config/registry.ts | 184 ++ src/config/validate.ts | 93 + src/configureOnly.ts | 18 - src/extension.ts | 463 ++++- src/i18n/t.ts | 81 + src/i18n/translate.ts | 55 + src/ideWorkflow.ts | 45 - src/languageServer.ts | 80 - src/library/addDependency.ts | 142 ++ src/library/detailHtml.ts | 713 ++++++++ src/library/detailPanel.ts | 531 ++++++ src/library/indexLocator.ts | 559 ++++++ src/library/indexModel.ts | 1520 +++++++++++++++++ src/library/libraryHtml.ts | 445 +++++ src/library/libraryView.ts | 462 +++++ src/library/xpkg.ts | 434 +++++ src/mcppTomlCompletion.ts | 223 --- src/mcppls/bridge.ts | 106 ++ src/mcppls/capabilities.ts | 212 +++ src/mcppls/contract.ts | 260 +++ src/mcppls/messages.ts | 61 + src/mcppls/state.ts | 229 +++ src/mcppls/stateSource.ts | 71 + src/mcppls/timers.ts | 80 + src/process.ts | 53 - src/{inProject.ts => projects/context.ts} | 23 + src/{ => projects}/discovery.ts | 24 +- src/projects/summary.ts | 111 ++ src/toml/completion.ts | 523 ++++++ src/toml/diagnostics.ts | 629 +++++++ src/toml/hover.ts | 206 +++ src/toml/navigation.ts | 306 ++++ src/{mcppTomlParser.ts => toml/parser.ts} | 12 +- src/toml/providers.ts | 284 +++ src/toml/schema.ts | 125 ++ src/util/format.ts | 153 ++ src/util/log.ts | 82 + src/util/text.ts | 79 + src/views/languageServerView.ts | 345 ++++ src/views/models.ts | 878 ++++++++++ src/views/projectView.ts | 148 ++ src/views/treeProvider.ts | 111 ++ src/views/viewPolicy.ts | 46 + src/webview/document.ts | 64 + src/webview/render.ts | 18 + src/{ => workflows}/moduleSetup.ts | 6 +- test/architecture.test.ts | 141 ++ test/artifacts.test.ts | 439 ++++- test/buildscript/analysis.test.ts | 199 +++ test/buildscript/api.test.ts | 113 ++ test/buildscript/modules.test.ts | 118 ++ test/buildscript/settings.test.ts | 136 ++ test/buildscript/snippets.test.ts | 74 + test/cache/cacheCss.test.ts | 115 ++ test/cache/cacheLegacy.test.ts | 73 + test/cache/cachePanelHtml.test.ts | 456 +++++ test/cache/cacheState.test.ts | 110 ++ test/cli/artifacts.test.ts | 99 ++ test/cli/cache.test.ts | 264 +++ test/cli/clean.test.ts | 94 + test/cli/controller.settings.test.ts | 193 +++ test/cli/errors.test.ts | 105 ++ test/cli/labels.test.ts | 57 + test/{ => cli}/newProject.test.ts | 13 +- test/cli/process.test.ts | 49 + test/cli/protocol.test.ts | 192 +++ test/cli/search.test.ts | 141 ++ test/cli/selfCheck.test.ts | 89 + test/cli/statusBar.test.ts | 37 + test/{ => cli}/tasks.test.ts | 13 +- test/{cli.test.ts => cli/toolchain.test.ts} | 2 +- test/commands.test.ts | 57 - test/commands/ids.test.ts | 118 ++ test/commands/menu.test.ts | 157 ++ test/config/panelHtml.test.ts | 243 +++ test/config/presets.test.ts | 51 + test/config/registry.test.ts | 86 + test/config/validate.test.ts | 82 + test/config/wiring.test.ts | 188 ++ test/configureOnly.test.ts | 58 - test/e2e/fixtures/mcppls-stub/extension.js | 27 - test/e2e/fixtures/mcppls-stub/package.json | 14 - test/e2e/mcpplsStub.ts | 110 ++ test/e2e/runTest.ts | 65 +- test/e2e/suite/extension.e2e.ts | 127 ++ test/e2e/suite/extension.test.ts | 68 - test/e2e/suite/index.ts | 2 +- test/i18n/hardcoded.test.ts | 98 ++ test/i18n/translate.test.ts | 40 + test/ideWorkflow.test.ts | 118 -- test/languageServer.test.ts | 107 -- test/library/detailHtml.test.ts | 407 +++++ test/library/indexModel.test.ts | 666 ++++++++ test/library/libraryHtml.test.ts | 263 +++ test/library/xpkg.test.ts | 169 ++ test/mcppTomlCompletion.test.ts | 113 -- test/mcppls/bridge.test.ts | 154 ++ test/mcppls/capabilities.test.ts | 180 ++ test/mcppls/contract.test.ts | 57 + test/mcppls/state.test.ts | 140 ++ test/mcppls/stateSource.test.ts | 54 + test/mcppls/timers.test.ts | 163 ++ test/process.test.ts | 13 - .../context.test.ts} | 2 +- test/{ => projects}/discovery.test.ts | 32 +- test/projects/summary.test.ts | 78 + test/toml/completion.test.ts | 300 ++++ .../contract.test.ts} | 4 +- test/toml/diagnostics.test.ts | 235 +++ test/toml/hover.test.ts | 83 + test/toml/navigation.test.ts | 103 ++ .../parser.test.ts} | 2 +- test/toml/schema.test.ts | 98 ++ test/util/format.test.ts | 109 ++ test/util/log.test.ts | 63 + test/util/text.test.ts | 56 + test/views/models.test.ts | 99 ++ test/views/projectLayout.test.ts | 416 +++++ test/webview/render.test.ts | 14 + test/{ => workflows}/moduleSetup.test.ts | 5 +- tools/check-config.mjs | 162 ++ tools/check-generators.mjs | 94 + tools/ci-vscode-cli.mjs | 51 + tools/dev-profile.mjs | 161 ++ tools/generate-activitybar-icon.mjs | 284 +++ tools/generate-buildscript-api.mjs | 374 ++++ tools/generate-l10n.mjs | 59 + tools/generate-quick-menu-icons.mjs | 171 ++ tools/generate-settings-docs.mjs | 100 ++ tools/generate-toml-schema.mjs | 553 ++++++ tools/l10n-check.mjs | 116 ++ tsconfig.json | 1 + 261 files changed, 43202 insertions(+), 2336 deletions(-) create mode 100644 .agents/docs/README.md create mode 100644 .agents/docs/architecture.md create mode 100644 .agents/docs/archive/2026-10-02-implementation-plan.md create mode 100644 .agents/docs/archive/2026-10-02-plugin-optimisation-plan.md create mode 100644 .agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md create mode 100644 .agents/docs/archive/2026-10-03-repo-engineering-plan.md create mode 100644 .agents/docs/mcpp-integration.md create mode 100644 .agents/docs/mcppls-integration.md rename {docs => .agents}/superpowers/plans/2026-07-31-mcpp-cli-integration.md (100%) rename {docs => .agents}/superpowers/plans/2026-08-01-tag-release-implementation.md (100%) rename {docs => .agents}/superpowers/plans/2026-08-05-clangd-hermetic-cdb-build-script.md (100%) rename {docs => .agents}/superpowers/plans/2026-08-08-issues-6-7-implementation.md (100%) rename {docs => .agents}/superpowers/plans/2026-09-25-mcpp-language-server-migration.md (100%) rename {docs => .agents}/superpowers/specs/2026-07-31-mcpp-cli-integration-design.md (100%) rename {docs => .agents}/superpowers/specs/2026-08-01-tag-release-design.md (100%) rename {docs => .agents}/superpowers/specs/2026-08-08-one-click-module-setup-design.md (100%) rename {docs => .agents}/superpowers/specs/2026-08-08-pr-ci-design.md (100%) create mode 100644 README.zh-CN.md create mode 100644 data/buildscript-api.json create mode 100644 data/config-registry.json create mode 100644 data/i18n/zh-cn.json create mode 100644 data/toml-schema.json create mode 100644 docs/architecture.md create mode 100644 docs/build-script.md create mode 100644 docs/cache.md create mode 100644 docs/commands.md create mode 100644 docs/compatibility.md create mode 100644 docs/mcpp-toml.md create mode 100644 docs/settings.md create mode 100644 docs/troubleshooting.md delete mode 100644 images/logo.png create mode 100644 l10n/bundle.l10n.json create mode 100644 l10n/bundle.l10n.zh-cn.json create mode 100644 media/activity-bar.png create mode 100644 media/cache.css create mode 100644 media/library.css create mode 100644 media/logo.png create mode 100644 media/quick-menu/archive--purple--dark.svg create mode 100644 media/quick-menu/archive--purple--light.svg create mode 100644 media/quick-menu/beaker--purple--dark.svg create mode 100644 media/quick-menu/beaker--purple--light.svg create mode 100644 media/quick-menu/chip--yellow--dark.svg create mode 100644 media/quick-menu/chip--yellow--light.svg create mode 100644 media/quick-menu/cloud-download--blue--dark.svg create mode 100644 media/quick-menu/cloud-download--blue--light.svg create mode 100644 media/quick-menu/cloud-download--yellow--dark.svg create mode 100644 media/quick-menu/cloud-download--yellow--light.svg create mode 100644 media/quick-menu/debug-restart--neutral--dark.svg create mode 100644 media/quick-menu/debug-restart--neutral--light.svg create mode 100644 media/quick-menu/file-zip--neutral--dark.svg create mode 100644 media/quick-menu/file-zip--neutral--light.svg create mode 100644 media/quick-menu/folder--neutral--dark.svg create mode 100644 media/quick-menu/folder--neutral--light.svg create mode 100644 media/quick-menu/folder-opened--neutral--dark.svg create mode 100644 media/quick-menu/folder-opened--neutral--light.svg create mode 100644 media/quick-menu/heart--green--dark.svg create mode 100644 media/quick-menu/heart--green--light.svg create mode 100644 media/quick-menu/library--blue--dark.svg create mode 100644 media/quick-menu/library--blue--light.svg create mode 100644 media/quick-menu/output--neutral--dark.svg create mode 100644 media/quick-menu/output--neutral--light.svg create mode 100644 media/quick-menu/play--green--dark.svg create mode 100644 media/quick-menu/play--green--light.svg create mode 100644 media/quick-menu/refresh--blue--dark.svg create mode 100644 media/quick-menu/refresh--blue--light.svg create mode 100644 media/quick-menu/refresh--green--dark.svg create mode 100644 media/quick-menu/refresh--green--light.svg create mode 100644 media/quick-menu/report--neutral--dark.svg create mode 100644 media/quick-menu/report--neutral--light.svg create mode 100644 media/quick-menu/settings-gear--neutral--dark.svg create mode 100644 media/quick-menu/settings-gear--neutral--light.svg create mode 100644 media/quick-menu/star-full--yellow--dark.svg create mode 100644 media/quick-menu/star-full--yellow--light.svg create mode 100644 media/quick-menu/symbol-interface--neutral--dark.svg create mode 100644 media/quick-menu/symbol-interface--neutral--light.svg create mode 100644 media/quick-menu/tools--blue--dark.svg create mode 100644 media/quick-menu/tools--blue--light.svg create mode 100644 media/quick-menu/trash--neutral--dark.svg create mode 100644 media/quick-menu/trash--neutral--light.svg create mode 100644 media/quick-menu/trash--red--dark.svg create mode 100644 media/quick-menu/trash--red--light.svg create mode 100644 media/quick-menu/type-hierarchy--neutral--dark.svg create mode 100644 media/quick-menu/type-hierarchy--neutral--light.svg create mode 100644 media/quick-menu/verified--green--dark.svg create mode 100644 media/quick-menu/verified--green--light.svg create mode 100644 media/settings.css create mode 100644 package.nls.json create mode 100644 package.nls.zh-cn.json create mode 100644 src/buildscript/analysis.ts create mode 100644 src/buildscript/api.ts create mode 100644 src/buildscript/modules.ts create mode 100644 src/buildscript/providers.ts create mode 100644 src/buildscript/settings.ts create mode 100644 src/buildscript/snippets.ts create mode 100644 src/cache/cachePanel.ts create mode 100644 src/cache/cachePanelHtml.ts create mode 100644 src/cache/cacheState.ts create mode 100644 src/cache/cacheView.ts create mode 100644 src/cli/artifacts.ts create mode 100644 src/cli/cache.ts create mode 100644 src/cli/clean.ts create mode 100644 src/cli/controller.ts create mode 100644 src/cli/errors.ts create mode 100644 src/cli/labels.ts rename src/{ => cli}/newProject.ts (70%) create mode 100644 src/cli/process.ts create mode 100644 src/cli/protocol.ts create mode 100644 src/cli/search.ts create mode 100644 src/cli/selfCheck.ts create mode 100644 src/cli/statusBar.ts rename src/{ => cli}/tasks.ts (68%) rename src/{cli.ts => cli/toolchain.ts} (100%) delete mode 100644 src/cliController.ts delete mode 100644 src/commands.ts create mode 100644 src/commands/ids.ts create mode 100644 src/commands/menu.ts create mode 100644 src/config/access.ts create mode 100644 src/config/migrate.ts create mode 100644 src/config/panel.ts create mode 100644 src/config/panelHtml.ts create mode 100644 src/config/presets.ts create mode 100644 src/config/registry.ts create mode 100644 src/config/validate.ts delete mode 100644 src/configureOnly.ts create mode 100644 src/i18n/t.ts create mode 100644 src/i18n/translate.ts delete mode 100644 src/ideWorkflow.ts delete mode 100644 src/languageServer.ts create mode 100644 src/library/addDependency.ts create mode 100644 src/library/detailHtml.ts create mode 100644 src/library/detailPanel.ts create mode 100644 src/library/indexLocator.ts create mode 100644 src/library/indexModel.ts create mode 100644 src/library/libraryHtml.ts create mode 100644 src/library/libraryView.ts create mode 100644 src/library/xpkg.ts delete mode 100644 src/mcppTomlCompletion.ts create mode 100644 src/mcppls/bridge.ts create mode 100644 src/mcppls/capabilities.ts create mode 100644 src/mcppls/contract.ts create mode 100644 src/mcppls/messages.ts create mode 100644 src/mcppls/state.ts create mode 100644 src/mcppls/stateSource.ts create mode 100644 src/mcppls/timers.ts delete mode 100644 src/process.ts rename src/{inProject.ts => projects/context.ts} (50%) rename src/{ => projects}/discovery.ts (53%) create mode 100644 src/projects/summary.ts create mode 100644 src/toml/completion.ts create mode 100644 src/toml/diagnostics.ts create mode 100644 src/toml/hover.ts create mode 100644 src/toml/navigation.ts rename src/{mcppTomlParser.ts => toml/parser.ts} (99%) create mode 100644 src/toml/providers.ts create mode 100644 src/toml/schema.ts create mode 100644 src/util/format.ts create mode 100644 src/util/log.ts create mode 100644 src/util/text.ts create mode 100644 src/views/languageServerView.ts create mode 100644 src/views/models.ts create mode 100644 src/views/projectView.ts create mode 100644 src/views/treeProvider.ts create mode 100644 src/views/viewPolicy.ts create mode 100644 src/webview/document.ts create mode 100644 src/webview/render.ts rename src/{ => workflows}/moduleSetup.ts (88%) create mode 100644 test/architecture.test.ts create mode 100644 test/buildscript/analysis.test.ts create mode 100644 test/buildscript/api.test.ts create mode 100644 test/buildscript/modules.test.ts create mode 100644 test/buildscript/settings.test.ts create mode 100644 test/buildscript/snippets.test.ts create mode 100644 test/cache/cacheCss.test.ts create mode 100644 test/cache/cacheLegacy.test.ts create mode 100644 test/cache/cachePanelHtml.test.ts create mode 100644 test/cache/cacheState.test.ts create mode 100644 test/cli/artifacts.test.ts create mode 100644 test/cli/cache.test.ts create mode 100644 test/cli/clean.test.ts create mode 100644 test/cli/controller.settings.test.ts create mode 100644 test/cli/errors.test.ts create mode 100644 test/cli/labels.test.ts rename test/{ => cli}/newProject.test.ts (88%) create mode 100644 test/cli/process.test.ts create mode 100644 test/cli/protocol.test.ts create mode 100644 test/cli/search.test.ts create mode 100644 test/cli/selfCheck.test.ts create mode 100644 test/cli/statusBar.test.ts rename test/{ => cli}/tasks.test.ts (90%) rename test/{cli.test.ts => cli/toolchain.test.ts} (99%) delete mode 100644 test/commands.test.ts create mode 100644 test/commands/ids.test.ts create mode 100644 test/commands/menu.test.ts create mode 100644 test/config/panelHtml.test.ts create mode 100644 test/config/presets.test.ts create mode 100644 test/config/registry.test.ts create mode 100644 test/config/validate.test.ts create mode 100644 test/config/wiring.test.ts delete mode 100644 test/configureOnly.test.ts delete mode 100644 test/e2e/fixtures/mcppls-stub/extension.js delete mode 100644 test/e2e/fixtures/mcppls-stub/package.json create mode 100644 test/e2e/mcpplsStub.ts create mode 100644 test/e2e/suite/extension.e2e.ts delete mode 100644 test/e2e/suite/extension.test.ts create mode 100644 test/i18n/hardcoded.test.ts create mode 100644 test/i18n/translate.test.ts delete mode 100644 test/ideWorkflow.test.ts delete mode 100644 test/languageServer.test.ts create mode 100644 test/library/detailHtml.test.ts create mode 100644 test/library/indexModel.test.ts create mode 100644 test/library/libraryHtml.test.ts create mode 100644 test/library/xpkg.test.ts delete mode 100644 test/mcppTomlCompletion.test.ts create mode 100644 test/mcppls/bridge.test.ts create mode 100644 test/mcppls/capabilities.test.ts create mode 100644 test/mcppls/contract.test.ts create mode 100644 test/mcppls/state.test.ts create mode 100644 test/mcppls/stateSource.test.ts create mode 100644 test/mcppls/timers.test.ts delete mode 100644 test/process.test.ts rename test/{inProject.test.ts => projects/context.test.ts} (98%) rename test/{ => projects}/discovery.test.ts (59%) create mode 100644 test/projects/summary.test.ts create mode 100644 test/toml/completion.test.ts rename test/{mcppTomlContract.test.ts => toml/contract.test.ts} (98%) create mode 100644 test/toml/diagnostics.test.ts create mode 100644 test/toml/hover.test.ts create mode 100644 test/toml/navigation.test.ts rename test/{mcppTomlParser.test.ts => toml/parser.test.ts} (99%) create mode 100644 test/toml/schema.test.ts create mode 100644 test/util/format.test.ts create mode 100644 test/util/log.test.ts create mode 100644 test/util/text.test.ts create mode 100644 test/views/models.test.ts create mode 100644 test/views/projectLayout.test.ts create mode 100644 test/webview/render.test.ts rename test/{ => workflows}/moduleSetup.test.ts (95%) create mode 100644 tools/check-config.mjs create mode 100644 tools/check-generators.mjs create mode 100644 tools/ci-vscode-cli.mjs create mode 100644 tools/dev-profile.mjs create mode 100644 tools/generate-activitybar-icon.mjs create mode 100644 tools/generate-buildscript-api.mjs create mode 100644 tools/generate-l10n.mjs create mode 100644 tools/generate-quick-menu-icons.mjs create mode 100644 tools/generate-settings-docs.mjs create mode 100644 tools/generate-toml-schema.mjs create mode 100644 tools/l10n-check.mjs diff --git a/.agents/docs/README.md b/.agents/docs/README.md new file mode 100644 index 0000000..c2d029b --- /dev/null +++ b/.agents/docs/README.md @@ -0,0 +1,32 @@ +# .agents/docs + +本目录存放 **面向贡献者与 agent 的设计/背景文档**:对 mcpp-vscode 当前架构、跨仓库契约 +与决策的记录。使用说明请看仓库根目录的 `README.md`。 + +| 文件 | 内容 | 状态 | +| --- | --- | --- | +| [`architecture.md`](architecture.md) | mcpp-vscode 的分层结构、模块职责与扩展点 | 现状 | +| [`mcpp-integration.md`](mcpp-integration.md) | 与 `mcpp-community/mcpp`(mcpp CLI)的接口契约 | 现状 | +| [`mcppls-integration.md`](mcppls-integration.md) | 与 `sunrisepeak.mcpp-language-server`(mcppls)的依赖与命令桥接契约 | 现状 | + + +边界: + +- `docs/`(仓库根)= **用户文档**(安装、命令、设置、排错); +- `.agents/docs/` = **贡献者/agent 文档**(设计、契约、方案、决策); +- `.agents/superpowers/` = **过程文档**(原 `docs/superpowers/`,plans/specs); +- `.agents/reviews/` = **分析与评审记录**,已加入 `.gitignore`,不进入版本库。 + +背景分析见 `.agents/reviews/2026-10-02-mcpp-vscode-architecture-and-mcppls-dependency-review.md`。 + +## archive/ + +`archive/` 存放**已按其执行完毕**的方案文档(round 制的评审与决定记录),按日期命名。 +当前实现与其偏差以代码与 CHANGELOG 为准: + +| 文档 | 内容 | +| --- | --- | +| [`archive/2026-10-02-implementation-plan.md`](archive/2026-10-02-implementation-plan.md) | 0.5.0 基座实施(纯模块、注册表、门禁) | +| [`archive/2026-10-02-plugin-optimisation-plan.md`](archive/2026-10-02-plugin-optimisation-plan.md) | 插件优化方案 v4(侧边栏、编辑体验、缓存、i18n、配置面板) | +| [`archive/2026-10-02-ui-ux-optimisation-plan.md`](archive/2026-10-02-ui-ux-optimisation-plan.md) | 0.6.0 UI/UX 方案与 23 轮作者反馈记录 | +| [`archive/2026-10-03-repo-engineering-plan.md`](archive/2026-10-03-repo-engineering-plan.md) | 仓库工程化(双市场发布、WebviewDocument、目录收敛) | diff --git a/.agents/docs/architecture.md b/.agents/docs/architecture.md new file mode 100644 index 0000000..c213cf5 --- /dev/null +++ b/.agents/docs/architecture.md @@ -0,0 +1,77 @@ +# mcpp-vscode 架构 + +> 状态:0.4.0(mcppls 语言服务迁移之后)。本文记录**当前**结构;变动请同步更新。 + +## 1. 一句话定位 + +mcpp-vscode 是 **mcpp CLI 的 IDE 前端**:工程发现、任务(build/run/test/clean)、工具链管理、 +`mcpp.toml` 的语法与结构补全。C++ 模块语义(诊断/补全/跳转/引用/模块图/状态)由扩展依赖 +`sunrisepeak.mcpp-language-server`(mcppls)提供,本扩展**不创建 LSP 客户端**。 + +## 2. 分层 + +``` +extension.ts VS Code 装配层:激活、命令注册、provider 注册、资源释放 +├── cliController.ts CLI 编排层:快捷菜单、任务、工具链、新工程、操作互斥 +│ ├── newProject.ts 新建工程流程(纯函数 + 注入的动作) +│ └── commands.ts 命令 ID 与快捷菜单清单(纯数据) +├── languageServer.ts mcppls 桥接层:只调用 4 个公开 VS Code 命令 +├── moduleSetup.ts 一键流程状态机(build → 刷新语言服务) +├── inProject.ts `mcpp.inProject` 上下文键 +├── mcppTomlCompletion.ts mcpp.toml 补全查询层(段头 + 写法模板) +├── mcppTomlParser.ts 容错 TOML 解析器(纯函数) +├── discovery.ts 最近的 mcpp.toml 发现(纯函数) +├── cli.ts mcpp CLI 输出解析(工具链清单) +├── process.ts runProcess 封装 +└── tasks.ts 任务计划、退出码分类、操作注册表(纯函数) +``` + +**约束**:`tasks.ts` / `moduleSetup.ts` / `discovery.ts` / `cli.ts` / `mcppTomlParser.ts` / +`newProject.ts` 不 import `vscode`,因此可用 `node --test` 直接测。`extension.ts` 与 +`cliController.ts` 是唯一接触 VS Code API 的地方。 + +## 3. 运行时边界 + +| 触发 | 行为 | +| --- | --- | +| 打开含 `mcpp.toml` 的工作区 | 激活;不执行 `mcpp`,不下载工具链 | +| 执行 mcpp 命令 | 通过 `vscode.Task` + `ProcessExecution` 在专用终端运行 | +| build 结束(含失败) | 调用一次 `mcppls.restartServer`(单飞,取消除外) | +| run/test/clean 结束 | 不触碰语言服务 | +| 未受信任工作区 | 只保留语法高亮与 TOML 结构补全 | + +## 4. 并发模型 + +`McppOperationRegistry`(`tasks.ts`)做两级互斥: + +- **项目级**(键 = 工程根):build/run/test/clean 互斥; +- **全局**:工具链安装 / 设置全局默认,与所有项目级操作互斥。 + +任务结束**先释放锁、再刷新语言服务**,避免刷新期间用户操作被拒。该顺序由 +`test/artifacts.test.ts` 钉住。 + +## 5. 与 mcppls 的唯一接口 + +`languageServer.ts` 是全部耦合面: + +| 本扩展命令 | 转发到 | +| --- | --- | +| `mcpp.configureLanguageServer`(+ 弃用别名 `mcpp.configureClangd`) | `mcppls.selectContext` | +| `mcpp.checkModuleSupport` | `mcppls.restartServer` | +| `mcpp.showModuleGraph` | `mcppls.showModuleGraph` | +| `mcpp.showLanguageServerLogs` | `mcppls.showLogs` | +| build 之后 | `mcppls.restartServer` | + +不读 `mcppls.*` 设置、不解析 LSP、不写跨扩展配置。详见 +[`mcppls-integration.md`](mcppls-integration.md)。 + +## 6. 已知的结构性债务 + +- `src/configureOnly.ts`、`src/ideWorkflow.ts` 在 0.4.0 迁移后已无生产调用者(仅被自身测试引用), + 它们描述的是 `compile_commands.json` 时代的行为,与当前职责边界冲突。 +- `cli.ts` 用正则解析 `mcpp toolchain list` 的**人类输出**,而 mcpp 已提供 + `--format json` 的稳定契约。 +- 命令 ID `mcpp.refreshCompilationDatabase` / `mcpp.checkModuleSupport` 是历史拼写,与当前语义 + (build / restart)不符。 + +具体建议见 `.agents/reviews/` 下的评审报告。 diff --git a/.agents/docs/archive/2026-10-02-implementation-plan.md b/.agents/docs/archive/2026-10-02-implementation-plan.md new file mode 100644 index 0000000..229e9b2 --- /dev/null +++ b/.agents/docs/archive/2026-10-02-implementation-plan.md @@ -0,0 +1,342 @@ +# mcpp-vscode 0.5.0 实施计划:任务拆分、依赖关系与验收 + +- 日期:2026-10-02 +- 上游设计:[`2026-10-02-plugin-optimisation-plan.md`](2026-10-02-plugin-optimisation-plan.md)(方案 v4) +- 分支:`feat/plugin-optimisation-v0.5.0`,**单 PR**,目标版本 `0.5.0` +- 本文只讲"怎么落地":任务、依赖、并行分组、每个任务自己的验收方式。 + +--- + +## 1. 六个评审角度 → 落到哪条硬约束 + +| 角度 | 硬约束(可机械检查) | 落在 | +|---|---|---| +| **架构** | `src/` 按用途分组;纯逻辑模块不 import `vscode`;每个新模块有对应测试 | T01、T03、T18、T24、T30、T33 | +| **稳定性** | 所有 `runProcess` 有超时;解析优先 JSON;任何上游失败都不能把成功的 `mcpp build` 变成失败 | T05–T09、T14 | +| **优雅简洁** | 单一事实源(配置 registry、i18n bundle、schema 快照);无重复的命令 ID 字面量;无死代码 | T02、T03、T24、T30 | +| **用户体验** | modal 只用于不可逆操作;每个视图有三态;每个清理有两步以上确认;文案跟随语言 | T15、T20–T23、T34、T37、T02 | +| **兼容性** | 不注册 `mcppls.*` 命令;`extensionDependencies` 语义不变;旧命令/旧设置保留为别名 | T11–T14、T04、T38 | +| **跨平台** | 无平台假设的路径/分隔符;CI 覆盖 linux-x64 + darwin-arm64;平台矩阵只在文档与 release note 声明 | T39、T40、T42 | +| **一致性** | 命令 ID / 设置键 / 文案 key 三者在 registry 与 package.json 之间由 CI 校验 | T03(check-config)、T02(l10n-check) | +| **无感升级** | 0.4.x 用户的设置、键位、命令 ID 全部继续工作;新增行为默认"不打扰";CHANGELOG 写迁移说明 | T04、T10、T38、T41 | + +--- + +## 2. 任务表 + +> **并行组**:同一组内的任务互不触碰同一文件,可以同时做;跨组有依赖。 +> **状态**:✅ 已完成 / 🔄 进行中 / ⬜ 未开始 + +### P0 地基(必须先做:后面所有任务都往这三处加东西) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T01 | 目录按用途分组 | `src/{cli,projects,toml,mcppls,commands,workflows}/` | — | P0-a | `npm test` 179 通过;`git log --follow` 可用 | ✅ | +| T02 | i18n 机制 | `data/i18n/{en,zh-cn}.json`、`l10n/bundle.l10n*.json`(生成)、`package.nls*.json`、`src/i18n/t.ts`、`tools/generate-l10n.mjs`、`tools/l10n-check.mjs` | T01 | P0-b | 缺 key 时 `l10n-check` 退出 1;`mcpp.ui.language` 三态有单测 | ⬜ | +| T03 | 配置模块 | `data/config-registry.json`、`src/config/{registry,access,validate,migrate}.ts`、`tools/check-config.mjs` | T01 | P0-c | registry ↔ package.json 语义一致的 CI 门禁;60 项全部可读且默认值正确 | ⬜ | +| T04 | 版本与迁移说明 | `package.json` 0.5.0、`CHANGELOG.md`(英) | — | P0-d | tag 与版本一致(release workflow 已有校验) | ⬜ | + +### P1 稳定性基座 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T05 | mcpp 协议探测 | `src/cli/protocol.ts` | T03 | P1-a | 对真实 mcpp 解析出 `mcpp.protocol`;对旧输出返回"不支持"且不抛 | ⬜ | +| T06 | 工具链清单 JSON 优先 | `src/cli/toolchain.ts` | T05 | P1-a | JSON 路径与文本路径对同一份样本给出一致结果 | ⬜ | +| T07 | 超时与输出上限 | `src/cli/process.ts` | T03 | P1-b | 超时返回可识别错误;截断保留尾部并置标志 | ⬜ | +| T08 | 错误分层(SPEC-003) | `src/cli/errors.ts` | T07 | P1-b | 2/4/1/70/127 各有断言 | ⬜ | +| T09 | `mcpp.path` 校验 | `src/cli/controller.ts` | T05 | P1-c | 配置变更时校验一次并在频道输出 | ⬜ | +| T10 | 激活面收敛 | `package.json` activationEvents | T03 | P1-c | 只留 workspaceContains + onLanguage:mcpp-* | ⬜ | + +### P2 mcppls 韧性与状态(方案 §2、§3.9) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T11 | 能力表 | `src/mcppls/contract.ts` | T02 | P2-a | 表驱动单测:ID 唯一、danger 合法、无 `mcppls.` 前缀的本扩展命令 | ⬜ | +| T12 | 能力探测 | `src/mcppls/capabilities.ts` | T11 | P2-a | 静态声明 + 运行期分类;`onDidChange` 失效有单测 | ⬜ | +| T13 | 状态读取适配层 | `src/mcppls/state.ts` | T11 | P2-a | 5 种输入(正常/无 API/抛异常/未知 state/字段缺失)各有断言 | ⬜ | +| T14 | 桥接改造 | `src/mcppls/bridge.ts` | T12、T13 | P2-b | 候选链回退;危险级确认;`build` 永不因 mcppls 失败 | ⬜ | +| T15 | 「C++ Modules」视图 | `src/views/languageServerView.ts` | T14、T33 | P2-c | 三态(未安装/未启用/未暴露状态)可测 | ⬜ | +| T16 | e2e 夹具 5 个 + 套件 | `test/e2e/fixtures/mcppls-stub*` | T12 | P2-d | 5 个变体各自断言降级 | ⬜ | +| T17 | 环境自检命令 | `src/cli/selfCheck.ts` | T05、T13 | P2-e | 快照含版本/协议/能力/状态/已改设置 | ⬜ | + +### P3 缓存统计与两级清理(方案 §3.4) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T18 | 缓存聚合(纯函数) | `src/cli/cache.ts` | T07 | P3-a | 空/单条/incomplete/大数/缺 accessed 各有用例 | ⬜ | +| T19 | 项目产物体积 | `src/cli/artifacts.ts` | T07 | P3-a | 临时目录树用例;越界与权限失败降级 | ⬜ | +| T20 | 缓存 TreeView | `src/views/cacheView.ts` | T18、T19、T33 | P3-b | 节点由纯函数产出,可单测 | ⬜ | +| T21 | 缓存面板 webview | `src/views/cachePanel.ts`、`media/*` | T18、T33 | P3-c | HTML 由纯函数生成;CSP 无外链;预算模拟器有单测 | ⬜ | +| T22 | 清理命令族 | `src/cli/clean.ts` | T18、T19 | P3-a | 五级危险分级各有参数断言 | ⬜ | +| T23 | 清理确认流 | `src/views/*` + `src/cli/clean.ts` | T22 | P3-d | L1 与 `--all` 走两步确认;`--dry-run` 预演先行 | ⬜ | + +### P4 `mcpp.toml` 编辑体验(方案 §3.3) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T24 | schema 快照 + 生成 | `data/toml-schema.json`、`tools/generate-toml-schema.mjs`、`src/toml/schema.ts` | T03 | P4-a | 生成物稳定;读不到源时**失败而非产出空表** | ⬜ | +| T25 | 键与枚举值补全 | `src/toml/completion.ts` | T24 | P4-b | 段/键/值三类上下文各有断言 | ⬜ | +| T26 | 悬停 | `src/toml/hover.ts` | T24 | P4-b | 段头、键、legacy 键各有断言 | ⬜ | +| T27 | 诊断 | `src/toml/diagnostics.ts` | T24 | P4-c | 7 条规则正反例;严重度可配 | ⬜ | +| T28 | 跳转 | `src/toml/navigation.ts` | T24 | P4-c | `workspace`/`path`/`features` 三类 | ⬜ | +| T29 | 依赖版本补全(默认关) | `src/cli/search.ts` | T25 | P4-d | 解析样本;超时/离线降级为空 | ⬜ | + +### P5 `build.mcpp` 智能(方案 §3.2) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T30 | API 快照 + 生成 | `data/buildscript-api.json`、`tools/generate-buildscript-api.mjs` | T03 | P5-a | 31 条指令 / 5 role / 协议 15;源缺失时失败 | ⬜ | +| T31 | 符号表与模块清单 | `src/buildscript/{api,modules}.ts` | T30 | P5-b | `std`/`mcpp.*` 被判为已知模块 | ⬜ | +| T32 | provider 集 | `src/buildscript/providers.ts` | T31、T24 | P5-c | 7 条静态诊断正反例;补全/hover/片段 | ⬜ | + +### P6 视图、视觉与格式化 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T33 | 主题与格式化 | `src/views/theme.ts`、`src/util/format.ts`、`src/util/text.ts` | T02 | P6-a | 字节/时长/相对时间/截断各有断言 | ⬜ | +| T34 | 工程视图与状态栏 | `src/views/{projectView,status}.ts` | T33、T06 | P6-b | 状态栏三态由纯函数产出 | ⬜ | +| T35 | contributions | `package.json` viewsContainers/views/colors/menus | T20、T15、T34 | P6-c | `artifacts` 测试断言结构 | ⬜ | +| T36 | 命令清单落地 | `src/commands/ids.ts` + 注册 | T20–T23、T15、T37 | P6-d | 每个 ID 在 package.json 与会话中一致 | ⬜ | + +### P7 配置面板(方案 §4.3) + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T37 | 配置面板 webview | `src/config/panel.ts`、`media/config.css/js` | T03、T33 | P7-a | HTML 由纯函数生成;scope/来源/生效时机都渲染 | ⬜ | +| T38 | 面板设置写入与边界提示 | `src/config/access.ts` | T37 | P7-b | 不写 `mcppls.*`;资源型设置带 URI | ⬜ | + +### P8 CI、文档与交付 + +| ID | 任务 | 产物 | 依赖 | 并行组 | 验收 | 状态 | +|---|---|---|---|---|---|---| +| T39 | CI 体系 | `.github/workflows/ci.yml`(漂移 job、l10n/check-config 门禁、linux+macos matrix、VSIX 依赖版本断言) | T02、T03、T16 | P8-a | 本地可复跑的等价命令写进 docs | ⬜ | +| T40 | 用户文档 | `README.md`(en)、`README.zh-CN.md`、`docs/*.md`(8 篇) | T02–T38 | P8-b | README 的每个链接都存在(测试断言) | ⬜ | +| T41 | CHANGELOG(英) | `CHANGELOG.md` | T04 | P8-c | 0.5.0 段含迁移与新增 | ⬜ | +| T42 | 本地验收 profile | `tools/dev-profile.mjs`(隔离 extensions/user-data + 安装 VSIX + 打印启动命令) | T39 | P8-d | 脚本自身可跑通并打印可复制的命令 | ⬜ | +| T43 | 自我 review | `.agents/reviews/` 下的 v0.5.0 review | 全部 | P8-e | 每条风险有对应的测试或文档 | ⬜ | + +--- + +## 3. 依赖图(关键路径) + +``` +T01 ─┬─ T02 ─┬─ T11 ─┬─ T12 ─┐ + │ │ └─ T13 ─┼─ T14 ─┬─ T15 ─┐ + │ │ │ └─ T16 │ + │ └─ T33 ─────────┼────────────────┼─ T35 ─ T36 + ├─ T03 ─┬─ T05 ─ T06 ───┘ │ + │ ├─ T07 ─┬─ T08 │ + │ │ └─ T18 ─┬─ T20 ──────────┤ + │ │ ├─ T21 ──────────┤ + │ │ └─ T22 ─ T23 ────┤ + │ ├─ T09 │ + │ ├─ T10 │ + │ ├─ T24 ─┬─ T25 ─ T29 ───────────┤ + │ │ ├─ T26 │ + │ │ ├─ T27 │ + │ │ └─ T28 │ + │ ├─ T30 ─ T31 ─ T32 ─────────────┤ + │ ├─ T37 ─ T38 ───────────────────┤ + │ └─ T17 ──────────────────────────┘ + └─ T04 ─ T41 + T34 ─┘ + T39 ─ T42 + T40, T43 在最后 +``` + +**关键路径**:`T01 → T03 → T24/T30 → T32 → T35 → T36 → T39 → T40 → T43`。 +`T02`(i18n)必须在任何"写文案"的任务之前完成,否则要写两遍。 + +--- + +## 4. 提交策略(单 PR、多 commit) + +| # | commit | 内容 | +|---|---|---| +| 1 | `refactor: 目录按用途分组…` | ✅ T01 | +| 2 | `feat(i18n): 中英文案跟随 VS Code` | T02 | +| 3 | `feat(config): 统一配置注册表与一致性门禁` | T03 | +| 4 | `feat(cli): mcpp 协议探测、超时与错误分层` | T05–T10 | +| 5 | `feat(mcppls): 能力探测与 C++ Modules 状态视图` | T11–T17 | +| 6 | `feat(cache): 缓存统计与两级清理` | T18–T23 | +| 7 | `feat(toml): mcpp.toml 键/值补全、悬停、诊断与跳转` | T24–T29 | +| 8 | `feat(buildscript): build.mcpp 智能` | T30–T32 | +| 9 | `feat(views): 视图容器、状态栏、配色与配置面板` | T33–T38 | +| 10 | `ci+docs: CI 门禁、双语文档、0.5.0 迁移说明` | T39–T42 | +| 11 | `docs(review): 0.5.0 自我 review` | T43 | + +每个 commit 必须保持 `npm test` 绿;`npm run test:e2e` 在 CI 与本地(有显示时)绿。 + +--- + +## 5. 验证矩阵 + +| 层 | 命令 | 覆盖 | +|---|---|---| +| 类型 | `npm run compile` | 全部 | +| 单测 | `npm test` | 纯逻辑:协议、能力、状态、缓存聚合、schema、诊断、格式化、生成物一致性 | +| 契约 | `npm test`(含 `test/toml/contract.test.ts`) | 真实 mcpp 的 `mcpp.toml` 段清单(无 mcpp 时跳过) | +| 生成物 | `tools/check-config.mjs`、`tools/l10n-check.mjs`、漂移 job | registry ↔ package.json ↔ nls ↔ docs | +| E2E | `npm run test:e2e` | 激活、命令注册、5 个 mcppls stub 变体、缓存视图节点、清理走 dry-run | +| 打包 | `npm run package` + `unzip -t` | VSIX 结构与依赖声明 | +| 人工 | `tools/dev-profile.mjs` 产出的隔离 profile | 用户在真实 VS Code 里验收 | + +--- + +## 6. 无感升级检查表(发布前逐条确认) + +- [ ] 0.4.x 的全部命令 ID 仍然存在(旧的转发命令与 `mcpp.clean` 保留为弃用别名) +- [ ] 0.4.x 的设置键仍然存在(`mcpp.path`、`mcpp.tomlCompletion`、3 个弃用设置) +- [ ] 新增设置默认值全部"不打扰"(新视图不影响既有布局的最小化:面板与视图都可关) +- [ ] `build` 之后的行为与 0.4.x 一致(刷新语言服务),只是优先用轻量重载 +- [ ] 未受信任工作区的行为不放松 +- [ ] CHANGELOG 写清"什么变了 / 什么没变 / 需要手动做什么" + +--- + +## 7. 进展(round 1,2026-10-02) + +分支 `feat/plugin-optimisation-v0.5.0`,`npm test` 390 通过,三个生成物/一致性门禁全绿。 + +| 任务 | 状态 | 产物 | +|---|---|---| +| T01 目录重构 | ✅ | `9 个提交之一`;179→390 测试 | +| T02 i18n 机制 | ✅ | `data/i18n/zh-cn.json`、`l10n/`(生成)、`package.nls*.json`、`src/i18n/{t,translate}.ts`、`tools/{generate-l10n,l10n-check}.mjs`;`package.json` 用户可见字符串全部 `%key%` | +| T03 配置模块 | ✅ | `data/config-registry.json`(64 项/10 组/29 公开)、`src/config/{registry,validate,access,migrate,presets}.ts`、`tools/{check-config,generate-settings-docs}.mjs`、`docs/settings.md`(生成) | +| T05 协议探测 | ✅ | `src/cli/protocol.ts`(已对真实 mcpp 2026.9.30.2 交叉验证) | +| T08 错误分层 | ✅ | `src/cli/errors.ts`(SPEC-003,含 101 仅 mcpp run / 4 / 127) | +| T11–T14 mcppls | ✅ | `src/mcppls/{contract,capabilities,state,bridge,messages}.ts`;15 项能力、候选链、惰性分类、状态白名单、结构化结果 | +| T18/T19 缓存聚合 | ✅ | `src/cli/{cache,artifacts}.ts` | +| T22 清理计划 | ✅(表) | `src/cli/clean.ts`(五级危险、预演、二次确认);命令接线待做 | +| T24/T27 TOML | ✅ | `data/toml-schema.json`(30 段/97 键)、`src/toml/{schema,diagnostics}.ts`、`tools/generate-toml-schema.mjs` | +| T30–T32 buildscript | ✅(分析层) | `data/buildscript-api.json`(31 指令/5 role/协议 15)、`src/buildscript/{api,modules,analysis,providers}.ts` | +| T33 格式化 | ✅ | `src/util/{format,text}.ts` | +| 视图模型 | ✅(模型层) | `src/views/models.ts`(三棵树,标签即 key)、`src/projects/summary.ts` | + +**下一轮的起点**:`package.json` 的 contributions(commands / viewsContainers / views / colors / +menus / activationEvents)、`src/views/` 的 provider 与命令实现、`src/extension.ts` 接线、 +`tools/dev-profile.mjs`、CI 工作流、README 双语与 `docs/*`、0.5.0 版本与 CHANGELOG。 + +**上游反馈已记录**:生成脚本对照 mcpp 源码时发现方案文档里两个不存在的键名 +(`[profile.].opt_level` 实为 `opt`;`bidi_schedule` 实为 `bmi_schedule`),已修正。 + +--- + +## 8. Round 2 审计出的缺口(下一轮的输入) + +文档子代理在写 `docs/` 时逐项核对了代码,发现"设置已声明但无人读取"的地方。这不是文档 +缺陷,而是**功能缺陷**:声明了却不生效,比没有这个设置更糟。按严重度排列: + +| # | 缺口 | 证据 | 处理 | +|---|---|---|---| +| G1 | `mcpp.toml` 的 hover 与跳转不存在 | `src/toml/providers.ts` 只注册补全与诊断;`mcpp.toml.hover`/`navigation` 无人读 | 实现 `src/toml/hover.ts` + `src/toml/navigation.ts`(schema 已有键与文档锚点) | +| G2 | 键/枚举值补全不存在 | `src/toml/completion.ts` 用的是手写 25 条段头表,**没有读** `data/toml-schema.json` | 让补全读 schema(键 + 枚举 + legacy 提示) | +| G3 | 依赖版本补全不存在 | `mcpp.toml.indexCompletion*` 无人读,`mcpp search` 无人调用 | 实现 `src/cli/search.ts` + 接入补全 | +| G4 | 缓存面板不是 webview | `mcpp.showCachePanel` 打开只读 Markdown 预览 | 实现 `src/views/cachePanelHtml.ts` + `cachePanel.ts`(叠条/年龄分布/TopN/预算模拟器) | +| G5 | 未贡献任何快捷键 | `contributes.keybindings` 缺失,方案 §3.5 承诺了 ctrl+alt+b/r/t/m/, | 加 keybindings | +| G6 | `mcpp.cache.gc.confirmAboveGiB` 无人读 | gc 超过阈值不会追加确认 | 接入 | +| G7 | pre-v1 遗留缓存节点永不显示 | `state.legacyBytes` 从未赋值 | 从 `cache dir` 的 legacy 路径估算体积并赋值 | +| G8 | 大量设置无人读 | 64 项中仅 16 项被生产代码读取 | 逐项接线:`mcpp.task.*`、`mcpp.languageService.*`、`mcpp.views.*.show`、`mcpp.ui.*`、`mcpp.runtime.*`、`mcpp.log.level`、`mcpp.buildScript.intelligence/snippets/imports.knownModules`、`mcpp.cache.statusBar/warnAboveGiB/autoRefreshSeconds/showLegacy` | +| G9 | 环境自检的缓存段永远是"not read" | `showSelfCheck` 没有传 `cache` | 采集后传入 | +| G10 | `src/cli/errors.ts` 与 `src/config/migrate.ts` 没有生产调用者 | 退出码分层与旧键迁移提示都没接 | 接入错误提示与首次激活的迁移提示 | +| G11 | i18n 仍有硬编码中文 | `src/cli/controller.ts`、`src/extension.ts` 多处直接写中文 | 全部改走 `t()`,并补 zh 条目 | +| G12 | `VERIFIED_MCPPLS_RANGE` 无人读 | 没有版本提示 | 在会话头/自检里提示(不作为门禁) | +| G13 | `mcpp.ui.numberFormat` 无人读 | `formatBytes` 恒为二进制单位 | 接入 | + +**验收方式**:新增一条单测,遍历 `data/config-registry.json`,断言每个设置键在 `src/` +中至少被"读取一次"(用一张显式的"由谁读取"映射表,而不是正则扫描),缺一项即失败。 +这条测试是这一轮的产出物之一——它把"声明了就要生效"变成可执行的约束。 + +--- + +## 9. 进展(round 2,2026-10-02) + +`npm test` 420 通过;`check-config` / `l10n-check` / `check-generators` 全绿; +VSIX 打包 69 文件 180 KB;本地隔离 profile 已就绪(含 mcppls 0.0.9 与一个可用的 mcpp 工程)。 + +| 任务 | 状态 | 说明 | +|---|---|---| +| T15/T20/T21/T34–T38 视图与命令 | ✅ | 三棵视图、42 个命令、视图容器、两个颜色 ID、配置面板 | +| T16 e2e 五变体 | ✅(代码)/ ⚠️(本地未跑通) | 变体生成 + 每变体一次隔离 Extension Host;本地因无法下载固定版 VS Code 未完成 | +| T39 CI | ⬜ | 工作流尚未更新 | +| T40 文档 | ✅ | README 英/中 + 7 篇 `docs/`,52 条链接全部解析 | +| T41 CHANGELOG | ✅ | 0.5.0 英文段 | +| T42 本地 profile | ✅ | `tools/dev-profile.mjs` | +| G1–G13(§8 审计缺口) | ⬜ | 下一轮的输入 | + +**本地 e2e 的限制**:`@vscode/test-electron` 需要下载 135 MB 的 1.91 稳定版,本机网络在 +下载中途反复中断;改用已安装的 VS Code(`MCPP_E2E_CODE`,本轮新增)时 Extension Host +以 0 退出但没有执行套件。因此 **e2e 的结论仍以 CI 为准**(CI 里有 xvfb 与固定版本), +本地只验证到"编译通过、变体生成正确、命令面完整"。这一条必须在本轮报告里如实说明。 + +--- + +## 10. 进展(round 3,2026-10-02) + +`npm test` **471 通过**(round 2 为 420);三个门禁全绿;CI 重写。 + +| 项 | 状态 | 说明 | +|---|---|---| +| G1 `mcpp.toml` hover/跳转 | ✅ | `src/toml/{hover,navigation}.ts` + `providers.ts` 注册,两个开关生效 | +| G2 键/枚举补全读 schema | ✅ | `completion.ts` 改为快照驱动;快照没有但 mcpp 接受的段(如 `[workspace.dependencies]`)由手写清单补齐 | +| G3 依赖版本补全 | 🟡 | `src/cli/search.ts` 解析器已写并测试;尚未接进补全(唯一一处解析人类输出,注释已说明) | +| G4 缓存 webview 面板 | ✅ | `src/views/cachePanel{,Html}.ts` + `media/cache.css`,叠条/年龄分布/TopN/预算模拟器,已替换原来的 Markdown 预览 | +| G5 快捷键 | ⬜ | 仍未贡献 keybindings | +| G6 gc 阈值与遗留缓存节点 | 🟡 | `warnAboveGiB` 已在面板生效;`confirmAboveGiB` 与 `legacyBytes` 未接 | +| G8 设置接线 | 🟡 | 已接:task 参数四键、状态栏开关、菜单开关、并发作用域、超时、输出缓冲、语言服务刷新模式、视图可见性、启动自检、buildscript 总开关、数字格式。其余见下面的例外表 | +| G11 i18n | 🟡 | 新增一条"硬编码中文不得增长"的门禁(当前 162 行封顶) | +| G13 数字格式 | ✅ | `mcpp.ui.numberFormat` 已作用于缓存状态栏与面板 | +| T39 CI | ✅ | 见下 | + +### 新增的两条"不得倒退"门禁 + +- `test/config/wiring.test.ts`:注册表里每个设置必须被代码读取,或在 `EXCEPTIONS` 里 + 写明理由并指向 §8 的缺口编号。例外表当前 **40** 条,只允许变小(有一条测试专门断言这点)。 + 已弃用的 4 个设置反过来断言"绝不被读取"。 +- `test/i18n/hardcoded.test.ts`:`src/` 里未走 `t()` 的中文行数封顶 162,只允许下降。 + +### CI(`.github/workflows/ci.yml`) + +| job | 内容 | +|---|---| +| `gates` | ubuntu + macos-14 矩阵跑 `npm test`(注册表/i18n/生成物漂移/单测) | +| `generated-drift` | 检出 mcpp 仓库并设置 `MCPP_REPO`,让快照漂移门禁真正执行;另断言 `docs/settings.md` 无 diff | +| `package` | 打包、`unzip -t`、清单承诺校验(依赖未变、无第二个语言客户端、无 `onCommand:*`)、VSIX 必须包含 l10n/nls/registry/css、上传产物 | +| `extension-host-e2e` | `xvfb-run` 下跑全部五种 mcppls 变体 | +| `isolated-install` | 把 VSIX 装进私有 profile,让 VS Code 解析依赖并断言 `sunrisepeak.mcpp-language-server@<版本>` 真实存在 | + +--- + +## 11. 进展(round 4,2026-10-02) + +| 项 | 状态 | 说明 | +|---|---|---| +| G5 快捷键 | ✅ | ctrl/cmd+alt + b/r/t/l/m,作用域 `mcpp.inProject`,并有断言 | +| G8 设置接线 | 🟡 | 三个并行工作流(controller 组 / views+mcppls 组 / buildscript+toml 组)在跑 | +| G10 `errors.ts`、`migrate.ts` | ✅ | 重命名提示接入激活路径(每工作区一次,旧键保留);退出码指引由 controller 组接入 | +| G12 版本提示 | ✅ | 低于 `VERIFIED_MCPPLS_RANGE` 时在输出频道写一句,仅提示不做门禁 | +| 视图可见性 | ✅ | `setContext` + `contributes.views.*.when` | +| 例外表 | 🟡 | 31 条,新增一条'已接线的设置必须从例外表删除'的断言,防止例外表腐烂 | + +--- + +## 12. 进展(round 5,2026-10-02) + +| 项 | 状态 | 说明 | +|---|---|---| +| 设置文档中英对照 | ✅ | `gen:docs` 直接复用 `package.nls.zh-cn.json` 的描述,64 个设置中英并列,不产生第三份译文 | +| 发布流程守卫 | ✅ | `release.yml` 与 CI 同样校验 VSIX 体积与目录(那个 64 MB 的事故若发生在发布流程里会直接进 Release 附件) | +| 死代码 | ✅ | 除入口 `src/extension.ts` 外,`src/` 已无未被引用的模块 | +| G11 i18n(162 行硬编码中文) | 🟡 | 后台工作流正在清零,完成后需合并译文并把门禁上限降到实际值 | +| Windows CI | ⬜ | 仍未覆盖;macOS 也在矩阵里,但同样未经本机验证(本机只有 Linux) | + +### round 5 收尾 + +| 项 | 状态 | +|---|---| +| G11 i18n | ✅ **162 → 0**,门禁改为断言 0;`t.ts` 真正变成 vscode-free(import type + 惰性 require) | +| 架构门禁 | ✅ 新增 `test/architecture.test.ts`:41 个纯模块既不得 `import vscode`,也必须能在无编辑器进程中加载 | +| 设置文档 | ✅ 64 个设置中英对照 | +| 发布守卫 | ✅ `release.yml` 与 CI 同样校验体积与目录 | +| Windows CI | ⬜ 已知缺口(本机只有 Linux,macOS 同样未经本机验证) | +| e2e / CI 本机运行 | ⬜ 环境限制,结论以 CI 为准 | diff --git a/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md b/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md new file mode 100644 index 0000000..df0e0bf --- /dev/null +++ b/.agents/docs/archive/2026-10-02-plugin-optimisation-plan.md @@ -0,0 +1,1115 @@ +# mcpp-vscode 插件优化方案(v4,待评审) + +- 日期:2026-10-02 +- 基线:`mcpp-vscode` `main@20f1076`(0.4.0) +- **硬约束:不改动 `mcpp` 与 `mcppls` 的任何代码。** 全部改动落在 mcpp-vscode 仓库内; + 对上游只读(贡献者机器上读源码生成快照,运行期不新增依赖)。 +- 前置分析:`.agents/reviews/2026-10-02-mcpp-vscode-architecture-and-mcppls-dependency-review.md` +- 版本历史:v1 首版 → v2 用真实 mcppls payload 实测后重写 `build.mcpp` 一节、缓存收敛为两级、 + 补 i18n / TOML 编辑 / 配色 / UI 对比 → v3 定稿评审意见、新增主线 D「统一配置模块与配置面板」、 + 补完整设置清单、去掉模式 B 的实现(仅文档备注)、加自我 review → + **v4 新增 §3.9「把 mcppls 的状态与管理并入 mcpp」、`cache clean --all` 的确认分级、 + 第一版公开设置清单(§4.6)、mcppls 状态读取的防御式设计与两个新 e2e 夹具。** + +--- + +## 0. 已确认的决策 + +| # | 决策 | 落点 | +|---|---|---| +| 1 | 目录树重构 + 两提交执行;UI 采用 **U2**(视图容器 + 2 TreeView + 1 Webview 面板) | §1.2、§3.6 | +| 2 | README 英文主 + `README.zh-CN.md`;`package.json.description` 与 `CHANGELOG.md` 改英文 | §1.3 | +| 3 | `docs/superpowers/` → `.agents/superpowers/` | §1.4 | +| 4/5 | `build.mcpp` **只做模式 A(隔离 + 自研 mcpp:: 智能)**;模式 B 不实现,仅在文档备注 | §3.2、附录 D | +| 6 | 清理收敛为**两级**:`mcpp clean` / `mcpp clean --stale` | §3.4 | +| 7 | i18n 第一版:`package.nls.*` 全量 + 关键流程文案;运行时文案渐进迁移 | §3.7 | +| 8 | 激活面收敛(去掉 `onLanguage:cpp`) | §3.1 | +| 9 | 查询类命令默认超时 30 s(`0` = 不限) | §3.1 | +| 10 | 配色 / 可视化 / `build.mcpp` / `mcpp.toml` 编辑 / mcpp 功能都要完整设计 | §3.3–§3.9 | +| 11 | `mcpp.cache.staleDays` 默认 **3** 天 | §3.4 | +| 12 | 全局缓存**暴露更多功能**,并配更好的可视化 | §3.4.3 | +| 13 | `mcpp.toml` 未知段/未知键的默认严重度 = **warning** | §3.3 | +| 14 | 依赖版本补全**进方案**(默认关,唯一的"解析人类输出"例外) | §3.3.2 | +| 15 | **凡可配置的功能都要有设置项,并由统一的配置模块管理 + 配置面板操作** | **§4(主线 D)** | +| 16 | 第一版**公开发布 29 项设置**,其余进入 `advanced` 默认隐藏 | §4.4、§4.6 | +| 17 | **保留** `mcpp.ui.language` 手动覆盖,并在面板与 docs 明确标注"部分界面不受影响" | §3.7 | +| 18 | **保留**依赖版本补全(`mcpp.toml.indexCompletion`) | §3.3.2 | +| 19 | 全局缓存**暴露 `cache clean --all`**,但点击必须走**确认提示** | §3.4.3、§3.4.4 | +| 20 | 配置面板定位:**不取代**原生设置页,只做集中 / 解释 / 预设 / 边界提示 | §4.3 | +| 21 | **把 mcppls 的核心状态与管理并入 mcpp-vscode**(LSP 状态、引擎、问题、缓存重置、诊断包…) | **§3.9** | + +--- + +## 1. 主线 A:仓库结构与文档 + +### 1.1 目标 + +1. `src/` 从"15 个文件平铺"变成"按用途分组",`test/` 镜像; +2. 新增的 UI、缓存、build 脚本、i18n、config 五块有明确归属; +3. `README.md` ≤ 120 行、英文,`README.zh-CN.md` 同结构; +4. 用户文档(`docs/`)、贡献者文档(`.agents/docs/`)、过程文档(`.agents/superpowers/`)边界明确。 + +### 1.2 目录树 + +``` +src/ + extension.ts # 唯一装配点:activate/deactivate + commands/{ids.ts,menu.ts} # 命令 ID 与快捷菜单清单(纯数据) + config/ # 【新】主线 D:统一配置模块 + registry.ts # 读 data/config-registry.json,导出类型化条目 + access.ts # 类型安全读写 + 生效值来源(default/user/workspace/folder) + validate.ts # 枚举/范围/正则校验,非法值回退默认并提示一次 + migrate.ts # 旧键 → 新键的 alias 与一次性迁移提示 + panel.ts # 配置面板 webview + cli/ # mcpp CLI 适配层 + process.ts # 原 process.ts(+ 默认超时、输出截断) + protocol.ts # 【新】--protocol-version 探测与信封解析 + toolchain.ts # 原 cli.ts(JSON 优先,文本 fallback) + controller.ts # 原 cliController.ts + tasks.ts # 原 tasks.ts + newProject.ts # 原 newProject.ts + artifacts.ts # 【新】target/ 体积估算(只读) + cache.ts # 【新】cache/clean 的调用与聚合(纯函数优先) + search.ts # 【新】`mcpp search` 解析(依赖版本补全,默认关) + projects/{discovery.ts,context.ts} + toml/{parser.ts,schema.ts,completion.ts,hover.ts,diagnostics.ts,navigation.ts} + buildscript/{api.ts,modules.ts,providers.ts} + mcppls/{contract.ts,capabilities.ts,state.ts,bridge.ts} + views/{status.ts,projectView.ts,cacheView.ts,cachePanel.ts,languageServerView.ts,theme.ts} + i18n/t.ts # 【新】文案解析(auto → vscode.l10n;否则读自带表) + util/{text.ts,format.ts} + +data/ + config-registry.json # 【新】主线 D 的单一事实源(提交) + buildscript-api.json # 【新】mcpp 构建脚本 API 快照(提交) + toml-schema.json # 【新】mcpp.toml 段/键/枚举快照(提交) + i18n/{en.json,zh-cn.json} # 【新】运行时文案的单一来源(提交) +media/ # webview 静态资源(CSS/SVG/JS),CSP 安全、无网络 +l10n/ # 由 data/i18n 生成,勿手改 + bundle.l10n.json + bundle.l10n.zh-cn.json +package.nls.json # 【新】package.json 的英文文案(默认) +package.nls.zh-cn.json # 【新】package.json 的中文文案 +tools/ + generate-buildscript-api.mjs # 从 mcpp 仓库生成 data/buildscript-api.json + generate-toml-schema.mjs # 从 mcpp docs/04 + SPEC-004 生成 data/toml-schema.json + generate-l10n.mjs # data/i18n/*.json → l10n/bundle.l10n*.json + check-config.mjs # 【新】registry ↔ package.json 语义一致性门禁 + l10n-check.mjs # 文案 key 与 bundle 覆盖门禁 +test/ # 与 src/ 镜像 + e2e/fixtures/{fake-mcpp.js, mcppls-stub/, mcppls-stub-partial/, mcppls-stub-renamed/, project/} +``` + +**必须一起改的地方**: + +| 位置 | 影响 | +|---|---| +| `tsconfig.json` | 增加 `resolveJsonModule: true`(要 `import` `data/*.json`);`rootDir`/`outDir` 不变 | +| `package.json` `main` | 不变(`./dist/src/extension.js`) | +| `package.json` `test` | `node --test dist/test/*.test.js` → `node --test "dist/test/**/*.test.js"` | +| `test/**` 的 `import "../src/xxx"` | 全部改为新路径 | +| `test/artifacts.test.ts` | 路径 + 把"源码文本断言"改成行为断言 | +| `.vscodeignore` | **排除** `tools/**`、`docs/**`、`.agents/**`、`data/**`(JSON 在编译期被 `import`,产物在 `dist/data/`);**必须保留** `media/**`、`l10n/**`、`package.nls*.json`、`dist/**` | +| `docs/superpowers/**` | `git mv` → `.agents/superpowers/{plans,specs}/` | + +> **执行建议**:提交 1 = `git mv` + import 路径替换 + 测试 glob(零行为变化,门禁 +> `npm test && npm run test:e2e`);提交 2 起才加新文件与功能。 + +### 1.3 README 双语与瘦身 + +`README.md`(英文,≤120 行)+ `README.zh-CN.md`(中文,同结构),顶部语言切换。 +章节:What this is → Why → Install(含平台矩阵)→ Quick start 60s → Features(6 条,各一行) +→ Commands(表)→ Settings(表,链接到 `docs/settings.md`)→ Editing `mcpp.toml` / `build.mcpp` +(各 3 行 + 链接)→ Troubleshooting(5 条一行 + 链接)→ Develop → License。 + +详情拆到 `docs/`:`architecture.md`、`commands.md`、`settings.md`、`mcpp-toml.md`、 +`build-script.md`、`cache.md`、`troubleshooting.md`、`compatibility.md`。 +`package.json` 的 `displayName` / `description` 改为 `%key%` 占位(见 §3.7)。 + +### 1.4 目录边界 + +| 目录 | 读者 | 进版本库 | 内容 | +|---|---|---|---| +| `docs/` | 用户 | ✅ | 用法、设置、排错、兼容性 | +| `.agents/docs/` | 贡献者/agent | ✅ | 设计、契约、决策、本方案 | +| `.agents/superpowers/` | 过程 | ✅ | 历史 plans/specs(原 `docs/superpowers/`) | +| `.agents/reviews/` | 贡献者 | ❌ 已 gitignore | 分析报告、评审记录 | + +--- + +## 2. 主线 B:mcppls 依赖的"永不折断"设计 + +### 2.1 能力模型 + +`src/mcppls/contract.ts` 只有数据。**两类能力**:`forward`(转发 mcppls 的命令)与 +`readState`(只读 mcppls 的状态,见 §3.9)。 + +```ts +export interface Capability { + key: string; // 稳定的内部名,用作日志/设置键 + kind: "forward" | "readState"; + titleKey: string; // 文案 key,走 §4 的 i18n + commands: string[]; // 候选链,按优先级(readState 为空) + required: boolean; // 全部为 false + /** 使用它需要多重的确认。 */ + danger: "none" | "confirm" | "destructive"; + degradedHintKey: string; +} + +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; +export const VERIFIED_MCPPLS_RANGE = ">=0.0.4"; // 实测 0.0.9 后收紧 + +export const CAPABILITIES: readonly Capability[] = [ + // ── 第一批:v1 已有 ── + { key: "refresh", kind: "forward", commands: ["mcppls.reloadBuildDescription", "mcppls.restartServer"], danger: "none" }, + { key: "selectContext", kind: "forward", commands: ["mcppls.selectContext"], danger: "none" }, + { key: "moduleGraph", kind: "forward", commands: ["mcppls.showModuleGraph"], danger: "none" }, + { key: "logs", kind: "forward", commands: ["mcppls.showLogs"], danger: "none" }, + // ── 第二批:§3.9 新增 ── + { key: "readState", kind: "readState", commands: [], danger: "none" }, + { key: "restartEngine", kind: "forward", commands: ["mcppls.restartClangd"], danger: "confirm" }, + { key: "resetCache", kind: "forward", commands: ["mcppls.resetWorkspaceCache"], danger: "destructive" }, + { key: "report", kind: "forward", commands: ["mcppls.collectReport"], danger: "none" }, + { key: "diagnosticBundle", kind: "forward", commands: ["mcppls.exportDiagnosticBundle"], danger: "none" }, + { key: "conflicts", kind: "forward", commands: ["mcppls.turnOffOtherCppFeatures", "mcppls.restoreOtherCppFeatures"], danger: "confirm" }, + { key: "runBuildTool", kind: "forward", commands: ["mcppls.runBuildToolInTerminal"], danger: "confirm" }, + { key: "enableInWorkspace", kind: "forward", commands: ["mcppls.turnOnInWorkspace", "mcppls.turnOffInWorkspace"], danger: "confirm" }, + { key: "installTools", kind: "forward", commands: ["mcppls.installCommandLineTools"], danger: "confirm" }, + { key: "review", kind: "forward", commands: ["mcppls.review.run", "mcppls.review.clear"], danger: "none" }, +]; +``` + +`refresh` 用候选链:上游哪天注册了 `reloadBuildDescription`,本插件自动升级,**无需改代码**。 +`mcpp.languageService.refreshAfterBuild`(§4)可把这条链强制成 `reload` / `restart` / `off`。 + +**`review.run` / `review.clear` 是服务器广告的命令**(由 `vscode-languageclient` 注册), +只在服务器运行后存在;能力探测按"调用失败即缺失"处理,无需特判。它们还受 +`mcppls.ai.enabled` 约束 —— 本插件只**转发**,不读、不写该设置。 + +### 2.2 探测:静态声明 + 惰性运行期分类 + +- **不要**在激活时逐个调用探测:多数 UI 命令都有副作用(弹 QuickPick、开面板、清缓存)。 +- **不要**把 `commands.getCommands()` 当唯一判据(是否包含未激活的贡献命令无保证)。 +- **`readState` 特殊**:它不靠命令,靠 `extension.exports`(见 §3.9),因此有自己的探测方式 + (形状探测 + try/catch),与命令能力分开。 +- **静态**:`getExtension(id)?.packageJSON.contributes.commands` —— 零副作用,激活时 + + `extensions.onDidChange` 刷新。读不到只**置灰并带说明**,不隐藏。 +- **运行期**:首次真正使用时调用一次并分类错误(`command '…' not found` → `missing`)。 + `missing` 才从菜单隐藏,并写一行日志 + 一次性提示(受 + `mcpp.languageService.notifyOnDegraded` 控制)。 + +### 2.3 降级矩阵与不变式 + +| 能力 | 缺失时 | 用户看到 | +|---|---|---| +| `refresh` | build 照常成功,不重试、不弹错 | 输出频道一行;成功提示照常 | +| `readState` | §3.9 的「C++ Modules」视图只显示版本/激活/启用 + 我们自己的刷新历史 + 转发按钮 | 状态区块显示"已安装的 mcppls 未暴露状态" | +| 其余 `forward` | 菜单/视图项隐藏(静态缺失时置灰) | 一次性 info + "查看兼容性"按钮 | + +**不变式(写成测试)**:① mcppls 的任何失败都不能把成功的 `mcpp build` 变成失败; +② 不能让其它命令不可用;③ `deactivate()` 不留悬挂 Promise; +④ `readState` 抛异常或返回未知形状时,**视图仍然可用**(只剩降级内容)。 + +### 2.4 版本只用于提示 + +`getExtension(id)?.packageJSON.version` → 会话头 + 环境自检 + 低于已验证区间的一次性提示。 +**不按版本开关功能**(版本号预测不了改名,能力探测才是判据)。 + +### 2.5 测试 + +五个 e2e 夹具:`mcppls-stub`(全部命令 + 同形状 `exports`)/ `mcppls-stub-partial` +(只有 `restartServer`)/ `mcppls-stub-renamed`(改名)/ **`mcppls-stub-noapi`**(不导出 API)/ +**`mcppls-stub-throwing`**(`lastStatus()` 抛异常、返回未知 `state`)。 +断言:降级、菜单隐藏、"build 永不因 mcppls 失败而失败"、**视图在无 API/异常时仍可用**。 +另加纯单测对 `CAPABILITIES` 与 §3.9 的状态适配层做表驱动形态断言。 + +### 2.6 新命令 `mcpp: 环境自检` + +输出可复制快照:扩展/VS Code/平台、工作区信任与根数、工程根、mcpp 路径+版本+协议+kinds、 +mcppls 版本 + 全部能力状态(`forward` 与 `readState` 分开列)、**mcppls 的状态摘要** +(`state` / `project.source` / `engine` / `issues` 的 code 列表,见 §3.9)、上次刷新时间与结果、 +缓存规模、**所有被改过的设置**(来自 §4 的 registry)、最近错误。把 90% 支持问题一次问清, +且不需要任何上游改动。 + +--- + +## 3. 主线 C:稳定性、编辑体验、缓存、交互与视觉 + +### 3.1 稳定性基座 + +| 项 | 目标 | +|---|---| +| 输出协议 | JSON 优先:`--protocol-version` 判协议 → `toolchain list --format json`;文本解析保留为 legacy 分支并单独测试 | +| 超时 | 查询类默认 30 s(`mcpp.runtime.timeoutSeconds`,`0` = 不限);`build/run/test/install` 走任务终端不设硬上限;`cache gc/prune` 300 s | +| 输出上限 | 保留 16 MiB `maxBuffer`;超限按行截断并提示"完整输出见 `mcpp` 输出频道" | +| 错误分层 | SPEC-003:`2` 用法 / `4` 环境未就绪 / `1` 运行失败 / `70` 内部 / `127` 未知命令 → 不同文案 + 下一步建议 + `mcpp self explain ` 一键跳转 | +| `mcpp.path` | 配置变更时跑一次 `mcpp --protocol-version`(无副作用),版本写进输出频道;不可执行时状态栏警告 | +| 激活面 | 收敛为 `workspaceContains:mcpp.toml` + `onLanguage:mcpp-toml` + `onLanguage:mcpp-build` | +| 未受信任工作区 | 缓存视图降级为纯文件系统估算并注明;清理一律要求信任 | + +### 3.2 `build.mcpp`:只做模式 A + +#### 3.2.1 实测结论(本机真实 mcppls 0.0.8 payload + clangd 23.1.0) + +``` +$ mcppls check build.mcpp --payload +root …/examples/11-features/greeter +database 6 entries, 2 standard library units, 0 left out +module build.mcpp:2: module 'mcpp' not found +E [module_not_found] Line 1: module 'std' not found +E Failed to build module mcpp; due to Don't get the module unit for module mcpp +clangd exit 3 + +$ mcppls check src/main.cpp --payload +database 6 entries, 2 standard library units, 0 left out +clangd exit 0 ← 零诊断 +``` + +并且 `mcpp emit build-database --spec compile-commands --format json` 的 6 条记录里 +**没有 `build.mcpp`**;它只出现在 `data.watch` 里。 + +**三条硬事实**:① mcpp 有意不把构建程序放进编译数据库;② mcppls 的模型里没有 +`build.mcpp`(它只处理 `target/.build-mcpp/deps/...`);③ 因此交给 clangd 后 +**`import std` 与 `import mcpp` 都会报 `module not found`**,不是"只有 mcpp 报错"。 + +而 **VS Code 没有公开 API 能过滤/删除另一个扩展发布的诊断**。所以"交给 C++ 语言服务、 +但抹掉 `import mcpp` 的红线"在不改上游的前提下**做不到**。 + +#### 3.2.2 本版设计:模式 A(隔离)—— 零报错 + +- `build.mcpp` 保持独立语言 id `mcpp-build`,语法即 C++(现有 `include: source.cpp` + + 模块注入语法),**mcppls 不介入 → 永不报错**; +- 在此之上补 **mcpp-vscode 自研的 `mcpp::` 智能**(数据来自 mcpp 的机器可读表,见 §3.2.3): + 补全 / 悬停 / 签名 / 静态诊断 / 片段 / 文档符号 / 折叠; +- **"认识 std 与 mcpp"**:内嵌"构建脚本可用模块名"清单 + (`std`、`std.compat`、`mcpp`、`mcpp.core`、`mcpp.plugins.*`),对其做**高亮 + 悬停说明 + + 文档链接**,并**永不对其报"找不到"**(`mcpp.buildScript.imports.knownModules`)。 + 这就是"std ok"在本层的实现:**认识它、解释它、不为难你**; +- 诚实写明代价:**没有 std 的符号级补全/跳转**。 + +#### 3.2.3 数据来源(贡献者期生成) + +`tools/generate-buildscript-api.mjs`(Node,无依赖)读 `MCPP_REPO`(默认 `../mcpp`): + +| 源 | 取什么 | +|---|---| +| `modules/buildmcpp/src/directives.cppm:297` | `kTable[31]`:wire / tag / slot / scope / transform / must / missingPrefix / missingSuffix / sinceProtocol | +| `modules/buildmcpp/src/directives.cppm:1132-1134` | 5 个 role 常量 | +| `modules/buildmcpp/src/program_protocol.cppm:121,147` | `kProtocolVersion = 15`、`kCacheEpoch = 3` | +| `modules/buildmcpp/src/provisions.cppm:80` | `tool` / `host-module` / `dep-dir` | +| `docs/specs/build-plugins.md`、`docs/30-build-mcpp.md` | 规则号与 hover 章节链接 | + +输出 `data/buildscript-api.json`(含 `sourceVersion` / `sourceCommit` / `protocolVersion`)。 +CI 漂移 job:用 `mcpp@main` 重新生成 + `git diff --exit-code`。 + +**静态诊断规则**(纯文本,不执行任何东西,未受信任工作区可用;严重度由 +`mcpp.buildScript.diagnostics.severity` 控制,默认 warning): +① 未知 `mcpp::`;② `role` 用字符串字面量(R3.6);③ `prepare` 未声明 `output_dir`(R3.3); +④ `link_flag` 含 `-Wl,-rpath`(R4.4);⑤ `cxxflag`/`link_flag` 含 `-I`/`-L`(R2.1); +⑥ action 命令含 `NAME=value cmd` 或 `cd x &&`(R3.8);⑦ `mcpp::action` 未声明 role 或输出。 + +### 3.3 `mcpp.toml` 编辑体验 + +#### 3.3.1 数据:`data/toml-schema.json` + +`tools/generate-toml-schema.mjs` 从 `docs/specs/manifest-semantics.md`(SPEC-004 §2 的平面划分) +与 `docs/04-mcpp-toml.md` 的字段小节生成,人工补**枚举值**与 **legacy 标记**: + +```jsonc +{ + "sourceVersion": "2026.10.1.3", + "sections": [ + { "header": "[package]", "plane": "identity", "doc": "docs/04-mcpp-toml.md#21-package", + "keys": [ + { "key": "name", "type": "string", "required": true }, + { "key": "standard", "type": "enum", "values": ["c++20","c++23","c++26"], "default": "c++23" }, + { "key": "mcpp", "type": "string", "pattern": "^>=.*", "since": "2026.9.28.3" } + ] }, + { "header": "[language]", "plane": "legacy", "deprecatedBy": "[package].standard" } + ], + "rules": [ { "id": "plane-separation", "messageKey": "toml.rule.planeSeparation" } ] +} +``` + +#### 3.3.2 功能矩阵 + +| 功能 | 内容 | 开关(§4) | +|---|---|---| +| 段头补全 | 已有,改为读 schema(平面分组 + legacy 标记) | `mcpp.toml.completion` | +| **键补全** | 已知段的键位置 → 该段全部键(类型、默认值、legacy),已存在的键剔除 | 同上 | +| **枚举值补全** | `standard`、`kind`、`linkage`、`opt`、`[profile.*]`、`[target.]` selector 词表 | 同上 | +| **悬停** | 段头/键 → 类型、默认值、平面、起始版本、legacy 迁移建议、`docs/04` 章节链接 | `mcpp.toml.hover` | +| **诊断** | ① TOML 语法错误;② 未知段;③ 已知段的未知键;④ `[dependencies]` 里的 `xim:` / `[xlings]` 里的 mcpp 包(SPEC-004 §2);⑤ `[package].mcpp` 非 `>=` 形式(SPEC-007 R9.8);⑥ legacy 键;⑦ `[[...]]` 数组表 | `mcpp.toml.diagnostics.*`(**未知段/未知键默认 warning**) | +| **跳转** | `workspace = true` → `[workspace.dependencies]` 同名键;`path = "../x"` → 那个 `mcpp.toml` 的 `[package]`;`features = ["a"]` → `[features.a]` | `mcpp.toml.navigation` | +| **依赖版本补全** | 在 `[dependencies]` 的值位置给候选版本 | `mcpp.toml.indexCompletion`(**默认 false**) | +| 格式化 | **不做** | — | + +**依赖版本补全的诚实边界**(本方案唯一的"解析人类输出"例外): +`mcpp search ` **没有机读格式**(实测 `mcpp search --help` 只有人类列表 + +`--all-versions`),而且它默认会刷新索引(联网)。因此: + +- 默认关闭,开启时给一次性提示"这会执行 mcpp 并可能联网"; +- 用 `mcpp search --all-versions` + `mcpp.toml.indexCompletionTimeoutSeconds`(默认 20 s); +- 解析 `名称 (版本)` 行,**按会话缓存**,失败/超时/离线即静默降级为"无候选"; +- 在 `docs/mcpp-toml.md` 写明这是临时方案,**mcpp 一旦提供机读格式就替换**。 + +### 3.4 缓存统计与清理 + +#### 3.4.1 可用数据(本机实测) + +| 命令 | 数据 | 机读 | +|---|---|---| +| `mcpp cache list --format json` | kind `mcpp.cache`:`data.root` + `data.entries[]`,每条 `{accessed(Unix 秒), bytes, complete, dir, files, key, kind, label}`。实测 **657 条 / 7.20 GiB / pkg 576 · std 81 / 2 条 incomplete / 83 个 label / 最旧 2026-09-23** | ✅ 信封 | +| `mcpp cache dir` | 缓存根 + `legacy (unused, removable with mcpp cache clean --legacy): ` | ❌ 文本 | +| `mcpp cache info ` | `dir/key/package/size/file count/last used/complete/inputs(JSON)` | ❌ 文本 | +| `mcpp cache verify` | 校验条目清单与磁盘 | ❌(看退出码与文本) | +| `mcpp cache prune --older-than ` | 按未使用时长丢弃 | — | +| `mcpp cache gc --max-size --older-than ` | LRU 收敛到预算 | — | +| `mcpp cache clean [--deps\|--std\|--all\|--legacy]` | 分类清空 | — | +| `mcpp clean [--stale] [--older-than …] [--dry-run] [--bmi-cache]` | 见 §3.4.2 | `--dry-run` 文本 | + +`target/` 体积只能自己 walk。mcpp 明确写"`target/` 下的内容不是接口",因此:体积一律标 +**"估算"**;**"过期集合"的权威来源永远是 `mcpp clean --dry-run`**;walk 失败显示"未知"。 + +#### 3.4.2 两级清理 + +| 级别 | 按钮 | 命令 | 语义 | +|---|---|---|---| +| **L1** | 清理项目产物 | `mcpp clean` | 删整个 `target/` | +| **L2** | 清理过期产物 | `mcpp clean --stale --older-than d` | 只删"不是当前构建"的 fingerprint 目录,保留最近 N 天 | + +`N` = `mcpp.cache.staleDays`,默认 **3**(下拉 1 / 3 / 7 / 30 / `0`=一个都不留)。 +不写时 mcpp 用 1 天,我们显式写出以保证可预期。 + +**全局缓存不并入这两级**(`--bmi-cache` 会清掉所有工程共享的缓存 → 全量重建)。 +「连全局缓存一起清」只作为 L1 确认框里**第三个、默认不勾选**的选项。 + +#### 3.4.3 全局缓存:暴露更多功能 + 更好的可视化 + +| 能力 | 命令 | 危险级 | 说明 | +|---|---|---|---| +| 浏览 | `cache list --format json` | 只读 | 树:按包分组 → 展开到条目(key/大小/最近使用/完整) | +| 详情 | `cache info ` | 只读 | 原文展示到只读预览文档(**不解析**) | +| 校验 | `cache verify` | 只读 | 报告损坏/缺失条目;失败项高亮 + "查看输出" | +| 按时间收敛 | `cache prune --older-than d` | 中 | 默认 `mcpp.cache.pruneAgeDays` = 30 | +| 按预算收敛 | `cache gc --max-size GiB [--older-than …]` | 中 | 默认预算 `mcpp.cache.gc.defaultBudgetGiB`(0 = 每次追问) | +| 分类清空 | `cache clean --deps` / `--std` / `--all` | 高 | 逐级更重的确认 | +| 清遗留 | `cache clean --legacy` | 低 | pre-v1 `$MCPP_HOME/bmi` | + +**可视化(webview 面板 `mcpp: 缓存统计`,纯 CSS + 内联 SVG,无图表库、无网络、严格 CSP)** + +1. **顶部两个大数字**:全局缓存 / 项目产物,各带"上次采集时间"; +2. **构成条**:按 `kind`(pkg / std)+ pre-v1 遗留 的横向堆叠条,颜色语义见 §3.5; +3. **年龄分布条**:把条目按 `accessed` 分桶(`<1d` / `1–7d` / `7–30d` / `>30d`,桶由 + `mcpp.views.cache.ageBuckets` 配置)→ 直观回答"能回收多少"; +4. **Top N 表**(`mcpp.views.cache.topN`,默认 5):label / 条目数 / 大小 / 最旧使用。 + 行内只提供「详情」(`cache info` 原文预览)与「复制命令到终端」—— + ⚠ **mcpp 没有"按包删除"的命令**(`cache clean` 只有 `--deps/--std/--all/--legacy` 四档), + 所以**不提供按包删除**,避免做出一个假的按钮; +5. **预算模拟器**:选目标 GiB → 本地按 `accessed` 升序做 LRU 模拟 → 预估"将删除 N 条 / 释放 X"。 + 标注为**预估**(mcpp 的 LRU 还受 `complete` 等影响),执行后以 mcpp 输出为准; +6. **不完整条目**区块:`complete=false` 的条目列出 + 一键 `cache verify`; +7. **pre-v1 遗留**区块:来自 `cache dir`,一键清理; +8. **项目产物**区块:按 triple 分组的条形 + 「过期产物 N 项 / X MiB」(来自 `clean --dry-run`)。 + +**危险操作分级**(`cache clean --all` **照评审意见暴露**,但确认最重): + +| 级 | 操作 | 确认方式 | +|---|---|---| +| 1 只读 | `list` / `dir` / `info` / `verify` | 无 | +| 2 中 | `prune --older-than` / `gc --max-size` | **必看预演**(列出将删除或预估释放;`clean --dry-run` 原文) | +| 3 高 | `clean --deps` / `--std` | modal,标题写明将释放的字节数 | +| 4 极重 | **`clean --all`** | **两步**:modal(写明"将删除本机所有 mcpp 工程共享的包缓存与标准库模块条目")+ 二次勾选确认 | +| 5 最重 | L1 清理 + `--bmi-cache` | modal + 二次勾选 + 文案点名"会影响本机所有 mcpp 工程" | + +所有清理都要求受信任工作区、走现有 `McppOperationRegistry`(与 build 互斥)、**绝不自动执行**。 +`mcpp.cache.gc.confirmAboveGiB`(默认 1)保证任何超过 1 GiB 的收敛动作都至少有 modal。 + +### 3.5 配色与可视化设计系统 + +单一来源 `src/views/theme.ts` + `media/*.css`。**所有颜色只用 VS Code 主题令牌**。 + +| 语义 | 令牌 | 用途 | +|---|---|---| +| 工程 / 主色 | `--vscode-charts-blue` | 工程视图、构建数值 | +| 缓存 / 正常 | `--vscode-charts-green` | 缓存总量、完整条目 | +| 标准库缓存 | `--vscode-charts-purple` | `kind: std` 分段 | +| 过期 / 可清理 | `--vscode-charts-yellow` | 过期产物、L2 按钮 | +| 警告 | `--vscode-charts-orange` + `$(warning)` | 不完整条目、超阈值 | +| 危险 | `--vscode-errorForeground` | L1 确认、`--bmi-cache` | +| C++ 语义(mcppls) | `--vscode-charts-blue` + `$(beaker)` | 「C++ Modules」视图、状态项 | +| 次要文字 | `--vscode-descriptionForeground` | 单位、时间、计数 | +| 进行中 | `--vscode-progressBar-background` + `$(sync~spin)` | 状态栏、视图 | + +- 贡献两个可覆盖颜色 ID:`mcpp.cacheOkForeground`、`mcpp.cacheStaleForeground` + (与 mcppls 的 `mcppls.statusReadyForeground` 同做法)。 +- 图标只用 codicon:`$(tools)` 构建、`$(play)` 运行、`$(beaker)` 测试、`$(trash)` 清理、 + `$(database)` 缓存、`$(graph)` 统计、`$(refresh)` 刷新、`$(history)` 按时间、`$(check)` 校验、 + `$(warning)` 警告、`$(package)` 包、`$(chip)` 工具链、`$(target)` 目标、`$(sync~spin)` 进行中、 + `$(settings-gear)` 配置面板。 +- **数字格式化统一**(`src/util/format.ts`):字节按 `mcpp.ui.numberFormat`(默认二进制 `GiB`); + 三位有效数字;时间相对("3 天前")+ 悬停绝对时间;计数千分位。 +- **三态设计**:每个视图/面板都要有「空 / 加载(`$(sync~spin)` + 上次采集时间)/ + 错误(`$(error)` + 一行原因 + 打开输出频道)」。 +- **通知策略**:modal 只用于不可逆操作(`mcpp.ui.confirmDestructiveOnly`);成功默认不弹 toast; + 失败弹 toast + "查看输出";同一原因去重 `mcpp.ui.notifications.dedupeMinutes`。 +- **状态栏**:一个 `mcpp` 项(priority 40):`$(tools) mcpp` / `$(sync~spin) mcpp: 构建中` / + `$(warning) mcpp: 缓存 8.1 GiB`。tooltip = 工程 + 工具链 + target + 缓存。 +- **快捷键**:`ctrl+alt+b` 构建、`ctrl+alt+r` 运行、`ctrl+alt+t` 测试、`ctrl+alt+m` 菜单、 + `ctrl+alt+,` 配置面板。清理类**不给快捷键**。 +- **与 mcppls 的视觉边界**(v3 修订):**不新增第二个状态栏项**(mcppls 已有 C++ Language + Status Item);输出频道保持独立(`mcpp`)与 `C++ Modules` 并列;但我们**新增第三个视图 + 「C++ Modules」**(§3.9),并在它的标题/描述里**明确标注提供方**,只做展示与转发, + 不冒充 mcppls、不复制它的状态项。 + +### 3.6 UI 形态(已定 U2) + +> v1/v2 比较过三种形态:**U1**(只加命令面板 + 状态栏,视觉侵入最低、可发现性差)、 +> **U2**(Activity Bar 容器 + 2 TreeView + 1 Webview 面板,可视化最好)、 +> **U3**(只加一个控制面板,折中)。评审选定 **U2**;**U1 保留为兜底**——状态栏项与命令面板 +> 永远可用,视图容器被拖走或视图报错时功能不减;U3 作为"若 Activity Bar 的侵入感被否决"的 +> 退路,面板内做同样的可视化,只是没有常驻条目。 + +``` +mcpp ← Activity Bar 容器 $(tools) +├── 工程 (mcpp.project) ← TreeView +│ ├── greeter 0.1.0 · c++23 +│ ├── 工具链 llvm@22.1.8 · target x86_64-unknown-linux-gnu +│ ├── 目标 greet (bin) · 测试 tests/ +│ └── [$(tools) 构建] [$(play) 运行] [$(beaker) 测试] +├── 缓存 (mcpp.cache) ← TreeView +│ ├── 项目产物 … 1.4 GiB(估算)· 3 个 fingerprint +│ │ ├── 过期产物 12 项 · 820 MiB [$(trash) 清理过期产物…] +│ │ └── [$(trash) 清理项目产物…] +│ ├── 全局构建缓存 … 7.20 GiB · 657 条目 +│ │ └── [$(refresh) 刷新] [$(graph) 统计面板] [$(history) 收敛到预算…] [$(check) 校验] +│ └── pre-v1 遗留缓存 … 167.5 MiB [$(trash) 清理遗留缓存] +└── C++ Modules (mcpp.languageServer) ← TreeView,由 mcppls 提供内容(§3.9) + ├── 状态 ready · 引擎 clangd 23.1.0 · 问题 0 + └── [$(refresh) 重启] [$(output) 日志] [$(report) 诊断报告] … +``` + +**兜底**:状态栏项与命令面板永远可用;即使视图容器被用户拖走或视图报错, +所有功能仍可从命令面板触达。 + +**新增命令清单**(全部用 `mcpp.` 前缀;**绝不使用 `mcppls.` 前缀**,见 `.agents/docs/mcppls-integration.md` §3) + +| 命令 ID | 标题 | 分类 | 视图内联 | 备注 | +|---|---|---|---|---| +| `mcpp.openSettings` | mcpp: 打开设置面板 | mcpp | — | §4.3 | +| `mcpp.showCachePanel` | mcpp: 缓存统计 | mcpp | ✅ 缓存视图 | §3.4.3 | +| `mcpp.refreshCacheStats` | mcpp: 刷新缓存统计 | mcpp | ✅ 缓存视图 | 只读 | +| `mcpp.cleanStaleArtifacts` | mcpp: 清理过期产物 | mcpp | ✅ 项目产物 | L2,必看预演 | +| `mcpp.cleanProjectArtifacts` | mcpp: 清理项目产物 | mcpp | ✅ 项目产物 | L1;替换现有 `mcpp.clean` | +| `mcpp.gcGlobalCache` | mcpp: 收敛全局缓存… | mcpp | ✅ 全局缓存 | `cache gc --max-size` | +| `mcpp.pruneGlobalCache` | mcpp: 按时间清理缓存… | mcpp | ✅ 全局缓存 | `cache prune --older-than` | +| `mcpp.verifyGlobalCache` | mcpp: 校验缓存 | mcpp | ✅ 全局缓存 | 只读 | +| `mcpp.cleanLegacyCache` | mcpp: 清理遗留缓存 | mcpp | ✅ 遗留缓存 | `cache clean --legacy` | +| `mcpp.showCacheEntry` | mcpp: 查看缓存条目详情 | mcpp | ✅ 条目 | `cache info` 原文 | +| `mcpp.updateDependencies` | mcpp: 更新依赖 | mcpp | ✅ 工程视图 | `mcpp update`,带预演 | +| `mcpp.selfDoctor` | mcpp: 环境诊断 | mcpp | — | `mcpp self doctor` | +| `mcpp.selfCheck` | mcpp: 环境自检 | mcpp | ✅ 工程视图 | §2.6 | +| `mcpp.explainCode` | mcpp: 解释错误码… | mcpp | — | `mcpp self explain` | +| `mcpp.addDependency` | mcpp: 添加依赖… | mcpp | ✅ 工程视图 | `mcpp add` 向导 | +| `mcpp.removeDependency` | mcpp: 移除依赖… | mcpp | ✅ 工程视图 | `mcpp remove` | +| `mcpp.searchPackages` | mcpp: 搜索包… | mcpp | — | `mcpp search` | +| `mcpp.openMcpplsSettings` | mcpp: 打开 C++ Modules 设置 | mcpp | — | 只打开别人的设置页,**不写** | + +**转发 mcppls 的命令**(§3.9;同样用 `mcpp.` 前缀,**不是** `mcppls.`) + +| 命令 ID | 标题 | 视图内联 | 危险 | 转发到 | +|---|---|---|---|---| +| `mcpp.languageServer.refreshState` | mcpp: 刷新 C++ Modules 状态 | ✅ C++ Modules | none | (只读 `readState`) | +| `mcpp.languageServer.restart` | mcpp: 重启 C++ Modules 语言服务 | ✅ | none | `mcppls.restartServer` | +| `mcpp.languageServer.restartEngine` | mcpp: 重启 clangd… | ✅ | confirm | `mcppls.restartClangd` | +| `mcpp.languageServer.resetWorkspaceCache` | mcpp: 重置本工作区缓存… | ✅ | **destructive** | `mcppls.resetWorkspaceCache` | +| `mcpp.languageServer.selectContext` | mcpp: 选择 C++ 模块分析上下文 | ✅ | none | `mcppls.selectContext` | +| `mcpp.languageServer.showModuleGraph` | mcpp: 查看模块图 | ✅ | none | `mcppls.showModuleGraph` | +| `mcpp.languageServer.showLogs` | mcpp: 打开 C++ Modules 日志 | ✅ | none | `mcppls.showLogs` | +| `mcpp.languageServer.collectReport` | mcpp: 收集 C++ Modules 诊断报告 | ✅ | none | `mcppls.collectReport` | +| `mcpp.languageServer.exportDiagnosticBundle` | mcpp: 导出 C++ Modules 诊断包… | ✅ | none | `mcppls.exportDiagnosticBundle` | +| `mcpp.languageServer.runBuildToolInTerminal` | mcpp: 在终端运行构建工具… | ✅ | confirm | `mcppls.runBuildToolInTerminal` | +| `mcpp.languageServer.manageConflicts` | mcpp: 处理其它 C++ 语言特性… | ✅ | confirm | `mcppls.turnOff/turnOnOtherCppFeatures` | +| `mcpp.languageServer.toggleInWorkspace` | mcpp: 在本工作区启用/停用 C++ Modules… | ✅ | confirm | `mcppls.turnOn/turnOffInWorkspace` | +| `mcpp.languageServer.installTools` | mcpp: 安装 C++ Modules 命令行工具… | ✅ | confirm | `mcppls.installCommandLineTools` | +| `mcpp.languageServer.reviewChanges` | mcpp: 审查工作区改动 | ✅ | none | `mcppls.review.run` / `review.clear` | + +> 现有 4 个转发命令(`mcpp.configureLanguageServer`、`mcpp.checkModuleSupport`、 +> `mcpp.showModuleGraph`、`mcpp.showLanguageServerLogs`)**保留为弃用别名**, +> 指向上表的新 ID —— 与 `mcpp.configureClangd → mcpp.configureLanguageServer` 同一套做法。 + +现有 `mcpp.clean` 保留为 `mcpp.cleanProjectArtifacts` 的弃用别名(与 +`mcpp.configureClangd → mcpp.configureLanguageServer` 的既有做法一致)。 + +### 3.7 国际化(i18n):中/英跟随 VS Code + +| 面 | 做法 | +|---|---| +| `package.json` 用户可见字符串 | `%key%` 占位;`package.nls.json`(英,默认)+ `package.nls.zh-cn.json`(中)。覆盖 `displayName`、`description`、命令标题、设置标题/描述/弃用信息、`untrustedWorkspaces.description` | +| 运行期字符串 | 单一来源 `data/i18n/{en,zh-cn}.json` → `tools/generate-l10n.mjs` 生成 `l10n/bundle.l10n.json` + `l10n/bundle.l10n.zh-cn.json` | +| 解析 | `src/i18n/t.ts`:`mcpp.ui.language === "auto"` → `vscode.l10n.t(key)`;否则读自带表 | +| webview | `getHtml` 时把当前语言的字符串注入,或 `vscode.l10n.uri` | +| 防漏翻译 | `tools/l10n-check.mjs`:`l10n.t(` 的字面量与 bundle 键集合比对;`package.nls.zh-cn.json` 覆盖所有 `%key%`;缺失即 CI 失败 | +| 测试影响 | `test/artifacts.test.ts` 现在断言中文字面量 → 改为断言 `%key%` 形态 + nls 覆盖完整 | + +**第一版范围(已定)**:`package.nls.*` **100% 覆盖**(命令标题、设置项、弃用信息)+ +**关键流程文案**(命令成功/失败、清理确认、降级提示、进度标题)100%; +其余运行期文案随各里程碑渐进迁移,但 `l10n-check.mjs` 从第一天起就阻止**新增硬编码**。 + +**必须写进 docs 的 API 限制**:`mcpp.ui.language` 的手动覆盖**只影响运行时提示与我们的面板**; +命令面板标题、原生设置页里的设置名称**永远跟随 VS Code 语言**(VS Code 在启动时读 +`package.nls.*`,无法运行时切换)。所以用户在 `zh-cn` 界面下把 `mcpp.ui.language` 设成 `en` +会看到"设置名是中文、提示是英文"的混搭 —— 这是有意提供的逃生门,不是缺陷。 + +### 3.8 mcpp 相关功能补齐 + +| 功能 | 说明 | 优先级 | +|---|---|---| +| 工程视图 | 包名/版本/标准/目标/工具链/target/profile | 高 | +| 缓存视图 + 面板 + 两级清理 + 全局缓存功能 | §3.4 | 高 | +| 配置面板 | §4 | 高 | +| `mcpp update` | 重解析依赖并改写 `mcpp.lock`,带"将改动 N 个包"的预演 | 高 | +| `mcpp self doctor` | 环境诊断 → 频道 + 结构化摘要 | 高 | +| `mcpp: 环境自检` | §2.6 | 高 | +| `mcpp add` / `remove` | 依赖编辑向导,与 `mcpp.toml` 键补全联动 | 中 | +| `mcpp search` | 包搜索 → 插入依赖(复用 §3.3.2 的 search 适配层) | 中 | +| `mcpp self explain ` | 失败时一键解释 | 中 | +| `mcpp pack` / `publish --dry-run` | 打包与发布前检查 | 低 | + +### 3.9 把 mcppls 的状态与管理并入 mcpp + +> 目标(评审意见):**把 mcppls 的核心状态与管理纳入 mcpp**——LSP 状态、引擎、构建描述、 +> 问题、缓存情况与清理、诊断包等,让用户在**一个地方**看清并操作。 +> 硬约束仍然是:**不改 mcppls**,所以这里的一切都建立在"**读它已有的状态**" +> 和"**转发它已有的命令**"之上,绝不重新实现、绝不写它的内部文件。 + +#### 3.9.1 能读到什么:三条通道,各有明确边界 + +| 通道 | 内容 | 契约强度 | 本方案的用法 | +|---|---|---|---| +| **A. 扩展元数据** | `packageJSON.version`、`isActive`、`contributes.commands`、平台 VSIX 是否存在、`mcppls.enable` 的当前值(只读配置) | **正式、稳定** | 基础信息与能力探测 | +| **B. `extension.exports`** | mcppls 的 `activate()` 返回一个对象(它对内是"测试 API"),其中 **`lastStatus()` 返回完整的 `CxxModulesStatus`**、`statusBarText()`、`serverRunning()`、`serverEnabled()`、`serverCommands()`、`environment()`、`waitForState()` | ⚠️ **非契约**:它是测试 API,字段可能随 mcppls 变化 | **尽力而为读取**,见 §3.9.2 | +| **C. 转发命令** | §2.1 的 `forward` 能力表(重启、重启引擎、重置缓存、报告、诊断包、冲突处理、启用/停用、在终端运行构建工具、review) | **命令 ID 是事实契约**(同现状) | 所有"管理"动作 | +| ~~D. LSP `cxxModules/status`~~ | 真正的契约来源 | 正式 | ❌ **不可用**:本扩展没有 LSP 客户端,且 CI 明确禁止再建一个 | +| ~~E. mcppls 的磁盘缓存/日志文件~~ | 缓存目录、模型、引擎数据库 | **内部布局,不是接口** | ❌ **不读、不解析、不删除** | + +`CxxModulesStatus` 的形状是 **S3 规范级契约**(`docs/specs/s3-lsp-extensions.md` §4), +所以"内容怎么解释"是稳定的;不稳定的只是"能不能拿到这个对象"。这决定了下面的防御写法。 + +#### 3.9.2 `readState`:尽力而为,坏掉就降级 + +```ts +// src/mcppls/state.ts —— 只有一个入口,返回"一定有值"的视图模型 +export interface McpplsStateView { + available: boolean; // false ⇒ 只有降级内容 + version?: string; + active: boolean; + enabled: boolean; // 只读 mcppls.enable + state?: "starting" | "loading" | "preparing" | "ready" | "degraded" | "error"; + project?: { root: string; source: string; level?: number; tier?: number }; + profile?: { kind: string; compiler?: string; stdlib: string; target: string; standard?: string }; + engine?: { name: string; version: string }; + engines?: Array<{ name: string; version: string; role: string; state: string }>; + progress?: { done: number; total: number }; + issues?: Array<{ code: string; message: string; command?: { command: string; arguments?: unknown[] } }>; + notices?: Array<{ code: string; message: string }>; + onlineRun?: { outcome: string; message: string; at: string }; +} + +export function readState(): McpplsStateView; +``` + +读取纪律(每一条都会写成测试): + +1. `getExtension(id)?.exports` 必须是对象,`typeof exports.lastStatus === "function"`, + 否则 `available: false`——**不抛错、不提示、只是少一块内容**; +2. `lastStatus()` 包在 `try/catch` 里; +3. 返回值必须是对象且 `state` 属于 S3 枚举的六个值之一,否则视为**未知形状** → `available: false`; +4. `issues[].code` 与 `engines[].state` 都用**白名单**渲染,未知值原样显示文本但**不解释**; +5. `readState` 由设置 `mcpp.languageService.readState`(默认 `true`)控制,可完全关闭; +6. **不主动轮询**:以 `vscode.extensions.onDidChange` + 我们自己的命令完成回调 + + `mcpp.languageService.stateRefreshSeconds`(默认 0 = 关)为触发点;高频轮询被明确排除; +7. 视图渲染**永远不因 `readState` 失败而报错**(不变式 ④)。 + +`issues[].command` 是 **S3 自带的"修复动作"**("an optional action that fixes the issue")。 +我们把它渲染成按钮并**直接 `executeCommand(issue.command.command, ...args)`** —— +这是契约内的字段,比我们自己猜修复方式更可靠。典型如 `producer-needs-download` +(`askOnline: true`)→ 提示用户是否允许联网重述工程。 + +#### 3.9.3 缓存与管理:能做的做,做不到的**不假装** + +| 用户想要 | mcppls 侧的可用手段 | 本方案 | +|---|---|---| +| 看 LSP 状态 | `readState()` 的 `state` / `project` / `profile` / `engine(s)` / `progress` | ✅ 视图 + 环境自检 | +| 看模块问题 | `readState()` 的 `issues` / `notices`,含修复命令 | ✅ 逐条列出 + 修复按钮 | +| 看构建描述 | `readState().project.{source, level, tier}` | ✅(**条数/单元数**只有 `mcppls check`/`model` 才有,见下) | +| 看引擎 | `readState().engine` / `engines[]` | ✅ | +| 重启语言服务 / 重启 clangd | `mcppls.restartServer` / `mcppls.restartClangd` | ✅ `confirm` | +| **清理 mcppls 的缓存** | `mcppls.resetWorkspaceCache`(它自己的"重置本工作区缓存") | ✅ **`destructive` + modal 确认**;确认框写明"会清掉本工作区的模型缓存并重新准备,可能耗时数分钟;**不影响** mcpp/全局构建缓存与项目 `target/`" | +| **mcppls 缓存的体积 / 占比 / 可视化** | ❌ 缓存目录**不在** `environment()` 里(它只给 version / vscode / appName / appHost / uiKind / platform / remote),也没有公开命令返回路径或大小;磁盘布局是内部实现 | ❌ **不做**。视图里给"**打开日志**""**收集诊断报告**""**导出诊断包**"三个入口,并在该区块写明"缓存由 C++ Modules 扩展自行管理,本扩展不读取其内部目录"。**向上游提 change request**(附录 D.5):注册一个返回缓存目录/体量的命令 | +| 看诊断 | `readState().issues` + `mcppls.showLogs` + `mcppls.collectReport` / `exportDiagnosticBundle` | ✅ | +| 关掉别的 C++ 扩展 | `mcppls.turnOffOtherCppFeatures` / `restoreOtherCppFeatures` | ✅ `confirm` | +| 在本工作区启用/停用 C++ Modules | `mcppls.turnOnInWorkspace` / `turnOffInWorkspace`(它们写 `mcppls.enable`,是 mcppls 自己的命令) | ✅ `confirm`;视图里显示当前值(只读) | +| 在终端运行构建工具 | `mcppls.runBuildToolInTerminal` | ✅ `confirm` | +| Review Changes | `mcppls.review.run` / `review.clear`(服务器命令,受 `mcppls.ai.enabled` 约束) | ✅ 仅在能力可用时显示,**只转发** | + +> **为什么"构建描述条数"要打折**:`readState()` 只给 `source/level/tier`,不给条目数。 +> 想要"6 entries / 2 std units"这种数字,只能跑 `mcppls check ` 或 `mcppls model` +> —— 那是**另起一个进程加载工程**(重则数分钟),不适合放进视图。 +> 因此本方案的视图**不显示假的条目数**;重信息只在用户**显式点击** +> "环境自检 / 收集诊断报告"时由 mcppls 自己产出。 + +#### 3.9.4 UI:第三个视图 + +`mcpp` 容器新增 **「C++ Modules」** 视图(`mcpp.languageServer`),`package.json` 的 +`views.when` = `mcpp.hasMcppls`(mcppls 存在才显示)。**必须**在视图 `name`/`description` +里写清提供方,避免冒充: + +``` +C++ Modules(由 sunrisepeak.mcpp-language-server 提供,此视图只做展示与转发) +├── 状态 ready · 项目 greeter · 级别 3 +├── 语义配置 clangd 22.1.8 · libc++ · x86_64-linux-gnu · c++26 +├── 构建描述 mcpp(来源)· S1 level 3 · tier 1 +├── 引擎 clangd 23.1.0 [core/ready] · mcppls [modules/ready] +├── 问题(2) $(warning) producer-needs-download … [$(lightbulb) 允许联网重述] +│ $(error) unresolved-module … +├── 通知(1) $(info) 上游写入工程目录 +├── 最近在线运行 fetched · 2026-10-02T12:40:11Z (仅有值时显示) +└── 操作 + [$(refresh) 重启语言服务] [$(chip) 重启 clangd…] [$(history) 重置本工作区缓存…] + [$(symbol-interface) 选择上下文] [$(type-hierarchy) 模块图] [$(output) 打开日志] + [$(report) 收集诊断报告] [$(package) 导出诊断包…] [$(debug-alt) 在终端运行构建工具…] + [$(settings-gear) 打开 C++ Modules 设置] [$(circle-slash) 在本工作区停用…] +``` + +- 问题条目:`$(warning)` / `$(error)` 图标按 `code` 白名单着色(`charts-yellow` / `errorForeground`), + 有 `command` 的显示 `$(lightbulb)` 按钮; +- 状态项用 `charts-green`(ready)/ `charts-yellow`(degraded)/ `errorForeground`(error), + `starting|loading|preparing` 用 `$(sync~spin)`; +- 多根工作区:按 workspace folder 分组(S3 明确每 root 一条状态); +- **空态**:mcppls 未安装/未启用 → 一行说明 + "安装/启用 C++ Modules"按钮; + `readState` 不可用 → "已安装的 mcppls(0.0.x)未暴露状态;下列操作仍可用"; +- **不新增第二个状态栏项**。想要的话,`mcpp.ui.statusBar.showLanguageServer`(默认 **false**) + 会在我们自己的状态栏项里追加一段 `· C++ Modules: ready`,tooltip 里说明"状态由 mcppls 提供"。 + +#### 3.9.5 与 mcppls 自己的 UI 的关系 + +mcppls 已经有一个 C++ Language Status Item、一个 "C++ Modules" 输出频道和一批调色板命令。 +本视图**不取代**它们,定位是: + +- **mcpp 工程视角的汇总**:把"这个工程的 mcpp 侧"和"这个工程的 C++ 语义侧"放在同一棵树里; +- **可发现性**:把 mcppls 散落在命令面板里的管理动作变成可见按钮; +- **诊断入口**:问题 → 修复命令 / 日志 / 诊断包,一条路径走完。 + +因此**不复制** mcppls 的状态栏项,**不合并**输出频道,**不代理**它的设置写入 +(唯一例外是转发它自己的 `turnOn/TurnOffInWorkspace` 命令,那仍然由 mcppls 执行)。 + +--- + +## 4. 主线 D:统一配置模块与配置面板 + +> 设计目标来自评审意见:「凡可配置的功能都要支持配置;配置功能作为独立模块统一管理」。 +> **上游先例**:mcppls 就是这么做的 —— `src/config/settings.cppm` 是唯一的配置注册表, +> 文档、`package.json`、命令行全部由它派生,并由 `tests/test_settings.cpp` 把三者钉在一起。 +> 本方案采用同一套思路。 + +### 4.1 单一事实源 `data/config-registry.json` + +```jsonc +{ + "version": 1, + "groups": [ + { "id": "project", "titleKey": "config.group.project", "order": 10 }, + { "id": "cache", "titleKey": "config.group.cache", "order": 60 } + ], + "settings": [ + { + "key": "mcpp.cache.staleDays", + "type": "number", + "default": 3, + "minimum": 0, + "maximum": 365, + "scope": "resource", // resource | window | machine-overridable + "group": "cache", + "order": 40, + "titleKey": "config.cache.staleDays.title", + "descriptionKey": "config.cache.staleDays.description", + "applies": "next-clean", // immediate | next-build | next-clean | view-reload + "advanced": false, + "tier": "public", // public | advanced(§4.6) + "since": "0.5.0", + "docs": "docs/cache.md#staledays", + "aliases": [] + } + ] +} +``` + +每个条目回答四件事:**是什么、默认什么、什么时候生效、在文档哪一节**。 + +### 4.2 由 registry 派生的四样东西 + +| 产物 | 由谁生成 | 门禁 | +|---|---|---| +| `package.json` 的 `contributes.configuration` | **手写,但由 `tools/check-config.mjs` 做语义一致性校验**(key 集合、type、default、enum、scope、标题 key) | CI:不一致即失败并提示"运行 `npm run gen:config` 同步" | +| `package.nls.json` / `package.nls.zh-cn.json` 的设置文案骨架 | `tools/generate-config.mjs` 生成骨架,人工填中文 | `l10n-check.mjs` 覆盖门禁 | +| `docs/settings.md` 的设置表 | `tools/generate-config.mjs` 生成表格 | `git diff --exit-code` | +| 配置面板 + `mcpp: 环境自检` 的"已改设置"清单 | 运行期读 `src/config/registry.ts` | 单测 | + +> 为什么不直接生成 `package.json`:JSON 往返会重排整个文件,产生无法 review 的巨 diff。 +> 语义校验比文本生成更稳。 + +### 4.3 配置面板(`mcpp: 打开设置面板`) + +- webview,按 registry 的 `groups` 分节渲染;纯 HTML/CSS/JS,严格 CSP,无网络,无框架; +- **每一行**:标题、说明、控件(checkbox / 下拉 / 数字 / 路径选择 / 字符串数组)、 + **当前生效值 + 来源**(默认 / 用户 / 工作区 / 工作区文件夹)、`applies` 提示 + ("下次清理生效")、「重置为默认」、「在原生设置中打开」(`workbench.action.openSettings` + + `@ext:mcpp-community.mcpp-vscode `); +- 工具栏:搜索、**仅显示已修改**、分组折叠、预设(**默认 / 极简 / 重度使用 / 只读浏览**)、 + 导出/导入设置 JSON、全部重置; +- 顶部固定一条边界提示:**这里只影响 mcpp-vscode;`mcppls.*` 由 C++ Modules 扩展自己管理, + 这个面板不写它**;并提供"打开 C++ Modules 设置"按钮 + (`workbench.action.openSettings "@ext:sunrisepeak.mcpp-language-server"`); +- `advanced: true` 的条目默认折叠; +- 多根工作区:按工作区文件夹分别显示"工作区值",并显示最终生效值; +- 写入走 `WorkspaceConfiguration.update(key, value, target)`,`resource` 型设置必须带 URI。 + +### 4.4 完整设置清单 + +> 这是评审意见「很多功能可以做成配置的都要支持配置」的落地。共 **60 项**,全部有默认值, +> 且**默认值一律是"不打扰"**。`advanced` 标记表示面板默认折叠。 + +**工程** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.path` | string | `""` | resource | immediate | +| `mcpp.project.discoveryBoundary` | `workspaceFolder` \| `filesystem` | `workspaceFolder` | resource | immediate | + +**任务** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.task.buildArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.runArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.testArgs` | string[] | `[]` | resource | next-build | +| `mcpp.task.cleanArgs` | string[] | `[]` | resource | next-clean | +| `mcpp.task.confirmClean` | boolean | `true` | resource | immediate | +| `mcpp.task.revealTerminal` | `always` \| `onFailure` \| `never` | `always` | resource | immediate | +| `mcpp.task.focusTerminal` | boolean | `false` | resource | immediate | +| `mcpp.task.clearTerminal` | boolean | `true` | resource | immediate | +| `mcpp.task.problemMatcher` | boolean | `true` | resource | immediate | +| `mcpp.task.editorTitleButtons` | boolean | `true` | resource | immediate | + +**语言服务(mcppls 桥接与状态)** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.languageService.refreshAfterBuild` | `auto` \| `reload` \| `restart` \| `off` | `auto` | resource | immediate | +| `mcpp.languageService.menuItems` | boolean | `true` | resource | immediate | +| `mcpp.languageService.notifyOnDegraded` | boolean | `true` | resource | immediate | +| `mcpp.languageService.readState` | boolean | `true` | resource | immediate | +| `mcpp.languageService.stateRefreshSeconds` | number | `0`(关) | resource | immediate | +| `mcpp.languageService.confirmResetCache` | boolean | `true` | resource | immediate | + +**`mcpp.toml` 编辑** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.toml.completion` | boolean | `true` | resource | immediate | +| `mcpp.toml.hover` | boolean | `true` | resource | immediate | +| `mcpp.toml.navigation` | boolean | `true` | resource | immediate | +| `mcpp.toml.diagnostics.enabled` | boolean | `true` | resource | immediate | +| `mcpp.toml.diagnostics.syntax` | severity | `error` | resource | immediate | +| `mcpp.toml.diagnostics.unknownSection` | severity | **`warning`** | resource | immediate | +| `mcpp.toml.diagnostics.unknownKey` | severity | **`warning`** | resource | immediate | +| `mcpp.toml.diagnostics.planeSeparation` | severity | `warning` | resource | immediate | +| `mcpp.toml.diagnostics.legacyKeys` | severity | `info` | resource | immediate | +| `mcpp.toml.indexCompletion` | boolean | `false` | resource | immediate | +| `mcpp.toml.indexCompletionTimeoutSeconds` | number | `20` | resource | immediate | + +(`severity` = `error` \| `warning` \| `info` \| `off`) + +**`build.mcpp` 编辑** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.buildScript.intelligence` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.diagnostics` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.diagnostics.severity` | `warning` \| `info` \| `off` | `warning` | resource | immediate | +| `mcpp.buildScript.imports.knownModules` | boolean | `true` | resource | immediate | +| `mcpp.buildScript.snippets` | boolean | `true` | resource | immediate | + +**缓存与清理** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.cache.statusBar` | boolean | `false` | resource | immediate | +| `mcpp.cache.warnAboveGiB` | number | `0`(关) | resource | immediate | +| `mcpp.cache.staleDays` | number | **`3`** | resource | next-clean | +| `mcpp.cache.autoRefreshSeconds` | number | `0`(关) | resource | immediate | +| `mcpp.cache.estimateProjectBytes` | boolean | `true` | resource | immediate | +| `mcpp.cache.showLegacy` | boolean | `true` | resource | immediate | +| `mcpp.cache.gc.defaultBudgetGiB` | number | `0`(每次追问) | resource | immediate | +| `mcpp.cache.gc.confirmAboveGiB` | number | `1` | resource | immediate | +| `mcpp.cache.pruneAgeDays` | number | `30` | resource | immediate | + +**视图** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.views.project.show` | boolean | `true` | window | view-reload | +| `mcpp.views.cache.show` | boolean | `true` | window | view-reload | +| `mcpp.views.languageServer.show` | boolean | `true` | window | view-reload | +| `mcpp.views.cache.topN` | number | `5` | window | immediate | +| `mcpp.views.cache.ageBuckets` | string[] | `["1d","7d","30d"]` | window | immediate | + +**界面与通知** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.ui.language` | `auto` \| `en` \| `zh-cn` | `auto` | window | immediate | +| `mcpp.ui.statusBar.show` | boolean | `true` | window | immediate | +| `mcpp.ui.statusBar.showLanguageServer` | boolean | **`false`** | window | immediate | +| `mcpp.ui.notifications.success` | `silent` \| `statusBar` \| `toast` | `statusBar` | window | immediate | +| `mcpp.ui.notifications.dedupeMinutes` | number | `5` | window | immediate | +| `mcpp.ui.confirmDestructiveOnly` | boolean | `true` | window | immediate | +| `mcpp.ui.numberFormat` | `binary` \| `decimal` | `binary` | window | immediate | + +**诊断与日志** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.log.level` | `error` \| `warn` \| `info` \| `debug` | `info` | window | immediate | +| `mcpp.diagnostics.selfCheckOnStartup` | boolean | `false` | window | immediate | + +**高级(面板默认折叠)** + +| key | 类型 | 默认 | 作用域 | 生效 | +|---|---|---|---|---| +| `mcpp.runtime.timeoutSeconds` | number | `30`(0 = 不限) | resource | immediate | +| `mcpp.runtime.maxOutputMiB` | number | `16` | resource | immediate | +| `mcpp.runtime.concurrency` | `perProject` \| `global` | `perProject` | resource | immediate | + +**弃用(保留为别名,面板不显示)**:`mcpp.clangd.path`、`mcpp.modulesSupport`、 +`mcpp.configureCppTools`、`mcpp.tomlCompletion`(→ `mcpp.toml.completion`)。 + +### 4.5 配置模块的职责边界 + +| 归 mcpp-vscode 配置 | 不归 | +|---|---| +| 上表全部 `mcpp.*` | `mcppls.*`(C++ Modules 扩展自己管理,本插件**只读不写**) | +| 面板里明确提示这条边界,并给"打开 C++ Modules 设置"按钮 | 用户的 `clangd.*`(已不在依赖面内) | + +### 4.6 第一版公开发布的设置(29 项) + +按评审意见,第一版**只在原生设置页与面板的常用区公开这些**;其余进入 `advanced` / +`tier: "advanced"`,默认折叠、文档里可查、面板里可搜到。registry 的每个条目因此多一个 +`tier: "public" | "advanced"` 字段。 + +**public(29 项)** + +| 分组 | 键 | +|---|---| +| 工程(1) | `mcpp.path` | +| 任务(8) | `buildArgs`、`runArgs`、`testArgs`、`cleanArgs`、`confirmClean`、`problemMatcher`、`focusTerminal`、`editorTitleButtons` | +| 语言服务(1) | `refreshAfterBuild` | +| `mcpp.toml`(6) | `completion`、`hover`、`navigation`、`diagnostics.enabled`、`diagnostics.unknownSection`、`diagnostics.unknownKey` | +| `build.mcpp`(2) | `intelligence`、`diagnostics` | +| 缓存(4) | `staleDays`、`statusBar`、`warnAboveGiB`、`gc.defaultBudgetGiB` | +| 视图(3) | `views.project.show`、`views.cache.show`、`views.languageServer.show` | +| 界面(3) | `ui.language`、`ui.notifications.success`、`ui.numberFormat` | +| 日志(1) | `log.level` | +| **合计** | **29** | + +**advanced(其余,默认折叠)**:`project.discoveryBoundary`、`revealTerminal`、`clearTerminal`、 +`languageService.{menuItems,notifyOnDegraded,readState,stateRefreshSeconds,confirmResetCache}`、 +`toml.{diagnostics.syntax,diagnostics.planeSeparation,diagnostics.legacyKeys,indexCompletion,indexCompletionTimeoutSeconds}`、 +`buildScript.{diagnostics.severity,imports.knownModules,snippets}`、 +`cache.{autoRefreshSeconds,estimateProjectBytes,showLegacy,gc.confirmAboveGiB,pruneAgeDays}`、 +`views.cache.{topN,ageBuckets}`、`ui.{statusBar.show,statusBar.showLanguageServer,notifications.dedupeMinutes,confirmDestructiveOnly}`、 +`diagnostics.selfCheckOnStartup`、`runtime.*`。 + +> 面板里有一个"显示高级设置"开关;公开与高级**只影响默认展开与设置页排序**, +> 不影响功能可用性。下一版根据使用反馈决定是否把某些项提升为 public。 + +--- + +## 5. 实施顺序与里程碑 + +| 里程碑 | 内容 | 预估 | +|---|---|---| +| **M0** | 目录纯移动 + `docs/superpowers/` → `.agents/superpowers/` + README 中英 + `docs/*` + `tools/` 与 CI 漂移 job 骨架 | 3 天 | +| **M1** | i18n:`data/i18n/*` + `generate-l10n` + `l10n-check` + `package.nls.*` 全量 + 关键流程文案 + 测试改写 | 2–3 天 | +| **M2** | 配置模块:`config-registry.json` + `check-config` + `access/validate/migrate` + `docs/settings.md` 生成 | 2–3 天 | +| **M3** | 配置面板(`mcpp: 打开设置面板`):分组渲染、来源显示、预设、搜索、仅显示已修改 | 2–3 天 | +| **M4** | 稳定性基座(协议优先、超时分级、错误分层、`mcpp.path` 校验、激活面收敛) | 2–3 天 | +| **M5** | 依赖韧性(能力模型、降级矩阵、五个 stub、`mcpp: 环境自检`) | 2–3 天 | +| **M5.5** | §3.9 mcppls 状态与管理:「C++ Modules」视图、`readState` 适配层、转发命令(13 个)、问题与修复按钮 | 3 天 | +| **M6** | `mcpp.toml` 编辑体验(schema 快照 + 键/值补全 + 悬停 + 诊断 + 跳转 + 依赖版本补全) | 4 天 | +| **M7** | `build.mcpp` 智能(快照 + provider + 静态诊断 + 模块清单 + 片段) | 3–4 天 | +| **M8** | 缓存统计与清理(聚合 + TreeView + 面板可视化 + 预算模拟器 + 命令) | 5 天 | +| **M9** | 视觉与交互统一(主题令牌、三态、状态栏、快捷键、进度) | 2 天 | + +M0–M2 先做:M0 定文件位置,M1 定文案写法,M2 定设置写法 —— 后面所有功能都会往这三处加东西, +越晚做返工越大。 + +--- + +## 6. 风险与取舍 + +| 风险 | 说明 | 缓解 | +|---|---|---| +| **设置项数量(60 项)本身就是风险** | 认识负担与"简单易用"相矛盾;测试面变大 | 面板默认只展开常用分组,`advanced` 折叠;全部默认"不打扰";`docs/settings.md` 给"推荐配置";预设一键切换 | +| 目录大移动污染 review | 触碰所有 import | M0 拆两提交,提交 1 零行为变化 | +| 配置面板与原生设置页重复 | 用户困惑"改了不生效" | 每项显示 `applies` 与**生效值来源**;给"在原生设置中打开";面板顶部写明与 `mcppls.*` 的边界 | +| 生成的 nls/docs 与 registry 漂移 | 三份文件要同步 | `check-config.mjs` + `l10n-check.mjs` + `git diff --exit-code` 三重门禁 | +| `mcpp.ui.language` 的能力边界 | manifest 文案无法运行时切换 | 写进 `docs/settings.md`;默认 `auto`;定位为"逃生门" | +| 模式 A 下 std 没有符号级补全 | 真实体验缺口 | 文档写清;上游诉求(把构建程序写进 build database);模式 B 的设计保留在附录 D 备查 | +| 依赖版本补全靠解析人类输出 | 与本方案"JSON 优先"原则冲突 | 明确列为唯一例外、默认关、带超时/离线降级/一次性提示;docs 写明"上游给机读格式就替换" | +| 预算模拟器是本地 LRU 近似 | 与 mcpp 实际策略可能有差异 | 标注"预估",只用于"选预算",不承诺删多少;执行后以 mcpp 输出为准 | +| `cache info` 无机器格式 | 详情视图只能展示原文 | 只读预览文档,不解析、不据此决策 | +| U2 的实现成本最高 | TreeView + 面板 + CSP 资源维护面 | 面板可延后到 M8 后半段;先出 TreeView 与命令;逻辑抽成纯函数,UI 只渲染 | +| `onLanguage:cpp` 收敛有副作用 | 无 `mcpp.toml` 的目录不再出现 mcpp 状态栏 | 有意的;写进 CHANGELOG 与 `docs/architecture.md` | +| TreeView/webview 的 e2e 覆盖有限 | Extension Host 里不能点 UI | 逻辑抽纯函数(聚合、格式化、模拟器)单测覆盖;e2e 只断言命令与视图注册 | +| 里程碑估算偏乐观 | M6–M8 都依赖"生成脚本 + 快照 + CI 门禁"三件套 | M0 就把 `tools/` 与漂移 job 骨架搭好 | +| **`extension.exports` 是 mcppls 的测试 API** | 不是契约;上游重构可能让 §3.9 的状态区整块消失 | 形状探测 + try/catch + 枚举白名单;坏掉只少一块内容(不变式 ④);两个 e2e 夹具(`noapi` / `throwing`)钉住降级行为;由 `mcpp.languageService.readState` 可关 | +| **mcppls 的缓存体量无法可视化** | 缓存目录不在任何公开面里(`environment()` 只给版本/平台/宿主),磁盘布局是内部实现 | **不做假数字**;该区块只给"重置本工作区缓存(它自己的命令)+ 日志 + 诊断包",并向上游提 change request(附录 D.5)。若评审坚持要有数字,需要先拿到上游接口 | +| **`readState` 的轮询会放大风险** | 高频调用可能影响 mcppls 主线程 | 默认 `stateRefreshSeconds = 0`(不轮询),只在 `onDidChange`、命令完成、用户手动刷新时读 | + +--- + +## 7. 自我 review(v4) + +### 7.1 这一版改对了什么 + +1. **v2 的 `build.mcpp` 结论基于实测**,v3 把它固化成"只做模式 A",v4 把模式 B 留在附录 D 备查。 +2. **把配置提升为主线**:设置从"功能的一部分"变成"功能的入口"(registry + 面板 + 四重门禁)。 +3. **两级清理与全局缓存分离**:语义上正确(项目级 vs 机器级),避免 `--bmi-cache` 误点导致全机重建。 +4. **i18n 从"改字符串"变成"改机制"**:单一来源 + 生成 + 门禁。 +5. **v4 新增 §3.9**:把 mcppls 的状态与管理并入 mcpp,且**先说清"拿不到什么"** + —— 缓存体量拿不到就不做数字,构建描述条数拿不到就不显示假条数。 + 这一节的价值不在于多做了功能,而在于**把"能做的"和"做不到的"划在同一条界线上**, + 避免以后有人补一个看起来很美的假面板。 + +### 7.2 我仍然不放心的地方(按严重度) + +| # | 问题 | 我的判断 | +|---|---|---| +| 1 | **设置总数 60 项依然偏多**,即便第一版只公开 29 项 | 已按评审意见分 `public`/`advanced`(§4.6);建议下一版按使用反馈只增不减地调整,不新增"品味型"设置 | +| 2 | **§3.9 建在 `extension.exports` 上**,它明确是 mcppls 的**测试 API** | 这是"在不改上游的前提下能拿到 LSP 状态"的唯一通道。已做形状探测 + 白名单 + 两个 e2e 夹具 + 可关设置。**风险与收益都在这里**:如果评审认为不能用,§3.9 就只剩"转发命令"和版本信息 | +| 3 | **mcppls 缓存体量确实看不到** | 不假装。给它的三个替代入口(重置/日志/诊断包)+ 上游 change request。若你希望一定要有数字,需要先推动上游 | +| 4 | **`mcpp.ui.language` 的手动覆盖只能覆盖一半界面** | 保留但标注(已定) | +| 5 | **配置面板与原生设置页的长期关系** | 定位为"集中 + 解释 + 预设 + 边界提示",不取代(已定) | +| 6 | **`mcpp.toml` schema 的枚举需人工补** | 唯一无法全自动处;写进 `tools/` 注释说明来源 | +| 7 | **缓存面板的预算模拟器可能过度设计** | 若 M8 时间紧,**第一个砍它** | +| 8 | **`mcpp clean --dry-run` 只有文本** | 可接受;将来 mcpp 给机读就换 | +| 9 | **§3.9 会让视图容器从 2 个视图变成 3 个** | Activity Bar 的侵入感增加;但 `mcpp.views.languageServer.show` 可关,且视图在 mcppls 不存在时不显示 | +| 10 | **`mcpp search` 解析人类输出仍是唯一的口子** | 已列为待替换项 | +| 11 | **模式 A 的 `mcpp::` 智能依赖 mcpp 源码结构**(`directives.cppm` 的表位置) | 生成脚本读不到就**失败而不是产出空表**;快照带 `sourceVersion`;漂移 job 在 CI 里跑 | + +### 7.3 如果时间不够,砍的顺序 + +1. `mcpp.toml.indexCompletion`(依赖版本补全)—— 唯一需要联网、唯一解析人类输出; +2. 缓存面板的预算模拟器 —— 保留"选预算 + 执行 + 看结果"; +3. `mcpp.ui.language` 的手动覆盖 —— 只保留跟随 VS Code; +4. `mcpp.pack` / `publish --dry-run` 入口 —— 与 IDE 体验关系最弱; +5. `mcpp.ui.notifications.success` / `mcpp.ui.numberFormat` 这类"品味型"设置 —— 固定为默认值即可。 + +**不砍的**:两级清理、缓存统计与可视化、`build.mcpp` 智能、`mcpp.toml` 诊断、配置模块与面板、 +mcppls 降级能力与状态视图、i18n 机制本身。 + +**v4 新增第 0 顺位(最先砍)**:`mcpp.languageService.readState` 走 `exports` 的那条路 +—— 如果评审认为"依赖测试 API"不可接受,§3.9 退化成"只有转发命令 + 版本信息", +其余设计不受影响。 + +--- + +## 8. 评审已决的问题与遗留 + +**v3 的 5 个问题已全部拍板**(见 §0 第 16–20 条):① 第一版公开 31 项、其余 advanced; +② 保留 `mcpp.ui.language` 并标注;③ 保留依赖版本补全;④ 暴露 `cache clean --all` 但走两步确认; +⑤ 配置面板定位为"集中/解释/预设/边界提示",不取代原生设置页。 + +**v4 遗留(只有一条需要你决定)**: + +1. **§3.9 的状态读取是否接受 `extension.exports`**(mcppls 的测试 API)? + - **接受**(推荐):能显示 `state`/`project`/`profile`/`engine(s)`/`progress`/`issues` + (含 S3 自带的修复命令),代价是上游重构后这一块可能消失(已用形状探测 + 白名单 + + 两个 e2e 夹具 + 可关设置兜住)。 + - **不接受**:§3.9 只保留"转发命令 + 版本/激活/启用信息",视图仍然有用但内容少很多; + 我会同时把"上游提供一个正式的状态/缓存查询命令"提为**更高优先级的 change request**。 + +**v4 记录在案的上游诉求**(本方案不改上游,只记录): + +| # | 对象 | 诉求 | 本插件因此获得 | +|---|---|---|---| +| U.1 | mcpp | 在 `mcpp emit build-database` 里描述构建程序自身(含 `-fmodule-file=mcpp=…` 与 std 单元),该命令不写工程目录 | `build.mcpp` 的 std + mcpp 完整语义(附录 D.1 的模式 B 变成默认,无需改本插件) | +| U.2 | mcppls | 把 `mcppls.reloadBuildDescription` 注册为 VS Code 命令 | build 后从"整机重启"降级为"轻量重载" | +| U.3 | mcppls | 把 `mcppls.mcpp` 暴露为可配置设置 | `mcpp.path` 与语言服务看到同一个 mcpp | +| U.4 | mcppls | 书面承诺 4 个命令 ID 的稳定性 | 把下游测试护栏升级为上游承诺 | +| U.5 | mcppls | 注册一个返回**缓存目录与体量**(或直接清理)的公开命令 | §3.9.3 里目前做不到的"缓存占比可视化" | +| U.6 | mcpp | `mcpp search` 提供机读格式 | 去掉方案里唯一的"解析人类输出" | + +--- + +## 附录 A:本方案用到的 mcpp 命令 + +| 命令 | 用途 | 写盘 | 机读 | +|---|---|---|---| +| `mcpp --protocol-version` | 协议/能力探测 | 否 | ✅ | +| `mcpp self env --format json` | 版本、MCPP_HOME、默认工具链 | 否 | ✅ | +| `mcpp toolchain list --format json` | 工具链/target 矩阵 | 否 | ✅ | +| `mcpp cache list --format json` | 缓存条目与体积 | 否 | ✅ | +| `mcpp cache dir` / `mcpp cache info ` | 缓存根 / 条目详情 | 否 | ❌ | +| `mcpp cache verify` | 缓存校验 | 否 | ❌ | +| `mcpp cache prune --older-than d` | 按时间收敛 | 是 | — | +| `mcpp cache gc --max-size GiB` | 按预算收敛(LRU) | 是 | — | +| `mcpp cache clean --deps\|--std\|--all\|--legacy` | 分类清空 | 是 | — | +| `mcpp clean --stale --dry-run` | 过期产物**预演** | 否 | ❌ | +| `mcpp clean` | L1 清理 | 是 | — | +| `mcpp clean --stale --older-than d` | L2 清理 | 是 | — | +| `mcpp clean --bmi-cache` | L1 + 全局缓存(单独、更重确认) | 是 | — | +| `mcpp search [--all-versions]` | 依赖版本补全(默认关) | 否 | ❌ | +| `mcpp update` / `mcpp self doctor` | 依赖更新 / 环境诊断 | 是 / 否 | — | +| `mcpp build/run/test`、`mcpp toolchain install/default` | 现有任务 | 是 | — | + +## 附录 B:实测证据 + +| 结论 | 证据 | +|---|---| +| mcpp 不把 `build.mcpp` 放进编译数据库 | `examples/11-features/greeter` 上 `mcpp emit build-database --spec compile-commands --format json` → 6 条(counters.cppm / greeter.cppm / main.cpp / test_greet.cpp / std.cppm / std.compat.cppm);`build.mcpp` 只在 `data.watch` | +| mcppls 不认识 `build.mcpp` | 全仓 grep 只命中 `target/.build-mcpp/deps/...`(依赖的构建程序产物) | +| 交给 clangd 后 std 与 mcpp 都报错 | `mcppls check build.mcpp` → `module 'std' not found` + `module 'mcpp' not found` + `Failed to build module mcpp`,clangd exit 3 | +| 数据库内的文件零诊断 | `mcppls check src/main.cpp` → clangd exit 0 | +| 构建脚本 API 可机读 | `modules/buildmcpp/src/directives.cppm:297`(31 行表)、`:1132-1134`(5 role)、`program_protocol.cppm:121,147`、`provisions.cppm:80` | +| 缓存数据可机读 | `mcpp cache list --format json` → kind `mcpp.cache`,657 条 / 7.20 GiB / pkg 576 · std 81 / 2 incomplete | +| `--stale` 语义与默认 | `mcpp clean --help`:`--older-than … (default 1d; 0 keeps none; implies --stale)`;`--dry-run` 隐含 `--stale` 且不删 | +| `search` / `info` / `verify` 无机器格式 | 各自 `--help` 只有人类选项;`search` 只有 `--all-versions` | + +## 附录 C:配置项 ↔ 功能 对照(抽查) + +| 功能 | 可配置项 | +|---|---| +| 构建 | `mcpp.task.buildArgs`、`revealTerminal`、`focusTerminal`、`clearTerminal`、`problemMatcher` | +| 清理 | `mcpp.task.cleanArgs`、`confirmClean`、`mcpp.cache.staleDays`、`pruneAgeDays`、`gc.defaultBudgetGiB`、`gc.confirmAboveGiB` | +| 语言服务 | `mcpp.languageService.refreshAfterBuild`、`menuItems`、`notifyOnDegraded` | +| mcppls 状态与管理(§3.9) | `mcpp.views.languageServer.show`、`mcpp.languageService.readState`、`stateRefreshSeconds`、`confirmResetCache`、`mcpp.ui.statusBar.showLanguageServer` | +| `mcpp.toml` | `mcpp.toml.*`(11 项) | +| `build.mcpp` | `mcpp.buildScript.*`(5 项) | +| 视图 | `mcpp.views.*`(5 项) | +| 通知与视觉 | `mcpp.ui.*`(7 项) | +| 排障 | `mcpp.log.level`、`mcpp.diagnostics.selfCheckOnStartup`、`mcpp.runtime.*` | + +## 附录 D:本版**不实现**、仅文档备注的项 + +1. **`build.mcpp` 模式 B(交给 C++ 语言服务)**:实测会让 `import std` 与 `import mcpp` 都报错 + (附录 B),因此不发布开关。文档备注保留这条路径,等上游把构建程序写进 + `mcpp emit build-database`(诉求 **U.1**)之后再启用 —— 那条改动只在 mcpp 一侧, + 本插件无需改动即可受益。 +2. **mcppls 缓存目录的体积与占比**:拿不到(§3.9.3),等诉求 **U.5**。 +3. **未受信任工作区下的 mcpp 只读命令白名单**:当前是一律禁用;将来若 mcpp 的 + `--protocol-version` 能证明某命令零副作用,再考虑放开。 +4. **`mcpp.toml` 格式化**:不做(会改用户文件,且不是本扩展的职责)。 +5. **遥测**:不做。本扩展不收集任何遥测数据。 diff --git a/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md b/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md new file mode 100644 index 0000000..fe4ecbb --- /dev/null +++ b/.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md @@ -0,0 +1,1466 @@ +# UI/UX 优化方案 v2(待 review) + +v1 的四个问题已收到答复,本版按答复重做设计,并补上你新提的三件事:**图标用 mcpp 官方的**、 +**梳理侧边栏**、**侧边栏整体可开关(关掉时左边不出现 mcpp)**。 + +--- + +## 0. 你的答复与我的理解 + +| 问题 | 你的答复 | 确认后的设计 | +|---|---|---| +| Q1 缓存视图形态 | **webview view** | `mcpp.cache` 从原生树改为**侧边栏 WebviewView**,统计与图形都在侧边栏内 | +| Q2 `mcpp: Cache Statistics` | **删除** | 删掉该命令与 `createWebviewPanel` 那条路径 | +| Q3 工程视图的动作 | "什么意思 按理是要能点击有动作的" | **我原来说得不清楚,见 §2。结论:动作保留、可点,只是与数据分区** | +| Q4 图标 | "不能用 mcpp 的 icon 吗,mcppls 里也有 logo" | **可以,而且必须**——现在两个扩展的 logo **不是同一张图**,见 §4 | + +--- + +## 1. 新查明的事实(都有出处) + +| # | 事实 | 出处 | +|---|---|---| +| F1 | mcpp 官方 logo 在 mcppls 仓库里,而且**就是 mcppls 扩展的图标**(两文件字节完全相同,md5 `f9457903…`) | `mcpp-language-server/docs/imgs/mcpp-logo.png` 与 `editors/vscode/icon.png` | +| F2 | **mcpp-vscode 用的是另一张图**(200×200,md5 `2d723543…`),与 mcppls 不一致 | `mcpp-vscode/images/logo.png` | +| F3 | mcppls 的 `mcpp-logo.svg` **不是真矢量**:excalidraw 导出,内部 `href` 是 base64 PNG。**上游没有可用的单色矢量图标** | `docs/imgs/mcpp-logo.svg` 开头即 `` | +| F4 | mcppls **没有贡献 Activity Bar 容器**(`viewsContainers: null`),所以左侧那个 mcpp 图标只可能来自 mcpp-vscode,不存在冲突 | mcppls `package.json` | +| F5 | **"所有视图都隐藏 → 容器从 Activity Bar 消失"是可行的**,但 `when` 必须写成**否定形式**(`!mcpp.sidebarHidden`),否则默认就被隐藏,`activate()` 不会被调用,也就永远没机会把开关设回来 | [VS Code issue #49145](https://github.com/microsoft/vscode/issues/49145)(closed/verified)+ [#48704](https://github.com/microsoft/vscode/issues/48704)(`viewsContainers` 本身不支持 `when`) | +| F6 | 现有开关只有三个**分视图**开关,**缺总开关** | `data/config-registry.json`: `mcpp.views.{project,cache,languageServer}.show` | + +**F5 是本方案的关键**,因为它决定了"关掉侧边栏"到底能不能做到——能,但写法有讲究,不能想当然。 + +--- + +## 2. 回答 Q3:动作不会消失,只是不再和数字混在一起 + +我上一版问的是"是否完全不出现动作",措辞不好,导致看起来像要**拿掉可点击性**。不是这个意思。 + +真实问题不是"有动作",而是**动作和数据是同一层的同级行**,视觉上没有分界: + +``` +现在(工程视图) 改后 +├ 包名 greeter ├ 包名 greeter +├ 标准 c++23 ├ 标准 c++23 +├ Build ← 动作 ├ 目标 x86_64… +├ Run ← 动作 ├ 工具链 llvm@22.1.8 +├ Test ← 动作 └─ 操作 ← 可折叠分组 +├ Test ← 动作 ├ 构建 (tools) +└ Clean project artifacts ← 动作 ├ 运行 (play) + ├ 测试 (beaker) + └ 清理产物 (trash) +``` + +**修订后的总原则(比 v1 更简单)**: + +> **数据区只读;操作区可点;两区之间有明确分界,且操作区默认折叠收起。** + +- 操作**依然是树/面板里可点的行**,点一下即执行——满足你说的"要能点击有动作"。 +- 视图标题栏只放**视图级**动作(刷新、溢出菜单),不再重复放业务动作。 +- 好处:打开视图先看到"是什么状态",要看动作展开一次即可;不需要在两类语义之间反复切换注意力。 + +--- + +## 3. 侧边栏整体梳理 + +### 3.1 结构(容器 `mcpp`,三个视图,顺序固定) + +``` +mcpp +├─ 工程 mcpp.project 树 "这是什么工程" +├─ 缓存 mcpp.cache WebviewView "占了多少、谁占的" +└─ C++ Modules mcpp.languageServer 树 "语言服务什么状态、哪里不对" +``` + +**为什么还是三个而不是合并成两个**:三者的**回答对象不同**,而且 C++ Modules 的内容由另一个扩展提供(`sunrisepeak.mcpp-language-server`)。合并会让"谁负责什么"重新变模糊——这正是上一轮花力气划清的边界。三个视图的取舍权交给用户(§3.3 的开关)。 + +### 3.2 每个视图的内容分区 + +**工程(树)** +``` +身份:包名 / 标准 / 类型 +目标:triple / profile / 编译器 +工具链:当前有效 spec(只读,改它用命令) +操作(折叠):构建 / 运行 / 测试 / 清理 target +``` + +**缓存(WebviewView,三区 + 一个操作条)** +``` +┌ 缓存 ────────────────────────── [↻] [⋯] ┐ +│ 7.20 GiB · 657 项 · 最早 12 天前 │ 区1 数字摘要(只读) +│ ████████████░░░░░░░░ 项目 12% │ 区2 可视化:构成 + 时间分布 +│ ██████████░░░░░░░░░░ std 31% │ 按 200–320px 窄宽度设计 +│ ▸ 最大占用(前 5) ▸ 按类型 ▸ 按时间 │ 区3 明细:只做「查看」钻取 +│ ──────────────────────────────────────── │ 分隔(视觉 + 语义) +│ 操作 清理过期 · 清理工程 · 收敛 · 校验 │ 操作条(与数据区分离) +└──────────────────────────────────────────┘ +``` +- 现在树里的 11 个动作节点全部收敛到这一条。 +- **`mcpp.showCachePanel` 删除**(Q2),`createWebviewPanel` 路径一并删掉,`cachePanelHtml.ts` 的渲染逻辑改为喂给 WebviewView。 + +**C++ Modules(树)** +``` +状态:就绪 / 降级 / 缺失(图标 + 一句话) + C++ Modules 0.0.9(外部扩展,只读) +问题:有问题才出现(引擎未就绪 / 某能力不可用 / 依赖缺失) +操作(折叠):重启 / 重选上下文 / 模块图 / 日志 / 上报 / 诊断包 / 重置缓存 / 身份与冲突 +``` +11 个动作节点从平铺变为一个折叠分组,状态与问题在它上面。 + +### 3.3 开关:一个总闸 + 三个分闸 + +| 设置 | 默认 | 作用 | +|---|---|---| +| `mcpp.views.enabled`(**新增**) | `true` | 总闸。关掉 → **Activity Bar 上的 mcpp 图标整体消失** | +| `mcpp.views.project.show` | `true` | 单个视图开关(已有) | +| `mcpp.views.cache.show` | `true` | 同上 | +| `mcpp.views.languageServer.show` | `true` | 同上 | + +**实现(关键,照 F5 的写法)**: + +```jsonc +// package.json —— when 必须是否定形式,默认才可见 +"views": { "mcpp": [ + { "id": "mcpp.project", "when": "!mcpp.sidebarHidden && mcpp.views.project" }, + { "id": "mcpp.cache", "when": "!mcpp.sidebarHidden && mcpp.views.cache" }, + { "id": "mcpp.languageServer", "when": "!mcpp.sidebarHidden && mcpp.views.languageServer" } +]} +``` +```ts +// extension.ts:设置变化时同步 +void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !read("mcpp.views.enabled")); +``` + +**必须验证的一点**(写进 e2e):`activate()` 的激活事件里**不能只依赖视图**,否则一旦隐藏就再也激活不了。当前激活事件是 `workspaceContains:mcpp.toml` / `onLanguage:*` / 派生的 `onCommand:*`,不依赖视图,所以安全——但这条要作为约束记下来并测试。 + +--- + +## 4. 图标:统一到 mcpp 官方标识(回答 Q4) + +**现状问题**:F1/F2 —— mcppls 用官方的 mcpp logo,mcpp-vscode 用另一张图。同一家族两个扩展并排出现在扩展列表里,看起来像两个不相干的项目。 + +**改法分两处,注意它们的要求不同**: + +| 用途 | 现在 | 改成 | 理由 | +|---|---|---|---| +| 扩展图标(Marketplace / 扩展列表) | `images/logo.png`(200×200,与官方不同) | **官方 `mcpp-logo.png`**(396×396,与 mcppls 同一张) | 家族一致;这是你在扩展列表里看到的图 | +| Activity Bar 容器图标 | 同一张 200×200 彩色 PNG | **单色 24×24 SVG** | Activity Bar 惯例是单色、跟随主题;彩色 PNG 缩到 24px 会糊成暗块,这正是你觉得"没有图标"的原因 | + +**一个必须先说清楚的限制**:F3 —— **上游没有可用的单色矢量 logo**(那个 `.svg` 里包的是 PNG)。所以 Activity Bar 的单色图标**必须新画**,方式二选一: + +- **方案 A(推荐)**:我按官方 logo 的形态**重绘一个极简单色 SVG**(几何化的 mcpp 标记,`fill="currentColor"`),先出 2–3 个候选给你挑。 +- **方案 B**:直接用官方彩色 PNG 作为容器图标(能显示、不改主题、24px 下偏糊)。 + +> 我不建议"自动描摹"官方 PNG——那会得到一个杂乱的路径,比手绘的几何标记更糟。 + +另外,`views` 条目本身也支持 `icon`(视图标题旁的小图标),目前我们一个都没设。**建议不加**:三个视图名称已经足够清楚,再加图标只会让标题栏更挤。 + +--- + +## 5. 任务拆分与依赖 + +``` +T-UI-1 图标统一 依赖:你选 A / B + ├─ 扩展图标换成官方 mcpp-logo.png(与 mcppls 对齐) + └─ 容器图标:单色 SVG(方案 A 需你从候选中挑一个) + +T-UI-2 快捷菜单:ThemeIcon + 分组分隔 + 描述改成"动作后果" 无依赖,可立刻做,风险最低 +T-UI-3 侧边栏梳理:顺序、操作区折叠、"操作"分组 无依赖 +T-UI-4 C++ Modules 重组:状态 / 问题 / 操作(折叠) 依赖 T-UI-3 的分组约定 +T-UI-5 缓存:树 → 侧边栏 WebviewView 依赖 Q1=webview view(已确认) + └─ 删除 mcpp.showCachePanel 与 createWebviewPanel 路径 +T-UI-6 总闸 mcpp.views.enabled + setContext(!mcpp.sidebarHidden) 依赖 T-UI-3 +T-UI-7 一致性守卫(关键) 依赖 T-UI-3~6 + ├─ 断言:视图正文的数据区里不存在命令节点 + ├─ 断言:每个动作的"主入口"在登记表中唯一 + └─ e2e:三个视图全部 when=false 后容器消失;且 activate 仍能被 workspaceContains 触发 +T-UI-8 文档与截图 依赖全部 +``` + +- **T-UI-2 可以现在就做**,最直接回应你的图标反馈。 +- **T-UI-3/4 是纯重排**,不动数据层,可以并行。 +- **T-UI-5 最大**:`createWebviewPanel` → `registerWebviewViewProvider`,注意 WebviewView **不支持 `retainContextWhenHidden`**,切换视图会销毁重建,所以渲染必须是幂等的(现有 `renderPanelHtml(model)` 天然满足)。 +- **T-UI-7 是本方案能不能站住的关键**:把"数据/操作分区"和"动作主入口唯一"变成**测试断言**,否则下一轮很容易又混回去。这与上一轮"64 个设置必须被读取"用的是同一个手法。 +- **T-UI-6 有一个必须测的边界**:隐藏后 `activate()` 还能不能被触发(F5 的陷阱)。 + +--- + +## 6. 需要你定的 3 件事 + +1. **Activity Bar 图标**:走方案 A(我画 2–3 个单色候选给你挑)还是方案 B(直接用彩色 PNG)? +2. **扩展图标是否统一为官方 mcpp logo**(与 mcppls 相同)?我建议是——这正是你问的"不能用 mcpp 的 icon 吗"。 +3. **总闸设置名**:`mcpp.views.enabled` 还是 `mcpp.sidebar.enabled`?(前者与既有的 `mcpp.views.*` 命名一致,我倾向它。) + +**T-UI-2 我建议不等,直接先做**;T-UI-3/4 只要"数据区只读、操作区折叠"这条原则你认可,也可以并行开工。 + +--- + +## 7. 可交互原型(已生成,本地打开) + +**`.agents/prototypes/ui-prototype.html`** —— 单文件、自包含(内嵌 VS Code 真实的 codicon 字体与官方 logo), +双击或用浏览器打开即可。**控制台里可以切:改前 / 改后、三个视图、三种主题、三个图标候选、侧边栏开关**; +拖侧边栏右缘能改宽度,用来验证窄宽度下的表现。 + +原型里的配色不是我自己编的,取自本机 VS Code 1.125.1 的默认主题文件 +(`theme-defaults/themes/2026-dark.json`、`2026-light.json`、`hc_black.json`,含 include 链)。 + +### 图标候选 A 是"真货" + +`mcpp-mark-traced.svg` 是**从官方 `mcpp-logo.png` 自动描摹出来的单色 SVG**(44×44 网格、148 条 +水平游程、4.9 KB),不是手绘近似。做法:解码 PNG → 按亮度分离前景 → 覆盖率采样 → 输出 +`fill="currentColor"` 的路径。所以它能跟随主题变色,可以直接当 Activity Bar 图标用。 +代价是边缘是阶梯状(44 格量化),要更干净需要人工重绘成曲线。 + +另外两个候选用于对比方向:B 是手绘的"模块方块",C 就是现在那张 200×200 彩色 PNG。 + +### 想请你在原型里重点看 + +1. **缓存**:改前是树(11 个可点动作和统计混在一起)→ 改后是侧边栏内的三区布局(摘要 / 图形 / 明细 + 独立操作条)。 +2. **C++ Modules**:改前 11 个动作平铺 → 改后只剩状态与问题,动作收进折叠的「操作」。 +3. **工程**:改前 Build/Run/Test/Clean 与"标准/目标/工具链"同级 → 改后数据在上、操作在下且折叠。 +4. **快捷菜单**:改前无图标、描述是重复的分组名 → 改后有图标、有分组分隔、描述改成"动作后果"。 +5. **Activity Bar 图标**:A / B / C 三个候选直接切换对比。 +6. **侧边栏总闸**:切到"关",确认左侧 mcpp 图标消失、其余图标不动。 + +--- + +## 8. v3:按你的第二轮反馈改(原型已同步重建) + +### 8.1 缓存:分开项目 / 全局,全局默认折叠 + +- **项目缓存**常显:26px 主数字 + `MiB` 单位 + 右侧一行副信息(文件数 / 构建目录),下面一条 6px 细条, + 再一行"其中过期产物约 4.1 MiB",最后两个按钮。 +- **全局缓存**用原生 `
` **默认折叠**,摘要压在一行里(`7.20 GiB · 657 项`);展开后才是构成条、 + 时间条、"最大占用"(再嵌一层折叠)和三个次级按钮。 +- "更优雅"的具体手段(不是形容词):整块只有**一条主条**(6px,不是三条 14px);图例**压成一行**用 `·` 分隔 + 而不是每项一行;数字用 `tabular-nums` 对齐;字号分层(26 / 12 / 11);去掉多余色块边框,靠留白分节。 + +### 8.2 C++ Modules:不显示 clangd + +正文只剩 **状态 → 语言服务 → 问题 → 操作**。`clangd` / 引擎 / 语义 profile 全部移除, +只留 `语言服务 mcppls 0.0.9`。操作从 11 行压成 5 行,其余收进「其它 7 项…」。 +**已用渲染产物断言**:改后 HTML 里 `clangd` 出现 **0** 次。 + +### 8.3 图标:用 mcppls 的官方资源(实测配色,有个坑) + +实测官方 `mcpp-logo.png` 的像素构成:**纯黑 75.1%** · 橙 `#f08c00` 6.8% · 蓝 `#1971c2` 6.1% · 透明 10.4%。 + +**坑**:深色主题的 Activity Bar 背景是 `#191A1B`,**75% 的黑墨迹在它上面几乎不可见**——直接用原图, +看起来就是"没有图标",正是你最初的抱怨。浅色主题(`#F8F8F8`)则正常。 + +原型的三个候选(都不再手绘,上一版那张描摹已删): + +| 候选 | 做法 | 代价 | +|---|---|---| +| A | 官方 PNG 原样 | 深色主题下黑墨迹消失 | +| B | 官方 PNG 放在浅色圆角底上 | 保留全部色彩;Activity Bar 里多一个浅色小方块 | +| C | 官方图形里的**黑色部分机械换成 `currentColor`**(橙蓝丢弃) | 单色、随主题变色;不是手绘,但仍是一次派生 | + +### 8.4 库市场:可行性(已在 mcpp 源码里核对) + +| 能力 | 现状 | +|---|---| +| 写入 `mcpp.toml` | ✅ `mcpp add [--dev]`,官方命令就是"Add a dependency to mcpp.toml" | +| 搜索包 | ⚠️ `mcpp search [--all-versions]` — **没有 `--format json`** | +| 包详情 | ⚠️ `mcpp info ` 只对**已缓存**的包,且无 JSON | +| 包列表 | ❌ `mcpp list` 的三个都是"工具链 / 缓存条目 / 已配置 registry" | +| 索引数据 | `mcpp-index` 仓库:238 个 Lua 描述符,含 name/namespace/description/licenses/repo/deps/多平台版本 | +| 对比 | `build` `pack` `why` `build-database` `parse` `cache list` `env` 等约 10 个子命令**都有 `--format json`** | + +三条路:**(a)** 请上游加 `mcpp search --format json`(与既有 10 个命令一致,改动小)← 推荐; +**(b)** 解析人类输出(脆弱,违反本项目"只有一处解析人类输出"的原则); +**(c)** 直接读 index 仓库的 Lua(重复 mcpp 的职责,还要自己找索引路径)。 + +推荐 (a) + 优雅降级:没有机器输出时,界面显示"需要更新的 mcpp"而不是坏掉——能力探测机制已经在了。 + +形态上我倾向**轻的那条**:`mcpp: 添加依赖…` 一个 quick pick(关键词 → 列表 → `mcpp add`), +放在工程段「常用命令」里。理由是写 `mcpp.toml` 本来就是"工程"的事,也不给侧边栏再加一段噪音。 + +--- + +## 9. v4:三段结构 + 库生态(原型已同步重建) + +### 9.1 图标:采纳官方 PNG 原样 + +你确认 2026 Dark 下黑底与 Activity Bar 有可见差别。实测支持这个判断:logo 黑是 `#000000`, +深色主题 Activity Bar 是 `#191A1B`——不是纯黑对纯黑,所以能看出一块更深的底加亮色图形。 +**采纳候选 A**:扩展图标与 Activity Bar 容器图标都用官方 `mcpp-logo.png`, +两个扩展从此是同一张图(当前 mcpp-vscode 用的是另一张,md5 不同)。B/C 仅留作备查。 + +### 9.2 侧边栏改为三段:工程 / mcpp 库生态 / 缓存 + +`mcpp.languageServer` 视图取消,内容折进**工程 → 基本信息**,默认折叠。 + +**前提(否则这个折叠是有害的)**:折叠状态下必须能看出好坏。所以折叠那一行自带状态图标 +(健康绿 / 降级黄),降级时右侧显示 `mcppls 0.0.9 · 1 个问题`。**不展开也能发现异常**是 +这一条能被接受的条件;否则问题会被藏起来,比不放更糟。 + +**一个取舍**:C++ Modules 的内容由**另一个扩展**提供。折进工程会让"谁负责什么"这条边界重新 +变模糊。缓解:展开后的块里固定一行「提供方 sunrisepeak.mcpp-language-server」。 + +### 9.3 库生态:用 mcpp-index + `mcpp xpkg`,不动上游(已实测) + +| 环节 | 实测结果 | +|---|---| +| 描述符解析 | **`mcpp xpkg parse --json` 存在且好用**:`{"namespace":"compat","name":"argparse","versions":{"linux":["3.2"],"macosx":["3.2"],"windows":["3.2"]},"standard":"c++23","sources":[…],"include_dirs":[…],"targets":["argparse"],"unknown_keys":[]}` | +| 索引位置 | `mcpp index status` 给出表格,含每个 registry 的 `path`;**且 `~/.mcpp/registry/data/*/pkgs` 可直接 glob**,无需解析输出 | +| 目录规模 | `mcpplibs`(官方 C++ 库索引)**239 个包**;另有 `xim-pkgindex`、`xim-pkgindex-local` | +| 目录字段 | 每个 `.lua` 含 `namespace` / `name` / `description` / `licenses` / `repo` | +| 添加依赖 | `mcpp add @ [--dev]`,官方命令直接写 `mcpp.toml` | + +**关键限制**:`mcpp xpkg parse --json` **不含** `description` / `licenses` / `repo` +——那些属于 `package` 表的目录字段,不在 resolver 语法里。所以: + +- **列表**(239 条,必须便宜):容错读取每个 `.lua` 里的几个**单行字符串字段**。 +- **详情与版本**:按需调 `mcpp xpkg parse --json`(版本按平台分组,权威)。 +- **绝不为 239 个包各起一个进程**——那会卡死;批量阶段只读文本,进程只在点开某一行时起。 +- 读不出来的条目用文件名兜底,详情仍可用。 + +**索引路径**:默认 glob `/.mcpp/registry/data/*/pkgs`,配 `mcpp.library.indexPath` 覆盖。 +`XLINGS_HOME` 不在环境变量里,且 `mcpp index status --format json` 是 unknown option, +所以走 glob 而不是解析 `mcpp index status` 的表格——避免出现第二处"解析人类输出"。 + +### 9.4 还没定的(下一轮要你拍板) + +1. **"已添加"怎么判定**:mcpp 没有列依赖的子命令(`mcpp list` 三个分别是工具链 / 缓存条目 / + 已配置 registry)。所以只能读 `mcpp.toml` 的 `[dependencies]`——我们本来就在解析它。 +2. **要不要区分"已声明"与"已缓存"**?两者不同:声明在 `mcpp.toml`,缓存在全局缓存目录。 +3. **搜索范围**:只搜本地索引(离线、快、可预期),还是也允许 `mcpp search`(覆盖其它 registry、 + 可能联网)? +4. **版本怎么选**:默认最新 / 让用户挑 / 跟随工具链兼容性? + +--- + +## 10. v5 定稿:按你的四条决定收口 + +### 10.1 图标 +采纳官方 `mcpp-logo.png` 原样,扩展图标与容器图标同一张(B/C 撤销)。 + +### 10.2 库生态 + +**搜索分两档,开关默认关闭联网:** + +| 档 | 数据源 | 默认 | 成本 | +|---|---|---|---| +| 本地索引搜索 | `~/.mcpp/registry/data/*/pkgs` 的 239 个描述符 | **开** | 离线、一次扫完、可预期 | +| 全 registry 搜索 | `mcpp search ` | **关**(`mcpp.library.searchRegistries`) | 可能联网;且只有人类输出 | + +> 联网那一档只有人类输出,所以打开时按"尽力而为"处理:解析失败就退回本地结果并提示, +> **不抛错、不静默失败**。这条要说在设置描述里。 + +**版本默认最新——但 mcpp 要求显式版本,所以"最新"必须由我们算出来:** + +实测 `mcpp add compat.argparse`(不带版本)会被拒: +``` +error: package version required: `mcpp add compat.argparse@` (M2 supports exact-version only) +``` +所以流程是: +1. `mcpp xpkg parse --json` → `versions: {linux:[…], macosx:[…], windows:[…]}` +2. 取**当前平台**那组,按版本序取最大 → `3.2` +3. `mcpp add compat.argparse@3.2`(dev 依赖加 `--dev`) + +**语义澄清**:`mcpp add ` 与 `mcpp add @` 是两件事,前者会被拒。 +界面上显示"最新 3.2",实际执行的是带版本的命令——不要让人以为我们省略了版本。 + +### 10.3 依赖树:**只能做两级,且必须说清楚** + +我实测了三条数据源,结论如下: + +| 来源 | 能给什么 | 机器可读? | +|---|---|---| +| `mcpp.toml` | **声明的**依赖(`[dependencies]` / `[dev-dependencies]`,含 version/path/git/features) | ✅ 文本,我们已在解析 | +| `mcpp.lock` | **解析到的**包集合:`[package."ns.name"]` + `namespace`/`version`/`source`/`hash`,**扁平、无父子边** | ✅ 结构化 TOML | +| `mcpp why deps` | 传递依赖的解释 | ❌ **实测被拒**:`error: --format json is defined for 'mcpp why toolchain'; 'deps' has no machine-readable shape yet` | + +所以**今天做不出真正的传递依赖树**。方案改为诚实的**两级展示**: + +``` +依赖 (4) +├ mcpplibs.cmdline 最新 0.3.1 · 已解析 0.0.1 ← 声明 → lock 解析值 +├ compat.argparse 最新 3.2 · 已解析 — ← 声明了但还没 lock +├ compat.gtest dev 最新 1.15.2 · 已解析 — +└ counters path ../counters +``` + +- 第一级 = `mcpp.toml` 的声明(含 `dev` / `path` / `git` 标记)。 +- 第二级 = `mcpp.lock` 里按名字匹配到的解析版本;没有就是"—"(未解析或未构建过)。 +- **不画连线**,因为 lock 里没有父子关系,画了就是编的。 +- 真正的传递树挂到上游请求:**U.7 `mcpp why deps --format json`**(`why toolchain` 已经有了, + 补齐 `deps` 是同一类改动)。等它落地再把两级升级成树。 + +### 10.4 顺带修正一处此前的说法 + +v3 里我写"`mcpp why ` 有 `--format json`,可以解释谁把它拉进来的"——**这是错的**。 +实测只有 `why toolchain` 有 JSON,`why deps/sources/tool` 都没有。已在此更正。 + +--- + +## 11. 自我 review(对方案本身,不是对代码) + +### 11.1 我实测过、有证据的(可以当事实用) + +| 结论 | 证据 | +|---|---| +| `mcpp xpkg parse --json` 可用 | 实跑 `compat.argparse.lua`,返回含 `versions` 的 JSON | +| 官方 C++ 库索引 239 个包 | `find ~/.mcpp/registry/data/mcpplibs/pkgs -name '*.lua' \| wc -l` = 239 | +| 索引路径可 glob,无需解析输出 | `~/.mcpp/registry/data/*/pkgs` 三个 registry 都在 | +| `mcpp index status --format json` 不存在 | 实跑 → `error: unknown option: --format` | +| `mcpp add` 必须带精确版本 | 实跑 → `error: package version required` | +| `mcpp why --format json` 只对 toolchain | 实跑 → `'deps' has no machine-readable shape yet` | +| `mcpp.lock` 是扁平的解析集合 | 读 mcpp 仓库真实 lock:`[package."ns.name"]` + version/source/hash,无父子边 | +| 官方 logo 75.1% 纯黑 | 解码像素统计 | +| `mcpp why --format json` 走标准信封 | 顶层键 `data/diagnostics/effects/kind/kindVersion/mcpp/schemaVersion` | + +### 11.2 我没验证、属于推断的(**别当结论**) + +1. **库视图的性能**:239 个文件读一遍到底多快,我没有实测。若首次渲染 > 300ms, + 需要加缓存或懒加载——这一点等实现时先量再定。 +2. **描述符的字段容错**:我抽样读了 4 个 `.lua`,确认 `namespace/name/description` 是单行 + 字符串。**没有验证全部 239 个是否都规范**——可能有换行、引号转义、或缺失。设计里已要求 + 用文件名兜底,但没有实测兜底覆盖率。 +3. **`mcpp add` 是否联网**:加索引包大概要拉索引/校验,但我没测它的耗时与失败表现。 + 界面上"添加"必须给进度与失败路径,这一点我写进了方案但没验证。 +4. **WebviewView 在 200px 窄宽度下的真实表现**:原型是 HTML 模拟,不是真实 VS Code 窗口。 +5. **三个视图全关后容器是否真的消失**:依据是 VS Code issue #49145(closed/verified)和一篇 + 第三方文章,**我没有在真实 VS Code 里验证过**。这是整个"侧边栏可关闭"的地基,落地时 + 必须先写一个 e2e 把它钉死,再写别的。 +6. **C++ Modules 折叠的观感**:折叠后能否"不展开就看出异常",是我认为的关键,但只能靠你看原型判断。 + +### 11.3 方案自身的风险 + +| 风险 | 说明 | 对策 | +|---|---|---| +| 第二处"解析人类输出" | 联网搜索档要解析 `mcpp search` | 默认关闭 + 失败即退回本地;已登记 U.8 `mcpp search --format json` | +| 读 239 个 Lua 是否算"重造 mcpp 的职责" | 有争议 | 限度写死:只读 4 个单行目录字段;**任何权威值走 `mcpp xpkg parse`** | +| 索引路径硬编码 `~/.mcpp/registry` | 非默认安装会找不到 | `mcpp.library.indexPath` 可覆盖;找不到时给明确提示而不是空列表 | +| 三段结构让 C++ Modules 的归属变模糊 | 上一轮刚划清的边界 | 展开块固定一行「提供方」;折叠行显示 mcppls 版本 | +| 工程段因为加了「常用命令」反而变长 | 与"简洁"目标相悖 | 你已确认可接受;若要更短可把常用命令收成折叠 | + +### 11.4 我在前几版里说错、已更正的 + +1. v1 把 Q3 问成"是否完全不出现动作"——**措辞导致误解**,你回"按理是要能点击有动作的"。 + 实际问题是"动作与数据同级混排",不是"要不要动作"。 +2. v3 说 `mcpp why ` 有 JSON 可解释依赖来源——**错**,只有 `why toolchain` 有。 +3. v3 提议"我画一个单色 SVG"——你没要,实际做法是从官方图机械派生(后来连这个也撤销了, + 直接用官方原图)。 +4. v2 说容器图标"看起来像没有图标"是因为 PNG 糊——**部分对**:真实原因是当时那张与官方 + 不是同一张图,且黑墨迹在深色主题下对比度低。 + +--- + +## 12. v6 定稿:详情页 / 示例代码 / 索引站的定位 + +### 12.1 包详情放在编辑器区(已定) + +侧边栏 = 搜索 + 列表;编辑器 = 详情页。列表行必须自足(名称 / 一行描述 / 用法标签 / 版本 / 状态), +不强迫开详情页。 + +### 12.2 "要索引站干什么"——定位说清楚 + +**不是为了拿数据。** 数据全在本地(见 §13.2)。索引站只有三个作用: + +1. **深链**:实测包页 URL 是 `https://mcpplibs.github.io/mcpp-index/packages/./` + (站点根页面里就能看到 `packages/compat.argparse/`、`packages/mcpplibs.cmdline/`)。 + 详情页上放一个**低调的**「在索引站打开」链接即可。 +2. **词表来源**(真正的价值):`SURFACES` + 徽标 + facet 的权威定义就在 + `.xpkgindex/plugins/mcpp.py` 里。用它,两处说同一套词。 +3. **信息层级对齐**的参照:分组顺序、徽标位置、字段取舍。 + +结论:**索引站是一个链接和一个词表,不是数据源。** 没有它这个功能照样成立。 + +### 12.3 示例代码代替 README(已定) + +README 不做(索引里没有,要联网去上游拉)。 + +改为**官方索引的示例代码**,数据源就在索引仓库里、**完全离线**: + +| 事实 | 实测 | +|---|---| +| 位置 | `tests/examples//mcpp.toml` + `tests/examples//tests/*.cpp` | +| 规模 | **172 个示例工程** | +| 契约 | 插件 `_scan_examples` 的产物:包 → `{project, path, paths[], count}` | +| 价值 | 这些是 **CI 真实构建并运行过**的代码,不是为网站写的片段(插件注释原话) | +| 抽取 | 从 `tests/*.cpp` 里取 `import x.y;` / `#include ` 行——即 `SURFACES` 判定所依赖的同一批行 | + +### 12.4 openkal 标识保留(已定) + +`openkal-ecosystem` / `openkal-compat` 作为 facet,`posix 环境` / `使用平台接口` 作为徽标。 +注意插件里的原话:这两个是**实测结果**(`tests/openkal/compat.py` 记录在 +`.xpkgindex/openkal-compat.json`),描述符里**没有**对应字段。所以我们要读那个 json,不能推导。 + +### 12.5 标签词表最终确定 + +| 层 | 取值 | 来源 | +|---|---|---| +| **用法标签(主)** | `import` / `#include` / `tool` / 上游 mcpp.toml | `SURFACES`,由 `xpkg parse` 字段推导 | +| 徽标 | `✓ 有示例` / `国内镜像` / `openkal-ecosystem` / `openkal-compat` / `posix 环境` / `使用平台接口` | 示例扫描 + 描述符 url 里的 `CN` + `.xpkgindex/openkal-compat.json` | +| 命名空间 | `mcpplibs` / `compat` / `llvm` / `khronos` / `freedesktop` … | 描述符 `namespace` | +| 状态 | 已添加 / 可添加 / **有更新** | `mcpp.toml` + `mcpp.lock` vs 索引最新 | + +上一版的 A–I 形状**废弃**(那是"怎么构建",给索引维护者看;`SURFACES` 是"怎么使用",给读者看)。 + +--- + +## 13. 综合自我 review(v6,对整套方案) + +### 13.1 需求 → 落点对照(有没有漏的) + +| 你提的 | 落点 | 状态 | +|---|---|---| +| 缓存可视化更优雅 | §8.1 一条 6px 主条 + 单行图例 + tabular-nums + 字号分层 | ✅ | +| 项目缓存 / 全局缓存分开,全局默认折叠 | §8.1 `
` 默认折叠,摘要一行 | ✅ | +| 不显示 clangd,只显示 lsp mcppls | §8.2 正文只留 状态 → 语言服务 → 问题 → 操作 | ✅ | +| 用官方 PNG/SVG,不自己画 | §9.1 官方 `mcpp-logo.png` 同图 | ✅ | +| C++ Modules 折进工程·基本信息 | §9.2,**前提**:折叠行自带状态图标 | ✅ | +| 三段:工程 / mcpp 库生态 / 缓存 | §9.2 | ✅ | +| 工程段有基本信息 + 常用 mcpp 命令 | §9.2 两块分区 | ✅ | +| 库市场 | §10.2 + §12 | ✅ | +| 联网搜索开关,默认关 | §10.2 | ✅(v6 后只剩跨 registry 一个用途) | +| 默认最新版本 | §10.2 由 `xpkg parse` 算出再显式传入 | ✅ | +| 显示依赖树 | §10.3 **降级为两级、不画连线** | ⚠️ 受上游限制 | +| 标签参考 mcpp-index | §12.5 改用官方 `SURFACES` | ✅ | +| 包详情 | §12.1 编辑器区 | ✅ | +| README 换成官方索引的示例代码 | §12.3,172 个示例工程,离线 | ✅ | +| openkal 标识 | §12.4,读 `openkal-compat.json` | ✅ | +| 风格对齐官方网页 | 词表/层级/文案对齐;**配色只跟 VS Code 主题** | ⚠️ 有意不对齐配色 | + +### 13.2 数据源清单(每个字段从哪来、要不要联网、是不是权威) + +| 字段 | 来源 | 离线 | 权威性 | +|---|---|---|---| +| 包名 / 命名空间 | 描述符文件名 + `xpkg parse` | ✅ | 权威 | +| 描述 / 许可 / 仓库 | 描述符 `package.{description,licenses,repo}` | ✅ | 权威 | +| 版本(按平台) | `mcpp xpkg parse --json` → `versions` | ✅ | 权威 | +| 用法标签 | `xpkg parse` 字段组合 → `SURFACES` | ✅ | 推导 | +| 示例代码 | 索引仓库 `tests/examples//tests/*.cpp` | ✅ | **真实(CI 跑过)** | +| openkal facet | `.xpkgindex/openkal-compat.json` | ✅ | 实测记录 | +| 已声明依赖 | `mcpp.toml` | ✅ | 权威 | +| 已解析版本 | `mcpp.lock` | ✅ | 权威 | +| 有更新 | 索引最新 vs lock 解析 | ✅ | 推导 | +| 传递依赖树 | — | — | ❌ 上游没有机器可读形状 | +| README / star | GitHub API | ❌ | **不做** | + +**一个重要的简化**:v6 之后,**整个库视图零网络**。联网只剩"跨 registry 搜索"这一个用途, +所以那个开关的语义变清晰、风险也变小了。 + +### 13.3 我没验证的(别当结论) + +1. **"所有视图关闭 → 容器从 Activity Bar 消失"没有在真实 VS Code 里验证过**(依据是 issue #49145 + 已 closed/verified + 第三方文章)。这是"侧边栏可关闭"的地基,**落地第一步就要写 e2e 钉死**。 +2. 233 个描述符读一遍的耗时没实测;若 > 300ms 需要缓存。 +3. 全部 233 个描述符的字段规范性没验证(只抽样了 6 个)。 +4. `mcpp xpkg parse` 的输出我只看了 3 个包;`unknown_keys` 非空的包会怎样没试。 +5. WebviewView 在 200px 窄宽度的真实表现——原型是 HTML 模拟。 +6. `mcpp add` 是否联网、耗时多少、失败表现,没测。 +7. `.xpkgindex/openkal-compat.json` 的字段结构没细看(只确认它存在且是实测记录)。 + +### 13.4 风险 + +| 风险 | 对策 | +|---|---| +| 联网搜索档要解析 `mcpp search` 人类输出(项目原则是只有一处) | 默认关闭 + 失败退回本地;登记 U.8 | +| 读 Lua 描述符是否算"重造 mcpp 职责" | 限度写死:只读目录字段与示例关联;**任何权威值走 `mcpp xpkg parse`** | +| 索引路径硬编码 `~/.mcpp/registry` | `mcpp.library.indexPath` 可覆盖;找不到给明确提示而非空列表 | +| C++ Modules 归属变模糊 | 展开块固定「提供方」行 | +| 工程段因加常用命令变长 | 已确认可接受;需要更短时收成折叠 | +| 词表跟着上游变 | 词表**从插件推导而非硬编码**;上游改了词会跟着变(也可能破坏,需容错) | + +### 13.5 我在这个过程中说错、已更正的(累计 5 条) + +1. v1 把 Q3 问成"要不要动作"——措辞误导,真实问题是"动作与数据同级混排"。 +2. v2 说容器图标糊是因为 PNG——部分对;真实原因是用的是**另一张图**,加上黑墨迹在深色主题对比度低。 +3. v3 说 `mcpp why ` 有 JSON 可解释依赖来源——**错**,只有 `why toolchain` 有。 +4. v3 提议我手绘单色 SVG——你没要;后来改为从官方图机械派生,最终撤销,直接用官方原图。 +5. **v5 提的 A–I 形状标签是错的**——那是"怎么构建",官方站用的是"**怎么使用**"(`SURFACES`)。 + 你让我参考 `mcpp-index` 仓库,直接纠正了这一条。 + +--- + +## 14. round 2:一条被我自己推翻的假设(重要) + +**§13.3 第 1 条说"所有视图关闭 → 容器从 Activity Bar 消失"只是依据二手资料、没验证过。 +我在本机 VS Code 1.132 的源码里查了,结论是:不成立。** + +证据链: + +1. `paneCompositeBar.ts` 里,Activity Bar 的每一项由 + `showOrHideViewContainer(container)` 决定:`shouldBeHidden(container)` 为真才 `hideComposite`。 +2. `shouldBeHidden` 的第一段是**决定性**的: + ```ts + if (viewContainer) { + if (viewContainer.hideIfEmpty) { … } + else return false; // ← 没有这个标志就永远不隐藏 + } + ``` +3. `hideIfEmpty` 的注释是「If enabled, view container is not shown if it has no active views」, + 全仓库**只有一处**赋值:`registerGeneratedViewContainer()`(VS Code 自己给"用户自定义容器"用的), + 值为 `true`。 +4. 我在已安装的 `workbench.desktop.main.js`(1.132)里把 `hideIfEmpty` 的 **24 处**全部看了: + 每一处都是**内置容器**的注册(ports / test / voice / debug / 用户容器 …), + **没有任何一处从扩展清单读取**。 + +所以:**扩展无法把自己的容器标记为"空则隐藏"**。对第三方容器,把所有视图 `when` 设为 false 只会 +让**内容**消失,图标会留下(点开是空面板)。我原先依据的 VS Code issue #49145(closed/verified) +和那篇第三方文章,对 1.132 不再适用。 + +### 已做的修正 + +- `mcpp.views.enabled` 的标题与描述改成实话:**「隐藏 mcpp 视图内容」**,并说明图标是否移除由 + VS Code 决定、要移除可在图标上右键。中英同步,注册表与 nls 一致(`check-config` 通过)。 +- `when` 门控保留——**内容隐藏这件事是确定的**(走的是 `activeViewDescriptors.length === 0` 那条路径)。 +- 教训记在这里:**二手资料必须用一手源码或真实运行验证**。这条假设是整套里风险最高的地基, + 幸好先查了。 + +### 另外两处本轮修掉的 + +- `mcpp.internal.markIndex` 之前注册了却没人调用,`mcpp.library.updateIndex` 装好索引后 + `viewsWelcome` 不会消失。改成 `markIndexFound()`,在激活时与 `index update` 之后都重算。 + **(§15.4 更正:这条整段作废——webview 视图根本不支持 `viewsWelcome`,`markIndexFound` + 已于 round 3 连同 `mcpp.library.indexFound` 一起删除。)** +- `test/architecture.test.ts` 补上库生态的 4 个纯模块(`indexModel` / `xpkg` / `libraryHtml` / + `detailHtml`),让"纯模块不得依赖 vscode"这条覆盖到新代码。 + +## 15. round 3:你在真实实例里给的 6 条反馈 + +这一轮的每一条都是先在**一手源码**或**本机真实运行**里定位到根因,再改。下面把证据一起留下, +因为它们决定了改法——其中两条的"根因"和最初看起来的完全不是一回事。 + +### 15.1 基本信息默认折叠、常用命令默认展开(反馈 1、2 前半) + +数据层一行的事:`buildProjectTree` 里 `project.section.basic` 去掉 `expanded: true`, +`project.section.commands` 保留。测试从「两个 section 都展开」改成 +「命令展开、基本信息 `expanded === undefined`」。 +dev profile 的 `workspaceStorage` 会在重启前清掉,否则 VS Code 会用上次记住的展开状态覆盖默认。 + +### 15.2 通用命令图标带色(反馈 2 后半) + +树**支持**颜色,快捷菜单**不支持**——两者机制不同,所以这里能做的和那里能做的不是一件事。 + +树的证据(1.132 `workbench.desktop.main.js`,`CustomTreeView` 的渲染分支): + +```js +this.shouldShowThemeIcon(!!r, n.themeIcon) && (L = j.asClassName(n.themeIcon), + n.themeIcon.color ? i.icon.style.color = this.themeService.getColorTheme() + .getColor(n.themeIcon.color.id)?.toString() ?? "" : L = L + " codicon-colored") +``` + +所以 `TreeNode` 加了 `iconColor?: string`(`ThemeColor` id),只允许 `charts.*` 这类所有主题都 +会定义的 id;主题没定义时 VS Code 让颜色为空串,图标退回普通前景色,不会变成看不见。 +配色按**这一行作用于什么**分组,不按装饰:蓝=构建/添加、绿=运行/校验、紫=测试、橙=删除、 +黄=工具链、设置保持中性。 + +### 15.3 快捷菜单:图标 + 分组(反馈 3) + +`quickMenuItems` 现在每条带 `icon`,菜单按 `QUICK_MENU_GROUPS` 插 `QuickPickItemKind.Separator` +做分组;分组标题与条目共用一张表,所以不可能出现"条目的分组没有标题"。 + +**颜色做不到,这是 API 限制,不是没做。** 1.132 的 `MainThreadQuickOpen.expandIconPath`: + +```js +expandIconPath(o){ let e = o.iconPathDto; + if (e) + if (j.isThemeIcon(e)) o.iconClass = j.asClassName(e); // ← color 被丢掉 + else if (Qh(e)) { let t = P.from(e); o.iconPath = { dark: t, light: t }; } + else { … } } +``` + +`ThemeIcon` 被压成一个 codicon class,列表渲染只写 +`i.icon.className = "quick-input-list-icon " + r.iconClass`,没有任何一处读颜色。 +所以这一轮**只做了图标形状 + 分组**,没有塞一个"永远画不出来"的颜色。顺带把 5 个分组标题与 20 条 +条目全部补上中文(原来 27 个 labelKey 里 20 个没有译文,中文用户看到的是英文),并加了 +`test/commands/menu.test.ts` 把"每个 labelKey 必须有译文""分组必须连续"钉住——这两件事 +`tools/l10n-check.mjs` 看不见,因为菜单标签是 `t(item.labelKey)` 而不是字面量。 + +### 15.4 库视图与「刷新 mcpp 包索引」毫无反应(反馈 4) + +**根因不是索引定位,是 provider 从来没注册过。** 上一轮我把 `registerWebviewViewProvider` +从 `libraryView.ts` 里"交代给调用方",`extension.ts` 的注释却写着"provider 由 registerLibraryView +自己注册"——两边互相甩锅,结果**整个 `src/library/` 里一次都没有这个调用** +(`grep -rn registerWebviewViewProvider src/` 当时只有 `cachePanel.ts` 一处)。 +没有注册就没有 document,`refresh()` 永远打在 `this.view === undefined` 上: +视图是空的,刷新是无声的。修法是在 `registerLibraryView` 内部注册(与 `registerCachePanel` +同构),并加了一条**能抓住这类错误**的门禁: + +``` +test("every webview view registers the provider that fills it") + → 每个 "type": "webview" 的视图 id 必须有唯一一个 `export const *_VIEW_ID = ""` 模块, + 且该模块必须调用 registerWebviewViewProvider +``` + +索引定位按你的建议增强了,但放在**最后**,因为它不是这次的根因: + +| 顺序 | 找法 | 代价 | +| --- | --- | --- | +| 1 | `mcpp.library.indexPath`(用户显式设置) | 0 | +| 2 | `$MCPP_HOME/registry/data`、`~/.mcpp/registry/data`,**有内容的都列** | 几次 `readdir` | +| 3 | `mcpp self env --format json` 的 `mcppHome` | 每会话最多 1 个进程,且只在 2 全空时才跑 | + +第 3 步走机器协议(`schemaVersion` + `kind`,见 `src/cli/protocol.ts` 的检测规则), +在**不受信任的工作区跳过**(`mcpp.path` 是 resource 作用域,不能让工作区指定要跑的二进制)。 +本机实测(用 `Module._load` 打桩 `vscode` 后直接跑编译产物): + +| 场景 | 结果 | +| --- | --- | +| 设置指向 mcpp-index 检出 | 1 个 root / 238 包 / 146 ms,二次 0 ms(快照缓存命中) | +| 默认(不设) | 3 个 root / 543 包 / 228 ms | +| 两个 home 都空 → `mcpp self env` | 3 个 root / 543 包 / 674 ms | +| mcpp 缺失 | 空 / 5 ms,不抛 | + +(写这条时踩了自己的一个 bug:`defaultDataDirectories` 先用探针**之前**的候选列表判断 +"探针给的主目录是不是新的",而探针恰恰是把它加进列表的那一步,于是永远判定"不新"、 +永远返回旧列表。改成探针后重读候选列表。这个 bug 只有在真的构造出"两个 home 都空"的场景 +才会暴露——上面那张表就是这么写的。) + +「刷新 mcpp 包索引」另外两处修:成功后**有反馈**了(进度通知 + 「已刷新:N 个包,来自 M 个索引目录」, +之前成功时零输出,看起来就是坏的),并且补上不受信任工作区的拦截。 + +顺手删掉两处**已被证伪的死代码**:`mcpp.library.indexFound` 上下文键与 `viewsWelcome.library` +文案。1.132 里基类 `ViewPane` 的 `shouldShowWelcome(){return !1}`,只有 tree 类视图覆写它 +(`TreeViewPane` 用 `dataProvider.isTreeEmpty`),所以 `"type": "webview"` 视图**永远不会** +渲染 welcome 内容;库视图在自己的 document 里说空状态,那是唯一会被显示的地方。 + +### 15.5 全局构建缓存的分布条是黑的(反馈 5) + +根因是 CSS 选择器和渲染出来的 DOM 差了一层: + +``` +渲染:
… +样式: [data-viz="composition"] rect[data-kind="pkg"] { fill: … } ← rect 上没有 data-kind +``` + +`rect[data-kind]` 谁都不匹配 → 每个 `` 落到 SVG 的默认填充(**黑**,任何主题都是黑)。 +图例里的 `.swatch[data-kind="pkg"]` 是打在 `` 上的,所以**图例有颜色、条没有**, +正好就是你看到的样子。修法:选择器改到 ``(`fill` 是继承属性,会漏到子 ``), +并在 `test/views/cachePanelHtml.test.ts` 加了一条**文档与样式表互查**的测试: +既要求文档真的把属性打在 `` 上,也要求样式表为每个值都有 `fill` 规则,同时**禁止** +`rect[data-…]` 这种写法再出现。 + +### 15.6 活动栏的 mcpp logo 是白的 / 不显示(反馈 6) + +一手证据在 `ActivityAction.toCompositeBarActionItem`(1.132): + +```js +let c = kc(i), l = new ab; l.update(c); let u = `activity-${e.replace(/\./g,"-")}-${l.digest()}` … +Sf(p, ` + mask: ${c} no-repeat 50% 50%; + mask-size: var(--activity-bar-icon-size, ${this.options.iconSize}px); + -webkit-mask: ${c} no-repeat 50% 50%; …`) +``` + +**自定义容器图标是被当模板(mask)用的**:画出来的是图标的 **alpha 通道**,颜色来自主题 +(`.style-override` 下未选中是 `--vscode-icon-foreground`、选中是 `--vscode-foreground`, +hover/active 是 `--vscode-activityBar-foreground`)。而官方 logo 是一张 +**377×377 不透明黑色圆角方块**(实测:不透明覆盖率 90%,其中 81.5% 是纯 `#000000`、 +6.7% `#f08c00`、5.7% `#1971c2`)。拿它当模板,自然就是**一整块浅色方块**——你看到的"白色"。 + +修法:`images/activity-bar.png` 改为**派生资产**——透明底 + 只留字形的白色 alpha +(96×56,字形 88×48)。由 `tools/generate-activitybar-icon.mjs` 从 `images/logo.png` +重新生成;覆盖率用「像素 = 覆盖率 × 墨色」反解(两个平涂色的最大通道做分母), +6% 以下当作徽章边缘抗锯齿丢掉,所以不会在字外留一圈灰晕。白色 + 透明底在 alpha 模板和 +亮度模板下**都对**,不依赖 Chromium 选哪一种。`npm run check:icon` 进了 `npm run check`, +产物与来源不可能漂移。市场图标(`package.json` 的 `icon`)继续用真 logo。 + +### 15.7 这一轮的状态与仍然没做到的 + +- 646 个单元测试通过;`check:config`(68 设置 / 32 public)、`l10n-check`(382 运行串 / + 201 清单键)、`check:icon`、`check:generators` 全过;VSIX 93 文件 / 343 KiB(两道体积门禁内)。 +- dev profile 重装并重启,扩展主机日志确认激活、无错误。 +- **没做到**:快捷菜单图标没有颜色(15.3 的 API 限制);活动栏图标在真实主题下的观感只能靠眼睛, + 我没有截图能力;e2e 仍未在本地跑通(要下载固定版 VS Code,本机大文件下载会中断); + 索引定位的三条路径是靠打桩 `vscode` 后直接跑编译产物验证的,不是通过编辑器 UI 验证的。 + + +## 16. round 4:第二轮真实反馈(含一个把库视图打瘫的 bug) + +这一轮的第一条是**真 bug**:库视图一显示就抖、点不动、搜不了、CPU 飙高,折叠起来就正常。 +根因和"性能问题"完全无关。 + +### 16.1 库视图的渲染死循环(反馈 2,最高优先级) + +**根因:文档在自我介绍,宿主拿"再渲染一次"回答它。** + +`libraryHtml.ts` 的客户端脚本最后一句是 `post({ type: "ready" })`,而 `libraryView.ts` 的 +`handle()` 里恰好有: + +```ts +case "ready": + this.paint(); // ← paint() 里是 view.webview.html = … + return; +``` + +`webview.html = …` 会**重载**文档;文档重载后又发 `ready`。更致命的是每次 `paint()` 都调用 +`randomNonce()` 生成新的 CSP nonce,所以**每次渲染出来的文档都不同**——连"内容没变就别重设" +这种自然去重也永远不会命中。于是视图以毫秒级无限重载:抖动、DOM 被反复销毁(点不到、输不进)、 +CPU 打满。折叠时 webview 不渲染,所以"折叠就正常"。 + +上一轮这个 bug 之所以没暴露,恰恰因为 provider 从来没注册过——视图根本没渲染过。**修好注册, +就把它放出来了。** + +修法三条 + 一条门禁: + +1. 客户端不再发 `ready`,`decodeLibraryMessage` 不再认识它,宿主删掉 `case "ready"`: + 文档本身就是数据(每一行、每个徽章、每个计数都已经在里面),"我加载好了"这句话没有用途, + 只可能招来一次重复渲染。 +2. nonce 改成**每个视图一个**(构造时生成一次),于是"内容没变 ⇒ 文档逐字节相同"成立。 +3. `paint()` 多一道 `documentNeedsRender(this.document, document)`(纯函数,见 `libraryHtml.ts`): + 文档相同就**不重设**,滚动位置和输入到一半的搜索框都不会被丢掉。 + `resolveWebviewView` / `onDidDispose` 会把 `this.document` 清空——新解析出来的 webview 是空的, + 上一次推给旧 webview 的文档对它没有任何意义。 +4. `test/artifacts.test.ts` 新增源码级门禁:文档不得再 post `ready`、宿主不得再有 `case "ready"`、 + nonce 必须是每视图一个、比较必须存在。 + +### 16.2 快捷菜单图标配色(反馈 1) + +**能做,但只能靠图片。** 上一轮我查到 `MainThreadQuickOpen.expandIconPath` 会把 `ThemeIcon` +压成没有颜色的 codicon class;这一轮把同一函数读完了——它是**两条分支**: + +```js +expandIconPath(o){ let e = o.iconPathDto; + if (e) + if (j.isThemeIcon(e)) o.iconClass = j.asClassName(e); // 字体图标:颜色由行前景决定 + else if (Qh(e)) { let t = P.from(e); o.iconPath = { dark: t, light: t }; } // Uri:当 background-image 画 + else { … { dark, light } … } } +``` + +而列表渲染这边: + +```js +if (r.iconPath) { … i.icon.className = "quick-input-list-icon"; i.icon.style.backgroundImage = kc(f); } +``` + +`Uri` 是当**图片**画的,颜色照原样显示;`iconPath` 又恰好接受 `{ light, dark }` 对。所以路线是: +**给每一行生成一张带颜色的 SVG**。 + +- 字形来自 `@vscode/codicons`(VS Code 自己那套 codicon 字体的美术源,作为 devDependency 精确锁定 + `0.0.46-24`),颜色取对应 `charts.*` 主题令牌的**默认 light/dark 值**(从 1.132 的颜色注册表读出)。 + 于是快捷菜单和工程视图用的是同一批颜色:树把 `charts.blue` 交给 `ThemeIcon` 由主题上色, + 这里把同一个默认值烤进图里。 +- 生成器 `tools/generate-quick-menu-icons.mjs` 解析 `src/commands/menu.ts` 的表格,输出 + `media/quick-menu/----.svg`(40 个,zip 后约 34 KiB), + 支持 `--check`(多一个没人要的文件也算漂移),已进 `npm run check`。 +- 门禁在 `test/commands/menu.test.ts`:逐行走表格,两个主题的资产必须存在、非空、不再含 + `currentColor`;并且**同一个命令在菜单和工程视图里必须是同一个图标 + 同一个颜色** + (`charts.` 是两者的接缝)。 + +顺手抓出三个真问题,都是门禁逼出来的: + +| 发现 | 处理 | +| --- | --- | +| `charts.orange` 解析到 `minimap.findMatchHighlight` → `editor.findMatchHighlightBackground`,是 `#EA5C00` **33% 透明**;当字形颜色就是两头上都糊成一团 | 破坏性动作改用 `charts.red`(`editorError.foreground`,各主题都是实色)。树里的 Clean 一起改 | +| `star` 根本不是 codicon(只有 `star-full` / `star-empty` / `star-half`)——"选择全局默认工具链"那一行一直是**空图标** | 换成 `star-full` | +| "Search and add a dependency" 在树里是 `cloud`、在菜单里是 `library` | 统一成 `library`(它打开的就是 Library 视图) | + +另外菜单里那 20 条标签的中英译文上一轮已补齐,这轮没有新增文案。 + +### 16.3 cache 视图默认折叠(反馈 3) + +`contributes.views` 的条目 schema 里本来就有 `visibility: "visible" | "hidden" | "collapsed"` +(默认 `visible`),消费点是 `createView(n, { …, expanded: !collapsed })`。所以 +`mcpp.cache` 加上 `"visibility": "collapsed"` 就够了:侧边栏打开时 cache 只剩标题栏, +高度让给上面的库列表。用户手动展开/折叠之后以用户的状态为准(VS Code 把它记在 workspace state 里)。 +门禁在 `test/artifacts.test.ts`:三个视图的 `visibility` 必须恰好是 +`[undefined, undefined, "collapsed"]`。 + +### 16.4 活动栏"彩色 logo":做不到,所以不做(反馈 4) + +你想要"保留单色 / 配置里切换彩色"。**彩色这条路在活动栏是不存在的**,有两条独立的一手证据: + +1. 自定义容器图标是当**模板**画的(15.6 的 `mask: url(icon) … mask-size: 24px`), + 画出来的是图标的 **alpha 通道**,颜色来自主题变量。颜色信息在第一步就被丢掉了。 +2. 清单里 `viewsContainers.activitybar[].icon` 的 schema 是纯 `string` + (`{description:…, type:"string"}`,`required:["id","title","icon"]`), + 连 `{light, dark}` 这种写法都会被 `isValidViewsContainer` 直接判为非法; + 而且没有任何运行时 API 能改容器图标。 + +所以"配置里切换彩色/单色"会是一个**永远无效的开关**——比没有这个开关更糟。 +单色字形保留(你说它和其他图标风格匹配,这也是它本该有的样子:活动栏图标就该是主题前景色的剪影)。 + +### 16.5 状态栏:背景色可以,logo 不行(反馈 5) + +**背景色:VS Code 只允许两种,而且由扩展主机强制执行。** `StatusBarItem.backgroundColor` +的 API 文档写了"只支持 `statusBarItem.errorBackground` 与 `statusBarItem.warningBackground`", +我不满足于文档,去找了实现(`extensionHostProcess.js`): + +```js +static ALLOWED_BACKGROUND_COLORS = new Map([ + ["statusBarItem.errorBackground", new ThemeColor("statusBarItem.errorForeground")], + ["statusBarItem.warningBackground", new ThemeColor("statusBarItem.warningForeground")], +]); +set backgroundColor(t){ t && !ALLOWED_BACKGROUND_COLORS.has(t.id) && (t = void 0); this._backgroundColor = t; … } +… +this._backgroundColor && (n = ALLOWED_BACKGROUND_COLORS.get(this._backgroundColor.id)); // ← 前景色被替换 +``` + +也就是说:自建一个 `mcpp.statusBarBackground` 颜色会被**静默丢弃**;自己设 `color` 也会被覆盖。 +实现方式是新设置 `mcpp.ui.statusBar.background`(`warning` / `error` / `none`,默认 `warning`), +纯映射放在 `src/cli/statusBar.ts`(已进纯模块门禁),`applyStatusBar()` 一行接上。 +默认 `warning` = 主题的琥珀色块 + VS Code 自动配的白色前景,正好和 logo 的金橙呼应;不喜欢就设 `none`。 + +**logo 放状态栏:不行。** `StatusBarItem.text` 是字符串,里面的 `$(name)` 由 +`ThemeIcon.fromString` 解析成 **codicon 字体字形**,没有任何图片通道;`StatusBarItem` 上也没有 +`iconPath` 之类的字段。所以 `$(tools) mcpp` 保持不变——它是这套菜单(构建 / 工具链 / 缓存 / 模块) +最贴切的字形,而"换成 mcpp logo"这条路在 API 层面不存在。 + +### 16.6 这一轮的状态与仍然没做到的 + +- 653 个单元测试通过;`check:config`(69 设置 / 32 public)、`l10n-check`(382 运行串 / + 203 清单键)、`check:icon`(活动栏图 + 40 个菜单图标)、`check:generators` 全过; + VSIX 134 文件 / 377 KiB(两道体积门禁内:<200 文件、<1 MiB)。 +- dev profile 重装并重启(清掉 workspaceStorage,让 cache 的默认折叠生效),扩展主机日志确认激活、无错误。 +- **没做到 / 需要你的眼睛**:快捷菜单的彩色图标我只验证到"文件内容正确、颜色被烤进去、 + 能被栅格化成正确的颜色",**没有**在真实 quick pick 里看过——如果 `file:` URI 在快速选择里 + 加载不出来,那 16 行的槽位会是空的,那我就改走别的路子(data: URI 或退回单色)。这一条请重点看。 +- 库视图的死循环是**逻辑上**必然成立(文档 self-announce + 每次新 nonce),但没有做进程级 + 的 CPU 观测;"不抖了、能点了"需要你确认。 +- 活动栏彩色、状态栏 logo:API 层面不存在,已用一手源码说明。 +- e2e 仍未在本地跑通;索引定位的三条路径仍是打桩验证,不是 UI 验证。 + +## 17. round 5:状态栏背景回退、侧边栏配比、库标签与详情页、年龄渐变、新建工程 + +### 17.1 状态栏背景色默认关掉(反馈 1) + +`mcpp.ui.statusBar.background` 保留(它仍是唯一能做的两种背景),但默认值从 `warning` 改成 +`none`:一个常驻的琥珀色块会被读成"出问题了"。想要就设 `warning` / `error`,两个值都由 +VS Code 自己配好对比度(见 16.5 的扩展主机白名单)。 + +### 17.2 侧边栏配比:库视图默认占下面 2/3(反馈 2) + +`contributes.views[].initialSize` 不是像素,它是**视图在容器里的配比权重**(一手证据 +`computeInitialSizes()`): + +```js +let t = this.viewContainerModel.visibleViewDescriptors.reduce((i,{weight:n}) => i + (n||20), 0); +for (let i of this.viewContainerModel.visibleViewDescriptors) + e.set(i.id, this.dimension.height * (i.weight || 20) / t); // 默认每个视图 20 +``` + +所以给 `mcpp.library` 一个 `initialSize: 40`(默认 20 的两倍)就够:默认布局里项目视图拿 1/3、 +库视图拿 2/3,cache 又是折叠的(只剩标题栏,它的份额按比例回流给另外两个)。门禁在 +`test/artifacts.test.ts`:三个视图的 `initialSize` 必须恰好是 `[undefined, 40, undefined]`, +注释里带上上面那段权重公式。 + +### 17.3 库的标签去掉外框,详情页重排(反馈 3) + +**标签**(`.badge`)之前是"1px 外框 + 圆角"的小方块,一行的标签看起来像一排按钮。 +现在是不带任何框和底的**安静元数据**,相邻项之间用 `·` 分隔;只有必须被看见的两个状态 +(`Added`、`Descriptor not readable`)保留颜色并加粗——文字本身也在说同一件事,所以不靠颜色。 +**筛选 chip**(`.chip`)是交互控件,所以换成 VS Code 自己的 toggle 配色: +未选中 = 透明底、`descriptionForeground`;选中 = `inputOption.activeBackground/Foreground/Border`。 +"未选中也描一圈边"正是让它像按钮的原因。 + +**详情页**的结构问题更根本:读者来点的那颗按钮原本在**四个段落之后**(`renderActions` 在 +`
` 的最后)。现在顺序是: + +``` +标题 → 一句话描述 → 事实行(registry / surface / standard / 许可 / 徽章 / 仓库链接) +→ 主操作块([Add to mcpp.toml] [☐ dev-dependency] + 命令预览) +→ 版本矩阵 → 用法示例 → 依赖 → 其它 +``` + +同时把**版本矩阵变成选择器**:每个版本是一个 button(`data-version`),点它就把上面的命令 +指到那个版本,被选中的那枚用同一套 toggle 配色标出。原来 ``, + ` ${escapeHtml(label(CACHE_PANEL_UI.budgetUnit))}`, + ` `, + ` ${estimate}`, + ` ${button("prune", label(CACHE_PANEL_UI.prune), !sharedAvailable, sharedReason)}`, + legacyBytes > 0 + ? ` ${button("cleanLegacy", label(CACHE_PANEL_UI.cleanLegacy), false, label(CACHE_PANEL_UI.reasonLegacy))}` + : "", + ``, + ].filter((line) => line.length > 0).join("\n"); +} + +/** + * The global block. Expanded it is the composition bar, the age bar, the nested + * largest-packages list and the shared actions; collapsed it is one summary line + * (`7.20 GiB · 657 entries`). The markup carries **no `open` attribute**, which + * is what makes the first render collapsed. + */ +function renderSharedBlock(model: CachePanelModel, label: UiLabel): string { + const formatters = model.format; + const notes: string[] = []; + if (model.shared.root !== undefined) { + notes.push( + `

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

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

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

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

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

`, + `

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

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

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

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

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

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

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

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

${escapeHtml(row.description)}

`, + `

${escapeHtml(deprecation)}

`, + `

${escapeHtml(hint)}

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

`, + ` `, + `

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

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

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

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

+

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

+ ${resource} +
+${renderToolbar(label, hasResource)} +${renderPresets(model.presets, label)} +
+${sections} +
+ + + + +`; +} + +/** Values posted back by the webview, decoded and validated. */ +export type PanelMessage = + | { type: "update"; key: string; value: unknown; target: "user" | "workspace" } + | { type: "reset"; key: string } + | { type: "preset"; id: string } + | { type: "openNative"; key: string } + | { type: "openMcpplsSettings" } + | { type: "ready" }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that + * is not exactly one of the six shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + * + * A settings key is always required and always non-empty; `update` additionally + * needs a write target and a `value` field (the value itself may be anything — + * `validate.ts` checks it against the registry before it is written). + */ +export function decodePanelMessage(raw: unknown): PanelMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "ready": + return { type: "ready" }; + case "openMcpplsSettings": + return { type: "openMcpplsSettings" }; + case "update": { + if (!nonEmptyString(raw.key) || !("value" in raw)) { + return undefined; + } + if (raw.target !== "user" && raw.target !== "workspace") { + return undefined; + } + return { type: "update", key: raw.key, value: raw.value, target: raw.target }; + } + case "reset": + return nonEmptyString(raw.key) ? { type: "reset", key: raw.key } : undefined; + case "preset": + return nonEmptyString(raw.id) ? { type: "preset", id: raw.id } : undefined; + case "openNative": + return nonEmptyString(raw.key) ? { type: "openNative", key: raw.key } : undefined; + default: + return undefined; + } +} diff --git a/src/config/presets.ts b/src/config/presets.ts new file mode 100644 index 0000000..21d3b2e --- /dev/null +++ b/src/config/presets.ts @@ -0,0 +1,110 @@ +/** + * Named bundles of setting values for the configuration panel. + * + * Pure: a preset is data, so the panel, the tests and the docs all read the same + * table. A preset only ever names keys that exist in the registry (enforced by + * `presetProblems()` and the unit tests) — a preset that silently does nothing is + * worse than no preset. + */ + +import { setting } from "./registry"; + +export interface Preset { + id: string; + /** English; the panel localizes this through `src/i18n/t.ts`. */ + title: string; + description: string; + /** Key -> value, applied in order. */ + values: Readonly>; +} + +export const PRESETS: readonly Preset[] = [ + { + id: "defaults", + title: "Defaults", + description: "Put every mcpp setting back to its shipped default.", + values: {}, + }, + { + id: "quiet", + title: "Quiet", + description: "No status-bar counters, no automatic refresh, no editor diagnostics from this extension.", + values: { + "mcpp.cache.statusBar": false, + "mcpp.cache.autoRefreshSeconds": 0, + "mcpp.languageService.stateRefreshSeconds": 0, + "mcpp.ui.notifications.success": "silent", + "mcpp.task.focusTerminal": false, + "mcpp.toml.diagnostics.unknownKey": "off", + "mcpp.toml.diagnostics.legacyKeys": "off", + "mcpp.buildScript.diagnostics": false, + }, + }, + { + id: "everything", + title: "Everything on", + description: "All observation and editing help enabled, including the ones that run mcpp a little more often.", + values: { + "mcpp.cache.statusBar": true, + "mcpp.cache.autoRefreshSeconds": 60, + "mcpp.cache.warnAboveGiB": 10, + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 30, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.toml.hover": true, + "mcpp.toml.navigation": true, + "mcpp.toml.diagnostics.enabled": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + "mcpp.toml.diagnostics.planeSeparation": "warning", + "mcpp.toml.diagnostics.legacyKeys": "info", + "mcpp.buildScript.intelligence": true, + "mcpp.buildScript.diagnostics": true, + "mcpp.buildScript.snippets": true, + "mcpp.buildScript.imports.knownModules": true, + "mcpp.views.cache.topN": 10, + "mcpp.ui.notifications.success": "statusBar", + "mcpp.log.level": "info", + }, + }, + { + id: "diagnose", + title: "Diagnose", + description: "Turn the logging up and read the C++ Modules state, for when something is wrong.", + values: { + "mcpp.log.level": "debug", + "mcpp.languageService.readState": true, + "mcpp.languageService.stateRefreshSeconds": 15, + "mcpp.languageService.notifyOnDegraded": true, + "mcpp.diagnostics.selfCheckOnStartup": true, + "mcpp.toml.diagnostics.unknownSection": "warning", + "mcpp.toml.diagnostics.unknownKey": "warning", + }, + }, +]; + +export function preset(id: string): Preset | undefined { + return PRESETS.find((entry) => entry.id === id); +} + +/** The key/value pairs a preset would write. `defaults` writes nothing: see `resetKeys()`. */ +export function presetValues(id: string): Array<{ key: string; value: unknown }> { + const found = preset(id); + if (found === undefined) { + return []; + } + return Object.entries(found.values).map(([key, value]) => ({ key, value })); +} + +/** Registry keys a preset mentions that do not exist (must be empty). */ +export function presetProblems(): string[] { + const problems: string[] = []; + for (const entry of PRESETS) { + for (const key of Object.keys(entry.values)) { + if (setting(key) === undefined) { + problems.push(`preset ${entry.id} names unknown setting ${key}`); + } + } + } + return problems; +} diff --git a/src/config/registry.ts b/src/config/registry.ts new file mode 100644 index 0000000..2f73423 --- /dev/null +++ b/src/config/registry.ts @@ -0,0 +1,184 @@ +/** + * The settings registry: **the single source of truth** for everything this + * extension makes configurable. + * + * `data/config-registry.json` holds the declaration; `package.json`'s + * `contributes.configuration` is hand-written but held to it by + * `tools/check-config.mjs`, and `docs/settings.md` is generated from it. The + * configuration panel (`src/config/panel.ts`) renders it, and + * `mcpp: environment self-check` dumps the entries the user has changed. + * + * Shape rules are enforced by `tools/check-config.mjs` and by the unit tests: + * unique keys, every `group` present in `groups`, ascending `order` within a + * group, and a `default` that `validate.ts` accepts. + */ + +import registryJson from "../../data/config-registry.json"; + +export type SettingType = "boolean" | "string" | "number" | "array"; +export type SettingScope = "resource" | "window"; +export type SettingTier = "public" | "advanced"; +export type SettingApplies = "immediate" | "next-build" | "next-clean" | "view-reload"; + +export interface SettingEntry { + key: string; + type: SettingType; + default: boolean | string | number | string[]; + enum?: string[]; + minimum?: number; + maximum?: number; + scope: SettingScope; + group: string; + order: number; + tier: SettingTier; + applies: SettingApplies; + since: string; + deprecated?: boolean; + aliases?: string[]; + title: string; + description: string; + deprecationMessage?: string; +} + +export interface SettingGroup { + id: string; + order: number; + title: string; +} + +interface RegistryFile { + version: number; + groups: SettingGroup[]; + settings: SettingEntry[]; +} + +const registry = registryJson as unknown as RegistryFile; + +export const SETTINGS: readonly SettingEntry[] = registry.settings; +export const GROUPS: readonly SettingGroup[] = [...registry.groups].sort((a, b) => a.order - b.order); + +const BY_KEY = new Map(SETTINGS.map((entry) => [entry.key, entry])); +const GROUP_BY_ID = new Map(GROUPS.map((group) => [group.id, group])); + +/** The section every key lives under; VS Code's `getConfiguration` needs it. */ +export const SECTION = "mcpp"; + +/** `"mcpp.cache.staleDays"` -> `"cache.staleDays"`. */ +export function subKey(key: string): string { + return key.startsWith(`${SECTION}.`) ? key.slice(SECTION.length + 1) : key; +} + +export function setting(key: string): SettingEntry | undefined { + return BY_KEY.get(key); +} + +export function requireSetting(key: string): SettingEntry { + const entry = BY_KEY.get(key); + if (entry === undefined) { + throw new Error(`unknown mcpp setting: ${key}`); + } + return entry; +} + +export function groupOf(key: string): SettingGroup | undefined { + const entry = BY_KEY.get(key); + return entry === undefined ? undefined : GROUP_BY_ID.get(entry.group); +} + +/** Entries of one group, in their declared order. */ +export function settingsInGroup(groupId: string): SettingEntry[] { + return SETTINGS.filter((entry) => entry.group === groupId) + .slice() + .sort((a, b) => a.order - b.order); +} + +export function settingsByTier(tier: SettingTier): SettingEntry[] { + return SETTINGS.filter((entry) => entry.tier === tier); +} + +/** Keys this build mentions in the manifest, in registry order. */ +export function manifestKeys(): string[] { + return SETTINGS.map((entry) => entry.key); +} + +/** The key an old name now resolves to, or `undefined` when it is unknown. */ +export function aliasedKey(oldKey: string): string | undefined { + for (const entry of SETTINGS) { + if (entry.aliases?.includes(oldKey) === true) { + return entry.key; + } + } + return undefined; +} + +/** Old name -> current name, for the one-time migration prompt. */ +export function renamedKeys(): Array<{ from: string; to: string }> { + const pairs: Array<{ from: string; to: string }> = []; + for (const entry of SETTINGS) { + for (const alias of entry.aliases ?? []) { + pairs.push({ from: alias, to: entry.key }); + } + } + return pairs; +} + +/** + * The registry's own invariants. Returns a list of problems (empty when sound). + * + * The same rules live in `tools/check-config.mjs`, but having them here means a + * malformed `data/config-registry.json` is caught by the unit tests as well — + * and lets `activate()` log a single line instead of failing feature by feature. + */ +export function registryShapeProblems(): string[] { + const problems: string[] = []; + const groupIds = new Set(); + for (const group of GROUPS) { + if (groupIds.has(group.id)) { + problems.push(`duplicate group ${group.id}`); + } + groupIds.add(group.id); + if (typeof group.title !== "string" || group.title.length === 0) { + problems.push(`group ${group.id} has no title`); + } + } + const seen = new Set(); + const lastOrder = new Map(); + for (const entry of SETTINGS) { + if (!entry.key.startsWith(`${SECTION}.`)) { + problems.push(`${entry.key} is not a ${SECTION}.* key`); + } + if (seen.has(entry.key)) { + problems.push(`duplicate key ${entry.key}`); + } + seen.add(entry.key); + if (!groupIds.has(entry.group)) { + problems.push(`${entry.key} names undeclared group ${entry.group}`); + } + const previous = lastOrder.get(entry.group); + if (previous !== undefined && !(entry.order > previous)) { + problems.push(`${entry.group} order is not ascending at ${entry.key}`); + } + lastOrder.set(entry.group, entry.order); + if (entry.title.length === 0 || entry.description.length === 0) { + problems.push(`${entry.key} has an empty title or description`); + } + if (entry.enum !== undefined && !entry.enum.includes(entry.default as string)) { + problems.push(`${entry.key} default is not in its enum`); + } + if (entry.deprecated === true && entry.deprecationMessage === undefined) { + problems.push(`${entry.key} is deprecated without a deprecationMessage`); + } + } + return problems; +} + +/** Every setting key, ordered by group then by the group's own order. */ +export function groupedKeys(): string[] { + const keys: string[] = []; + for (const group of GROUPS) { + for (const entry of settingsInGroup(group.id)) { + keys.push(entry.key); + } + } + return keys; +} diff --git a/src/config/validate.ts b/src/config/validate.ts new file mode 100644 index 0000000..91ec337 --- /dev/null +++ b/src/config/validate.ts @@ -0,0 +1,93 @@ +/** + * Value validation for the settings registry. Pure: no `vscode`. + * + * Used in two places: `access.ts` when a value arrives from the editor (a + * hand-edited `settings.json` can hold anything), and the configuration panel + * before it writes. An invalid value never reaches the rest of the extension – + * the declared default is used and the user is told once. + */ + +import type { SettingEntry } from "./registry"; + +export type ValidationFailure = + | "type" + | "enum" + | "minimum" + | "maximum" + | "items"; + +export interface ValidationResult { + ok: boolean; + /** Present when `ok`: the coerced value. */ + value?: boolean | string | number | string[]; + /** Present when `!ok`: why it was rejected. */ + reason?: ValidationFailure; +} + +function typeOf(entry: SettingEntry): string { + return entry.type === "array" ? "array of strings" : entry.type; +} + +export function validateValue(entry: SettingEntry, raw: unknown): ValidationResult { + if (raw === undefined || raw === null) { + return { ok: false, reason: "type" }; + } + switch (entry.type) { + case "boolean": { + if (typeof raw !== "boolean") { + return { ok: false, reason: "type" }; + } + return { ok: true, value: raw }; + } + case "number": { + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return { ok: false, reason: "type" }; + } + if (entry.minimum !== undefined && raw < entry.minimum) { + return { ok: false, reason: "minimum" }; + } + if (entry.maximum !== undefined && raw > entry.maximum) { + return { ok: false, reason: "maximum" }; + } + return { ok: true, value: raw }; + } + case "string": { + if (typeof raw !== "string") { + return { ok: false, reason: "type" }; + } + if (entry.enum !== undefined && !entry.enum.includes(raw)) { + return { ok: false, reason: "enum" }; + } + return { ok: true, value: raw }; + } + case "array": { + if (!Array.isArray(raw)) { + return { ok: false, reason: "type" }; + } + if (raw.some((item) => typeof item !== "string")) { + return { ok: false, reason: "items" }; + } + return { ok: true, value: [...raw] as string[] }; + } + default: + return { ok: false, reason: "type" }; + } +} + +/** The declared default, as a fresh value (arrays are copied). */ +export function defaultValue(entry: SettingEntry): T { + return (Array.isArray(entry.default) ? [...entry.default] : entry.default) as T; +} + +/** Human-readable expectation, for the "value rejected" notice. */ +export function expectation(entry: SettingEntry): string { + if (entry.enum !== undefined) { + return entry.enum.join(" | "); + } + if (entry.type === "number" && (entry.minimum !== undefined || entry.maximum !== undefined)) { + const low = entry.minimum ?? Number.NEGATIVE_INFINITY; + const high = entry.maximum ?? Number.POSITIVE_INFINITY; + return `${low} … ${high}`; + } + return typeOf(entry); +} diff --git a/src/configureOnly.ts b/src/configureOnly.ts deleted file mode 100644 index cf233ae..0000000 --- a/src/configureOnly.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { runProcess, type ProcessResult, type ProcessRunner } from "./process"; - -export const configureOnlyArguments = ["build", "--configure-only"] as const; -// 配置阶段可能解析工具链和依赖,但不能无限期占住 IDE 操作队列。 -export const configureOnlyTimeoutMs = 5 * 60_000; - -export function runConfigureOnly( - projectRoot: string, - executable = "mcpp", - runner: ProcessRunner = runProcess, -): Promise { - return runner( - executable, - [...configureOnlyArguments], - projectRoot, - { timeoutMs: configureOnlyTimeoutMs }, - ); -} diff --git a/src/extension.ts b/src/extension.ts index b4d875e..db251be 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,23 +1,73 @@ import * as vscode from "vscode"; -import { McppCliController } from "./cliController"; -import { CLI_COMMANDS, DEPRECATED_COMMANDS } from "./commands"; -import { findNearestMcppProject, type McppProjectDiscovery } from "./discovery"; -import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./inProject"; +import { McppCliController, discoveryBoundaryFromSettings } from "./cli/controller"; +import { runMcpp } from "./cli/process"; +import { parseProtocolInfo } from "./cli/protocol"; +import { buildSelfCheckText } from "./cli/selfCheck"; +import { CLI_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, LIBRARY_COMMANDS, TOOL_COMMANDS } from "./commands/ids"; +import { findNearestMcppProject, type McppProjectDiscovery } from "./projects/discovery"; +import { MCPP_MANIFEST_GLOB, registerInProjectContext } from "./projects/context"; import { createLanguageServerBridge, type LanguageServerBridge, type LanguageServerCommandResult, -} from "./languageServer"; -import { computeMcppTomlCompletions } from "./mcppTomlCompletion"; +} from "./mcppls/bridge"; +import { compareVersions } from "./library/xpkg"; +import { CAPABILITIES, MCPPLS_EXTENSION_ID, VERIFIED_MCPPLS_MINIMUM, VERIFIED_MCPPLS_RANGE } from "./mcppls/contract"; +import { formatResult } from "./mcppls/messages"; +import { describeState } from "./mcppls/state"; +import { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, +} from "./mcppls/stateSource"; +import { registerBuildScriptProviders } from "./buildscript/providers"; +import { registerTomlProviders } from "./toml/providers"; import { buildModuleSetupPlan, executeModuleSetup, moduleSetupConfirmation, type ModuleSetupDecision, type ModuleSetupStepResult, -} from "./moduleSetup"; -import type { TaskCompletion } from "./tasks"; +} from "./workflows/moduleSetup"; +import type { TaskCompletion } from "./cli/tasks"; +import { changedSettings, onDidChange as onConfigurationChanged, read } from "./config/access"; +import { applyRenames, pendingRenames, renamePrompt } from "./config/migrate"; +import { registerSettingsPanel } from "./config/panel"; +import { languagePreference, setLanguagePreference, t, type LanguagePreference } from "./i18n/t"; +import { readCacheSnapshot, registerCacheView } from "./cache/cacheView"; +import { registerLanguageServerCommands } from "./views/languageServerView"; +import { createLibraryDetailOpener } from "./library/detailPanel"; +import { loadSnapshot } from "./library/indexLocator"; +import { registerLibraryView } from "./library/libraryView"; +import { registerProjectView } from "./views/projectView"; + +/** + * The commands an extension declares in its own `package.json`, read without + * activating it. `undefined` means "no static information", which the capability + * probe treats as "assume it works until a call says otherwise". + */ +function declaredCommandsOf(id: string): readonly string[] | undefined { + const extension = vscode.extensions.getExtension(id); + const commands = (extension?.packageJSON as { contributes?: { commands?: Array<{ command?: string }> } }) + ?.contributes?.commands; + if (!Array.isArray(commands)) { + return undefined; + } + return commands + .map((entry) => entry.command) + .filter((command): command is string => typeof command === "string"); +} + +/** When the language service was last asked to reload, for the self-check. */ +let lastRefresh: { at: string; state: string; command?: string } | undefined; + +/** `mcpp.ui.language` decides which of our strings the user sees. */ +function applyLanguagePreference(): void { + setLanguagePreference(read("mcpp.ui.language")); +} + function findCurrentProject(): McppProjectDiscovery | undefined { const activeEditor = vscode.window.activeTextEditor; @@ -30,11 +80,19 @@ function findCurrentProject(): McppProjectDiscovery | undefined { if (workspaceFolder === undefined) { return undefined; } - return findNearestMcppProject(activeUri.fsPath, workspaceFolder.uri.fsPath); + return findNearestMcppProject( + activeUri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); } for (const workspaceFolder of vscode.workspace.workspaceFolders ?? []) { - const project = findNearestMcppProject(workspaceFolder.uri.fsPath); + const project = findNearestMcppProject( + workspaceFolder.uri.fsPath, + workspaceFolder.uri.fsPath, + discoveryBoundaryFromSettings(workspaceFolder.uri), + ); if (project !== undefined) { return project; } @@ -51,35 +109,41 @@ function outputText(output: vscode.OutputChannel, line: string): void { } function resultText(output: vscode.OutputChannel, result: LanguageServerCommandResult): void { - outputText(output, `[C++ Modules] ${result.message}`); + const formatted = formatResult(result); + outputText(output, `[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); 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"); + const install = t("Install extension"); + void vscode.window.showWarningMessage(formatted.message, install).then((choice) => { + if (choice === install) { + void vscode.commands.executeCommand("workbench.extensions.search", `@id:${MCPPLS_EXTENSION_ID}`); } }); - } else if (result.state === "failed") { - void vscode.window.showErrorMessage(result.message); + return; + } + if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } else if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); } } function moduleSetupBlockedMessage(reason: Extract["reason"]): string { switch (reason) { case "untrusted": - return "当前工作区未受信任,不会执行 mcpp 或刷新 C++ 模块语言服务。请先信任工作区。"; + return t("This workspace is not trusted, so mcpp will not run and the C++ Modules language service will not refresh. Trust the workspace first."); case "busy": - return "已有 mcpp 操作正在运行,请等待完成后再试。"; + return t("An mcpp operation is already running; wait for it to finish and try again."); } } function taskCompletionText(completion: TaskCompletion): string { if (completion.state === "cancelled") { - return "构建已取消;C++ 模块语言服务未刷新。"; + return t("The build was cancelled; the C++ Modules language service was not refreshed."); } if (completion.state === "failed") { - return `mcpp 构建失败(退出码 ${completion.exitCode ?? "未知"});C++ 模块语言服务已重新读取现有构建描述。`; + return t("The mcpp build failed (exit code {0}); the C++ Modules language service re-read the existing build description.", completion.exitCode ?? t("unknown")); } - return "mcpp 构建完成,C++ 模块语言服务已刷新。"; + return t("The mcpp build finished; the C++ Modules language service has been refreshed."); } async function autoConfigureModulesWizard( @@ -102,12 +166,14 @@ async function autoConfigureModulesWizard( return; } const confirmation = moduleSetupConfirmation(); + // One definition: the label is shown *and* the reply is compared against it. + const confirmLabel = t("Confirm one-click setup"); const choice = await vscode.window.showWarningMessage( confirmation.message, { modal: true, detail: confirmation.detail }, - "确认一键配置", + confirmLabel, ); - if (choice !== "确认一键配置") { + if (choice !== confirmLabel) { return; } @@ -118,7 +184,7 @@ async function autoConfigureModulesWizard( return { stage: "build", state: "not-started", - detail: "未启动 mcpp 构建。", + detail: t("mcpp build was not started."), }; } return { @@ -133,7 +199,7 @@ async function autoConfigureModulesWizard( return { stage: "language-server", state: result.state === "completed" ? "succeeded" : "failed", - detail: result.state === "completed" ? undefined : result.message, + detail: result.state === "completed" ? undefined : formatResult(result).message, }; }, }); @@ -146,53 +212,20 @@ async function autoConfigureModulesWizard( } else if (outcome.state === "cancelled") { await vscode.window.showWarningMessage(taskCompletionText({ state: "cancelled" })); } else { - await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? "C++ 模块语言服务配置失败。"); + await vscode.window.showErrorMessage(outcome.steps.at(-1)?.detail ?? t("Configuring the C++ Modules language service failed.")); } } -// 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, -} as const; - -const mcppTomlCompletionProvider: vscode.CompletionItemProvider = { - provideCompletionItems(document, position) { - if (!vscode.workspace.getConfiguration("mcpp", document.uri).get("tomlCompletion", true)) { - return undefined; - } - const lines: string[] = []; - for (let line = 0; line <= position.line; line += 1) { - lines.push(document.lineAt(line).text); - } - return computeMcppTomlCompletions(lines, position.line, position.character).map((suggestion) => { - const item = new vscode.CompletionItem( - suggestion.label, - mcppTomlCompletionKinds[suggestion.kind], - ); - item.detail = suggestion.detail; - if (suggestion.documentation !== undefined) { - item.documentation = new vscode.MarkdownString(suggestion.documentation); - } - if (suggestion.insertSnippet !== undefined) { - item.insertText = new vscode.SnippetString(suggestion.insertSnippet); - } - item.range = new vscode.Range( - position.line, - suggestion.range.startCharacter, - position.line, - suggestion.range.endCharacter, - ); - return item; - }); - }, -}; - export async function activate(extensionContext: vscode.ExtensionContext): Promise { const output = vscode.window.createOutputChannel("mcpp"); + + // Language first: everything below may want to speak to the user. + applyLanguagePreference(); + extensionContext.subscriptions.push(onConfigurationChanged(applyLanguagePreference)); + const bridge = createLanguageServerBridge({ extensionInstalled: (id) => vscode.extensions.getExtension(id) !== undefined, + declaredCommands: declaredCommandsOf, activateExtension: async (id) => { await vscode.extensions.getExtension(id)?.activate(); }, @@ -203,8 +236,8 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi await operation(); } catch (error) { const message = error instanceof Error ? error.message : String(error); - outputText(output, `发生未预期错误:${message}`); - void vscode.window.showErrorMessage(`mcpp:${message}`); + outputText(output, t("Unexpected error: {0}", message)); + void vscode.window.showErrorMessage(t("mcpp: {0}", message)); } }; const invokeLanguageServer = ( @@ -233,7 +266,14 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi if (completion.state === "cancelled") { return; } - const result = await bridge.refreshLanguageServerAfterBuild(); + const mode = read("mcpp.languageService.refreshAfterBuild"); + if (mode === "off") { + lastRefresh = { at: new Date().toISOString(), state: "skipped (mcpp.languageService.refreshAfterBuild = off)" }; + return; + } + const result = + mode === "restart" ? await bridge.restartLanguageServer() : await bridge.refreshLanguageServerAfterBuild(); + lastRefresh = { at: new Date().toISOString(), state: result.state, command: result.command }; resultText(output, result); if (completion.state === "succeeded" && result.state === "completed") { await vscode.window.showInformationMessage(taskCompletionText(completion)); @@ -244,9 +284,145 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi const cliController = new McppCliController({ output, + extensionUri: extensionContext.extensionUri, currentProject: findCurrentProject, afterProjectTask, isTrusted: () => vscode.workspace.isTrusted, + languageServerSummary: () => { + const view = readLanguageServerState(); + return view.available ? view.state : undefined; + }, + }); + + // ── views ──────────────────────────────────────────────────────────────── + // Each view owns its own tree provider and its own commands; the project view + // reads the manifest, the cache view runs mcpp's read-only queries, and the + // C++ Modules view only forwards to mcppls. + const applyViewVisibility = (): void => { + // Literal keys, so the wiring gate can see them. + // `mcpp.views.enabled` is the master switch: with it off every view is hidden. + // Whether VS Code also drops the container's icon from the activity bar is + // up to VS Code itself — an extension cannot mark its own container + // hidden-when-empty (the setting's description says the same). The `when` + // clauses in package.json are negated (`!mcpp.sidebarHidden`) so the default + // state is visible — otherwise activation would never run to set the key back. + void vscode.commands.executeCommand("setContext", "mcpp.sidebarHidden", !read("mcpp.views.enabled")); + void vscode.commands.executeCommand("setContext", "mcpp.views.project", read("mcpp.views.project.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.library", read("mcpp.views.library.show")); + void vscode.commands.executeCommand("setContext", "mcpp.views.cache", read("mcpp.views.cache.show")); + }; + applyViewVisibility(); + extensionContext.subscriptions.push(onConfigurationChanged(applyViewVisibility)); + + // The language service is a block inside the project view, so its state source has + // to exist before the view is registered. + const languageService = registerLanguageServerCommands(extensionContext, { bridge, output }); + registerProjectView(extensionContext, { currentProject: findCurrentProject, languageService }); + // The library ecosystem: a sidebar view plus an editor-area package page. The + // detail page writes `mcpp.toml` through `mcpp add`, so it tells the view to + // refresh — no save event fires for a file written outside the editor. + const openLibraryDetail = createLibraryDetailOpener(extensionContext, { + mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), + projectRoot: () => findCurrentProject()?.root, + output, + isTrusted: () => vscode.workspace.isTrusted, + onDependenciesChanged: () => void library.refresh(), + }); + const library = registerLibraryView(extensionContext, { + projectRoot: () => findCurrentProject()?.root, + mcppExecutable: () => cliController.mcppExecutable(findCurrentProject()), + openDetail: openLibraryDetail, + output, + // The cross-registry search runs `mcpp search`, and `mcpp.path` is + // resource-scoped: an untrusted workspace must not name the program. + isTrusted: () => vscode.workspace.isTrusted, + }); + // `registerLibraryView` registers its own webview provider; these are only the + // entry points a menu or the project view can name. + extensionContext.subscriptions.push( + vscode.commands.registerCommand(LIBRARY_COMMANDS.search, async () => { + await vscode.commands.executeCommand("mcpp.library.focus"); + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.openDetail, async (id: unknown) => { + if (typeof id === "string" && id.length > 0) { + await openLibraryDetail(id); + } + }), + vscode.commands.registerCommand(LIBRARY_COMMANDS.updateIndex, async () => { + if (!vscode.workspace.isTrusted) { + void vscode.window.showWarningMessage( + t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first."), + ); + return; + } + const project = findCurrentProject(); + // A refresh that reaches the network is worth a progress notification, and + // a refresh that succeeds has to *say so*: the command produced no visible + // change at all before, which reads exactly like a broken button. + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Refreshing the mcpp Package Index…") }, + () => + runMcpp(vscode.workspace.isTrusted, cliController.mcppExecutable(project), ["index", "update"], project?.root, { + timeoutMs: 300_000, + }), + ); + await library.refresh(); + if (result === undefined || result.exitCode !== 0) { + void vscode.window.showErrorMessage( + result === undefined + ? t("This workspace is not trusted. mcpp commands may run external programs named by workspace settings; trust the workspace first.") + : t("mcpp index update failed with exit code {0}", result.exitCode), + ); + return; + } + // `library.refresh()` has just filled the snapshot cache, so this second + // look is the count the view is showing, not another read of the index. + const snapshot = await loadSnapshot(project === undefined ? {} : { projectRoot: project.root }); + void vscode.window.showInformationMessage( + t( + "mcpp Package Index refreshed: {0} package(s) from {1} index folder(s).", + snapshot.entries.length, + snapshot.roots.length, + ), + ); + }), + ); + // There is no `mcpp.library.indexFound` context key and no `viewsWelcome` + // entry for the library view. `paneview.ts`'s base `ViewPane` answers + // `shouldShowWelcome() === false`; only the tree-shaped panes override it, so + // welcome content can never render inside a `"type": "webview"` view. The + // library view states its own empty case in the document instead, which is the + // only place VS Code will show it. + + registerCacheView(extensionContext, { + output, + currentProject: findCurrentProject, + mcppExecutable: (project) => cliController.mcppExecutable(project), + isTrusted: () => vscode.workspace.isTrusted, + }); + registerSettingsPanel(extensionContext); + + // Text analysis only: no mcpp process, so both stay available in a restricted + // workspace, which is exactly what the `limited` capability promises. + registerTomlProviders( + extensionContext, + () => read("mcpp.toml.completion"), + () => ({ + syntax: read("mcpp.toml.diagnostics.syntax"), + unknownSection: read("mcpp.toml.diagnostics.unknownSection"), + unknownKey: read("mcpp.toml.diagnostics.unknownKey"), + planeSeparation: read("mcpp.toml.diagnostics.planeSeparation"), + legacyKeys: read("mcpp.toml.diagnostics.legacyKeys"), + }), + () => read("mcpp.toml.diagnostics.enabled"), + () => read("mcpp.toml.hover"), + () => read("mcpp.toml.navigation"), + ); + registerBuildScriptProviders(extensionContext, () => { + if (!read("mcpp.buildScript.intelligence") || !read("mcpp.buildScript.diagnostics")) { + return "off"; + } + return read<"warning" | "info" | "off">("mcpp.buildScript.diagnostics.severity"); }); extensionContext.subscriptions.push( @@ -254,40 +430,35 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi manifestWatcher, inProjectContext, ...cliController.register(), - vscode.languages.registerCompletionItemProvider( - { language: "mcpp-toml" }, - mcppTomlCompletionProvider, - "[", - ), - 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(CLI_COMMANDS.checkModuleSupport, invokeLanguageServer( - () => bridge.restartLanguageServer(), - )), - vscode.commands.registerCommand(CLI_COMMANDS.showModuleGraph, invokeLanguageServer( - () => bridge.showModuleGraph(), - )), - vscode.commands.registerCommand(CLI_COMMANDS.showLanguageServerLogs, invokeLanguageServer( - () => bridge.showLanguageServerLogs(), - )), + + // The ids from 0.4.x keep working. vscode.commands.registerCommand(CLI_COMMANDS.autoConfigureModules, runGuarded(async () => { const project = findCurrentProject(); if (project === undefined) { - await vscode.window.showWarningMessage("当前工作区没有找到 mcpp.toml。"); + await vscode.window.showWarningMessage(t("This workspace has no mcpp.toml.")); return; } await autoConfigureModulesWizard(bridge, cliController, output); })), + vscode.commands.registerCommand(LEGACY_LANGUAGE_SERVER_COMMANDS.refreshCompilationDatabase, runGuarded(async () => { + await cliController.runProjectTask("build"); + })), + + // Environment self-check: one copyable snapshot of everything a bug report needs. + vscode.commands.registerCommand(TOOL_COMMANDS.selfCheck, runGuarded(async () => { + await showSelfCheck(output, cliController, bridge, extensionContext.extension.packageJSON.version); + })), + vscode.workspace.onDidGrantWorkspaceTrust(() => cliController.refreshStatus()), ); + void offerSettingRenames(extensionContext); + void noteUnverifiedLanguageService(output); + + if (read("mcpp.diagnostics.selfCheckOnStartup")) { + void showSelfCheck(output, cliController, bridge, extensionContext.extension.packageJSON.version); + } + extensionContext.subscriptions.push( manifestWatcher.onDidCreate(() => cliController.refreshStatus()), manifestWatcher.onDidChange(() => cliController.refreshStatus()), @@ -295,6 +466,114 @@ export async function activate(extensionContext: vscode.ExtensionContext): Promi ); } +/** + * The 0.4.x keys that were renamed still hold values nothing reads. Offer to move + * them, once per workspace — and leave the old key in place either way, because + * deleting a user's setting is their decision. + */ +async function offerSettingRenames(context: vscode.ExtensionContext): Promise { + const pending = pendingRenames(vscode.window.activeTextEditor?.document.uri); + if (pending.length === 0 || context.workspaceState.get("mcpp.renamesOffered") === true) { + return; + } + + const toUser = t("Move them to my user settings"); + const toWorkspace = t("Move them to this workspace"); + const choice = await vscode.window.showInformationMessage(renamePrompt(pending), toUser, toWorkspace); + if (choice !== toUser && choice !== toWorkspace) { + // Dismissed is not answered: the marker is only written after a choice, so + // the offer comes back next session instead of being lost forever — a + // marker written before the prompt stranded exactly those users (external + // review P2-9). + return; + } + await context.workspaceState.update("mcpp.renamesOffered", true); + const moved = await applyRenames(pending, choice === toUser ? "user" : "workspace"); + void vscode.window.showInformationMessage(t("mcpp: moved {0} setting(s).", moved)); +} + +/** + * A version notice, never a gate. `extensionDependencies` cannot express a version + * range, so a user can legitimately end up with a build of the language service + * older than the one this extension was tested against; the capability probe means + * that still works, but it is worth one sentence in the log. + */ +async function noteUnverifiedLanguageService(output: vscode.OutputChannel): Promise { + const version = languageServerVersion(); + if (version === undefined) { + return; + } + // The same comparator the version matrix uses, against the contract's own + // minimum — the old hand-rolled comparison had dead `< 0` branches and a + // second, drifting copy of the floor (external review P1-3). + const below = compareVersions(version, VERIFIED_MCPPLS_MINIMUM) < 0; + if (below) { + output.appendLine( + t("{0} {1} is older than the verified range ({2}); the capability probe will hide what it cannot do.", MCPPLS_EXTENSION_ID, version, VERIFIED_MCPPLS_RANGE), + ); + } +} + +/** + * The self-check gathers, never guesses: anything it cannot read is reported as + * unknown rather than left blank. It is the first thing to ask for in a bug + * report, and it writes to the `mcpp` channel so it can be copied in one go. + */ +async function showSelfCheck( + output: vscode.OutputChannel, + cliController: McppCliController, + bridge: LanguageServerBridge, + extensionVersion: unknown, +): Promise { + const project = findCurrentProject(); + const executable = cliController.mcppExecutable(project); + const timeoutSeconds = read("mcpp.runtime.timeoutSeconds"); + // The untrusted-workspace promise in the manifest is "no mcpp command runs", + // and `mcpp.path` is resource-scoped, so the probe refuses rather than + // executing a workspace-named program; the report says so in plain words. + const probeResult = await runMcpp(vscode.workspace.isTrusted, executable, ["--protocol-version"], project?.root, { + timeoutMs: timeoutSeconds > 0 ? timeoutSeconds * 1000 : undefined, + }); + const info = probeResult !== undefined && probeResult.exitCode === 0 ? parseProtocolInfo(probeResult.stdout) : undefined; + const state = readLanguageServerState(); + const capabilities = CAPABILITIES.map((entry) => ({ + key: entry.key, + state: bridge.isGone(entry.key) ? "missing" : bridge.isUnconfirmed(entry.key) ? "unconfirmed" : "available", + })); + const text = buildSelfCheckText({ + extensionVersion: typeof extensionVersion === "string" ? extensionVersion : "unknown", + vscodeVersion: vscode.version, + platform: `${process.platform}-${process.arch}`, + languagePreference: languagePreference(), + trusted: vscode.workspace.isTrusted, + workspaceRoots: (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath), + projectRoot: project?.root, + mcppPath: executable, + mcppProbe: + info === undefined + ? undefined + : { version: info.mcppVersion, envelopeMax: info.envelopeMax, kinds: Object.keys(info.kinds) }, + mcppls: { + installed: languageServerInstalled(), + version: languageServerVersion(), + enabled: languageServerEnabled(), + state: describeState(state), + capabilities, + }, + changedSettings: changedSettings(project === undefined ? undefined : vscode.Uri.file(project.root)), + lastRefresh: lastRefresh, + cache: await readCacheSnapshot(), + }); + output.appendLine(""); + output.appendLine("===== mcpp: environment self-check ====="); + output.appendLine(text); + if (probeResult === undefined) { + output.appendLine(t("The workspace is not trusted; mcpp itself was not probed.")); + } + output.appendLine("========================================"); + output.show(true); +} + export function deactivate(): void { // VS Code disposes everything registered in activate(). } diff --git a/src/i18n/t.ts b/src/i18n/t.ts new file mode 100644 index 0000000..f36c3af --- /dev/null +++ b/src/i18n/t.ts @@ -0,0 +1,81 @@ +/** + * Runtime strings, in one place, following the editor's language by default. + * + * The **English text is the key** (the same convention `vscode.l10n` uses), so a + * missing translation degrades to readable English rather than to an identifier. + * `data/i18n/zh-cn.json` carries the translations; `tools/generate-l10n.mjs` + * turns it into `l10n/bundle.l10n.zh-cn.json`, which is what makes `auto` work + * inside VS Code. + * + * `mcpp.ui.language` overrides the choice for **our** strings — runtime messages + * and our webviews. It cannot change the command palette or the Settings UI: + * VS Code resolves `package.nls.*` once at startup from its own locale. That + * limitation is documented in `docs/settings.md` and stated in the panel. + */ + +import type * as vscode from "vscode"; + +import zhCn from "../../data/i18n/zh-cn.json"; +import { format, translate, type Bundle, type LanguagePreference } from "./translate"; + +export type { LanguagePreference } from "./translate"; + +const BUNDLES: Readonly> = { + en: {}, + "zh-cn": zhCn as Bundle, +}; + +let preference: LanguagePreference = "auto"; + +/** The effective preference, for the environment self-check and the panel. */ +export function languagePreference(): LanguagePreference { + return preference; +} + +export function setLanguagePreference(value: LanguagePreference | undefined): void { + preference = value === "en" || value === "zh-cn" ? value : "auto"; +} + +export type Substitution = string | number | boolean; + +/** + * The editor's own `l10n`, loaded on demand. + * + * `t()` needs it only on the `auto` path, so requiring it lazily keeps this + * module importable from **pure** modules: the TOML completion tables are unit + * tested without an editor, and importing them must not fail on a missing + * `vscode`. Outside an editor host there is no locale to consult, and the + * English text — which is the key — is the correct answer: it is exactly what a + * missing translation degrades to. + */ +function editorL10n(): typeof vscode.l10n | undefined { + try { + return (require("vscode") as typeof vscode).l10n; + } catch { + return undefined; + } +} + +/** + * Resolve one runtime string. + * + * - `auto` asks VS Code, which consults `l10n/bundle.l10n..json` and + * falls back to the English text we passed in; + * - a manual preference reads our own bundle, so the escape hatch works even + * when the editor is in a third language. + */ +export function t(english: string, ...args: readonly Substitution[]): string { + if (preference === "auto") { + const l10n = editorL10n(); + return l10n === undefined ? format(english, args) : l10n.t(english, ...args); + } + return translate(preference, english, args, BUNDLES[preference]); +} + +/** Substitute into an already-translated template (webviews, generated HTML). */ +export { format }; + +/** The bundle this build ships, for `tools/l10n-check.mjs`. */ +export function translationBundle(): Bundle { + return BUNDLES["zh-cn"]; +} diff --git a/src/i18n/translate.ts b/src/i18n/translate.ts new file mode 100644 index 0000000..122b706 --- /dev/null +++ b/src/i18n/translate.ts @@ -0,0 +1,55 @@ +/** + * The language-independent half of `src/i18n/t.ts`. Pure: no `vscode`, so the + * resolution rules are unit-testable. + * + * English text is the key; a translation bundle maps it to the target language. + * A missing entry returns the English text, which is why a forgotten translation + * reads as English rather than as an identifier. + */ + +export type LanguagePreference = "auto" | "en" | "zh-cn"; +export type Bundle = Readonly>; + +/** `{0}`-style substitution, the same placeholder syntax `vscode.l10n` uses. */ +export function format(template: string, args: readonly unknown[]): string { + return template.replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number.parseInt(index, 10)]; + return value === undefined ? whole : String(value); + }); +} + +/** + * Resolve one string for an explicit preference. `auto` is not handled here: + * that path belongs to VS Code, which owns the editor's locale. + */ +export function translate( + preference: Exclude, + english: string, + args: readonly unknown[], + bundle: Bundle, +): string { + if (preference === "en") { + return format(english, args); + } + const translated = bundle[english]; + return format(typeof translated === "string" && translated.length > 0 ? translated : english, args); +} + +/** `"zh-cn"` for any Chinese locale VS Code may report; `undefined` otherwise. */ +export function localeFromEditorLanguage(language: string | undefined): "en" | "zh-cn" | undefined { + if (language === undefined) { + return undefined; + } + return language.toLowerCase().startsWith("zh") ? "zh-cn" : "en"; +} + +/** Missing entries for a set of used strings, for `tools/l10n-check.mjs`. */ +export function missingTranslations(used: Iterable, bundle: Bundle): string[] { + const missing: string[] = []; + for (const key of used) { + if (typeof bundle[key] !== "string" || bundle[key].length === 0) { + missing.push(key); + } + } + return missing; +} diff --git a/src/ideWorkflow.ts b/src/ideWorkflow.ts deleted file mode 100644 index 954ae55..0000000 --- a/src/ideWorkflow.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { ProcessResult } from "./process"; - -export interface IdeConfigurationRequest { - projectRoot: string; - compilationDatabasePath: string; - trusted: boolean; - force?: boolean; - databaseValid: () => boolean; - configure: () => Promise; -} - -export type IdeConfigurationOutcome = - | { state: "configured"; compileCommands: string } - | { state: "existing"; compileCommands: string } - | { state: "failed"; compileCommands?: string; exitCode: number } - | { state: "untrusted"; compileCommands?: string }; - -/** - * 决定是否需要执行 mcpp build --configure-only;不在这里读写 VS Code 设置, - * 便于 Extension Host 和纯 Node 测试共享同一套生命周期边界。 - */ -export async function ensureIdeConfigured( - request: IdeConfigurationRequest, -): Promise { - const validBefore = request.databaseValid(); - if (!request.trusted) { - return { - state: "untrusted", - ...(validBefore ? { compileCommands: request.compilationDatabasePath } : {}), - }; - } - if (validBefore && !request.force) { - return { state: "existing", compileCommands: request.compilationDatabasePath }; - } - const result = await request.configure(); - const validAfter = request.databaseValid(); - if (result.exitCode === 0 && validAfter) { - return { state: "configured", compileCommands: request.compilationDatabasePath }; - } - return { - state: "failed", - ...(validAfter ? { compileCommands: request.compilationDatabasePath } : {}), - exitCode: result.exitCode, - }; -} diff --git a/src/languageServer.ts b/src/languageServer.ts deleted file mode 100644 index 509cde8..0000000 --- a/src/languageServer.ts +++ /dev/null @@ -1,80 +0,0 @@ -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/library/addDependency.ts b/src/library/addDependency.ts new file mode 100644 index 0000000..d761851 --- /dev/null +++ b/src/library/addDependency.ts @@ -0,0 +1,142 @@ +/** + * `mcpp add @ [--dev]` — the only way this extension changes a + * project's dependencies. + * + * **`mcpp.toml` is never written here.** `mcpp add` is the official "add a + * dependency to mcpp.toml" command; it owns the file's formatting, comments and + * lockfile. Editing TOML from an extension is how a manifest gets corrupted. + * + * The version is **mandatory**, and that is mcpp's rule, not ours: a bare + * `mcpp add compat.argparse` is refused with + * `error: package version required: \`mcpp add compat.argparse@\` (M2 supports + * exact-version only)`. The caller therefore always passes the version it picked + * from `mcpp xpkg parse --json` (the greatest one for this platform), and this + * module refuses to run without one rather than producing an error the user + * cannot act on. + * + * Failures are values, not exceptions: the exit code and the stderr tail travel + * back to the caller, which shows them. Nothing here throws, so a failed add can + * never leave the detail page half-rendered. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import { clampOutput } from "../util/text"; + +/** `mcpp add` may have to fetch an index or build the cache entry, so it gets a long budget. */ +const ADD_TIMEOUT_MS = 300_000; + +export interface AddDependencyDeps { + /** The configured `mcpp` path, or `mcpp`. */ + mcppExecutable: () => string; + /** The workspace folder holding `mcpp.toml`; `undefined` is a refusal, not a crash. */ + projectRoot: () => string | undefined; + /** Where the command and its output are logged, the same channel the CLI uses. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; an untrusted workspace may not be written to. */ + isTrusted: () => boolean; +} + +export interface AddDependencyRequest { + /** The package id, `ns.name`. */ + id: string; + /** The exact version; `undefined` is refused. */ + version?: string; + /** `--dev`, i.e. a `[dev-dependencies]` entry. */ + dev: boolean; +} + +export interface AddDependencyResult { + ok: boolean; + /** The argv that ran (empty when nothing ran), for the log and the UI. */ + argv: string[]; + exitCode: number; + /** A sentence for the detail page, already localized. */ + message: string; +} + +function failure(message: string, argv: readonly string[] = []): AddDependencyResult { + return { ok: false, argv: [...argv], exitCode: 1, message }; +} + +/** The argv of one add, without running it. The preview in the UI uses the same shape. */ +export function addDependencyArgv(request: { id: string; version: string; dev: boolean }): string[] { + return ["add", `${request.id}@${request.version}`, ...(request.dev ? ["--dev"] : [])]; +} + +/** + * Run one `mcpp add`, with a progress notification, and report what happened. + * + * Every refusal is explicit and happens **before** anything runs: no version, no + * `mcpp.toml`, an untrusted workspace. A run that fails returns the exit code and + * the last lines of stderr, which the detail page prints verbatim — the user gets + * mcpp's own words, not ours. + */ +export async function addDependency( + deps: AddDependencyDeps, + request: AddDependencyRequest, +): Promise { + const version = request.version?.trim() ?? ""; + if (request.id.trim().length === 0 || version.length === 0) { + const message = t("mcpp add needs an exact version: mcpp add {0}@.", request.id); + void vscode.window.showWarningMessage(message); + return failure(message); + } + const root = deps.projectRoot(); + if (root === undefined) { + const message = t("This workspace has no mcpp.toml."); + void vscode.window.showWarningMessage(message); + return failure(message); + } + if (!deps.isTrusted()) { + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + void vscode.window.showWarningMessage(message); + return failure(message); + } + + const argv = addDependencyArgv({ id: request.id, version, dev: request.dev }); + const executable = deps.mcppExecutable(); + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: t("Adding {0}…", request.id) }, + () => + runMcpp(deps.isTrusted(), executable, argv, root, { + timeoutMs: ADD_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + // The seam refused: the guard above usually answers first with a friendlier + // message, so this is the defensive spelling of the same refusal. + const message = t("This workspace is not trusted. mcpp commands that write are disabled until you trust it."); + return failure(message); + } + + try { + deps.output?.appendLine(`\n[${new Date().toISOString()}] mcpp ${argv.join(" ")}`); + deps.output?.appendLine(`$ ${[executable, ...argv].join(" ")}`); + const stdout = clampOutput(result.stdout); + const stderr = clampOutput(result.stderr); + if (stdout.text.trim().length > 0) deps.output?.appendLine(stdout.text.trimEnd()); + if (stderr.text.trim().length > 0) deps.output?.appendLine(stderr.text.trimEnd()); + deps.output?.appendLine(`[exit ${result.exitCode}]`); + } catch { + // The channel can already be gone during shutdown. + } + + if (result.exitCode === 0) { + const message = t("Added {0} to mcpp.toml.", `${request.id}@${version}`); + void vscode.window.showInformationMessage(message); + return { ok: true, argv, exitCode: 0, message }; + } + + const detail = clampOutput(result.stderr.length > 0 ? result.stderr : result.stdout).text.trim(); + const message = + detail.length === 0 + ? t("mcpp add failed with exit code {0}.", result.exitCode) + : t("mcpp add failed with exit code {0}: {1}", result.exitCode, detail); + void vscode.window.showErrorMessage(message); + return { ok: false, argv, exitCode: result.exitCode, message }; +} diff --git a/src/library/detailHtml.ts b/src/library/detailHtml.ts new file mode 100644 index 0000000..1c6d292 --- /dev/null +++ b/src/library/detailHtml.ts @@ -0,0 +1,713 @@ +/** + * The package detail page's document, as a pure function (plan §12.1–§12.5). + * + * The sidebar answers "which package"; this page answers "what is it, how do I + * consume it, and how do I add it". It opens in the **editor area** (a + * `WebviewPanel`, see `src/library/detailPanel.ts`), because a 200 px sidebar + * cannot show a version matrix and a code block. + * + * The same house rules as the other webviews apply: strict CSP, every label from + * `DetailModel.ui`, every interpolation escaped, no inline `style=`, and no state + * that is only a colour. Everything shown is either authoritative + * (`mcpp xpkg parse --json`, `mcpp.toml`) or explicitly attributed (the example + * code comes from the index's own CI-built test projects, and says so). + * + * The example block is tokenized by `indexModel.tokenizeCppLine` — a deliberately + * small highlighter: comments, strings, the preprocessor directive, a short + * keyword list and punctuation. It is not a parser and is not meant to be. + */ + +import { + BADGE_UI, + surfaceLabel, + tokenizeCppLine, + type BadgeKey, + type Surface, +} from "./indexModel"; + +/** One platform's row in the version matrix. */ +export interface DetailVersionGroup { + platform: string; + versions: string[]; + /** The platform this extension host is running on. */ + current: boolean; +} + +/** One declared dependency, as far as it is cheaply known. */ +export interface DetailDependency { + id: string; + version?: string; + /** The version the project's `mcpp.lock` resolved, when it is known. */ + resolved?: string; +} + +/** One window of real example code. */ +export interface DetailSnippet { + file: string; + startLine: number; + lines: string[]; + usageLine: number; +} + +/** The outcome of the last `mcpp add`, shown in place. */ +export interface DetailResult { + /** `pending` is the client's own "I asked the host" line, replaced by the answer. */ + state: "ok" | "error" | "pending"; + message: string; +} + +/** The workspace's own answer about one package. */ +export interface DetailInstalled { + version: string; + /** Declared under `[dev-dependencies]`. */ + dev: boolean; +} + +export interface DetailModel { + /** Everything is already localized by the caller. */ + ui: Record; + id: string; + name: string; + description?: string; + licenses: string[]; + repo?: string; + registry: string; + surface?: Surface; + surfaces: Surface[]; + badges: BadgeKey[]; + /** The version matrix, current platform first. */ + versions: DetailVersionGroup[]; + /** Every version the current platform has, greatest first. */ + currentVersions: string[]; + /** What `mcpp add` would use by default. */ + latest?: string; + /** + * The version this workspace already asks for — `mcpp.toml` first, then + * `mcpp.lock` — so the page can say "you have this one" and offer the switch + * instead of a blind add. Absent when the project does not depend on the + * package, or when the dependency names no version (a path or git entry). + */ + installed?: DetailInstalled; + standard?: string; + dependencies: DetailDependency[]; + includeDirs: string[]; + targets: string[]; + snippets: DetailSnippet[]; + /** The example project the snippets come from, when there is one. */ + exampleProject?: string; + /** + * How a reader brings the package into code (§22): the example project's + * real `import …;` / `#include …` lines when there are any, else the honest + * synthetic form for the surface. Empty when the surface is not importable. + */ + usage: string[]; + /** The index site's package page; omitted for a registry that has no site. */ + indexUrl?: string; + /** `mcpp add {0}@{1}` / with `--dev`, so the preview and the run agree. */ + commandTemplate: string; + commandDevTemplate: string; + /** Set when `mcpp xpkg parse` could not be read; the page still renders. */ + parseNotice?: string; + /** The data source, in plain words, for the footer. */ + dataSource: string; + result?: DetailResult; +} + +export interface DetailAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** The `ui` keys the renderer reads, so the caller and the renderer cannot drift. */ +export const DETAIL_UI = { + htmlLang: "detail.htmlLang", + title: "detail.title", + overview: "detail.overview", + license: "detail.license", + repo: "detail.repo", + openRepo: "detail.openRepo", + registry: "detail.registry", + surface: "detail.surface", + surfaceExternal: "detail.surface.external", + standard: "detail.standard", + versions: "detail.versions", + versionsAll: "detail.versions.all", + versionsCurrent: "detail.versions.current", + versionsNone: "detail.versions.none", + versionsPick: "detail.versions.pick", + dependencies: "detail.dependencies", + dependenciesNone: "detail.dependencies.none", + dependenciesHint: "detail.dependencies.hint", + resolved: "detail.resolved", + code: "detail.code", + codeNone: "detail.code.none", + codeSource: "detail.code.source", + codeProject: "detail.code.project", + add: "detail.add", + addDev: "detail.addDev", + switchTo: "detail.switchTo", + alreadyAdded: "detail.alreadyAdded", + installed: "detail.installed", + opening: "detail.opening", + addLatest: "detail.addLatest", + addNoVersion: "detail.add.noVersion", + command: "detail.command", + usage: "detail.usage", + copy: "detail.copy", + copied: "detail.copied", + copying: "detail.copying", + indexLink: "detail.indexLink", + badgeExamples: BADGE_UI.examples.key, + badgeCn: BADGE_UI.cn.key, + badgeOpenkalEcosystem: BADGE_UI["openkal-ecosystem"].key, + badgeOpenkalCompat: BADGE_UI["openkal-compat"].key, + badgeOpenkalPosix: BADGE_UI["openkal-posix"].key, + badgeOpenkalPlatform: BADGE_UI["openkal-platform"].key, + targets: "detail.targets", + includeDirs: "detail.includeDirs", +} as const; + +const BADGE_KEY_UI: Readonly> = { + examples: DETAIL_UI.badgeExamples, + cn: DETAIL_UI.badgeCn, + "openkal-ecosystem": DETAIL_UI.badgeOpenkalEcosystem, + "openkal-compat": DETAIL_UI.badgeOpenkalCompat, + "openkal-posix": DETAIL_UI.badgeOpenkalPosix, + "openkal-platform": DETAIL_UI.badgeOpenkalPlatform, +}; + +/** + * What the page says to the host. + * + * There is no `ready`: the document is already the whole model, and a page that + * announces its own load only invites the host to render it again — which is + * exactly the reload loop the *library sidebar* shipped with (see the note in + * `libraryHtml.ts`). This host happened to answer `ready` with an empty `return`, + * so the trap never fired here; it is gone all the same. + */ +export type DetailMessage = + | { type: "add"; version: string; dev: boolean } + | { type: "openUrl"; url: string } + | { type: "copy"; text: string }; + +export type UiLabel = (key: string) => string; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function escapeCsp(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function attribute(name: string, value: string | number | undefined): string { + return value === undefined ? "" : ` ${name}="${escapeHtml(String(value))}"`; +} + +function flag(name: string, on: boolean): string { + return on ? ` ${name}` : ""; +} + +/** `{0}`-style substitution into an already-localized ui string. */ +function fill(label: UiLabel, key: string, args: readonly (string | number)[]): string { + return label(key).replace(/\{(\d+)\}/g, (whole, index: string) => { + const value = args[Number(index)]; + return value === undefined ? whole : String(value); + }); +} + +function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string[] { + return badges.map((badge) => label(BADGE_KEY_UI[badge])); +} + +/** One source line, tokenized. The renderer escapes every token. */ +function renderLine(line: string, usage: boolean): string { + if (line.length === 0) { + return "\n"; + } + const tokens = tokenizeCppLine(line) + .map((token) => `${escapeHtml(token.text)}`) + .join(""); + return `${tokens}\n`; +} + +function renderSnippets(model: DetailModel, label: UiLabel): string { + if (model.snippets.length === 0) { + return `

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

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

${escapeHtml(caption)}

`, + ].join("\n"); + }); + return blocks.join("\n"); +} + +/** + * The version matrix, as the page's **selector**: each version is a button that + * aims the command at the top of the page at itself. + * + * A `${escapeHtml(label(DETAIL_UI.addDev))}`, + ...(model.repo === undefined ? [] : [link(model.repo, label(DETAIL_UI.openRepo))]), + ...(model.indexUrl === undefined ? [] : [link(model.indexUrl, label(DETAIL_UI.indexLink))]), + ` `, + // The copy button sits beside the command, not inside it: `command.textContent` + // is what a click copies, and a button inside the paragraph would be copied too. + `
`, + `

${escapeHtml(command)}

`, + ` `, + `
`, + disabled + ? `

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

` + : `

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

`, + ...usage, + `

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

`, + `
`, + ] + .filter((line) => line.length > 0) + .join("\n"); +} + +/** + * The client. It posts two things — `add` and `openUrl` — and applies + * the host's `{type:"result"}` in place, so running `mcpp add` never rebuilds the + * document and never resets the version the reader picked. + */ +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var devInput = document.getElementById("detail-dev"); + var command = document.getElementById("detail-command"); + var result = document.getElementById("detail-result"); + var addButton = document.getElementById("detail-add"); + var version = command ? (command.getAttribute("data-selected-version") || "") : ""; + var labels = (state && state.labels) || {}; + var installed = state && state.installed ? state.installed : ""; + + function post(message) { + if (api) { api.postMessage(message); } + } + + function fillTemplate(template, values) { + var out = template; + for (var index = 0; index < values.length; index += 1) { + out = out.split("{" + index + "}").join(String(values[index])); + } + return out; + } + + function updateCommand() { + if (!command) { return; } + var dev = devInput ? devInput.checked === true : false; + var template = dev + ? (command.getAttribute("data-template-dev") || "") + : (command.getAttribute("data-template") || ""); + command.setAttribute("data-selected-version", version); + command.textContent = fillTemplate(template, [state.id, version || "?"]); + } + + /** + * The button's label and enabled state follow the selection: adding a new + * package, switching the version of one that is already there, or nothing to do + * because this is the version the manifest already asks for. The inert state + * carries the data-installed attribute, which is what paints it the thinned + * green of a thing already done. + */ + function updateButton() { + if (!addButton) { return; } + if (!version) { + addButton.textContent = labels.add || ""; + addButton.disabled = true; + addButton.removeAttribute("data-installed"); + return; + } + if (installed && version === installed) { + addButton.textContent = labels.alreadyAdded || ""; + addButton.disabled = true; + addButton.setAttribute("data-installed", ""); + return; + } + addButton.textContent = installed + ? (labels.switchTo || "{0}").split("{0}").join(version) + : (labels.add || ""); + addButton.disabled = false; + addButton.removeAttribute("data-installed"); + } + + /** One version button is the selection; the rest are alternatives. */ + function selectVersion(next) { + version = next; + var buttons = document.querySelectorAll("[data-version]"); + for (var index = 0; index < buttons.length; index += 1) { + var button = buttons[index]; + if (button.getAttribute("data-version") === next) { + button.setAttribute("data-selected", ""); + } else { + button.removeAttribute("data-selected"); + } + } + updateCommand(); + updateButton(); + } + + function showResult(payload) { + if (!result) { return; } + result.hidden = false; + result.setAttribute( + "data-state", + payload.state === "ok" ? "ok" : payload.state === "pending" ? "pending" : "error", + ); + result.textContent = payload.message || ""; + } + + if (devInput) { devInput.addEventListener("change", updateCommand); } + + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var link = target.closest("[data-open-url]"); + if (link) { + event.preventDefault(); + var url = link.getAttribute("data-open-url") || ""; + // Say something the instant the click lands: the host's answer replaces + // this line, and if it never arrives the reader can see that too. + showResult({ state: "pending", message: (labels.opening || "{0}").split("{0}").join(url) }); + post({ type: "openUrl", url: url }); + return; + } + var copy = target.closest("[data-copy-command], [data-copy]"); + if (copy) { + // The command's own text is what gets copied — the button beside it, not + // inside it, is why the command's textContent is exactly the command. + var text = copy.hasAttribute("data-copy-command") + ? (command ? String(command.textContent || "") : "") + : String(copy.getAttribute("data-copy") || ""); + if (!text) { return; } + showResult({ state: "pending", message: labels.copying || "" }); + post({ type: "copy", text: text }); + return; + } + var pick = target.closest("[data-version]"); + if (pick) { + selectVersion(pick.getAttribute("data-version") || ""); + return; + } + var add = target.closest("#detail-add"); + if (add && !add.disabled) { + if (!version) { return; } + post({ type: "add", version: version, dev: devInput ? devInput.checked === true : false }); + } + }); + + /** Move the "added" marker onto the version the project now asks for. */ + function markInstalled(next) { + installed = next; + var marks = document.querySelectorAll(".detail-installed"); + for (var index = 0; index < marks.length; index += 1) { marks[index].parentNode.removeChild(marks[index]); } + var buttons = document.querySelectorAll("[data-version]"); + for (var index2 = 0; index2 < buttons.length; index2 += 1) { + var button = buttons[index2]; + if (button.getAttribute("data-version") !== next) { + button.removeAttribute("data-installed"); + continue; + } + button.setAttribute("data-installed", ""); + var mark = document.createElement("span"); + mark.className = "detail-installed"; + mark.textContent = labels.installed || ""; + button.parentNode.insertBefore(mark, button.nextSibling); + } + updateButton(); + } + + window.addEventListener("message", function (event) { + var data = event.data; + if (data && data.type === "result" && data.result) { showResult(data.result); } + if (data && data.added && data.added.version) { markInstalled(data.added.version); } + }); + + updateCommand(); + updateButton(); +})();`; +} + +/** The little state the client needs; the page is already rendered from the rest. */ +function clientState(model: DetailModel): Record { + return { + id: model.id, + ...(model.latest === undefined ? {} : { latest: model.latest }), + // The three labels the button can wear, and the version the project already + // has: the client re-decides the label every time the selection changes, so + // the host cannot be the only one that knows what the button means. The + // `installed` word belongs here too — `markInstalled()` writes it next to + // the version button after a successful add, and an empty string there is + // a marker nobody can read (§22). + labels: { + add: model.ui[DETAIL_UI.add] ?? "", + switchTo: model.ui[DETAIL_UI.switchTo] ?? "", + alreadyAdded: model.ui[DETAIL_UI.alreadyAdded] ?? "", + opening: model.ui[DETAIL_UI.opening] ?? "", + installed: model.ui[DETAIL_UI.installed] ?? "", + copying: model.ui[DETAIL_UI.copying] ?? "", + }, + ...(model.installed === undefined ? {} : { installed: model.installed.version }), + }; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const title = fill(label, DETAIL_UI.title, [model.id]); + const currentPlatform = model.versions.find((group) => group.current)?.platform; + const versionsHeading = + currentPlatform === undefined ? label(DETAIL_UI.versionsAll) : fill(label, DETAIL_UI.versions, [currentPlatform]); + return ` + + + + + + +${escapeHtml(title)} + + +
+

${escapeHtml(model.id)}

+ ${model.description === undefined ? "" : `

${escapeHtml(model.description)}

`} + ${renderMeta(model, label)} +
+
+ ${renderActions(model, label)} +
+

${escapeHtml(versionsHeading)}

+ ${renderVersions(model, label)} +
+
+

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

+ ${renderDependencies(model, label)} +
+
+

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

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

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

`} +
+
+

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

+ ${renderExtras(model, label)} + ${model.parseNotice === undefined ? "" : `

${escapeHtml(model.parseNotice)}

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

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

    `; + } + return [ + `
      `, + model.rows.map((row) => renderRow(row, label)).join("\n"), + `
    `, + ``, + ].join("\n"); +} + +/** + * The client. Dependency-free, no template literals of its own, and it only + * writes through `textContent`/`setAttribute`. + * + * Search is local: a row carries the text the search box matches against as a + * `data-haystack` attribute, and the query decides its `hidden` flag. The host is + * told the query, and answers with a whole new document only when the data + * behind it changed. + */ +function clientScript(initialModel: string): string { + return `(function () { + "use strict"; + var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; + var state = ${initialModel}; + var searchInput = document.getElementById("library-search"); + var networkInput = document.getElementById("library-network"); + var list = document.getElementById("library-list"); + var empty = document.getElementById("library-empty"); + var countElement = document.getElementById("library-count"); + + function post(message) { + if (api) { api.postMessage(message); } + } + + // Every whitespace-separated word has to appear in the row's haystack, which + // the host builds from the id, the name, the description and the usage labels. + function rowMatches(row, query) { + if (query.length === 0) { return true; } + var haystack = row.getAttribute("data-haystack") || ""; + var parts = query.split(/\\s+/); + for (var index = 0; index < parts.length; index += 1) { + if (parts[index].length > 0 && haystack.indexOf(parts[index]) < 0) { return false; } + } + return true; + } + + function apply() { + var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; + var any = false; + var visible = 0; + if (list) { + var rows = list.querySelectorAll("[data-row]"); + for (var index = 0; index < rows.length; index += 1) { + var shown = rowMatches(rows[index], query); + rows[index].parentNode.hidden = !shown; + if (shown) { any = true; visible += 1; } + } + list.hidden = !any; + } + if (empty) { empty.hidden = any || !list; } + if (countElement) { + var template = state && state.countTemplate ? state.countTemplate : ""; + var total = state && state.total ? state.total : 0; + countElement.textContent = template.split("{0}").join(String(visible)).split("{1}").join(String(total)); + } + } + + if (searchInput) { + searchInput.addEventListener("input", function () { + apply(); + post({ type: "search", query: searchInput.value }); + }); + } + if (networkInput) { + networkInput.addEventListener("change", function () { + post({ type: "networkSearch", enabled: networkInput.checked === true }); + }); + } + document.addEventListener("click", function (event) { + var target = event.target; + if (!target || typeof target.closest !== "function") { return; } + var refresh = target.closest("[data-action]"); + if (refresh && refresh.getAttribute("data-action") === "refresh") { + post({ type: "refresh" }); + return; + } + var row = target.closest("[data-row]"); + if (row) { post({ type: "open", id: row.getAttribute("data-row") }); } + }); + apply(); +})();`; +} + +/** + * The little state the client needs, not the whole model. + * + * The rows are already in the document and the badges are already resolved, so + * embedding the model again would double the page for nothing. `<` is escaped in + * `embedJson`, so even this cannot close the script element. + */ +function clientState(model: LibraryModel): Record { + return { + countTemplate: model.countTemplate, + total: model.total, + query: model.query, + }; +} + +/** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ +function embedJson(value: unknown): string { + return JSON.stringify(value) + .replace(/ model.ui[key] ?? key; + const csp = `default-src 'none'; style-src ${assets.cspSource}; script-src 'nonce-${assets.nonce}'; img-src ${assets.cspSource}`; + const visible = model.rows.filter((row) => rowMatchesClient(model, row)).length; + const count = fill(label, LIBRARY_UI.count, [visible, model.total]); + return ` + + + + + + +${escapeHtml(label(LIBRARY_UI.title))} + + +
    +

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

    +

    ${escapeHtml(count)}

    +
    +${renderToolbar(model, label)} +
    +${renderList(model, label)} +
    +
    + ${escapeHtml(model.dataSource)} + +
    + + + +`; +} + +/** + * The rows the initial query keeps. The live search is the client's + * `rowMatches`; this mirrors it for the header count, so the count the reader + * first sees matches the list beneath it. + */ +function rowMatchesClient(model: LibraryModel, row: LibraryRow): boolean { + const query = model.query.trim().toLowerCase(); + if (query.length === 0) { + return true; + } + return query.split(/\s+/).every((part) => part.length === 0 || row.haystack.includes(part)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +/** + * Decode one `postMessage` payload. Webview input is untrusted: anything that is + * not exactly one of the four shapes is dropped, and the returned object is + * rebuilt so foreign fields never travel further. + */ +export function decodeLibraryMessage(raw: unknown): LibraryMessage | undefined { + if (!isRecord(raw)) { + return undefined; + } + switch (raw.type) { + case "refresh": + return { type: "refresh" }; + case "search": + return typeof raw.query === "string" ? { type: "search", query: raw.query } : undefined; + case "networkSearch": + return typeof raw.enabled === "boolean" ? { type: "networkSearch", enabled: raw.enabled } : undefined; + case "open": + return nonEmptyString(raw.id) ? { type: "open", id: raw.id } : undefined; + default: + return undefined; + } +} diff --git a/src/library/libraryView.ts b/src/library/libraryView.ts new file mode 100644 index 0000000..b156fcb --- /dev/null +++ b/src/library/libraryView.ts @@ -0,0 +1,462 @@ +/** + * The library sidebar: the `mcpp.library` view. + * + * The *model* is `src/library/libraryHtml.ts` plus `src/library/indexModel.ts`, + * both pure and unit-tested; the *reading* is `src/library/indexLocator.ts`, + * which caches per index revision. This file is the seam: it owns the webview's + * lifetime, the `t()` literals, the settings and the messages. + * + * Three deliberate boundaries: + * + * - **The list never spawns a process per package and never touches the + * network.** The descriptors are read as text; `mcpp xpkg parse --json` runs + * only from the detail page. The one exception is `mcpp search`, behind + * `mcpp.library.networkSearch`, which is **off by default** and whose output + * reader is the tolerant best-effort one in `indexModel.parseSearchOutput` + * (plan §10.2; upstream request U.8). + * - **One render, applied in place.** Filtering happens in the document; the + * host re-renders only when the data changed. A `webview.html` assignment + * would otherwise steal the caret on every keystroke. + * - **A failure still renders.** No index, no descriptors or a failed read all + * produce a document with a sentence in it, never a blank sidebar. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { read, write } from "../config/access"; +import { languagePreference, t } from "../i18n/t"; +import { localeFromEditorLanguage } from "../i18n/translate"; +import { WebviewDocument } from "../webview/document"; +import { badgesOf, searchText, parseSearchOutput, type LibraryEntry } from "./indexModel"; +import { loadSnapshot, type IndexRoot, type LibrarySnapshot } from "./indexLocator"; +import { + LIBRARY_UI, + decodeLibraryMessage, + renderLibraryHtml, + type LibraryModel, + type LibraryRow, +} from "./libraryHtml"; + +/** The view id `package.json` declares. */ +export const LIBRARY_VIEW_ID = "mcpp.library"; + +const MEDIA_DIRECTORY = "media"; +const STYLESHEET = "library.css"; + +/** The delay before a cross-registry search runs: one per pause, not per keystroke. */ +const SEARCH_DEBOUNCE_MS = 400; +const SEARCH_TIMEOUT_MS = 20_000; + +export interface LibraryViewDeps { + /** + * The workspace folder whose `mcpp.toml` decides which packages are "already + * declared". `undefined` is a normal state: the list then simply has none. + */ + projectRoot: () => string | undefined; + /** The configured executable, for the cross-registry search only. */ + mcppExecutable: () => string; + /** Opens the editor-area detail page for one package id. */ + openDetail: (id: string) => Promise | void; + /** Where `mcpp search` is logged; the same channel the other commands use. */ + output?: vscode.OutputChannel; + /** `vscode.workspace.isTrusted`; `mcpp search` never runs without it. */ + isTrusted: () => boolean; +} + +/** + * Register the view: the provider, its settings/save listeners and the + * `registerWebviewViewProvider` call that makes VS Code ask it for a document. + * + * That last call is the whole point — without it the view has no document at + * all, and every `refresh()` below is a no-op against `this.view === undefined`. + * It lives here, next to the provider it hands over, exactly like + * `registerCachePanel`; `extension.ts` only wires the two entry-point commands. + * + * `retainContextWhenHidden` is not supported by a webview view, which is why the + * render is idempotent: the document is rebuilt from the snapshot every time the + * view becomes visible again. + */ +export function registerLibraryView(context: vscode.ExtensionContext, deps: LibraryViewDeps): LibraryViewHandle { + const provider = new LibraryViewProvider(context, deps); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider(LIBRARY_VIEW_ID, provider, { + webviewOptions: { retainContextWhenHidden: false }, + }), + provider, + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("mcpp.library")) { + void provider.refresh(); + } + }), + // `mcpp.toml` decides the "已添加" state, so saving it re-renders the list. + vscode.workspace.onDidSaveTextDocument((document) => { + if (document.uri.path.endsWith("/mcpp.toml") || document.uri.path.endsWith("\\mcpp.toml")) { + void provider.refresh(); + } + }), + ); + return { + refresh: () => provider.refresh(), + dispose: () => provider.dispose(), + }; +} + +/** + * What `extension.ts` keeps a reference to. + * + * `refresh()` is how a dependency change made **outside the editor** — `mcpp add` + * run by the detail page, or `mcpp index update` — reaches the list, because no + * document is saved in that path and therefore no save event fires. The provider + * itself is not handed back: `registerLibraryView` has already registered it, + * and a caller that wants to reach into the view would be reaching past the two + * methods below. + */ +export interface LibraryViewHandle { + refresh(): Promise; + dispose(): void; +} + +class LibraryViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { + private view: vscode.WebviewView | undefined; + private snapshot: LibrarySnapshot | undefined; + private query = ""; + /** Cross-registry hits for the current query, from `mcpp search`. */ + private extra: LibraryRow[] = []; + private searchNote: string | undefined; + private searchTimer: NodeJS.Timeout | undefined; + private rendering: Promise | undefined; + /** + * The document on screen and the one CSP nonce it may be built with. Both + * live in `WebviewDocument`: the nonce is per view (a nonce per render would + * make every render a *different* document and defeat the comparison, which + * is how the reload loop started), and `paint()` refuses to re-assign a + * document that has not changed, because an assignment reloads the view. + */ + private readonly webviewDocument = new WebviewDocument(STYLESHEET); + + public constructor( + private readonly context: vscode.ExtensionContext, + private readonly deps: LibraryViewDeps, + ) {} + + public dispose(): void { + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + this.searchTimer = undefined; + } + } + + public resolveWebviewView(view: vscode.WebviewView): void { + this.view = view; + // A resolved view is a fresh, empty webview; see the note in `onDidDispose`. + this.webviewDocument.invalidate(); + view.webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY)], + }; + view.webview.onDidReceiveMessage((raw: unknown) => { + void this.handle(raw); + }); + view.onDidDispose(() => { + if (this.view === view) { + this.view = undefined; + // The next `resolveWebviewView` gets a brand-new, empty webview: what was + // pushed to the old one says nothing about it, so the comparison in + // `paint()` must start from nothing again. + this.webviewDocument.invalidate(); + } + }); + // First paint from the cached snapshot, so the view is never blank while the + // index is being read. + this.paint(); + void this.refresh(); + } + + /** Re-read the index (from the cache when the revision is unchanged) and paint. */ + public refresh(): Promise { + if (this.rendering !== undefined) { + return this.rendering; + } + const projectRoot = this.deps.projectRoot(); + this.rendering = loadSnapshot(projectRoot === undefined ? {} : { projectRoot }) + .then((snapshot) => { + this.snapshot = snapshot; + }) + .catch((error: unknown) => { + this.deps.output?.appendLine(`mcpp library: ${error instanceof Error ? error.message : String(error)}`); + this.snapshot = { entries: [], roots: [], revision: "error" }; + }) + .then(() => { + this.rendering = undefined; + this.paint(); + }); + return this.rendering; + } + + private async handle(raw: unknown): Promise { + const message = decodeLibraryMessage(raw); + if (message === undefined) { + return; + } + switch (message.type) { + case "refresh": + await this.refresh(); + return; + case "search": + this.query = message.query; + this.scheduleSearch(); + return; + case "networkSearch": + await this.setNetworkSearch(message.enabled); + return; + case "open": + await this.deps.openDetail(message.id); + return; + default: + return; + } + } + + /** `mcpp.library.networkSearch`: persisted, then honoured on the next query. */ + private async setNetworkSearch(enabled: boolean): Promise { + this.extra = []; + this.searchNote = undefined; + try { + await write("mcpp.library.networkSearch", enabled, "user"); + } catch (error) { + this.deps.output?.appendLine( + `mcpp library: could not write mcpp.library.networkSearch: ${error instanceof Error ? error.message : String(error)}`, + ); + this.searchNote = t("The setting could not be written; the toggle was not saved."); + } + if (enabled) { + await this.runCrossRegistrySearch(); + } else { + this.paint(); + } + } + + private scheduleSearch(): void { + if (this.searchTimer !== undefined) { + clearTimeout(this.searchTimer); + } + if (!this.networkSearch()) { + this.extra = []; + this.searchNote = undefined; + return; + } + this.searchTimer = setTimeout(() => { + this.searchTimer = undefined; + void this.runCrossRegistrySearch(); + }, SEARCH_DEBOUNCE_MS); + } + + private networkSearch(): boolean { + return read("mcpp.library.networkSearch") === true; + } + + /** + * The cross-registry tier: `mcpp search `. + * + * Best effort by design (§10.2): it may use the network, its output is human + * text (upstream request U.8 asks for `--format json`), and any failure keeps + * the local results and *says so* rather than failing silently. Only ids the + * local index does not already have are added. + */ + private async runCrossRegistrySearch(): Promise { + const query = this.query.trim(); + if (query.length < 2) { + this.extra = []; + this.searchNote = undefined; + this.paint(); + return; + } + // `mcpp search` runs the configured executable and `mcpp.path` is + // resource-scoped: an untrusted workspace must not name the program, so the + // tier refuses and says so instead of silently showing nothing extra. + const result = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window, title: t("Searching all registries…") }, + () => + runMcpp(this.deps.isTrusted(), this.deps.mcppExecutable(), ["search", query], this.deps.projectRoot(), { + timeoutMs: SEARCH_TIMEOUT_MS, + maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), + }), + ); + if (result === undefined) { + this.extra = []; + this.searchNote = t("The workspace is not trusted; the other registries were not searched."); + this.paint(); + return; + } + if (result.exitCode !== 0) { + this.extra = []; + this.searchNote = t( + "Searching all registries failed (exit {0}); showing the local index only.", + result.exitCode, + ); + this.paint(); + return; + } + const known = new Set((this.snapshot?.entries ?? []).map((entry) => entry.id)); + this.extra = parseSearchOutput(result.stdout) + .filter((hit) => !known.has(hit.id)) + .map((hit) => ({ + id: hit.id, + name: hit.id.includes(".") ? hit.id.slice(hit.id.indexOf(".") + 1) : hit.id, + ...(hit.id.includes(".") ? { namespace: hit.id.slice(0, hit.id.indexOf(".")) } : {}), + ...(hit.version === undefined ? {} : { version: hit.version }), + surfaces: [], + badges: [], + haystack: `${hit.id} ${hit.description ?? ""}`.toLowerCase(), + ...(hit.description === undefined ? {} : { description: hit.description }), + added: false, + unreadable: false, + crossRegistry: true, + })); + this.searchNote = + this.extra.length === 0 + ? t("No further packages were found in the other registries.") + : t("{0} further package(s) came from the other registries.", this.extra.length); + this.paint(); + } + + private labels(): Record { + return { + [LIBRARY_UI.htmlLang]: htmlLanguage(), + [LIBRARY_UI.title]: t("Library"), + [LIBRARY_UI.search]: t("Search packages"), + [LIBRARY_UI.networkSearch]: t("Search all registries"), + [LIBRARY_UI.networkSearchHint]: t( + "Off by default: this tier runs mcpp search, which may use the network and is read from human output on a best-effort basis. Failures fall back to the local index.", + ), + [LIBRARY_UI.versionLatest]: t("latest {0}"), + [LIBRARY_UI.surfaceExternal]: t("upstream mcpp.toml"), + [LIBRARY_UI.badgeExamples]: t("✓ Has examples"), + [LIBRARY_UI.badgeCn]: t("China mirror"), + [LIBRARY_UI.badgeOpenkalEcosystem]: t("openkal-ecosystem"), + [LIBRARY_UI.badgeOpenkalCompat]: t("openkal-compat"), + [LIBRARY_UI.badgeOpenkalPosix]: t("POSIX environment"), + [LIBRARY_UI.badgeOpenkalPlatform]: t("uses platform interfaces"), + [LIBRARY_UI.added]: t("Added"), + [LIBRARY_UI.unreadable]: t("Descriptor not readable"), + [LIBRARY_UI.noResults]: t("No package matches this search."), + [LIBRARY_UI.noIndex]: t( + "No mcpp index was found. Run mcpp: Refresh the mcpp Package Index once, or set mcpp.library.indexPath to an index checkout.", + ), + [LIBRARY_UI.dataSource]: t("Data source"), + [LIBRARY_UI.count]: t("{0} of {1} packages"), + [LIBRARY_UI.open]: t("Open the detail page for {0}"), + [LIBRARY_UI.crossRegistry]: t("other registry"), + [LIBRARY_UI.refresh]: t("Refresh"), + }; + } + + /** + * Put the current model on screen — but only when it says something new. + * + * `webview.html = …` reloads the document, so pushing an identical one would + * throw away the scroll position and the half-typed query for nothing. With + * a per-view nonce, "identical" means identical: the same model renders byte + * for byte the same document, and a render that produced it is dropped in + * `WebviewDocument.paint()`. + */ + private paint(): void { + const view = this.view; + if (view === undefined) { + return; + } + const document = renderLibraryHtml( + this.model(), + this.webviewDocument.assets(vscode.Uri.joinPath(this.context.extensionUri, MEDIA_DIRECTORY), view.webview), + ); + this.webviewDocument.paint(view.webview, document); + } + + private model(): LibraryModel { + const snapshot = this.snapshot; + const ui = this.labels(); + if (snapshot === undefined) { + return this.emptyModel(ui, t("Reading the index…"), t("Reading the index…")); + } + const entries = snapshot.entries; + const rows = [...entries.map(rowOf), ...this.extra]; + const notice = + snapshot.roots.length === 0 + ? ui[LIBRARY_UI.noIndex] + : entries.length === 0 + ? t("The index folders contain no descriptors.") + : ui[LIBRARY_UI.noResults]; + return { + ui, + rows, + query: this.query, + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource: this.dataSource(snapshot.roots, entries.length), + countTemplate: ui[LIBRARY_UI.count], + total: rows.length, + ...(rows.length === 0 ? { notice } : {}), + }; + } + + private emptyModel(ui: Record, notice: string, dataSource: string): LibraryModel { + return { + ui, + rows: [], + query: "", + networkSearch: this.networkSearch(), + networkSearchSetting: "mcpp.library.networkSearch", + dataSource, + countTemplate: ui[LIBRARY_UI.count], + total: 0, + notice, + }; + } + + /** The footer, in plain words: where the rows came from and whether it is offline. */ + private dataSource(roots: readonly IndexRoot[], total: number): string { + const registries = roots.map((root) => root.registry).join(", "); + const base = + roots.length === 0 + ? t("No local index was read.") + : t( + "{0} descriptor(s) from {1} local index folder(s): {2}. Read offline.", + total, + roots.length, + registries, + ); + const sentence = `${t("Data source")}: ${base}`; + return this.searchNote === undefined ? sentence : `${sentence} ${this.searchNote}`; + } +} + +/** One entry as the row the document renders. */ +function rowOf(entry: LibraryEntry): LibraryRow { + return { + id: entry.id, + haystack: searchText(entry), + ...(entry.namespace === undefined ? {} : { namespace: entry.namespace }), + name: entry.name, + ...(entry.version === undefined ? {} : { version: entry.version }), + ...(entry.surface === undefined ? {} : { surface: entry.surface }), + surfaces: entry.surfaces, + badges: badgesOf(entry), + ...(entry.description === undefined ? {} : { description: entry.description }), + added: entry.added, + unreadable: entry.unreadable === true, + }; +} + + +/** `auto` is the editor's own language; `en`/`zh-cn` are the manual override. */ +function htmlLanguage(): string { + const preference = languagePreference(); + if (preference === "zh-cn") { + return "zh-cn"; + } + if (preference === "en") { + return "en"; + } + return localeFromEditorLanguage(vscode.env.language) === "zh-cn" ? "zh-cn" : "en"; +} diff --git a/src/library/xpkg.ts b/src/library/xpkg.ts new file mode 100644 index 0000000..f0355e6 --- /dev/null +++ b/src/library/xpkg.ts @@ -0,0 +1,434 @@ +/** + * `mcpp xpkg parse --json`, decoded — as a pure module. + * + * The index's Lua descriptors are read cheaply and tolerantly by + * `src/library/indexModel.ts` (a few single-line catalog fields, no process). + * Everything **authoritative** comes from here instead: `mcpp`'s own resolver + * is the only thing that knows which platform a version belongs to, whether a + * descriptor is Form A, and what a target's `kind` is. That command is run on + * demand — when a row is expanded or the detail page opens — never once per + * package (233 spawns would freeze the view). + * + * The document is the flat shape `mcpp` prints, verified against + * `mcpp 2026.9.30.2` and all 239 `mcpplibs` descriptors: + * + * ```json + * {"namespace":"compat","name":"argparse", + * "versions":{"linux":["3.2"],"macosx":["3.2"],"windows":["3.2"]}, + * "standard":"c++23","import_std":false, + * "sources":["mcpp_generated/argparse_anchor.c"], + * "include_dirs":["<archive>/include"], + * "generated_files":[{"path":"…","bytes":52}], + * "generated_contents":{"mcpp_generated/argparse_anchor.c":"…"}, + * "targets":["argparse"],"unknown_keys":[]} + * ``` + * + * Two facts this module is built around, both measured rather than assumed: + * + * - a **Form A** descriptor answers with only `namespace`, `name`, `versions` + * and `form: "A"` — it deliberately carries no build information, because the + * upstream archive ships its own manifest. That is the index's `external` + * surface ("上游 mcpp.toml"), and it is the only honest answer offline. + * - `targets` is an **array of names** in every descriptor here (239/239). The + * site generator's `targets` *kinds* come from the upstream manifest, which + * `xpkg parse` does not expose. So a `kind` is honoured when the JSON happens + * to carry one and is not invented when it does not. + */ + +/** + * The consumer-facing usage label, exactly the index site's `SURFACES` table + * (`.xpkgindex/plugins/mcpp.py`): what a reader does with the package, not how + * it is built. An earlier design invented shape letters here; those were wrong. + */ +export type Surface = "module" | "header" | "tool" | "external"; + +/** `SURFACES` order, which is also the order the chips and badges are shown in. */ +export const SURFACES: readonly Surface[] = ["module", "header", "tool", "external"]; + +/** + * The label and the example line for each surface. + * + * `import` / `#include` / `tool` are code, not prose — the site generator emits + * them untranslated in every language, so they are constants here. Only + * `external` is a sentence, and it is looked up through `uiKey` so the caller + * can localize it. `usage` is the muted placeholder the site shows when a + * descriptor names no module or header of its own. + */ +export const SURFACE_TEXT: Readonly< + Record +> = { + module: { label: "import", usage: "import x.y;" }, + header: { label: "#include", usage: "#include " }, + tool: { label: "tool", usage: "$ tool" }, + external: { label: "upstream mcpp.toml", usage: "import x.y;", uiKey: "library.surface.external" }, +}; + +/** One `targets[]` entry. `mcpp` prints bare names; the site's kinds are optional. */ +export interface XpkgTarget { + name?: string; + kind?: string; +} + +/** The decoded `mcpp xpkg parse --json` document. Every list is present. */ +export interface XpkgInfo { + namespace: string; + name: string; + /** `form: "A"` means the descriptor names no build shape of its own. */ + form?: string; + versions: Record; + standard?: string; + importStd?: boolean; + sources: string[]; + includeDirs: string[]; + generatedFiles: string[]; + /** Only the paths; the contents themselves are of no use to a reader. */ + generatedContents: string[]; + targets: XpkgTarget[]; + unknownKeys: string[]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); +} + +/** A `versions` object; anything else becomes an empty table. */ +function versionTable(value: unknown): Record { + if (!isRecord(value)) { + return {}; + } + const out: Record = {}; + for (const [platform, list] of Object.entries(value)) { + const versions = stringList(list); + if (versions.length > 0) { + out[platform] = versions; + } + } + return out; +} + +function targetList(value: unknown): XpkgTarget[] { + if (!Array.isArray(value)) { + return []; + } + const out: XpkgTarget[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push({ name: entry }); + continue; + } + if (isRecord(entry)) { + const name = typeof entry.name === "string" ? entry.name : undefined; + const kind = typeof entry.kind === "string" ? entry.kind : undefined; + out.push({ ...(name === undefined ? {} : { name }), ...(kind === undefined ? {} : { kind }) }); + } + } + return out; +} + +/** + * Decode one already-parsed JSON value. Anything that is not the documented + * document — a different `kind`, a resolver error envelope, a hand-edited file — + * returns `undefined`, which the callers render as "cannot read this descriptor" + * rather than as a package with no versions. + */ +export function parseXpkgJsonValue(value: unknown): XpkgInfo | undefined { + if (!isRecord(value)) { + return undefined; + } + const name = value.name; + if (typeof name !== "string" || name.length === 0) { + return undefined; + } + const namespace = typeof value.namespace === "string" ? value.namespace : ""; + const generatedContents = isRecord(value.generated_contents) ? Object.keys(value.generated_contents) : []; + return { + namespace, + name, + ...(typeof value.form === "string" && value.form.length > 0 ? { form: value.form } : {}), + versions: versionTable(value.versions), + ...(typeof value.standard === "string" && value.standard.length > 0 ? { standard: value.standard } : {}), + ...(typeof value.import_std === "boolean" ? { importStd: value.import_std } : {}), + sources: stringList(value.sources), + includeDirs: stringList(value.include_dirs), + generatedFiles: generatedFilePaths(value.generated_files), + generatedContents, + targets: targetList(value.targets), + unknownKeys: stringList(value.unknown_keys), + }; +} + +/** `generated_files` is either a list of paths or a list of `{path, bytes}`. */ +function generatedFilePaths(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const out: string[] = []; + for (const entry of value) { + if (typeof entry === "string" && entry.length > 0) { + out.push(entry); + } else if (isRecord(entry) && typeof entry.path === "string" && entry.path.length > 0) { + out.push(entry.path); + } + } + return out; +} + +/** + * Decode the command's stdout. A non-JSON answer (an error message on stdout, a + * partial write) returns `undefined`; the caller keeps the descriptor's own + * fields and says the authoritative read failed. + */ +export function parseXpkgJson(text: string): XpkgInfo | undefined { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return undefined; + } + try { + return parseXpkgJsonValue(JSON.parse(trimmed)); + } catch { + return undefined; + } +} + +/** `.cppm` anywhere in a source or a generated file makes the package modular. */ +function isModular(info: XpkgInfo): boolean { + return ( + info.generatedContents.some((path) => path.endsWith(".cppm")) || + info.sources.some((source) => source.includes(".cppm")) || + info.generatedFiles.some((path) => path.endsWith(".cppm")) + ); +} + +/** A target the site files under the `tool` surface. */ +function hasBinaryTarget(info: XpkgInfo): boolean { + return info.targets.some((target) => /^(bin|binary)$/i.test(target.kind ?? "")); +} + +/** + * Every surface a descriptor's **parsed fields** support, in the order the site + * generator emits them (`_surfaces`): a module package may also offer its + * header, a package may ship both a library and a binary. + * + * A Form A descriptor answers `["external"]` and nothing else: the fields that + * would justify a module or header claim are not in the descriptor, and the + * index site only fills them in by fetching the upstream manifest — which this + * extension deliberately never does. + * + * An empty array is a real answer, not a failure, and it happens when a + * descriptor's fields name targets but neither a module nor a header. The site's + * generator degrades exactly that case to `external`; saying nothing is more + * honest than calling an inline Form B descriptor "upstream mcpp.toml". The row + * then shows no usage label, and the detail page still shows everything known. + * (The seven `compat.*-runtime` anchor packages are *not* that case: they carry a + * generated `.c` source, which is the header surface the generator gives a Form B + * package whose interfaces no module wraps.) + */ +export function surfacesOf(info: XpkgInfo): Surface[] { + if (info.form !== undefined) { + return ["external"]; + } + const out: Surface[] = []; + const modular = isModular(info); + if (modular) { + out.push("module"); + } + // `include_dirs` is not by itself a public header surface: on a module package + // it can exist only so the wrapper's own `#include` resolves. The site + // generator therefore keeps the header surface only for a package that is not + // modular (or that demonstrates an include line, which needs the example scan). + if (!modular && (info.includeDirs.length > 0 || info.sources.length > 0)) { + out.push("header"); + } + if (hasBinaryTarget(info)) { + out.push("tool"); + } + return out; +} + +/** The surface a row leads with, or `undefined` when nothing is claimed. */ +export function surfaceOf(info: XpkgInfo): Surface | undefined { + return surfacesOf(info)[0]; +} + +/** Does the descriptor's own text name a `kind = "bin"` target? */ +export function textNamesBinaryTarget(text: string): boolean { + return /\bkind\s*=\s*"(?:bin|binary)"/.test(text); +} + +/** + * The site's per-platform version groups, in a stable order: the current + * platform first, then the rest as `mcpp` printed them. Used by the detail + * page's version matrix, where the current platform must be the one a reader + * finds first. + */ +export function versionGroups( + versions: Record, + currentPlatform: string | undefined, +): Array<{ platform: string; versions: string[] }> { + const platforms = Object.keys(versions); + const ordered = [ + ...(currentPlatform !== undefined && platforms.includes(currentPlatform) ? [currentPlatform] : []), + ...platforms.filter((platform) => platform !== currentPlatform), + ]; + return ordered.map((platform) => ({ platform, versions: [...versions[platform]] })); +} + +/** + * The current platform as `mcpp` names it: `linux` / `macosx` / `windows`. + * `undefined` for a platform the index has no vocabulary for, so the caller + * shows the matrix rather than guessing which column is "mine". + */ +export function platformKey(nodePlatform: string): string | undefined { + switch (nodePlatform) { + case "linux": + return "linux"; + case "darwin": + return "macosx"; + case "win32": + return "windows"; + default: + return undefined; + } +} + +/** + * Version order, total and deterministic. + * + * mcpp's versions are dotted numbers (`3.2`, `1.6.43`, `0.2.4`), sometimes with + * a packaging revision (`22.1.8.1`), a prerelease tail (`1.0.0-rc1`) or a + * non-semver tag the upstream project publishes (`b10069.2`, the llama.cpp + * checkpoint revisions). The comparison follows the shape a reader expects: + * + * - the dotted core first, segment by segment, numerically when both segments + * are numbers (`1.10` is after `1.9`); + * - build metadata (`+…`) is ignored; + * - a prerelease (`-rc1`) sorts **below** its release, as semver says; + * - a numeric segment outranks a non-numeric one, which is what makes the + * `"latest"` alias some xlings descriptors publish lose to the concrete + * version beside it — the only answer `mcpp add` accepts. + */ +export function compareVersions(a: string, b: string): number { + const left = splitVersion(a); + const right = splitVersion(b); + const core = compareSegments(left.core, right.core); + if (core !== 0) { + return core; + } + if (left.pre === undefined && right.pre === undefined) { + return 0; + } + if (left.pre === undefined) { + return 1; + } + if (right.pre === undefined) { + return -1; + } + return compareSegments(left.pre, right.pre); +} + +function splitVersion(version: string): { core: string[]; pre?: string[] } { + const withoutBuild = version.split("+")[0]; + const dash = withoutBuild.indexOf("-"); + if (dash === -1) { + return { core: withoutBuild.split(".") }; + } + return { core: withoutBuild.slice(0, dash).split("."), pre: withoutBuild.slice(dash + 1).split(".") }; +} + +function compareSegments(left: readonly string[], right: readonly string[]): number { + const length = Math.max(left.length, right.length); + for (let index = 0; index < length; index += 1) { + const one = left[index]; + const two = right[index]; + if (one === undefined) { + return two === undefined ? 0 : -1; + } + if (two === undefined) { + return 1; + } + const oneNumber = /^\d+$/.test(one); + const twoNumber = /^\d+$/.test(two); + if (oneNumber && twoNumber) { + const difference = Number(one) - Number(two); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (oneNumber !== twoNumber) { + return oneNumber ? 1 : -1; + } + const natural = compareNatural(one, two); + if (natural !== 0) { + return natural; + } + } + return 0; +} + +/** `rc2` before `rc10`: within an alphanumeric identifier, digit runs are numbers. */ +function compareNatural(left: string, right: string): number { + const one = left.match(/\d+|\D+/g) ?? []; + const two = right.match(/\d+|\D+/g) ?? []; + const length = Math.max(one.length, two.length); + for (let index = 0; index < length; index += 1) { + const a = one[index]; + const b = two[index]; + if (a === undefined) { + return b === undefined ? 0 : -1; + } + if (b === undefined) { + return 1; + } + const aNumber = /^\d+$/.test(a); + const bNumber = /^\d+$/.test(b); + if (aNumber && bNumber) { + const difference = Number(a) - Number(b); + if (difference !== 0) { + return difference < 0 ? -1 : 1; + } + continue; + } + if (a !== b) { + return a < b ? -1 : 1; + } + } + return 0; +} + +/** The list's versions, greatest first. */ +export function sortVersions(versions: readonly string[]): string[] { + return [...versions].sort((a, b) => compareVersions(b, a)); +} + +/** + * The version `mcpp add` must be given: the greatest one this platform has. + * + * `undefined` means the index knows no version for the current platform, and the + * UI says so instead of running a command mcpp would reject. + */ +export function latestVersion(versions: Record, platform: string | undefined): string | undefined { + if (platform === undefined) { + return undefined; + } + const list = versions[platform]; + if (list === undefined || list.length === 0) { + return undefined; + } + return sortVersions(list)[0]; +} + +/** Every version this platform has, greatest first; `[]` when it has none. */ +export function versionsFor(versions: Record, platform: string | undefined): string[] { + if (platform === undefined) { + return []; + } + return sortVersions(versions[platform] ?? []); +} diff --git a/src/mcppTomlCompletion.ts b/src/mcppTomlCompletion.ts deleted file mode 100644 index 016af8f..0000000 --- a/src/mcppTomlCompletion.ts +++ /dev/null @@ -1,223 +0,0 @@ -// mcpp.toml 的代码补全查询层(结构补全版)。 -// -// 范围:段头结构建议 + 开放词汇段的写法模板。每条建议携带显式替换范围。 -// 依赖包名/版本等动态数据补全与静态字段键/枚举补全均不在本版——前者等上游 -// 批量 catalog 接口,后者等版本化 manifest schema(见设计 issue #8 与 -// mcpp RFC #379)。 -// -// 本模块不依赖 vscode API;上下文来自 mcppTomlParser 的 contextAt(容错解析)。 - -import { - contextAt, - type ReplaceRange, - type SectionResolution, -} from "./mcppTomlParser"; - -export type McppTomlSuggestionKind = "section" | "template"; - -export interface McppTomlSuggestion { - label: string; - kind: McppTomlSuggestionKind; - detail: string; - documentation?: string; - /** 插入文本;含 $1 等 snippet 占位符。缺省时插入 label。 */ - insertSnippet?: string; - /** 替换范围(光标所在行的起止列)。 */ - range: ReplaceRange; -} - -export interface SectionHeaderSpec { - group: string; - label: string; - /** snippet 形式的段头(含 ${1:...} 占位)。 */ - header: string; - detail: string; -} - -// 段头结构清单:TOML 结构语法,非字段语义。出处:mcpp 文档 02/03/05/06 -// 与 src/manifest/toml.cppm 的段清单(契约测试用真实 mcpp 逐段验证)。 -export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ - { group: "package", label: "[package]", header: "[package]", detail: "包元数据" }, - { group: "lib", label: "[lib]", header: "[lib]", detail: "库根模块约定" }, - { group: "build", label: "[build]", header: "[build]", detail: "构建配置" }, - { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: "生成文件(路径 → 内容)" }, - { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: "运行时依赖" }, - { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: "开发/测试依赖" }, - { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: "构建期依赖(仅构建期拉取,运行时不可见)" }, - { group: "workspace", label: "[workspace]", header: "[workspace]", detail: "工作空间成员声明" }, - { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: "集中声明依赖版本,成员用 workspace = true 继承" }, - { group: "features", label: "[features]", header: "[features]", detail: "feature 定义" }, - { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: "由 feature 拉取的可选依赖" }, - { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: "capability 绑定(provider 选择)" }, - { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: "构建目标" }, - { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: "构建档案" }, - { group: "runtime", label: "[runtime]", header: "[runtime]", detail: "主机运行时能力" }, - { group: "resources", label: "[resources]", header: "[resources]", detail: "编译进产物的元数据与资产(仅 PE 目标)" }, - { 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: "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 过滤策略微调" }, - { group: "indices", label: "[indices]", header: "[indices]", detail: "项目级索引重定向" }, - { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: "host 工具二进制覆盖" }, - { group: "language", label: "[language]", header: "[language]", detail: "旧版兼容字段;新项目请用 [package].standard" }, -]; - -/** 依赖类段(键位置给依赖写法模板)。 */ -const DEPENDENCY_GROUPS: ReadonlySet = new Set([ - "dependencies", - "dev-dependencies", - "build-dependencies", - "workspace.dependencies", - "feature-deps", -]); - -interface TemplateSpec { - label: string; - detail: string; - documentation?: string; - insertSnippet: string; -} - -const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ - { - label: 'name = "version"', - detail: "SemVer 版本依赖", - documentation: "默认 caret 约束(^);也支持 ~、= 与 \">=1.0, <2.0\" 范围组合。", - insertSnippet: '${1:name} = "${2:1.0.0}"', - }, - { - label: "name = { path = ... }", - detail: "路径依赖(本地开发)", - insertSnippet: '${1:name} = { path = "${2:../mylib}" }', - }, - { - label: "name = { git = ..., tag = ... }", - detail: "Git 依赖(tag / branch / rev 三选一)", - insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', - }, - { - label: "name = { version = ..., features = [...] }", - detail: "长式 dep spec:请求该依赖的 feature", - insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', - }, - { - label: "name = { version = ..., tools = [...] }", - detail: "依赖产出的 host 工具(须为该包的 bin target)", - insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', - }, -]; - -const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ - { label: "name = [...]", detail: "数组简写:仅隐含 feature", insertSnippet: "${1:name} = [${2}]" }, - { label: "name = { defines = [...] }", detail: "表形式:激活时贡献包自有宏", insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, - { label: "name = { requires = [...] }", detail: "表形式:需要 capability", insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, - { label: "name = { sources = [...] }", detail: "表形式:feature 门控的源 glob", insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, -]; - -const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ - { - label: '"path" = "content"', - detail: "生成文件(相对路径 → 内容,进指纹)", - insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', - }, -]; - -const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ - { - label: 'capability = "provider"', - detail: "capability 绑定(等价于 --cap)", - insertSnippet: '${1:blas} = "${2:compat.openblas}"', - }, -]; - -const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ - { label: 'tool = "version"', detail: "固定 xlings 工具版本", insertSnippet: '${1:node} = "${2:24.19.0}"' }, -]; - -const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ - { - label: '"pkg:tool" = "path"', - detail: "用已有二进制覆盖 host 工具(跳过构建)", - insertSnippet: '"${1:compat.protobuf:protoc}" = "${2:/usr/bin/protoc}"', - }, -]; - -const TEMPLATES_BY_GROUP: Record = { - "features": FEATURE_TEMPLATES, - "generated_files": GENERATED_FILE_TEMPLATES, - "capabilities": CAPABILITY_TEMPLATES, - "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, - "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, -}; - -function sectionHeaderSuggestions(range: ReplaceRange): McppTomlSuggestion[] { - return SECTION_HEADERS.map((section) => ({ - label: section.label, - kind: "section", - detail: section.detail, - insertSnippet: section.header, - range, - })); -} - -function templateSuggestions(templates: readonly TemplateSpec[], range: ReplaceRange): McppTomlSuggestion[] { - return templates.map((template) => ({ - label: template.label, - kind: "template", - detail: template.detail, - documentation: template.documentation, - insertSnippet: template.insertSnippet, - range, - })); -} - -/** - * 计算 mcpp.toml 在指定位置的补全建议(结构补全:段头 + 写法模板)。 - */ -export function computeMcppTomlCompletions( - lines: readonly string[], - line: number, - character: number, -): McppTomlSuggestion[] { - const context = contextAt(lines, line, character); - - if (context.kind === "section-header") { - // mcpp manifest 不使用 TOML 数组表([[...]]);[[ 内不提供建议, - // 避免把用户意图的数组表悄悄替换成普通段 [x](未知段会被 mcpp 静默忽略)。 - if (context.isArray) { - return []; - } - // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", - // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 - // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 - const lineText = (lines[line] ?? "").replace(/\r$/, ""); - const bracket = lineText.indexOf("["); - const firstNonWs = lineText.search(/\S/); - const range = bracket >= 0 && bracket === firstNonWs - ? { startCharacter: bracket, endCharacter: context.replaceRange.endCharacter } - : context.replaceRange; - return sectionHeaderSuggestions(range); - } - - if (context.kind === "key") { - const { section, containerPath, replaceRange } = context; - // 文档顶部(尚无段头):提示段头。未知段:不提供建议 - // (附录 A:不支持包自定义 toml 键)。 - if (section.kind === "top") { - return sectionHeaderSuggestions(replaceRange); - } - if (section.kind !== "known" || containerPath.length > 0) { - return []; - } - if (DEPENDENCY_GROUPS.has(section.group)) { - return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); - } - const templates = TEMPLATES_BY_GROUP[section.group]; - return templates === undefined ? [] : templateSuggestions(templates, replaceRange); - } - - // 值位置:自由格式值不瞎猜(版本候选等动态数据层落地后再说)。 - return []; -} diff --git a/src/mcppls/bridge.ts b/src/mcppls/bridge.ts new file mode 100644 index 0000000..55a7738 --- /dev/null +++ b/src/mcppls/bridge.ts @@ -0,0 +1,106 @@ +/** + * The one place this extension talks to the C++ Modules extension. + * + * It forwards commands and nothing else: no LSP client, no reading of + * `mcppls.*` settings, no writing of them, no parsing of its logs. The + * capability table (`./contract.ts`) says what may be forwarded and how much + * confirmation each action needs; `./capabilities.ts` decides whether it exists. + * + * Results are **structured, not phrased**: a caller that has a user in front of + * it turns them into words through `./messages.ts`. That keeps this module free + * of `vscode` and therefore unit-testable, and it keeps the strings translatable + * without threading a translator through the probe logic. + */ + +import { CapabilityRegistry, type CapabilityEnvironment, type InvokeResult } from "./capabilities"; +import { capability } from "./contract"; + +export { MCPPLS_EXTENSION_ID } from "./contract"; + +export type LanguageServerCommandState = InvokeResult["state"]; +export type LanguageServerCommandResult = InvokeResult; +export { CapabilityRegistry } from "./capabilities"; +export type { CapabilityEnvironment } from "./capabilities"; + +export interface LanguageServerBridge { + /** The probe, exposed so the environment self-check can print the whole table. */ + readonly capabilities: CapabilityRegistry; + /** Generic entry point for the view and the menu. */ + invoke(key: string, ...args: unknown[]): Promise; + /** True when the user should not be offered this capability. */ + isGone(key: string): boolean; + /** True when it exists but the static read could not confirm it. */ + isUnconfirmed(key: string): boolean; + /** How much confirmation the action needs. */ + dangerOf(key: string): "none" | "confirm" | "destructive"; + + refreshLanguageServerAfterBuild(): Promise; + restartLanguageServer(): Promise; + restartEngine(): Promise; + resetWorkspaceCache(): Promise; + selectContext(): Promise; + showModuleGraph(): Promise; + showLanguageServerLogs(): Promise; + collectReport(): Promise; + exportDiagnosticBundle(): Promise; + runBuildToolInTerminal(): Promise; + manageConflicts(restore?: boolean): Promise; + toggleInWorkspace(enable: boolean): Promise; + installCommandLineTools(): Promise; + reviewChanges(clear?: boolean): Promise; +} + +/** Methods that map one-to-one onto a capability, so the bridge stays a table. */ +const FORWARDED = { + restartLanguageServer: "restartServer", + restartEngine: "restartEngine", + resetWorkspaceCache: "resetCache", + selectContext: "selectContext", + showModuleGraph: "moduleGraph", + showLanguageServerLogs: "logs", + collectReport: "report", + exportDiagnosticBundle: "diagnosticBundle", + runBuildToolInTerminal: "runBuildTool", + installCommandLineTools: "installTools", +} as const; + +export function createLanguageServerBridge(environment: CapabilityEnvironment): LanguageServerBridge { + const capabilities = new CapabilityRegistry(environment); + let refreshInFlight: Promise | undefined; + + const invoke = (key: string, ...args: unknown[]): Promise => + capabilities.invoke(key, ...args); + + /** + * Builds are frequent; a second refresh while one is in flight would restart + * the language server twice for one edit. The single-flight promise is shared, + * so both callers see the same outcome. + */ + function refreshLanguageServerAfterBuild(): Promise { + if (refreshInFlight !== undefined) { + return refreshInFlight; + } + refreshInFlight = invoke("refresh").finally(() => { + refreshInFlight = undefined; + }); + return refreshInFlight; + } + + const forwarded = Object.fromEntries( + Object.entries(FORWARDED).map(([method, key]) => [method, () => invoke(key)]), + ) as Record Promise>; + + return { + capabilities, + invoke, + isGone: (key) => capabilities.isGone(key), + isUnconfirmed: (key) => capabilities.isUnconfirmed(key), + dangerOf: (key) => capability(key)?.danger ?? "none", + refreshLanguageServerAfterBuild, + ...forwarded, + // The two commands whose *argument* selects the direction. + manageConflicts: (restore = false) => invoke("manageConflicts", restore), + toggleInWorkspace: (enable) => invoke("toggleInWorkspace", enable), + reviewChanges: (clear = false) => invoke("review", clear), + }; +} diff --git a/src/mcppls/capabilities.ts b/src/mcppls/capabilities.ts new file mode 100644 index 0000000..2a9f64f --- /dev/null +++ b/src/mcppls/capabilities.ts @@ -0,0 +1,212 @@ +/** + * Capability probing and invocation for the C++ Modules extension. + * + * Pure: the only VS Code-shaped things it needs arrive through + * `CapabilityEnvironment`, which is exactly what the extension host supplies and + * what the tests fake. + * + * Why probing is two-level: + * + * - **Static** — `packageJSON.contributes.commands` says which ids mcppls + * declares. It costs nothing and never runs anything. A command the static + * read cannot see is *greyed out*, not hidden, so a wrong read cannot remove a + * feature. + * - **Runtime** — the first real use calls the command once and classifies the + * failure. `command '…' not found` means the capability is gone for this + * session; anything else is a failure of that one call and does not remove the + * feature. + * + * We deliberately do **not** probe by calling every command at activation: + * `selectContext`, `showModuleGraph` and `showLogs` are UI commands with side + * effects, and probing them would open pickers and panels. `commands.getCommands()` + * is not used either — whether it lists a contributed-but-unactivated command is + * not guaranteed across VS Code versions. + */ + +import { CAPABILITIES, MCPPLS_EXTENSION_ID, capability } from "./contract"; + +export interface CapabilityEnvironment { + /** `vscode.extensions.getExtension(id) !== undefined`. */ + extensionInstalled(id: string): boolean; + /** Declared command ids, from the extension's `package.json`. */ + declaredCommands?(id: string): readonly string[] | undefined; + /** Activate the dependency before the first forward, when VS Code has not. */ + activateExtension?(id: string): Thenable; + executeCommand(command: string, ...args: unknown[]): Thenable; +} + +export type CapabilityState = "available" | "declared" | "undeclared" | "missing" | "unavailable"; + +export interface CapabilityStatus { + key: string; + state: CapabilityState; + /** The command chosen for this capability, when there is one. */ + command?: string; +} + +export type InvokeState = "completed" | "unavailable" | "missing" | "failed"; + +export interface InvokeResult { + state: InvokeState; + capabilityKey: string; + /** The command that ran, when one ran. */ + command?: string; + /** Error text from the failed call, verbatim. */ + error?: string; + /** + * Whatever the command answered with, when it answered with anything. + * + * A few mcppls commands return a path — `mcppls.exportDiagnosticBundle` + * resolves to the zip it wrote (`exportDiagnosticBundle(): Promise` upstream) — and that is the only reliable way to point at the + * file afterwards. Most commands return nothing, and the caller must treat this + * as unknown: it is passed on untouched, never parsed. + */ + value?: unknown; +} + +/** `command 'x' not found` in the several spellings VS Code has used. */ +const NOT_FOUND = /command\s+(?:['"`][^'"`]+['"`]\s+)?not found|not registered|no such command/i; + +export function classifyCommandError(error: unknown): "missing" | "failed" { + const message = error instanceof Error ? error.message : String(error); + return NOT_FOUND.test(message) ? "missing" : "failed"; +} + +export class CapabilityRegistry { + private readonly statuses = new Map(); + + public constructor(private readonly environment: CapabilityEnvironment) { + this.probe(); + } + + /** Re-read the static declaration; called when extensions are installed, enabled or updated. */ + public invalidate(): void { + this.statuses.clear(); + this.probe(); + } + + public status(key: string): CapabilityStatus { + return this.statuses.get(key) ?? { key, state: "unavailable" }; + } + + /** Capabilities whose first command is believed usable right now. */ + public availableKeys(): string[] { + return [...this.statuses.values()] + .filter((status) => status.state === "available" || status.state === "declared") + .map((status) => status.key); + } + + /** True when the user should not be offered the capability. */ + public isGone(key: string): boolean { + const state = this.status(key).state; + return state === "unavailable" || state === "missing"; + } + + /** True when the capability exists but the static read could not confirm it: grey out, do not hide. */ + public isUnconfirmed(key: string): boolean { + return this.status(key).state === "undeclared"; + } + + private probe(): void { + const installed = this.environment.extensionInstalled(MCPPLS_EXTENSION_ID); + const declared = installed ? this.environment.declaredCommands?.(MCPPLS_EXTENSION_ID) : undefined; + for (const entry of CAPABILITIES) { + if (entry.kind === "readState") { + // State comes from `extension.exports`, which cannot be inspected without + // activating; the reader decides and reports for itself. + this.statuses.set(entry.key, { key: entry.key, state: installed ? "declared" : "unavailable" }); + continue; + } + if (!installed) { + this.statuses.set(entry.key, { key: entry.key, state: "unavailable" }); + continue; + } + if (declared === undefined) { + // No static information: assume the first command works until a call says otherwise. + this.statuses.set(entry.key, { key: entry.key, state: "declared", command: entry.commands[0] }); + continue; + } + const command = entry.commands.find((candidate) => declared.includes(candidate)); + this.statuses.set( + entry.key, + command === undefined + ? { key: entry.key, state: "undeclared" } + : { key: entry.key, state: "declared", command }, + ); + } + } + + /** + * Run a capability, activating the dependency first. Returns the command that + * ran so the caller can name it in a log line. + */ + public async invoke(key: string, ...args: unknown[]): Promise { + const entry = capability(key); + if (entry === undefined) { + return { state: "failed", capabilityKey: key, error: `unknown capability ${key}` }; + } + if (!this.environment.extensionInstalled(MCPPLS_EXTENSION_ID)) { + return { state: "unavailable", capabilityKey: key }; + } + const known = this.status(key); + if (known.state === "missing") { + // Remembered from an earlier call: do not ask VS Code again. + return { state: "missing", capabilityKey: key }; + } + if (known.state === "unavailable") { + return { state: "unavailable", capabilityKey: key }; + } + + // Candidate chain: prefer the first command the probe could confirm. + const status = known; + const candidate = status.command ?? entry.commands[0]; + if (candidate === undefined) { + return { state: "failed", capabilityKey: key, error: `capability ${key} has no command` }; + } + + try { + await this.environment.activateExtension?.(MCPPLS_EXTENSION_ID); + const value = await this.environment.executeCommand(candidate, ...args); + this.statuses.set(key, { key, state: "available", command: candidate }); + return { + state: "completed", + capabilityKey: key, + command: candidate, + ...(value === undefined ? {} : { value }), + }; + } catch (error) { + const failure = classifyCommandError(error); + if (failure === "missing") { + // This command does not exist in the installed version: try the next + // candidate once, then remember. + const next = entry.commands.find((other) => other !== candidate); + if (next !== undefined) { + try { + const value = await this.environment.executeCommand(next, ...args); + this.statuses.set(key, { key, state: "available", command: next }); + return { + state: "completed", + capabilityKey: key, + command: next, + ...(value === undefined ? {} : { value }), + }; + } catch (second) { + if (classifyCommandError(second) === "missing") { + this.statuses.set(key, { key, state: "missing" }); + return { state: "missing", capabilityKey: key, command: candidate }; + } + return { state: "failed", capabilityKey: key, command: next, error: messageOf(second) }; + } + } + this.statuses.set(key, { key, state: "missing" }); + return { state: "missing", capabilityKey: key, command: candidate }; + } + return { state: "failed", capabilityKey: key, command: candidate, error: messageOf(error) }; + } + } +} + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/mcppls/contract.ts b/src/mcppls/contract.ts new file mode 100644 index 0000000..67bbffa --- /dev/null +++ b/src/mcppls/contract.ts @@ -0,0 +1,260 @@ +/** + * The C++ Modules language service we depend on, as **data**. + * + * Everything this extension knows about `sunrisepeak.mcpp-language-server` + * (mcppls) lives here: its extension id, the commands we forward, and how badly + * each one needs confirming. No `vscode` import, so the table is unit-testable + * and `tools/` can read it. + * + * Two rules this file exists to keep: + * + * 1. **Our own commands never use the `mcppls.` prefix.** mcppls's S3 spec + * (S3-5.6-3) forbids a client from registering a command id the server + * advertises: `vscode-languageclient` registers those itself, and a collision + * stops the language client from starting. See `.agents/docs/mcppls-integration.md`. + * 2. **Nothing here writes an `mcppls.*` setting.** The only settings we ever + * touch are our own `mcpp.*`. The two commands that change mcppls state + * (`turnOn/turnOffInWorkspace`) are mcppls's own, and the user triggers them. + */ + +export const MCPPLS_EXTENSION_ID = "sunrisepeak.mcpp-language-server"; + +/** + * The version range this build was written against. Used for a **notice** only: + * a command that disappears is detected by calling it, not by reading a version, + * because a version number cannot predict a rename. + * + * The minimum is a fact in its own right (the comparison in `extension.ts` + * reads it through `compareVersions`), and the range string is its display + * form — one source of truth, two spellings (external review P1-3). + */ +export const VERIFIED_MCPPLS_MINIMUM = "0.0.4"; +export const VERIFIED_MCPPLS_RANGE = `>=${VERIFIED_MCPPLS_MINIMUM}`; + +export type CapabilityKind = "forward" | "readState"; + +/** How much ceremony an action needs before it runs. */ +export type Danger = "none" | "confirm" | "destructive"; + +export interface Capability { + key: string; + kind: CapabilityKind; + /** English; the UI resolves it through `src/i18n/t.ts`. */ + title: string; + /** Candidate command ids, most preferred first. Empty for `readState`. */ + commands: readonly string[]; + /** Always false: losing mcppls must never disable mcpp's own features. */ + required: boolean; + danger: Danger; + /** Shown when the capability is gone; says what still works. */ + degradedHint: string; + /** Extra sentence for a destructive action, spelling out what it does *not* touch. */ + confirmHint?: string; +} + +export const CAPABILITIES: readonly Capability[] = [ + { + key: "refresh", + kind: "forward", + title: "Refresh the C++ Modules build description", + commands: ["mcppls.reloadBuildDescription", "mcppls.restartServer"], + required: false, + danger: "none", + degradedHint: + "The build finished, but the language service could not be refreshed. Run \"C++ Modules: Restart Language Server\" from the Command Palette.", + }, + { + key: "selectContext", + kind: "forward", + title: "Select the C++ Modules analysis context", + commands: ["mcppls.selectContext"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a context picker; analysis uses the default context.", + }, + { + key: "moduleGraph", + kind: "forward", + title: "Show the module graph", + commands: ["mcppls.showModuleGraph"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a module graph.", + }, + { + key: "logs", + kind: "forward", + title: "Show the C++ Modules log", + commands: ["mcppls.showLogs"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a log command; look for its output channel in the Output view.", + }, + { + key: "readState", + kind: "readState", + title: "C++ Modules state", + commands: [], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not expose its state; only the actions below are available.", + }, + { + key: "restartServer", + kind: "forward", + title: "Restart the C++ Modules language server", + commands: ["mcppls.restartServer"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a restart command.", + }, + { + key: "restartEngine", + kind: "forward", + title: "Restart the C++ semantic engine", + commands: ["mcppls.restartClangd"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer an engine restart.", + confirmHint: "The semantic engine restarts; module preparation starts over and may take a while.", + }, + { + key: "resetCache", + kind: "forward", + title: "Reset this workspace's C++ Modules cache", + commands: ["mcppls.resetWorkspaceCache"], + required: false, + danger: "destructive", + degradedHint: "Installed C++ Modules does not offer a cache reset.", + confirmHint: + "This discards the language server's model cache for this workspace and prepares it again, which can take minutes. mcpp's own build cache and the project's target/ directory are not touched.", + }, + { + key: "logsDirectory", + // Verified upstream: `mcppls.revealCacheDirectory(which)` takes `'logs'` for + // `paths.logDirectory` and anything else for the workspace cache root + // (`editors/vscode/src/commands.ts` -> `revealCacheDirectory`), and the log + // directory is `/logs` (`src/orchestrator/workspace.cpp`). + // The argument is passed by the caller, so the chain stays one command. + kind: "forward", + title: "Open the C++ Modules log folder", + commands: ["mcppls.revealCacheDirectory"], + required: false, + danger: "none", + degradedHint: + "Installed C++ Modules does not offer a reveal command; its log lives in the mcppls cache directory.", + }, + { + key: "report", + kind: "forward", + title: "Collect a C++ Modules diagnostic report", + commands: ["mcppls.collectReport"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a report command.", + }, + { + key: "diagnosticBundle", + kind: "forward", + title: "Export a C++ Modules diagnostic bundle", + commands: ["mcppls.exportDiagnosticBundle"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer a diagnostic bundle.", + }, + { + key: "runBuildTool", + kind: "forward", + title: "Run the build tool in a terminal", + commands: ["mcppls.runBuildToolInTerminal"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer this action.", + confirmHint: "The build tool runs in an integrated terminal, with its normal side effects.", + }, + { + key: "manageConflicts", + kind: "forward", + title: "Manage other C++ language features", + commands: ["mcppls.turnOffOtherCppFeatures", "mcppls.restoreOtherCppFeatures"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer conflict handling.", + confirmHint: "This changes settings belonging to other C++ extensions, at their own keys. C++ Modules performs the change.", + }, + { + key: "toggleInWorkspace", + kind: "forward", + title: "Enable or disable C++ Modules in this workspace", + commands: ["mcppls.turnOffInWorkspace", "mcppls.turnOnInWorkspace"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a per-workspace switch.", + confirmHint: "This writes mcppls.enable, a setting owned by the C++ Modules extension.", + }, + { + key: "installTools", + kind: "forward", + title: "Install the C++ Modules command line tools", + commands: ["mcppls.installCommandLineTools"], + required: false, + danger: "confirm", + degradedHint: "Installed C++ Modules does not offer a command line tools installer.", + confirmHint: "This may install system packages the language server needs.", + }, + { + key: "review", + kind: "forward", + title: "Review workspace changes", + commands: ["mcppls.review.run", "mcppls.review.clear"], + required: false, + danger: "none", + degradedHint: "Installed C++ Modules does not offer its review commands here.", + }, +]; + +const BY_KEY = new Map(CAPABILITIES.map((capability) => [capability.key, capability])); + +export function capability(key: string): Capability | undefined { + return BY_KEY.get(key); +} + +/** The commands mcppls must declare for a capability to be usable at all. */ +export function commandsOf(key: string): readonly string[] { + return BY_KEY.get(key)?.commands ?? []; +} + +/** + * The capability table's own invariants (must be empty). + * + * The "our ids never use the mcppls prefix" rule is checked where our ids live + * (`src/commands/ids.ts`); this covers the forward table itself. + */ +export function capabilityProblems(): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const entry of CAPABILITIES) { + if (seen.has(entry.key)) { + problems.push(`duplicate capability ${entry.key}`); + } + seen.add(entry.key); + if (entry.required) { + problems.push(`${entry.key} is marked required; losing the language service must not disable mcpp features`); + } + if (entry.kind === "forward" && entry.commands.length === 0) { + problems.push(`forward capability ${entry.key} has no command`); + } + if (entry.kind === "readState" && entry.commands.length > 0) { + problems.push(`readState capability ${entry.key} must not name a command`); + } + for (const command of entry.commands) { + if (!command.startsWith("mcppls.")) { + problems.push(`${entry.key} forwards ${command}, which is not an mcppls. command`); + } + } + if (entry.danger !== "none" && entry.confirmHint === undefined) { + problems.push(`${entry.key} needs confirmation but has no confirmHint`); + } + } + return problems; +} diff --git a/src/mcppls/messages.ts b/src/mcppls/messages.ts new file mode 100644 index 0000000..7d0282e --- /dev/null +++ b/src/mcppls/messages.ts @@ -0,0 +1,61 @@ +/** + * Turning a structured bridge result into something a person reads. + * + * Split out of `./bridge.ts` so the bridge stays free of `vscode` (and testable) + * while these strings still go through `src/i18n/t.ts`. Every English string here + * is a key: `tools/l10n-check.mjs` fails the build if one has no Chinese entry. + */ + +import { t } from "../i18n/t"; +import type { LanguageServerCommandResult } from "./bridge"; +import { MCPPLS_EXTENSION_ID, capability } from "./contract"; + +export type ResultSeverity = "info" | "warning" | "error"; + +export interface FormattedResult { + severity: ResultSeverity; + message: string; + /** The capability's own advice, when it is gone. */ + hint?: string; +} + +function titleOf(key: string): string { + return capability(key)?.title ?? key; +} + +export function formatResult(result: LanguageServerCommandResult): FormattedResult { + const title = titleOf(result.capabilityKey); + switch (result.state) { + case "completed": + return { severity: "info", message: t("{0}: done.", title) }; + case "unavailable": + return { + severity: "warning", + message: t("The C++ Modules extension ({0}) is not installed or is disabled.", MCPPLS_EXTENSION_ID), + }; + case "missing": + return { + severity: "warning", + message: t("{0}: the installed C++ Modules does not offer this action.", title), + hint: capability(result.capabilityKey)?.degradedHint, + }; + default: + return { + severity: "error", + message: t("{0} failed: {1}", title, result.error ?? t("unknown error")), + }; + } +} + +/** Confirmation text for an action that needs one, or `undefined` when it does not. */ +export function confirmationFor(key: string, value?: unknown): string | undefined { + const entry = capability(key); + if (entry === undefined || entry.danger === "none") { + return undefined; + } + const parts = [entry.confirmHint ?? entry.title]; + if (typeof value === "boolean") { + parts.push(value ? t("Direction: enable.") : t("Direction: disable.")); + } + return parts.join(" "); +} diff --git a/src/mcppls/state.ts b/src/mcppls/state.ts new file mode 100644 index 0000000..a931e62 --- /dev/null +++ b/src/mcppls/state.ts @@ -0,0 +1,229 @@ +/** + * What the C++ Modules extension reports about itself, normalised. + * + * Pure: it takes the object mcppls's `activate()` returned and answers with a + * shape this extension can render, or with `available: false`. + * + * **This is a best-effort channel, not a contract.** mcppls's own name for that + * object is `TestApi`; the *contents* we read (`CxxModulesStatus`) are specified + * by S3, but whether the object is exposed at all is not promised. So every read + * is defensive: a missing function, a thrown error, an unknown `state` or a + * field of the wrong type all end in `available: false` and the view simply + * shows less. Nothing here may throw. + * + * The one thing worth calling out: an issue carries an optional `command` — S3's + * own fix for it. We surface that rather than guessing a remedy. + */ + +export interface McpplsIssue { + code: string; + message: string; + /** S3's own remedy, when it offers one. */ + command?: { command: string; arguments?: unknown[]; title?: string }; +} + +export interface McpplsEngineStatus { + name: string; + version: string; + role: string; + state: string; +} + +export interface McpplsStateView { + available: boolean; + /** Why it is unavailable, for the log; never shown as an error. */ + reason?: string; + version?: string; + active?: boolean; + enabled?: boolean; + state?: McpplsState; + project?: { root: string; source: string; level?: number; tier?: number }; + profile?: { kind: string; compiler?: string; stdlib: string; target: string; standard?: string }; + engine?: { name: string; version: string }; + engines?: McpplsEngineStatus[]; + progress?: { done: number; total: number }; + issues?: McpplsIssue[]; + notices?: McpplsIssue[]; + onlineRun?: { outcome: string; message: string; at: string }; +} + +export const MCPPLS_STATES = ["starting", "loading", "preparing", "ready", "degraded", "error"] as const; +export type McpplsState = (typeof MCPPLS_STATES)[number]; + +export interface McpplsMeta { + version?: string; + active?: boolean; + enabled?: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function knownState(value: unknown): McpplsState | undefined { + return typeof value === "string" && (MCPPLS_STATES as readonly string[]).includes(value) + ? (value as McpplsState) + : undefined; +} + +function issue(value: unknown): McpplsIssue | undefined { + if (!isRecord(value)) { + return undefined; + } + const code = text(value.code); + const message = text(value.message); + if (code === undefined || message === undefined) { + return undefined; + } + const rawCommand = value.command; + const command = + isRecord(rawCommand) && text(rawCommand.command) !== undefined + ? { + command: text(rawCommand.command) as string, + arguments: Array.isArray(rawCommand.arguments) ? rawCommand.arguments : undefined, + title: text(rawCommand.title), + } + : undefined; + return { code, message, command }; +} + +function issues(value: unknown): McpplsIssue[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const parsed = value.map(issue).filter((entry): entry is McpplsIssue => entry !== undefined); + return parsed.length === 0 ? undefined : parsed; +} + +/** + * Normalise the object mcppls's `activate()` returned. + * + * `lastStatus()` is read through `Function.prototype.call` on the object itself + * so a getter or a prototype method behaves the same way. + */ +export function readStateFromExports(exports: unknown, meta: McpplsMeta = {}): McpplsStateView { + const base: McpplsStateView = { + available: false, + version: meta.version, + active: meta.active, + enabled: meta.enabled, + }; + if (!isRecord(exports)) { + return { ...base, reason: "mcppls exposes no API object" }; + } + const reader = (exports as Record).lastStatus; + if (typeof reader !== "function") { + return { ...base, reason: "mcppls's API object has no lastStatus()" }; + } + let raw: unknown; + try { + raw = (reader as () => unknown).call(exports); + } catch (error) { + return { ...base, reason: `lastStatus() threw: ${error instanceof Error ? error.message : String(error)}` }; + } + if (!isRecord(raw)) { + // `undefined` is normal before the first status arrives; say so, do not complain. + return { ...base, reason: raw === undefined ? "no status yet" : "lastStatus() returned an unexpected shape" }; + } + const state = knownState(raw.state); + if (state === undefined) { + return { ...base, reason: `unknown state ${JSON.stringify(raw.state)}` }; + } + + const view: McpplsStateView = { ...base, available: true, state }; + + const project = raw.project; + if (isRecord(project)) { + const root = text(project.root); + const source = text(project.source); + if (root !== undefined && source !== undefined) { + view.project = { + root, + source, + level: finiteNumber(project.level), + tier: finiteNumber(project.tier), + }; + } + } + + const profile = raw.profile; + if (isRecord(profile)) { + const kind = text(profile.kind); + const stdlib = text(profile.stdlib); + const target = text(profile.target); + if (kind !== undefined && stdlib !== undefined && target !== undefined) { + view.profile = { kind, stdlib, target, compiler: text(profile.compiler), standard: text(profile.standard) }; + } + } + + const engine = raw.engine; + if (isRecord(engine)) { + const name = text(engine.name); + const version = text(engine.version); + if (name !== undefined && version !== undefined) { + view.engine = { name, version }; + } + } + + if (Array.isArray(raw.engines)) { + const engines = raw.engines + .map((entry): McpplsEngineStatus | undefined => { + if (!isRecord(entry)) return undefined; + const name = text(entry.name); + const version = text(entry.version); + const role = text(entry.role); + const state = text(entry.state); + return name !== undefined && version !== undefined && role !== undefined && state !== undefined + ? { name, version, role, state } + : undefined; + }) + .filter((entry): entry is McpplsEngineStatus => entry !== undefined); + if (engines.length > 0) { + view.engines = engines; + } + } + + const progress = raw.progress; + if (isRecord(progress)) { + const done = finiteNumber(progress.done); + const total = finiteNumber(progress.total); + if (done !== undefined && total !== undefined) { + view.progress = { done, total }; + } + } + + view.issues = issues(raw.issues); + view.notices = issues(raw.notices); + + const onlineRun = raw.onlineRun; + if (isRecord(onlineRun)) { + const outcome = text(onlineRun.outcome); + const message = text(onlineRun.message); + const at = text(onlineRun.at); + if (outcome !== undefined && message !== undefined && at !== undefined) { + view.onlineRun = { outcome, message, at }; + } + } + + return view; +} + +/** One line for the environment self-check; never throws. */ +export function describeState(view: McpplsStateView): string { + if (!view.available) { + return `unavailable (${view.reason ?? "unknown reason"})`; + } + const parts = [view.state ?? "unknown"]; + if (view.project !== undefined) parts.push(`project ${view.project.source}`); + if (view.engine !== undefined) parts.push(`engine ${view.engine.name} ${view.engine.version}`); + if (view.issues !== undefined) parts.push(`${view.issues.length} issue(s)`); + return parts.join(" · "); +} diff --git a/src/mcppls/stateSource.ts b/src/mcppls/stateSource.ts new file mode 100644 index 0000000..a63e92d --- /dev/null +++ b/src/mcppls/stateSource.ts @@ -0,0 +1,71 @@ +/** + * The `vscode`-facing half of the C++ Modules state read. + * + * `state.ts` normalises what it is given; this file fetches it. It is the only + * place that touches `extension.exports`, and it is deliberately defensive: the + * object is mcppls's test API, not a contract, so every failure becomes + * `available: false` with a reason and never an exception. + * + * `mcpp.languageService.readState` can switch the whole read off. When it is off + * the gate below returns before `extension.exports` is touched at all — not a + * partial read, no read — and the view falls back to the degraded content it + * always has for an installed-but-unreadable mcppls. + */ + +import * as vscode from "vscode"; + +import { read } from "../config/access"; +import { MCPPLS_EXTENSION_ID } from "./contract"; +import { readStateFromExports, type McpplsStateView } from "./state"; + +/** Why a state read did not happen. The view prints it verbatim. */ +export function stateUnavailableReason(reason: "disabled" | "missing"): string { + return reason === "disabled" + ? "reading the C++ Modules state is turned off (mcpp.languageService.readState)" + : `mcppls ${MCPPLS_EXTENSION_ID} is not installed`; +} + +function dependency(): vscode.Extension | undefined { + return vscode.extensions.getExtension(MCPPLS_EXTENSION_ID); +} + +export function languageServerInstalled(): boolean { + return dependency() !== undefined; +} + +export function languageServerVersion(): string | undefined { + const version = (dependency()?.packageJSON as { version?: string } | undefined)?.version; + return typeof version === "string" ? version : undefined; +} + +/** `mcppls.enable`, read-only: the setting belongs to the other extension. */ +export function languageServerEnabled(): boolean { + return vscode.workspace.getConfiguration("mcppls").get("enable", true) !== false; +} + +export function readLanguageServerState(): McpplsStateView { + const extension = dependency(); + const meta = { + version: languageServerVersion(), + active: extension?.isActive, + enabled: languageServerEnabled(), + }; + // The gate: nothing below this line runs when the setting is off, so + // `extension.exports` is never read. `read()` falls back to the declared + // default (true) when the registry has no value for the key. + if (read("mcpp.languageService.readState") === false) { + return { available: false, reason: stateUnavailableReason("disabled"), ...meta }; + } + if (extension === undefined) { + return { available: false, reason: stateUnavailableReason("missing"), ...meta }; + } + try { + return readStateFromExports(extension.exports, meta); + } catch (error) { + return { + available: false, + reason: `reading mcppls state threw: ${error instanceof Error ? error.message : String(error)}`, + ...meta, + }; + } +} diff --git a/src/mcppls/timers.ts b/src/mcppls/timers.ts new file mode 100644 index 0000000..f915951 --- /dev/null +++ b/src/mcppls/timers.ts @@ -0,0 +1,80 @@ +/** + * The one interval timer two views share. + * + * Both `mcpp.cache.autoRefreshSeconds` and + * `mcpp.languageService.stateRefreshSeconds` mean the same thing: re-read while + * the view is visible, stop when it is not, and never let a second read overlap + * one that is still running. Keeping the mechanism here — free of `vscode`, so + * `node:test` can drive it with a fake clock — means the two call sites own only + * the decision of *what* to refresh. + * + * The caller disposes the timer with the extension; `dispose()` is idempotent. + */ + +export interface PollTimerOptions { + periodMs: number; + /** The refresh itself. Runs between ticks; the timer never awaits it. */ + tick: () => void; + /** Injected for tests; defaults to `setInterval`/`clearInterval`. */ + setIntervalFn?: (handler: () => void, periodMs: number) => unknown; + clearIntervalFn?: (handle: unknown) => void; +} + +export class PollTimer { + private readonly setIntervalFn: (handler: () => void, periodMs: number) => unknown; + private readonly clearIntervalFn: (handle: unknown) => void; + private handle: unknown; + private periodMs: number; + + public constructor(private readonly options: PollTimerOptions) { + this.setIntervalFn = + options.setIntervalFn ?? ((handler, periodMs) => setInterval(handler, periodMs)); + this.clearIntervalFn = options.clearIntervalFn ?? ((handle) => clearInterval(handle as NodeJS.Timeout)); + this.periodMs = options.periodMs; + } + + /** True while a period is armed, so the caller can assert it did not leak. */ + public get active(): boolean { + return this.handle !== undefined; + } + + /** The period currently armed, in milliseconds. */ + public get period(): number { + return this.periodMs; + } + + /** + * Arm, re-arm or stop. A non-positive or unreadable period stops the timer, so + * "0 = off" holds no matter what the settings file contains. + */ + public start(periodMs: number): void { + this.periodMs = periodMs; + this.stop(); + if (!Number.isFinite(periodMs) || periodMs <= 0) { + return; + } + this.handle = this.setIntervalFn(() => { + try { + this.options.tick(); + } catch { + // A refresh that throws must not kill the timer or the host. + } + }, periodMs); + } + + public stop(): void { + if (this.handle === undefined) { + return; + } + try { + this.clearIntervalFn(this.handle); + } catch { + // Already cleared, or the host is shutting down. + } + this.handle = undefined; + } + + public dispose(): void { + this.stop(); + } +} diff --git a/src/process.ts b/src/process.ts deleted file mode 100644 index e6740cf..0000000 --- a/src/process.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export interface ProcessResult { - exitCode: number; - stdout: string; - stderr: string; -} - -export interface ProcessRunOptions { - timeoutMs?: number; -} - -export type ProcessRunner = ( - executable: string, - args: string[], - cwd?: string, - options?: ProcessRunOptions, -) => Promise; - -export async function runProcess( - executable: string, - args: string[], - cwd?: string, - options: ProcessRunOptions = {}, -): Promise { - try { - const result = await execFileAsync(executable, args, { - cwd, - encoding: "utf8", - maxBuffer: 16 * 1024 * 1024, - timeout: options.timeoutMs, - }); - return { - exitCode: 0, - stdout: result.stdout, - stderr: result.stderr, - }; - } catch (error) { - const processError = error as NodeJS.ErrnoException & { - stdout?: string; - stderr?: string; - code?: number | string; - }; - return { - exitCode: typeof processError.code === "number" ? processError.code : 1, - stdout: processError.stdout ?? "", - stderr: processError.stderr ?? (typeof processError.message === "string" ? processError.message : ""), - }; - } -} diff --git a/src/inProject.ts b/src/projects/context.ts similarity index 50% rename from src/inProject.ts rename to src/projects/context.ts index 7b901e1..7b9f590 100644 --- a/src/inProject.ts +++ b/src/projects/context.ts @@ -1,6 +1,13 @@ export const IN_PROJECT_CONTEXT_KEY = "mcpp.inProject"; export const MCPP_MANIFEST_GLOB = "**/mcpp.toml"; +/** + * `mcpp.task.editorTitleButtons`: the second half of the editor/title `when` + * clause. `mcpp.inProject` says "this file belongs to an mcpp project"; this key + * lets the user turn the Run/Test buttons off without hiding them for everyone. + */ +export const EDITOR_TITLE_BUTTONS_CONTEXT_KEY = "mcpp.editorTitleButtons"; + export interface DisposableLike { dispose(): unknown; } @@ -17,6 +24,22 @@ export async function updateInProjectContext(env: InProjectEnvironment): Promise return inProject; } +export interface EditorTitleButtonsEnvironment { + enabled(): boolean; + setContextValue(key: string, value: boolean): PromiseLike; +} + +/** + * Apply `mcpp.task.editorTitleButtons` to the context key `package.json` gates + * the editor/title buttons with. The caller re-runs it on a configuration + * change; like every other context write it is fire-and-forget. + */ +export async function updateEditorTitleButtonsContext(env: EditorTitleButtonsEnvironment): Promise { + const enabled = env.enabled(); + await env.setContextValue(EDITOR_TITLE_BUTTONS_CONTEXT_KEY, enabled); + return enabled; +} + export function registerInProjectContext(env: InProjectEnvironment): { dispose(): unknown } { void updateInProjectContext(env); const disposables = env.subscribe(() => void updateInProjectContext(env)); diff --git a/src/discovery.ts b/src/projects/discovery.ts similarity index 53% rename from src/discovery.ts rename to src/projects/discovery.ts index c7a08ff..2f680f4 100644 --- a/src/discovery.ts +++ b/src/projects/discovery.ts @@ -6,6 +6,20 @@ export interface McppProjectDiscovery { manifestPath: string; } +/** + * How far the upward walk may go. `workspaceFolder` is the shipped behaviour; + * `filesystem` also reaches a project outside the opened folder + * (`mcpp.project.discoveryBoundary`). The walk itself never reads a setting — + * the `vscode` layer resolves the boundary and passes it in, which keeps this + * module pure and testable. + */ +export type DiscoveryBoundary = "workspaceFolder" | "filesystem"; + +/** `mcpp.project.discoveryBoundary`; anything unknown is the safe default. */ +export function discoveryBoundaryOf(value: unknown): DiscoveryBoundary { + return value === "filesystem" ? "filesystem" : "workspaceFolder"; +} + function isPathWithin(candidate: string, root: string): boolean { const relative = path.relative(root, candidate); return relative === "" || ( @@ -18,9 +32,13 @@ function isPathWithin(candidate: string, root: string): boolean { export function findNearestMcppProject( startPath: string, workspaceRoot?: string, + boundary: DiscoveryBoundary = "workspaceFolder", ): McppProjectDiscovery | undefined { let current = path.resolve(startPath); - const boundary = workspaceRoot === undefined ? undefined : path.resolve(workspaceRoot); + // `filesystem` drops the boundary entirely and keeps walking to the root. + const stop = boundary === "filesystem" || workspaceRoot === undefined + ? undefined + : path.resolve(workspaceRoot); try { if (statSync(current).isFile()) { @@ -30,7 +48,7 @@ export function findNearestMcppProject( // A newly-created workspace path may not exist yet; treat it as a directory. } - if (boundary !== undefined && !isPathWithin(current, boundary)) { + if (stop !== undefined && !isPathWithin(current, stop)) { return undefined; } @@ -39,7 +57,7 @@ export function findNearestMcppProject( if (existsSync(manifestPath)) { return { root: current, manifestPath }; } - if (boundary !== undefined && current === boundary) { + if (stop !== undefined && current === stop) { return undefined; } const parent = path.dirname(current); diff --git a/src/projects/summary.ts b/src/projects/summary.ts new file mode 100644 index 0000000..3ecc978 --- /dev/null +++ b/src/projects/summary.ts @@ -0,0 +1,111 @@ +/** + * A project summary read out of `mcpp.toml`, for the project view. + * + * Deliberately shallow and line-oriented: the view shows an identity line and + * the toolchain/targets, so it needs a handful of scalars, not a manifest model. + * Anything it cannot read stays `undefined` and the view simply omits it — the + * authoritative reader is mcpp itself, and `mcpp: Environment Self-check` shows + * what mcpp says. + * + * Pure: no `vscode`, no `mcpp` execution. + */ + +import type { ProjectSummary, TargetSummary } from "../views/models"; + +/** `[section]` or `[section.sub]`, ignoring quotes inside dotted names. */ +function headerOf(line: string): string | undefined { + const match = /^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/.exec(line); + if (match === null) { + return undefined; + } + const name = match[1].replace(/^['"]|['"]$/g, "").trim(); + return name.length === 0 ? undefined : name; +} + +/** `key = "value"` where the value is a plain string or a bare word. */ +function scalar(body: string, key: string): string | undefined { + const pattern = new RegExp(`^\\s*${key.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*=\\s*(.+?)\\s*(?:#.*)?$`); + const match = pattern.exec(body); + if (match === null) { + return undefined; + } + return unquote(match[1]); +} + +function unquote(raw: string): string | undefined { + const trimmed = raw.trim(); + const quoted = /^"(.*)"$/.exec(trimmed) ?? /^'(.*)'$/.exec(trimmed); + const value = quoted === null ? trimmed : quoted[1]; + return value.length === 0 ? undefined : value; +} + +/** Section names whose scalars we read, matched by prefix. */ +const IDENTITY = "package"; +const TOOLCHAIN = "toolchain"; +const TARGETS = "targets."; + +export function readProjectSummary(root: string, lines: readonly string[]): ProjectSummary { + let section: string | undefined; + let name: string | undefined; + let version: string | undefined; + let standard: string | undefined; + let profile: string | undefined; + let toolchainSpec: string | undefined; + let target: string | undefined; + const targets: TargetSummary[] = []; + let currentTarget: { name: string; kind?: string } | undefined; + + const flushTarget = (): void => { + if (currentTarget !== undefined) { + targets.push({ name: currentTarget.name, kind: currentTarget.kind ?? "target" }); + currentTarget = undefined; + } + }; + + for (const line of lines) { + const header = headerOf(line); + if (header !== undefined) { + flushTarget(); + section = header; + if (header.startsWith(TARGETS)) { + currentTarget = { name: header.slice(TARGETS.length).replace(/^['"]|['"]$/g, "") }; + } + continue; + } + if (section === IDENTITY) { + // `[package]` only: a `[target.'cfg(..)'.package]` is a different plane. + name ??= scalar(line, "name"); + version ??= scalar(line, "version"); + standard ??= scalar(line, "standard"); + profile ??= scalar(line, "default_profile"); + } else if (section === TOOLCHAIN) { + toolchainSpec ??= scalar(line, "spec") ?? scalar(line, "family"); + } else if (section === "build") { + profile ??= scalar(line, "profile"); + } else if (currentTarget !== undefined) { + currentTarget.kind ??= scalar(line, "kind"); + } + } + flushTarget(); + + // `[target.]` names the triple; `[targets.]` names an artifact. + for (const line of lines) { + const match = /^\s*\[\s*target\s*\.\s*(?:'([^']+)'|"([^"]+)"|([^\]]+))\s*\]\s*$/.exec(line); + if (match !== null) { + target = (match[1] ?? match[2] ?? match[3] ?? "").trim() || undefined; + break; + } + } + + // Keys are omitted rather than set to undefined, so a summary compares cleanly. + const summary: ProjectSummary = { root }; + if (name !== undefined) summary.name = name; + if (version !== undefined) summary.version = version; + if (standard !== undefined) summary.standard = standard; + if (profile !== undefined) summary.profile = profile; + if (toolchainSpec !== undefined) summary.toolchainSpec = toolchainSpec; + if (target !== undefined) summary.target = target; + if (targets.length > 0) summary.targets = targets; + if (lines.some((line) => headerOf(line) === "test")) summary.hasTests = true; + return summary; +} diff --git a/src/toml/completion.ts b/src/toml/completion.ts new file mode 100644 index 0000000..876d2e6 --- /dev/null +++ b/src/toml/completion.ts @@ -0,0 +1,523 @@ +// mcpp.toml 的代码补全查询层(schema 驱动版)。 +// +// 范围:段头结构建议 + 已知段的键/枚举值补全 + 开放词汇段的写法模板。每条建议 +// 携带显式替换范围。段/键/枚举来自 `data/toml-schema.json`(src/toml/schema.ts), +// 与诊断共用同一份快照:快照没有的段不会出现在段头表里,快照标记 `openKeys` +// 的段(键由用户自选)不出键建议。 +// +// 依赖版本补全(`mcpp.toml.indexCompletion`,默认关):本模块只负责 +// 「光标是不是在依赖版本值位置」与「把候选变成建议」两件纯事;执行 +// `mcpp search`、超时、会话缓存、信任/离线判定都在 src/toml/providers.ts, +// 解析人类输出在 src/cli/search.ts。本模块不依赖 vscode API:`t()` 只在 +// `auto` 路径上按需加载编辑器 API,纯单测环境下退化为英文 key。 + +import type { PackageVersion } from "../cli/search"; +import { t } from "../i18n/t"; +import { + contextAt, + parseMcppToml, + resolveSection, + type ReplaceRange, +} from "./parser"; +import { SCHEMA, sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export type McppTomlSuggestionKind = "section" | "template" | "version"; + +export interface McppTomlSuggestion { + label: string; + kind: McppTomlSuggestionKind; + detail: string; + documentation?: string; + /** 插入文本;含 $1 等 snippet 占位符。缺省时插入 label。 */ + insertSnippet?: string; + /** 替换范围(光标所在行的起止列)。 */ + range: ReplaceRange; +} + +export interface SectionHeaderSpec { + group: string; + label: string; + /** snippet 形式的段头(含 ${1:...} 占位)。 */ + header: string; + /** + * 明细文案。存的是英文 key 的**取用函数**,每个补全请求才 `t()` 一次,所以 + * 改 `mcpp.ui.language` 无需重载窗口,且本模块在无编辑器的单测里也能加载。 + */ + detail: () => string; +} + +// 段头明细表:只补充「写法」(label/snippet/detail),不再是段清单本身。 +// 段清单来自 schema;这里按 group 名匹配,schema 新出现的段用 plane 兜底。 +// 出处:mcpp 文档 02/03/05/06 与 src/manifest/toml.cppm 的段清单(契约测试用 +// 真实 mcpp 逐段验证)。 +export const SECTION_HEADERS: readonly SectionHeaderSpec[] = [ + { group: "package", label: "[package]", header: "[package]", detail: () => t("Package metadata") }, + { group: "lib", label: "[lib]", header: "[lib]", detail: () => t("Library root-module convention") }, + { group: "build", label: "[build]", header: "[build]", detail: () => t("Build configuration") }, + { group: "generated_files", label: "[generated_files]", header: "[generated_files]", detail: () => t("Generated files (path → content)") }, + { group: "dependencies", label: "[dependencies]", header: "[dependencies]", detail: () => t("Runtime dependencies") }, + { group: "dev-dependencies", label: "[dev-dependencies]", header: "[dev-dependencies]", detail: () => t("Development/test dependencies") }, + { group: "build-dependencies", label: "[build-dependencies]", header: "[build-dependencies]", detail: () => t("Build-time dependencies (pulled at build time only, invisible at run time)") }, + { group: "workspace", label: "[workspace]", header: "[workspace]", detail: () => t("Workspace member declarations") }, + { group: "workspace.dependencies", label: "[workspace.dependencies]", header: "[workspace.dependencies]", detail: () => t("Declare dependency versions centrally; members inherit them with workspace = true") }, + { group: "features", label: "[features]", header: "[features]", detail: () => t("Feature definitions") }, + { group: "feature-deps", label: "[feature-deps.]", header: "[feature-deps.${1:name}]", detail: () => t("Optional dependencies pulled in by a feature") }, + { group: "capabilities", label: "[capabilities]", header: "[capabilities]", detail: () => t("Capability bindings (provider selection)") }, + { group: "targets", label: "[targets.]", header: "[targets.${1:name}]", detail: () => t("Build targets") }, + { group: "profile", label: "[profile.]", header: "[profile.${1:name}]", detail: () => t("Build profiles") }, + { group: "runtime", label: "[runtime]", header: "[runtime]", detail: () => t("Host runtime capabilities") }, + { group: "resources", label: "[resources]", header: "[resources]", detail: () => t("Metadata and assets compiled into the product (PE targets only)") }, + { group: "toolchain", label: "[toolchain]", header: "[toolchain]", detail: () => t("Compiler toolchain shorthand") }, + { group: "xlings", label: "[xlings]", header: "[xlings]", detail: () => t("Build environment (supplied by xlings)") }, + { group: "xlings.workspace", label: "[xlings.workspace]", header: "[xlings.workspace]", detail: () => t("Pin tool versions") }, + { group: "target", label: "[target.]", header: "[target.${1:x86_64-linux-gnu}]", detail: () => t("Configuration per target triple") }, + { group: "pack", label: "[pack]", header: "[pack]", detail: () => t("mcpp pack packaging configuration") }, + { group: "pack.bundle-project", label: "[pack.bundle-project]", header: "[pack.bundle-project]", detail: () => t("Fine-tuning of the vendored filtering policy") }, + { group: "indices", label: "[indices]", header: "[indices]", detail: () => t("Project-level index redirection") }, + { group: "tools.overrides", label: "[tools.overrides]", header: "[tools.overrides]", detail: () => t("Host tool binary overrides") }, + { group: "language", label: "[language]", header: "[language]", detail: () => t("Legacy compatibility field; new projects should use [package].standard") }, +]; + +/** 依赖类段(键位置给依赖写法模板)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +interface TemplateSpec { + label: string; + /** 同 `SectionHeaderSpec.detail`:取用时才 `t()`,语言切换无需重载。 */ + detail: () => string; + documentation?: () => string; + insertSnippet: string; +} + +const DEPENDENCY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'name = "version"', + detail: () => t("SemVer version dependency"), + documentation: () => t("Caret constraint (^) by default; ~, = and range combinations such as \">=1.0, <2.0\" are also supported."), + insertSnippet: '${1:name} = "${2:1.0.0}"', + }, + { + label: "name = { path = ... }", + detail: () => t("Path dependency (local development)"), + insertSnippet: '${1:name} = { path = "${2:../mylib}" }', + }, + { + label: "name = { git = ..., tag = ... }", + detail: () => t("Git dependency (one of tag / branch / rev)"), + insertSnippet: '${1:name} = { git = "${2:https://github.com/user/repo.git}", tag = "${3:v1.0.0}" }', + }, + { + label: "name = { version = ..., features = [...] }", + detail: () => t("Long dep spec: request a feature of that dependency"), + insertSnippet: '${1:name} = { version = "${2:1.0}", features = ["${3:feature}"] }', + }, + { + label: "name = { version = ..., tools = [...] }", + detail: () => t("Host tools produced by the dependency (must be a bin target of that package)"), + insertSnippet: '${1:name} = { version = "${2:1.0}", tools = ["${3:protoc}"] }', + }, +]; + +const FEATURE_TEMPLATES: readonly TemplateSpec[] = [ + { label: "name = [...]", detail: () => t("Array shorthand: implies the feature only"), insertSnippet: "${1:name} = [${2}]" }, + { label: "name = { defines = [...] }", detail: () => t("Table form: contributes the package's own macros when activated"), insertSnippet: '${1:name} = { defines = ["${2:MACRO}"] }' }, + { label: "name = { requires = [...] }", detail: () => t("Table form: requires a capability"), insertSnippet: '${1:name} = { requires = ["${2:blas}"] }' }, + { label: "name = { sources = [...] }", detail: () => t("Table form: feature-gated source globs"), insertSnippet: '${1:name} = { sources = ["${2:src/simd/**}"] }' }, +]; + +const GENERATED_FILE_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"path" = "content"', + detail: () => t("Generated file (relative path → content, part of the fingerprint)"), + insertSnippet: '"${1:src/gen/wrap.cppm}" = """\n${2:}\n"""', + }, +]; + +const CAPABILITY_TEMPLATES: readonly TemplateSpec[] = [ + { + label: 'capability = "provider"', + detail: () => t("Capability binding (equivalent to --cap)"), + insertSnippet: '${1:blas} = "${2:compat.openblas}"', + }, +]; + +const XLINGS_WORKSPACE_TEMPLATES: readonly TemplateSpec[] = [ + { label: 'tool = "version"', detail: () => t("Pin the xlings tool version"), insertSnippet: '${1:node} = "${2:24.19.0}"' }, +]; + +const TOOLS_OVERRIDES_TEMPLATES: readonly TemplateSpec[] = [ + { + label: '"pkg:tool" = "path"', + detail: () => t("Override a host tool with an existing binary (skips the build)"), + insertSnippet: '"${1:compat.protobuf:protoc}" = "${2:/usr/bin/protoc}"', + }, +]; + +const TEMPLATES_BY_GROUP: Record = { + "features": FEATURE_TEMPLATES, + "generated_files": GENERATED_FILE_TEMPLATES, + "capabilities": CAPABILITY_TEMPLATES, + "xlings.workspace": XLINGS_WORKSPACE_TEMPLATES, + "tools.overrides": TOOLS_OVERRIDES_TEMPLATES, +}; + +/** 手写明细按 group 索引;schema 段只借它取 label/snippet/detail。 */ +const HEADER_SPEC_BY_GROUP: ReadonlyMap = new Map( + SECTION_HEADERS.map((spec) => [spec.group, spec]), +); + +interface HeaderEntry { + label: string; + header: string; + detail: string; + documentation?: string; +} + +/** + * 段头清单:以 schema 为准(§3.3.2)。schema 为空(快照未生成)时才退回手写 + * 清单,保证补全在缺数据时仍可用。手写明细按段名匹配以保留既有 label/snippet/ + * 文案;schema 新收录的段用平面名兜底。 + */ +function headerEntries(): HeaderEntry[] { + if (SCHEMA.sections.length === 0) { + return SECTION_HEADERS.map((spec) => ({ + label: spec.label, + header: spec.header, + detail: spec.detail(), + })); + } + // The snapshot describes the schema mcpp validates; the curated list also covers + // tables mcpp accepts but the snapshot does not model (for example + // `[workspace.dependencies]`). Merge them, preferring the curated wording, so + // schema-driven detail never costs a writable table its suggestion. + const entries: HeaderEntry[] = SCHEMA.sections.map((section) => { + const spec = HEADER_SPEC_BY_GROUP.get(section.name); + const entry: HeaderEntry = { + label: spec?.label ?? section.header, + header: spec?.header ?? section.header, + detail: spec?.detail() ?? `plane: ${section.plane}`, + }; + if (section.deprecatedBy) { + entry.documentation = `Deprecated; use \`${section.deprecatedBy}\`.`; + } + return entry; + }); + const suggested = new Set(entries.map((entry) => entry.header)); + for (const spec of SECTION_HEADERS) { + if (suggested.has(spec.header)) { + continue; + } + entries.push({ label: spec.label, header: spec.header, detail: spec.detail() }); + } + return entries; +} + +function headerSuggestions(range: ReplaceRange): McppTomlSuggestion[] { + return headerEntries().map((entry) => { + const suggestion: McppTomlSuggestion = { + label: entry.label, + kind: "section", + detail: entry.detail, + insertSnippet: entry.header, + range, + }; + if (entry.documentation !== undefined) { + suggestion.documentation = entry.documentation; + } + return suggestion; + }); +} + +function templateSuggestions(templates: readonly TemplateSpec[], range: ReplaceRange): McppTomlSuggestion[] { + return templates.map((template) => ({ + label: template.label, + kind: "template", + detail: template.detail(), + documentation: template.documentation?.(), + insertSnippet: template.insertSnippet, + range, + })); +} + +/** 类型的占位值:与 §3.3.2 的键补全约定一致。 */ +function keyPlaceholder(key: TomlKey): string { + switch (key.type) { + case "boolean": + return "true"; + case "number": + return "0"; + case "array": + return "[]"; + case "enum": + return JSON.stringify(key.values?.[0] ?? ""); + default: + return '""'; + } +} + +/** `detail` 展示类型;枚举展开取值,有默认值再补默认值。 */ +function keyDetail(key: TomlKey): string { + let detail = key.type; + if (key.values !== undefined && key.values.length > 0) { + detail += `: ${key.values.join(" | ")}`; + } + if (key.default !== undefined) { + detail += ` (default: ${JSON.stringify(key.default)})`; + } + return detail; +} + +function keyDocumentation(key: TomlKey): string | undefined { + const lines: string[] = []; + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + if (key.since !== undefined) { + lines.push(`Since ${key.since}.`); + } + if (key.legacy === true) { + lines.push("Legacy key."); + } + return lines.length === 0 ? undefined : lines.join(" "); +} + +function keySuggestions( + section: TomlSection, + used: ReadonlySet, + range: ReplaceRange, +): McppTomlSuggestion[] { + const keys = section.keys ?? []; + return keys + .filter((key) => !used.has(key.key)) + .map((key) => { + const suggestion: McppTomlSuggestion = { + label: key.key, + kind: "template", + detail: keyDetail(key), + insertSnippet: `${key.key} = ${keyPlaceholder(key)}`, + range, + }; + const documentation = keyDocumentation(key); + if (documentation !== undefined) { + suggestion.documentation = documentation; + } + return suggestion; + }); +} + +/** + * 同段中光标之前已出现的顶层键。用容错解析器遍历节点树:`[targets.]` 这类 + * 行表按 parser 的组归属聚合,因此同组的不同行表会互相剔除已用键。 + */ +function keysUsedBefore(lines: readonly string[], line: number, group: string): Set { + const used = new Set(); + let current: string | undefined; + for (const node of parseMcppToml(lines).nodes) { + if (node.type === "section") { + const resolution = resolveSection(node.segments.map((segment) => segment.name)); + current = resolution.kind === "known" ? resolution.group : undefined; + continue; + } + if (current !== group || node.range.startLine >= line) { + continue; + } + const first = node.keyPath[0]; + if (first !== undefined) { + used.add(first.name); + } + } + return used; +} + +/** 值位置的枚举键:点分键按完整路径查,找不到退回首段(与诊断一致)。 */ +function enumKeyOf(section: TomlSection | undefined, keyPath: readonly string[]): TomlKey | undefined { + if (section?.keys === undefined || keyPath.length === 0) { + return undefined; + } + const joined = keyPath.join("."); + const key = + section.keys.find((entry) => entry.key === joined) ?? + section.keys.find((entry) => entry.key === keyPath[0]); + return key?.type === "enum" ? key : undefined; +} + +function enumValueSuggestions( + key: TomlKey, + insideString: boolean, + range: ReplaceRange, +): McppTomlSuggestion[] { + return (key.values ?? []).map((value) => ({ + label: value, + kind: "template", + detail: `value of ${key.key}`, + // 字符串内只替换内容;裸值位置补上引号,枚举值在 manifest 里都是字符串。 + insertSnippet: insideString ? value : JSON.stringify(value), + range, + })); +} + +/** + * 计算 mcpp.toml 在指定位置的补全建议(段头 + 键 + 枚举值 + 写法模板)。 + */ +export function computeMcppTomlCompletions( + lines: readonly string[], + line: number, + character: number, +): McppTomlSuggestion[] { + const context = contextAt(lines, line, character); + + if (context.kind === "section-header") { + // mcpp manifest 不使用 TOML 数组表([[...]]);[[ 内不提供建议, + // 避免把用户意图的数组表悄悄替换成普通段 [x](未知段会被 mcpp 静默忽略)。 + if (context.isArray) { + return []; + } + // parser 的替换范围从段名 token 开始;段头建议插入的是完整 "[xxx]", + // 需要把范围扩展到本行的 "[",避免留下 "[["。仅当 "[" 是行内首个 + // 非空白字符时才扩展(section-header 上下文正常都满足,防御奇怪输入)。 + const lineText = (lines[line] ?? "").replace(/\r$/, ""); + const bracket = lineText.indexOf("["); + const firstNonWs = lineText.search(/\S/); + const range = bracket >= 0 && bracket === firstNonWs + ? { startCharacter: bracket, endCharacter: context.replaceRange.endCharacter } + : context.replaceRange; + return headerSuggestions(range); + } + + if (context.kind === "key") { + const { section, containerPath, replaceRange } = context; + // 文档顶部(尚无段头):提示段头。未知段:不提供建议 + // (附录 A:不支持包自定义 toml 键)。 + if (section.kind === "top") { + return headerSuggestions(replaceRange); + } + if (section.kind !== "known" || containerPath.length > 0) { + return []; + } + if (DEPENDENCY_GROUPS.has(section.group)) { + return templateSuggestions(DEPENDENCY_TEMPLATES, replaceRange); + } + const schemaSection = sectionByName(section.group); + // openKeys 段的键由用户自选(如 [toolchain] 的平台名),不能给固定词表。 + if (schemaSection?.openKeys !== true && schemaSection?.keys !== undefined) { + const used = keysUsedBefore(lines, line, section.group); + return keySuggestions(schemaSection, used, replaceRange); + } + const templates = TEMPLATES_BY_GROUP[section.group]; + return templates === undefined ? [] : templateSuggestions(templates, replaceRange); + } + + // 值位置:只有已知枚举键才出候选,其余自由格式值不瞎猜。 + if (context.section.kind === "known") { + const schemaSection = sectionByName(context.section.group); + const key = enumKeyOf(schemaSection, context.keyPath); + if (key !== undefined) { + return enumValueSuggestions(key, context.insideString, context.replaceRange); + } + } + return []; +} + +/** 光标所在的依赖版本值位置:属于哪个依赖、替换范围、是否已在字符串内。 */ +export interface DependencyVersionContext { + /** 依赖名(值所属的键,如 `zlib`)。 */ + name: string; + /** 光标已在引号内(只替换内容,不再补引号)。 */ + insideString: boolean; + /** 替换范围:简写覆盖 `= ` 之后的值,长式在字符串内只覆盖已输入内容。 */ + range: ReplaceRange; +} + +/** + * 判断光标是否落在依赖的**版本值**上(方案 §3.3.2 的 `toml.indexCompletion` + * 位置)。只认两种写法: + * + * `zlib = "1.2|"` 简写版本 + * `zlib = { version = "1.2|" }` 长式 dep spec 的 version 字段 + * + * `git` / `path` / `features` 的值不是版本,返回 undefined,绝不据此查询索引。 + */ +export function dependencyVersionContextAt( + lines: readonly string[], + line: number, + character: number, +): DependencyVersionContext | undefined { + const context = contextAt(lines, line, character); + if (context.kind !== "value" || context.section.kind !== "known") { + return undefined; + } + if (!DEPENDENCY_GROUPS.has(context.section.group) || context.keyPath.length === 0) { + return undefined; + } + const last = context.keyPath[context.keyPath.length - 1]; + if (context.keyPath.length > 1 && last !== "version") { + return undefined; + } + // A version is a string, or a value not typed yet. An inline table, an array + // or a bare non-version token is a different dep-spec field; offering versions + // there would replace the wrong thing. + const kind = context.valueKind; + if (kind !== undefined && kind !== "string" && kind !== "unknown") { + return undefined; + } + return { + name: context.keyPath[0], + insideString: context.insideString, + range: context.replaceRange, + }; +} + +/** 把索引候选变成补全建议;字符串内只替换内容,裸值位置补上引号。 */ +export function indexVersionSuggestions( + context: DependencyVersionContext, + versions: readonly PackageVersion[], +): McppTomlSuggestion[] { + return versions.map((entry) => { + const suggestion: McppTomlSuggestion = { + label: entry.version, + kind: "version", + detail: + entry.summary === undefined ? `version of ${context.name}` : `${context.name}: ${entry.summary}`, + insertSnippet: context.insideString ? entry.version : JSON.stringify(entry.version), + range: context.range, + }; + return suggestion; + }); +} + +/** 取索引候选:按依赖名查询,失败与超时都由调用方降级为空数组。 */ +export type IndexVersionResolver = (name: string) => Promise; + +/** + * 静态补全 + 依赖版本补全的合并入口。静态层有建议就返回它;否则若光标在依赖 + * 版本值上且调用方给了 resolver,就询问索引。resolver 抛异常时静默降级为 + * 「无候选」,绝不冒泡成错误。 + */ +export async function computeMcppTomlCompletionsWithIndex( + lines: readonly string[], + line: number, + character: number, + resolveIndex?: IndexVersionResolver, +): Promise { + const direct = computeMcppTomlCompletions(lines, line, character); + if (direct.length > 0 || resolveIndex === undefined) { + return direct; + } + const context = dependencyVersionContextAt(lines, line, character); + if (context === undefined) { + return direct; + } + let versions: readonly PackageVersion[]; + try { + versions = await resolveIndex(context.name); + } catch { + return direct; + } + return indexVersionSuggestions(context, versions); +} diff --git a/src/toml/diagnostics.ts b/src/toml/diagnostics.ts new file mode 100644 index 0000000..1f0095e --- /dev/null +++ b/src/toml/diagnostics.ts @@ -0,0 +1,629 @@ +// mcpp.toml 的纯文本诊断层。 +// +// 输入是文件的物理行 + 严重度配置,输出是 1 基行列(列按 UTF-16 码元计数, +// 与 VS Code 的 Diagnostic 一致)的诊断列表。本模块不依赖 vscode API, +// 可在 node --test 下直接测试。 +// +// 它**不是**一个 TOML 校验器:只理解一个保守子集——段头、`key = value`、 +// 行注释、单/双引号字符串、三引号多行字符串,以及可跨行的数组/内联表—— +// 目的是抓住明显写坏的行与 mcpp 专属的平面/废弃规则,而不是重实现 TOML。 +// 子集之外的一切一律不报(宁可漏报,不可误报)。下面每个规则都注明它停在哪里。 +// +// 规则与严重度(默认值见 data/toml-schema.json 的 rules): +// 1 syntax 明显的语法破坏 +// 2 unknown-section 段不在 schema 中 +// 3 unknown-key 已知段中的未知键 +// 4 plane-separation 工具当依赖 / 包当工具 +// 5 mcpp-floor [package].mcpp 不是 ">=" 形式 +// 6 legacy-key legacy 段/键 +// 7 array-table 数组表 [[...]](mcpp 只接受少数几处,见下) + +import { sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export type Severity = "error" | "warning" | "info" | "off"; + +export interface TomlDiagnostic { + code: string; + message: string; + severity: Exclude; + /** 1 基行号。 */ + line: number; + /** 1 基起始列(含)。 */ + startCharacter: number; + /** 1 基结束列(不含)。 */ + endCharacter: number; + relatedKey?: string; +} + +export interface DiagnosticSettings { + syntax: Severity; + unknownSection: Severity; + unknownKey: Severity; + planeSeparation: Severity; + legacyKeys: Severity; +} + +/** + * `mcppFloor` 与 `arrayTable` 在设置面上还没有独立开关(§4 的 registry 尚未 + * 收录),因此默认是 error;一旦设置面补齐,传入同名字段即可覆盖,无需改签名。 + */ +type ExtendedSettings = DiagnosticSettings & { + mcppFloor?: Severity; + arrayTable?: Severity; +}; + +/** 稳定的诊断码。补全/快速修复按它分发。 */ +export const DIAGNOSTIC_CODES = { + syntax: "mcpp.manifest.syntax", + unknownSection: "mcpp.manifest.unknownSection", + unknownKey: "mcpp.manifest.unknownKey", + planeSeparation: "mcpp.manifest.planeSeparation", + mcppFloor: "mcpp.manifest.mcppFloor", + legacyKey: "mcpp.manifest.legacyKey", + arrayTable: "mcpp.manifest.arrayTable", +} as const; + +type RuleName = keyof typeof DIAGNOSTIC_CODES; + +// ── TOML 子集的扫描状态 ───────────────────────────────────────────────────── +// mode: 当前是否在一个未闭合的三引号字符串里。 +// depth: 未闭合的 `[` / `{` 层数(数组与内联表可跨行)。 +interface ParseState { + mode: "normal" | "ml-double" | "ml-single"; + depth: number; +} + +/** text[i] 之前的反斜杠是否为奇数个(三引号串里的 \"\"\" 不结束)。 */ +function isEscaped(text: string, index: number): boolean { + let backslashes = 0; + for (let i = index - 1; i >= 0 && text[i] === "\\"; i -= 1) { + backslashes += 1; + } + return backslashes % 2 === 1; +} + +/** + * 扫描一行,更新跨行状态;返回该行中「真正的注释起始 `#`」的下标(没有则 -1)。 + * 引号内的 `#` 不是注释。这是本模块对 TOML 词法理解的边界:不处理转义还原、 + * 不处理 \uXXXX、不看值是否合法。 + */ +function scan(text: string, state: ParseState): number { + let i = 0; + while (i < text.length) { + if (state.mode === "ml-double") { + if (text.startsWith('"""', i) && !isEscaped(text, i)) { + state.mode = "normal"; + i += 3; + continue; + } + i += 1; + continue; + } + if (state.mode === "ml-single") { + if (text.startsWith("'''", i)) { + state.mode = "normal"; + i += 3; + continue; + } + i += 1; + continue; + } + + const ch = text[i]; + if (ch === "#") { + return i; + } + if (ch === '"') { + if (text.startsWith('"""', i)) { + state.mode = "ml-double"; + i += 3; + continue; + } + i += 1; + while (i < text.length) { + if (text[i] === "\\") { + i += 2; + continue; + } + if (text[i] === '"') { + i += 1; + break; + } + i += 1; + } + continue; + } + if (ch === "'") { + if (text.startsWith("'''", i)) { + state.mode = "ml-single"; + i += 3; + continue; + } + i += 1; + while (i < text.length) { + if (text[i] === "'") { + i += 1; + break; + } + i += 1; + } + continue; + } + if (ch === "[" || ch === "{") { + state.depth += 1; + i += 1; + continue; + } + if (ch === "]" || ch === "}") { + if (state.depth > 0) state.depth -= 1; + i += 1; + continue; + } + i += 1; + } + return -1; +} + +/** 去掉行尾注释(引号内的 `#` 保留),返回可解析的代码片段。 */ +function codeBeforeComment(text: string): string { + const stop = scan(text, { mode: "normal", depth: 0 }); + return stop < 0 ? text : text.slice(0, stop); +} + +/** 在引号之外查找 needle 的下标;找不到返回 -1。 */ +function indexOfUnquoted(text: string, needle: string): number { + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"' || ch === "'") { + const delim = ch.repeat(3); + if (!text.startsWith(delim, i)) { + i += 1; + while (i < text.length) { + if (ch === '"' && text[i] === "\\") { + i += 2; + continue; + } + if (text[i] === ch) { + i += 1; + break; + } + i += 1; + } + continue; + } + i += 3; + while (i < text.length) { + if (text.startsWith(delim, i) && !isEscaped(text, i)) { + i += 3; + break; + } + i += 1; + } + continue; + } + if (text.startsWith(needle, i)) { + return i; + } + i += 1; + } + return -1; +} + +/** 按点拆分段路径/点分键,引号内的点不拆。保留引号。 */ +function splitDots(text: string): string[] { + const parts: string[] = []; + let current = ""; + let quote: string | null = null; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (quote !== null) { + current += ch; + if (ch === quote) { + quote = null; + } + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + current += ch; + continue; + } + if (ch === ".") { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + parts.push(current); + return parts; +} + +function isBareKey(text: string): boolean { + return /^[A-Za-z0-9_-]+$/.test(text); +} + +function isQuotedKey(text: string): boolean { + if (text.length < 2) { + return false; + } + const first = text[0]; + const last = text[text.length - 1]; + return (first === '"' && last === '"') || (first === "'" && last === "'"); +} + +/** `a.b` / `"a b"` / `c."d.e"` 是合法键;其余交给语法规则。 */ +function isValidKey(text: string): boolean { + const parts = splitDots(text); + return parts.length > 0 && parts.every((part) => isBareKey(part) || isQuotedKey(part)); +} + +/** 去掉一层引号;不是带引号的字符串则返回 undefined(不做转义还原)。 */ +function unquote(text: string): string | undefined { + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1); + } + } + return undefined; +} + +// ── schema 侧的段解析 ─────────────────────────────────────────────────────── + +/** 精确匹配,失败时逐级去掉尾部段(`[target.'cfg(x)'.dependencies]` → `target`)。 */ +function lookupSection(name: string): TomlSection | undefined { + const exact = sectionByName(name); + if (exact !== undefined) { + return exact; + } + const parts = splitDots(name); + for (let i = parts.length - 1; i >= 1; i -= 1) { + const candidate = sectionByName(parts.slice(0, i).join(".")); + if (candidate !== undefined) { + return candidate; + } + } + return undefined; +} + +/** + * 键规则要用的段:`[targets.]`、`[profile.]`、`[target.]` 这三种 + * 「行表」继承基段的键表,更深一层(`[package.metadata.]`、 + * `[target..build]`、`[runtime.]`、`[toolchain.]`)的键 + * 属于另一套词表,不做未知键检查。 + */ +const ROW_KEY_SECTIONS: ReadonlySet = new Set(["targets", "profile", "target"]); + +function keySectionOf(headerName: string): TomlSection | undefined { + const exact = sectionByName(headerName); + if (exact !== undefined) { + return exact; + } + const parts = splitDots(headerName); + if (parts.length === 2 && ROW_KEY_SECTIONS.has(parts[0])) { + return sectionByName(parts[0]); + } + return undefined; +} + +/** 键在段里的元数据;点分键(`metadata.demo`)按首段查找。 */ +function keyInfoOf(section: TomlSection | undefined, key: string): TomlKey | undefined { + if (section?.keys === undefined) { + return undefined; + } + const parts = splitDots(key); + const direct = section.keys.find((entry) => entry.key === key); + if (direct !== undefined) { + return direct; + } + if (parts.length > 1) { + return section.keys.find((entry) => entry.key === parts[0]); + } + return undefined; +} + +// ── mcpp 专属规则用的判定 ─────────────────────────────────────────────────── + +const DEPENDENCY_TABLES: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", +]); + +function lastSegment(name: string): string { + const parts = splitDots(name); + return parts[parts.length - 1]; +} + +function isDependencySection(name: string): boolean { + return DEPENDENCY_TABLES.has(lastSegment(name)); +} + +/** `[xlings]`、`[xlings.workspace]` 及其条件形式 `[target..xlings…]`。 */ +function isXlingsSection(name: string): boolean { + return splitDots(name).includes("xlings"); +} + +/** 工具载荷名:`xim:` / `xpm:` / `xlings:` / `xvm:` 前缀。 */ +function isToolPayloadName(key: string): boolean { + const bare = unquote(key) ?? key; + return /^(xim|xpm|xlings|xvm):/i.test(bare); +} + +/** mcpp 包身份形如 `namespace.name`(至少一个点),不是工具载荷。 */ +function isMcppPackageName(key: string): boolean { + const bare = unquote(key) ?? key; + if (isToolPayloadName(bare)) { + return false; + } + return /^[A-Za-z_][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+$/.test(bare); +} + +/** + * 值是否陈述了一个版本而不是一个路径。`"1.0"` / `{ version = "1.0" }` 是; + * `{ path = "../x" }`、`""`(平台键对象)、`{ linux = "" }` 不是。 + */ +function declaresVersion(valueText: string): boolean { + const text = valueText.trim(); + if (text.startsWith("{")) { + return /(^|[,{\s])version\s*=/.test(text) && !/(^|[,{\s])path\s*=/.test(text); + } + const bare = unquote(text); + if (bare === undefined || bare === "") { + return false; + } + return !bare.includes("/") && !bare.startsWith("."); +} + +/** + * mcpp 允许的数组表(`[[...]]`)。这份名单来自 toml.cppm 的 + * `kAllowedArraysOfTables`:除这些之外,任何 `[[...]]` 都是 mcpp 不接受的写法。 + * + * 注意:任务书写的是「`[[name]]`,mcpp 不使用」,但 mcpp 明确接受 + * `[[build.flags]]`、`[[features..flags]]`、`[[runtime.requirements]]` 等 + * 少数数组表,把它们报错就是误报——而本层的第一原则是不误报。 + */ +const ALLOWED_ARRAY_TABLES: ReadonlyArray> = [ + ["build", "flags"], + ["build", "sources"], + ["features", "*", "flags"], + ["runtime", "requirements"], + ["runtime", "artifacts"], + ["runtime", "deploy"], + ["target", "*", "build", "flags"], + ["target", "*", "build", "sources"], + ["xlings", "deps"], +]; + +function isAllowedArrayTable(name: string): boolean { + const parts = splitDots(name); + return ALLOWED_ARRAY_TABLES.some( + (pattern) => + pattern.length === parts.length && pattern.every((part, index) => part === "*" || part === parts[index]), + ); +} + +/** docs/04 §2.1:只有 `>=` 形式的下界被接受。 */ +function isMcppFloor(valueText: string): boolean { + const bare = unquote(valueText.trim()); + return bare !== undefined && /^>=\d+(\.\d+)+$/.test(bare); +} + +// ── 入口 ──────────────────────────────────────────────────────────────────── + +/** + * 分析一份 mcpp.toml。`lines` 是文件的物理行(含或不含 `\r` 都可以); + * 返回按 (line, startCharacter, code) 排序的稳定诊断列表。 + */ +export function analyseManifest( + lines: readonly string[], + settings: DiagnosticSettings, +): TomlDiagnostic[] { + const extended = settings as ExtendedSettings; + const severity: Record = { + syntax: settings.syntax, + unknownSection: settings.unknownSection, + unknownKey: settings.unknownKey, + planeSeparation: settings.planeSeparation, + mcppFloor: extended.mcppFloor ?? "error", + legacyKey: settings.legacyKeys, + arrayTable: extended.arrayTable ?? "error", + }; + + const diagnostics: TomlDiagnostic[] = []; + + function push( + rule: RuleName, + message: string, + line: number, + startCharacter: number, + endCharacter: number, + relatedKey?: string, + ): void { + const level = severity[rule]; + if (level === "off") { + return; + } + const diagnostic: TomlDiagnostic = { + code: DIAGNOSTIC_CODES[rule], + message, + severity: level, + line, + startCharacter, + endCharacter, + }; + if (relatedKey !== undefined) { + diagnostic.relatedKey = relatedKey; + } + diagnostics.push(diagnostic); + } + + const state: ParseState = { mode: "normal", depth: 0 }; + /** 当前段头的原始名字(未解析到 schema 的时刻也保留,用于平面规则)。 */ + let headerName: string | undefined; + /** 当前段解析到的 schema 段(键规则用;未知段为 undefined)。 */ + let section: TomlSection | undefined; + + for (let index = 0; index < lines.length; index += 1) { + const raw = lines[index].endsWith("\r") ? lines[index].slice(0, -1) : lines[index]; + const lineNumber = index + 1; + + // 多行字符串/数组的续行:整行属于上一个结构,不产生诊断。 + if (state.mode !== "normal" || state.depth > 0) { + scan(raw, state); + continue; + } + + const code = codeBeforeComment(raw); + const text = code.trim(); + if (text === "") { + scan(raw, state); + continue; + } + // 行首空白宽度。 + const lead = code.length - code.trimStart().length; + const lineStart = lead + 1; + const lineEnd = lead + text.length + 1; + + // ── 段头 ── + if (text.startsWith("[")) { + const arrayHeader = /^\[\[(.+?)\]\]$/.exec(text); + const plainHeader = /^\[(.+?)\]$/.exec(text); + if (arrayHeader === null && plainHeader === null) { + // `[` / `[foo` / `[foo] bar`:不是合法段头,也不是 key = value。 + push("syntax", `not a [section] header: ${text}`, lineNumber, lineStart, lineEnd); + scan(raw, state); + continue; + } + + const isArray = arrayHeader !== null; + const name = ((isArray ? arrayHeader : plainHeader)?.[1] ?? "").trim(); + headerName = name; + section = keySectionOf(name); + + if (isArray) { + // 规则 7:mcpp 只接受少数数组表;其余是用户写错的 `[[...]]`。 + if (!isAllowedArrayTable(name)) { + push( + "arrayTable", + `[[${name}]] is an array of tables, which mcpp does not use here`, + lineNumber, + lineStart, + lineEnd, + name, + ); + } + // 规则 2 明确忽略数组表。 + } else { + // 规则 2:未知段(允许点分名逐级回退到已知前缀段)。 + if (lookupSection(name) === undefined) { + push("unknownSection", `unknown section [${name}]`, lineNumber, lineStart, lineEnd, name); + } else if (section?.deprecatedBy) { + // 规则 6:段级 legacy。 + push( + "legacyKey", + `[${name}] is deprecated; use ${section.deprecatedBy}`, + lineNumber, + lineStart, + lineEnd, + section.deprecatedBy, + ); + } + } + scan(raw, state); + continue; + } + + // ── 键值行 ── + const equals = indexOfUnquoted(text, "="); + if (equals <= 0) { + push("syntax", `not a [section] header or key = value: ${text}`, lineNumber, lineStart, lineEnd); + scan(raw, state); + continue; + } + const key = text.slice(0, equals).trim(); + const value = text.slice(equals + 1).trim(); + const keyStart = lineStart; + const keyEnd = keyStart + key.length; + + if (!isValidKey(key)) { + push("syntax", `invalid key: ${key}`, lineNumber, keyStart, keyEnd); + scan(raw, state); + continue; + } + if (value === "") { + push("syntax", `key ${key} has no value`, lineNumber, keyStart, keyEnd, key); + scan(raw, state); + continue; + } + + const info = keyInfoOf(section, key); + + // 规则 3:已知段 + 有键表 + 不在表中(且该段不是自选键的段)。 + if (section?.keys !== undefined && section.openKeys !== true && info === undefined) { + const label = section.header; + push("unknownKey", `unknown key ${key} in ${label}`, lineNumber, keyStart, keyEnd, key); + } + + // 规则 6:键级 legacy(段本身已 deprecated 时只在段头报一次)。 + if (info?.legacy === true && !section?.deprecatedBy) { + const replacement = info.note ?? "no replacement documented"; + push( + "legacyKey", + `key ${key} is deprecated; ${replacement}`, + lineNumber, + keyStart, + keyEnd, + key, + ); + } + + // 规则 5:[package].mcpp 必须是 ">="。 + if (section?.name === "package" && (splitDots(key)[0] === "mcpp") && !isMcppFloor(value)) { + push( + "mcppFloor", + `[package] mcpp must be a release floor like ">=2026.9.28.3"`, + lineNumber, + keyStart, + keyEnd, + "mcpp", + ); + } + + // 规则 4:平面分离。 + const bareKey = unquote(key) ?? key; + if (isDependencySection(headerName ?? "") && isToolPayloadName(bareKey)) { + push( + "planeSeparation", + `${bareKey} is a tool payload, not a library dependency; declare it in [xlings]/[xlings.workspace]`, + lineNumber, + keyStart, + keyEnd, + bareKey, + ); + } else if (isXlingsSection(headerName ?? "") && isMcppPackageName(bareKey) && declaresVersion(value)) { + push( + "planeSeparation", + `${bareKey} is an mcpp package, not a tool payload; declare it in [dependencies]`, + lineNumber, + keyStart, + keyEnd, + bareKey, + ); + } + + scan(raw, state); + } + + diagnostics.sort( + (a, b) => + a.line - b.line || + a.startCharacter - b.startCharacter || + a.code.localeCompare(b.code) || + a.message.localeCompare(b.message), + ); + return diagnostics; +} diff --git a/src/toml/hover.ts b/src/toml/hover.ts new file mode 100644 index 0000000..1342499 --- /dev/null +++ b/src/toml/hover.ts @@ -0,0 +1,206 @@ +// mcpp.toml 的悬停查询层。 +// +// 输入是文件的物理行 + 0 基光标,输出纯数据 HoverInfo;provider 负责渲染成 +// MarkdownString(本模块不依赖 vscode API)。上下文来自 parser 的 contextAt, +// 与补全共用同一套容错解析,不另写 TOML 解析器。 +// +// 覆盖三类位置:段头、键、枚举键的值。其余位置一律 undefined——宁可不显示, +// 也不猜;任何畸形输入都不抛异常。 + +import { + contextAt, + resolveSection, + type ReplaceRange, + type TomlCursorContext, +} from "./parser"; +import { sectionByName, type TomlKey, type TomlSection } from "./schema"; + +export interface HoverInfo { + title: string; + body: string; + documentation?: string; +} + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(Math.trunc(value), min), max); +} + +/** 取光标所在行(越界与 CRLF 都容错)。 */ +function lineTextAt(lines: readonly string[], line: number): string { + if (!Array.isArray(lines) || lines.length === 0) { + return ""; + } + const index = clamp(line, 0, lines.length - 1); + return (lines[index] ?? "").replace(/\r$/, ""); +} + +/** 替换范围对应的原文(越界钳制)。 */ +function tokenAt(lineText: string, range: ReplaceRange): string { + const start = clamp(range.startCharacter, 0, lineText.length); + const end = clamp(range.endCharacter, start, lineText.length); + return lineText.slice(start, end); +} + +function unquote(text: string): string { + if (text.length >= 2) { + const first = text[0]; + const last = text[text.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return text.slice(1, -1); + } + } + return text; +} + +/** + * 段头路径 → schema 段:先精确匹配,再让 parser 的组映射处理参数化段 + * (`[targets.app]` → `targets`),最后逐级去掉尾部(与诊断的 lookupSection + * 同策略),使 `[features.a]` 这类行表也能落到基段。 + */ +function sectionForHeader(segments: readonly string[]): TomlSection | undefined { + if (segments.length === 0) { + return undefined; + } + const exact = sectionByName(segments.join(".")); + if (exact !== undefined) { + return exact; + } + const resolution = resolveSection(segments); + if (resolution.kind === "known") { + // parser 认识的组就是权威归属:schema 没收录它(如 [xlings.workspace])时 + // 不显示,而不是把键张冠李戴到基段上。 + return sectionByName(resolution.group); + } + for (let length = segments.length - 1; length >= 1; length -= 1) { + const found = sectionByName(segments.slice(0, length).join(".")); + if (found !== undefined) { + return found; + } + } + return undefined; +} + +function sectionHover(section: TomlSection): HoverInfo { + const lines: string[] = [`plane: \`${section.plane}\``]; + if (section.deprecatedBy) { + lines.push(`legacy: replaced by \`${section.deprecatedBy}\``); + } + const keyCount = section.keys?.length ?? 0; + lines.push( + section.keys === undefined + ? "keys: none declared in the snapshot" + : `keys: ${keyCount}${section.openKeys === true ? " (open: the user picks the names)" : ""}`, + ); + const info: HoverInfo = { title: section.header, body: lines.join("\n\n") }; + if (section.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +function keyHover(key: TomlKey, section: TomlSection | undefined): HoverInfo { + const lines: string[] = [`type: \`${key.type}\``]; + if (key.values !== undefined && key.values.length > 0) { + lines.push(`values: ${key.values.map((value) => `\`${value}\``).join(" | ")}`); + } + if (key.default !== undefined) { + lines.push(`default: \`${JSON.stringify(key.default)}\``); + } + if (key.since !== undefined) { + lines.push(`since: ${key.since}`); + } + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + if (key.legacy === true) { + lines.push("legacy: this key is deprecated"); + } + if (key.unmodelled === true) { + lines.push("unmodelled: the type is inferred from the docs, not read from mcpp"); + } + const info: HoverInfo = { title: key.key, body: lines.join("\n\n") }; + if (section?.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +function enumValueHover(key: TomlKey, value: string, section: TomlSection | undefined): HoverInfo { + const lines: string[] = [`enum value of \`${key.key}\``]; + if (key.values !== undefined && key.values.length > 0) { + lines.push(`one of: ${key.values.map((entry) => `\`${entry}\``).join(" | ")}`); + } + if (key.default !== undefined) { + lines.push(`default: \`${JSON.stringify(key.default)}\``); + } + if (key.note !== undefined && key.note !== "") { + lines.push(key.note); + } + const info: HoverInfo = { title: value === "" ? key.key : value, body: lines.join("\n\n") }; + if (section?.doc !== undefined) { + info.documentation = section.doc; + } + return info; +} + +/** + * 光标下的悬停内容;没有可显示的内容时返回 undefined。永不抛异常。 + */ +export function hoverAt( + lines: readonly string[], + line: number, + character: number, +): HoverInfo | undefined { + try { + const context: TomlCursorContext = contextAt(lines, line, character); + const text = lineTextAt(lines, line); + + if (context.kind === "section-header") { + const segments = [...context.segments]; + const token = tokenAt(text, context.replaceRange).trim(); + // 光标落在某个段路径 token 内时,context.segments 是它之前的段。 + if (token !== "") { + segments.push(unquote(token)); + } + const section = sectionForHeader(segments); + return section === undefined ? undefined : sectionHover(section); + } + + if (context.kind === "key") { + if (context.section.kind !== "known" || context.containerPath.length > 0) { + return undefined; + } + const section = sectionByName(context.section.group); + if (section?.keys === undefined) { + return undefined; + } + // 点分键的根段是 schema 里的键(`metadata.demo` → `metadata`)。 + const token = unquote(tokenAt(text, context.replaceRange).trim()); + const name = context.keyPrefix.length > 0 ? context.keyPrefix[0] : token; + const key = section.keys.find((entry) => entry.key === name); + return key === undefined ? undefined : keyHover(key, section); + } + + // 值位置:只有枚举键的值有可展示的语义。 + if (context.section.kind !== "known") { + return undefined; + } + const section = sectionByName(context.section.group); + if (section?.keys === undefined || context.keyPath.length === 0) { + return undefined; + } + const key = + section.keys.find((entry) => entry.key === context.keyPath.join(".")) ?? + section.keys.find((entry) => entry.key === context.keyPath[0]); + if (key?.type !== "enum") { + return undefined; + } + const value = unquote(tokenAt(text, context.replaceRange)).trim(); + return enumValueHover(key, value, section); + } catch { + return undefined; + } +} diff --git a/src/toml/navigation.ts b/src/toml/navigation.ts new file mode 100644 index 0000000..aa0b7f8 --- /dev/null +++ b/src/toml/navigation.ts @@ -0,0 +1,306 @@ +// mcpp.toml 的跳转查询层:只处理 manifest 内部的三种局部跳转。 +// +// workspace = true → [workspace.dependencies] 里的同名依赖键 +// path = "../x" → 那个 mcpp.toml 的 [package] 段头(行号由调用方解析) +// features = ["a"] → [features.a] 段头(光标所在的那个元素) +// +// 复用 parser 的节点树(parseMcppToml)定位光标下的键/值/数组元素;跨文件的 +// 文件系统语义由 `resolveManifest` 回调注入,本模块保持纯函数、不依赖 vscode, +// 也不自己读盘。任何畸形输入都不抛异常。 + +import { + parseMcppToml, + resolveSection, + type TomlDocument, + type TomlKeyValueNode, + type TomlSectionNode, + type TomlValueNode, +} from "./parser"; + +export interface ManifestLocation { + line: number; + startCharacter: number; + endCharacter: number; +} + +/** 依赖类段组:三种跳转都只在这些段里有意义(`[lib] path`、`[indices] path` 不是)。 */ +const DEPENDENCY_GROUPS: ReadonlySet = new Set([ + "dependencies", + "dev-dependencies", + "build-dependencies", + "workspace.dependencies", + "feature-deps", +]); + +const PACKAGE_HEADER = "[package]"; + +function clamp(value: number, min: number, max: number): number { + if (!Number.isFinite(value)) { + return min; + } + return Math.min(Math.max(Math.trunc(value), min), max); +} + +/** 光标是否落在范围内(端点含尾,与 parser 的判定一致)。 */ +function contains( + range: { startLine: number; startCharacter: number; endLine: number; endCharacter: number }, + line: number, + character: number, +): boolean { + if (line < range.startLine || line > range.endLine) { + return false; + } + if (line === range.startLine && character < range.startCharacter) { + return false; + } + if (line === range.endLine && character > range.endCharacter) { + return false; + } + return true; +} + +/** 光标所在候选:一条键值语句,外加它所属的顶层依赖键(内联表内的条目才有)。 */ +interface Candidate { + node: TomlKeyValueNode; + /** 顶层语句的点分键(`compat.zlib = { workspace = true }` → "compat.zlib")。 */ + ownerKey?: string; + sectionSegments: string[]; +} + +/** 内联表/数组可以嵌套:把所有可达的键值语句摊平,附带顶层依赖键。 */ +function collectNested( + value: TomlValueNode | undefined, + ownerKey: string, + sectionSegments: string[], + out: Candidate[], +): void { + if (value === undefined) { + return; + } + if (value.kind === "inlineTable" && value.entries !== undefined) { + for (const entry of value.entries) { + out.push({ node: entry, ownerKey, sectionSegments }); + collectNested(entry.value, ownerKey, sectionSegments, out); + } + return; + } + if (value.kind === "array" && value.elements !== undefined) { + for (const element of value.elements) { + collectNested(element, ownerKey, sectionSegments, out); + } + } +} + +function candidatesOf(doc: TomlDocument): Candidate[] { + const out: Candidate[] = []; + let sectionSegments: string[] = []; + for (const node of doc.nodes) { + if (node.type === "section") { + sectionSegments = node.segments.map((segment) => segment.name); + continue; + } + out.push({ node, sectionSegments }); + const key = node.keyPath.map((segment) => segment.name).join("."); + collectNested(node.value, key, sectionSegments, out); + } + return out; +} + +function groupOf(sectionSegments: readonly string[]): string | undefined { + if (sectionSegments.length === 0) { + return undefined; + } + const resolution = resolveSection(sectionSegments); + return resolution.kind === "known" ? resolution.group : undefined; +} + +/** 依赖名:内联表条目取顶层语句的键;`[dependencies.foo]` 取段名去掉首段。 */ +function dependencyName(candidate: Candidate): string { + if (candidate.ownerKey !== undefined && candidate.ownerKey !== "") { + return candidate.ownerKey; + } + if (candidate.sectionSegments.length >= 2) { + return candidate.sectionSegments.slice(1).join("."); + } + return ""; +} + +function locationOfKey(node: TomlKeyValueNode): ManifestLocation | undefined { + const first = node.keyPath[0]; + const last = node.keyPath[node.keyPath.length - 1]; + if (first === undefined || last === undefined) { + return undefined; + } + // 点分键(`compat.zlib`)整体选中,不只选第一段。 + return { + line: first.range.startLine, + startCharacter: first.range.startCharacter, + endCharacter: last.range.endCharacter, + }; +} + +function locationOfSection(node: TomlSectionNode): ManifestLocation { + return { + line: node.line, + startCharacter: node.range.startCharacter, + endCharacter: node.range.endCharacter, + }; +} + +/** `[workspace.dependencies]` 里名字匹配的那条键值语句。 */ +function findWorkspaceDependency( + doc: TomlDocument, + dependency: string, +): TomlKeyValueNode | undefined { + if (dependency === "") { + return undefined; + } + let inWorkspaceDependencies = false; + for (const node of doc.nodes) { + if (node.type === "section") { + inWorkspaceDependencies = groupOf(node.segments.map((segment) => segment.name)) === "workspace.dependencies"; + continue; + } + if (inWorkspaceDependencies && node.keyPath.map((segment) => segment.name).join(".") === dependency) { + return node; + } + } + return undefined; +} + +/** `[features.]` 段头(数组表不算)。 */ +function findFeatureSection(doc: TomlDocument, name: string): TomlSectionNode | undefined { + for (const node of doc.nodes) { + if (node.type !== "section" || node.isArray || node.segments.length !== 2) { + continue; + } + if (node.segments[0].name === "features" && node.segments[1].name === name) { + return node; + } + } + return undefined; +} + +function isRelativePath(value: string): boolean { + const text = value.trim(); + if (text === "") { + return false; + } + if (text.startsWith("/") || text.startsWith("\\") || text.startsWith("~")) { + return false; + } + if (/^[A-Za-z]:[\\/]/.test(text)) { + return false; + } + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) { + return false; + } + return true; +} + +function safeResolve( + resolveManifest: (relative: string) => number | undefined, + relative: string, +): number | undefined { + try { + const value = resolveManifest(relative); + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return undefined; + } + return Math.trunc(value); + } catch { + return undefined; + } +} + +function locate( + candidate: Candidate, + line: number, + character: number, + doc: TomlDocument, + resolveManifest: (relative: string) => number | undefined, +): ManifestLocation | undefined { + const { node, sectionSegments } = candidate; + const onKey = node.keyPath.some((segment) => contains(segment.range, line, character)); + const onValue = node.value !== undefined && contains(node.value.range, line, character); + if (!onKey && !onValue) { + return undefined; + } + + const keyName = node.keyPath[node.keyPath.length - 1]?.name ?? ""; + const group = groupOf(sectionSegments); + const inDependencies = group !== undefined && DEPENDENCY_GROUPS.has(group); + + if (keyName === "workspace") { + const isTrue = node.value?.kind === "boolean" && node.value.text === "true"; + if (!isTrue || !inDependencies) { + return undefined; + } + const dependency = dependencyName(candidate); + const target = findWorkspaceDependency(doc, dependency); + return target === undefined ? undefined : locationOfKey(target); + } + + if (keyName === "path") { + if (!inDependencies || node.value?.kind !== "string") { + return undefined; + } + const relative = node.value.text ?? ""; + if (!isRelativePath(relative)) { + return undefined; + } + const resolved = safeResolve(resolveManifest, relative); + if (resolved === undefined) { + return undefined; + } + // 只有行号是可知的(另一个文件的内容不在本函数手里),所以选中该行的 + // [package] 字面量;缩进可能使起点略有偏差,位置仍落在段头行上。 + return { line: resolved, startCharacter: 0, endCharacter: PACKAGE_HEADER.length }; + } + + if (keyName === "features") { + if (node.value?.kind !== "array" || node.value.elements === undefined) { + return undefined; + } + const element = node.value.elements.find((entry) => contains(entry.range, line, character)); + if (element === undefined || element.kind !== "string") { + return undefined; + } + const name = (element.text ?? "").trim(); + if (name === "") { + return undefined; + } + const target = findFeatureSection(doc, name); + return target === undefined ? undefined : locationOfSection(target); + } + + return undefined; +} + +/** + * 光标位置的跳转目标;不在三种形态上时返回 undefined。永不抛异常。 + * + * `resolveManifest` 把相对路径解析为「那个 mcpp.toml 里 [package] 段头的行号 + * (0 基)」,不存在时返回 undefined——文件系统由调用方负责。 + */ +export function definitionAt( + lines: readonly string[], + line: number, + character: number, + resolveManifest: (relative: string) => number | undefined = () => undefined, +): ManifestLocation | undefined { + try { + const cursorLine = clamp(line, 0, Number.MAX_SAFE_INTEGER); + const cursorCharacter = clamp(character, 0, Number.MAX_SAFE_INTEGER); + const doc = parseMcppToml(lines); + for (const candidate of candidatesOf(doc)) { + const found = locate(candidate, cursorLine, cursorCharacter, doc, resolveManifest); + if (found !== undefined) { + return found; + } + } + return undefined; + } catch { + return undefined; + } +} diff --git a/src/mcppTomlParser.ts b/src/toml/parser.ts similarity index 99% rename from src/mcppTomlParser.ts rename to src/toml/parser.ts index 1276eca..9587c0e 100644 --- a/src/mcppTomlParser.ts +++ b/src/toml/parser.ts @@ -624,7 +624,8 @@ class Scanner { // 光标后方同行还有值 token(光标恰在 token 首字符、或在 = 与 token // 之间的空白上)时,替换范围要覆盖整个 token,否则补全插入后原文残留。 if (!this.atEol() && this.peek() !== "#") { - const ahead = this.scanValue(valuePath); // 光标落在 token 内时由 scanValue 捕获 + // 光标落在 token 内时由 scanValue 捕获。 + const ahead = this.scanValue(valuePath); this.captureValue(valuePath, ahead.kind, { replaceRange: Scanner.singleLineReplaceRange(ahead.range) ?? this.emptyReplaceRange(), }); @@ -681,7 +682,8 @@ class Scanner { if (open) { while (!this.eof()) { if (!multiline && this.atEol()) { - break; // 单行串跨行 → 未闭合 + // 单行串跨行 → 未闭合。 + break; } const ch = this.peek(); if (ch === "\\" && quote === '"') { @@ -788,7 +790,8 @@ class Scanner { const elementStart = this.pos(); elements.push(this.scanValue(keyPath)); if (this.samePos(this.pos(), elementStart)) { - this.advance(); // 进度保护 + // 进度保护。 + this.advance(); } } return { kind: "array", range: this.rangeFrom(start), open, elements }; @@ -825,7 +828,8 @@ class Scanner { entries.push(entry); } if (this.samePos(this.pos(), entryStart)) { - this.advance(); // 进度保护 + // 进度保护。 + this.advance(); } } return { kind: "inlineTable", range: this.rangeFrom(start), open, entries }; diff --git a/src/toml/providers.ts b/src/toml/providers.ts new file mode 100644 index 0000000..d474ecc --- /dev/null +++ b/src/toml/providers.ts @@ -0,0 +1,284 @@ +/** + * `mcpp.toml` editing: structural completion plus manifest diagnostics. + * + * Both are pure text analysis — no mcpp process, no language server — so they + * keep working in a restricted workspace, which is what + * `capabilities.untrustedWorkspaces: limited` promises. + * + * The one exception is dependency-version completion + * (`mcpp.toml.indexCompletion`, off by default): it runs `mcpp search`, which may + * use the network. Everything about *that* decision lives here — the two + * settings, workspace trust, the timeout and the session cache — while + * `./completion` stays pure and `../cli/search` stays the one place that parses + * mcpp's human-readable output. + */ + +import * as vscode from "vscode"; + +import { runMcpp } from "../cli/process"; +import { + indexCompletionRequest, + parseSearchOutput, + withDeadline, + type PackageVersion, +} from "../cli/search"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; + +import { computeMcppTomlCompletionsWithIndex } from "./completion"; +import { analyseManifest, type DiagnosticSettings, type Severity } from "./diagnostics"; +import { hoverAt } from "./hover"; +import { definitionAt } from "./navigation"; + +export const MCPP_TOML_LANGUAGE = "mcpp-toml"; + +/** + * The session cache §3.3.2 asks for: at most one `mcpp search + * --all-versions` per package per session. Empty results are cached too — a + * package the index does not know must not be queried on every keystroke. + */ +const indexVersions = new Map(); + +let indexNoticeShown = false; + +/** The plan's one-time notice: this feature runs mcpp and may use the network. */ +function noticeIndexQuery(): void { + if (indexNoticeShown) { + return; + } + indexNoticeShown = true; + void vscode.window.showInformationMessage( + t("Completing dependency versions runs `mcpp search`, which may use the network."), + ); +} + +/** + * Versions for one dependency, or none. Every failure mode — the setting off, an + * untrusted workspace, a non-zero exit, a timeout, output the parser does not + * recognise — degrades to an empty list; nothing here surfaces an error. + */ +async function resolveIndexVersions( + document: vscode.TextDocument, + name: string, +): Promise { + const request = indexCompletionRequest(name, { + enabled: read("mcpp.toml.indexCompletion"), + trusted: vscode.workspace.isTrusted, + // VS Code exposes no offline flag and this extension never probes the + // network, so "offline" is discovered the only honest way: the query fails + // and degrades to no candidates. The term stays in `shouldSearch`'s contract + // (and its tests) so a future signal can fill it in without a shape change. + offline: false, + timeoutSeconds: read("mcpp.toml.indexCompletionTimeoutSeconds"), + }); + if (request === undefined) { + return []; + } + + // The cache is consulted *after* the switches, so turning the setting off (or + // losing trust) stops suggestions immediately instead of serving a hit. + const cached = indexVersions.get(name); + if (cached !== undefined) { + return cached; + } + noticeIndexQuery(); + + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + const cwd = folder?.uri.fsPath ?? vscode.Uri.joinPath(document.uri, "..").fsPath; + const executable = (read("mcpp.path") ?? "").trim() || "mcpp"; + // The process gets the setting's timeout and the promise a short grace period, + // so a process that refuses to die still cannot hold the editor past it. + const result = await withDeadline( + runMcpp(vscode.workspace.isTrusted, executable, request.args, cwd, { timeoutMs: request.timeoutMs }), + request.timeoutMs + 250, + ); + const versions = + result === undefined || result.exitCode !== 0 ? [] : parseSearchOutput(result.stdout); + indexVersions.set(name, versions); + return versions; +} + +function linesOf(document: vscode.TextDocument): string[] { + const lines: string[] = []; + for (let line = 0; line < document.lineCount; line += 1) { + lines.push(document.lineAt(line).text); + } + return lines; +} + +/** + * The line index of `[package]` in a neighbouring manifest, for `path = "…"`. + * The file system belongs to the caller, which is why navigation takes a callback. + */ +function packageHeaderLine(uri: vscode.Uri, relative: string): number | undefined { + try { + const target = vscode.Uri.joinPath(uri, "..", relative, "mcpp.toml"); + const bytes = require("node:fs").readFileSync(target.fsPath, "utf8") as string; + const index = bytes.split(/\r?\n/).findIndex((line) => /^\s*\[\s*package\s*\]\s*$/.test(line)); + return index === -1 ? undefined : index; + } catch { + return undefined; + } +} + +function completionItemKind(kind: "section" | "template" | "version"): vscode.CompletionItemKind { + switch (kind) { + case "section": + return vscode.CompletionItemKind.Folder; + case "version": + return vscode.CompletionItemKind.Value; + default: + return vscode.CompletionItemKind.Snippet; + } +} + +function severityOf(value: Severity | undefined, fallback: Severity): Severity { + return value === "error" || value === "warning" || value === "info" || value === "off" ? value : fallback; +} + +export function registerTomlProviders( + context: vscode.ExtensionContext, + /** `mcpp.toml.completion`: structure suggestions on or off. */ + completionEnabled: () => boolean, + settings: () => DiagnosticSettings, + /** `mcpp.toml.diagnostics.enabled`. */ + enabled: () => boolean, + /** `mcpp.toml.hover` and `mcpp.toml.navigation`, which are independent switches. */ + hoverEnabled: () => boolean = () => true, + navigationEnabled: () => boolean = () => true, +): void { + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + { language: MCPP_TOML_LANGUAGE }, + { + async provideCompletionItems(document, position) { + if (!completionEnabled()) { + return undefined; + } + const lines: string[] = []; + for (let line = 0; line <= position.line; line += 1) { + lines.push(document.lineAt(line).text); + } + const suggestions = await computeMcppTomlCompletionsWithIndex( + lines, + position.line, + position.character, + (name) => resolveIndexVersions(document, name), + ); + return suggestions.map((suggestion) => { + const item = new vscode.CompletionItem(suggestion.label, completionItemKind(suggestion.kind)); + item.detail = suggestion.detail; + if (suggestion.documentation !== undefined) { + item.documentation = new vscode.MarkdownString(suggestion.documentation); + } + if (suggestion.insertSnippet !== undefined) { + item.insertText = new vscode.SnippetString(suggestion.insertSnippet); + } + item.range = new vscode.Range( + position.line, + suggestion.range.startCharacter, + position.line, + suggestion.range.endCharacter, + ); + return item; + }); + }, + }, + // `"` so a dependency version value is discoverable without Ctrl+Space; + // every other trigger position falls straight through to no suggestions. + "[", + '"', + ), + ); + + context.subscriptions.push( + vscode.languages.registerHoverProvider({ language: MCPP_TOML_LANGUAGE }, { + provideHover(document, position) { + if (!hoverEnabled()) { + return undefined; + } + const info = hoverAt(linesOf(document), position.line, position.character); + if (info === undefined) { + return undefined; + } + const contents = [new vscode.MarkdownString(`**${info.title}**`), new vscode.MarkdownString(info.body)]; + if (info.documentation !== undefined) { + contents.push(new vscode.MarkdownString(`[${t("Manifest reference")}](${info.documentation})`)); + } + return new vscode.Hover(contents); + }, + }), + vscode.languages.registerDefinitionProvider({ language: MCPP_TOML_LANGUAGE }, { + provideDefinition(document, position) { + if (!navigationEnabled()) { + return undefined; + } + const location = definitionAt(linesOf(document), position.line, position.character, (relative) => + packageHeaderLine(document.uri, relative), + ); + if (location === undefined) { + return undefined; + } + return new vscode.Location(document.uri, new vscode.Position(location.line, location.startCharacter)); + }, + }), + ); + + const collection = vscode.languages.createDiagnosticCollection("mcpp-manifest"); + context.subscriptions.push(collection); + + const refresh = (document: vscode.TextDocument): void => { + if (document.languageId !== MCPP_TOML_LANGUAGE) { + return; + } + if (!enabled()) { + collection.delete(document.uri); + return; + } + const lines: string[] = []; + for (let line = 0; line < document.lineCount; line += 1) { + lines.push(document.lineAt(line).text); + } + const diagnostics = analyseManifest(lines, settings()).map((entry) => { + const range = new vscode.Range( + Math.max(0, entry.line - 1), + entry.startCharacter, + Math.max(0, entry.line - 1), + entry.endCharacter, + ); + const diagnostic = new vscode.Diagnostic(range, entry.message, severityFor(entry.severity)); + diagnostic.code = entry.code; + diagnostic.source = "mcpp"; + return diagnostic; + }); + collection.set(document.uri, diagnostics); + }; + + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(refresh), + vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)), + vscode.workspace.onDidCloseTextDocument((document) => collection.delete(document.uri)), + vscode.workspace.onDidChangeConfiguration(() => { + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } + }), + ); +} + +function severityFor(severity: "error" | "warning" | "info"): vscode.DiagnosticSeverity { + switch (severity) { + case "error": + return vscode.DiagnosticSeverity.Error; + case "warning": + return vscode.DiagnosticSeverity.Warning; + default: + return vscode.DiagnosticSeverity.Information; + } +} + +/** The registry keys that shape manifest diagnostics, read in one place. */ +export { severityOf }; diff --git a/src/toml/schema.ts b/src/toml/schema.ts new file mode 100644 index 0000000..a27b93d --- /dev/null +++ b/src/toml/schema.ts @@ -0,0 +1,125 @@ +// mcpp.toml 快照的读取层。 +// +// 数据来自 `tools/generate-toml-schema.mjs` 生成的 `data/toml-schema.json` +// (贡献者期从 mcpp 仓库生成,运行期只读快照,不新增依赖)。本模块是纯数据 +// 访问,不依赖 vscode API:补全、悬停、诊断都从这里取段/键/枚举。 +// +// 快照里没有的段不会出现在这里,因此 mcpp 停止接受的段会自动消失——这正是 +// "段列表必须来自 toml.cppm" 的落点。 + +import raw from "../../data/toml-schema.json"; + +export interface TomlKey { + key: string; + type: "string" | "boolean" | "number" | "array" | "enum"; + values?: string[]; + default?: unknown; + since?: string; + legacy?: boolean; + note?: string; + /** 类型是推断而非读出来的:不要据此做严格的类型校验。 */ + unmodelled?: boolean; +} + +export interface TomlSection { + header: string; + name: string; + plane: string; + doc?: string; + deprecatedBy?: string | null; + keys?: TomlKey[]; + /** + * 该段的键由用户自选而非固定词表(如 `[toolchain]` 的平台名与 `bootstrap`), + * 诊断层不对它做未知键检查。生成器只对这类段写 true。 + */ + openKeys?: boolean; +} + +export interface TomlSchema { + sourceVersion: string; + sourceCommit: string; + sections: TomlSection[]; + rules: Array<{ id: string; severity: string }>; +} + +export const SCHEMA: TomlSchema = raw as unknown as TomlSchema; + +/** `[package]` / `package` → `package`;数组表 `[[x]]` 不是段,返回空串。 */ +function normaliseSectionName(headerOrName: string): string { + const text = headerOrName.trim(); + if (text.startsWith("[[")) { + return ""; + } + if (text.startsWith("[") && text.endsWith("]")) { + return text.slice(1, -1).trim(); + } + return text; +} + +/** 按段头(`"[package]"` 或 `"package"`)查找。数组表不匹配任何段。 */ +export function sectionByHeader(header: string): TomlSection | undefined { + const name = normaliseSectionName(header); + return name === "" ? undefined : sectionByName(name); +} + +/** 按段名精确查找。参数化段(`[targets.]`)由调用方用前缀匹配处理。 */ +export function sectionByName(name: string): TomlSection | undefined { + const wanted = normaliseSectionName(name); + if (wanted === "") { + return undefined; + } + return SCHEMA.sections.find((section) => section.name === wanted); +} + +/** 段内某个键的元数据;段不存在、段没有键表或键不在表中都返回 undefined。 */ +export function keyOf(sectionName: string, key: string): TomlKey | undefined { + const section = sectionByName(sectionName); + return section?.keys?.find((entry) => entry.key === key); +} + +/** + * `deprecatedBy` 形如 `[package].standard` / `[package]` / `package.standard`, + * 取出它指向的段名。 + */ +function deprecatedSectionName(reference: string): string { + const text = reference.trim(); + const bracketed = /^\[([^\]]+)\]/.exec(text); + if (bracketed !== null) { + return bracketed[1].split(".")[0].trim(); + } + return text.split(".")[0].trim(); +} + +/** + * 快照自身的结构问题:重复段头、空平面、以及指向不存在段的 deprecatedBy。 + * 生成器保证当前快照为空数组;测试把它当门禁。 + */ +export function sectionProblems(): string[] { + const problems: string[] = []; + const seen = new Set(); + for (const section of SCHEMA.sections) { + if (seen.has(section.header)) { + problems.push(`duplicate section header ${section.header}`); + } + seen.add(section.header); + + if (section.plane.trim() === "") { + problems.push(`section ${section.header} has an empty plane`); + } + + if (section.deprecatedBy) { + const target = deprecatedSectionName(section.deprecatedBy); + if (target === "" || sectionByName(target) === undefined) { + problems.push( + `${section.header} is deprecated by ${section.deprecatedBy}, which names no known section`, + ); + } + } + } + return problems; +} + +/** 快照来自哪个 mcpp 版本与提交(环境自检与诊断输出用)。 */ +export function schemaSource(): { version: string; commit: string } { + return { version: SCHEMA.sourceVersion, commit: SCHEMA.sourceCommit }; +} diff --git a/src/util/format.ts b/src/util/format.ts new file mode 100644 index 0000000..d4a03b9 --- /dev/null +++ b/src/util/format.ts @@ -0,0 +1,153 @@ +/** + * Byte / count formatting for the cache views and the status bar. + * + * Pure: no `vscode`, no i18n. The unit suffixes (B/KiB/MiB/GiB/TiB) are the same + * in every language this extension speaks, so they are not translated; the + * surrounding sentence is (`src/i18n/t.ts`). + */ + +export type NumberFormat = "binary" | "decimal"; + +const BINARY_UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"] as const; +const DECIMAL_UNITS = ["B", "kB", "MB", "GB", "TB", "PB"] as const; + +function significant(value: number): string { + if (!Number.isFinite(value)) { + return "0"; + } + const abs = Math.abs(value); + if (abs >= 100) { + return value.toFixed(0); + } + if (abs >= 10) { + return value.toFixed(1); + } + return value.toFixed(2); +} + +/** + * `1500000` -> `"1.43 MiB"` (binary) / `"1.50 MB"` (decimal). + * + * Three significant digits, matching what the plan promises for the cache views: + * big numbers stay readable and small ones keep two decimals. + */ +export function formatBytes(bytes: number, format: NumberFormat = "binary"): string { + const units = format === "decimal" ? DECIMAL_UNITS : BINARY_UNITS; + const step = format === "decimal" ? 1000 : 1024; + let value = Number.isFinite(bytes) ? Math.max(0, bytes) : 0; + let index = 0; + while (value >= step && index < units.length - 1) { + value /= step; + index += 1; + } + return `${significant(value)} ${units[index]}`; +} + +/** `12345` -> `"12,345"`. Grouping follows the host locale. */ +export function formatCount(value: number): string { + return Number.isFinite(value) ? Math.round(value).toLocaleString() : "0"; +} + +/** Seconds since the Unix epoch -> `Date`, or `undefined` for a non-finite input. */ +export function fromUnixSeconds(seconds: number | undefined): Date | undefined { + if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) { + return undefined; + } + return new Date(seconds * 1000); +} + +/** + * Numbers of whole days between two instants, floored at 0. + * Used by the cache age buckets: `<1d` / `1–7d` / `7–30d` / `>30d`. + */ +export function ageInDays(at: Date, now: Date): number { + const delta = now.getTime() - at.getTime(); + return delta <= 0 ? 0 : Math.floor(delta / 86_400_000); +} + +/** + * Bucket boundaries in days, ascending, from settings such as + * `["1d", "7d", "30d"]`. Unparsable entries are dropped; the result is sorted + * and de-duplicated so a hand-edited setting cannot produce overlapping buckets. + */ +export function parseAgeBuckets(values: readonly string[] | undefined): number[] { + const parsed = new Set(); + for (const raw of values ?? []) { + const match = /^\s*(\d+)\s*([dhwm]?)\s*$/.exec(raw); + if (match === null) { + continue; + } + const amount = Number.parseInt(match[1], 10); + const unit = match[2]; + const days = unit === "h" ? amount / 24 : unit === "w" ? amount * 7 : unit === "m" ? amount * 30 : amount; + const rounded = Math.max(0, Math.round(days)); + parsed.add(rounded); + } + if (parsed.size === 0) { + return [1, 7, 30]; + } + return [...parsed].sort((a, b) => a - b); +} + +/** Which bucket index `days` falls in; `boundaries.length` is the last (oldest) bucket. */ +export function bucketIndex(days: number, boundaries: readonly number[]): number { + for (let index = 0; index < boundaries.length; index += 1) { + if (days < boundaries[index]) { + return index; + } + } + return boundaries.length; +} + +export interface CacheEntrySize { + bytes: number; + accessed?: number; + complete?: boolean; +} + +export interface CacheProjection { + /** Entries removed, in LRU order, to reach the budget. */ + removed: CacheEntrySize[]; + kept: CacheEntrySize[]; + freedBytes: number; + remainingBytes: number; +} + +/** + * Simulate `mcpp cache gc --max-size `: drop least-recently-used entries + * until the total fits the budget. Entries without a usable `accessed` timestamp + * are treated as the most recent, so a projection never promises to free + * something mcpp would keep. + * + * This is an **estimate** — mcpp's own LRU also weighs entry completeness and + * its own bookkeeping, so the figure is offered as a preview only. + */ +export function projectGc(entries: readonly CacheEntrySize[], budgetBytes: number): CacheProjection { + const total = entries.reduce((sum, entry) => sum + Math.max(0, entry.bytes), 0); + if (budgetBytes >= total) { + return { removed: [], kept: [...entries], freedBytes: 0, remainingBytes: total }; + } + const ranked = entries + .map((entry, index) => ({ entry, index })) + .sort((a, b) => { + const left = a.entry.accessed ?? Number.MAX_SAFE_INTEGER; + const right = b.entry.accessed ?? Number.MAX_SAFE_INTEGER; + return left === right ? a.index - b.index : left - right; + }); + const removed: CacheEntrySize[] = []; + let remaining = total; + for (const { entry } of ranked) { + if (remaining <= budgetBytes) { + break; + } + removed.push(entry); + remaining -= Math.max(0, entry.bytes); + } + const removedSet = new Set(removed); + return { + removed, + kept: entries.filter((entry) => !removedSet.has(entry)), + freedBytes: total - remaining, + remainingBytes: remaining, + }; +} diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..2119e4d --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,82 @@ +/** + * One place that decides whether a line reaches the `mcpp` output channel. + * + * `mcpp.log.level` is a **threshold**, not a filter list: `error` writes only + * errors, `warn` adds warnings, `info` is the declared default, and `debug` + * writes everything. Two rules keep the channel honest: + * + * - an error is never suppressed by any configured level — that is why a failed + * command's raw stdout/stderr is written at `error` by its caller; + * - an unknown configured value degrades to the default instead of silencing + * the channel, and an unknown line level is treated as the most verbose thing + * it could be. + * + * Pure and `vscode`-free: `createLogger` takes any `appendLine`-shaped sink and + * a level callback, so the policy can be asserted without an editor. + */ + +export type LogLevel = "error" | "warn" | "info" | "debug"; + +const ORDER: Readonly> = { + error: 0, + warn: 1, + info: 2, + debug: 3, +}; + +const DEFAULT_LEVEL: LogLevel = "info"; + +export function isLogLevel(value: unknown): value is LogLevel { + return value === "error" || value === "warn" || value === "info" || value === "debug"; +} + +/** Coerce a configured value: anything unknown becomes the declared default. */ +export function logLevelOf(value: unknown): LogLevel { + return isLogLevel(value) ? value : DEFAULT_LEVEL; +} + +/** + * True when a line at `level` should be written while `configured` is the + * effective `mcpp.log.level`. An error passes at every level. + */ +export function shouldLog(level: LogLevel, configured: unknown): boolean { + const threshold = ORDER[logLevelOf(configured)]; + const wanted = ORDER[isLogLevel(level) ? level : "debug"]; + return wanted <= threshold; +} + +/** The part of an `OutputChannel` this module needs. */ +export interface LogChannel { + appendLine(line: string): unknown; +} + +export interface Logger { + error(line: string): void; + warn(line: string): void; + info(line: string): void; + debug(line: string): void; +} + +/** + * A levelled writer over an output channel. The level is read per line, so a + * configuration change applies to the next line without re-creating the logger. + * A channel that has already been disposed is a no-op, never a crash. + */ +export function createLogger(channel: LogChannel, levelOf: () => unknown): Logger { + const write = (level: LogLevel, line: string): void => { + if (!shouldLog(level, levelOf())) { + return; + } + try { + channel.appendLine(line); + } catch { + // The output channel can be released during a window reload or shutdown. + } + }; + return { + error: (line) => write("error", line), + warn: (line) => write("warn", line), + info: (line) => write("info", line), + debug: (line) => write("debug", line), + }; +} diff --git a/src/util/text.ts b/src/util/text.ts new file mode 100644 index 0000000..ec3c0ae --- /dev/null +++ b/src/util/text.ts @@ -0,0 +1,79 @@ +/** + * Small text helpers shared by the CLI adapter and the views. Pure: no `vscode`. + */ + +/** `mcpp` colourises its status row; anything captured from a terminal may carry ANSI. */ +const ANSI_ESCAPE = /\u001b\[[0-?]*[ -/]*[@-~]/g; + +export function stripAnsi(text: string): string { + return text.replace(ANSI_ESCAPE, ""); +} + +export interface ClampedText { + text: string; + truncated: boolean; + /** Lines dropped from the front, so a caller can say "… N earlier lines". */ + droppedLines: number; +} + +export interface ClampOptions { + maxLines?: number; + maxChars?: number; +} + +/** + * Keep the **tail** of a long output and say so. Command output that matters + * (a failure, a list of stale directories) is at the end; the head is status + * narration. The full text is expected to be in the `mcpp` output channel. + */ +export function clampOutput(input: string, options: ClampOptions = {}): ClampedText { + const maxLines = options.maxLines ?? 400; + const maxChars = options.maxChars ?? 64 * 1024; + const normalised = stripAnsi(input).replace(/\r\n/g, "\n"); + const lines = normalised.split("\n"); + let truncated = false; + let droppedLines = 0; + let kept = lines; + if (lines.length > maxLines) { + droppedLines = lines.length - maxLines; + kept = lines.slice(droppedLines); + truncated = true; + } + let text = kept.join("\n"); + if (text.length > maxChars) { + text = text.slice(text.length - maxChars); + truncated = true; + } + return { text, truncated, droppedLines }; +} + +/** First line of a multi-line string, trimmed; used for one-line tree tooltips. */ +export function firstLine(text: string): string { + const index = text.indexOf("\n"); + return (index === -1 ? text : text.slice(0, index)).trim(); +} + +/** Escape a string for use as a `markdown` tooltip / webview text node. */ +export function escapeHtml(text: string): string { + return text + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** Split a comma-separated setting value (`"mcpp,cmake"`) into trimmed entries. */ +export function splitList(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** `["-j","4"]` -> `"-j 4"`, quoting entries that contain whitespace. */ +export function formatArguments(args: readonly string[]): string { + return args + .map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg)) + .join(" "); +} diff --git a/src/views/languageServerView.ts b/src/views/languageServerView.ts new file mode 100644 index 0000000..6baa138 --- /dev/null +++ b/src/views/languageServerView.ts @@ -0,0 +1,345 @@ +/** + * The C++ Modules language service, as commands plus one state source. + * + * There is no view of its own any more: the four things worth seeing — state, + * provider, problems, actions — are folded into the project view's 「基本信息」 + * section, and this file feeds that block. Every button still forwards an mcppls + * command; this extension never reimplements language-service behaviour and never + * writes an `mcppls.*` setting itself. The block states its provider on its own + * line, so "who owns what" stays answerable after the fold. + * + * Confirmation policy comes from the capability table (`danger` + `confirmHint`), + * so a new dangerous action cannot be added without saying what it does. + * `mcpp.languageService.confirmResetCache` can add one more explicit step to the + * reset-cache action; it can never take the capability's own modal away, and + * `./viewPolicy.ts` is where that rule lives. + * + * Three settings also own the block's *timing*: `mcpp.languageService.readState` + * decides whether state is read at all, `…stateRefreshSeconds` whether it is + * re-read on an interval, and `…notifyOnDegraded` whether the one-time + * missing-dependency line is written. `mcpp.views.languageServer.show` decides + * whether the block appears in the project view at all. + */ + +import { existsSync } from "node:fs"; + +import * as vscode from "vscode"; + +import { LANGUAGE_SERVER_COMMANDS, LEGACY_LANGUAGE_SERVER_COMMANDS, TOOL_COMMANDS } from "../commands/ids"; +import { read } from "../config/access"; +import { t } from "../i18n/t"; +import type { LanguageServerBridge } from "../mcppls/bridge"; +import { capability } from "../mcppls/contract"; +import { confirmationFor, formatResult, type FormattedResult } from "../mcppls/messages"; +import { + languageServerEnabled, + languageServerInstalled, + languageServerVersion, + readLanguageServerState, +} from "../mcppls/stateSource"; +import { PollTimer } from "../mcppls/timers"; +import { refreshTimerDecision } from "../cache/cacheState"; +import type { LanguageServiceBlock } from "./models"; +import { degradedNoticeEnabled, resetCacheConfirmation } from "./viewPolicy"; + +/** The tick granularity; the settings are in seconds. */ +const REFRESH_TICK_MS = 100; + +export interface LanguageServerViewDeps { + bridge: LanguageServerBridge; + output: vscode.OutputChannel; +} + +/** + * The folded block's data source, handed to the project view. + * + * `input()` reads; `onDidChange` tells the project view when to rebuild; and + * `setVisible` is how the poller learns whether the project view is on screen at + * all. Keeping the timer here rather than in the project view means the setting + * that shapes it (`mcpp.languageService.stateRefreshSeconds`) is read next to the + * action it governs. + */ +export interface LanguageServerBlockSource { + /** The block's current input, or `undefined` while `mcpp.views.languageServer.show` is off. */ + input(): LanguageServiceBlock | undefined; + /** Fires whenever the state, or the setting that shows it, may have changed. */ + onDidChange(listener: () => void): vscode.Disposable; + /** The project view says whether its tree is visible; the poller follows it. */ + setVisible(visible: boolean): void; +} + +/** Ask, run, report — the same shape for every forwarded command. */ +export async function runLanguageServerCommand( + bridge: LanguageServerBridge, + output: vscode.OutputChannel, + key: string, + options: { args?: unknown[]; value?: unknown; extraConfirmation?: string } = {}, +): Promise<{ message: string; value?: unknown } | undefined> { + const confirmation = confirmationFor(key, options.value); + if (confirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage(confirmation, { modal: true }, run); + if (choice !== run) { + return undefined; + } + } + // A setting may ask once more; it never replaces the capability's own modal. + if (options.extraConfirmation !== undefined) { + const run = t("Run"); + const choice = await vscode.window.showWarningMessage( + options.extraConfirmation, + { modal: true }, + run, + ); + if (choice !== run) { + return undefined; + } + } + const result = await bridge.invoke(key, ...(options.args ?? [])); + const formatted: FormattedResult = formatResult(result); + try { + output.appendLine(`[C++ Modules] ${formatted.message}${formatted.hint === undefined ? "" : ` ${formatted.hint}`}`); + } catch { + // The channel can be gone during shutdown. + } + if (formatted.severity === "error") { + void vscode.window.showErrorMessage(formatted.message); + } else if (formatted.severity === "warning") { + void vscode.window.showWarningMessage(formatted.message); + } + return result.value === undefined + ? { message: formatted.message } + : { message: formatted.message, value: result.value }; +} + +/** + * The extra confirmation `mcpp.languageService.confirmResetCache` adds, or + * `undefined` when the capability's own `danger` level should be the only one. + * `bridge.dangerOf` is the capability table's answer, never a copy of it. + * + * The text is the capability's own `confirmHint`, so the second modal repeats + * exactly what the reset does — the setting buys a second explicit "yes", not a + * second explanation. It is resolved at render time, like every other capability + * string (`src/mcppls/messages.ts`). + */ +export function extraResetCacheConfirmation( + bridge: LanguageServerBridge, + confirmResetCache: boolean, +): string | undefined { + const decision = resetCacheConfirmation({ + danger: bridge.dangerOf("resetCache"), + confirmResetCache, + }); + return decision === "extra-modal" ? capability("resetCache")?.confirmHint : undefined; +} + +/** + * Registers every `mcpp.languageServer.*` command (and the forwarded mcppls + * actions behind them) and returns the state source the project view renders. + */ +export function registerLanguageServerCommands( + context: vscode.ExtensionContext, + deps: LanguageServerViewDeps, +): LanguageServerBlockSource { + const changed = new vscode.EventEmitter(); + context.subscriptions.push(changed); + + // Whether the project view's tree is on screen. The timer below follows it, so + // a collapsed sidebar does not keep asking mcppls for state. + let visible = false; + const refresh = (): void => { + changed.fire(); + }; + + const input = (): LanguageServiceBlock | undefined => { + // Literal key, so the wiring gate can see it. + if (read("mcpp.views.languageServer.show") === false) { + return undefined; + } + return { + show: true, + installed: languageServerInstalled(), + enabled: languageServerEnabled(), + version: languageServerVersion(), + state: readLanguageServerState(), + }; + }; + + // `mcpp.languageService.stateRefreshSeconds`, but only while the project view is + // visible and only while state is read at all (0 = off). + const timer = new PollTimer({ + periodMs: 0, + tick: (): void => { + if (visible) { + refresh(); + } + }, + }); + context.subscriptions.push(timer); + const applyTimer = (): void => { + const disabled = read("mcpp.languageService.readState") === false; + const seconds = read("mcpp.languageService.stateRefreshSeconds"); + const usable = !disabled && Number.isFinite(seconds) && seconds > 0; + const decision = refreshTimerDecision(usable ? seconds : 0, visible); + timer.start(decision.active ? decision.seconds * 1000 : 0); + }; + applyTimer(); + + const register = (id: string, handler: (...args: unknown[]) => Promise): void => { + context.subscriptions.push( + vscode.commands.registerCommand(id, async (...args: unknown[]) => { + try { + await handler(...args); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + deps.output.appendLine(t("C++ Modules command failed: {0}", message)); + void vscode.window.showErrorMessage(t("{0} failed: {1}", id, message)); + } + }), + ); + }; + + const forward = (id: string, key: string, args?: () => unknown[]): void => { + register(id, async () => { + const options: { args?: unknown[]; extraConfirmation?: string } = + args === undefined ? {} : { args: args() }; + if (key === "resetCache") { + const extra = extraResetCacheConfirmation( + deps.bridge, + read("mcpp.languageService.confirmResetCache"), + ); + if (extra !== undefined) { + options.extraConfirmation = extra; + } + } + await runLanguageServerCommand(deps.bridge, deps.output, key, options); + refresh(); + }); + }; + + register(LANGUAGE_SERVER_COMMANDS.refreshState, async () => { + refresh(); + }); + + /** + * Where the last bundle was written. + * + * `mcppls.exportDiagnosticBundle` resolves to the zip it wrote, and that path + * is the only reliable answer to "where is it?" — the directory is + * `/bundles` on the server's own terms, which this extension + * must not guess at. Remembered machine-wide, because the bundle directory is + * machine-wide too. + */ + const BUNDLE_PATH_KEY = "mcpp.languageServer.lastDiagnosticBundle"; + const bundleFrom = (value: unknown): string | undefined => { + const path = value instanceof vscode.Uri ? value.fsPath : typeof value === "string" ? value : undefined; + return path !== undefined && path.length > 0 ? path : undefined; + }; + + register(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle, async () => { + const result = await runLanguageServerCommand(deps.bridge, deps.output, "diagnosticBundle"); + const bundle = bundleFrom(result?.value); + if (bundle !== undefined) { + await context.globalState.update(BUNDLE_PATH_KEY, bundle); + } + refresh(); + }); + + register(LANGUAGE_SERVER_COMMANDS.revealBundle, async () => { + const remembered = context.globalState.get(BUNDLE_PATH_KEY); + if (remembered === undefined || !existsSync(remembered)) { + const exportNow = t("Capture logs now"); + const choice = await vscode.window.showInformationMessage( + t("No diagnostic bundle has been written yet. Capturing the logs writes one zip with the report, the environment and the recent logs."), + exportNow, + ); + if (choice === exportNow) { + await vscode.commands.executeCommand(LANGUAGE_SERVER_COMMANDS.exportDiagnosticBundle); + } + return; + } + await vscode.commands.executeCommand("revealFileInOS", vscode.Uri.file(remembered)); + }); + + register(LANGUAGE_SERVER_COMMANDS.openLogFolder, async () => { + // Upstream owns the path: `revealCacheDirectory('logs')` asks the server's own + // detail for `paths.logDirectory` and reveals it, falling back to the output + // channel when the server cannot answer. Nothing here reconstructs a path. + await runLanguageServerCommand(deps.bridge, deps.output, "logsDirectory", { args: ["logs"] }); + }); + + forward(LANGUAGE_SERVER_COMMANDS.restart, "restartServer"); + forward(LANGUAGE_SERVER_COMMANDS.restartEngine, "restartEngine"); + forward(LANGUAGE_SERVER_COMMANDS.resetWorkspaceCache, "resetCache"); + forward(LANGUAGE_SERVER_COMMANDS.selectContext, "selectContext"); + forward(LANGUAGE_SERVER_COMMANDS.showModuleGraph, "moduleGraph"); + forward(LANGUAGE_SERVER_COMMANDS.showLogs, "logs"); + forward(LANGUAGE_SERVER_COMMANDS.collectReport, "report"); + // `exportDiagnosticBundle` is registered above rather than forwarded: its + // answer is the zip's path, and the caller has to keep it. + forward(LANGUAGE_SERVER_COMMANDS.runBuildToolInTerminal, "runBuildTool"); + forward(LANGUAGE_SERVER_COMMANDS.installTools, "installTools"); + forward(LANGUAGE_SERVER_COMMANDS.manageConflicts, "manageConflicts", () => [true]); + forward(LANGUAGE_SERVER_COMMANDS.reviewChanges, "review", () => [false]); + forward(LANGUAGE_SERVER_COMMANDS.toggleInWorkspace, "toggleInWorkspace", () => [!languageServerEnabled()]); + + // The legacy ids from 0.4.x keep working, pointing at the new actions. + register(LEGACY_LANGUAGE_SERVER_COMMANDS.configureLanguageServer, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register("mcpp.configureClangd", async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "selectContext"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.checkModuleSupport, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "restartServer"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showModuleGraph, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "moduleGraph"); + }); + register(LEGACY_LANGUAGE_SERVER_COMMANDS.showLanguageServerLogs, async () => { + await runLanguageServerCommand(deps.bridge, deps.output, "logs"); + }); + + register(TOOL_COMMANDS.openLanguageServerSettings, async () => { + await vscode.commands.executeCommand("workbench.action.openSettings", "@ext:sunrisepeak.mcpp-language-server"); + }); + + context.subscriptions.push( + vscode.extensions.onDidChange(() => refresh()), + vscode.workspace.onDidChangeConfiguration((event) => { + if ( + event.affectsConfiguration("mcppls.enable") || + event.affectsConfiguration("mcpp.views.languageServer") || + event.affectsConfiguration("mcpp.languageService") + ) { + refresh(); + applyTimer(); + } + }), + ); + + // A capability that has gone missing is worth one sentence, once — unless + // `mcpp.languageService.notifyOnDegraded` says the user does not want it. + if (degradedNoticeEnabled(read("mcpp.languageService.notifyOnDegraded")) && !languageServerInstalled()) { + deps.output.appendLine( + t("The C++ Modules extension ({0}) is not installed or is disabled.", capability("refresh")?.key ?? ""), + ); + } + + return { + input, + onDidChange: (listener: () => void): vscode.Disposable => changed.event(listener), + setVisible: (next: boolean): void => { + if (visible === next) { + return; + } + visible = next; + applyTimer(); + // Becoming visible is exactly when a stale block must be re-read. + if (visible) { + refresh(); + } + }, + }; +} + diff --git a/src/views/models.ts b/src/views/models.ts new file mode 100644 index 0000000..95028d1 --- /dev/null +++ b/src/views/models.ts @@ -0,0 +1,878 @@ +/** + * The trees, as **data**. + * + * Labels are keys, not sentences: the builder stays free of `vscode` and of the + * current language, the tests assert stable keys, and `src/views/treeProvider.ts` + * resolves them through `src/i18n/t.ts` at render time. A label's arguments may + * themselves be labels, so a phrase like `C++23 · 87 source file(s)` is composed + * from two independently translatable pieces instead of one frozen sentence. + * + * The project view is two labelled sections: **Basics** (what this project is) + * and **Common commands** (what can be done to it). The C++ Modules block is + * folded into the first one, with the status line carrying the problem count, so + * a degraded language service is visible **without expanding anything**. The + * second section is the only place in the view whose rows are all commands, and + * it is the one that starts open: the sidebar should open on what can be done, + * with 「基本信息」 one click away. + * + * Everything the project view needs from disk lives here as well — the declared + * dependencies, `mcpp.lock`'s resolved versions and the source-file count — so + * the whole layout can be unit tested without an editor host. Nothing here + * imports `vscode`; `test/architecture.test.ts` enforces that. + */ + +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; + +import { parseMcppToml } from "../toml/parser"; + +export type LabelArgument = string | number | Label; + +export interface Label { + key: string; + args?: readonly LabelArgument[]; +} + +export interface TreeCommand { + command: string; + title: Label; + arguments?: readonly unknown[]; +} + +export interface TreeNode { + id: string; + label: Label; + description?: Label; + tooltip?: Label; + /** A codicon id without the `$()`, e.g. `"database"`. */ + icon?: string; + /** + * A `ThemeColor` id the icon is drawn in, e.g. `"charts.blue"`. + * + * Only ids the colour registry always carries are used (`charts.*`), and a + * theme that omits one leaves the icon in the normal foreground colour rather + * than making the row unreadable — colour is a hint here, never the state. + */ + iconColor?: string; + /** Consumed by `when` clauses in package.json for inline actions. */ + contextValue?: string; + /** + * Render expanded on first display. A node with children is collapsed unless + * this is set. 「常用命令」 starts open because it is the part of the view that + * gets used; 「基本信息」 starts closed, so the sidebar opens on what can be + * *done* rather than on what the project happens to be. + */ + expanded?: boolean; + command?: TreeCommand; + children?: readonly TreeNode[]; +} + +const plain = (text: string): Label => ({ key: text }); + +/** `a`, `b` -> `a · b`, nested so each side keeps its own translation. */ +function joined(parts: readonly Label[]): Label | undefined { + if (parts.length === 0) { + return undefined; + } + return parts.reduce((left, right): Label => ({ key: "{0} · {1}", args: [left, right] })); +} + +export interface TargetSummary { + name: string; + kind: string; +} + +/** One dependency declared in `mcpp.toml`, before `mcpp.lock` says what it resolved to. */ +export interface DependencyDeclaration { + /** `namespace.name` as written in the manifest. */ + name: string; + /** The declared version constraint, when the manifest states one. */ + version?: string; + /** Declared in `[dev-dependencies]`. */ + dev?: boolean; + /** A local path dependency. */ + path?: string; + /** A git dependency. */ + git?: string; +} + +export interface ProjectSummary { + root: string; + name?: string; + version?: string; + standard?: string; + profile?: string; + toolchainSpec?: string; + target?: string; + targets?: readonly TargetSummary[]; + hasTests?: boolean; + /** Declared in `[dependencies]` / `[dev-dependencies]`; absent when there are none. */ + dependencies?: readonly DependencyDeclaration[]; + /** Source files counted under the project root; absent when not measured. */ + sourceFiles?: number; + /** Set when `mcpp.toml` could not be read; the tree then says so instead of lying. */ + error?: string; +} + +/* ------------------------------------------------------------------ mcpp.toml */ + +/** The two manifest groups the project view reports. A dep is either runtime or dev. */ +const DEPENDENCY_GROUPS: Readonly> = { + dependencies: false, + "dev-dependencies": true, +}; + +function dependencyField(entry: DependencyDeclaration, key: string, value: string | undefined): void { + if (value === undefined || value.length === 0) { + return; + } + if (key === "version") { + entry.version = value; + } else if (key === "path") { + entry.path = value; + } else if (key === "git") { + entry.git = value; + } +} + +/** + * The declared dependencies, read with the fault-tolerant TOML scanner the + * completion provider already uses. + * + * Three shapes are recognised, because all three are legal mcpp.toml: + * `name = "1.0"`, `name = { path = "…" }` (including multi-line inline tables) + * and `[dependencies.name]` with the fields on following lines. `workspace` and + * `features` are deliberately ignored: neither changes what the row must say. + */ +export function readDependencies(lines: readonly string[]): DependencyDeclaration[] { + const found: DependencyDeclaration[] = []; + const byName = new Map(); + const ensure = (name: string, dev: boolean): DependencyDeclaration => { + const existing = byName.get(name); + if (existing !== undefined) { + return existing; + } + const entry: DependencyDeclaration = dev ? { name, dev: true } : { name }; + byName.set(name, entry); + found.push(entry); + return entry; + }; + + try { + let group: string | undefined; + let named: DependencyDeclaration | undefined; + for (const node of parseMcppToml(lines).nodes) { + if (node.type === "section") { + const segments = node.segments.map((segment) => segment.name); + const head = segments[0]; + if (head === undefined || DEPENDENCY_GROUPS[head] === undefined) { + group = undefined; + named = undefined; + continue; + } + group = head; + // `[dependencies.name]` names the dependency; its fields follow. + named = segments.length >= 2 ? ensure(segments.slice(1).join("."), DEPENDENCY_GROUPS[head] === true) : undefined; + continue; + } + if (group === undefined) { + continue; + } + const dev = DEPENDENCY_GROUPS[group] === true; + const key = node.keyPath.map((segment) => segment.name).join("."); + const value = node.value; + if (value === undefined) { + continue; + } + if (named !== undefined) { + dependencyField(named, key, value.text); + continue; + } + if (key.length === 0) { + continue; + } + const entry = ensure(key, dev); + if (value.kind === "string") { + dependencyField(entry, "version", value.text); + } else if (value.kind === "inlineTable") { + for (const field of value.entries ?? []) { + dependencyField(entry, field.keyPath.map((segment) => segment.name).join("."), field.value?.text); + } + } + } + } catch { + // A manifest being edited is not an error here; whatever was read stands. + } + return found; +} + +/* ------------------------------------------------------------------ mcpp.lock */ + +/** One `[package."…"]` entry of `mcpp.lock`: a resolved package, with no parent edge. */ +export interface LockPackage { + /** The table key as written: `openkal` or `compat.freetype`. */ + key: string; + namespace?: string; + version?: string; + source?: string; + hash?: string; +} + +const LOCK_HEADER = /^\[\s*package\s*\.\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\s*\]\s*$/; +const LOCK_FIELD = /^([A-Za-z0-9_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^#\s]+))\s*(?:#.*)?$/; +const LOCK_FIELDS: ReadonlySet = new Set(["namespace", "version", "source", "hash"]); + +/** + * `mcpp.lock`, read tolerantly. + * + * The file is a flat TOML set — `version = 2` then one `[package."…"]` table per + * resolved package — and it deliberately carries **no parent/child edges**, + * which is why the view can only ever show two levels. Anything unrecognised is + * skipped and nothing here throws: a file being written is still a valid lock + * with fewer entries, not a broken view. + */ +export function parseLockfile(text: string): LockPackage[] { + const packages: LockPackage[] = []; + try { + let current: LockPackage | undefined; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.length === 0 || line.startsWith("#")) { + continue; + } + const header = LOCK_HEADER.exec(line); + if (header !== null) { + const key = header[1] ?? header[2] ?? header[3] ?? ""; + if (key.length === 0) { + current = undefined; + continue; + } + current = { key }; + packages.push(current); + continue; + } + if (line.startsWith("[")) { + current = undefined; + continue; + } + if (current === undefined) { + continue; + } + const field = LOCK_FIELD.exec(line); + if (field === null || !LOCK_FIELDS.has(field[1])) { + continue; + } + const value = field[2] ?? field[3] ?? field[4]; + if (value === undefined || value.length === 0) { + continue; + } + if (field[1] === "namespace") current.namespace = value; + else if (field[1] === "version") current.version = value; + else if (field[1] === "source") current.source = value; + else current.hash = value; + } + } catch { + // Tolerant by construction: an unreadable lock is an empty lock. + } + return packages; +} + +/** {@link parseLockfile} for a path that may not exist. A missing lock is empty, not an error. */ +export function readLockfile(file: string): LockPackage[] { + try { + return parseLockfile(readFileSync(file, "utf8")); + } catch { + return []; + } +} + +/** + * The lock entry a declaration resolved to, matched by `namespace.name`. + * + * Real lock files are inconsistent about the table key: `[package."openkal"]` + * carries `namespace = "mcpplibs"` while `[package."compat.freetype"]` already + * folds the namespace into the key. Both resolve here; when the entry has no + * namespace of its own, the name is the only thing left to match on. + */ +export function resolveLockedPackage(entries: readonly LockPackage[], name: string): LockPackage | undefined { + const dot = name.lastIndexOf("."); + const namespace = dot > 0 ? name.slice(0, dot) : undefined; + const local = dot > 0 ? name.slice(dot + 1) : name; + for (const entry of entries) { + if (entry.key === name) { + return entry; + } + const entryLocal = entry.key.includes(".") ? entry.key.slice(entry.key.lastIndexOf(".") + 1) : entry.key; + if (entryLocal !== local) { + continue; + } + if (namespace === undefined || entry.namespace === undefined || entry.namespace === namespace) { + return entry; + } + } + return undefined; +} + +/* --------------------------------------------------------------- source files */ + +/** Extensions counted as source: module interfaces, translation units and headers. */ +const SOURCE_EXTENSIONS: ReadonlySet = new Set([ + ".c", + ".cc", + ".cpp", + ".cxx", + ".c++", + ".cppm", + ".ixx", + ".mpp", + ".h", + ".hh", + ".hpp", + ".hxx", + ".ipp", + ".inl", +]); + +/** Directories that hold build output or other people's code, never the project's own sources. */ +const SOURCE_SKIP_DIRS: ReadonlySet = new Set([ + "target", + "build", + "out", + "dist", + "node_modules", + ".git", + ".mcpp", + ".vscode", + ".cache", +]); + +const SOURCE_MAX_ENTRIES = 20_000; +const SOURCE_MAX_DEPTH = 8; + +/** + * How many source files this project has, counted under `root`. + * + * Bounded and non-throwing, like every other walk in this codebase: a huge or + * unreadable tree stops early rather than hanging the extension host, and the + * build directories are skipped so `target/` cannot flatter the number. The + * count is what makes the identity row say something about the project's size + * without running mcpp. + */ +export function countSourceFiles(root: string): number { + let count = 0; + let seen = 0; + const visit = (dir: string, depth: number): void => { + if (depth > SOURCE_MAX_DEPTH || seen >= SOURCE_MAX_ENTRIES) { + return; + } + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + seen += 1; + if (seen > SOURCE_MAX_ENTRIES) { + return; + } + if (entry.isSymbolicLink()) { + continue; + } + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!SOURCE_SKIP_DIRS.has(entry.name)) { + visit(full, depth + 1); + } + } else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + count += 1; + } + } + }; + visit(root, 0); + return count; +} + +/* ---------------------------------------------------------------- keybindings */ + +export interface KeybindingHint { + key?: string; + mac?: string; +} + +export type KeyboardPlatform = "mac" | "other"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * The keybindings this extension contributes, keyed by command id, read from + * `package.json` rather than copied into the source. + * + * A hint in the tree that disagrees with the manifest is worse than no hint, so + * the row asks the manifest. `context.extension.packageJSON` is what the caller + * hands in; this function itself is pure and testable. + */ +export function keybindingsFromPackage(packageJSON: unknown): Readonly> { + const bindings: Record = {}; + if (!isRecord(packageJSON) || !isRecord(packageJSON.contributes)) { + return bindings; + } + const list = packageJSON.contributes.keybindings; + if (!Array.isArray(list)) { + return bindings; + } + for (const entry of list) { + if (!isRecord(entry) || typeof entry.command !== "string" || bindings[entry.command] !== undefined) { + continue; + } + const key = typeof entry.key === "string" && entry.key.length > 0 ? entry.key : undefined; + const mac = typeof entry.mac === "string" && entry.mac.length > 0 ? entry.mac : undefined; + if (key === undefined && mac === undefined) { + continue; + } + const hint: KeybindingHint = {}; + if (key !== undefined) hint.key = key; + if (mac !== undefined) hint.mac = mac; + bindings[entry.command] = hint; + } + return bindings; +} + +function keyToken(token: string, platform: KeyboardPlatform): string { + if (platform === "mac") { + switch (token) { + case "cmd": + case "meta": + case "super": + return "⌘"; + case "ctrl": + case "control": + return "⌃"; + case "alt": + case "option": + return "⌥"; + case "shift": + return "⇧"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } + } + switch (token) { + case "cmd": + case "meta": + case "super": + return "Meta"; + case "ctrl": + case "control": + return "Ctrl"; + case "alt": + case "option": + return "Alt"; + case "shift": + return "Shift"; + default: + return token.length === 1 ? token.toUpperCase() : token; + } +} + +/** `cmd+alt+b` -> `⌘⌥B` on macOS, `ctrl+alt+b` -> `Ctrl+Alt+B` elsewhere. */ +export function formatKeybinding(hint: KeybindingHint | undefined, platform: KeyboardPlatform): string | undefined { + if (hint === undefined) { + return undefined; + } + const raw = platform === "mac" ? hint.mac ?? hint.key : hint.key ?? hint.mac; + if (raw === undefined || raw.trim().length === 0) { + return undefined; + } + const parts = raw + .split("+") + .map((part) => keyToken(part.trim().toLowerCase(), platform)) + .filter((part) => part.length > 0); + if (parts.length === 0) { + return undefined; + } + return platform === "mac" ? parts.join("") : parts.join("+"); +} + +/* ------------------------------------------------------------ language service */ + +/** The part of an mcppls issue the folded block renders. `clangd` is never among them. */ +export interface LanguageServiceIssue { + code: string; + message: string; + /** mcppls's own remedy for this issue, when it offers one. */ + command?: { command: string; arguments?: unknown[]; title?: string }; +} + +/** + * The folded C++ Modules block. + * + * Only the language service's own identity, its state and its problems are + * rendered: the engines, the semantic profile and the compilation database used + * to *define* the old view, and the spec is explicit that `clangd` must not + * appear anywhere in it (§8.2). + */ +export interface LanguageServiceBlock { + /** `mcpp.views.languageServer.show`; `false` removes the block from the tree. */ + show?: boolean; + installed: boolean; + enabled?: boolean; + version?: string; + state?: { + available: boolean; + reason?: string; + state?: string; + issues?: readonly LanguageServiceIssue[]; + }; +} + +/** The provider line the block always carries, so "who owns this" stays answerable. */ +export const LANGUAGE_SERVICE_PROVIDER = "sunrisepeak.mcpp-language-server"; + +interface ActionRow { + id: string; + label: string; + icon: string; + command: string; +} + +/** The four actions worth a permanent row; everything else folds into 「其它 N 项…」. */ +const LANGUAGE_SERVICE_COMMON_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.restart", label: "Restart language service", icon: "debug-restart", command: "mcpp.languageServer.restart" }, + { id: "project.languageService.action.selectContext", label: "Select analysis context", icon: "symbol-interface", command: "mcpp.languageServer.selectContext" }, + { id: "project.languageService.action.graph", label: "Module graph", icon: "type-hierarchy", command: "mcpp.languageServer.showModuleGraph" }, + { id: "project.languageService.action.logs", label: "Open logs", icon: "output", command: "mcpp.languageServer.showLogs" }, +]; + +const LANGUAGE_SERVICE_MORE_ACTIONS: readonly ActionRow[] = [ + { id: "project.languageService.action.refreshState", label: "Refresh this view", icon: "refresh", command: "mcpp.languageServer.refreshState" }, + { id: "project.languageService.action.restartEngine", label: "Restart the semantic engine", icon: "debug-restart", command: "mcpp.languageServer.restartEngine" }, + { id: "project.languageService.action.resetCache", label: "Reset this workspace's cache", icon: "trash", command: "mcpp.languageServer.resetWorkspaceCache" }, + { id: "project.languageService.action.report", label: "Collect a diagnostic report", icon: "report", command: "mcpp.languageServer.collectReport" }, + { id: "project.languageService.action.bundle", label: "Export a diagnostic bundle", icon: "file-zip", command: "mcpp.languageServer.exportDiagnosticBundle" }, + { id: "project.languageService.action.runBuildTool", label: "Run the build tool in a terminal", icon: "terminal", command: "mcpp.languageServer.runBuildToolInTerminal" }, + { id: "project.languageService.action.settings", label: "Open the C++ Modules settings", icon: "settings-gear", command: "mcpp.openMcpplsSettings" }, +]; + +/* -------------------------------------------------------------- project view */ + +export interface ProjectTreeOptions { + /** `mcpp.lock`'s resolved packages, matched to the declarations by name. */ + lock?: readonly LockPackage[]; + /** The folded C++ Modules block; omitted means "no block". */ + languageService?: LanguageServiceBlock; + /** Keybindings read from the extension manifest. */ + keybindings?: Readonly>; + platform?: KeyboardPlatform; +} + +/** The project view: two labelled sections — what this is, then what to do with it. */ +export function buildProjectTree(project: ProjectSummary | undefined, options: ProjectTreeOptions = {}): TreeNode[] { + if (project === undefined) { + // An empty workspace gets the one thing that can be done in it, next to the + // sentence that says why the view is otherwise empty. 「初始化当前目录」 is + // deliberately absent: `mcpp new` refuses a destination that already exists + // (`scaffold/create.cppm`: `"'{}' already exists"`) and has no `--here`, so + // there is nothing to run for "make this folder a project" — see §17 of + // `.agents/docs/archive/2026-10-02-ui-ux-optimisation-plan.md`. + return [ + { + id: "project.none", + label: plain("No mcpp project in this workspace"), + icon: "info", + tooltip: plain("Open a folder containing mcpp.toml, or create a project with mcpp: New Project."), + }, + newProjectNode(), + ]; + } + if (project.error !== undefined) { + return [ + { + id: "project.error", + label: plain("mcpp.toml could not be read"), + description: { key: "{0}", args: [project.error] }, + icon: "error", + contextValue: "mcppProjectError", + }, + ]; + } + + const facts: Label[] = []; + if (project.standard !== undefined) { + facts.push({ key: "{0}", args: [project.standard] }); + } + if (project.sourceFiles !== undefined) { + facts.push({ key: "{0} source file(s)", args: [project.sourceFiles] }); + } + const identity: TreeNode = { + id: "project.package", + label: plain(project.name ?? "mcpp project"), + icon: "package", + tooltip: + project.version === undefined + ? { key: "{0}", args: [project.root] } + : { key: "{0} · version {1}", args: [project.root, project.version] }, + contextValue: "mcppProject", + }; + const identityFacts = joined(facts); + if (identityFacts !== undefined) { + identity.description = identityFacts; + } + + const basic: TreeNode[] = [ + identity, + ...(project.target === undefined + ? [] + : [{ id: "project.target", label: plain("Target"), description: { key: "{0}", args: [project.target] }, icon: "target" }]), + { + id: "project.toolchain", + label: plain("Toolchain"), + description: { key: "{0}", args: [project.toolchainSpec ?? "host default"] }, + icon: "chip", + }, + ...dependencyNodes(project, options), + ...languageServiceNodes(options.languageService), + ]; + + return [ + { id: "project.section.basic", label: plain("Basics"), icon: "info", children: basic }, + { id: "project.section.commands", label: plain("Common commands"), icon: "terminal", expanded: true, children: commandNodes(options) }, + ]; +} + +interface CommandRow { + id: string; + label: string; + icon: string; + /** See `TreeNode.iconColor`; the palette groups rows by what they act on. */ + iconColor: string; + command: string; +} + +/** + * The nine rows of 「常用命令」, in the order the prototype fixes them, with + * creation first: the section is what the view offers to *do*, and in a new + * workspace the only thing to do is make a project. + * + * The colours are a small, explainable palette grouped by **what the row acts + * on**, not by decoration: blue builds or adds, green runs or verifies, purple + * tests, red deletes, yellow manages the toolchain, and configuration stays in + * the ordinary foreground so it never competes with the actions. + * + * Deletion is `charts.red` and not `charts.orange` because `charts.orange` + * resolves to `minimap.findMatchHighlight` -> `editor.findMatchHighlightBackground`, + * which is `#EA5C00` at 33% alpha: as a glyph colour that is a washed-out smear + * in both themes. `charts.red` is `editorError.foreground`, a solid value in + * every theme. The quick menu uses the same words, one file per colour — see + * `src/commands/menu.ts`. + */ +const COMMON_COMMANDS: readonly CommandRow[] = [ + { id: "project.action.new", label: "New mcpp project…", icon: "new-folder", iconColor: "charts.blue", command: "mcpp.newProject" }, + { id: "project.action.build", label: "Build", icon: "tools", iconColor: "charts.blue", command: "mcpp.build" }, + { id: "project.action.run", label: "Run", icon: "play", iconColor: "charts.green", command: "mcpp.run" }, + { id: "project.action.test", label: "Test", icon: "beaker", iconColor: "charts.purple", command: "mcpp.test" }, + { id: "project.action.clean", label: "Clean", icon: "trash", iconColor: "charts.red", command: "mcpp.cleanProjectArtifacts" }, + { id: "project.action.toolchain", label: "Toolchain", icon: "chip", iconColor: "charts.yellow", command: "mcpp.showToolchains" }, + { id: "project.action.librarySearch", label: "Search and add a dependency…", icon: "library", iconColor: "charts.blue", command: "mcpp.library.search" }, + { id: "project.action.selfCheck", label: "Environment self-check", icon: "heart", iconColor: "charts.green", command: "mcpp.selfCheck" }, + { id: "project.action.settings", label: "Settings", icon: "settings-gear", iconColor: "", command: "mcpp.openSettings" }, +]; + +/** The creation row, built once so the empty state and the section agree. */ +function newProjectNode(): TreeNode { + const row = COMMON_COMMANDS[0]; + const label = plain(row.label); + return { + id: row.id, + label, + icon: row.icon, + iconColor: row.iconColor, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; +} + +function commandNodes(options: ProjectTreeOptions): TreeNode[] { + return COMMON_COMMANDS.map((row): TreeNode => { + const hint = formatKeybinding(options.keybindings?.[row.command], options.platform ?? "other"); + const label = plain(row.label); + const node: TreeNode = { + id: row.id, + label, + icon: row.icon, + contextValue: "mcppProjectCommand", + command: { command: row.command, title: label }, + }; + if (row.iconColor.length > 0) { + node.iconColor = row.iconColor; + } + if (hint !== undefined) { + node.description = plain(hint); + } + return node; + }); +} + +function dependencyNodes(project: ProjectSummary, options: ProjectTreeOptions): TreeNode[] { + const declared = project.dependencies ?? []; + if (declared.length === 0) { + return []; + } + const lock = options.lock ?? []; + const dev = declared.filter((entry) => entry.dev === true).length; + return [ + { + id: "project.dependencies", + label: { key: "Dependencies ({0})", args: [declared.length] }, + ...(dev === 0 ? {} : { description: { key: "{0} dev", args: [dev] } }), + icon: "library", + tooltip: plain("Declared in mcpp.toml; the resolved version comes from mcpp.lock."), + expanded: true, + children: declared.map((entry) => dependencyNode(entry, lock)), + }, + ]; +} + +/** + * One declared dependency. + * + * Level 1 is the declaration, with its markers; level 2 is the single version + * `mcpp.lock` resolved, or `Resolved —` when the lock has nothing to say. There + * is deliberately no third level and no connector glyph: `mcpp.lock` is a flat + * set with no parent/child edges, so a deeper tree would be invented. + */ +function dependencyNode(declaration: DependencyDeclaration, lock: readonly LockPackage[]): TreeNode { + const resolved = resolveLockedPackage(lock, declaration.name); + const resolvedLabel: Label = + resolved?.version === undefined ? plain("Resolved —") : { key: "Resolved {0}", args: [resolved.version] }; + const marker = dependencyMarker(declaration); + const node: TreeNode = { + id: `project.dependency.${declaration.name}`, + label: plain(declaration.name), + description: marker === undefined ? resolvedLabel : { key: "{0} · {1}", args: [marker, resolvedLabel] }, + icon: declaration.path === undefined ? "package" : "folder", + }; + return node; +} + +function dependencyMarker(declaration: DependencyDeclaration): Label | undefined { + if (declaration.path !== undefined) { + return { key: "path · {0}", args: [declaration.path] }; + } + if (declaration.git !== undefined) { + return { key: "git · {0}", args: [declaration.git] }; + } + return declaration.dev === true ? plain("dev") : undefined; +} + +function languageServiceNodes(block: LanguageServiceBlock | undefined): TreeNode[] { + if (block === undefined || block.show === false) { + return []; + } + const issues = block.state?.issues ?? []; + const healthy = block.state?.available === true && block.state.state === "ready"; + const identity: Label = { key: "mcppls {0}", args: [block.version ?? "?"] }; + const node: TreeNode = { + id: "project.languageService", + label: plain("Language service"), + description: + issues.length === 0 + ? identity + : { key: "{0} · {1}", args: [identity, { key: "{0} problem(s)", args: [issues.length] }] }, + icon: healthy ? "pass" : "warning", + contextValue: "mcppLanguageService", + children: languageServiceChildren(block), + }; + if (block.state?.available === false && block.state.reason !== undefined) { + node.tooltip = plain(block.state.reason); + } + return [node]; +} + +/** + * The expanded block: state, provider, every problem, then the four common + * actions and one folded row for the rest. mcppls's engines and semantic profile + * are not read here at all — only the language service itself. + */ +function languageServiceChildren(block: LanguageServiceBlock): TreeNode[] { + if (!block.installed) { + return [ + { + id: "project.languageService.status", + label: plain("C++ Modules is not installed"), + icon: "warning", + command: { command: "mcpp.openMcpplsSettings", title: plain("Install C++ Modules") }, + }, + ]; + } + + const state = block.state; + const nodes: TreeNode[] = [ + languageServiceStatus(state), + { + id: "project.languageService.provider", + label: plain("Provided by"), + description: plain(LANGUAGE_SERVICE_PROVIDER), + icon: "beaker", + }, + ]; + + (state?.issues ?? []).forEach((issue, index) => { + nodes.push({ + id: `project.languageService.issue.${index}.${issue.code}`, + label: plain(issue.message), + description: plain(issue.code), + icon: "warning", + // S3 hands us the remedy; use it rather than inventing one. + command: + issue.command === undefined + ? undefined + : { + command: issue.command.command, + // mcppls's own title — a sentence we did not write, shown as-is. + title: plain(issue.command.title ?? "Fix"), + arguments: issue.command.arguments, + }, + }); + }); + + for (const action of LANGUAGE_SERVICE_COMMON_ACTIONS) { + nodes.push(actionNode(action)); + } + nodes.push({ + id: "project.languageService.action.more", + label: { key: "More ({0})…", args: [LANGUAGE_SERVICE_MORE_ACTIONS.length] }, + icon: "ellipsis", + children: LANGUAGE_SERVICE_MORE_ACTIONS.map((action) => actionNode(action)), + }); + return nodes; +} + +function actionNode(action: ActionRow): TreeNode { + const label = plain(action.label); + return { + id: action.id, + label, + icon: action.icon, + contextValue: "mcppProjectCommand", + command: { command: action.command, title: label }, + }; +} + +function languageServiceStatus(state: LanguageServiceBlock["state"]): TreeNode { + if (state === undefined || !state.available) { + return { + id: "project.languageService.status", + label: plain("Unavailable"), + description: plain(state?.reason ?? "not read yet"), + icon: "warning", + }; + } + return { + id: "project.languageService.status", + label: plain(state.state === "ready" ? "Ready" : state.state === "degraded" ? "Degraded" : state.state ?? "Unknown"), + icon: state.state === "ready" ? "pass" : "warning", + }; +} diff --git a/src/views/projectView.ts b/src/views/projectView.ts new file mode 100644 index 0000000..27e5239 --- /dev/null +++ b/src/views/projectView.ts @@ -0,0 +1,148 @@ +/** + * The project view: two labelled sections — 「基本信息」 and 「常用命令」. + * + * The first section is what the project is (identity, target, toolchain, the + * declared dependencies and the folded C++ Modules block); the second is what can + * be done to it. The summary is read from `mcpp.toml` and refreshed when the + * manifest changes; anything unreadable is omitted rather than guessed, and + * `mcpp: Environment Self-check` is the place that reports what mcpp itself + * resolves. + */ + +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import * as vscode from "vscode"; + +import { readProjectSummary } from "../projects/summary"; +import { MCPP_MANIFEST_GLOB } from "../projects/context"; +import type { McppProjectDiscovery } from "../projects/discovery"; +import type { LanguageServerBlockSource } from "./languageServerView"; +import { + buildProjectTree, + countSourceFiles, + keybindingsFromPackage, + readDependencies, + readLockfile, + type KeyboardPlatform, + type LockPackage, + type ProjectSummary, +} from "./models"; +import { registerTreeView } from "./treeProvider"; + +export const PROJECT_VIEW_ID = "mcpp.project"; + +export interface ProjectViewDeps { + currentProject: () => McppProjectDiscovery | undefined; + /** + * The folded C++ Modules block. Omit it and the block is never rendered, which + * is also what happens when `mcpp.views.languageServer.show` is off. + */ + languageService?: LanguageServerBlockSource; +} + +/** `mcpp.lock` sits next to `mcpp.toml`, and changes without the manifest changing. */ +const LOCKFILE_NAME = "mcpp.lock"; +const LOCKFILE_GLOB = "**/mcpp.lock"; + +/** Read one manifest into a summary; a failure becomes `error`, never a throw. */ +export function summariseProject(project: McppProjectDiscovery | undefined): ProjectSummary | undefined { + if (project === undefined) { + return undefined; + } + try { + const text = readFileSync(project.manifestPath, "utf8"); + const lines = text.split(/\r?\n/); + const summary = readProjectSummary(project.root, lines); + const dependencies = readDependencies(lines); + return dependencies.length === 0 ? summary : { ...summary, dependencies }; + } catch (error) { + return { + root: project.root, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +interface ProjectSnapshot { + summary: ProjectSummary | undefined; + lock: LockPackage[]; +} + +export function registerProjectView(context: vscode.ExtensionContext, deps: ProjectViewDeps): void { + // Read from the manifest rather than copied into the source: a hint in the tree + // that disagrees with `package.json` is worse than no hint. + const keybindings = keybindingsFromPackage(context.extension.packageJSON); + const platform: KeyboardPlatform = process.platform === "darwin" ? "mac" : "other"; + const bridge = deps.languageService; + + // Counting source files walks the project, so it is not repeated for every + // active-editor change: the manifest watcher and a workspace-folder change ask + // for a fresh count, everything else reuses the last one. + let measured: { root: string; count: number } | undefined; + const withSources = (summary: ProjectSummary | undefined, fresh: boolean): ProjectSummary | undefined => { + if (summary === undefined || summary.error !== undefined) { + return summary; + } + if (fresh || measured === undefined || measured.root !== summary.root) { + measured = { root: summary.root, count: countSourceFiles(summary.root) }; + } + return { ...summary, sourceFiles: measured.count }; + }; + + const snapshot = (project: McppProjectDiscovery | undefined, freshSources: boolean): ProjectSnapshot => ({ + summary: withSources(summariseProject(project), freshSources), + lock: project === undefined ? [] : readLockfile(path.join(project.root, LOCKFILE_NAME)), + }); + + let current = snapshot(deps.currentProject(), true); + + const view = registerTreeView(PROJECT_VIEW_ID, () => + buildProjectTree(current.summary, { + lock: current.lock, + keybindings, + platform, + languageService: bridge?.input(), + }), + ); + context.subscriptions.push(view.disposable); + + const reload = (freshSources: boolean): void => { + current = snapshot(deps.currentProject(), freshSources); + view.provider.refresh(); + }; + + const manifestWatcher = vscode.workspace.createFileSystemWatcher(MCPP_MANIFEST_GLOB); + const lockWatcher = vscode.workspace.createFileSystemWatcher(LOCKFILE_GLOB); + context.subscriptions.push( + manifestWatcher, + lockWatcher, + manifestWatcher.onDidCreate(() => reload(true)), + manifestWatcher.onDidChange(() => reload(true)), + manifestWatcher.onDidDelete(() => reload(true)), + lockWatcher.onDidCreate(() => reload(false)), + lockWatcher.onDidChange(() => reload(false)), + lockWatcher.onDidDelete(() => reload(false)), + vscode.window.onDidChangeActiveTextEditor(() => reload(false)), + vscode.workspace.onDidChangeWorkspaceFolders(() => reload(true)), + vscode.workspace.onDidGrantWorkspaceTrust(() => reload(true)), + ); + + if (bridge !== undefined) { + const tree = view.view; + context.subscriptions.push( + bridge.onDidChange(() => reload(false)), + // The folded block's poller only runs while this tree is on screen. + tree.onDidChangeVisibility(() => bridge.setVisible(tree.visible)), + ); + bridge.setVisible(tree.visible); + } + + context.subscriptions.push( + vscode.commands.registerCommand("mcpp.internal.refreshProjectView", async () => { + reload(true); + }), + ); + + reload(true); +} diff --git a/src/views/treeProvider.ts b/src/views/treeProvider.ts new file mode 100644 index 0000000..67d5ce0 --- /dev/null +++ b/src/views/treeProvider.ts @@ -0,0 +1,111 @@ +/** + * The tree provider for the project view. + * + * The trees are described as data (`./models.ts`); this file is the only part + * that knows about `vscode.TreeItem`. Labels carry keys, so the translation + * happens here, at render time. + */ + +import * as vscode from "vscode"; + +import { t } from "../i18n/t"; +import type { Label, LabelArgument, TreeNode } from "./models"; + +/** `{ key, args }` -> a sentence in the user's language. */ +export function resolveLabel(label: Label | undefined): string { + if (label === undefined) { + return ""; + } + return t(label.key, ...(label.args ?? []).map(resolveArgument)); +} + +/** + * An argument is usually a value, but it may be another label: `C++23 · 87 + * source file(s)` is two translatable pieces, and the join happens here rather + * than in a builder that must stay free of the current language. + */ +function resolveArgument(argument: LabelArgument): string | number { + return typeof argument === "object" ? resolveLabel(argument) : argument; +} + +export function toTreeItem(node: TreeNode): vscode.TreeItem { + const collapsible = + node.children === undefined || node.children.length === 0 + ? vscode.TreeItemCollapsibleState.None + : node.expanded === true + ? vscode.TreeItemCollapsibleState.Expanded + : vscode.TreeItemCollapsibleState.Collapsed; + + const item = new vscode.TreeItem(resolveLabel(node.label), collapsible); + item.id = node.id; + item.description = node.description === undefined ? undefined : resolveLabel(node.description); + item.tooltip = node.tooltip === undefined ? undefined : resolveLabel(node.tooltip); + if (node.icon !== undefined) { + // A `ThemeColor` id is resolved by the host; an id a theme does not define + // leaves the icon in the normal foreground colour, which is why the model + // only ever names `charts.*`. + item.iconPath = + node.iconColor === undefined || node.iconColor.length === 0 + ? new vscode.ThemeIcon(node.icon) + : new vscode.ThemeIcon(node.icon, new vscode.ThemeColor(node.iconColor)); + } + item.contextValue = node.contextValue; + if (node.command !== undefined) { + item.command = { + command: node.command.command, + title: resolveLabel(node.command.title), + arguments: [...(node.command.arguments ?? [])], + }; + } + return item; +} + +/** + * A provider over a snapshot the caller recomputes whenever something changes. + * `refresh()` re-asks for the root nodes and fires the tree event. + * + * It is disposable so it can be handed straight to `createTreeView`'s + * `context.subscriptions.push`, next to the view it feeds. + */ +export class StaticTreeProvider implements vscode.TreeDataProvider, vscode.Disposable { + private readonly changed = new vscode.EventEmitter(); + + public readonly onDidChangeTreeData = this.changed.event; + + public constructor(private readonly roots: () => readonly TreeNode[]) {} + + public refresh(): void { + this.changed.fire(undefined); + } + + public dispose(): void { + this.changed.dispose(); + } + + public getTreeItem(element: TreeNode): vscode.TreeItem { + return toTreeItem(element); + } + + public getChildren(element?: TreeNode): TreeNode[] { + if (element === undefined) { + return [...this.roots()]; + } + return [...(element.children ?? [])]; + } +} + +/** + * Registers a view and hands back the provider, the view and its disposable. + * + * The `TreeView` is returned (rather than using `registerTreeDataProvider`) + * because a caller may need its visibility: the project view gates the C++ + * Modules poller on whether its tree is actually on screen. + */ +export function registerTreeView( + viewId: string, + roots: () => readonly TreeNode[], +): { provider: StaticTreeProvider; view: vscode.TreeView; disposable: vscode.Disposable } { + const provider = new StaticTreeProvider(roots); + const view = vscode.window.createTreeView(viewId, { treeDataProvider: provider }); + return { provider, view, disposable: view }; +} diff --git a/src/views/viewPolicy.ts b/src/views/viewPolicy.ts new file mode 100644 index 0000000..6dffc0d --- /dev/null +++ b/src/views/viewPolicy.ts @@ -0,0 +1,46 @@ +/** + * The C++ Modules view's settings, as decisions. + * + * Three settings shape that view, and each one is a policy question rather than + * a rendering detail, so the answers live here — free of `vscode` and therefore + * testable: + * + * - `mcpp.languageService.confirmResetCache` — does resetting the workspace cache + * ask a modal of its own, or does it rely on the capability table's `danger`? + * The answer may only ever *add* a confirmation; + * - `mcpp.languageService.notifyOnDegraded` — is the one-time "the dependency is + * gone" notice emitted at all? + * + * `src/mcppls/contract.ts` stays the single source of what each capability's + * danger is; `src/views/languageServerView.ts` stays the only place that talks to + * `vscode.window`. + */ + +export type LsConfirmation = "extra-modal" | "capability" | "none"; + +export interface ResetCacheConfirmInput { + /** The capability's own `danger` level, from `contract.ts`. */ + danger: "none" | "confirm" | "destructive"; + /** `mcpp.languageService.confirmResetCache`. */ + confirmResetCache: boolean; +} + +/** + * What the reset-cache command must ask before forwarding. + * + * Both settings default to `true`, so the shipped behaviour is unchanged: + * the destructive capability asks its modal, and this setting adds a second + * explicit step. Turning the setting off falls back to the capability's own + * level — it never removes a `destructive` modal. + */ +export function resetCacheConfirmation(input: ResetCacheConfirmInput): LsConfirmation { + if (!input.confirmResetCache) { + return input.danger === "none" ? "none" : "capability"; + } + return input.danger === "none" ? "none" : "extra-modal"; +} + +/** `mcpp.languageService.notifyOnDegraded`: emit the one-time dependency notice? */ +export function degradedNoticeEnabled(notifyOnDegraded: boolean | undefined): boolean { + return notifyOnDegraded !== false; +} diff --git a/src/webview/document.ts b/src/webview/document.ts new file mode 100644 index 0000000..cdd8005 --- /dev/null +++ b/src/webview/document.ts @@ -0,0 +1,64 @@ +/** + * One webview document: the CSP nonce and the render-only-on-change rule every + * webview host in this extension shares (library view, cache view, settings + * panel, package detail page). + * + * Assigning `webview.html` reloads the document — it throws away the scroll + * position, any half-typed input and the focus. Two properties make a host + * safe against pointless and looping reloads: + * + * 1. **The nonce is per view, not per render.** The CSP nonce is embedded in + * the document, so a fresh nonce per render makes every render a *different* + * document and no comparison can ever say "unchanged" — that is exactly how + * the library view once reloaded itself forever (flicker, unclickable rows, + * a pegged CPU). + * 2. **`paint()` assigns only when the document changed**, via + * `documentNeedsRender` in `./render`. + * + * A hidden `WebviewView` loses its context (`retainContextWhenHidden` is not + * supported), so the next resolve is a brand-new, empty webview: the host + * calls `invalidate()` on resolve and on dispose, and the comparison starts + * from nothing again. + */ +import { randomBytes } from "node:crypto"; +import * as vscode from "vscode"; + +import { documentNeedsRender } from "./render"; + +/** What an html renderer embeds: the CSP source, the script nonce, the stylesheet. */ +export interface WebviewAssets { + cspSource: string; + nonce: string; + styleUri: string; +} + +/** One webview's document state: its nonce and the html currently on screen. */ +export class WebviewDocument { + private readonly nonce = randomBytes(16).toString("base64"); + private current: string | undefined; + + /** @param stylesheet the stylesheet file, joined onto the media root. */ + constructor(private readonly stylesheet: string) {} + + /** The webview was (re)resolved or disposed: it is a fresh, empty document. */ + invalidate(): void { + this.current = undefined; + } + + assets(mediaRoot: vscode.Uri, webview: vscode.Webview): WebviewAssets { + return { + cspSource: webview.cspSource, + nonce: this.nonce, + styleUri: webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, this.stylesheet)).toString(), + }; + } + + /** Put the document on screen, but only when it says something new. */ + paint(webview: vscode.Webview, html: string): void { + if (!documentNeedsRender(this.current, html)) { + return; + } + this.current = html; + webview.html = html; + } +} diff --git a/src/webview/render.ts b/src/webview/render.ts new file mode 100644 index 0000000..0de9fe8 --- /dev/null +++ b/src/webview/render.ts @@ -0,0 +1,18 @@ +/** + * The pure half of the webview render kit. + * + * This module deliberately imports nothing — not even `vscode` — so the unit + * tests can state its semantics directly under `node --test`. + */ + +/** + * Whether a freshly rendered document should replace the one on screen. + * + * Assigning `webview.html` reloads the view, so an identical document must not + * be pushed. This is the guard that keeps a "render again" request from + * turning into a reload loop; it is the one rule of the kit that a unit test + * can state in one line. + */ +export function documentNeedsRender(rendered: string | undefined, next: string): boolean { + return rendered !== next; +} diff --git a/src/moduleSetup.ts b/src/workflows/moduleSetup.ts similarity index 88% rename from src/moduleSetup.ts rename to src/workflows/moduleSetup.ts index cc0557f..6810dde 100644 --- a/src/moduleSetup.ts +++ b/src/workflows/moduleSetup.ts @@ -1,3 +1,5 @@ +import { t } from "../i18n/t"; + export type ModuleSetupStage = "build" | "language-server"; export type ModuleSetupStepState = "succeeded" | "failed" | "cancelled" | "not-started"; export type ModuleSetupBlockedReason = "untrusted" | "busy"; @@ -26,8 +28,8 @@ export interface ModuleSetupConfirmation { export function moduleSetupConfirmation(): ModuleSetupConfirmation { return { - message: "是否构建当前 mcpp 工程并刷新 C++ 模块语言服务?", - detail: "将执行 mcpp build,并由 C++ Modules 扩展重新读取构建描述;不会修改工具链、mcpp.toml 或任何语言服务设置。", + message: t("Build the current mcpp project and refresh the C++ Modules language service?"), + detail: t("This runs mcpp build and has the C++ Modules extension re-read the build description; it does not change the toolchain, mcpp.toml or any language service setting."), }; } diff --git a/test/architecture.test.ts b/test/architecture.test.ts new file mode 100644 index 0000000..4b6c377 --- /dev/null +++ b/test/architecture.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The architectural rule this codebase is built on: **pure modules never import + * `vscode`**. + * + * It is what makes the logic testable under plain `node --test`, and it is easy + * to break by accident — a convenience `vscode.window.showWarningMessage` in a + * policy helper, or an `import * as vscode` at the top of a translation module, + * silently takes the whole file out of the unit test's reach. So the rule is + * checked two ways: the import is absent from the source, and the module can + * actually be loaded outside an editor host. + */ + +/** The modules that must stay importable without VS Code. */ +const PURE_MODULES = [ + "src/i18n/t.ts", + "src/i18n/translate.ts", + "src/cli/clean.ts", + "src/cli/cache.ts", + "src/cli/artifacts.ts", + "src/cli/process.ts", + "src/cli/protocol.ts", + "src/cli/search.ts", + "src/cli/tasks.ts", + "src/cli/newProject.ts", + "src/cli/toolchain.ts", + "src/cli/labels.ts", + "src/cli/statusBar.ts", + "src/cli/selfCheck.ts", + "src/config/registry.ts", + "src/config/validate.ts", + "src/config/panelHtml.ts", + "src/mcppls/contract.ts", + "src/mcppls/state.ts", + "src/mcppls/messages.ts", + "src/mcppls/timers.ts", + "src/projects/discovery.ts", + "src/projects/summary.ts", + "src/toml/completion.ts", + "src/toml/diagnostics.ts", + "src/toml/parser.ts", + "src/toml/schema.ts", + "src/toml/hover.ts", + "src/toml/navigation.ts", + "src/views/models.ts", + "src/cache/cacheState.ts", + "src/library/indexModel.ts", + "src/library/xpkg.ts", + "src/library/libraryHtml.ts", + "src/library/detailHtml.ts", + "src/cache/cachePanelHtml.ts", + "src/views/viewPolicy.ts", + "src/buildscript/analysis.ts", + "src/buildscript/api.ts", + "src/buildscript/modules.ts", + "src/buildscript/settings.ts", + "src/buildscript/snippets.ts", + "src/util/format.ts", + "src/util/log.ts", + "src/util/text.ts", + "src/webview/render.ts", + "src/workflows/moduleSetup.ts", +]; + +const root = process.cwd(); + +test("a pure module does not import vscode", () => { + const offenders: string[] = []; + for (const relative of PURE_MODULES) { + const text = readFileSync(path.join(root, relative), "utf8"); + // `import type` is fine: it is erased at compile time and costs nothing at + // runtime, which is exactly how `src/i18n/t.ts` reads the l10n API lazily. + for (const match of text.matchAll(/^\s*import\s+(?!type\b)[^;]*from\s+"vscode";/gm)) { + offenders.push(`${relative}: ${match[0].trim()}`); + } + assert.ok(!/require\(\s*["']vscode["']\s*\)/.test(text.replace(/require\(\s*["']vscode["']\s*\)\s*as\s+typeof\s+vscode/g, "")), + `${relative} requires vscode at module scope`); + } + assert.deepEqual(offenders, [], `these pure modules must not import vscode:\n ${offenders.join("\n ")}`); +}); + +test("a pure module can actually be loaded without an editor host", async () => { + // The source check above proves the absence of an import; this proves the + // consequence, which is the property the unit tests depend on. + const failed: string[] = []; + for (const relative of PURE_MODULES) { + const compiled = path.join(root, "dist", relative.replace(/\.ts$/, ".js")); + try { + await import(compiled); + } catch (error) { + failed.push(`${relative}: ${error instanceof Error ? error.message : String(error)}`); + } + } + assert.deepEqual(failed, [], `these pure modules could not load outside VS Code:\n ${failed.join("\n ")}`); +}); + +test("the list is not so short that the rule stops meaning anything", () => { + assert.ok(PURE_MODULES.length >= 40, `only ${PURE_MODULES.length} modules are checked`); +}); + +test("every listed module exists", () => { + for (const relative of PURE_MODULES) { + readFileSync(path.join(root, relative), "utf8"); + } +}); + +test("runProcess stays inside src/cli — everything else runs mcpp through the trust-gated seam", () => { + // The external review's P0 (2026-10-03): `mcpp.path` is a resource-scoped + // setting, so any call site that skips the workspace-trust gate lets an + // untrusted workspace name the program that runs. The seam is `runMcpp`; + // `src/cli/` keeps `runProcess` because its controller wraps whole commands + // in `requireTrusted()` prompts of its own. + const offenders: string[] = []; + const walk = (directory: string): void => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.name.endsWith(".ts")) { + continue; + } + const relative = path.relative(root, full); + if (relative.split(path.sep)[1] === "cli") { + continue; + } + const text = readFileSync(full, "utf8"); + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/\brunProcess\b/.test(code)) { + offenders.push(relative); + } + } + }; + walk(path.join(root, "src")); + assert.deepEqual(offenders, [], "these files call runProcess directly; use runMcpp(trusted, …) instead"); +}); diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index c3ca208..d710dbd 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -1,10 +1,13 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; +import { contributedCommandIds } from "../src/commands/ids"; + interface PackageManifest { version?: string; + displayName?: string; description?: string; icon?: string; dependencies?: Record; @@ -15,13 +18,29 @@ interface PackageManifest { engines?: { vscode?: string }; extensionDependencies?: string[]; activationEvents?: string[]; - capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string } }; + capabilities?: { untrustedWorkspaces?: { supported?: string; description?: string; restrictedConfigurations?: string[] } }; contributes?: { - commands?: Array<{ command: string; icon?: string }>; + commands?: Array<{ command: string; title?: string; category?: string; icon?: string }>; menus?: { "editor/title"?: Array<{ command: string; group?: string; when?: string }> }; - configuration?: { properties?: Record }; + configuration?: { title?: string; properties?: Record }; configurationDefaults?: Record; languages?: Array<{ id: string; aliases?: string[]; filenames?: string[]; configuration?: string }>; + viewsContainers?: { activitybar?: Array<{ id: string; title?: string; icon?: string }> }; + views?: Record< + string, + Array<{ + id: string; + name?: string; + description?: string; + when?: string; + type?: string; + visibility?: string; + initialSize?: number; + }> + >; + viewsWelcome?: Array<{ view: string; contents: string; when?: string }>; + colors?: Array<{ id: string; description?: string }>; + keybindings?: Array<{ command: string; key?: string; mac?: string; when?: string }>; grammars?: Array<{ language?: string; scopeName: string; injectTo?: string[]; path: string }>; }; } @@ -30,43 +49,61 @@ const root = path.resolve(process.cwd()); test("declares mcpp-language-server as the C++ modules language service", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.version, "0.4.0"); - assert.equal(manifest.description, "mcpp 工程构建、工具链与 C++ Modules 语言服务集成"); + // The version lives in three places; they must agree or a release tag is a lie. + const lock = JSON.parse(readFileSync(path.join(root, "package-lock.json"), "utf8")) as { + version?: string; + packages?: Record; + }; + assert.match(manifest.version ?? "", /^\d+\.\d+\.\d+$/); + assert.equal(lock.version, manifest.version); + assert.equal(lock.packages?.[""]?.version, manifest.version); + // User-visible strings live in the nls bundle; the manifest only names them. + assert.equal(manifest.description, "%description%"); + assert.equal(manifest.displayName, "%displayName%"); assert.equal(manifest.engines?.vscode, "^1.91.0"); assert.deepEqual(manifest.extensionDependencies, ["sunrisepeak.mcpp-language-server"]); assert.ok(!manifest.extensionDependencies?.includes("llvm-vs-code-extensions.vscode-clangd")); - 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 + // Since VS Code 1.74 every contributes.commands entry implies its own + // onCommand activation, so only the file- and folder-based triggers remain. + assert.deepEqual(manifest.activationEvents, [ + "workspaceContains:mcpp.toml", + "onLanguage:mcpp-toml", + "onLanguage:mcpp-build", + ]); assert.equal(manifest.capabilities?.untrustedWorkspaces?.supported, "limited"); - assert.equal( - manifest.capabilities?.untrustedWorkspaces?.description, - "未受信任工作区仅启用本扩展的模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 mcpp CLI 或接管语言服务配置。", + assert.equal(manifest.capabilities?.untrustedWorkspaces?.description, "%untrustedWorkspaces.description%"); + // The `limited` promise says "no mcpp command runs" in an untrusted + // workspace; a resource-scoped `mcpp.path` can only be kept out of the + // workspace's reach by naming it here (external review P0, 2026-10-03). + assert.deepEqual(manifest.capabilities?.untrustedWorkspaces?.restrictedConfigurations, [ + "mcpp.path", + "mcpp.clangd.path", + ]); + // Same set, not necessarily the same order: the manifest's order is the + // palette's presentation order, which `ids.ts` has no business dictating. + assert.deepEqual( + (manifest.contributes?.commands?.map((command) => command.command) ?? []).slice().sort(), + contributedCommandIds().slice().sort(), + ); + assert.deepEqual( + manifest.contributes?.viewsContainers?.activitybar?.map((container) => container.id), + ["mcpp"], ); assert.deepEqual( - manifest.contributes?.commands?.map((command) => command.command), - [ - "mcpp.showMenu", - "mcpp.newProject", - "mcpp.build", - "mcpp.run", - "mcpp.test", - "mcpp.clean", - "mcpp.showToolchains", - "mcpp.installToolchain", - "mcpp.selectDefaultToolchain", - "mcpp.configureLanguageServer", - "mcpp.configureClangd", - "mcpp.refreshCompilationDatabase", - "mcpp.checkModuleSupport", - "mcpp.autoConfigureModules", - "mcpp.showModuleGraph", - "mcpp.showLanguageServerLogs", - ], + manifest.contributes?.views?.mcpp?.map((view) => view.id), + ["mcpp.project", "mcpp.library", "mcpp.cache"], + ); + assert.deepEqual( + manifest.contributes?.colors?.map((color) => color.id), + ["mcpp.cacheOkForeground", "mcpp.cacheStaleForeground"], ); assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.path"]); assert.ok(manifest.contributes?.configuration?.properties?.["mcpp.tomlCompletion"]); + assert.equal(manifest.contributes?.configuration?.title, "%mcpp.configuration.title%"); + for (const command of manifest.contributes?.commands ?? []) { + assert.match(command.title ?? "", /^%command\.[^%]+\.title%$/, `command ${command.command} title must be an nls key`); + assert.equal(command.category, "%category%"); + } assert.equal(manifest.dependencies?.["vscode-languageclient"], undefined); assert.equal(manifest.devDependencies?.["vscode-languageclient"], undefined); assert.deepEqual(manifest.contributes?.configurationDefaults?.["files.associations"], { @@ -78,10 +115,12 @@ test("declares mcpp-language-server as the C++ modules language service", () => }); test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服务", () => { - const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + // Slice up to a marker that exists for its own sake, not as a test hook: the + // wizard is the last function before `activate`. const start = source.indexOf("async function autoConfigureModulesWizard"); - const end = source.indexOf("const mcppTomlCompletionKinds", start); + const end = source.indexOf("export async function activate(", start); assert.notEqual(start, -1); assert.notEqual(end, -1); const wizard = source.slice(start, end); @@ -100,11 +139,207 @@ test("一键向导只执行普通 build 并在之后刷新 C++ 模块语言服 assert.doesNotMatch(controller, /runAutomaticModuleSetup|executeAutomaticModuleSetupCommand/); }); +test("the promised keybindings are contributed and scoped to a project", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const bindings = manifest.contributes?.keybindings ?? []; + assert.deepEqual( + bindings.map((binding) => binding.command), + ["mcpp.build", "mcpp.run", "mcpp.test", "mcpp.cleanProjectArtifacts", "mcpp.showMenu"], + ); + for (const binding of bindings) { + assert.match(binding.key ?? "", /^ctrl\+alt\+[a-z]$/); + assert.match(binding.mac ?? "", /^cmd\+alt\+[a-z]$/); + assert.equal(binding.when, "mcpp.inProject"); + } +}); + +test("each view is gated by its own visibility setting", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // The master switch comes first, in negated form: with `mcpp.views.enabled` off + // every view is hidden and VS Code drops the container from the activity bar. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.when), [ + "!mcpp.sidebarHidden && mcpp.views.project", + "!mcpp.sidebarHidden && mcpp.views.library", + "!mcpp.sidebarHidden && mcpp.views.cache", + ]); + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.type), [ + undefined, + "webview", + "webview", + ]); + // The cache view starts folded, so the sidebar opens on the library list; the + // two above it start open. `visibility` is the only way a view can say this — + // VS Code keeps whatever the user does to the header afterwards. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.visibility), [ + undefined, + undefined, + "collapsed", + ]); + // `initialSize` becomes the view's split-view `weight`, and VS Code hands out + // the container height in proportion to it — defaulting every view to 20 + // (`computeInitialSizes()`: `dimension.height * (weight || 20) / total`). Twice + // the default is what makes the library take the bottom two thirds and leaves + // the tree above it its third, which is the layout the sidebar is for. + assert.deepEqual(manifest.contributes?.views?.mcpp?.map((view) => view.initialSize), [ + undefined, + 40, + undefined, + ]); +}); + +test("every webview view registers the provider that fills it", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const webviews = (manifest.contributes?.views?.mcpp ?? []).filter((view) => view.type === "webview"); + assert.ok(webviews.length > 0, "this gate is pointless without a webview view"); + for (const view of webviews) { + // The view id is declared once, in the module that owns the view (other + // modules re-export the constant), and that module must also call + // `registerWebviewViewProvider`. Without it VS Code never asks the view for + // a document and every `refresh()` silently does nothing — which is exactly + // the bug this test exists for: the library view declared its id, exported a + // provider, wired its listeners, and never registered. + const declaration = new RegExp(`export const \\w*_VIEW_ID = "${view.id}"`); + const owners = sourceFiles().filter(([, text]) => declaration.test(text)); + assert.equal(owners.length, 1, `${view.id} must have exactly one declaring module, found ${owners.length}`); + assert.ok( + owners[0][1].includes("registerWebviewViewProvider("), + `${owners[0][0]} declares ${view.id} but never calls registerWebviewViewProvider`, + ); + } +}); + +test("the library view cannot reload itself in a loop", () => { + const view = readFileSync(path.join(root, "src/library", "libraryView.ts"), "utf8"); + const html = readFileSync(path.join(root, "src/library", "libraryHtml.ts"), "utf8"); + // Assigning `webview.html` reloads the document. The library document used to + // announce its own load with a `ready` message and the host answered it with a + // repaint, so the view reloaded itself forever: flicker, rows that could not be + // clicked, a pegged CPU. Both halves of that handshake are gone. + assert.doesNotMatch(html, /post\(\{\s*type:\s*"ready"\s*\}\)/, "the document must not announce its own load"); + assert.doesNotMatch(view, /case "ready"/, "the host must not answer a load with a re-render"); + assert.doesNotMatch(html, /\| \{ type: "ready" \}/); + // Two guards keep it shut: the render-only-on-change comparison, and a nonce + // that is per *view* — a nonce per render would make every render a different + // document, so the comparison could never say "unchanged". Both now live in + // the shared WebviewDocument kit, so the gate holds the host to the kit + // instead of to one implementation of it. Comments are stripped first: the + // prose explains the rule with the very assignment it forbids. + const viewCode = view.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(viewCode, /new WebviewDocument\(/, "the host must hold its document through the kit"); + assert.doesNotMatch(viewCode, /randomNonce\(\)/, "the host must not mint its own nonce"); + assert.doesNotMatch(viewCode, /webview\.html\s*=/, "the host must assign html only through the kit"); + // A freshly resolved view is a new, empty webview: the last document says + // nothing about it, so it must be forgotten when the old view goes away. + assert.match(view, /\.invalidate\(\);/); +}); + +test("every webview host holds its document through the kit", () => { + // The nonce and the html assignment are the two halves of the reload-loop + // accident (see the library gate above); since the kit extraction they are + // owned once. Every host — library, cache, settings panel, detail page — + // must go through `WebviewDocument`, and a nonce may be minted nowhere but + // the kit. + const hosts = [ + "src/library/libraryView.ts", + "src/cache/cachePanel.ts", + "src/config/panel.ts", + "src/library/detailPanel.ts", + ]; + for (const host of hosts) { + const text = readFileSync(path.join(root, host), "utf8"); + // Comments are stripped: the prose explains the rules with the very code + // it forbids. + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + assert.match(code, /new WebviewDocument\(/, `${host} must hold its document through the kit`); + assert.doesNotMatch(code, /\.html\s*=/, `${host} must assign html only through the kit`); + } + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + if (file === path.join("src", "webview", "document.ts")) { + continue; + } + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/randomBytes/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a webview nonce is minted outside src/webview/document.ts"); +}); + +test("no module drives a second language client", () => { + // The same rule the `package` job enforces, but runnable here: the CI grep used + // to match the bare name, and the compiled comments that explain why this + // extension does *not* use a language client made it fail on a clean build. + const offenders: string[] = []; + for (const [file, text] of sourceFiles()) { + const code = text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); + if (/from\s+["']vscode-languageclient["']|require\(["']vscode-languageclient["']\)/.test(code)) { + offenders.push(file); + } + } + assert.deepEqual(offenders, [], "a language client is imported again"); +}); + +test("the committed snapshots record a clone-independent commit", () => { + // `git rev-parse --short` shortens to the shortest unique prefix *for that + // clone*, so the same mcpp produced a 7-character hash in a fresh CI checkout + // and 8 in a developer's — and the drift gate went red on an unchanged API. + for (const file of ["data/buildscript-api.json", "data/toml-schema.json"]) { + const snapshot = JSON.parse(readFileSync(path.join(root, file), "utf8")) as { + sourceVersion?: string; + sourceCommit?: string; + }; + assert.match(snapshot.sourceVersion ?? "", /^\d{4}\.\d+\.\d+/, `${file}: no mcpp version`); + assert.match( + snapshot.sourceCommit ?? "", + /^[0-9a-f]{40}$/, + `${file}: the provenance hash must be full length, not an abbreviation`, + ); + } +}); + +test("opening a link always produces an answer", () => { + // The comment above `openExternal` quotes the old call, so comments are + // stripped before the check — the point is the code, not the prose. + const panel = readFileSync(path.join(root, "src", "library", "detailPanel.ts"), "utf8").replace( + /\/\*[\s\S]*?\*\//g, + "", + ); + // `void vscode.env.openExternal(…)` drops the boolean it resolves to, so a host + // that cannot open a browser answered a click with nothing at all. The call is + // awaited, both outcomes are posted to the page, and a failure leaves the URL + // somewhere the reader can use it. + assert.doesNotMatch(panel, /void vscode\.env\.openExternal/); + assert.match(panel, /opened = await vscode\.env\.openExternal\(/); + assert.match(panel, /postResult\(session, \{ state: "ok", message: t\("Opened \{0\} in your browser\."/); + assert.match(panel, /postResult\(session, \{\s*state: "error",/); + assert.match(panel, /vscode\.env\.clipboard\.writeText\(url\)/); + // The client's own line, so the click is visible before the host answers. + const html = readFileSync(path.join(root, "src", "library", "detailHtml.ts"), "utf8"); + assert.match(html, /state: "pending"/); +}); + +/** `src/**\/*.ts`, relative path and text, for the source-level gates. */ +function sourceFiles(directory = path.join(root, "src")): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + out.push(...sourceFiles(full)); + } else if (entry.name.endsWith(".ts")) { + out.push([path.relative(root, full), readFileSync(full, "utf8")]); + } + } + return out; +} + test("shows editor title buttons only inside mcpp projects", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + // Both conditions matter: a project must be open, and the user must not have + // turned the buttons off with `mcpp.task.editorTitleButtons`. assert.deepEqual(manifest.contributes?.menus?.["editor/title"], [ - { command: "mcpp.run", group: "navigation@1", when: "mcpp.inProject" }, - { command: "mcpp.test", group: "navigation@2", when: "mcpp.inProject" }, + { command: "mcpp.run", group: "navigation@1", when: "mcpp.inProject && mcpp.editorTitleButtons" }, + { command: "mcpp.test", group: "navigation@2", when: "mcpp.inProject && mcpp.editorTitleButtons" }, ]); const commands = manifest.contributes?.commands ?? []; @@ -220,7 +455,7 @@ test("ships an injection grammar with module-specific scopes", () => { }); test("设置全局默认后先释放工具链锁再提供立即构建", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("private async selectDefaultToolchainFromInventory"); const end = source.indexOf("private async pickInstallSpec", start); assert.notEqual(start, -1); @@ -233,7 +468,7 @@ test("设置全局默认后先释放工具链锁再提供立即构建", () => { }); test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async runProjectTask"); const end = source.indexOf("public async showToolchains", start); assert.notEqual(start, -1); @@ -246,7 +481,7 @@ test("项目任务结束后先释放项目锁再刷新 C++ 模块语言服务", }); test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async installToolchain"); const end = source.indexOf("public async selectDefaultToolchain", start); assert.notEqual(start, -1); @@ -261,16 +496,16 @@ test("安装流程把系统工具链和 target 兼容 spec 交给 mcpp 解析", }); test("泛化 triple 工具链由 mcpp 最终校验", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async installToolchain"); const end = source.indexOf("public async selectDefaultToolchain", start); const method = source.slice(start, end); - assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s); + assert.match(method, /may carry target semantics.*mcpp validates it in the end/s); }); test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const start = source.indexOf("public async newProject"); const end = source.indexOf("private guarded", start); assert.notEqual(start, -1); @@ -292,7 +527,7 @@ test("新建工程先校验目标路径再确认创建,成功后只打开不 }); test("新建工程契约是创建并打开,不自动构建", () => { - const controller = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const controller = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); const extension = readFileSync(path.join(root, "src/extension.ts"), "utf8"); assert.doesNotMatch(controller, /globalState|PENDING_NEW_PROJECT/); assert.doesNotMatch(extension, /PENDING_NEW_PROJECT/); @@ -300,7 +535,7 @@ test("新建工程契约是创建并打开,不自动构建", () => { test("声明 GitHub 仓库和扩展图标", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; - assert.equal(manifest.icon, "images/logo.png"); + assert.equal(manifest.icon, "media/logo.png"); assert.equal(manifest.repository?.url, "https://github.com/mcpp-community/mcpp-vscode.git"); assert.equal(manifest.homepage, "https://github.com/mcpp-community/mcpp-vscode#readme"); assert.equal(manifest.bugs?.url, "https://github.com/mcpp-community/mcpp-vscode/issues"); @@ -309,24 +544,62 @@ test("声明 GitHub 仓库和扩展图标", () => { assert.deepEqual([...icon.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); }); -test("README 说明 mcpp 与 mcppls 的职责边界和升级限制", () => { +test("the activity bar gets the stencil, not the marketplace badge", () => { + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + const container = manifest.contributes?.viewsContainers?.activitybar?.[0]; + // VS Code masks a container icon with the theme's foreground colour + // (`mask: url(icon)` at 24 px), so what is painted is the icon's *alpha + // channel*. The official badge is an opaque rounded square with the wordmark + // on it: as a stencil that is a solid block, which is what shipped. The + // marketplace icon keeps the real logo; the activity bar gets its derived + // stencil, and `npm run check:icon` proves the derivation is current. + assert.equal(manifest.icon, "media/logo.png"); + assert.equal(container?.icon, "media/activity-bar.png"); + const stencil = readFileSync(path.join(root, "media", "activity-bar.png")); + assert.deepEqual([...stencil.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]); + // Read from IHDR: 8-bit RGBA, and wider than tall, because the wordmark is. + assert.equal(stencil.readUInt32BE(16), 96); + assert.equal(stencil.readUInt32BE(20), 56); +}); + +test("the READMEs state the responsibility split, the boundary and the limits", () => { + // The English README is the Marketplace listing; the Chinese one is its mirror. const readme = readFileSync(path.join(root, "README.md"), "utf8"); - 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/); + const chinese = readFileSync(path.join(root, "README.zh-CN.md"), "utf8"); + + for (const text of [readme, chinese]) { + assert.match(text, /sunrisepeak\.mcpp-language-server/); + assert.match(text, /darwin-x64/); + assert.match(text, /mcpp\.path/); + } + assert.match(readme, /README\.zh-CN\.md/); + assert.match(chinese, /README\.md/); + + // The claims that must not creep back in: this extension is not a language + // client, does not read clangd settings, and does not claim LLVM-only support. + for (const text of [readme, chinese]) { + assert.doesNotMatch(text, /starts? (a|its own) (second )?LSP client/i); + assert.doesNotMatch(text, /reads your `clangd\./); + } + assert.doesNotMatch(readme, /only LLVM/i); }); test("嵌套工程提示不猜测它一定是 mcpp 工作区成员", () => { - const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const source = readFileSync(path.join(root, "src/cli/controller.ts"), "utf8"); assert.doesNotMatch(source, /isWorkspaceMember/); assert.doesNotMatch(source, /当前是工作区成员/); }); +test("the rename offer is only marked answered after the user answered it", () => { + const source = readFileSync(path.join(root, "src/extension.ts"), "utf8"); + const body = /async function offerSettingRenames[\s\S]*?\n\}/.exec(source)?.[0] ?? ""; + assert.ok(body.length > 0, "offerSettingRenames must exist"); + assert.ok( + body.indexOf("showInformationMessage") < body.indexOf('workspaceState.update("mcpp.renamesOffered", true)'), + "the marker is written before the prompt again: a dismissed popup would strand the user's renamed settings forever (external review P2-9)", + ); +}); + test("tag release 工作流校验版本并发布 VSIX", () => { const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); assert.match(workflow, /push:\s*\n\s+tags:\s*\n\s+- "v\*"/); @@ -340,18 +613,68 @@ test("tag release 工作流校验版本并发布 VSIX", () => { assert.match(workflow, /gh release upload.*--clobber/s); }); -test("PR CI 分离单元打包和 Extension Host E2E", () => { +test("the release workflow guards the published artefact against local leakage", () => { + const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); + assert.match(workflow, /校验 VSIX 体积与内容/); + assert.match(workflow, /forbidden in \.dev-profile\/ \.agents\/ test\/ tools\/ src\/ node_modules\//); + assert.match(workflow, /unzip -t/); +}); + +test("tag release 同时发布两个市场:Open VSX 必发,Marketplace 未配 token 则跳过", () => { + const workflow = readFileSync(path.join(root, ".github/workflows/release.yml"), "utf8"); + // 两个市场收到的都是 GitHub Release 上的那只 VSIX,不重新打包。Open VSX 的 + // secret 名就是仓库实际配置的 OPENVSX_TOKEN(曾写成 OVSX_PAT,首版必然失败)。 + assert.match(workflow, /npx ovsx publish "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix" --pat "\$OPENVSX_TOKEN"/); + assert.match(workflow, /npx vsce publish --packagePath "mcpp-vscode-\$\{PACKAGE_VERSION\}\.vsix"/); + // Open VSX 是必发目标:缺 token 显式失败。 + assert.match(workflow, /OPENVSX_TOKEN: \$\{\{ secrets\.OPENVSX_TOKEN \}\}/); + assert.match(workflow, /if \[ -z "\$\{OPENVSX_TOKEN:-\}" \]; then[\s\S]*?exit 1/); + // Marketplace 机会发布:缺 token 打一行说明就过,Release 不失败。 + assert.match(workflow, /VSCE_PAT: \$\{\{ secrets\.VSCE_PAT \}\}/); + assert.match(workflow, /if \[ -z "\$\{VSCE_PAT:-\}" \]; then[\s\S]*?exit 0/); + // 发布用的是仓库锁定的 ovsx:npx 不在发布中途去网络解析。 + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; + assert.ok(manifest.devDependencies?.ovsx, "ovsx must be a devDependency the release resolves offline"); +}); + +test("CI runs the gates, the package checks, the drift check and every e2e variant", () => { const workflow = readFileSync(path.join(root, ".github/workflows/ci.yml"), "utf8"); assert.match(workflow, /pull_request:/); assert.match(workflow, /push:\s*\n\s+branches:\s*\n\s+- main/); assert.match(workflow, /permissions:\s*\n\s+contents: read/); assert.match(workflow, /concurrency:[\s\S]*cancel-in-progress: true/); - assert.match(workflow, /node-version: 22/); - assert.match(workflow, /unit-and-package:/); + assert.match(workflow, /node-version: \$\{\{ env.NODE_VERSION \}\}/); + assert.match(workflow, /NODE_VERSION: 22/); + // Cross-platform confidence: the unit gates, the Extension Host e2e and the + // isolated install all run the same three-OS matrix — Linux, macOS ARM64 and + // Windows, where the spawn and path handling actually differ. + const osMatrixes = workflow.match(/os: \[ubuntu-latest, macos-14, windows-latest\]/g) ?? []; + assert.equal( + osMatrixes.length, + 3, + "gates, extension-host-e2e and isolated-install must all run on Linux, macOS and Windows", + ); + assert.match(workflow, /gates:/); assert.match(workflow, /extension-host-e2e:/); + assert.match(workflow, /package:/); assert.match(workflow, /npm ci/); assert.match(workflow, /npm test/); assert.match(workflow, /npm run package/); assert.match(workflow, /unzip -t/); - assert.match(workflow, /xvfb-run -a npm run test:e2e/); + // A local dev profile once leaked into the VSIX (64 MB); the guard is now explicit. + assert.match(workflow, /The VSIX stays small and free of local state/); + assert.match(workflow, /forbidden in \.dev-profile\/ \.agents\/ test\/ tools\/ src\/ node_modules\//); + assert.match(workflow, /xvfb-run -a npm run test:e2e:one/); + // The snapshot drift gate only means something with an mcpp checkout present. + assert.match(workflow, /generated-drift:/); + assert.match(workflow, /repository: mcpp-community\/mcpp/); + assert.match(workflow, /MCPP_REPO: \$\{\{ github.workspace \}\}\/\.mcpp-source/); + // The end-to-end dependency resolution check. + assert.match(workflow, /isolated-install:/); + assert.match(workflow, /sunrisepeak.mcpp-language-server@/); + // The ubuntu runner ships no `code` on PATH, so the job downloads the CLI via + // the e2e tooling instead. A step that calls bare `code` again is the exact + // `code: command not found` failure this gate was added after. + assert.match(workflow, /tools\/ci-vscode-cli\.mjs/); + assert.doesNotMatch(workflow, /^\s+code --/m); }); diff --git a/test/buildscript/analysis.test.ts b/test/buildscript/analysis.test.ts new file mode 100644 index 0000000..ea3cb4b --- /dev/null +++ b/test/buildscript/analysis.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DIAGNOSTIC_CODES, analyseBuildScript } from "../../src/buildscript/analysis"; + +function codes(lines: readonly string[]): string[] { + return analyseBuildScript(lines, "warning").map((entry) => entry.code); +} + +test("a clean, realistic build script earns nothing", () => { + const lines = [ + "import std;", + "import mcpp;", + "", + "int main() {", + ' const std::string out = std::string(mcpp::out_dir()) + "/foo.pb.cc";', + ' mcpp::cxxflag("-DHAVE_BANNER=1");', + ' mcpp::link_lib("m");', + ' mcpp::link_search("vendor/lib");', + ' mcpp::link_flag("-Wl,--version-script=gen.map");', + ' mcpp::define("HAVE_FEATURE");', + ' mcpp::generated("src/gen.cpp");', + ' mcpp::include_dir("vendor/include");', + ' mcpp::runtime_search_dir("lib");', + ' mcpp::rerun_if_changed("config.h");', + ' mcpp::rerun_if_env_changed("USE_FAST");', + " mcpp::action a;", + ' a.id = "protoc:foo";', + " a.role = mcpp::roles::source;", + ' a.arg(mcpp::dep_bin("protobuf", "protoc"))', + ' .arg("--cpp_out=gen")', + ' .output("gen/foo.pb.cc")', + " .submit();", + " return 0;", + "}", + ]; + assert.deepEqual(analyseBuildScript(lines, "warning"), []); +}); + +test("rule 1 flags an unknown mcpp:: name and only that", () => { + assert.deepEqual(codes(["int main() {", ' mcpp::cxxflagk("-O2");', "}"]), [ + DIAGNOSTIC_CODES.unknownSymbol, + ]); + assert.deepEqual( + codes([ + ' mcpp::cxxflag("-O2");', + " mcpp::roles::prepare;", + ' mcpp::warning("x");', + ' mcpp::xpkg_source("a", "b");', + ' mcpp::toolchain("k", "v");', + ' mcpp::has_feature("f");', + ' mcpp::pack_format("dir");', + ' mcpp::windows_subsystem("app", "windows");', + ]), + [], + ); +}); + +test("rule 1 ignores mcpp:: inside strings and comments", () => { + assert.deepEqual( + codes([ + ' const char* s = "mcpp::bogus";', + " // mcpp::bogus();", + " /* mcpp::bogus(); */", + " const char* t = R\"(mcpp::bogus;)\";", + ]), + [], + ); +}); + +test("rule 1 reports 1-based line and column, at the requested severity", () => { + const diagnostics = analyseBuildScript(["mcpp::bogus();"], "info"); + assert.equal(diagnostics.length, 1); + assert.deepEqual( + { + code: diagnostics[0].code, + line: diagnostics[0].line, + startCharacter: diagnostics[0].startCharacter, + endCharacter: diagnostics[0].endCharacter, + severity: diagnostics[0].severity, + }, + { + code: DIAGNOSTIC_CODES.unknownSymbol, + line: 1, + startCharacter: 1, + endCharacter: 12, + severity: "info", + }, + ); +}); + +test("rule 2 flags a role spelled as a string literal", () => { + assert.deepEqual(codes(["mcpp::action a;", 'a.role = "source";', "a.submit();"]), [ + DIAGNOSTIC_CODES.roleLiteral, + ]); + assert.deepEqual(codes(["mcpp::action a;", "a.role = mcpp::roles::source;", "a.submit();"]), []); + // The frozen printf surface is allowed to spell the string (docs/30). + assert.deepEqual(codes(['printf("mcpp:action={\\"role\\":\\"source\\"}");']), []); +}); + +test("rule 3 flags a prepare action with no output_dir", () => { + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::prepare;", + 'a.arg("install");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.prepareNeedsOutputDir], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::prepare;", + "a.output_dir(prefix.c_str());", + "a.submit();", + ]), + [], + ); + // The raw payload carries the same obligation. + assert.deepEqual(codes(['printf("mcpp:action={\\"role\\":\\"prepare\\"}");']), [ + DIAGNOSTIC_CODES.prepareNeedsOutputDir, + ]); +}); + +test("rule 4 flags a run path spelled in link_flag", () => { + assert.deepEqual(codes(['mcpp::link_flag("-Wl,-rpath,$ORIGIN/../lib");']), [ + DIAGNOSTIC_CODES.rpathInLinkFlag, + ]); + assert.deepEqual( + codes([ + 'mcpp::link_flag("-Wl,--version-script=x.map");', + 'mcpp::runtime_search_dir("lib");', + ]), + [], + ); +}); + +test("rule 5 flags -I and -L spelled in raw flags", () => { + assert.deepEqual(codes(['mcpp::cxxflag("-Ivendor/include");']), [DIAGNOSTIC_CODES.rawPathFlag]); + assert.deepEqual(codes(['mcpp::link_flag("-Lvendor/lib");']), [DIAGNOSTIC_CODES.rawPathFlag]); + assert.deepEqual( + codes(['mcpp::include_dir("vendor/include");', 'mcpp::link_search("vendor/lib");']), + [], + ); +}); + +test("rule 6 flags shell syntax in an action command", () => { + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.arg("CFLAGS=-O2").arg("make");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.shellSyntaxInAction], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.arg("cd tools && make");', + "a.submit();", + ]), + [DIAGNOSTIC_CODES.shellSyntaxInAction], + ); + assert.deepEqual( + codes([ + "mcpp::action a;", + "a.role = mcpp::roles::source;", + 'a.env("GEN_MODE", "release").cwd("tools");', + 'a.arg("--out=build");', + "a.submit();", + ]), + [], + ); +}); + +test("rule 7 flags a missing or unknown action role", () => { + assert.deepEqual(codes(["mcpp::action a;", 'a.arg("gen");', "a.submit();"]), [ + DIAGNOSTIC_CODES.actionNeedsRole, + ]); + assert.deepEqual(codes(["mcpp::action a;", 'a.role = "banana";', "a.submit();"]), [ + DIAGNOSTIC_CODES.actionNeedsRole, + ]); + assert.deepEqual(codes(["mcpp::action a;", "a.role = mcpp::roles::object;", "a.submit();"]), []); +}); + +test("each rule id is the SPEC-007 code the plan names", () => { + assert.deepEqual(Object.values(DIAGNOSTIC_CODES), [ + "mcpp.buildscript.unknownSymbol", + "mcpp.buildscript.roleLiteral", + "mcpp.buildscript.prepareNeedsOutputDir", + "mcpp.buildscript.rpathInLinkFlag", + "mcpp.buildscript.rawPathFlag", + "mcpp.buildscript.shellSyntaxInAction", + "mcpp.buildscript.actionNeedsRole", + ]); +}); diff --git a/test/buildscript/api.test.ts b/test/buildscript/api.test.ts new file mode 100644 index 0000000..efab84d --- /dev/null +++ b/test/buildscript/api.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import apiJson from "../../data/buildscript-api.json"; +import { + ACTION_ROLES, + API, + apiProblems, + apiSource, + directive, + directives, +} from "../../src/buildscript/api"; + +// Compiled layout: /dist/test/buildscript/*.js. +const repositoryRoot = path.resolve(__dirname, "..", "..", ".."); + +/** The mcpp checkout to compare against, when one is reachable. */ +function findMcppRepo(): string | undefined { + const candidates = [ + process.env.MCPP_REPO, + path.resolve(process.cwd(), "..", "mcpp"), + path.resolve(repositoryRoot, "..", "mcpp"), + ].filter((candidate): candidate is string => typeof candidate === "string"); + return candidates.find((candidate) => + fs.existsSync(path.join(candidate, "modules", "buildmcpp", "src", "directives.cppm")), + ); +} + +const mcppRepo = findMcppRepo(); +const skipReason = + mcppRepo === undefined + ? "no mcpp checkout found (set MCPP_REPO to compare against the source)" + : false; + +test("the snapshot loads with a usable shape", () => { + assert.equal(typeof API.sourceVersion, "string"); + assert.ok(API.sourceVersion.length > 0); + assert.equal(typeof API.sourceCommit, "string"); + assert.ok(API.protocolVersion >= 1); + assert.ok(API.cacheEpoch >= 1); + assert.equal(API.directives.length, 31); + assert.equal(directives().length, apiJson.directives.length); +}); + +test("the snapshot's own invariants hold", () => { + assert.deepEqual(apiProblems(), []); +}); + +test("every role the engine knows is present", () => { + assert.equal(API.roles.length, ACTION_ROLES.length); + for (const role of ACTION_ROLES) { + assert.ok(API.roles.includes(role), `missing role ${role}`); + } +}); + +test("directive() resolves wire names and only wire names", () => { + const cxxflag = directive("cxxflag"); + assert.ok(cxxflag); + assert.equal(cxxflag.slot, "CxxFlags"); + assert.equal(cxxflag.scope, "PackagePrivate"); + assert.equal(cxxflag.transform, "Verbatim"); + assert.equal(cxxflag.mustExist, false); + assert.ok(cxxflag.since >= 1); + assert.ok(Array.isArray(cxxflag.rules)); + assert.equal(directive("cxx_flag"), undefined); + assert.equal(directive("no-such-directive"), undefined); +}); + +test("apiSource() reports the generated provenance", () => { + assert.deepEqual(apiSource(), { + version: API.sourceVersion, + commit: API.sourceCommit, + }); +}); + +test("provisions carry the kind and the wire name", () => { + assert.ok(API.provisions.length >= 3); + const tool = API.provisions.find((entry) => entry.wire === "tool"); + assert.ok(tool); + assert.equal(tool.kind, "tool"); + for (const entry of API.provisions) { + assert.equal(typeof entry.kind, "string"); + assert.ok(entry.wire.length > 0); + } +}); + +test( + "the table size and every wire name match the mcpp source", + { skip: skipReason }, + () => { + assert.ok(mcppRepo); + const source = fs.readFileSync( + path.join(mcppRepo, "modules", "buildmcpp", "src", "directives.cppm"), + "utf8", + ); + const declared = /inline constexpr\s+std::array\s+kTable\{\{/.exec(source); + assert.ok(declared, "the declared kTable size is missing"); + + const open = source.indexOf("kTable{{"); + const body = source.slice(open, source.indexOf("}};", open)); + const rows = body + .split(/\r?\n/) + .filter((line) => /Slot::\w+\s*,\s*Scope::\w+\s*,\s*Transform::\w+/.test(line)); + + assert.equal(Number(declared[1]), directives().length); + assert.equal(rows.length, directives().length); + for (const entry of directives()) { + assert.ok(body.includes(`"${entry.wire}"`), `source has no row for ${entry.wire}`); + } + }, +); diff --git a/test/buildscript/modules.test.ts b/test/buildscript/modules.test.ts new file mode 100644 index 0000000..0df8cff --- /dev/null +++ b/test/buildscript/modules.test.ts @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isKnownModule, + knownModule, + knownModules, + scanImports, +} from "../../src/buildscript/modules"; + +test("scans plain, exported and dotted imports", () => { + const sites = scanImports([ + "import std;", + "export import mcpp;", + "import mcpp.plugins.tool; // trailing comment", + ]); + assert.deepEqual( + sites.map((site) => site.module), + ["std", "mcpp", "mcpp.plugins.tool"], + ); + assert.deepEqual( + sites.map((site) => site.line), + [0, 1, 2], + ); +}); + +test("reports the module name's own span, not the statement's", () => { + const [site] = scanImports(["import mcpp.core; "]); + assert.equal(site.module, "mcpp.core"); + assert.equal(site.startCharacter, 9); + assert.equal(site.endCharacter, 18); +}); + +test("finds two imports on one line", () => { + const sites = scanImports(["import a; import b.c;"]); + assert.deepEqual( + sites.map((site) => site.module), + ["a", "b.c"], + ); +}); + +test("ignores import inside strings, line comments and block comments", () => { + assert.deepEqual( + scanImports([ + "// import fake;", + 'const char* s = "import fake;";', + "/* import fake; */", + "/*", + "import fake;", + "*/", + 'const char* t = R"(import fake;)";', + "important(); // import fake;", + ]), + [], + ); +}); + +test("ignores header units, header imports and partitions", () => { + assert.deepEqual(scanImports(['import ;', 'import "vector.h";', "import :part;"]), []); +}); + +test("a comment after a real import does not hide it", () => { + const sites = scanImports(["import std; // import fake;"]); + assert.deepEqual( + sites.map((site) => site.module), + ["std"], + ); +}); + +test("recognises the standard library, the engine and mcpp plugins", () => { + for (const name of [ + "std", + "std.compat", + "mcpp", + "mcpp.core", + "mcpp.plugins.tool", + "mcpp.plugins.a.b", + "mcpp.deps.vcpkg", + "mcpp.rules.qt", + ]) { + assert.equal(isKnownModule(name), true, `${name} should be known`); + } +}); + +test("does not claim names that are not modules", () => { + for (const name of [ + "mcpp.toml", + "mcppx", + "stdlib", + "std.compat.x", + "mcpp.plugins", + "mcpp.plugins.", + "mcpp.core.x", + "", + ]) { + assert.equal(isKnownModule(name), false, `${name} should not be known`); + } +}); + +test("knownModule() describes a wildcard match with the queried name", () => { + const plugin = knownModule("mcpp.plugins.tool"); + assert.ok(plugin); + assert.equal(plugin.name, "mcpp.plugins.tool"); + assert.ok(plugin.description.length > 0); + assert.ok(plugin.docsUrl?.startsWith("https://github.com/mcpp-community/mcpp")); + assert.equal(knownModule("mcpp.toml"), undefined); + assert.equal(knownModule("nope"), undefined); +}); + +test("knownModules() lists the exact names a build program can import", () => { + const names = knownModules().map((entry) => entry.name); + for (const name of ["std", "std.compat", "mcpp", "mcpp.core", "mcpp.plugins.*"]) { + assert.ok(names.includes(name), `missing ${name}`); + } + for (const entry of knownModules()) { + assert.ok(entry.description.length > 0, `${entry.name} has no description`); + } +}); diff --git a/test/buildscript/settings.test.ts b/test/buildscript/settings.test.ts new file mode 100644 index 0000000..6d122a3 --- /dev/null +++ b/test/buildscript/settings.test.ts @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { analyseBuildScript } from "../../src/buildscript/analysis"; +import { isKnownModule } from "../../src/buildscript/modules"; +import { buildScriptContributions } from "../../src/buildscript/settings"; + +/** + * The four boolean keys `settings.ts` resolves, with the registry's defaults. + * A stub reader records what was asked for, so a wiring change that stops + * reading a key fails the first test instead of silently keeping a default. + */ +const DEFAULTS: Readonly> = { + "mcpp.buildScript.intelligence": true, + "mcpp.buildScript.diagnostics": true, + "mcpp.buildScript.snippets": true, + "mcpp.buildScript.imports.knownModules": true, +}; + +function reader(overrides: Record = {}): { + read: (key: string) => T; + keys: string[]; +} { + const keys: string[] = []; + const values: Record = { ...DEFAULTS, ...overrides }; + return { + keys, + read: (key: string): T => { + keys.push(key); + return values[key] as T; + }, + }; +} + +test("all four switches are resolved, each from its own registry key", () => { + const { read, keys } = reader(); + const contributions = buildScriptContributions(read, () => "warning"); + assert.deepEqual(contributions, { + intelligence: true, + diagnosticSeverity: "warning", + snippets: true, + knownModules: true, + }); + // The four literal keys matter: `test/config/wiring.test.ts` scans the sources + // for exactly this accessor shape to prove each declared setting is read. + assert.deepEqual(keys.sort(), [ + "mcpp.buildScript.diagnostics", + "mcpp.buildScript.imports.knownModules", + "mcpp.buildScript.intelligence", + "mcpp.buildScript.snippets", + ]); +}); + +test("mcpp.buildScript.snippets=false drops snippets and nothing else", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.snippets": false }).read, + () => "warning", + ); + assert.equal(contributions.snippets, false); + assert.equal(contributions.intelligence, true, "snippets must not turn the whole layer off"); + assert.equal(contributions.diagnosticSeverity, "warning", "diagnostics must stay"); + assert.equal(contributions.knownModules, true, "import completion must stay"); +}); + +test("mcpp.buildScript.imports.knownModules=false drops only the completion list", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.imports.knownModules": false }).read, + () => "warning", + ); + assert.equal(contributions.knownModules, false); + assert.equal(contributions.intelligence, true); + assert.equal(contributions.snippets, true); + assert.equal(contributions.diagnosticSeverity, "warning"); +}); + +test("mcpp.buildScript.diagnostics=false stops diagnostics but keeps completion and hover", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.diagnostics": false }).read, + () => "warning", + ); + assert.equal(contributions.diagnosticSeverity, undefined); + assert.equal(contributions.intelligence, true); + assert.equal(contributions.snippets, true); + assert.equal(contributions.knownModules, true); +}); + +test("mcpp.buildScript.intelligence=false leaves no build.mcpp intelligence at all", () => { + const contributions = buildScriptContributions( + reader({ "mcpp.buildScript.intelligence": false }).read, + () => "off", + ); + assert.equal(contributions.intelligence, false); + assert.equal(contributions.diagnosticSeverity, undefined); + assert.equal(contributions.snippets, false); + assert.equal(contributions.knownModules, false); +}); + +test("a severity of off publishes nothing, and a valid severity is passed through", () => { + const off = buildScriptContributions(reader().read, () => "off"); + assert.equal(off.diagnosticSeverity, undefined); + assert.equal(off.intelligence, true, "severity=off must not disable completion or hover"); + + const info = buildScriptContributions(reader().read, () => "info"); + assert.equal(info.diagnosticSeverity, "info"); +}); + +test("known modules stay recognised and are never reported missing with every switch off", () => { + const { read } = reader({ + "mcpp.buildScript.intelligence": false, + "mcpp.buildScript.diagnostics": false, + "mcpp.buildScript.snippets": false, + "mcpp.buildScript.imports.knownModules": false, + }); + const contributions = buildScriptContributions(read, () => "off"); + assert.equal(contributions.diagnosticSeverity, undefined, "no diagnostics with everything off"); + + // §3.2.1: mcpp keeps `build.mcpp` out of the compilation database, so this + // extension can never validate an import. The "never report std/std.compat/ + // mcpp.* as missing" rule is therefore absolute and lives in the analyser and + // the recognition table, not behind `mcpp.buildScript.imports.knownModules` + // (that key removes the *completion list* only). The two `mcpp.toml. + // indexCompletion*` switches act on a different language and cannot reach + // this path, so "every switch off" here is the five `mcpp.buildScript.*` keys. + const lines = [ + "import std;", + "import std.compat;", + "import mcpp;", + "import mcpp.core;", + "import mcpp.plugins.tool;", + "import mcpp.deps.something;", + ]; + assert.deepEqual(analyseBuildScript(lines, "warning"), []); + for (const name of ["std", "std.compat", "mcpp", "mcpp.core", "mcpp.plugins.tool"]) { + assert.equal(isKnownModule(name), true, `${name} stopped being recognised`); + } +}); diff --git a/test/buildscript/snippets.test.ts b/test/buildscript/snippets.test.ts new file mode 100644 index 0000000..d99bbcf --- /dev/null +++ b/test/buildscript/snippets.test.ts @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ACTION_ROLES, API } from "../../src/buildscript/api"; +import { + actionSnippets, + buildScriptSnippets, + directiveCallSnippets, + snippetsForScope, +} from "../../src/buildscript/snippets"; + +test("every snapshot directive but action has exactly one call snippet", () => { + const expected = new Set( + API.directives.map((entry) => entry.wire.replace(/-/g, "_")).filter((name) => name !== "action"), + ); + const snippets = directiveCallSnippets(); + assert.equal(snippets.length, expected.size); + assert.deepEqual( + [...new Set(snippets.map((snippet) => snippet.filterText))].sort(), + [...expected].sort(), + ); + // A declaration is not a call: `mcpp::action` must not get an `action(...)` stub. + assert.ok(!snippets.some((snippet) => snippet.filterText === "action")); +}); + +test("a directive snippet is the typed spelling with one editable argument", () => { + const snippet = directiveCallSnippets().find((entry) => entry.filterText === "link_script"); + assert.ok(snippet !== undefined, "link_script disappeared from the snippet list"); + assert.equal(snippet.label, "link_script(...)"); + assert.equal(snippet.insertText, 'link_script("${1:value}")'); + assert.match(snippet.detail, /mcpp:link-script=/); +}); + +test("the action snippets carry a role, and the prepare one carries output_dir", () => { + const snippets = actionSnippets(); + assert.equal(snippets.length, 2); + for (const snippet of snippets) { + assert.equal(snippet.filterText, "action"); + assert.match(snippet.insertText, /\.submit\(\);/); + assert.match(snippet.insertText, /mcpp::roles::/); + } + const prepare = snippets.find((snippet) => snippet.label.includes("prepare")); + assert.ok(prepare !== undefined); + assert.match(prepare.insertText, /mcpp::roles::prepare/); + assert.match(prepare.insertText, /\.output_dir\("/); + assert.match(prepare.detail, /R3\.3/); + + const general = snippets.find((snippet) => snippet.label.includes("typed")); + assert.ok(general !== undefined); + // The role choice lists the engine's five roles. + for (const role of ACTION_ROLES) { + assert.ok(general.insertText.includes(role), `${role} is missing from the role choice`); + } +}); + +test("snippetsForScope narrows on the typed mcpp:: prefix", () => { + const all = buildScriptSnippets(); + assert.equal(snippetsForScope("").length, all.length); + const link = snippetsForScope("link_"); + assert.ok(link.length > 0); + assert.ok(link.every((snippet) => snippet.filterText.startsWith("link_"))); + assert.ok(!link.some((snippet) => snippet.filterText === "action")); + assert.equal(snippetsForScope("action").length, actionSnippets().length); + assert.deepEqual(snippetsForScope("zzz_not_a_directive"), []); +}); + +test("every snippet is insertable: it has a placeholder and a non-empty body", () => { + for (const snippet of buildScriptSnippets()) { + assert.ok(snippet.insertText.length > 0, `${snippet.label} has no insert text`); + assert.match(snippet.insertText, /\$\{1[:|]/, `${snippet.label} has no first placeholder`); + assert.ok(snippet.label.length > 0); + assert.ok(snippet.detail.length > 0); + } +}); diff --git a/test/cache/cacheCss.test.ts b/test/cache/cacheCss.test.ts new file mode 100644 index 0000000..1c7c97f --- /dev/null +++ b/test/cache/cacheCss.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +/** + * The cache view's stylesheet, checked as text. + * + * Geometry cannot be measured outside a browser, but the properties §8.1 + * actually asks for can be: theme tokens only, tabular figures, the 26 / 12 / 11 + * scale, one 6 px primary bar, a legend that flows as one line, and a narrow-width + * answer. These assertions are what stops the next edit from quietly turning the + * sidebar back into three 14 px bars and a card per kind. + */ + +const css = readFileSync(path.join(process.cwd(), "media", "cache.css"), "utf8"); + +/** Every declaration that can carry a colour. */ +const COLOUR_DECLARATION = + /(?:^|[;{\s])(border-bottom|border-top|border-left|border-right|border-color|border|background-color|background|box-shadow|outline-color|outline|text-decoration-color|text-decoration|fill|stroke|color)\s*:\s*([^;}]+)/gi; + +/** Values that are not a colour at all, so they cannot be a hard-coded one. */ +const NOT_A_COLOUR = new Set([ + "transparent", + "currentColor", + "inherit", + "initial", + "unset", + "none", + "solid", + "dashed", + "dotted", + "double", + "underline", + "line-through", + "overline", + "wavy", + "0", + "1px", + "2px", + "3px", + "1", +]); + +test("every colour comes from a --vscode-* token", () => { + const offenders: string[] = []; + for (const match of css.matchAll(COLOUR_DECLARATION)) { + const property = match[1]; + const value = match[2].trim(); + // Strip the theme references: what is left must be geometry or a keyword. + const rest = value.replace(/var\([^)]*\)/g, " "); + for (const token of rest.split(/[\s,()]+/).filter((part) => part.length > 0)) { + if (NOT_A_COLOUR.has(token)) continue; + if (/^[\d.]+(px|em|rem|%|fr|deg)?$/.test(token)) continue; + offenders.push(`${property}: ${value} (offending token "${token}")`); + } + } + assert.deepEqual(offenders, [], `these declarations carry a literal colour:\n ${offenders.join("\n ")}`); +}); + +test("and no colour is hidden in a hex or function form either", () => { + assert.doesNotMatch(css, /#[0-9a-f]{3,8}\b/i, "a hex colour literal"); + assert.doesNotMatch(css, /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix|light-dark)\(/i, "a functional colour"); +}); + +test("the type scale is §8.1's 26 / 12 / 11 and the figures are tabular", () => { + assert.match(css, /font-variant-numeric:\s*tabular-nums/); + assert.match(css, /\.metric-value\s*\{[^}]*font-size:\s*26px/); + assert.match(css, /font-size:\s*12px/); + assert.match(css, /font-size:\s*11px/); +}); + +test("one 14 px composition bar and 6 px everywhere else", () => { + assert.match(css, /\.viz-bar\s*\{[^}]*height:\s*14px/); + assert.match(css, /\.viz-bar-thin\s*\{[^}]*height:\s*6px/); + // No third bar height sneaks in. + const heights = [...css.matchAll(/height:\s*(\d+)px/g)].map((match) => Number(match[1])); + assert.deepEqual([...new Set(heights)].sort((a, b) => a - b), [1, 6, 8, 14]); +}); + +test("the legend flows as one wrapped line separated by ·", () => { + assert.match(css, /\.legend-inline\s*\{[^}]*display:\s*block/); + assert.match(css, /\.legend-inline\s+\.legend-item\s*\{[^}]*display:\s*inline/); + assert.match(css, /content:\s*" · "/); + assert.doesNotMatch(css, /\.legend[^{]*\{[^}]*display:\s*grid/); +}); + +test("nothing in the stylesheet can force a 170 px sidebar to scroll sideways", () => { + const minimums = [...css.matchAll(/min-width:\s*(\d+)px/g)].map((match) => Number(match[1])); + for (const value of minimums) { + assert.ok(value <= 170, `min-width: ${value}px is wider than the narrowest sidebar`); + } + assert.match(css, /\.actions\s*\{[^}]*flex-wrap:\s*wrap/); + assert.match(css, /overflow-wrap:\s*anywhere/); + assert.match(css, /@media \(max-width: 260px\)/, "there is a rule for a narrow sidebar"); + // At 200 px the table stops being a table and each cell names its column. + assert.match(css, /td\[data-head\]::before\s*\{[^}]*content:\s*attr\(data-head\)/); +}); + +test("the house rules survive: hidden, the focus ring and the dashed disabled state", () => { + assert.match(css, /\[hidden\]\s*\{[^}]*display:\s*none\s*!important/); + assert.match(css, /:focus-visible/); + assert.match(css, /button\[disabled\]/); + assert.match(css, /button\[disabled\][^{]*\{[^}]*border-style:\s*dashed/); +}); + +test("the sections are separated by space, not by nested boxes", () => { + // §8.1: "去掉多余色块边框,靠留白分节" — no block, card or viz draws a border. + for (const selector of ["\\.block\\b", "\\.viz\\b", "\\.card\\b"]) { + const rule = new RegExp(selector + "\\s*\\{[^}]*\\}", "g"); + for (const match of css.matchAll(rule)) { + assert.doesNotMatch(match[0], /\bborder(-[a-z]+)?\s*:/, `${match[0]} draws a border`); + } + } +}); diff --git a/test/cache/cacheLegacy.test.ts b/test/cache/cacheLegacy.test.ts new file mode 100644 index 0000000..e082386 --- /dev/null +++ b/test/cache/cacheLegacy.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { measureDirectory } from "../../src/cli/artifacts"; + +/** + * The bounded walk behind `mcpp.cache.showLegacy`: the pre-v1 cache lives + * outside the workspace, so it is measured by path rather than as `target/`. + * + * Each case creates one throwaway directory and removes it in the test's own + * cleanup hook. + */ + +function withScratch(t: { after(callback: () => void): void }, body: (root: string) => void): void { + const root = mkdtempSync(path.join(os.tmpdir(), "mcpp-legacy-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + body(root); +} + +test("a missing legacy directory measures as absent with zeros, without throwing", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(path.join(root, "does-not-exist")); + assert.equal(estimate.exists, false); + assert.equal(estimate.totalBytes, 0); + assert.equal(estimate.files, 0); + assert.deepEqual(estimate.byTopLevel, []); + }); +}); + +test("a non-empty legacy directory gets a byte figure", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg", "deep"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(700)); + writeFileSync(path.join(root, "pkg", "deep", "b.bin"), Buffer.alloc(300)); + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 1000); + assert.equal(estimate.files, 2); + }); +}); + +test("an empty legacy directory measures as zero bytes, not as absent", (t) => { + withScratch(t, (root) => { + const estimate = measureDirectory(root); + assert.equal(estimate.exists, true); + assert.equal(estimate.totalBytes, 0); + }); +}); + +test("the walk never follows a symlink back into its own ancestor", (t) => { + withScratch(t, (root) => { + mkdirSync(path.join(root, "pkg"), { recursive: true }); + writeFileSync(path.join(root, "pkg", "a.bin"), Buffer.alloc(64)); + symlinkSync(root, path.join(root, "pkg", "loop"), "dir"); + const estimate = measureDirectory(root, { maxEntries: 1000, maxDepth: 8 }); + // The link is counted at zero bytes, so the total stays the 64 it really holds. + assert.equal(estimate.totalBytes, 64); + }); +}); + +test("a walk that runs out of budget reports a floor instead of hanging", (t) => { + withScratch(t, (root) => { + for (let index = 0; index < 20; index += 1) { + writeFileSync(path.join(root, `f${index}.bin`), Buffer.alloc(10)); + } + const estimate = measureDirectory(root, { maxEntries: 5 }); + assert.equal(estimate.truncated, "entries"); + assert.ok(estimate.totalBytes <= 200); + }); +}); diff --git a/test/cache/cachePanelHtml.test.ts b/test/cache/cachePanelHtml.test.ts new file mode 100644 index 0000000..f87e138 --- /dev/null +++ b/test/cache/cachePanelHtml.test.ts @@ -0,0 +1,456 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +import { + CACHE_PANEL_UI, + ageRampStep, + decodeCachePanelMessage, + renderCachePanelHtml, + type CachePanelAssets, + type CachePanelModel, +} from "../../src/cache/cachePanelHtml"; + +const ASSETS: CachePanelAssets = { + cspSource: "vscode-webview://cache", + nonce: "nonce-cache123", + styleUri: "vscode-webview://cache/media/cache.css", +}; + +const UI: Record = { + [CACHE_PANEL_UI.htmlLang]: "en", + [CACHE_PANEL_UI.title]: "Cache statistics", + [CACHE_PANEL_UI.boundary]: "Sizes are estimates.", + [CACHE_PANEL_UI.projectTitle]: "Project cache", + [CACHE_PANEL_UI.projectFiles]: "{0} file(s) · {1} group(s)", + [CACHE_PANEL_UI.projectStale]: "Stale artifacts: about {0}", + [CACHE_PANEL_UI.sharedTitle]: "Global build cache", + [CACHE_PANEL_UI.sharedEntries]: "{0} entries", + [CACHE_PANEL_UI.sharedRoot]: "Root: {0}", + [CACHE_PANEL_UI.legacyTitle]: "Pre-v1 cache", + [CACHE_PANEL_UI.legacyPath]: "Path: {0}", + [CACHE_PANEL_UI.unknown]: "not recorded", + [CACHE_PANEL_UI.projectUnavailable]: "Project artifacts could not be measured.", + [CACHE_PANEL_UI.sharedUnavailable]: "The shared build cache could not be read.", + [CACHE_PANEL_UI.actions]: "Cache actions", + [CACHE_PANEL_UI.cleanStale]: "Clean stale artifacts", + [CACHE_PANEL_UI.cleanProject]: "Clean project artifacts", + [CACHE_PANEL_UI.prune]: "Drop entries unused for a while", + [CACHE_PANEL_UI.verify]: "Verify the cache", + [CACHE_PANEL_UI.cleanLegacy]: "Remove the pre-v1 cache", + [CACHE_PANEL_UI.collect]: "Collect to this budget", + [CACHE_PANEL_UI.detailsFor]: "Show cache entry details for {0}", + [CACHE_PANEL_UI.reasonShared]: "The shared build cache is not available.", + [CACHE_PANEL_UI.reasonProject]: "Project artifacts are not available.", + [CACHE_PANEL_UI.reasonLegacy]: "There is no pre-v1 cache to remove.", + [CACHE_PANEL_UI.composition]: "Composition by kind", + [CACHE_PANEL_UI.compositionEmpty]: "No cache entries were found.", + [CACHE_PANEL_UI.age]: "Last use", + [CACHE_PANEL_UI.ageEmpty]: "No entry has a recorded last use.", + [CACHE_PANEL_UI.ageUnder]: "under {0} day(s)", + [CACHE_PANEL_UI.ageRange]: "{0}–{1} day(s)", + [CACHE_PANEL_UI.ageOverflow]: "more than {0} day(s)", + [CACHE_PANEL_UI.ageUnknown]: "{0} entries have no recorded last use.", + [CACHE_PANEL_UI.top]: "Largest packages (top {0})", + [CACHE_PANEL_UI.topEmpty]: "No cache label was read.", + [CACHE_PANEL_UI.colLabel]: "Label", + [CACHE_PANEL_UI.colEntries]: "Entries", + [CACHE_PANEL_UI.colBytes]: "Size", + [CACHE_PANEL_UI.colOldest]: "Oldest use", + [CACHE_PANEL_UI.budgetLabel]: "Keep the shared build cache under", + [CACHE_PANEL_UI.budgetHint]: "Simulates mcpp cache gc --max-size.", + [CACHE_PANEL_UI.budgetUnit]: "GiB", + [CACHE_PANEL_UI.incompleteWarning]: "{0} cache entries are incomplete.", + [CACHE_PANEL_UI.sizeWarning]: "The shared build cache is {0}, at or above the {1} warning threshold.", +}; + +interface PageOptions { + project?: Partial; + shared?: Partial; + legacy?: CachePanelModel["legacy"]; + limits?: Partial; + estimate?: string; +} + +/** + * A known cache: 500 B in two kinds (300 B `pkg`, 200 B `std`) and, when asked + * for, a 500 B pre-v1 cache — so the composition percentages are exactly + * 30 / 20 / 50 and the SVG widths are 300 / 200 / 500 out of 1000. The project + * block is a 1000 B `target/` in 3 files over 1 group. + * + * The byte formatter keeps its unit after a space, which is what the metric + * split relies on: `1000 B` -> value `1000`, unit `B`. + */ +function makeModel(options: PageOptions = {}): CachePanelModel { + return { + ui: UI, + project: { available: true, totalBytes: 1000, files: 3, groups: 1, ...options.project }, + shared: { + available: true, + totalBytes: 500, + totalEntries: 4, + byKind: [ + { kind: "pkg", entries: 3, bytes: 300 }, + { kind: "std", entries: 1, bytes: 200 }, + ], + buckets: [ + { fromDays: 0, toDays: 1, entries: 1, bytes: 100 }, + { fromDays: 1, toDays: 7, entries: 1, bytes: 100 }, + { fromDays: 7, toDays: 30, entries: 1, bytes: 100 }, + { fromDays: 30, entries: 1, bytes: 200 }, + ], + top: [{ label: "zlib", entries: 2, bytes: 200, oldestAccessed: 1_758_636_000 }], + incomplete: 0, + ...options.shared, + }, + format: { bytes: (value) => `${value} B`, count: (value) => `c${value}` }, + limits: { topN: 5, warnAboveGiB: 0, ...options.limits }, + ...(options.legacy === undefined ? {} : { legacy: options.legacy }), + ...(options.estimate === undefined ? {} : { estimate: options.estimate }), + }; +} + +function page(options: PageOptions = {}): string { + return renderCachePanelHtml(makeModel(options), ASSETS); +} + +/** The single source line a one-line legend has to fit on. */ +function legendLine(html: string, id: string): string { + const line = html.split("\n").find((candidate) => candidate.includes(`data-legend="${id}"`)); + assert.ok(line !== undefined, `no legend line for ${id}`); + return line; +} + +test("the document carries the nonce and the strict CSP", () => { + const html = page(); + assert.ok(html.includes("default-src 'none'")); + assert.ok(html.includes("style-src vscode-webview://cache;")); + assert.ok(html.includes("script-src 'nonce-nonce-cache123'")); + assert.ok(html.includes('img-src vscode-webview://cache"')); + assert.ok(html.includes('', entries: 1, bytes: 10 }] }, + }); + assert.ok(!html.includes("', + description: "A bold description", + }), + ]); + assert.ok(!html.includes("', "import a.b;", ""], + usageLine: 2, + }, + ], + }), + ASSETS, + ); + assert.match(hostile, /class="tok-punctuation">#<\/span>/); + assert.match(hostile, /class="tok-keyword">include<\/span>/); + assert.match(hostile, /class="tok-comment">\/\/ <\/script><b><\/span>/); + assert.match(hostile, /class="tok-keyword">import<\/span>/); + assert.match(hostile, //); + assert.match(hostile, /a\.cpp · line 1/); + assert.match(hostile, /Example project: argparse/); + assert.equal(hostile.split("").length - 1, 1, "the hostile comment cannot close our script"); + + const none = renderDetailHtml(model({ snippets: [], exampleProject: undefined }), ASSETS); + assert.match(none, /This package has no test project in the index\./); +}); + +test("dependencies are listed as declared, and the resolved column is left out when unknown", () => { + const html = renderDetailHtml(model(), ASSETS); + assert.match(html, /compat\.vulkan<\/code> — 1\.4\.357\.3/); + assert.match(html, /What the descriptor declares\./); + const resolved = renderDetailHtml( + model({ dependencies: [{ id: "compat.zlib", version: "1.3", resolved: "1.2.13" }] }), + ASSETS, + ); + assert.match(resolved, /compat\.zlib<\/code> — 1\.3 · resolved 1\.2\.13/); + const none = renderDetailHtml(model({ dependencies: [] }), ASSETS); + assert.match(none, /This descriptor declares no dependencies\./); +}); + +test("the add button carries the exact command, and is disabled when there is no version", () => { + const html = renderDetailHtml(model(), ASSETS); + // One place chooses the version (the matrix) and one place shows what the + // choice produces; there is no second, redundant `Add to mcpp\.toml<\/button>/); + assert.match(html, //); + assert.match(none, /This index publishes no version for this platform\./); +}); + +test("a package the project already has says so, and the button offers the switch", () => { + // Same version as the manifest: nothing to do, and the button says so — with + // `data-installed`, which paints it the thinned green of a done thing. + const same = renderDetailHtml(model({ installed: { version: "3.2", dev: false } }), ASSETS); + assert.match( + same, + /${snippet.fileKey === undefined ? "" : ``}`, `
    ${lines}
    `, `

    ${escapeHtml(caption)}

    `, ].join("\n"); @@ -254,52 +303,16 @@ function renderSnippets(model: DetailModel, label: UiLabel): string { return blocks.join("\n"); } -/** - * The version matrix, as the page's **selector**: each version is a button that - * aims the command at the top of the page at itself. - * - * A `${escapeHtml(label(DETAIL_UI.addDev))}`, - ...(model.repo === undefined ? [] : [link(model.repo, label(DETAIL_UI.openRepo))]), - ...(model.indexUrl === undefined ? [] : [link(model.indexUrl, label(DETAIL_UI.indexLink))]), - ` `, - // The copy button sits beside the command, not inside it: `command.textContent` - // is what a click copies, and a button inside the paragraph would be copied too. - `
    `, - `

    ${escapeHtml(command)}

    `, - ` `, - `
    `, - disabled - ? `

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

    ` - : `

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

    `, - ...usage, - `

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

    `, - ``, - ] - .filter((line) => line.length > 0) - .join("\n"); + const kind = model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"); + const groups = groupsFor(model); + const current = groups[kind]; + const version = selectedVersion(model); + const manual = kind === "build" || current.ambiguous || current.declared && current.sourceType !== "registry" || !current.declared && groups[kind === "dev" ? "normal" : "dev"].declared; + const already = current.declaredVersion !== undefined && current.declaredVersion === version && !manual; + const canOpen = model.manifestAvailable ?? !!model.projectRoot; + const disabled = !!model.busy || !!model.loading || (manual ? !canOpen : !!model.blockedReason || !version || already); + const location = dependencyLocation(model.id, kind); + const command = model.preview ? `mcpp search ${model.id}` : manual ? `[${location.table}] +${location.name} = ${JSON.stringify(version ?? "")}` : (kind === "dev" ? model.commandDevTemplate : model.commandTemplate).replace("{0}", model.id).replace("{1}", version ?? "?"); + const addLabel = model.busy ? label(DETAIL_UI.pending) : manual ? label(DETAIL_UI.openManifest) : already ? label(DETAIL_UI.alreadyAdded) : current.declared ? fill(label, DETAIL_UI.switchTo, [version ?? "?"]) : label(kind === "dev" ? DETAIL_UI.addDevAction : DETAIL_UI.add); + const link = (url: string, text: string) => ``; + const kinds: Array<[DependencyKind, string, string]> = [["normal", DETAIL_UI.kindNormal, "dependencies"], ["dev", DETAIL_UI.kindDev, "dev-dependencies"], ["build", DETAIL_UI.kindBuild, "build-dependencies"]]; + return `
    + ${model.loading ? `

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

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

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

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

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

    ${escapeHtml(model.versionNotice)}

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

    ${escapeHtml(model.blockedReason)}

    ` : ""} +

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

    +

    ${escapeHtml(command)}

    +

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

    +
    +
    `; +} + +export function detailClientState(model: DetailModel): Record { + return { + key: model.key ?? model.id, generation: model.generation ?? 0, projectRoot: model.projectRoot, + id: model.id, versions: model.currentVersions, version: selectedVersion(model), + groups: groupsFor(model), kind: model.dependencyKind ?? (model.projectState?.dev || model.installed?.dev ? "dev" : "normal"), + locations: Object.fromEntries((["normal", "dev", "build"] as const).map((kind) => [kind, dependencyLocation(model.id, kind)])), + loading: !!model.loading, canOpenManifest: model.manifestAvailable ?? !!model.projectRoot, + blocked: !!model.blockedReason, busy: !!model.busy, preview: !!model.preview, + command: model.commandTemplate, commandDev: model.commandDevTemplate, + labels: Object.fromEntries(Object.entries(DETAIL_UI).map(([name, key]) => [name, model.ui[key] ?? key])), + }; } -/** - * The client. It posts two things — `add` and `openUrl` — and applies - * the host's `{type:"result"}` in place, so running `mcpp add` never rebuilds the - * document and never resets the version the reader picked. - */ function clientScript(initialModel: string): string { return `(function () { "use strict"; var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; var state = ${initialModel}; - var devInput = document.getElementById("detail-dev"); - var command = document.getElementById("detail-command"); - var result = document.getElementById("detail-result"); - var addButton = document.getElementById("detail-add"); - var version = command ? (command.getAttribute("data-selected-version") || "") : ""; - var labels = (state && state.labels) || {}; - var installed = state && state.installed ? state.installed : ""; - - function post(message) { - if (api) { api.postMessage(message); } + var saved = api && api.getState ? api.getState() || {} : {}; + var pages = saved.pages || {}; + var busy = state.busy; + var version = state.version || ""; + var kind = state.kind || "normal"; + var copyId = 0; + var copyRequests = {}; + function copyLabel(button, text) { (button.querySelector("[data-action-label]") || button).textContent = text; } + function restoreCopyFeedback() { + Object.keys(copyRequests).forEach(function (token) { + var request = copyRequests[token]; + if (request.key !== pageKey() || request.until < Date.now()) { delete copyRequests[token]; return; } + var buttons = Array.from(document.querySelectorAll(request.command ? "[data-copy-command]" : "[data-copy]")); + var button = buttons.find(function (node) { return request.command ? document.getElementById("detail-command").textContent === request.text : node.getAttribute("data-copy") === request.text; }); + if (button) { button.setAttribute("data-copy-token", token); if (request.message) { copyLabel(button, request.message); } } + }); } - - function fillTemplate(template, values) { - var out = template; - for (var index = 0; index < values.length; index += 1) { - out = out.split("{" + index + "}").join(String(values[index])); - } - return out; + function pageKey() { return state.key + "|" + (state.projectRoot || ""); } + function post(message) { message.pageKey = state.key; message.generation = state.generation; if (api) { api.postMessage(message); } } + function persist() { + if (state.loading) { return; } + var expanded = Array.from(document.querySelectorAll("details[data-persist]")).filter(function (node) { return node.open; }).map(function (node) { return node.getAttribute("data-persist"); }); + pages[pageKey()] = { version: version, kind: kind, scroll: window.scrollY, expanded: expanded }; + var keys = Object.keys(pages); while (keys.length > 20) { delete pages[keys.shift()]; } + if (api && api.setState) { api.setState({ pages: pages }); } } - - function updateCommand() { - if (!command) { return; } - var dev = devInput ? devInput.checked === true : false; - var template = dev - ? (command.getAttribute("data-template-dev") || "") - : (command.getAttribute("data-template") || ""); - command.setAttribute("data-selected-version", version); - command.textContent = fillTemplate(template, [state.id, version || "?"]); + function closeKindMenu(focus) { + var menu = document.getElementById("detail-kind-menu"); + var toggle = document.getElementById("detail-kind-toggle"); + if (menu) { menu.hidden = true; } + if (toggle) { toggle.setAttribute("aria-expanded", "false"); if (focus) { toggle.focus(); } } } - - /** - * The button's label and enabled state follow the selection: adding a new - * package, switching the version of one that is already there, or nothing to do - * because this is the version the manifest already asks for. The inert state - * carries the data-installed attribute, which is what paints it the thinned - * green of a thing already done. - */ - function updateButton() { - if (!addButton) { return; } - if (!version) { - addButton.textContent = labels.add || ""; - addButton.disabled = true; - addButton.removeAttribute("data-installed"); - return; - } - if (installed && version === installed) { - addButton.textContent = labels.alreadyAdded || ""; - addButton.disabled = true; - addButton.setAttribute("data-installed", ""); - return; - } - addButton.textContent = installed - ? (labels.switchTo || "{0}").split("{0}").join(version) - : (labels.add || ""); - addButton.disabled = false; - addButton.removeAttribute("data-installed"); + function manualSelection() { + var group = state.groups[kind]; + return kind === "build" || group.ambiguous || group.declared && group.sourceType !== "registry" || !group.declared && state.groups[kind === "dev" ? "normal" : "dev"].declared; } - - /** One version button is the selection; the rest are alternatives. */ - function selectVersion(next) { - version = next; - var buttons = document.querySelectorAll("[data-version]"); - for (var index = 0; index < buttons.length; index += 1) { - var button = buttons[index]; - if (button.getAttribute("data-version") === next) { - button.setAttribute("data-selected", ""); - } else { - button.removeAttribute("data-selected"); - } + function update() { + var select = document.getElementById("detail-version"); + var button = document.getElementById("detail-add"); + if (state.preview) { return; } + var group = state.groups[kind]; + var manual = manualSelection(); + var same = !!group.declaredVersion && group.declaredVersion === version && !manual; + if (select) { select.value = version; select.disabled = busy || state.loading || !state.versions.length; } + var toggle = document.getElementById("detail-kind-toggle"); if (toggle) { toggle.disabled = busy || state.loading; } + var kindLabel = document.getElementById("detail-kind-label"); + if (kindLabel) { kindLabel.textContent = kind === "dev" ? "dev-dependencies" : kind === "build" ? "build-dependencies" : "dependencies"; } + document.querySelectorAll("[data-kind]").forEach(function (item) { item.setAttribute("aria-checked", String(item.getAttribute("data-kind") === kind)); }); + if (button) { + button.disabled = busy || state.loading || (manual ? !state.canOpenManifest : state.blocked || !version || same); + button.textContent = busy ? state.labels.pending : manual ? state.labels.openManifest : same ? state.labels.alreadyAdded : group.declared ? state.labels.switchTo.split("{0}").join(version) : kind === "dev" ? state.labels.addDevAction : state.labels.add; + if (same) { button.setAttribute("data-installed", ""); } else { button.removeAttribute("data-installed"); } } - updateCommand(); - updateButton(); + var note = document.getElementById("detail-selection-note"); if (note) { note.textContent = manual ? kind === "build" ? state.labels.buildHint : state.labels.moveHint : ""; note.hidden = !manual; } + var command = document.getElementById("detail-command"); + var location = state.locations[kind]; + if (command) { command.textContent = manual ? "[" + location.table + "]\\n" + location.name + " = " + JSON.stringify(version || "") : (kind === "dev" ? state.commandDev : state.command).split("{0}").join(state.id).split("{1}").join(version || "?"); } } - - function showResult(payload) { - if (!result) { return; } - result.hidden = false; - result.setAttribute( - "data-state", - payload.state === "ok" ? "ok" : payload.state === "pending" ? "pending" : "error", - ); - result.textContent = payload.message || ""; + function restore() { + var page = pages[pageKey()] || {}; + if (state.versions.indexOf(page.version) >= 0) { version = page.version; } + kind = ["normal", "dev", "build"].indexOf(page.kind) >= 0 ? page.kind : state.kind || "normal"; + document.querySelectorAll("details[data-persist]").forEach(function (node) { node.open = (page.expanded || []).indexOf(node.getAttribute("data-persist")) >= 0; }); + update(); window.scrollTo(0, page.scroll || 0); } - - if (devInput) { devInput.addEventListener("change", updateCommand); } - + document.addEventListener("change", function (event) { + if (event.target.id === "detail-version") { version = event.target.value; } + if (event.target.id === "detail-version") { update(); persist(); } + }); + document.addEventListener("toggle", persist, true); + window.addEventListener("scroll", persist); document.addEventListener("click", function (event) { - var target = event.target; - if (!target || typeof target.closest !== "function") { return; } - var link = target.closest("[data-open-url]"); - if (link) { - event.preventDefault(); - var url = link.getAttribute("data-open-url") || ""; - // Say something the instant the click lands: the host's answer replaces - // this line, and if it never arrives the reader can see that too. - showResult({ state: "pending", message: (labels.opening || "{0}").split("{0}").join(url) }); - post({ type: "openUrl", url: url }); + var target = event.target.closest && event.target.closest("button"); + if (!target || target.disabled) { return; } + if (target.id === "detail-kind-toggle") { + var menu = document.getElementById("detail-kind-menu"); menu.hidden = !menu.hidden; + target.setAttribute("aria-expanded", String(!menu.hidden)); + if (!menu.hidden) { menu.querySelector('[aria-checked="true"]').focus(); } return; } - var copy = target.closest("[data-copy-command], [data-copy]"); - if (copy) { - // The command's own text is what gets copied — the button beside it, not - // inside it, is why the command's textContent is exactly the command. - var text = copy.hasAttribute("data-copy-command") - ? (command ? String(command.textContent || "") : "") - : String(copy.getAttribute("data-copy") || ""); - if (!text) { return; } - showResult({ state: "pending", message: labels.copying || "" }); - post({ type: "copy", text: text }); - return; + if (target.hasAttribute("data-kind")) { + kind = target.getAttribute("data-kind"); + var declared = state.groups[kind].declaredVersion; + if (state.versions.indexOf(declared) >= 0) { version = declared; } + closeKindMenu(true); update(); persist(); return; } - var pick = target.closest("[data-version]"); - if (pick) { - selectVersion(pick.getAttribute("data-version") || ""); - return; + closeKindMenu(false); + var action = target.getAttribute("data-action"); + if (action) { post({ type: action }); return; } + if (target.hasAttribute("data-open-dependency")) { persist(); post({ type: "openDependency", key: target.getAttribute("data-open-dependency") }); return; } + if (target.hasAttribute("data-open-file")) { post({ type: "openFile", key: target.getAttribute("data-open-file") }); return; } + if (target.hasAttribute("data-open-url")) { post({ type: "openUrl", url: target.getAttribute("data-open-url") }); return; } + if (target.hasAttribute("data-copy") || target.hasAttribute("data-copy-command")) { + var token = "copy-" + (++copyId); target.setAttribute("data-copy-token", token); + copyRequests[token] = { key: pageKey(), command: target.hasAttribute("data-copy-command"), text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, until: Date.now() + 20000 }; + post({ type: "copy", text: target.hasAttribute("data-copy") ? target.getAttribute("data-copy") : document.getElementById("detail-command").textContent, token: token }); return; } - var add = target.closest("#detail-add"); - if (add && !add.disabled) { + if (target.id === "detail-add" && !busy) { + if (manualSelection()) { post({ type: "openManifest" }); return; } if (!version) { return; } - post({ type: "add", version: version, dev: devInput ? devInput.checked === true : false }); + busy = true; update(); + var result = document.getElementById("detail-result"); result.hidden = false; result.dataset.state = "pending"; result.textContent = state.labels.pending; + persist(); post({ type: "add", version: version, dev: kind === "dev" }); } }); - - /** Move the "added" marker onto the version the project now asks for. */ - function markInstalled(next) { - installed = next; - var marks = document.querySelectorAll(".detail-installed"); - for (var index = 0; index < marks.length; index += 1) { marks[index].parentNode.removeChild(marks[index]); } - var buttons = document.querySelectorAll("[data-version]"); - for (var index2 = 0; index2 < buttons.length; index2 += 1) { - var button = buttons[index2]; - if (button.getAttribute("data-version") !== next) { - button.removeAttribute("data-installed"); - continue; - } - button.setAttribute("data-installed", ""); - var mark = document.createElement("span"); - mark.className = "detail-installed"; - mark.textContent = labels.installed || ""; - button.parentNode.insertBefore(mark, button.nextSibling); - } - updateButton(); - } - + document.addEventListener("click", function (event) { if (!event.target.closest || !event.target.closest(".detail-add-group")) { closeKindMenu(false); } }); + document.addEventListener("keydown", function (event) { + var menu = document.getElementById("detail-kind-menu"); + if (!menu || menu.hidden) { return; } + if (event.key === "Escape") { event.preventDefault(); closeKindMenu(true); return; } + if (event.key === "Tab") { closeKindMenu(false); return; } + var options = Array.from(menu.querySelectorAll("[data-kind]")); + if (["ArrowDown", "ArrowUp", "Home", "End"].indexOf(event.key) < 0) { return; } + event.preventDefault(); var index = options.indexOf(document.activeElement); + index = event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : (index + (event.key === "ArrowDown" ? 1 : options.length - 1)) % options.length; + options[index].focus(); + }); window.addEventListener("message", function (event) { var data = event.data; - if (data && data.type === "result" && data.result) { showResult(data.result); } - if (data && data.added && data.added.version) { markInstalled(data.added.version); } + if (!data) { return; } + if (data.type === "detailUpdate") { + persist(); + var focused = document.activeElement && document.activeElement.id; + var doc = new DOMParser().parseFromString(data.html, "text/html"); + ["detail-header", "detail-main", "detail-footer"].forEach(function (id) { var old = document.getElementById(id); var next = doc.getElementById(id); if (old && next) { old.replaceWith(next); } }); + state = data.state; busy = state.busy; version = state.version || ""; kind = state.kind || "normal"; + restore(); restoreCopyFeedback(); + var nextFocus = focused && document.getElementById(focused); if (nextFocus) { nextFocus.focus({ preventScroll: true }); } + return; + } + if (data.key !== state.key || data.generation !== state.generation) { return; } + if (data.type === "copyResult") { + var request = copyRequests[data.token]; + if (request) { + request.message = data.message; request.until = Date.now() + 1800; restoreCopyFeedback(); + setTimeout(function () { var button = document.querySelector('[data-copy-token="' + data.token + '"]'); if (button) { copyLabel(button, state.labels.copy); } delete copyRequests[data.token]; }, 1800); + } + } + if (data.type === "linkResult") { var status = document.getElementById("detail-link-result"); status.textContent = data.message; status.hidden = !data.message; } }); - - updateCommand(); - updateButton(); + restore(); + post({ type: "mounted" }); })();`; } -/** The little state the client needs; the page is already rendered from the rest. */ -function clientState(model: DetailModel): Record { - return { - id: model.id, - ...(model.latest === undefined ? {} : { latest: model.latest }), - // The three labels the button can wear, and the version the project already - // has: the client re-decides the label every time the selection changes, so - // the host cannot be the only one that knows what the button means. The - // `installed` word belongs here too — `markInstalled()` writes it next to - // the version button after a successful add, and an empty string there is - // a marker nobody can read (§22). - labels: { - add: model.ui[DETAIL_UI.add] ?? "", - switchTo: model.ui[DETAIL_UI.switchTo] ?? "", - alreadyAdded: model.ui[DETAIL_UI.alreadyAdded] ?? "", - opening: model.ui[DETAIL_UI.opening] ?? "", - installed: model.ui[DETAIL_UI.installed] ?? "", - copying: model.ui[DETAIL_UI.copying] ?? "", - }, - ...(model.installed === undefined ? {} : { installed: model.installed.version }), - }; -} - /** A model embedded in the page's own script; `<` is escaped so it cannot close it. */ function embedJson(value: unknown): string { return JSON.stringify(value) @@ -637,35 +605,31 @@ export function renderDetailHtml(model: DetailModel, assets: DetailAssets): stri ${escapeHtml(title)} -
    -

    ${escapeHtml(model.id)}

    +
    +

    ${escapeHtml(model.id)}

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

    ${escapeHtml(model.description)}

    `} - ${renderMeta(model, label)}
    -
    +
    ${renderActions(model, label)} -
    -

    ${escapeHtml(versionsHeading)}

    - ${renderVersions(model, label)} -
    -
    -

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

    - ${renderDependencies(model, label)} -
    -
    -

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

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

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

    `} -
    -
    -

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

    - ${renderExtras(model, label)} - ${model.parseNotice === undefined ? "" : `

    ${escapeHtml(model.parseNotice)}

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

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

    + ${model.usage.length ? `

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

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

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

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

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

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

    ${escapeHtml(model.exampleProvenance)}

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

    ${escapeHtml(model.dataSource)}

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

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

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

    ${escapeHtml(model.parseNotice)}

    `}
    @@ -691,6 +655,15 @@ export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { return undefined; } switch (raw.type) { + case "mounted": + case "selectProject": + case "showOutput": + case "retry": + case "openManifest": + return { type: raw.type }; + case "openDependency": + case "openFile": + return nonEmptyString(raw.key) ? { type: raw.type, key: raw.key } : undefined; case "add": return nonEmptyString(raw.version) && typeof raw.dev === "boolean" ? { type: "add", version: raw.version, dev: raw.dev } @@ -705,7 +678,7 @@ export function decodeDetailMessage(raw: unknown): DetailMessage | undefined { // hostile document cannot park a novel in the clipboard — and everything // else about the text is the user's own click. return nonEmptyString(raw.text) && raw.text.length <= 16_384 - ? { type: "copy", text: raw.text } + ? { type: "copy", text: raw.text, ...(typeof raw.token === "string" && /^copy-\d+$/.test(raw.token) ? { token: raw.token } : {}) } : undefined; default: return undefined; diff --git a/src/library/detailPanel.ts b/src/library/detailPanel.ts index b784b26..2fb0adb 100644 --- a/src/library/detailPanel.ts +++ b/src/library/detailPanel.ts @@ -18,7 +18,7 @@ * file. */ -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import * as path from "node:path"; import * as vscode from "vscode"; @@ -27,28 +27,28 @@ import { read } from "../config/access"; import { languagePreference, t } from "../i18n/t"; import { localeFromEditorLanguage } from "../i18n/translate"; import { WebviewDocument } from "../webview/document"; +import { projectDependencyState, readProjectDeclarations, operationMatches } from "./projectState"; +import type { LibraryRow } from "./libraryHtml"; import { addDependency } from "./addDependency"; import { badgesOf, - codeSnippets, descriptorDependencies, - installedFor, lockPackageVersions, mergeSurfaces, parseXpkgJson, platformKey, surfaceLabel, - syntheticUsageLines, usageLinesFor, type LibraryEntry, type Surface, } from "./indexModel"; import { cachedDescriptorText, loadSnapshot, readDescriptorText, readExampleFiles } from "./indexLocator"; +import { bundledExample, ExampleDocuments, EXAMPLE_SCHEME } from "./examples"; import { DETAIL_UI, decodeDetailMessage, renderDetailHtml, - type DetailInstalled, + detailClientState, type DetailModel, type DetailVersionGroup, type DetailResult, @@ -63,7 +63,9 @@ const STYLESHEET = "library.css"; const PARSE_TIMEOUT_MS = 20_000; export interface DetailPanelDeps { - mcppExecutable: () => string; + mcppExecutable: (projectRoot?: string) => string; + chooseProject?: () => Promise; + onPackageOpened?: (key: string) => void; projectRoot: () => string | undefined; output: vscode.OutputChannel; isTrusted: () => boolean; @@ -74,19 +76,30 @@ export interface DetailPanelDeps { * library view's `refresh()`. */ onDependenciesChanged?: () => void; + onProjectFilesRead?: (root: string, manifest: string, lock: string) => void; } /** Build the opener once; `extension.ts` passes `openLibraryDetail` to the view. */ export function createLibraryDetailOpener( context: vscode.ExtensionContext, deps: DetailPanelDeps, -): (id: string) => Promise { +): LibraryDetailOpener { + const sourceChanged = new vscode.EventEmitter(); + const exampleDocuments = new ExampleDocuments((uri) => sourceChanged.fire(vscode.Uri.parse(uri))); + context.subscriptions.push(sourceChanged, vscode.workspace.registerTextDocumentContentProvider(EXAMPLE_SCHEME, { + onDidChange: sourceChanged.event, + provideTextDocumentContent: (uri) => exampleDocuments.read(uri.toString()) ?? "", + })); const session: DetailSession = { context, + exampleDocuments, deps, panel: undefined, id: undefined, busy: false, + generation: 0, + projectRoot: deps.projectRoot(), + painted: false, webviewDocument: new WebviewDocument(STYLESHEET), }; context.subscriptions.push({ @@ -95,17 +108,41 @@ export function createLibraryDetailOpener( session.panel = undefined; }, }); - return (id: string) => open(session, id); + const opener = (id: string, preview?: LibraryRow) => open(session, id, preview); + opener.refresh = async () => { + if (session.id === undefined || session.panel === undefined) { return; } + const root = deps.projectRoot(); + if (root !== session.projectRoot) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + } + await render(session, session.id); + }; + return opener; +} + +export interface LibraryDetailOpener { + (id: string, preview?: LibraryRow): Promise; + refresh(): Promise; } /** One panel per window, remembered so a second `open` reveals rather than stacks. */ export interface DetailSession { + exampleDocuments: ExampleDocuments; context: vscode.ExtensionContext; deps: DetailPanelDeps; panel: vscode.WebviewPanel | undefined; id: string | undefined; /** `mcpp add` in flight: a second click must not start a second command. */ busy: boolean; + generation: number; + renderRequest?: number; + projectRoot?: string; + preview?: LibraryRow; + model?: DetailModel; + painted: boolean; + lastHtml?: string; + result?: DetailResult; /** * The document and its nonce, kept per session so re-opening the *same* * package renders byte for byte the same document and does not reload the @@ -117,7 +154,7 @@ export interface DetailSession { /** The parse result per descriptor, so re-opening a package costs no process. */ const parsed = new Map(); -export async function open(session: DetailSession, id: string): Promise { +export async function open(session: DetailSession, id: string, preview?: LibraryRow): Promise { if (session.panel === undefined) { const panel = vscode.window.createWebviewPanel( PANEL_VIEW_TYPE, @@ -133,6 +170,9 @@ export async function open(session: DetailSession, id: string): Promise { panel.onDidDispose(() => { session.panel = undefined; session.id = undefined; + session.generation++; + session.painted = false; + session.lastHtml = undefined; // The next open creates a brand-new webview: what was on screen says // nothing about it, so the document comparison starts from nothing. session.webviewDocument.invalidate(); @@ -142,161 +182,198 @@ export async function open(session: DetailSession, id: string): Promise { }); } const panel = session.panel; + const changed = session.id !== id; session.id = id; - panel.title = id; + session.preview = preview; + session.deps.onPackageOpened?.(id); + if (changed) { session.generation++; session.result = undefined; } + const nextProject = session.deps.projectRoot(); + if (nextProject !== session.projectRoot) { session.generation++; session.result = undefined; } + session.projectRoot = nextProject; + panel.title = preview?.id ?? path.basename(id, ".lua"); + if (changed || !session.painted) { + const loading = emptyModel(preview?.id ?? path.basename(id, ".lua"), labels(), t("Reading package details…")); + loading.key = id; loading.generation = session.generation; loading.projectRoot = session.projectRoot; + loading.loading = true; loading.blockedReason = t("Reading package details…"); + paintModel(session, loading); + } panel.reveal(vscode.ViewColumn.Active, false); await render(session, id); } async function handle(session: DetailSession, raw: unknown): Promise { const message = decodeDetailMessage(raw); - if (message === undefined || session.panel === undefined) { + if (message === undefined || session.panel === undefined || session.id === undefined) { return; } + const envelope = raw as { pageKey?: unknown; generation?: unknown }; + if (message.type === "mounted") { + if (session.model) { paintModel(session, session.model, true); } return; } + if (envelope.pageKey !== session.id || envelope.generation !== session.generation) { return; } + const operation = { key: session.id, projectRoot: session.projectRoot, generation: session.generation }; + const current = () => operationMatches(operation, { key: session.id ?? "", projectRoot: session.projectRoot, generation: session.generation }); switch (message.type) { - case "openUrl": { - // `decodeDetailMessage` already restricted this to https. - await openExternal(session, message.url); + case "selectProject": { + const root = await session.deps.chooseProject?.(); + if (root !== undefined && current()) { + session.projectRoot = root; session.generation++; session.result = undefined; + if (session.model) { paintModel(session, { ...session.model, generation: session.generation, projectRoot: root, projectState: undefined, installed: undefined, loading: true, blockedReason: t("Reading package details…"), result: undefined }); } + await render(session, session.id); + } + return; + } + case "showOutput": session.deps.output.show(true); return; + case "openManifest": + if (session.projectRoot) { await vscode.window.showTextDocument(vscode.Uri.file(path.join(session.projectRoot, "mcpp.toml"))); } + return; + case "retry": + if (session.result?.state === "error") { session.result = undefined; } + await render(session, session.id); return; + case "openDependency": + if (session.model?.dependencies.some((dependency) => dependency.key === message.key)) { await open(session, message.key); } + return; + case "openFile": { + if (!session.model?.snippets.some((snippet) => snippet.fileKey === message.key)) { return; } + if (message.key.startsWith(`${EXAMPLE_SCHEME}:`)) { await vscode.window.showTextDocument(vscode.Uri.parse(message.key)); } return; } case "copy": { - // The command preview and the usage lines: both are text a reader pastes - // somewhere else, and a click that answers with nothing looks like a dead - // button (the lesson `openExternal` learned in §20.2). - try { - await vscode.env.clipboard.writeText(message.text); - postResult(session, { state: "ok", message: t("Copied to the clipboard.") }); - } catch (error) { - session.deps.output.appendLine( - `mcpp library: copying to the clipboard failed: ${error instanceof Error ? error.message : String(error)}`, - ); - postResult(session, { state: "error", message: t("Could not write the clipboard.") }); - } + let result: string; + try { await vscode.env.clipboard.writeText(message.text); result = t("Copied"); } + catch { result = t("Copy failed"); } + if (current()) { void session.panel?.webview.postMessage({ type: "copyResult", key: operation.key, generation: operation.generation, token: message.token, message: result }); } + return; + } + case "openUrl": { + // Only links in this package's model can ask the host to open a browser. + if (message.url !== session.model?.repo && message.url !== session.model?.indexUrl) { return; } + let opened = false; + try { opened = await vscode.env.openExternal(vscode.Uri.parse(message.url)); } catch { /* report below */ } + let copied = false; + if (!opened) { try { await vscode.env.clipboard.writeText(message.url); copied = true; } catch { /* accurate feedback below */ } } + if (current()) { void session.panel?.webview.postMessage({ type: "linkResult", key: operation.key, generation: operation.generation, + message: opened ? t("Opened in your browser.") : copied ? t("Could not open the browser. Link copied to clipboard.") : t("Could not open the browser or copy the link: {0}", message.url) }); } return; } case "add": { - if (session.busy || session.id === undefined) { - return; - } + const model = session.model; + if (session.busy || !model || model.blockedReason || model.preview || !model.currentVersions.includes(message.version)) { return; } + const state = model.dependencyGroups?.[message.dev ? "dev" : "normal"] ?? model.projectState; + const other = model.dependencyGroups?.[message.dev ? "normal" : "dev"]; + if (state?.ambiguous || state?.declared && state.sourceType !== "registry" || !state?.declared && other?.declared) { return; } session.busy = true; - let result: DetailResult; + session.result = { state: "pending", message: t("Adding dependency…") }; + paintModel(session, { ...model, busy: true, result: session.result }); try { - const outcome = await addDependency( - { - mcppExecutable: session.deps.mcppExecutable, - projectRoot: session.deps.projectRoot, - output: session.deps.output, - isTrusted: session.deps.isTrusted, - }, - { id: session.id, version: message.version, dev: message.dev }, - ); - result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; - if (outcome.ok) { - session.deps.onDependenciesChanged?.(); + const executable = session.deps.mcppExecutable(operation.projectRoot); + const snapshot = await loadSnapshot({ projectRoot: operation.projectRoot }); + const declarations = readProjectDeclarations(operation.projectRoot ? readTextTolerantly(path.join(operation.projectRoot, "mcpp.toml")) : ""); + const catalog = snapshot.entries.map((entry) => entry.id); + const fresh = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "dev" : "normal"); + const otherGroup = projectDependencyState(model.id, declarations, [], catalog, message.dev ? "normal" : "dev"); + if (fresh.ambiguous || fresh.declared && fresh.sourceType !== "registry" || !fresh.declared && otherGroup.declared) { + if (current()) { session.result = { state: "error", message: t("The dependency declaration changed. Review mcpp.toml before adding it.") }; } + return; + } + const outcome = await addDependency({ + mcppExecutable: () => executable, + projectRoot: () => operation.projectRoot, + isTrusted: session.deps.isTrusted, + output: session.deps.output, + quiet: true, + }, { id: model.id, version: message.version, dev: message.dev }); + if (outcome.ok) { session.deps.onDependenciesChanged?.(); } + if (current() && session.panel) { + session.result = { state: outcome.ok ? "ok" : "error", message: outcome.message }; + } else { + void (outcome.ok ? vscode.window.showInformationMessage(outcome.message) : vscode.window.showErrorMessage(outcome.message)); } + } catch (error) { + session.deps.output.appendLine(String(error)); + if (current()) { session.result = { state: "error", message: t("Could not add the dependency. See output for details.") }; } } finally { session.busy = false; + if (session.panel && session.id) { await render(session, session.id); } } - if (session.panel === undefined) { - return; - } - postResult(session, result, result.state === "ok" ? message.version : undefined); return; } - default: - return; } } -/** - * Open a link, and say what happened. - * - * This used to be `void vscode.env.openExternal(…)`: the boolean it resolves to - * was dropped, so a host with no browser (or no way to reach one) answered a - * click with nothing at all — indistinguishable from a dead button. Now the page - * is told either way, a failure is copied to the clipboard so the click is still - * worth something, and the output channel keeps a record. - */ -async function openExternal(session: DetailSession, url: string): Promise { - let opened = false; - try { - opened = await vscode.env.openExternal(vscode.Uri.parse(url)); - } catch (error) { - session.deps.output?.appendLine( - `mcpp library: opening ${url} failed: ${error instanceof Error ? error.message : String(error)}`, - ); - } - if (opened) { - postResult(session, { state: "ok", message: t("Opened {0} in your browser.", url) }); - return; - } - session.deps.output?.appendLine(`mcpp library: VS Code could not open ${url}; copying it instead.`); - try { - await vscode.env.clipboard.writeText(url); - } catch { - // A clipboard that refuses is not worth a second error. +function paintModel(session: DetailSession, model: DetailModel, replay = false): void { + const panel = session.panel; + if (!panel) { return; } + session.model = model; + const html = renderDetailHtml(model, session.webviewDocument.assets(vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), panel.webview)); + if (session.lastHtml === html && !replay) { return; } + session.lastHtml = html; + if (!session.painted && !replay) { + session.webviewDocument.paint(panel.webview, html); session.painted = true; + } else { + void panel.webview.postMessage({ type: "detailUpdate", html, state: detailClientState(model) }); } - postResult(session, { - state: "error", - message: t("VS Code could not open {0}. The link is on your clipboard.", url), - }); -} - -/** - * The page's status line, in the same shape the add flow uses. - * - * `added` tells the page that the project now depends on that version, so it can - * move its "added" marker and re-label the button without rebuilding the - * document — which would throw away the reader's scroll position and their - * version selection, the one thing running `mcpp add` must not do. - */ -function postResult(session: DetailSession, result: DetailResult, added?: string): void { - void session.panel?.webview.postMessage({ - type: "result", - result, - ...(added === undefined ? {} : { added: { version: added } }), - }); } async function render(session: DetailSession, id: string): Promise { const panel = session.panel; - if (panel === undefined) { - return; - } - const model = await buildModel(session, id); - if (session.panel !== panel || session.id !== id) { - return; + const generation = session.generation; + const request = session.renderRequest = (session.renderRequest ?? 0) + 1; + const root = session.projectRoot; + if (!panel) { return; } + let model: DetailModel; + try { model = await buildModel(session, id, request); } + catch (error) { + session.deps.output.appendLine(String(error)); + model = emptyModel(path.basename(id, ".lua"), labels(), t("Could not read package details. See output for details.")); + model.blockedReason = model.parseNotice; + model.result = { state: "error", message: model.parseNotice ?? "" }; } - const html = renderDetailHtml( - model, - session.webviewDocument.assets( - vscode.Uri.joinPath(session.context.extensionUri, MEDIA_DIRECTORY), - panel.webview, - ), - ); - session.webviewDocument.paint(panel.webview, html); + if (session.panel !== panel || session.id !== id || generation !== session.generation || request !== session.renderRequest || root !== session.projectRoot) { return; } + paintModel(session, { ...model, key: id, generation, busy: session.busy, result: session.result ?? model.result }); } -async function buildModel(session: DetailSession, id: string): Promise { - const projectRoot = session.deps.projectRoot(); +async function buildModel(session: DetailSession, id: string, request: number): Promise { + const projectRoot = session.projectRoot; + const generation = session.generation; const snapshot = await loadSnapshot(projectRoot === undefined ? {} : { projectRoot }); - const entry = snapshot.entries.find((candidate) => candidate.id === id); + const entry = snapshot.entries.find((candidate) => candidate.file === id || candidate.id === id); // The root the descriptor lives under, matched by path: two roots may share a // directory name, and the site link belongs to the one that holds the file. - const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => entry.file.startsWith(candidate.path)); + const root = entry === undefined ? undefined : snapshot.roots.find((candidate) => { const relative = path.relative(candidate.path, entry.file); return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); }); const ui = labels(); if (entry === undefined) { - return emptyModel(id, ui, t("This package is not in the local index any more.")); + const preview = session.preview; + const model = emptyModel(preview?.id ?? path.basename(id, ".lua"), ui, preview ? t("This is a search preview. Set an index path containing this package to read its complete details.") : t("This package is not in the local index any more.")); + model.preview = preview !== undefined; + model.previewVersion = preview?.version; + model.description = preview?.description; + model.registry = preview?.source ?? ""; + model.projectRoot = projectRoot; + model.blockedReason = preview ? t("Search previews cannot be added. Open a complete local descriptor first.") : t("The package descriptor is missing. Reread the local index."); + return model; } - const text = cachedDescriptorText(id) ?? (await readDescriptorText(entry.file)) ?? ""; + const text = cachedDescriptorText(entry.file) ?? (await readDescriptorText(entry.file)) ?? ""; const platform = platformKey(process.platform); // Keyed by the index revision as well as the path, so a refreshed index is not // answered from a parse of the previous one. - const cacheKey = `${snapshot.revision}\u0000${entry.file}`; + const cacheKey = `${snapshot.revision}\u0000${entry.file}\u0000${session.deps.isTrusted()}\u0000${session.deps.mcppExecutable(projectRoot)}\u0000${projectRoot ?? ""}`; let info = parsed.get(cacheKey); if (!parsed.has(cacheKey)) { - info = await readXpkg(session, entry); - parsed.set(cacheKey, info); + if (session.id === id && session.generation === generation && session.renderRequest === request && session.model?.loading) { + const lightweight: DetailModel = { + ...emptyModel(entry.id, ui, t("Reading package details…")), + key: id, generation, projectRoot, platform: platform ?? process.platform, + description: entry.description, registry: entry.registry, surface: entry.surface, surfaces: entry.surfaces, + licenses: entry.licenses, repo: entry.repo, badges: badgesOf(entry), + currentVersions: entry.versions[platform ?? ""] ?? [], latest: entry.version, + versions: versionGroups(entry.versions, platform).map((group) => ({ ...group, current: group.platform === platform })), + loading: true, blockedReason: t("Reading package details…"), dataSource: dataSource(entry, snapshot.roots.length), + }; + paintModel(session, lightweight); + } + info = await readXpkg(session, entry, projectRoot); + if (info !== undefined) { parsed.set(cacheKey, info); } + while (parsed.size > 100) { parsed.delete(parsed.keys().next().value!); } } const versions = info === undefined ? entry.versions : info.versions; @@ -308,44 +385,69 @@ async function buildModel(session: DetailSession, id: string): Promise ({ + file: file.path, startLine: 1, usageLine: 0, lines: file.text.split(/\r?\n/).slice(0, 80), + fileKey: session.exampleDocuments.capture(file), + })); + // Only verified lines from real example source are copyable. const realUsage = usageLinesFor(exampleFiles, entry.id); - const usage = realUsage.length > 0 ? realUsage : syntheticUsageLines(mergeSurfaces(info, text), entry.id); + const usage = realUsage; // The lock answers what a build actually resolved for each declared edge — // the same matching rule `installedFor` uses for this package itself. - const lock = lockPackageVersions(readInstalledLock(session)); + const lockText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.lock")) : ""; + const manifestText = projectRoot ? readTextTolerantly(path.join(projectRoot, "mcpp.toml")) : ""; + if (projectRoot) { session.deps.onProjectFilesRead?.(projectRoot, manifestText, lockText); } + const lock = lockPackageVersions(lockText); + const declarations = readProjectDeclarations(manifestText); + const catalog = snapshot.entries.map((entry) => entry.id); + const dependencyGroups = { + normal: projectDependencyState(entry.id, declarations, lock, catalog, "normal"), + dev: projectDependencyState(entry.id, declarations, [], catalog, "dev"), + build: projectDependencyState(entry.id, declarations, [], catalog, "build"), + }; + const dependencyKind = dependencyGroups.normal.declared ? "normal" : dependencyGroups.dev.declared ? "dev" : dependencyGroups.build.declared ? "build" : "normal"; + const state = dependencyGroups[dependencyKind]; const dependencies = descriptorDependencies(text).map((dependency) => { - const short = dependency.id.slice(dependency.id.lastIndexOf(".") + 1); - const resolved = lock.find((entry) => entry.id === dependency.id || entry.id === short)?.version; - return resolved === undefined ? { ...dependency } : { ...dependency, resolved }; + const resolved = lock.find((entry) => entry.id === dependency.id)?.version; + const local = snapshot.entries.find((entry) => entry.id === dependency.id); + return { ...dependency, ...(resolved ? { resolved } : {}), ...(local ? { key: local.file } : {}) }; }); const model: DetailModel = { ui, + projectRoot, + manifestAvailable: !!projectRoot && existsSync(path.join(projectRoot, "mcpp.toml")), + platform: platform ?? process.platform, + projectState: state, + dependencyGroups, + dependencyKind, + blockedReason: !session.deps.isTrusted() ? t("Trust this workspace to run mcpp and add dependencies.") + : !projectRoot || !existsSync(path.join(projectRoot, "mcpp.toml")) ? t("Select an mcpp project before adding this library.") + : platform === undefined ? t("The current environment platform is not recognized by this index.") + : latest === undefined ? info === undefined ? t("Version availability could not be confirmed. Retry reading this descriptor.") : t("No version is published for the current environment platform.") + : undefined, + versionNotice: info === undefined ? t("Versions are from descriptor text; mcpp will confirm availability when adding.") : undefined, id: entry.id, name: entry.name, ...(entry.description === undefined ? {} : { description: entry.description }), licenses: entry.licenses, ...(entry.repo === undefined ? {} : { repo: entry.repo }), registry: entry.registry, - ...(entry.surface === undefined ? {} : { surface: entry.surface }), - surfaces: entry.surfaces, + ...(mergeSurfaces(info, text)[0] === undefined ? {} : { surface: mergeSurfaces(info, text)[0] }), + surfaces: mergeSurfaces(info, text), badges: badgesOf(entry), versions: groups, currentVersions: sortVersions(currentVersions), ...(latest === undefined ? {} : { latest }), - // §22: the page finally learns what the project already has. Without this - // the button said "Add" to a package mcpp.toml listed all along. - ...readInstalled(session, entry.id), ...(info?.standard === undefined ? {} : { standard: info.standard }), dependencies, includeDirs: info?.includeDirs ?? [], targets: (info?.targets ?? []).map((target) => target.name ?? "").filter((name) => name.length > 0), snippets, - ...(entry.example === undefined ? {} : { exampleProject: entry.example.project }), + ...(bundled ? { exampleProject: bundled.project, exampleProvenance: t("Bundled official index tests · commit {0}", bundled.commit.slice(0, 12)) } : entry.example ? { exampleProject: entry.example.project } : {}), usage, // One discreet link, and only when this root really is the index that // publishes those package pages (see `IndexRoot.site`). @@ -367,13 +469,13 @@ async function buildModel(session: DetailSession, id: string): Promise { +async function readXpkg(session: DetailSession, entry: LibraryEntry, projectRoot?: string): Promise { // `mcpp.path` is resource-scoped, so an untrusted workspace must not name // the program: the refusal degrades to the descriptor's own text. const result = await vscode.window.withProgress( { location: vscode.ProgressLocation.Window, title: t("Reading {0}…", entry.id) }, () => - runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(), ["xpkg", "parse", entry.file, "--json"], session.deps.projectRoot(), { + runMcpp(session.deps.isTrusted(), session.deps.mcppExecutable(projectRoot), ["xpkg", "parse", entry.file, "--json"], projectRoot, { timeoutMs: PARSE_TIMEOUT_MS, maxBufferMiB: read("mcpp.runtime.maxOutputMiB"), }), @@ -424,42 +526,41 @@ function emptyModel(id: string, ui: Record, notice: string): Det }; } -/** - * The workspace's own answer about one package (§20.1, wired in §22): - * `mcpp.toml` first, `mcpp.lock` second, both read tolerantly — a missing or - * unreadable file is simply "not installed", never a broken page. The spread - * sets `installed` only when there is one. - */ -function readInstalled(session: DetailSession, id: string): { installed?: DetailInstalled } { - const root = session.deps.projectRoot(); - if (root === undefined) { - return {}; - } - const toml = readTextTolerantly(path.join(root, "mcpp.toml")); - const lock = readTextTolerantly(path.join(root, "mcpp.lock")); - const installed = installedFor(id, toml, lock); - return installed === undefined ? {} : { installed }; -} - -/** An unreadable or missing file reads as empty text; the view degrades, not breaks. */ function readTextTolerantly(file: string): string { - try { - return readFileSync(file, "utf8"); - } catch { - return ""; - } -} - -/** The workspace's `mcpp.lock` text, empty when there is none to read. */ -function readInstalledLock(session: DetailSession): string { - const root = session.deps.projectRoot(); - return root === undefined ? "" : readTextTolerantly(path.join(root, "mcpp.lock")); + try { return readFileSync(file, "utf8"); } catch { return ""; } } /** Every visible string, resolved once per model. */ function labels(): Record { return { [DETAIL_UI.htmlLang]: htmlLanguage(), + [DETAIL_UI.platforms]: t("Platforms and versions"), + [DETAIL_UI.kind]: t("Dependency type"), + [DETAIL_UI.kindNormal]: t("Project dependency"), + [DETAIL_UI.kindDev]: t("Development dependency"), + [DETAIL_UI.kindBuild]: t("Build dependency"), + [DETAIL_UI.addDevAction]: t("Add development dependency"), + [DETAIL_UI.buildHint]: t("Build dependencies are used by the build script. Copy this entry into mcpp.toml; mcpp add does not support this group yet."), + [DETAIL_UI.moveHint]: t("Changing this declaration requires a manifest edit. Copy the entry and update its dependency group in mcpp.toml."), + [DETAIL_UI.chooseProject]: t("Choose project"), + [DETAIL_UI.platform]: t("Environment platform"), + [DETAIL_UI.version]: t("Version"), + [DETAIL_UI.otherPlatforms]: t("Other platforms (read only)"), + [DETAIL_UI.declared]: t("Declared"), + [DETAIL_UI.locked]: t("Resolved in lockfile"), + [DETAIL_UI.sourceDependency]: t("Source dependency"), + [DETAIL_UI.unknownUsage]: t("No verified import or header path is available locally. See the repository or index site for usage."), + [DETAIL_UI.usageSource]: t("From the index's example source"), + [DETAIL_UI.source]: t("Source and diagnostics"), + [DETAIL_UI.loading]: t("Reading package details…"), + [DETAIL_UI.pending]: t("Adding dependency…"), + [DETAIL_UI.groupChange]: t("Changing dependency groups is not supported here. Edit mcpp.toml to move the declaration."), + [DETAIL_UI.output]: t("Show output"), + [DETAIL_UI.retry]: t("Retry reading details"), + [DETAIL_UI.openManifest]: t("Open mcpp.toml"), + [DETAIL_UI.openFile]: t("Source"), + [DETAIL_UI.readOnlySource]: t("Open read-only source"), + [DETAIL_UI.preview]: t("Search preview: only the fields returned by mcpp search are available."), [DETAIL_UI.title]: t("Library package {0}"), [DETAIL_UI.overview]: t("Build shape"), [DETAIL_UI.license]: t("License"), @@ -477,23 +578,23 @@ function labels(): Record { [DETAIL_UI.dependencies]: t("Dependencies"), [DETAIL_UI.dependenciesNone]: t("This descriptor declares no dependencies."), [DETAIL_UI.dependenciesHint]: t( - "What the descriptor declares. The resolved version lives in a project's mcpp.lock, not here.", + "Descriptor-level dependencies; the selected version may differ. Resolved values refer to this project's lockfile.", ), [DETAIL_UI.resolved]: t("resolved {0}"), [DETAIL_UI.code]: t("Example code"), [DETAIL_UI.codeNone]: t("This package has no test project in the index, so there is no example to show."), [DETAIL_UI.codeSource]: t("{0} · line {1}"), [DETAIL_UI.codeProject]: t("Example project: {0} — built and run by the index's CI, not written for this page."), - [DETAIL_UI.add]: t("Add to mcpp.toml"), + [DETAIL_UI.add]: t("Add dependency"), [DETAIL_UI.addDev]: t("dev dependency"), [DETAIL_UI.addLatest]: t("The version is required: mcpp accepts an exact version only."), [DETAIL_UI.switchTo]: t("Switch to {0}"), - [DETAIL_UI.alreadyAdded]: t("Already added"), + [DETAIL_UI.alreadyAdded]: t("Already declared"), [DETAIL_UI.installed]: t("added"), [DETAIL_UI.opening]: t("Opening {0}…"), [DETAIL_UI.addNoVersion]: t("This index publishes no version for this platform, so there is nothing to add."), [DETAIL_UI.command]: t("Command"), - [DETAIL_UI.usage]: t("Bring it into your code"), + [DETAIL_UI.usage]: t("Usage"), [DETAIL_UI.copy]: t("Copy"), [DETAIL_UI.copied]: t("Copied to the clipboard."), [DETAIL_UI.copying]: t("Copying…"), diff --git a/src/library/examples.ts b/src/library/examples.ts new file mode 100644 index 0000000..71a3c00 --- /dev/null +++ b/src/library/examples.ts @@ -0,0 +1,22 @@ +import snapshot from "../../data/library-examples.json"; +import { ReadOnlyDocuments } from "../webview/readOnlyDocuments"; +import { exampleCatalog, type CodeFile } from "./indexModel"; + +const catalog = exampleCatalog(snapshot.projects.map((project) => ({ + project: project.project, text: project.text, sources: project.files.map((file) => file.path), +}))); +const files = new Map(snapshot.projects.flatMap((project) => project.files.map((file) => [file.path, file.text] as const))); + +/** Official-index fallback only. Local checkouts use their own current tests. */ +export function bundledExample(id: string): { project: string; files: CodeFile[]; commit: string } | undefined { + const ref = catalog.get(id); + return ref ? { project: ref.project, commit: snapshot.commit, files: ref.paths.map((path) => ({ path, text: files.get(path)! })) } : undefined; +} + +export function bundledExampleText(path: string): string | undefined { return files.get(path.replace(/^\//, "")); } +export const EXAMPLE_SCHEME = "mcpp-example"; + +/** Immutable virtual documents keep local and bundled index sources equally read-only. */ +export class ExampleDocuments extends ReadOnlyDocuments { + constructor(captured?: (uri: string) => void) { super(EXAMPLE_SCHEME, captured); } +} diff --git a/src/library/indexLocator.ts b/src/library/indexLocator.ts index a29770a..afa6291 100644 --- a/src/library/indexLocator.ts +++ b/src/library/indexLocator.ts @@ -1,33 +1,7 @@ -/** - * Where the index lives, and how it is read **cheaply**. - * - * The catalog is a directory of Lua descriptors: - * `/.mcpp/registry/data//pkgs//.lua`. Three - * registries are installed on the reference machine (`mcpplibs` with 239 - * descriptors, `xim-pkgindex`, `xim-pkgindex-local`), and `mcpp index status` - * prints a table of them — which is deliberately **not** parsed: `--format json` - * is an unknown option there, and a second human-output parser is exactly what - * this project forbids. Globbing the data directory under `` gives the - * same answer without parsing anything. - * - * `` is `$MCPP_HOME`, then `~/.mcpp` — every one of them that holds a data - * directory — and when none does, whatever `mcpp self env --format json` - * reports, which is a machine shape and therefore allowed to be read. - * - * Two budgets shape this file: - * - * - **No process per package.** The list is read from the descriptor text - * (`src/library/indexModel.ts`); `mcpp xpkg parse --json` runs on demand, from - * the detail panel, never here. - * - **One read per index revision.** A snapshot is cached and invalidated by a - * cheap revision key (the `pkgs` directory's mtime, the `.mcpp-index-updated` - * marker and the descriptor count), so re-rendering the view after a filter - * change costs a few stats, not 239 file reads. - * - * A missing index is **not** an error: `locateIndexRoots()` returns `[]` and the - * caller renders the message this module hands it. +/** Local package discovery. Official indexes are separate from the xlings tool registry. + * Descriptor contents are cached independently from the project's dependency state. */ - +import { createHash } from "node:crypto"; import { promises as fs } from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -35,10 +9,10 @@ import * as vscode from "vscode"; import { runMcpp } from "../cli/process"; import { read } from "../config/access"; +import { projectDependencyState, readProjectDeclarations } from "./projectState"; import { descriptorEntry, descriptorId, - declaredDependencies, exampleCatalog, openkalFacetFor, parseDescriptorLua, @@ -68,6 +42,8 @@ export const INDEX_SITE = "https://mcpplibs.github.io/mcpp-index/packages"; export interface IndexRoot { /** The directory name, e.g. `mcpplibs`. */ registry: string; + kind: "official" | "custom"; + updatedAt?: number; /** The index root: it contains `pkgs`, and may contain `tests/` and `.xpkgindex/`. */ path: string; /** `/pkgs`. */ @@ -124,164 +100,83 @@ async function isFile(target: string): Promise { } /** One index root, or `undefined` when the directory is not one. */ -async function rootAt(directory: string): Promise { +async function rootAt(directory: string, official = false): Promise { const pkgs = path.join(directory, "pkgs"); if (!(await isDirectory(pkgs))) { return undefined; } + directory = await fs.realpath(directory); const registry = path.basename(directory); + const updated = await statSafe(path.join(directory, ".mcpp-index-updated")); const generator = await isFile(path.join(directory, ".xpkgindex", "plugins", "mcpp.py")); return { registry, + kind: official ? "official" : "custom", + ...(updated === undefined ? {} : { updatedAt: updated.mtimeMs }), path: directory, - pkgs, + pkgs: path.join(directory, "pkgs"), hasExamples: await isDirectory(path.join(directory, "tests", "examples")), hasOpenkal: await isFile(path.join(directory, ".xpkgindex", "openkal-compat.json")), - ...(registry === "mcpplibs" || generator ? { site: INDEX_SITE } : {}), + ...(official || generator ? { site: INDEX_SITE } : {}), }; } -/** - * The index roots to read. - * - * `mcpp.library.indexPath` wins when the user set it — it may name one index - * root (a directory with `pkgs/`), a `pkgs` directory itself, or a whole - * `/.mcpp/registry/data` directory of registries. With no setting, the - * default glob is the data directory under each known mcpp home. - * - * **Finding ``.** Two are tried, in order: `$MCPP_HOME` (the variable mcpp - * itself honours) and `~/.mcpp`. Both are listed when both hold something, and - * the view's footer names the registries it read, so a stale second home shows - * up as an extra source rather than as a wrong answer. When *neither* has a - * `registry/data` with anything in it, mcpp is asked directly — `mcpp self env - * --format json` reports `mcppHome`, which is the only authoritative answer for - * an install in a place nothing can guess. That probe costs one process **per - * session** and only runs when the cheap globs came up empty, so the ordinary - * machine never pays for it; it is also skipped in an untrusted workspace, - * because `mcpp.path` is a resource-scoped setting and a workspace must not - * choose a binary to run. - * - * `[]` means "no index found"; the caller turns that into the message rather - * than into an error, because a machine without the index is a normal machine. - * - * (`locateIndexRoots` is kept as an alias: the name reads better at the call - * site, the exported name is the one `extension.ts` uses.) - */ -export async function readIndexRoots(): Promise { +/** Explicit paths never silently fall back. Only the official library source is auto-discovered. */ +export async function readIndexRoots(projectRoot?: string): Promise { const configured = read("mcpp.library.indexPath"); - const candidates: string[] = []; - if (typeof configured === "string" && configured.trim().length > 0) { - const configured_path = path.resolve(expandHome(configured.trim())); - // A `pkgs` directory was named directly. - if (path.basename(configured_path) === "pkgs" && (await isDirectory(configured_path))) { - candidates.push(path.dirname(configured_path)); - } else { - const own = await rootAt(configured_path); - if (own !== undefined) { - candidates.push(own.path); - } else { - // A data directory: every registry inside it. - for (const entry of await readdirSafe(configured_path)) { - candidates.push(path.join(configured_path, entry)); - } - } - } - } else { - for (const data of await defaultDataDirectories()) { - for (const entry of await readdirSafe(data)) { - candidates.push(path.join(data, entry)); - } - } - } + return discoverIndexRoots(configured, configured.trim() ? path.join(os.homedir(), ".mcpp") : await activeHome(projectRoot)); +} - const roots: IndexRoot[] = []; - for (const candidate of candidates) { - const root = await rootAt(candidate); - if (root !== undefined && !roots.some((seen) => seen.path === root.path)) { - roots.push(root); +/** Filesystem seam used by the extension and fixture-based tests. */ +export async function discoverIndexRoots(configured: string, home: string): Promise { + if (configured.trim()) { + const target = path.resolve(expandHome(configured.trim())); + const own = await rootAt(path.basename(target) === "pkgs" ? path.dirname(target) : target); + const roots = own === undefined + ? (await Promise.all((await readdirSafe(target)).sort().map((name) => rootAt(path.join(target, name))))) + .filter((root): root is IndexRoot => root !== undefined) + : [own]; + if (roots.length === 0) { + throw new Error(`No package index (pkgs/) at ${target}`); } + return roots.filter((root, index) => roots.findIndex((other) => other.path === root.path) === index); + } + for (const directory of [path.join(home, "indices", "mcpplibs"), path.join(home, "registry", "data", "mcpplibs")]) { + const root = await rootAt(directory, true); + if (root !== undefined) { return [root]; } } - return roots.sort((a, b) => (a.registry < b.registry ? -1 : a.registry > b.registry ? 1 : 0)); + return []; } -/** Set once per session, so `mcpp self env` runs at most once. */ -let probedHome: string | undefined; -let probeAttempted = false; +let homeProbe: { key: string; promise: Promise } | undefined; -/** The homes mcpp may have used, most specific first, without duplicates. */ -function candidateHomes(): string[] { - const homes: string[] = []; - const fromEnvironment = process.env.MCPP_HOME?.trim(); - if (fromEnvironment !== undefined && fromEnvironment.length > 0) { - homes.push(fromEnvironment); - } - homes.push(path.join(os.homedir(), ".mcpp")); - if (probedHome !== undefined) { - homes.push(probedHome); - } - return [...new Set(homes)]; +/** CLI also supports self-contained installs: ask it once rather than merge unrelated homes. */ +async function activeHome(projectRoot?: string): Promise { + const explicit = process.env.MCPP_HOME?.trim(); + if (explicit) { return path.resolve(expandHome(explicit)); } + return (await mcppHomeFromCli(projectRoot)) ?? path.join(os.homedir(), ".mcpp"); } -/** - * The `/registry/data` directories worth listing. - * - * The cheap answer first: a data directory that already holds something is the - * answer, and no process is started. Only when all of them are missing or empty - * is `mcpp self env` asked where its home is — and its answer is picked up by - * re-reading the candidate list, because the probe is what adds the home to it. - */ -async function defaultDataDirectories(): Promise { - const candidates = (): string[] => candidateHomes().map((home) => path.join(home, "registry", "data")); - const present: string[] = []; - for (const data of candidates()) { - if ((await readdirSafe(data)).length > 0) { - present.push(data); - } - } - if (present.length > 0) { - return present; - } - await mcppHomeFromCli(); - return [...new Set(candidates())]; +export function invalidateIndexCache(): void { + cache = undefined; + homeProbe = undefined; } -/** - * `mcpp self env --format json`, for the one field that says where the index is. - * - * It runs at most once per session and never in an untrusted workspace. A - * missing or failing mcpp is not an error here: the caller falls back to the - * directories it already has, and the view says "no index" the way it does when - * there is none. - */ -async function mcppHomeFromCli(): Promise { - if (probeAttempted) { - return probedHome; - } - probeAttempted = true; - const executable = read("mcpp.path").trim(); - // `mcpp.path` is resource-scoped, so the probe goes through the trust-gated - // seam; a refusal is the same "no answer" as a failing mcpp. - const result = await runMcpp( - vscode.workspace.isTrusted, - executable.length === 0 ? "mcpp" : executable, - ["self", "env", "--format", "json"], - undefined, - { timeoutMs: SELF_ENV_TIMEOUT_MS, maxBufferMiB: 1 }, - ); - if (result === undefined || result.exitCode !== 0) { - return undefined; - } - const env = parseSelfEnv(result.stdout); - if (env === undefined) { - return undefined; - } - if (env.mcppHome !== undefined) { - probedHome = env.mcppHome; - } else if (env.registry !== undefined) { - // `registry` is `/registry`; the home is what the other paths hang off. - probedHome = path.dirname(env.registry); - } - return probedHome; +/** Share concurrent probes, with separate answers for different configured executables/projects. */ +async function mcppHomeFromCli(projectRoot?: string): Promise { + const resource = projectRoot === undefined ? undefined : vscode.Uri.file(projectRoot); + const executable = read("mcpp.path", resource).trim() || "mcpp"; + const key = `${executable}\u0000${projectRoot ?? ""}\u0000${vscode.workspace.isTrusted}`; + if (homeProbe?.key === key) { return homeProbe.promise; } + const promise = (async () => { + const result = await runMcpp(vscode.workspace.isTrusted, executable, ["self", "env", "--format", "json"], projectRoot, + { timeoutMs: SELF_ENV_TIMEOUT_MS, maxBufferMiB: 1 }); + if (result === undefined || result.exitCode !== 0) { return undefined; } + const env = parseSelfEnv(result.stdout); + return env?.mcppHome ?? (env?.registry === undefined ? undefined : path.dirname(env.registry)); + })(); + homeProbe = { key, promise }; + return promise; } export { readIndexRoots as locateIndexRoots }; @@ -324,23 +219,15 @@ async function descriptorFiles(pkgs: string): Promise { return out.sort(); } -/** - * A cheap key that changes when the index does. - * - * The `pkgs` directory's own mtime catches an added or removed letter - * directory; `.mcpp-index-updated` is written whenever mcpp refreshes the index, - * which catches a changed descriptor body. The descriptor count is included so a - * tree that stops being updated still costs one full read when its size changes. - */ +/** Include source identity and every descriptor stat, including checkout edits without a marker. */ async function revisionOf(roots: readonly IndexRoot[]): Promise { const parts: string[] = []; for (const root of roots) { - const pkgs = await statSafe(root.pkgs); - const marker = await statSafe(path.join(root.path, ".mcpp-index-updated")); - const letters = await readdirSafe(root.pkgs); - parts.push(`${root.registry}:${pkgs?.mtimeMs ?? 0}:${marker?.mtimeMs ?? 0}:${letters.length}`); + const files = await descriptorFiles(root.pkgs); + const stats = await Promise.all(files.map(async (file) => `${file}:${JSON.stringify(await statSafe(file))}`)); + parts.push(`${root.path}:${root.kind}:${root.updatedAt ?? ""}:${stats.join(";")}`); } - return parts.join("|"); + return createHash("sha256").update(parts.join("|")).digest("hex"); } async function statSafe(target: string): Promise<{ mtimeMs: number; size: number } | undefined> { @@ -416,17 +303,14 @@ async function readOpenkal(roots: readonly IndexRoot[]): Promise> { - if (projectRoot === undefined) { - return new Set(); - } - try { - const text = await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8"); - return new Set(declaredDependencies(text)); - } catch { - return new Set(); - } +/** Project overlay is always fresh and never changes the descriptor cache. */ +async function withProject(snapshot: LibrarySnapshot, projectRoot?: string): Promise { + const text = projectRoot === undefined ? "" : await fs.readFile(path.join(projectRoot, "mcpp.toml"), "utf8").catch(() => ""); + const declarations = readProjectDeclarations(text); + const ids = snapshot.entries.map((entry) => entry.id); + return { ...snapshot, entries: snapshot.entries.map((entry) => ({ + ...entry, added: (["normal", "dev", "build"] as const).some((kind) => projectDependencyState(entry.id, declarations, [], ids, kind).declared), + })) }; } interface Cache { @@ -441,15 +325,14 @@ let cache: Cache | undefined; /** * The list, read from disk only when the revision changed. * - * `projectRoot` is the workspace folder whose `mcpp.toml` decides the "already - * declared" state; it is part of the cache key, because switching workspaces - * changes the answer without changing the index. + * Only descriptor contents are cached. Project declarations are reapplied on + * every read, so disk writes and project switches cannot reuse old badges. */ export async function loadSnapshot(options: { projectRoot?: string; platform?: string } = {}): Promise { - const roots = await readIndexRoots(); - const revision = `${await revisionOf(roots)}|${options.projectRoot ?? ""}`; + const roots = await readIndexRoots(options.projectRoot); + const revision = `${await revisionOf(roots)}|${options.platform ?? platformKey(process.platform)}`; if (cache !== undefined && cache.revision === revision) { - return cache.snapshot; + return withProject(cache.snapshot, options.projectRoot); } if (roots.length === 0) { const empty: LibrarySnapshot = { entries: [], roots, revision }; @@ -458,21 +341,18 @@ export async function loadSnapshot(options: { projectRoot?: string; platform?: s } const platform = options.platform ?? platformKey(process.platform); - const added = await readAdded(options.projectRoot); const entries: LibraryEntry[] = []; const texts = new Map(); /** Example refs, keyed by package id, from every root that has examples. */ const exampleRefs = new Map(); for (const root of roots) { for (const [id, ref] of exampleCatalog(await readExamples(root))) { - if (!exampleRefs.has(id)) { - exampleRefs.set(id, ref); - } + exampleRefs.set(`${root.path}\u0000${id}`, ref); } } - const openkal = await readOpenkal(roots); for (const root of roots) { + const openkal = await readOpenkal([root]); for (const file of await descriptorFiles(root.pkgs)) { let text: string; try { @@ -485,7 +365,7 @@ export async function loadSnapshot(options: { projectRoot?: string; platform?: s // `descriptorEntry` decides the identity; the example and openkal lookups // below must use that same id, so it is computed the same way here. const id = descriptorId(fields, fileName).id; - const example = exampleRefs.get(id); + const example = exampleRefs.get(`${root.path}\u0000${id}`); const facet = openkalFacetFor(openkal, id); const entry = descriptorEntry({ fileName, @@ -493,19 +373,19 @@ export async function loadSnapshot(options: { projectRoot?: string; platform?: s file, text, platform, - added: added.has(id), + added: false, ...(example === undefined ? {} : { example }), ...(facet === undefined ? {} : { openkal: facet }), }); entries.push(entry); - texts.set(entry.id, text); + texts.set(entry.file, text); } } entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); const snapshot: LibrarySnapshot = { entries, roots, revision }; cache = { revision, snapshot, texts }; - return snapshot; + return withProject(snapshot, options.projectRoot); } /** The descriptor text of one package, from the cache when possible. */ @@ -518,8 +398,8 @@ export async function readDescriptorText(file: string): Promise { const catalog = new Map(); + const priorities = new Map(); const sorted = [...inputs].sort((a, b) => (a.project < b.project ? -1 : a.project > b.project ? 1 : 0)); for (const input of sorted) { if (input.sources.length === 0) { @@ -949,8 +950,12 @@ export function exampleCatalog(inputs: readonly ExampleInput[]): Map; rows: LibraryRow[]; + selectedKey?: string; query: string; networkSearch: boolean; /** @@ -71,6 +74,9 @@ export interface LibraryModel { networkSearchSetting: string; /** The data source, in plain words, for the footer. */ dataSource: string; + sourceDetails?: string; + status?: string; + projectNotice?: string; /** `{0}` visible of `{1}` known, already localized; the header count uses it. */ countTemplate: string; /** Rows before filtering; the count line shows it against what is visible. */ @@ -112,6 +118,12 @@ export const LIBRARY_UI = { count: "library.count", open: "library.open", refresh: "library.refresh", + sources: "library.sources", + other: "library.other", + settings: "library.settings", + update: "library.update", + output: "library.output", + clear: "library.clear", } as const; /** One badge key's `ui` key, so the renderer has a single lookup table. */ @@ -124,23 +136,13 @@ const BADGE_KEY_UI: Readonly> = { "openkal-platform": LIBRARY_UI.badgeOpenkalPlatform, }; -/** - * What the document says to the host. - * - * There deliberately **no `ready` message**. The document is the data — every - * row, badge and count is already in it — so a "I have loaded" announcement can - * only invite the host to render the same thing again. It did exactly that, and - * because assigning `webview.html` reloads the document while every render mints - * a fresh CSP nonce, the page never matched the previous one: the view reloaded - * itself forever (flicker, unclickable rows, a pegged CPU). The host now also - * refuses to re-assign an identical document — both rules live in - * `src/webview/document.ts` (`WebviewDocument`) and `src/webview/render.ts` - * (`documentNeedsRender`) — so this cannot come back by accident. +/** A mounted handshake replays data in place after the initial document loads. + * The host never assigns webview.html in response, so it cannot create a reload loop. */ export type LibraryMessage = - | { type: "refresh" } + | { type: "refresh" | "settings" | "update" | "output" | "mounted" } | { type: "search"; query: string } - | { type: "networkSearch"; enabled: boolean } + | { type: "networkSearch"; enabled: boolean; token?: string } | { type: "open"; id: string }; export type UiLabel = (key: string) => string; @@ -187,7 +189,7 @@ export function badgeLabels(badges: readonly BadgeKey[], label: UiLabel): string } function renderRow(row: LibraryRow, label: UiLabel): string { - const badges = badgeLabels(row.badges, label); + const badges: string[] = []; const openTitle = fill(label, LIBRARY_UI.open, [row.id]); const surface = row.surface === undefined ? undefined : surfaceLabel(row.surface, label); const version = @@ -197,14 +199,14 @@ function renderRow(row: LibraryRow, label: UiLabel): string { ...badges.map((badge) => `${escapeHtml(badge)}`), row.added ? `${escapeHtml(label(LIBRARY_UI.added))}` : "", row.unreadable ? `${escapeHtml(label(LIBRARY_UI.unreadable))}` : "", - row.crossRegistry === true ? `${escapeHtml(label(LIBRARY_UI.crossRegistry))}` : "", + row.crossRegistry === true || row.source ? `${escapeHtml(row.source ?? label(LIBRARY_UI.crossRegistry))}` : "", ] .filter((part) => part.length > 0) .join(""); const description = row.description === undefined ? "" : row.description; return [ `
  • `, - ` `, ` `, + ` ${escapeHtml(fill(label, LIBRARY_UI.count, [model.rows.filter((row) => !row.crossRegistry && rowMatchesClient(model, row)).length, model.rows.filter((row) => !row.crossRegistry).length]))}`, ``, ].join("\n"); } function renderList(model: LibraryModel, label: UiLabel): string { - if (model.rows.length === 0) { - return `

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

    `; - } - return [ - `
      `, - model.rows.map((row) => renderRow(row, label)).join("\n"), - `
    `, - ``, - ].join("\n"); + const local = model.rows.filter((row) => !row.crossRegistry); + const extra = model.rows.filter((row) => row.crossRegistry); + return `
    + ${model.projectNotice ? `

    ${escapeHtml(model.projectNotice)}

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

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

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

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

    +

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

    +
    `; } /** @@ -272,72 +279,98 @@ function clientScript(initialModel: string): string { "use strict"; var api = typeof acquireVsCodeApi === "function" ? acquireVsCodeApi() : undefined; var state = ${initialModel}; + var saved = api && api.getState ? api.getState() || {} : {}; var searchInput = document.getElementById("library-search"); var networkInput = document.getElementById("library-network"); - var list = document.getElementById("library-list"); - var empty = document.getElementById("library-empty"); - var countElement = document.getElementById("library-count"); - - function post(message) { - if (api) { api.postMessage(message); } + var networkToken; + var networkSequence = 0; + var selected = state.selectedKey || saved.selected || ""; + if (searchInput && typeof saved.query === "string") { searchInput.value = saved.query; } + function post(message) { if (api) { api.postMessage(message); } } + function persist() { + if (api && api.setState) { api.setState({ query: searchInput.value, selected: selected, scroll: window.scrollY }); } } - - // Every whitespace-separated word has to appear in the row's haystack, which - // the host builds from the id, the name, the description and the usage labels. - function rowMatches(row, query) { - if (query.length === 0) { return true; } - var haystack = row.getAttribute("data-haystack") || ""; - var parts = query.split(/\\s+/); - for (var index = 0; index < parts.length; index += 1) { - if (parts[index].length > 0 && haystack.indexOf(parts[index]) < 0) { return false; } - } - return true; - } - function apply() { - var query = searchInput ? searchInput.value.trim().toLowerCase() : ""; - var any = false; - var visible = 0; + document.querySelector('[data-action="clear"]').hidden = searchInput.value.length === 0; + var query = searchInput.value.trim().toLowerCase(); + var parts = query.split(/\\s+/); + var rows = Array.from(document.querySelectorAll("[data-row]")); + var local = 0; + var extra = 0; + rows.forEach(function (row) { + var haystack = row.getAttribute("data-haystack") || ""; + var shown = parts.every(function (part) { return !part || haystack.indexOf(part) >= 0; }); + row.parentNode.hidden = !shown; + if (shown) { if (row.getAttribute("data-cross") === "true") { extra++; } else { local++; } } + if (row.getAttribute("data-row") === selected) { row.setAttribute("aria-current", "true"); } + else { row.removeAttribute("aria-current"); } + }); + var list = document.getElementById("library-list"); if (list) { - var rows = list.querySelectorAll("[data-row]"); - for (var index = 0; index < rows.length; index += 1) { - var shown = rowMatches(rows[index], query); - rows[index].parentNode.hidden = !shown; - if (shown) { any = true; visible += 1; } - } - list.hidden = !any; - } - if (empty) { empty.hidden = any || !list; } - if (countElement) { - var template = state && state.countTemplate ? state.countTemplate : ""; - var total = state && state.total ? state.total : 0; - countElement.textContent = template.split("{0}").join(String(visible)).split("{1}").join(String(total)); + Array.from(list.children).sort(function (a, b) { + function rank(item) { + var id = item.firstElementChild.getAttribute("data-id").toLowerCase(); + var name = id.slice(id.lastIndexOf(".") + 1); + return !query ? 2 : id === query || name === query ? 0 : id.indexOf(query) === 0 || name.indexOf(query) === 0 ? 1 : 2; + } + return rank(a) - rank(b) || a.firstElementChild.getAttribute("data-id").localeCompare(b.firstElementChild.getAttribute("data-id")); + }).forEach(function (item) { list.appendChild(item); }); } + var empty = document.getElementById("library-empty"); + if (empty) { empty.hidden = local + extra > 0; } + var group = document.getElementById("library-extra"); + if (group) { group.hidden = extra === 0; } + document.getElementById("library-count").textContent = state.countTemplate.split("{0}").join(String(local)).split("{1}").join(String(state.localTotal)); + persist(); } - - if (searchInput) { - searchInput.addEventListener("input", function () { - apply(); - post({ type: "search", query: searchInput.value }); - }); - } - if (networkInput) { - networkInput.addEventListener("change", function () { - post({ type: "networkSearch", enabled: networkInput.checked === true }); - }); - } + searchInput.addEventListener("input", function () { apply(); post({ type: "search", query: searchInput.value }); }); + networkInput.addEventListener("change", function () { networkToken = Date.now() + "-" + (++networkSequence); post({ type: "networkSearch", enabled: networkInput.checked, token: networkToken }); }); document.addEventListener("click", function (event) { - var target = event.target; - if (!target || typeof target.closest !== "function") { return; } - var refresh = target.closest("[data-action]"); - if (refresh && refresh.getAttribute("data-action") === "refresh") { - post({ type: "refresh" }); - return; + var target = event.target.closest && event.target.closest("[data-action], [data-row]"); + if (!target) { return; } + var action = target.getAttribute("data-action"); + if (action === "clear") { searchInput.value = ""; searchInput.focus(); apply(); post({ type: "search", query: "" }); return; } + if (action) { post({ type: action }); return; } + selected = target.getAttribute("data-row"); apply(); post({ type: "open", id: selected }); + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Escape" && event.target === searchInput) { + searchInput.value = ""; apply(); post({ type: "search", query: "" }); } - var row = target.closest("[data-row]"); - if (row) { post({ type: "open", id: row.getAttribute("data-row") }); } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") { return; } + var rows = Array.from(document.querySelectorAll("[data-row]")).filter(function (row) { return !row.parentNode.hidden; }); + if (!rows.length || (event.target !== searchInput && !event.target.hasAttribute("data-row"))) { return; } + event.preventDefault(); + var index = rows.indexOf(document.activeElement); + var next = event.key === "ArrowDown" ? Math.min(rows.length - 1, index + 1) : Math.max(0, index - 1); + rows[next].focus(); + }); + window.addEventListener("scroll", persist); + window.addEventListener("message", function (event) { + var payload = event.data; + if (payload && payload.type === "networkSearchSaved") { if (payload.token === networkToken) { networkToken = undefined; networkInput.checked = payload.enabled; } return; } + if (!payload || payload.type !== "libraryUpdate") { return; } + var scroll = window.scrollY; + var focused = document.activeElement && document.activeElement.getAttribute("data-row"); + var details = document.querySelector("footer details"); + var expanded = details && details.open; + var doc = new DOMParser().parseFromString(payload.html, "text/html"); + ["library-results", "library-footer"].forEach(function (id) { + var old = document.getElementById(id); var next = doc.getElementById(id); + if (old && next) { old.replaceWith(next); } + }); + state = payload.state; + if (state.selectedKey) { selected = state.selectedKey; } + if (!networkToken) { networkInput.checked = payload.networkSearch; } + apply(); + var nextDetails = document.querySelector("footer details"); if (nextDetails) { nextDetails.open = expanded; } + if (focused) { Array.from(document.querySelectorAll("[data-row]")).some(function (row) { if (row.getAttribute("data-row") === focused) { row.focus(); return true; } return false; }); } + window.scrollTo(0, scroll); }); apply(); + window.scrollTo(0, saved.scroll || 0); + post({ type: "mounted" }); + post({ type: "search", query: searchInput.value }); })();`; } @@ -348,10 +381,12 @@ function clientScript(initialModel: string): string { * embedding the model again would double the page for nothing. `<` is escaped in * `embedJson`, so even this cannot close the script element. */ -function clientState(model: LibraryModel): Record { +export function libraryClientState(model: LibraryModel): Record { return { countTemplate: model.countTemplate, + selectedKey: model.selectedKey, total: model.total, + localTotal: model.rows.filter((row) => !row.crossRegistry).length, query: model.query, }; } @@ -380,20 +415,24 @@ export function renderLibraryHtml(model: LibraryModel, assets: LibraryAssets): s ${escapeHtml(label(LIBRARY_UI.title))} -
    +

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

    -

    ${escapeHtml(count)}

    ${renderToolbar(model, label)}
    ${renderList(model, label)}
    -