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

[NSManchester] Code generation using Swift Pack...

[NSManchester] Code generation using Swift Package plugins

Avatar for Pol Piella Abadia

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