diff --git a/package.json b/package.json index e83188c..071823f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "onCommand:mcpp.refreshCompilationDatabase", "onCommand:mcpp.checkModuleSupport", "onCommand:mcpp.showMenu", + "onCommand:mcpp.newProject", "onCommand:mcpp.build", "onCommand:mcpp.run", "onCommand:mcpp.test", @@ -68,6 +69,10 @@ "command": "mcpp.showMenu", "title": "mcpp: 打开快捷菜单" }, + { + "command": "mcpp.newProject", + "title": "mcpp: 新建工程" + }, { "command": "mcpp.build", "title": "mcpp: 构建" diff --git a/src/cliController.ts b/src/cliController.ts index 77766cf..2e53172 100644 --- a/src/cliController.ts +++ b/src/cliController.ts @@ -1,3 +1,5 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import process from "node:process"; import * as vscode from "vscode"; @@ -23,6 +25,7 @@ import { type TaskCompletion, } from "./tasks"; import { CLI_COMMANDS, quickMenuItems, quickMenuStatusText } from "./commands"; +import { runNewProjectFlow, validateNewProjectName } from "./newProject"; export interface McppCliControllerOptions { output: vscode.OutputChannel; @@ -82,6 +85,7 @@ export class McppCliController { 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"))), @@ -493,6 +497,57 @@ export class McppCliController { } } + 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 { diff --git a/src/commands.ts b/src/commands.ts index bd1c8fb..a28796c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,5 +1,6 @@ export const CLI_COMMANDS = { showMenu: "mcpp.showMenu", + newProject: "mcpp.newProject", build: "mcpp.build", run: "mcpp.run", test: "mcpp.test", diff --git a/src/newProject.ts b/src/newProject.ts new file mode 100644 index 0000000..07cdead --- /dev/null +++ b/src/newProject.ts @@ -0,0 +1,90 @@ +export interface NewProjectActions { + exists(path: string): boolean; + confirm(message: string): Promise; + run(name: string, cwd: string): Promise; + openFolder(path: string): Promise; + showError(message: string): Promise | void; +} + +export type NewProjectOutcome = "exists" | "declined" | "failed" | "opened"; + +/** + * 新建工程的核心流程,依赖全部注入以便单测。契约:创建并打开工程—— + * 打开后的构建交给用户手动触发(或后续 #5 的 IDE configure 流程), + * 避免与缺少 CDB 时的 configure 重复执行。 + */ +export async function runNewProjectFlow( + projectName: string, + location: string, + projectRoot: string, + actions: NewProjectActions, +): Promise { + if (actions.exists(projectRoot)) { + await actions.showError(`目标路径已存在:${projectRoot}。请更换项目名或位置。`); + return "exists"; + } + const confirmed = await actions.confirm( + `将在 ${location} 执行 “mcpp new ${projectName}”,创建项目文件夹 ${projectRoot} 并打开它。`, + ); + if (!confirmed) { + return "declined"; + } + const exitCode = await actions.run(projectName, location); + if (exitCode !== 0) { + await actions.showError( + `mcpp new ${projectName} 失败(退出码 ${exitCode})。请查看 mcpp 输出频道。`, + ); + return "failed"; + } + await actions.openFolder(projectRoot); + return "opened"; +} + +const CONTROL_CHARS = /[\u0000-\u001F\u007F]/; +const WINDOWS_RESERVED_CHARS = /[<>:"|?*]/; +const WINDOWS_DEVICE_NAMES = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i; +const MCPP_BUILTIN_TEMPLATE_MARKER = "PROJECT"; + +/** + * 新建工程的项目名校验。返回错误提示;undefined 表示合法。 + * + * 项目名随后作为 `mcpp new ` 的 argv 传入:参数数组只能防 shell 注入, + * 不能阻止 mcpp 自身把名字解析为 CLI 选项(如 --template),所以这里拒绝 + * `-` 前缀以及 `.`、`..`。 + * + * mcpp 模板把项目名直接写进 mcpp.toml 的 `name = "{}"` 和 main.cpp,不做 + * TOML/C++ 转义,所以拒绝双引号和控制字符;Windows 保留字符、保留设备名和 + * 尾随点一并按跨平台策略拒绝。根本修复应在 mcpp CLI 自身完成。 + */ +export function validateNewProjectName(input: string): string | undefined { + const name = input.trim(); + if (name.length === 0) { + return "项目名不能为空"; + } + if (/[\\/]/.test(name)) { + return "项目名不能包含路径分隔符"; + } + if (name.startsWith("-")) { + return "项目名不能以 - 开头,否则会被 mcpp 解析为命令行选项"; + } + if (name === "." || name === "..") { + return "项目名不能是 . 或 .."; + } + // mcpp#380:当前内置模板会重复扫描替换结果,名称包含该标记时不会终止。 + if (name.includes(MCPP_BUILTIN_TEMPLATE_MARKER)) { + return "项目名不能包含 PROJECT,否则会触发当前 mcpp 模板替换缺陷"; + } + if (CONTROL_CHARS.test(name)) { + return "项目名不能包含控制字符"; + } + if (WINDOWS_RESERVED_CHARS.test(name)) { + return '项目名不能包含 <>:"|?* 等保留字符'; + } + if (name.endsWith(".")) { + return "项目名不能以 . 结尾(Windows 不支持)"; + } + if (WINDOWS_DEVICE_NAMES.test(name)) { + return "项目名不能是 Windows 保留设备名"; + } + return undefined; +} diff --git a/test/artifacts.test.ts b/test/artifacts.test.ts index f2d24cb..e9f304b 100644 --- a/test/artifacts.test.ts +++ b/test/artifacts.test.ts @@ -39,6 +39,7 @@ test("declares the official clangd dependency and mcpp commands", () => { manifest.contributes?.commands?.map((command) => command.command), [ "mcpp.showMenu", + "mcpp.newProject", "mcpp.build", "mcpp.run", "mcpp.test", @@ -231,6 +232,35 @@ test("泛化 triple 工具链由 mcpp 最终校验", () => { assert.match(method, /可能携带 target 语义.*最终由 mcpp 校验/s); }); +test("新建工程先校验目标路径再确认创建,成功后只打开不构建", () => { + const source = readFileSync(path.join(root, "src/cliController.ts"), "utf8"); + const start = source.indexOf("public async newProject"); + const end = source.indexOf("private guarded", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + // 控制流本身由 test/newProject.test.ts 对 runNewProjectFlow 的行为级测试覆盖; + // 这里只验证控制器把 UI/进程依赖注入流程函数。 + const method = source.slice(start, end); + assert.match(method, /validateNewProjectName/); + assert.match(method, /runNewProjectFlow/); + const flow = method.indexOf("runNewProjectFlow"); + const exists = method.indexOf("existsSync", flow); + const confirm = method.indexOf("showWarningMessage", flow); + const create = method.indexOf("runProcess", flow); + const open = method.indexOf('executeCommand("vscode.openFolder"', flow); + assert.ok(exists >= 0 && exists < confirm); + assert.ok(confirm >= 0 && confirm < create); + assert.ok(create >= 0 && create < open); +}); + +test("新建工程契约是创建并打开,不自动构建", () => { + const controller = readFileSync(path.join(root, "src/cliController.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/); +}); + test("声明 GitHub 仓库和扩展图标", () => { const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")) as PackageManifest; assert.equal(manifest.icon, "images/logo.png"); diff --git a/test/commands.test.ts b/test/commands.test.ts index 6e59248..843a2fc 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -10,6 +10,7 @@ test("状态栏快捷菜单名称与模块状态易于区分", () => { test("CLI 命令覆盖项目、工具链和 IDE", () => { assert.deepEqual(Object.values(CLI_COMMANDS), [ "mcpp.showMenu", + "mcpp.newProject", "mcpp.build", "mcpp.run", "mcpp.test", diff --git a/test/newProject.test.ts b/test/newProject.test.ts new file mode 100644 index 0000000..420c642 --- /dev/null +++ b/test/newProject.test.ts @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { validateNewProjectName } from "../src/newProject"; + +test("拒绝空值和纯空白项目名", () => { + for (const name of ["", " "]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${JSON.stringify(name)}`); + } +}); + +test("拒绝包含路径分隔符的项目名", () => { + for (const name of ["a/b", "a\\b", "/abs", "..\\up"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("拒绝会被 mcpp 解析为 CLI 选项的项目名", () => { + // 参数数组只能防 shell 注入;`-` 前缀会被 mcpp 自身解析为 + // --template、--list-templates 等选项,可能成功退出却没有创建工程。 + for (const name of ["-x", "--template", "--list-templates"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("拒绝相对路径名 . 和 ..", () => { + for (const name of [".", ".."]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("拒绝双引号和控制字符,避免破坏 mcpp 生成的 TOML 和 C++ 源码", () => { + // mcpp 模板把项目名直接写进 mcpp.toml 的 name = "{}" 和 main.cpp, + // 不做 TOML/C++ 转义,这些输入会生成坏工程。 + for (const name of ['bad"name', "bad\tname", "bad\nname", "bad\rname", "bad\u001Fname", "bad\u007Fname"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${JSON.stringify(name)}`); + } +}); + +test("拒绝会触发 mcpp 内置模板无限替换的项目名", () => { + // mcpp 当前会循环替换字面量 PROJECT;插入值仍包含该标记时不会终止。 + for (const name of ["PROJECT", "myPROJECTname", "demo-PROJECT-app"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("按跨平台策略拒绝 Windows 保留字符、设备名和尾随点", () => { + for (const name of ["ab", "a:b", "a|b", "a?b", "a*b", "name."]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } + for (const name of ["CON", "con", "PRN", "AUX", "NUL", "COM1", "com9", "LPT1"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("Windows 保留设备名添加扩展后仍然拒绝", () => { + for (const name of ["CON.txt", "con.json", "AUX.md", "LPT1.log", "COM9.tar.gz"]) { + assert.ok(validateNewProjectName(name) !== undefined, `should reject: ${name}`); + } +}); + +test("接受常规项目名,前后空白忽略", () => { + for (const name of ["hello", "hello-mcpp", "my_project", "a.b.c", "项目", "console", "com10", " padded "]) { + assert.equal(validateNewProjectName(name), undefined, `should accept: ${name}`); + } +}); +import { runNewProjectFlow, type NewProjectActions } from "../src/newProject"; + +function recordingActions(overrides: Partial, calls: string[]): NewProjectActions { + return { + exists: (path) => { + calls.push(`exists:${path}`); + return overrides.exists?.(path) ?? false; + }, + confirm: async (message) => { + calls.push(`confirm:${message}`); + return overrides.confirm?.(message) ?? true; + }, + run: async (name, cwd) => { + calls.push(`run:${name}@${cwd}`); + return overrides.run?.(name, cwd) ?? 0; + }, + openFolder: async (path) => { + calls.push(`openFolder:${path}`); + await overrides.openFolder?.(path); + }, + showError: (message) => { + calls.push(`showError:${message}`); + }, + }; +} + +test("目标路径已存在时报错且不确认、不创建、不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ exists: () => true }, calls), + ); + assert.equal(outcome, "exists"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "showError:目标路径已存在:/parent/demo。请更换项目名或位置。", + ]); +}); + +test("用户取消确认时不创建、不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ confirm: async () => false }, calls), + ); + assert.equal(outcome, "declined"); + assert.deepEqual(calls.map((call) => call.split(":", 1)[0]), ["exists", "confirm"]); +}); + +test("mcpp new 失败时报错且不打开", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({ run: async () => 2 }, calls), + ); + assert.equal(outcome, "failed"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "confirm:将在 /parent 执行 “mcpp new demo”,创建项目文件夹 /parent/demo 并打开它。", + "run:demo@/parent", + "showError:mcpp new demo 失败(退出码 2)。请查看 mcpp 输出频道。", + ]); +}); + +test("创建成功后只打开项目文件夹,不自动构建", async () => { + const calls: string[] = []; + const outcome = await runNewProjectFlow( + "demo", + "/parent", + "/parent/demo", + recordingActions({}, calls), + ); + assert.equal(outcome, "opened"); + assert.deepEqual(calls, [ + "exists:/parent/demo", + "confirm:将在 /parent 执行 “mcpp new demo”,创建项目文件夹 /parent/demo 并打开它。", + "run:demo@/parent", + "openFolder:/parent/demo", + ]); +});