diff --git a/CHANGELOG.md b/CHANGELOG.md index f72c0b396..650c4593b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Show revision hash or local/editing keyword in project panel dependency descriptions ([#1667](https://github.com/swiftlang/vscode-swift/pull/1667)) +- Show files generated by build plugins under Target in Project Panel ([#1592](https://github.com/swiftlang/vscode-swift/pull/1592)) ## 2.6.2 - 2025-07-02 diff --git a/assets/test/targets/Package.swift b/assets/test/targets/Package.swift index 35cda10ab..1e67f25b1 100644 --- a/assets/test/targets/Package.swift +++ b/assets/test/targets/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -25,17 +25,28 @@ let package = Package( ], targets: [ .target( - name: "LibraryTarget" + name: "LibraryTarget", + plugins: [ + .plugin(name: "BuildToolPlugin") + ] ), .executableTarget( name: "ExecutableTarget" ), + .executableTarget( + name: "BuildToolExecutableTarget" + ), .plugin( name: "PluginTarget", capability: .command( intent: .custom(verb: "testing", description: "A plugin for testing plugins") ) ), + .plugin( + name: "BuildToolPlugin", + capability: .buildTool(), + dependencies: ["BuildToolExecutableTarget"] + ), .testTarget( name: "TargetsTests", dependencies: ["LibraryTarget"] diff --git a/assets/test/targets/Plugins/BuildToolPlugin/BuildToolPlugin.swift b/assets/test/targets/Plugins/BuildToolPlugin/BuildToolPlugin.swift new file mode 100644 index 000000000..c56ae309b --- /dev/null +++ b/assets/test/targets/Plugins/BuildToolPlugin/BuildToolPlugin.swift @@ -0,0 +1,48 @@ +import PackagePlugin +import Foundation + +@main +struct SimpleBuildToolPlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + guard let sourceFiles = target.sourceModule?.sourceFiles else { return [] } + + #if os(Windows) + return [] + #endif + + let generatorTool = try context.tool(named: "BuildToolExecutableTarget") + + // Construct a build command for each source file with a particular suffix. + return sourceFiles.map(\.path).compactMap { + createBuildCommand( + for: $0, + in: context.pluginWorkDirectory, + with: generatorTool.path + ) + } + } + + /// Calls a build tool that transforms JSON files into Swift files. + func createBuildCommand(for inputPath: Path, in outputDirectoryPath: Path, with generatorToolPath: Path) -> Command? { + let inputURL = URL(fileURLWithPath: inputPath.string) + let outputDirectoryURL = URL(fileURLWithPath: outputDirectoryPath.string) + + // Skip any file that doesn't have the extension we're looking for (replace this with the actual one). + guard inputURL.pathExtension == "json" else { return .none } + + // Produces .swift files in the same directory structure as the input JSON files appear in the target. + let components = inputURL.absoluteString.split(separator: "LibraryTarget", omittingEmptySubsequences: false).map(String.init) + let inputName = inputURL.lastPathComponent + let outputDir = outputDirectoryURL.appendingPathComponent(components[1]).deletingLastPathComponent() + let outputName = inputURL.deletingPathExtension().lastPathComponent + ".swift" + let outputURL = outputDir.appendingPathComponent(outputName) + + return .buildCommand( + displayName: "Generating \(outputName) from \(inputName)", + executable: generatorToolPath, + arguments: ["\(inputPath)", "\(outputURL.path)"], + inputFiles: [inputPath], + outputFiles: [Path(outputURL.path)] + ) + } +} diff --git a/assets/test/targets/Sources/BuildToolExecutableTarget/BuildToolExecutableTarget.swift b/assets/test/targets/Sources/BuildToolExecutableTarget/BuildToolExecutableTarget.swift new file mode 100644 index 000000000..fd59c3add --- /dev/null +++ b/assets/test/targets/Sources/BuildToolExecutableTarget/BuildToolExecutableTarget.swift @@ -0,0 +1,45 @@ +#if !os(Windows) +import Foundation + +@main +struct CodeGenerator { + static func main() async throws { + // Use swift-argument-parser or just CommandLine, here we just imply that 2 paths are passed in: input and output + guard CommandLine.arguments.count == 3 else { + throw CodeGeneratorError.invalidArguments + } + // arguments[0] is the path to this command line tool + guard let input = URL(string: "file://\(CommandLine.arguments[1])"), let output = URL(string: "file://\(CommandLine.arguments[2])") else { + return + } + let jsonData = try Data(contentsOf: input) + let enumFormat = try JSONDecoder().decode(JSONFormat.self, from: jsonData) + + let code = """ + enum \(enumFormat.name): CaseIterable { + \t\(enumFormat.cases.map({ "case \($0)" }).joined(separator: "\n\t")) + } + """ + guard let data = code.data(using: .utf8) else { + throw CodeGeneratorError.invalidData + } + try data.write(to: output, options: .atomic) + } +} + +struct JSONFormat: Decodable { + let name: String + let cases: [String] +} + +enum CodeGeneratorError: Error { + case invalidArguments + case invalidData +} +#else +@main +struct DummyMain { + static func main() { + } +} +#endif \ No newline at end of file diff --git a/assets/test/targets/Sources/LibraryTarget/Bar/Baz.json b/assets/test/targets/Sources/LibraryTarget/Bar/Baz.json new file mode 100644 index 000000000..6d2776154 --- /dev/null +++ b/assets/test/targets/Sources/LibraryTarget/Bar/Baz.json @@ -0,0 +1,8 @@ +{ + "name": "Baz", + "cases": [ + "bar", + "baz", + "bbb" + ] +} \ No newline at end of file diff --git a/assets/test/targets/Sources/LibraryTarget/Foo.json b/assets/test/targets/Sources/LibraryTarget/Foo.json new file mode 100644 index 000000000..577d47e6d --- /dev/null +++ b/assets/test/targets/Sources/LibraryTarget/Foo.json @@ -0,0 +1,8 @@ +{ + "name": "Foo", + "cases": [ + "bar", + "baz", + "qux" + ] +} \ No newline at end of file diff --git a/scripts/package.ts b/scripts/package.ts index 9e03f61f7..397cbdccf 100644 --- a/scripts/package.ts +++ b/scripts/package.ts @@ -34,8 +34,13 @@ main(async () => { // Update version in CHANGELOG await updateChangelog(versionString); + // Use VSCE to package the extension - await exec("npx", ["vsce", "package"], { + // Note: There are no sendgrid secrets in the extension. `--allow-package-secrets` works around a false positive + // where the symbol `SG.MessageTransports.is` can appear in the dist.js if we're unlucky enough + // to have `SG` as the minified name of a namespace. Here is the rule we sometimes mistakenly match: + // https://github.com/secretlint/secretlint/blob/5706ac4942f098b845570541903472641d4ae914/packages/%40secretlint/secretlint-rule-sendgrid/src/index.ts#L35 + await exec("npx", ["vsce", "package", "--allow-package-secrets", "sendgrid"], { cwd: rootDirectory, }); }); diff --git a/src/ui/ProjectPanelProvider.ts b/src/ui/ProjectPanelProvider.ts index 2dcb80e54..71bcfd9b5 100644 --- a/src/ui/ProjectPanelProvider.ts +++ b/src/ui/ProjectPanelProvider.ts @@ -24,6 +24,8 @@ import { FolderContext } from "../FolderContext"; import { getPlatformConfig, resolveTaskCwd } from "../utilities/tasks"; import { SwiftTask, TaskPlatformSpecificConfig } from "../tasks/SwiftTaskProvider"; import { convertPathToPattern, glob } from "fast-glob"; +import { Version } from "../utilities/version"; +import { existsSync } from "fs"; const LOADING_ICON = "loading~spin"; @@ -282,9 +284,13 @@ function snippetTaskName(name: string): string { } class TargetNode { + private newPluginLayoutVersion = new Version(6, 0, 0); + constructor( public target: Target, - private activeTasks: Set + private folder: FolderContext, + private activeTasks: Set, + private fs?: (folder: string) => Promise ) {} get name(): string { @@ -355,7 +361,41 @@ class TargetNode { } getChildren(): TreeNode[] { - return []; + return this.buildPluginOutputs(this.folder.toolchain.swiftVersion); + } + + private buildToolGlobPattern(version: Version): string { + const base = this.folder.folder.fsPath.replace(/\\/g, "/"); + if (version.isGreaterThanOrEqual(this.newPluginLayoutVersion)) { + return `${base}/.build/plugins/outputs/*/${this.target.name}/*/*/**`; + } else { + return `${base}/.build/plugins/outputs/*/${this.target.name}/*/**`; + } + } + + private buildPluginOutputs(version: Version): TreeNode[] { + // Files in the `outputs` directory follow the pattern: + // .build/plugins/outputs/buildtoolplugin//destination//* + // This glob will capture all the files in the outputs directory for this target. + const pattern = this.buildToolGlobPattern(version); + const base = this.folder.folder.fsPath.replace(/\\/g, "/"); + const depth = version.isGreaterThanOrEqual(this.newPluginLayoutVersion) ? 4 : 3; + const matches = glob.sync(pattern, { onlyFiles: false, cwd: base, deep: depth }); + return matches.map(filePath => { + const pluginName = path.basename(filePath); + return new HeaderNode( + `${this.target.name}-${pluginName}`, + `${pluginName} - Generated Files`, + "debug-disconnect", + () => + getChildren( + filePath, + excludedFilesForProjectPanelExplorer(), + this.target.path, + this.fs + ) + ); + }); } } @@ -435,6 +475,8 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { private disposables: vscode.Disposable[] = []; private activeTasks: Set = new Set(); private lastComputedNodes: TreeNode[] = []; + private buildPluginOutputWatcher?: vscode.FileSystemWatcher; + private buildPluginFolderWatcher?: vscode.Disposable; onDidChangeTreeData = this.didChangeTreeDataEmitter.event; @@ -515,6 +557,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { if (!folder) { return; } + this.watchBuildPluginOutputs(folder); treeView.title = `Swift Project (${folder.name})`; this.didChangeTreeDataEmitter.fire(); break; @@ -537,6 +580,33 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { ); } + watchBuildPluginOutputs(folderContext: FolderContext) { + if (this.buildPluginOutputWatcher) { + this.buildPluginOutputWatcher.dispose(); + } + if (this.buildPluginFolderWatcher) { + this.buildPluginFolderWatcher.dispose(); + } + + const fire = () => this.didChangeTreeDataEmitter.fire(); + const buildPath = path.join(folderContext.folder.fsPath, ".build/plugins/outputs"); + this.buildPluginFolderWatcher = watchForFolder( + buildPath, + () => { + this.buildPluginOutputWatcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(buildPath, "{*,*/*}") + ); + this.buildPluginOutputWatcher.onDidCreate(fire); + this.buildPluginOutputWatcher.onDidDelete(fire); + this.buildPluginOutputWatcher.onDidChange(fire); + }, + () => { + this.buildPluginOutputWatcher?.dispose(); + fire(); + } + ); + } + getTreeItem(element: TreeNode): vscode.TreeItem { return element.toTreeItem(); } @@ -553,7 +623,6 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { ...this.lastComputedNodes, ]; } - const nodes = await this.computeChildren(folderContext, element); // If we're fetching the root nodes then save them in case we have an error later, @@ -649,7 +718,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { // Snipepts are shown under the Snippets header return targets .filter(target => target.type !== "snippet") - .map(target => new TargetNode(target, this.activeTasks)) + .map(target => new TargetNode(target, folderContext, this.activeTasks)) .sort((a, b) => targetSort(a).localeCompare(targetSort(b))); } @@ -705,7 +774,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider { const targets = await folderContext.swiftPackage.targets; return targets .filter(target => target.type === "snippet") - .flatMap(target => new TargetNode(target, this.activeTasks)) + .flatMap(target => new TargetNode(target, folderContext, this.activeTasks)) .sort((a, b) => a.name.localeCompare(b.name)); } } @@ -757,3 +826,35 @@ class TaskPoller implements vscode.Disposable { } } } + +/** + * Polls for the existence of a folder at the given path every 2.5 seconds. + * Notifies via the provided callbacks when the folder becomes available or is deleted. + */ +function watchForFolder( + folderPath: string, + onAvailable: () => void, + onDeleted: () => void +): vscode.Disposable { + const POLL_INTERVAL = 2500; + let folderExists = existsSync(folderPath); + + if (folderExists) { + onAvailable(); + } + + const interval = setInterval(() => { + const nowExists = existsSync(folderPath); + if (nowExists && !folderExists) { + folderExists = true; + onAvailable(); + } else if (!nowExists && folderExists) { + folderExists = false; + onDeleted(); + } + }, POLL_INTERVAL); + + return { + dispose: () => clearInterval(interval), + }; +} diff --git a/test/integration-tests/ui/ProjectPanelProvider.test.ts b/test/integration-tests/ui/ProjectPanelProvider.test.ts index fbf64d5cd..620b14838 100644 --- a/test/integration-tests/ui/ProjectPanelProvider.test.ts +++ b/test/integration-tests/ui/ProjectPanelProvider.test.ts @@ -84,13 +84,26 @@ suite("ProjectPanelProvider Test Suite", function () { () => treeProvider.getChildren(), commands => { const commandNames = commands.map(n => n.name); - expect(commandNames).to.deep.equal([ - "Dependencies", - "Targets", - "Tasks", - "Snippets", - "Commands", - ]); + // There is a bug in 5.9 where if you have a build tool plugin and a + // command plugin the command plugins do not get returned from `swift package plugin list`. + if ( + workspaceContext.globalToolchain.swiftVersion.isLessThan(new Version(5, 10, 0)) + ) { + expect(commandNames).to.deep.equal([ + "Dependencies", + "Targets", + "Tasks", + "Snippets", + ]); + } else { + expect(commandNames).to.deep.equal([ + "Dependencies", + "Targets", + "Tasks", + "Snippets", + "Commands", + ]); + } } ); }); @@ -105,8 +118,10 @@ suite("ProjectPanelProvider Test Suite", function () { targetNames, `Expected to find dependencies target, but instead items were ${targetNames}` ).to.deep.equal([ + "BuildToolExecutableTarget", "ExecutableTarget", "LibraryTarget", + "BuildToolPlugin", "PluginTarget", "AnotherTests", "TargetsTests", @@ -114,6 +129,36 @@ suite("ProjectPanelProvider Test Suite", function () { } ); }); + + test("Shows files generated by build tool plugin", async function () { + if (process.platform === "win32") { + this.skip(); + } + + const children = await getHeaderChildren("Targets"); + const target = children.find(n => n.name === "LibraryTarget") as PackageNode; + expect( + target, + `Expected to find LibraryTarget, but instead items were ${children.map(n => n.name)}` + ).to.not.be.undefined; + const generatedFilesHeaders = await target.getChildren(); + const generatedFiles = generatedFilesHeaders.find( + n => n.name === "BuildToolPlugin - Generated Files" + ) as PackageNode; + const generatedFilesChildren = await generatedFiles.getChildren(); + const file = generatedFilesChildren.find(n => n.name === "Foo.swift") as FileNode; + expect( + file, + `Expected to find Foo.swift, but instead items were ${generatedFilesChildren.map(n => n.name)}` + ).to.not.be.undefined; + const folder = generatedFilesChildren.find(n => n.name === "Bar") as FileNode; + const folderChildren = await folder.getChildren(); + const folderFile = folderChildren.find(n => n.name === "Baz.swift") as FileNode; + expect( + folderFile, + `Expected to find Foo.swift, but instead items were ${folderChildren.map(n => n.name)}` + ).to.not.be.undefined; + }); }); suite("Tasks", () => { @@ -145,7 +190,13 @@ suite("ProjectPanelProvider Test Suite", function () { expect(dep).to.not.be.undefined; }); - test("Executes a task", async () => { + test("Executes a task", async function () { + if ( + process.platform === "win32" && + workspaceContext.globalToolchain.swiftVersion.isLessThan(new Version(5, 10, 0)) + ) { + this.skip(); + } const task = await getBuildAllTask(); expect(task).to.not.be.undefined; const treeItem = task?.toTreeItem(); @@ -175,7 +226,7 @@ suite("ProjectPanelProvider Test Suite", function () { if ( process.platform === "win32" && workspaceContext.globalToolchain.swiftVersion.isLessThanOrEqual( - new Version(5, 9, 0) + new Version(5, 10, 0) ) ) { this.skip(); @@ -200,10 +251,11 @@ suite("ProjectPanelProvider Test Suite", function () { suite("Commands", () => { test("Includes commands", async function () { if ( - process.platform === "win32" && - workspaceContext.globalToolchain.swiftVersion.isLessThanOrEqual( - new Version(6, 0, 0) - ) + (process.platform === "win32" && + workspaceContext.globalToolchain.swiftVersion.isLessThanOrEqual( + new Version(6, 0, 0) + )) || + workspaceContext.globalToolchain.swiftVersion.isLessThan(new Version(5, 10, 0)) ) { this.skip(); } @@ -219,10 +271,11 @@ suite("ProjectPanelProvider Test Suite", function () { test("Executes a command", async function () { if ( - process.platform === "win32" && - workspaceContext.globalToolchain.swiftVersion.isLessThanOrEqual( - new Version(6, 0, 0) - ) + (process.platform === "win32" && + workspaceContext.globalToolchain.swiftVersion.isLessThanOrEqual( + new Version(6, 0, 0) + )) || + workspaceContext.globalToolchain.swiftVersion.isLessThan(new Version(5, 10, 0)) ) { this.skip(); }