Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Generating Code And Other Mischief With Swift P...

Generating Code And Other Mischief With Swift Package Manager Plugins - 360iDev, Denver, CO, August 2022

It’s the build tooling step everyone hates: “Now, add a new Run Script Build Phase.” The Swift and Xcode teams have worked to try to make things at least a little bit better for things which need to happen at build time by adding Build Tool Plugins and Command Plugins to Swift Package manager.

In this talk, you’ll get a look at how to set these up to generate code and documentation, plus a look at some other silly things you can make it do.

Sample App: https://github.com/designatednerd/RadioactiveCat
Sample Plugin: https://github.com/designatednerd/DatedImageGenerator

Avatar for Ellen Shapiro

Ellen Shapiro

August 30, 2022
Tweet

More Decks by Ellen Shapiro

Other Decks in Technology

Transcript

  1. GENERATING CODE AND OTHER MISCHIEF WITH SWIFT PACKAGE MANAGER PLUGINS

    360IDEV | DENVER, CO | AUGUST 2022 ELLEN SHAPIRO | @DESIGNATEDNERD | GUSTO.COM
  2. !

  3. REQUIRED HOST MAC APP ▸ ! Can now be distributed

    easily on the Mac App store ▸ " Sandboxing restrictions
  4. REQUIRED HOST MAC APP ▸ ! Can now be distributed

    easily on the Mac App store ▸ " Sandboxing restrictions ▸ # Often winds up with an empty app
  5. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting
  6. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file
  7. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST
  8. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST ▸ $ No access to file or project structure
  9. ONLY SINGLE-FILE TEXT BUFFER ACCESS ▸ ! Does allow at

    least some sorting, linting, formatting ▸ ☹ Only ever the current file ▸ # No access to the AST ▸ $ No access to file or project structure ▸ $ No user interface permitted
  10. FURTHER INVOCATION LIMITATIONS ▸ ! Cannot run in background ▸

    " Must be actively invoked by the user ▸ # No default key binding
  11. // Package.swift .plugin( name: "GenerateContributors", capability: .command( intent: .custom(verb: "regenerate-contributors-list",

    description: "Generates the CONTRIBUTORS.txt file based on Git logs"), permissions: [ .writeToPackageDirectory(reason: "This command write the new CONTRIBUTORS.txt to the source root.") ] )),
  12. // Package.swift .plugin( name: "GenerateContributors", capability: .command( intent: .custom(verb: "regenerate-contributors-list",

    description: "Generates the CONTRIBUTORS.txt file based on Git logs"), permissions: [ .writeToPackageDirectory(reason: "This command write the new CONTRIBUTORS.txt to the source root.") ] )),
  13. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line
  14. // Get all plugins available on a particular package swift

    package plugin --list // Run a specific plugin, in this case GenerateContributors swift package regenerate-contributors-list
  15. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line ▸ Can be invoked from a menu item in Xcode
  16. COMMAND PLUGINS ▸ Can ask permission to write in the

    package directory ▸ Can be invoked at the command line ▸ Can be invoked from a menu item Xcode ▸ Either way, must be invoked separately from the build process
  17. // Package.swift .target( name: "Cats", dependencies: [ ], plugins: [

    .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  18. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  19. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  20. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  21. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  22. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage // <-- Exported library used in executable and main app " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  23. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage // <-- Exported Framework used in executable and main app " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable // <-- The thing that does all the work " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  24. // File structure of the Swift Lib ! DatedImageGenerator !

    Plugins ! DatedImageGenerator " DatedImageGenerator.swift ! Sources ! DatedImage " DatedImage.swift " ExifDateFormatter.swift ! DatedImageGeneratorExecutable " DatedImageGeneratorExecutable.swift ! Tests ! DatedImageGeneratorTests " DatedImageGeneratorExecutable.swift " Media.xcassets
  25. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { // TODO: Create info for build command return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, // more to come here arguments: "TODO", outputFiles: ["TODO"] ) ] } }
  26. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Grab the 2nd argument as a string ProcessInfo.processInfo.arguments[1] } }
  27. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Turn it into Data Data(ProcessInfo.processInfo.arguments[1].utf8)) } }
  28. // Sources/DatedImageGeneratorExecutable/ // DatedImageGeneratorExecutable.swift @main struct DatedImageGeneratorExecutable { static func

    main() throws { // Decode it into a codable object! let invocation = try JSONDecoder() .decode(PluginInvocation.self, from: Data(ProcessInfo.processInfo.arguments[1].utf8)) } }
  29. // One copy of this in the plugin, one in

    the executable struct PluginInvocation: Codable { let catalogPaths: [String] let outputPath: String func encodedString() throws -> String { let data = try JSONEncoder().encode(self) return String(decoding: data, as: UTF8.self) } }
  30. // One copy of this in the plugin, one in

    the executable // (or share with a common lib) struct PluginInvocation: Codable { let catalogPaths: [String] let outputPath: String func encodedString() throws -> String { let data = try JSONEncoder().encode(self) return String(decoding: data, as: UTF8.self) } }
  31. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  32. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  33. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  34. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  35. // Plugins/DatedImageGenerator.swift @main struct DatedImageGenerator: BuildToolPlugin { func createBuildCommands(context: PluginContext,

    target: Target) async throws -> [Command] { guard let target = target as? SourceModuleTarget else { throw ImageGenError.notASourceModule } let assetCatalogPaths = target.sourceFiles(withSuffix: "xcassets") .compactMap { assetCatalog in return assetCatalog.path.string } let outputPath = context.pluginWorkDirectory .appending(["DatedImages.swift"]) let invocation = PluginInvocation(catalogPaths: assetCatalogPaths, outputPath: outputPath.string) return [ .buildCommand( displayName: "Generate Dated Images", executable: try context.tool(named: "DatedImageGeneratorExecutable").path, arguments: [try invocation.encodedString()], outputFiles: [outputPath] ) ] } }
  36. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources
  37. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources ▸ Use a codable object to pass data between them
  38. QUICK RECAP OF PLUGIN CREATION ▸ Create a plugin in

    Plugins ▸ Create an executable it will call in Sources ▸ Use a codable object to pass data between them ▸ Note that the plugin depends on but cannot import the executable
  39. // In Cats/Package.swift dependencies: [ .package(url: "https://github.com/designatednerd/ DatedImageGenerator.git", from: "0.0.1"),

    .package(url: "https://github.com/icanzilb/ DoNilDisturbPlugin.git", from: "0.0.5"), ],
  40. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  41. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  42. // In Cats/Package.swift .target( name: "Cats", dependencies: [ .product(name: "DatedImage",

    package: "DatedImageGenerator"), ], plugins: [ .plugin(name: "DatedImageGenerator", package: "DatedImageGenerator"), .plugin(name: "DoNilDisturbPlugin", package: "DoNilDisturbPlugin") ] ),
  43. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  44. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  45. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  46. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  47. #if canImport(XcodeProjectPlugin) import XcodeProjectPlugin extension DatedImageGenerator: XcodeBuildToolPlugin { func createBuildCommands(context:

    XcodePluginContext, target: XcodeTarget) throws -> [Command] { let assetCatalogPaths = context.xcodeProject.filePaths .filter({ $0.string.hasSuffix("xcassets")}) .map { $0.string } // Rest of the code the same as in the main plugin } } #endif
  48. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network
  49. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network ▸ You can't share data between plugins
  50. SPM PLUGIN LIMITATIONS (that I've found so far) ▸ Still

    no UI for you ▸ You can't even ask to use the network ▸ You can't share data between plugins ▸ Can't do anything with build results
  51. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build
  52. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build ▸ Security: Be super-mindful of which plugins you use
  53. OBLIGATORY SUMMARY SLIDE ▸ Alcatraz ! " Xcode 8 Extensions

    ▸ SPM Plugins to the rescue! ▸ Command plugins: On demand, Build tool plugins: On every build ▸ Security: Be super-mindful of which plugins you use ▸ Building plugins is a bit complicated, but also pretty fun
  54. SWIFT-EVOLUTION LINKS! ▸ SE-0303: Extensible Build Tools github.com/apple/swift-evolution/ blob/main/proposals/0303-swiftpm-extensible-build-tools.md ▸

    SE-0325: Additional Plugin APIs github.com/apple/swift-evolution/ blob/main/proposals/0325-swiftpm-additional-plugin-apis.md ▸ SE-0332: SPM Command Plugins github.com/apple/swift-evolution/ blob/main/proposals/0332-swiftpm-command-plugins.md
  55. ! SLIGHTLY TERRIFYING LINKS! ▸ Xcode Ghost details: en.wikipedia.org/wiki/XcodeGhost ▸

    Strawhorse leaked document: theintercept.com/document/ 2015/03/10/strawhorse-attacking-macos-ios-software- development-kit ▸ Strawhorse Context: theintercept.com/2015/03/10/ispy-cia- campaign-steal-apples-secrets/