Skip to content
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

Add templates for build tool plugins and command plugins #6111

Merged
merged 10 commits into from
Mar 29, 2023
Merged
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ Note: This is in reverse chronological order, so newer entries are added to the
Swift Next
-----------

* [#6111]

Package creation using `package init` now also supports the build tool plugin and command plugin types.

* [#5728]

In packages that specify resources using a future tools version, the generated resource bundle accessor will import `Foundation.Bundle` for its own implementation only. _Clients_ of such packages therefore no longer silently import `Foundation`, preventing inadvertent use of Foundation extensions to standard library APIs, which helps to avoid unexpected code size increases.
Expand Down
2 changes: 2 additions & 0 deletions Sources/Commands/PackageTools/Init.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ extension SwiftPackageTool {
tool - A package with an executable that uses
Swift Argument Parser. Use this template if you
plan to have a rich set of command-line arguments.
build-tool-plugin - A package that vends a build tool plugin.
command-plugin - A package that vends a command plugin.
macro - A package that vends a macro.
empty - An empty package with a Package.swift manifest.
"""))
Expand Down
90 changes: 84 additions & 6 deletions Sources/Workspace/InitPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2014-2020 Apple Inc. and the Swift project authors
// Copyright (c) 2014-2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -44,7 +44,8 @@ public final class InitPackage {
case library = "library"
case executable = "executable"
case tool = "tool"
case `extension` = "extension"
case buildToolPlugin = "build-tool-plugin"
case commandPlugin = "command-plugin"
case macro = "macro"

public var description: String {
Expand Down Expand Up @@ -115,6 +116,7 @@ public final class InitPackage {
// none of it exists, and then act.
try writeManifestFile()
try writeGitIgnore()
try writePlugins()
try writeSources()
try writeTests()
}
Expand Down Expand Up @@ -146,6 +148,7 @@ public final class InitPackage {
}

stream <<< """

let package = Package(

"""
Expand Down Expand Up @@ -213,6 +216,15 @@ public final class InitPackage {
targets: ["\(pkgname)"]),
]
""")
} else if packageType == .buildToolPlugin || packageType == .commandPlugin {
pkgParams.append("""
products: [
// Products define the executables and libraries a package produces, making them visible to other packages.
.plugin(
name: "\(pkgname)",
targets: ["\(pkgname)"]),
]
""")
} else if packageType == .macro {
pkgParams.append("""
products: [
Expand Down Expand Up @@ -269,6 +281,22 @@ public final class InitPackage {
]),
]
"""
} else if packageType == .buildToolPlugin {
param += """
.plugin(
name: "\(typeName)",
capability: .buildTool()
),
]
"""
} else if packageType == .commandPlugin {
param += """
.plugin(
name: "\(typeName)",
capability: .command(intent: .custom(verb: "\(typeName)", description: "prints hello world"))
),
]
"""
} else if packageType == .macro {
param += """
// Macro implementation, only built for the host and never part of a client program.
Expand Down Expand Up @@ -350,8 +378,58 @@ public final class InitPackage {
}
}

private func writePlugins() throws {
switch packageType {
case .buildToolPlugin, .commandPlugin:
let plugins = destinationPath.appending(component: "Plugins")
guard self.fileSystem.exists(plugins) == false else {
return
}
progressReporter?("Creating \(plugins.relative(to: destinationPath))/")
try makeDirectories(plugins)

let moduleDir = plugins.appending(component: "\(pkgname)")
try makeDirectories(moduleDir)

let sourceFileName = "plugin.swift"
let sourceFile = try AbsolutePath(validating: sourceFileName, relativeTo: moduleDir)

var content = """
import PackagePlugin

@main
"""
if packageType == .buildToolPlugin {
content += """
struct \(typeName): BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
print("Hello, World!")
return []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A more full-fledged starting point would be to have a shared function between the SwiftPM and Xcode API, and some boilerplate to construct a sample build command. Not sure how far to go in that direction.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think having a sample build command would be good.

A bit less sure about how to handle Xcode vs. SwiftPM plugins, on the one hand, shared function is obviously the right thing for many use cases, on the other hand it could potentially make it confusing that the two entry points even exist?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cc @bitjammer @TimTr for opinions on the content of these templates

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the long run, it might make sense to have an option for whether or not to add the Xcode specifics. We can leave it out for now but maybe include the URL of the plugins Markdown here in the SwiftPM repository, which talks about how to add them. That would seem to be in line with how the argument parser template works (it puts in this comment: // https://swiftpackageindex.com/apple/swift-argument-parser/documentation).

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tomerd good point that these seem to be using the older style of always having a /Sources/<target>/ folder structure. With the updated templates for executables using just /Sources/ the base templates can start a bit cleaner. Also note this PR: #6294 which makes the SwiftPM folder search path work with /Sources/ by default (no need for a path: entry), and also work with the traditional per-target folder model. So either option is now supported by default.

I don't have a strong opinion for these templates using the traditional or simplified model. But if the common case will be a single target, may be nice to stick with the simpler approach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified model sounds good given that the manifest doesn't have to be made more complicated. I'll update the PR. Thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimTr What about the point about whether or not to include the optional XcodeProjectPlugin support to make it easier for new plugins to work with Xcode projects out-of-the-box.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Anything specific to Xcode feels like it shouldn't be there by default in the CLI tools. I could see an option (making the command pretty long to manually type, but trivial for tooling) to support tools like Xcode, or VSCode, or others. This could then be used when a user is inside Xcode already, pretty seamlessly, without implying to the rest of the Swift world that Swift is tied to Xcode.

}
}
"""
}
else {
content += """
struct \(typeName): CommandPlugin {
func performCommand(context: PluginContext, arguments: [String]) async throws {
print("Hello, World!")
}
}
"""
}

try writePackageFile(sourceFile) { stream in
stream.write(content)
}

case .empty, .library, .executable, .tool, .macro:
break
}
}

private func writeSources() throws {
if packageType == .empty {
if packageType == .empty || packageType == .buildToolPlugin || packageType == .commandPlugin {
return
}

Expand Down Expand Up @@ -427,7 +505,7 @@ public final class InitPackage {
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "\(moduleName)Macros", type: "StringifyMacro")
"""

case .empty, .`extension`:
case .empty, .buildToolPlugin, .commandPlugin:
throw InternalError("invalid packageType \(packageType)")
}

Expand All @@ -443,7 +521,7 @@ public final class InitPackage {

private func writeTests() throws {
switch packageType {
case .empty, .executable, .tool, .`extension`: return
case .empty, .executable, .tool, .buildToolPlugin, .commandPlugin: return
default: break
}
let tests = destinationPath.appending("Tests")
Expand Down Expand Up @@ -589,7 +667,7 @@ public final class InitPackage {

let testClassFile = try AbsolutePath(validating: "\(moduleName)Tests.swift", relativeTo: testModule)
switch packageType {
case .empty, .`extension`, .executable, .tool: break
case .empty, .buildToolPlugin, .commandPlugin, .executable, .tool: break
case .library:
try writeLibraryTestsFile(testClassFile)
case .macro:
Expand Down
60 changes: 59 additions & 1 deletion Tests/WorkspaceTests/InitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// This source file is part of the Swift open source project
//
// Copyright (c) 2014-2017 Apple Inc. and the Swift project authors
// Copyright (c) 2014-2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
Expand Down Expand Up @@ -149,6 +149,64 @@ class InitTests: XCTestCase {
}
}

func testInitPackageCommandPlugin() throws {
try testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("MyCommandPlugin")
let name = path.basename
try fs.createDirectory(path)

// Create the package
try InitPackage(
name: name,
packageType: .commandPlugin,
destinationPath: path,
fileSystem: localFileSystem
).writePackageStructure()

// Verify basic file system content that we expect in the package
let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let manifestContents: String = try localFileSystem.readFileContents(manifest)
XCTAssertMatch(manifestContents, .and(.contains(".plugin("), .contains("targets: [\"MyCommandPlugin\"]")))
XCTAssertMatch(manifestContents, .and(.contains(".plugin("), .contains("capability: .command(intent: .custom(verb")))

let source = path.appending("Plugins", "MyCommandPlugin", "plugin.swift")
XCTAssertFileExists(source)
let sourceContents: String = try localFileSystem.readFileContents(source)
XCTAssertMatch(sourceContents, .contains("struct MyCommandPlugin: CommandPlugin"))
}
}

func testInitPackageBuildToolPlugin() throws {
try testWithTemporaryDirectory { tmpPath in
let fs = localFileSystem
let path = tmpPath.appending("MyBuildToolPlugin")
let name = path.basename
try fs.createDirectory(path)

// Create the package
try InitPackage(
name: name,
packageType: .buildToolPlugin,
destinationPath: path,
fileSystem: localFileSystem
).writePackageStructure()

// Verify basic file system content that we expect in the package
let manifest = path.appending("Package.swift")
XCTAssertFileExists(manifest)
let manifestContents: String = try localFileSystem.readFileContents(manifest)
XCTAssertMatch(manifestContents, .and(.contains(".plugin("), .contains("targets: [\"MyBuildToolPlugin\"]")))
XCTAssertMatch(manifestContents, .and(.contains(".plugin("), .contains("capability: .buildTool()")))

let source = path.appending("Plugins", "MyBuildToolPlugin", "plugin.swift")
XCTAssertFileExists(source)
let sourceContents: String = try localFileSystem.readFileContents(source)
XCTAssertMatch(sourceContents, .contains("struct MyBuildToolPlugin: BuildToolPlugin"))
}
}

// MARK: Special case testing

func testInitPackageNonc99Directory() throws {
Expand Down