Skip to content

Show files generated by build plugins under Target in Project Panel #1592

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

15 changes: 13 additions & 2 deletions assets/test/targets/Package.swift
Original file line number Diff line number Diff line change
@@ -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"]
Original file line number Diff line number Diff line change
@@ -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)]
)
}
}
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions assets/test/targets/Sources/LibraryTarget/Bar/Baz.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "Baz",
"cases": [
"bar",
"baz",
"bbb"
]
}
8 changes: 8 additions & 0 deletions assets/test/targets/Sources/LibraryTarget/Foo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "Foo",
"cases": [
"bar",
"baz",
"qux"
]
}
7 changes: 6 additions & 1 deletion scripts/package.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
111 changes: 106 additions & 5 deletions src/ui/ProjectPanelProvider.ts
Original file line number Diff line number Diff line change
@@ -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<string>
private folder: FolderContext,
private activeTasks: Set<string>,
private fs?: (folder: string) => Promise<string[]>
) {}

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/<target-name>/destination/<build-tool-plugin-name>/*
// 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<TreeNode> {
private disposables: vscode.Disposable[] = [];
private activeTasks: Set<string> = 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<TreeNode> {
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<TreeNode> {
);
}

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<TreeNode> {
...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<TreeNode> {
// 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<TreeNode> {
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),
};
}
Loading