├── .gitignore
├── LICENSE
├── Makefile
├── Package.resolved
├── Package.swift
├── Plugins
├── SwiftUIGenPlugin
│ └── Plugin.swift
└── SwiftUIGenPluginCommand
│ └── Plugin.swift
├── README.md
├── Sources
├── SwiftUIGen
│ └── App.swift
└── SwiftUIGenKit
│ ├── Error.swift
│ ├── Extensions.swift
│ ├── PreviewsGenerator.swift
│ └── Shell.swift
└── Tests
└── SwiftUIGenTests
└── SwiftUIGenTests.swift
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 | DerivedData/
7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Timberlane Labs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | release:
2 | swift build --configuration release
3 | cp -f .build/release/swiftuigen swiftuigen
4 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-argument-parser",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-argument-parser",
7 | "state" : {
8 | "revision" : "4ad606ba5d7673ea60679a61ff867cc1ff8c8e86",
9 | "version" : "1.2.1"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.7
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "SwiftUIGen",
8 | platforms: [.iOS(.v14), .macOS(.v12)],
9 | products: [
10 | .executable(name: "SwiftUIGen", targets: ["SwiftUIGen"]),
11 | .plugin(name: "SwiftUIGenPlugin", targets: ["SwiftUIGenPlugin"]),
12 | .plugin(name: "SwiftUIGenPluginCommand", targets: ["SwiftUIGenPluginCommand"]),
13 | ],
14 | dependencies: [
15 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.2.1"),
16 | ],
17 | targets: [
18 | .executableTarget(name: "SwiftUIGen", dependencies: [
19 | .product(name: "ArgumentParser", package: "swift-argument-parser"),
20 | "SwiftUIGenKit"
21 | ]),
22 | .target(name: "SwiftUIGenKit", dependencies: []),
23 | .plugin(name: "SwiftUIGenPlugin", capability: .buildTool(), dependencies: ["SwiftUIGen"]),
24 | .plugin(
25 | name: "SwiftUIGenPluginCommand",
26 | capability: .command(
27 | intent: .custom(
28 | verb: "SwiftUIGen",
29 | description: "Generates SwiftUI Preview supporting code"
30 | ),
31 | permissions: [.writeToPackageDirectory(reason: "SwiftUIGen needs write permission to save the generated code")]
32 | ),
33 | dependencies: ["SwiftUIGen"]
34 | ),
35 | .testTarget(name: "SwiftUIGenTests", dependencies: ["SwiftUIGen"]),
36 | ]
37 | )
38 |
--------------------------------------------------------------------------------
/Plugins/SwiftUIGenPlugin/Plugin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIGenPlugin.swift
3 | // SwiftUIGenPlugin
4 | //
5 | // Created by Ian Keen on 2022-10-07.
6 | //
7 |
8 | import Foundation
9 | import PackagePlugin
10 |
11 | @main
12 | struct SwiftUIGenPlugin: BuildToolPlugin {
13 | func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
14 | guard let sourceTarget = target as? SourceModuleTarget else { return [] }
15 |
16 | return try runSwiftUIGen(
17 | tool: context.tool(named: "SwiftUIGen"),
18 | inputFolder: sourceTarget.directory,
19 | outputFolder: sourceTarget.directory,
20 | inputFiles: sourceTarget.sourceFiles.map { $0 }
21 | )
22 | }
23 | }
24 |
25 | #if canImport(XcodeProjectPlugin)
26 | import XcodeProjectPlugin
27 |
28 | extension SwiftUIGenPlugin: XcodeBuildToolPlugin {
29 | func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] {
30 | return try runSwiftUIGen(
31 | tool: context.tool(named: "SwiftUIGen"),
32 | inputFolder: context.xcodeProject.directory,
33 | outputFolder: context.pluginWorkDirectory,
34 | inputFiles: target.inputFiles.map { $0 }
35 | )
36 | }
37 | }
38 | #endif
39 |
40 | private extension Path {
41 | var adjusted: Path {
42 | return removingLastComponent().appending([lastComponent.uppercased()])
43 | }
44 | }
45 |
46 | private func runSwiftUIGen(tool: PluginContext.Tool, inputFolder: Path, outputFolder: Path, inputFiles: [File]) -> [Command] {
47 | guard !inputFiles.isEmpty else { return [] }
48 |
49 | let inputFiles = inputFiles.map { $0.path.adjusted }
50 | let outputFile = outputFolder.appending(["SwiftUI.Generated.swift"])
51 |
52 | return [
53 | .buildCommand(
54 | displayName: "SwiftUIGen",
55 | executable: tool.path,
56 | arguments: ["previews", "--output", outputFile.string],
57 | inputFiles: inputFiles,
58 | outputFiles: [outputFile]
59 | )
60 | ]
61 | }
62 |
--------------------------------------------------------------------------------
/Plugins/SwiftUIGenPluginCommand/Plugin.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftUIGenPluginCommand.swift
3 | // SwiftUIGenPluginCommand
4 | //
5 | // Created by Ian Keen on 2023-02-06.
6 | //
7 |
8 | import Foundation
9 | import PackagePlugin
10 |
11 | @main
12 | struct SwiftUIGenPluginCommand: CommandPlugin {
13 | func performCommand(context: PluginContext, arguments: [String]) async throws {
14 | try runSwiftUIGen(
15 | exe: context.tool(named: "SwiftUIGen"),
16 | output: context.package.directory
17 | )
18 | }
19 | }
20 |
21 | #if canImport(XcodeProjectPlugin)
22 | import XcodeProjectPlugin
23 |
24 | extension SwiftUIGenPluginCommand: XcodeCommandPlugin {
25 | func performCommand(context: XcodePluginContext, arguments: [String]) throws {
26 | try runSwiftUIGen(
27 | exe: context.tool(named: "SwiftUIGen"),
28 | output: context.xcodeProject.directory
29 | )
30 | }
31 | }
32 | #endif
33 |
34 | enum PluginError: Error {
35 | case inputMissing
36 | case outputMissing
37 | }
38 |
39 | private func runSwiftUIGen(exe: PluginContext.Tool, output: Path) throws {
40 | guard !output.string.isEmpty else { throw PluginError.outputMissing }
41 |
42 | let outputFile = output.appending(subpath: "SwiftUI.Generated.swift")
43 |
44 | let process = Process()
45 | process.executableURL = URL(fileURLWithPath: exe.path.string)
46 | process.arguments = ["previews", "--output", outputFile.string]
47 |
48 | let outputPipe = Pipe()
49 | process.standardOutput = outputPipe
50 | try process.run()
51 | process.waitUntilExit()
52 | }
53 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # SwiftUIGen
2 |
3 | A utility to generate `PreviewDevice` presets from the available devices
4 |
5 | ### Installation
6 |
7 | - Plugins!
8 |
9 | `SwiftUIGen` has been updated to support build tool and command plugins for Xcode and SPM. Simply add this package as a dependency and generate code however suits your workflow.
10 |
11 | Add the `SwiftUIGen` tool to your `Package.swift` or Xcode Package Dependencies
12 |
13 | ```swift
14 | .package(url: "https://github.com/timberlanelabs/SwiftUIGen", branch: "main")
15 | ```
16 |
17 | And that's it!. It doesn't need to be added to any targets for the plugins to become available.
18 |
19 | - Manual
20 |
21 | Go to the GitHub page for the [latest release](https://github.com/timberlanelabs/SwiftUIGen/releases/latest)
22 | Download the `swiftuigen.zip` file associated with that release
23 | Extract the content of the zip archive in your project directory
24 |
25 | - Homebrew
26 |
27 | ```terminal
28 | $ brew tap timberlanelabs/tap
29 | $ brew install swiftuigen
30 | ```
31 |
32 | ### Usage
33 | To generate a file containing the preview devices simply run:
34 |
35 | ```terminal
36 | swiftuigen previews --output file.swift
37 | ```
38 |
39 | Add the file to your project and you can then use them in your SwiftUI previews:
40 |
41 | ```swift
42 | struct MyApp_Previews: PreviewProvider {
43 | static var previews: some View {
44 | MyView()
45 | .previewDevice(.iPhone(.iPhone11))
46 | }
47 | }
48 | ```
49 |
50 | This _could_ be run as part of a build phase if that is desirable, however this could result in some git 'nosiness' if different team members have
51 | different devices so it may be more advantageous to only run this periodically as needed.
52 |
--------------------------------------------------------------------------------
/Sources/SwiftUIGen/App.swift:
--------------------------------------------------------------------------------
1 | import ArgumentParser
2 | import Foundation
3 | import SwiftUIGenKit
4 |
5 | @main
6 | struct SwiftUIGen: ParsableCommand {
7 | static let configuration = CommandConfiguration(
8 | commandName: "swiftuigen",
9 | abstract: "A utility to generate helper code for SwiftUI",
10 | version: "1.0.0",
11 | subcommands: [Previews.self]
12 | )
13 |
14 | struct Previews: ParsableCommand {
15 | static let configuration = CommandConfiguration(abstract: "Generate `PreviewDevice` presets")
16 |
17 | @Option(help: "The location the generated code will be written")
18 | var output: String
19 |
20 | func run() throws {
21 | try PreviewsGenerator(output: output).run()
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/SwiftUIGenKit/Error.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Error.swift
3 | // SwiftUIGen
4 | //
5 | // Created by Ian Keen on 2021-09-15.
6 | //
7 |
8 | extension String: Error { }
9 |
--------------------------------------------------------------------------------
/Sources/SwiftUIGenKit/Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Extensions.swift
3 | // SwiftUIGenKit
4 | //
5 | // Created by Ian Keen on 2021-09-15.
6 | //
7 |
8 | import Foundation
9 |
10 | extension CharacterSet {
11 | func contains(_ character: Character) -> Bool {
12 | guard let value = character.unicodeScalars.first else { return false }
13 | return contains(value)
14 | }
15 | }
16 |
17 | extension String {
18 | mutating func lowercaseFirst() {
19 | guard !self[startIndex].isLowercase else { return }
20 | replaceSubrange(startIndex...startIndex, with: self[startIndex].lowercased())
21 | }
22 | func lowercasedFirst() -> String {
23 | guard !self[startIndex].isLowercase else { return self }
24 | return replacingCharacters(in: startIndex...startIndex, with: self[startIndex].lowercased())
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Sources/SwiftUIGenKit/PreviewsGenerator.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PreviewsGenerator.swift
3 | // SwiftUIGenKit
4 | //
5 | // Created by Ian Keen on 2021-09-15.
6 | //
7 |
8 | import Foundation
9 |
10 | public struct PreviewsGenerator {
11 | enum Error: Swift.Error {
12 | case outputMustBeSwiftFile
13 | }
14 |
15 | private let output: String
16 | private let charactersToRemove = CharacterSet.alphanumerics.inverted
17 |
18 | public init(output: String) {
19 | self.output = output
20 | }
21 |
22 | public func run() throws {
23 | var isDirectory: ObjCBool = false
24 | FileManager.default.fileExists(atPath: output, isDirectory: &isDirectory)
25 | if isDirectory.boolValue || !output.hasSuffix(".swift") {
26 | throw "Output '\(output)' must be a .swift file"
27 | }
28 |
29 | let jsonData = try Process().launch(with: "xcrun simctl list devicetypes -j")
30 | let devices = try JSONDecoder().decode(DeviceList.self, from: jsonData)
31 |
32 | let grouped = Dictionary(grouping: devices.devicetypes, by: \.productFamily)
33 |
34 | let previews = grouped.keys.sorted()
35 | .map { generatePreviews(grouped[$0, default: []]) }
36 | .joined()
37 |
38 | let contents = """
39 | // swiftlint:disable all
40 | // Generated using SwiftUIGen
41 |
42 | import SwiftUI
43 |
44 | \(previews)
45 | """
46 |
47 | try Data(contents.utf8).write(to: URL(fileURLWithPath: output), options: [.atomic])
48 | }
49 |
50 | private func generatePreviews(_ devices: [Device]) -> String {
51 | guard let first = devices.first else { return "" }
52 |
53 | var familyName = first.productFamily
54 | familyName.removeAll(where: charactersToRemove.contains)
55 |
56 | return """
57 | public struct \(familyName)Device: RawRepresentable, Equatable {
58 | public var rawValue: String
59 | public init(rawValue: String) { self.rawValue = rawValue }
60 | }
61 | extension PreviewDevice {
62 | public static func \(familyName.lowercasedFirst())(_ device: \(familyName)Device) -> PreviewDevice { .init(rawValue: device.rawValue) }
63 | }
64 | extension \(familyName)Device {
65 | \(devices.sorted(by: { $0.name < $1.name })
66 | .map { device -> String in
67 | var deviceName = device.name
68 | deviceName.removeAll(where: charactersToRemove.contains)
69 | deviceName = deviceName
70 | .replacingOccurrences(of: "mini", with: "Mini")
71 | .replacingOccurrences(of: "generation", with: "Generation")
72 |
73 | deviceName.lowercaseFirst()
74 |
75 | return """
76 | public static let \(deviceName) = \(familyName)Device(rawValue: "\(device.identifier)")
77 | """
78 | }
79 | .joined(separator: "\n")
80 | )
81 | }
82 |
83 | """
84 | }
85 | }
86 |
87 | private struct DeviceList: Decodable, Equatable {
88 | let devicetypes: [Device]
89 | }
90 | private struct Device: Decodable, Equatable {
91 | let name: String
92 | let identifier: String
93 | let productFamily: String
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/SwiftUIGenKit/Shell.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Shell.swift
3 | // SwiftUIGen
4 | //
5 | // Created by Ian Keen on 2021-09-15.
6 | //
7 |
8 | // Originally from: https://github.com/JohnSundell/ShellOut/blob/master/Sources/ShellOut.swift
9 |
10 | import Foundation
11 |
12 | struct ProcessError: Error {
13 | var terminationStatus: Int32
14 | var errorData: Data
15 | var outputData: Data
16 | }
17 |
18 | extension Process {
19 | @discardableResult
20 | func launch(with command: String, outputHandle: FileHandle? = nil, errorHandle: FileHandle? = nil) throws -> Data {
21 | launchPath = "/bin/bash"
22 | arguments = ["-c", command]
23 |
24 | // Because FileHandle's readabilityHandler might be called from a
25 | // different queue from the calling queue, avoid a data race by
26 | // protecting reads and writes to outputData and errorData on
27 | // a single dispatch queue.
28 | let outputQueue = DispatchQueue(label: "bash-output-queue")
29 |
30 | var outputData = Data()
31 | var errorData = Data()
32 |
33 | let outputPipe = Pipe()
34 | standardOutput = outputPipe
35 |
36 | let errorPipe = Pipe()
37 | standardError = errorPipe
38 |
39 | outputPipe.fileHandleForReading.readabilityHandler = { handler in
40 | let data = handler.availableData
41 | outputQueue.async {
42 | outputData.append(data)
43 | outputHandle?.write(data)
44 | }
45 | }
46 |
47 | errorPipe.fileHandleForReading.readabilityHandler = { handler in
48 | let data = handler.availableData
49 | outputQueue.async {
50 | errorData.append(data)
51 | errorHandle?.write(data)
52 | }
53 | }
54 |
55 | launch()
56 |
57 | waitUntilExit()
58 |
59 | if let handle = outputHandle, !handle.isStandard {
60 | handle.closeFile()
61 | }
62 |
63 | if let handle = errorHandle, !handle.isStandard {
64 | handle.closeFile()
65 | }
66 |
67 | outputPipe.fileHandleForReading.readabilityHandler = nil
68 | errorPipe.fileHandleForReading.readabilityHandler = nil
69 |
70 | // Block until all writes have occurred to outputData and errorData,
71 | // and then read the data back out.
72 | return try outputQueue.sync {
73 | if terminationStatus != 0 {
74 | throw ProcessError(
75 | terminationStatus: terminationStatus,
76 | errorData: errorData,
77 | outputData: outputData
78 | )
79 | }
80 |
81 | return outputData
82 | }
83 | }
84 | }
85 |
86 | private extension FileHandle {
87 | var isStandard: Bool {
88 | return self === FileHandle.standardOutput ||
89 | self === FileHandle.standardError ||
90 | self === FileHandle.standardInput
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/Tests/SwiftUIGenTests/SwiftUIGenTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import class Foundation.Bundle
3 |
4 | final class SwiftUIGenTests: XCTestCase {
5 | func testExample() throws {
6 | // This is an example of a functional test case.
7 | // Use XCTAssert and related functions to verify your tests produce the correct
8 | // results.
9 |
10 | // Some of the APIs that we use below are available in macOS 10.13 and above.
11 | guard #available(macOS 10.13, *) else {
12 | return
13 | }
14 |
15 | // Mac Catalyst won't have `Process`, but it is supported for executables.
16 | #if !targetEnvironment(macCatalyst)
17 |
18 | let fooBinary = productsDirectory.appendingPathComponent("SwiftUIGen")
19 |
20 | let process = Process()
21 | process.executableURL = fooBinary
22 |
23 | let pipe = Pipe()
24 | process.standardOutput = pipe
25 |
26 | try process.run()
27 | process.waitUntilExit()
28 |
29 | let data = pipe.fileHandleForReading.readDataToEndOfFile()
30 | let output = String(data: data, encoding: .utf8)
31 |
32 | XCTAssertEqual(output, "Hello, world!\n")
33 | #endif
34 | }
35 |
36 | /// Returns path to the built products directory.
37 | var productsDirectory: URL {
38 | #if os(macOS)
39 | for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") {
40 | return bundle.bundleURL.deletingLastPathComponent()
41 | }
42 | fatalError("couldn't find the products directory")
43 | #else
44 | return Bundle.main.bundleURL
45 | #endif
46 | }
47 | }
48 |
--------------------------------------------------------------------------------