diff --git a/package.json b/package.json index 0a10ca7f3..7ed22a908 100644 --- a/package.json +++ b/package.json @@ -489,6 +489,11 @@ "command": "swift.generateSourcekitConfiguration", "title": "Generate SourceKit-LSP Configuration", "category": "Swift" + }, + { + "command": "swift.createDocumentationCatalog", + "title": "Create Documentation Catalog", + "category": "Swift" } ], "configuration": [ @@ -1382,6 +1387,10 @@ { "command": "swift.play", "when": "false" + }, + { + "command": "swift.createDocumentationCatalog", + "when": "workspaceFolderCount > 0" } ], "editor/context": [ diff --git a/src/commands.ts b/src/commands.ts index 707aaa2b0..9f75e82ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -20,6 +20,7 @@ import { WorkspaceContext } from "./WorkspaceContext"; import { attachDebugger } from "./commands/attachDebugger"; import { cleanBuild, debugBuild, runBuild } from "./commands/build"; import { captureDiagnostics } from "./commands/captureDiagnostics"; +import { createDocumentationCatalog } from "./commands/createDocumentationCatalog"; import { createNewProject } from "./commands/createNewProject"; import { editDependency } from "./commands/dependencies/edit"; import { resolveDependencies } from "./commands/dependencies/resolve"; @@ -350,6 +351,10 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(packagePath)); }), vscode.commands.registerCommand("swift.openDocumentation", () => openDocumentation()), + vscode.commands.registerCommand( + "swift.createDocumentationCatalog", + async () => await createDocumentationCatalog(ctx) + ), vscode.commands.registerCommand( Commands.GENERATE_SOURCEKIT_CONFIG, async () => await generateSourcekitConfiguration(ctx) diff --git a/src/commands/createDocumentationCatalog.ts b/src/commands/createDocumentationCatalog.ts new file mode 100644 index 000000000..c46e002ca --- /dev/null +++ b/src/commands/createDocumentationCatalog.ts @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import * as fs from "fs/promises"; +import * as path from "path"; +import * as vscode from "vscode"; + +import { FolderContext } from "../FolderContext"; +import { WorkspaceContext } from "../WorkspaceContext"; +import { selectFolder } from "../ui/SelectFolderQuickPick"; +import { folderExists, pathExists } from "../utilities/filesystem"; + +type DoccLocationPickItem = vscode.QuickPickItem & { + basePath: string; +}; + +export async function createDocumentationCatalog( + ctx: WorkspaceContext, + folderContext?: FolderContext +): Promise { + let folder = folderContext ?? ctx.currentFolder; + + // ---- workspace folder resolution (standard pattern) ---- + if (!folder) { + if (ctx.folders.length === 0) { + void vscode.window.showErrorMessage( + "Creating a documentation catalog requires an open workspace folder." + ); + return; + } + + if (ctx.folders.length === 1) { + folder = ctx.folders[0]; + } else { + const selected = await selectFolder( + ctx, + "Select a workspace folder to create the DocC catalog in", + { all: "" } + ); + if (selected.length !== 1) { + return; + } + folder = selected[0]; + } + } + + const rootPath = folder.folder.fsPath; + + // ---- build QuickPick items from swiftPackage (PROMISE) ---- + const itemsPromise = folder.swiftPackage.getTargets().then(async targets => { + const items: DoccLocationPickItem[] = []; + + for (const target of targets) { + const base = path.join(rootPath, target.path); + + // target paths must be directories → folderExists is correct here + if (await folderExists(base)) { + items.push({ + label: `Target: ${target.name}`, + description: target.type, + detail: target.path, + basePath: base, + }); + } + } + + items.push({ + label: "Standalone documentation catalog", + description: "Workspace root", + basePath: rootPath, + }); + + return items; + }); + + // ---- show QuickPick (toolchain-style pattern) ---- + const selection = await vscode.window.showQuickPick(itemsPromise, { + title: "Create DocC Documentation Catalog", + placeHolder: "Select where to create the documentation catalog", + canPickMany: false, + }); + + if (!selection) { + return; + } + + const basePath = selection.basePath; + + // ---- module name input ---- + const moduleName = await vscode.window.showInputBox({ + prompt: "Enter Swift module name", + placeHolder: "MyModule", + validateInput: async value => { + const name = value.trim(); + if (name.length === 0) { + return "Module name cannot be empty"; + } + + const doccDir = path.join(basePath, `${name}.docc`); + + // creation path → must be unused → pathExists + if (await pathExists(doccDir)) { + return `Documentation catalog "${name}.docc" already exists`; + } + + return undefined; + }, + }); + + if (!moduleName) { + return; + } + + const doccDir = path.join(basePath, `${moduleName}.docc`); + const markdownFile = path.join(doccDir, `${moduleName}.md`); + + // ---- execution-time guard (race-safe) ---- + if (await pathExists(doccDir)) { + void vscode.window.showErrorMessage( + `Documentation catalog "${moduleName}.docc" already exists` + ); + return; + } + + await fs.mkdir(doccDir); + await fs.writeFile(markdownFile, `# ${moduleName}\n`, "utf8"); + + void vscode.window.showInformationMessage( + `Created DocC documentation catalog: ${moduleName}.docc` + ); +} diff --git a/test/integration-tests/commands/createDocumentationCatalog.test.ts b/test/integration-tests/commands/createDocumentationCatalog.test.ts new file mode 100644 index 000000000..083454f58 --- /dev/null +++ b/test/integration-tests/commands/createDocumentationCatalog.test.ts @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +import { expect } from "chai"; +import * as fs from "fs/promises"; +import * as path from "path"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; + +import { FolderContext } from "@src/FolderContext"; +import { WorkspaceContext } from "@src/WorkspaceContext"; + +import { tag } from "../../tags"; +import { activateExtensionForSuite, folderInRootWorkspace } from "../utilities/testutilities"; + +tag("large").suite("Create Documentation Catalog Command", function () { + let folderContext: FolderContext; + let workspaceContext: WorkspaceContext; + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + folderContext = await folderInRootWorkspace("defaultPackage", workspaceContext); + await workspaceContext.focusFolder(folderContext); + }, + }); + + test("creates a DocC catalog for a SwiftPM target", async () => { + test("creates a DocC catalog for a SwiftPM target", async () => { + const quickPickStub = sinon.stub(vscode.window, "showQuickPick"); + const inputBoxStub = sinon.stub(vscode.window, "showInputBox"); + + try { + inputBoxStub.resolves("MyModule"); + quickPickStub.callsFake(async itemsOrPromise => { + const items = await Promise.resolve(itemsOrPromise); + return items.find(item => item.label.startsWith("Target:")); + }); + + await vscode.commands.executeCommand("swift.createDocumentationCatalog"); + const basePath = folderContext.folder.fsPath; + const doccDir = path.join(basePath, "MyModule.docc"); + const markdownFile = path.join(doccDir, "MyModule.md"); + + expect(await fs.stat(doccDir)).to.exist; + expect(await fs.stat(markdownFile)).to.exist; + + const contents = await fs.readFile(markdownFile, "utf8"); + expect(contents).to.contain("# MyModule"); + } finally { + quickPickStub.restore(); + inputBoxStub.restore(); + } + }); + }); +});