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/package.json b/package.json index 4ccbd35..ff193c0 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", @@ -45,7 +46,7 @@ "capabilities": { "untrustedWorkspaces": { "supported": "limited", - "description": "未受信任工作区仅启用模块语法高亮,不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" + "description": "未受信任工作区仅启用模块语法高亮与 mcpp.toml 结构补全(纯文本分析),不执行 CDB、mcpp 或 clangd 指定的任何程序,也不接管 clangd 配置。" } }, "main": "./dist/src/extension.js", @@ -151,6 +152,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 199dbcf..f16d760 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"; import { buildModuleSetupPlan, executeModuleSetup, @@ -825,6 +826,47 @@ async function autoConfigureModulesWizard( } } +// 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(); @@ -1001,6 +1043,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..3cc0e16 --- /dev/null +++ b/src/mcppTomlCompletion.ts @@ -0,0 +1,229 @@ +// 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: "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") { + // 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/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 4ab77e4..ebcf44b 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..33983d7 --- /dev/null +++ b/test/mcppTomlCompletion.test.ts @@ -0,0 +1,107 @@ +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("[build-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("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); + 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 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")); + 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..a9506fb --- /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", "build-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 }); +});