Skip to content

Commit 74627ad

Browse files
authored
Show files generated by build plugins under Target in Project Panel (#1592)
* Show files generated by build plugins under Target in Project Panel Monitor the outputs folder where generated build tool plugin files are written to and show files under their Target in the Project Panel. By associating the generated files with their target its easier to browse the generated files without having to dig through the .build folder. Issue: #1564
1 parent 6ae4fa8 commit 74627ad

File tree

9 files changed

+305
-25
lines changed

9 files changed

+305
-25
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Add clickable toolchain selection to Swift version status bar item ([#1674](https://github.com/swiftlang/vscode-swift/pull/1674))
88
- Add macOS support for Swiftly toolchain management ([#1673](https://github.com/swiftlang/vscode-swift/pull/1673))
99
- Show revision hash or local/editing keyword in project panel dependency descriptions ([#1667](https://github.com/swiftlang/vscode-swift/pull/1667))
10+
- Show files generated by build plugins under Target in Project Panel ([#1592](https://github.com/swiftlang/vscode-swift/pull/1592))
1011

1112
## 2.6.2 - 2025-07-02
1213

assets/test/targets/Package.swift

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// swift-tools-version: 5.6
1+
// swift-tools-version: 5.9
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
@@ -25,17 +25,28 @@ let package = Package(
2525
],
2626
targets: [
2727
.target(
28-
name: "LibraryTarget"
28+
name: "LibraryTarget",
29+
plugins: [
30+
.plugin(name: "BuildToolPlugin")
31+
]
2932
),
3033
.executableTarget(
3134
name: "ExecutableTarget"
3235
),
36+
.executableTarget(
37+
name: "BuildToolExecutableTarget"
38+
),
3339
.plugin(
3440
name: "PluginTarget",
3541
capability: .command(
3642
intent: .custom(verb: "testing", description: "A plugin for testing plugins")
3743
)
3844
),
45+
.plugin(
46+
name: "BuildToolPlugin",
47+
capability: .buildTool(),
48+
dependencies: ["BuildToolExecutableTarget"]
49+
),
3950
.testTarget(
4051
name: "TargetsTests",
4152
dependencies: ["LibraryTarget"]
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import PackagePlugin
2+
import Foundation
3+
4+
@main
5+
struct SimpleBuildToolPlugin: BuildToolPlugin {
6+
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
7+
guard let sourceFiles = target.sourceModule?.sourceFiles else { return [] }
8+
9+
#if os(Windows)
10+
return []
11+
#endif
12+
13+
let generatorTool = try context.tool(named: "BuildToolExecutableTarget")
14+
15+
// Construct a build command for each source file with a particular suffix.
16+
return sourceFiles.map(\.path).compactMap {
17+
createBuildCommand(
18+
for: $0,
19+
in: context.pluginWorkDirectory,
20+
with: generatorTool.path
21+
)
22+
}
23+
}
24+
25+
/// Calls a build tool that transforms JSON files into Swift files.
26+
func createBuildCommand(for inputPath: Path, in outputDirectoryPath: Path, with generatorToolPath: Path) -> Command? {
27+
let inputURL = URL(fileURLWithPath: inputPath.string)
28+
let outputDirectoryURL = URL(fileURLWithPath: outputDirectoryPath.string)
29+
30+
// Skip any file that doesn't have the extension we're looking for (replace this with the actual one).
31+
guard inputURL.pathExtension == "json" else { return .none }
32+
33+
// Produces .swift files in the same directory structure as the input JSON files appear in the target.
34+
let components = inputURL.absoluteString.split(separator: "LibraryTarget", omittingEmptySubsequences: false).map(String.init)
35+
let inputName = inputURL.lastPathComponent
36+
let outputDir = outputDirectoryURL.appendingPathComponent(components[1]).deletingLastPathComponent()
37+
let outputName = inputURL.deletingPathExtension().lastPathComponent + ".swift"
38+
let outputURL = outputDir.appendingPathComponent(outputName)
39+
40+
return .buildCommand(
41+
displayName: "Generating \(outputName) from \(inputName)",
42+
executable: generatorToolPath,
43+
arguments: ["\(inputPath)", "\(outputURL.path)"],
44+
inputFiles: [inputPath],
45+
outputFiles: [Path(outputURL.path)]
46+
)
47+
}
48+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#if !os(Windows)
2+
import Foundation
3+
4+
@main
5+
struct CodeGenerator {
6+
static func main() async throws {
7+
// Use swift-argument-parser or just CommandLine, here we just imply that 2 paths are passed in: input and output
8+
guard CommandLine.arguments.count == 3 else {
9+
throw CodeGeneratorError.invalidArguments
10+
}
11+
// arguments[0] is the path to this command line tool
12+
guard let input = URL(string: "file://\(CommandLine.arguments[1])"), let output = URL(string: "file://\(CommandLine.arguments[2])") else {
13+
return
14+
}
15+
let jsonData = try Data(contentsOf: input)
16+
let enumFormat = try JSONDecoder().decode(JSONFormat.self, from: jsonData)
17+
18+
let code = """
19+
enum \(enumFormat.name): CaseIterable {
20+
\t\(enumFormat.cases.map({ "case \($0)" }).joined(separator: "\n\t"))
21+
}
22+
"""
23+
guard let data = code.data(using: .utf8) else {
24+
throw CodeGeneratorError.invalidData
25+
}
26+
try data.write(to: output, options: .atomic)
27+
}
28+
}
29+
30+
struct JSONFormat: Decodable {
31+
let name: String
32+
let cases: [String]
33+
}
34+
35+
enum CodeGeneratorError: Error {
36+
case invalidArguments
37+
case invalidData
38+
}
39+
#else
40+
@main
41+
struct DummyMain {
42+
static func main() {
43+
}
44+
}
45+
#endif
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "Baz",
3+
"cases": [
4+
"bar",
5+
"baz",
6+
"bbb"
7+
]
8+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "Foo",
3+
"cases": [
4+
"bar",
5+
"baz",
6+
"qux"
7+
]
8+
}

scripts/package.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ main(async () => {
3434

3535
// Update version in CHANGELOG
3636
await updateChangelog(versionString);
37+
3738
// Use VSCE to package the extension
38-
await exec("npx", ["vsce", "package"], {
39+
// Note: There are no sendgrid secrets in the extension. `--allow-package-secrets` works around a false positive
40+
// where the symbol `SG.MessageTransports.is` can appear in the dist.js if we're unlucky enough
41+
// to have `SG` as the minified name of a namespace. Here is the rule we sometimes mistakenly match:
42+
// https://github.com/secretlint/secretlint/blob/5706ac4942f098b845570541903472641d4ae914/packages/%40secretlint/secretlint-rule-sendgrid/src/index.ts#L35
43+
await exec("npx", ["vsce", "package", "--allow-package-secrets", "sendgrid"], {
3944
cwd: rootDirectory,
4045
});
4146
});

src/ui/ProjectPanelProvider.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import { FolderContext } from "../FolderContext";
2424
import { getPlatformConfig, resolveTaskCwd } from "../utilities/tasks";
2525
import { SwiftTask, TaskPlatformSpecificConfig } from "../tasks/SwiftTaskProvider";
2626
import { convertPathToPattern, glob } from "fast-glob";
27+
import { Version } from "../utilities/version";
28+
import { existsSync } from "fs";
2729

2830
const LOADING_ICON = "loading~spin";
2931

@@ -285,9 +287,13 @@ function snippetTaskName(name: string): string {
285287
}
286288

287289
class TargetNode {
290+
private newPluginLayoutVersion = new Version(6, 0, 0);
291+
288292
constructor(
289293
public target: Target,
290-
private activeTasks: Set<string>
294+
private folder: FolderContext,
295+
private activeTasks: Set<string>,
296+
private fs?: (folder: string) => Promise<string[]>
291297
) {}
292298

293299
get name(): string {
@@ -358,7 +364,41 @@ class TargetNode {
358364
}
359365

360366
getChildren(): TreeNode[] {
361-
return [];
367+
return this.buildPluginOutputs(this.folder.toolchain.swiftVersion);
368+
}
369+
370+
private buildToolGlobPattern(version: Version): string {
371+
const base = this.folder.folder.fsPath.replace(/\\/g, "/");
372+
if (version.isGreaterThanOrEqual(this.newPluginLayoutVersion)) {
373+
return `${base}/.build/plugins/outputs/*/${this.target.name}/*/*/**`;
374+
} else {
375+
return `${base}/.build/plugins/outputs/*/${this.target.name}/*/**`;
376+
}
377+
}
378+
379+
private buildPluginOutputs(version: Version): TreeNode[] {
380+
// Files in the `outputs` directory follow the pattern:
381+
// .build/plugins/outputs/buildtoolplugin/<target-name>/destination/<build-tool-plugin-name>/*
382+
// This glob will capture all the files in the outputs directory for this target.
383+
const pattern = this.buildToolGlobPattern(version);
384+
const base = this.folder.folder.fsPath.replace(/\\/g, "/");
385+
const depth = version.isGreaterThanOrEqual(this.newPluginLayoutVersion) ? 4 : 3;
386+
const matches = glob.sync(pattern, { onlyFiles: false, cwd: base, deep: depth });
387+
return matches.map(filePath => {
388+
const pluginName = path.basename(filePath);
389+
return new HeaderNode(
390+
`${this.target.name}-${pluginName}`,
391+
`${pluginName} - Generated Files`,
392+
"debug-disconnect",
393+
() =>
394+
getChildren(
395+
filePath,
396+
excludedFilesForProjectPanelExplorer(),
397+
this.target.path,
398+
this.fs
399+
)
400+
);
401+
});
362402
}
363403
}
364404

@@ -438,6 +478,8 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
438478
private disposables: vscode.Disposable[] = [];
439479
private activeTasks: Set<string> = new Set();
440480
private lastComputedNodes: TreeNode[] = [];
481+
private buildPluginOutputWatcher?: vscode.FileSystemWatcher;
482+
private buildPluginFolderWatcher?: vscode.Disposable;
441483

442484
onDidChangeTreeData = this.didChangeTreeDataEmitter.event;
443485

@@ -518,6 +560,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
518560
if (!folder) {
519561
return;
520562
}
563+
this.watchBuildPluginOutputs(folder);
521564
treeView.title = `Swift Project (${folder.name})`;
522565
this.didChangeTreeDataEmitter.fire();
523566
break;
@@ -540,6 +583,33 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
540583
);
541584
}
542585

586+
watchBuildPluginOutputs(folderContext: FolderContext) {
587+
if (this.buildPluginOutputWatcher) {
588+
this.buildPluginOutputWatcher.dispose();
589+
}
590+
if (this.buildPluginFolderWatcher) {
591+
this.buildPluginFolderWatcher.dispose();
592+
}
593+
594+
const fire = () => this.didChangeTreeDataEmitter.fire();
595+
const buildPath = path.join(folderContext.folder.fsPath, ".build/plugins/outputs");
596+
this.buildPluginFolderWatcher = watchForFolder(
597+
buildPath,
598+
() => {
599+
this.buildPluginOutputWatcher = vscode.workspace.createFileSystemWatcher(
600+
new vscode.RelativePattern(buildPath, "{*,*/*}")
601+
);
602+
this.buildPluginOutputWatcher.onDidCreate(fire);
603+
this.buildPluginOutputWatcher.onDidDelete(fire);
604+
this.buildPluginOutputWatcher.onDidChange(fire);
605+
},
606+
() => {
607+
this.buildPluginOutputWatcher?.dispose();
608+
fire();
609+
}
610+
);
611+
}
612+
543613
getTreeItem(element: TreeNode): vscode.TreeItem {
544614
return element.toTreeItem();
545615
}
@@ -556,7 +626,6 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
556626
...this.lastComputedNodes,
557627
];
558628
}
559-
560629
const nodes = await this.computeChildren(folderContext, element);
561630

562631
// If we're fetching the root nodes then save them in case we have an error later,
@@ -652,7 +721,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
652721
// Snipepts are shown under the Snippets header
653722
return targets
654723
.filter(target => target.type !== "snippet")
655-
.map(target => new TargetNode(target, this.activeTasks))
724+
.map(target => new TargetNode(target, folderContext, this.activeTasks))
656725
.sort((a, b) => targetSort(a).localeCompare(targetSort(b)));
657726
}
658727

@@ -708,7 +777,7 @@ export class ProjectPanelProvider implements vscode.TreeDataProvider<TreeNode> {
708777
const targets = await folderContext.swiftPackage.targets;
709778
return targets
710779
.filter(target => target.type === "snippet")
711-
.flatMap(target => new TargetNode(target, this.activeTasks))
780+
.flatMap(target => new TargetNode(target, folderContext, this.activeTasks))
712781
.sort((a, b) => a.name.localeCompare(b.name));
713782
}
714783
}
@@ -760,3 +829,35 @@ class TaskPoller implements vscode.Disposable {
760829
}
761830
}
762831
}
832+
833+
/**
834+
* Polls for the existence of a folder at the given path every 2.5 seconds.
835+
* Notifies via the provided callbacks when the folder becomes available or is deleted.
836+
*/
837+
function watchForFolder(
838+
folderPath: string,
839+
onAvailable: () => void,
840+
onDeleted: () => void
841+
): vscode.Disposable {
842+
const POLL_INTERVAL = 2500;
843+
let folderExists = existsSync(folderPath);
844+
845+
if (folderExists) {
846+
onAvailable();
847+
}
848+
849+
const interval = setInterval(() => {
850+
const nowExists = existsSync(folderPath);
851+
if (nowExists && !folderExists) {
852+
folderExists = true;
853+
onAvailable();
854+
} else if (!nowExists && folderExists) {
855+
folderExists = false;
856+
onDeleted();
857+
}
858+
}, POLL_INTERVAL);
859+
860+
return {
861+
dispose: () => clearInterval(interval),
862+
};
863+
}

0 commit comments

Comments
 (0)