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

[NSManchester] Code generation using Swift Package plugins

[NSManchester] Code generation using Swift Package plugins

Pol Piella Abadia

July 04, 2022
Tweet

More Decks by Pol Piella Abadia

Other Decks in Programming

Transcript

  1. 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! 🎉
  2. 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.
  3. 📂 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
  4. The executable 🏃 Extract types conforming to the protocol 🔎

    Generate a XCTestCase 🔨 Scan target for Swift Files 🗄
  5. 📂 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
  6. 📂 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
  7. 📂 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
  8. 📂 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
  9. 📂 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
  10. 📂 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
  11. 📂 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
  12. 📂 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
  13. 📂 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
  14. 📂 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
  15. 📂 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
  16. 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
  17. 📂 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
  18. 📂 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
  19. 📂 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
  20. 📂 SourceKitPlugin.swift import PackagePlugin Package.swift 📂 Sources 📂 CodeGenSample CodeGenSample.swift

    📂 PluginExecutable PluginExecutable.swift 📂 Tests 📂 CodeGenSampleTests Empty.swift 📂 Plugins 📂 SourceKitPlugin SourceKitPlugin.swift
  21. 📂 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
  22. 📂 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
  23. 📂 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
  24. 📂 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
  25. 📂 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
  26. 📂 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
  27. 📂 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
  28. 📂 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