Slide 1

Slide 1 text

Pol Piella Abadia - 04/07/2022 Code generation using Swift Package Plugins πŸ“ NSManchester 🐝

Slide 2

Slide 2 text

Let’s get started! πŸš€

Slide 3

Slide 3 text

Let’s get started! πŸš€ - Context β€’ A new package exposes a protocol - very similar to decodable. β€’ Many features implement types which conform to it. New package decodes these types from data. β€’ A fallback json fi le is shipped with the app. It must always work with all models. β€’ Decoding errors are all runtime and would cause issues when certain screens are accessed. β€’ We gained con fi dence by testing, but doing this manually is not very scalable… β€’ Very di ff i cult to automate at the time as we have a mix of Swift Packages and Xcode projects. β€’ Xcode 14 will allow us to use plugins for Xcode projects too! πŸŽ‰

Slide 4

Slide 4 text

Let’s get started! πŸš€ - Specifics β€’ A build tool Swift Package Plugin. β€’ Uses SourceKitten (SourceKit under the hood) to fi nd all types conforming to a speci fi c protocol. β€’ Automatically generates tests for each type which conforms to such protocol.

Slide 5

Slide 5 text

swift package init πŸ’»

Slide 6

Slide 6 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 7

Slide 7 text

The executable πŸƒ

Slide 8

Slide 8 text

The executable πŸƒ Scan target for Swift Files πŸ—„

Slide 9

Slide 9 text

The executable πŸƒ Extract types conforming to the protocol πŸ”Ž Scan target for Swift Files πŸ—„

Slide 10

Slide 10 text

The executable πŸƒ Extract types conforming to the protocol πŸ”Ž Generate a XCTestCase πŸ”¨ Scan target for Swift Files πŸ—„

Slide 11

Slide 11 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 12

Slide 12 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 13

Slide 13 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ), .executableTarget( name: "PluginExecutable", dependencies: [ .product(name: "SourceKittenFramework", package: "SourceKitten"), .product(name: "ArgumentParser", package: "swift-argument-parser") ] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 14

Slide 14 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 15

Slide 15 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 16

Slide 16 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 17

Slide 17 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true)) } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 18

Slide 18 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true)) let structures = try files.map { try Structure(file: File(path: $0.path)!) } } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 19

Slide 19 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true)) let structures = try files.map { try Structure(file: File(path: $0.path)!) } var matchedTypes = [String]() structures.forEach { walkTree(dictionary: $0.dictionary, acc: &matchedTypes) } } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 20

Slide 20 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true)) let structures = try files.map { try Structure(file: File(path: $0.path)!) } var matchedTypes = [String]() structures.forEach { walkTree(dictionary: $0.dictionary, acc: &matchedTypes) } try createOutputFile(withContent: matchedTypes) } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 21

Slide 21 text

πŸ“‚ PluginExecutable.swift import SourceKittenFramework import ArgumentParser import Foundation @main struct PluginExecutable: ParsableCommand { @Argument(help: "The protocol name to match") var protocolName: String @Argument(help: "The module's name") var moduleName: String @Option(help: "Directory containing the swift files") var input: String @Option(help: "The path where the generated files will be created") var output: String func run() throws { // Needed to ensure that sourcekit runs in a single process setenv("IN_PROCESS_SOURCEKIT", "YES", 1) let files = try deepSearch(URL(fileURLWithPath: input, isDirectory: true)) let structures = try files.map { try Structure(file: File(path: $0.path)!) } var matchedTypes = [String]() structures.forEach { walkTree(dictionary: $0.dictionary, acc: &matchedTypes) } try createOutputFile(withContent: matchedTypes) } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 22

Slide 22 text

More context… ⭐ Thanks to @jozasvalancius, which did all the work for this. πŸŽ‰ This is also a fi x for getting SwiftLint to work with SPM plugins 🌍 Link to the PR: https://github.com/jpsim/SourceKitten/pull/728

Slide 23

Slide 23 text

The plugin πŸ”Œ

Slide 24

Slide 24 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ), .executableTarget( name: "PluginExecutable", dependencies: [ .product(name: "SourceKittenFramework", package: "SourceKitten"), .product(name: "ArgumentParser", package: "swift-argument-parser") ] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift

Slide 25

Slide 25 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: ["CodeGenSample"] ), .executableTarget( name: "PluginExecutable", dependencies: [ .product(name: "SourceKittenFramework", package: "SourceKitten"), .product(name: "ArgumentParser", package: "swift-argument-parser") ] ), .plugin( name: "SourceKitPlugin", capability: .buildTool(), dependencies: [.target(name: "PluginExecutable")] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 26

Slide 26 text

πŸ“‚ Package.swift // swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "CodeGenSample", platforms: [.macOS(.v10_11)], products: [ .library( name: "CodeGenSample", targets: ["CodeGenSample"]), ], dependencies: [ .package(url: "https://github.com/jpsim/SourceKitten.git", exact: "0.32.0"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0") ], targets: [ .target( name: "CodeGenSample", dependencies: [] ), .testTarget( name: "CodeGenSampleTests", dependencies: [β€œCodeGenSample"], plugins: [β€œSourceKitPlugin”], ), .executableTarget( name: "PluginExecutable", dependencies: [ .product(name: "SourceKittenFramework", package: "SourceKitten"), .product(name: "ArgumentParser", package: "swift-argument-parser") ] ), .plugin( name: "SourceKitPlugin", capability: .buildTool(), dependencies: [.target(name: "PluginExecutable")] ) ] ) Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 27

Slide 27 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 28

Slide 28 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 29

Slide 29 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", 🀷 ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [🀷] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 30

Slide 30 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 31

Slide 31 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) let dependencyTarget = target .dependencies return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 32

Slide 32 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) let dependencyTarget = target .dependencies .compactMap { dependency -> Target? in switch dependency { case .target(let target): return target default: return nil } } return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 33

Slide 33 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) let dependencyTarget = target .dependencies .compactMap { dependency -> Target? in switch dependency { case .target(let target): return target default: return nil } } .filter { "\($0.name)Tests" == target.name } .first return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 34

Slide 34 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) guard let dependencyTarget = target .dependencies .compactMap { dependency -> Target? in switch dependency { case .target(let target): return target default: return nil } } .filter { "\($0.name)Tests" == target.name } .first else { Diagnostics.error("Could not get a dependency to scan!”) return [] } return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", 🀷, "--input", 🀷, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 35

Slide 35 text

πŸ“‚ SourceKitPlugin.swift import PackagePlugin @main struct SourceKitPlugin: BuildToolPlugin { func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { let outputPath = context.pluginWorkDirectory.appending(β€œGeneratedTests.swift”) guard let dependencyTarget = target .dependencies .compactMap { dependency -> Target? in switch dependency { case .target(let target): return target default: return nil } } .filter { "\($0.name)Tests" == target.name } .first else { Diagnostics.error("Could not get a dependency to scan!”) return [] } return [ .buildCommand( displayName: "Protocol Extraction!", executable: try context.tool(named: "PluginExecutable").path, arguments: [ "FindThis", dependencyTarget.name, "--input", dependencyTarget.directory, "--output", outputPath ], environment: ["IN_PROCESS_SOURCEKIT": "YES"], outputFiles: [outputPath] ) ] } } Package.swift πŸ“‚ Sources πŸ“‚ CodeGenSample CodeGenSample.swift πŸ“‚ PluginExecutable PluginExecutable.swift πŸ“‚ Tests πŸ“‚ CodeGenSampleTests Empty.swift πŸ“‚ Plugins πŸ“‚ SourceKitPlugin SourceKitPlugin.swift

Slide 36

Slide 36 text

Let’s see it running!