From 900616e9a89a7e5b5d5210dfe7f0cb66616ba555 Mon Sep 17 00:00:00 2001 From: Miguel Piedrafita Date: Wed, 11 Jun 2025 22:28:42 +0100 Subject: [PATCH] wip --- Package.resolved | 14 ++ Package.swift | 50 +++++-- Plugin/BuildPlugin.swift | 57 ++++++++ Sources/rive-codegen/Command.swift | 62 ++++++++ Sources/rive-codegen/Generator.swift | 210 +++++++++++++++++++++++++++ Sources/rive-codegen/helpers.swift | 18 +++ 6 files changed, 397 insertions(+), 14 deletions(-) create mode 100644 Package.resolved create mode 100644 Plugin/BuildPlugin.swift create mode 100644 Sources/rive-codegen/Command.swift create mode 100644 Sources/rive-codegen/Generator.swift create mode 100644 Sources/rive-codegen/helpers.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..e61da897 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "011f0c765fb46d9cac61bca19be0527e99c98c8b", + "version" : "1.5.1" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift index fb9130ca..8d69f8a2 100644 --- a/Package.swift +++ b/Package.swift @@ -1,18 +1,40 @@ -// swift-tools-version:5.9 +// swift-tools-version:6.0 import PackageDescription let package = Package( - name: "RiveRuntime", - platforms: [.iOS("14.0"), .visionOS("1.0"), .tvOS("16.0"), .macOS("13.1"), .macCatalyst("14.0")], - products: [ - .library( - name: "RiveRuntime", - targets: ["RiveRuntime"])], - targets: [ - .binaryTarget( - name: "RiveRuntime", - url: "https://github.com/rive-app/rive-ios/releases/download/6.9.4/RiveRuntime.xcframework.zip", - checksum: "e5a5c810a838cf1ac8e44dcff606f84c1dbb80b17a144f6d7099b57705d83ee9" - ) - ] + name: "RiveRuntime", + platforms: [.iOS("14.0"), .visionOS("1.0"), .tvOS("16.0"), .macOS("13.1"), .macCatalyst("14.0")], + products: [ + .library( + name: "RiveRuntime", + targets: ["RiveRuntime"] + ), + .plugin(name: "RivePlugin", targets: ["RivePlugin"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + ], + targets: [ + .binaryTarget( + name: "RiveRuntime", + url: "https://github.com/rive-app/rive-ios/releases/download/6.9.4/RiveRuntime.xcframework.zip", + checksum: "e5a5c810a838cf1ac8e44dcff606f84c1dbb80b17a144f6d7099b57705d83ee9" + ), + .executableTarget( + name: "rive-codegen", + dependencies: [ + .target(name: "RiveRuntime"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + linkerSettings: [ + .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path"]), + ] + ), + .plugin( + name: "RivePlugin", + capability: .buildTool(), + dependencies: ["rive-codegen"], + path: "Plugin" + ), + ] ) diff --git a/Plugin/BuildPlugin.swift b/Plugin/BuildPlugin.swift new file mode 100644 index 00000000..7d3f59d6 --- /dev/null +++ b/Plugin/BuildPlugin.swift @@ -0,0 +1,57 @@ +import Foundation +import PackagePlugin + +enum PluginError: Swift.Error, CustomStringConvertible, LocalizedError { + case incompatibleTarget(name: String) + + var description: String { + switch self { + case let .incompatibleTarget(name): "Incompatible target called '\(name)'. Only Swift source targets can be used with the Rive plugin." + } + } + + var errorDescription: String? { description } +} + +@main +struct RivePlugin { + func createBuildCommands( + pluginWorkDirectory: URL, + tool: (String) throws -> PluginContext.Tool, + sourceFiles: FileList + ) throws -> [Command] { + let outputDir = pluginWorkDirectory.appending(path: "GeneratedSources") + let inputFiles = sourceFiles.filter { $0.url.lastPathComponent.hasSuffix(".riv") }.map(\.url) + + return try [ + .buildCommand( + displayName: "Running rive-codegen", + executable: tool("rive-codegen").url, + arguments: ["generate"] + inputFiles.map { $0.absoluteString } + ["--output-directory", "\(outputDir)"], + environment: [:], + inputFiles: inputFiles, + outputFiles: [outputDir.appending(path: "Rive+Generated.swift")] + ), + ] + } +} + +extension RivePlugin: BuildToolPlugin { + func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] { + guard let swiftTarget = target as? SwiftSourceModuleTarget else { + throw PluginError.incompatibleTarget(name: target.name) + } + + return try createBuildCommands(pluginWorkDirectory: context.pluginWorkDirectoryURL, tool: context.tool, sourceFiles: swiftTarget.sourceFiles) + } +} + +#if canImport(XcodeProjectPlugin) +import XcodeProjectPlugin + +extension RivePlugin: XcodeBuildToolPlugin { + func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + try createBuildCommands(pluginWorkDirectory: context.pluginWorkDirectoryURL, tool: context.tool, sourceFiles: target.inputFiles) + } +} +#endif diff --git a/Sources/rive-codegen/Command.swift b/Sources/rive-codegen/Command.swift new file mode 100644 index 00000000..1d8365e5 --- /dev/null +++ b/Sources/rive-codegen/Command.swift @@ -0,0 +1,62 @@ +import Foundation +import ArgumentParser + +@main struct Command: AsyncParsableCommand { + static let configuration: CommandConfiguration = .init( + commandName: "rive-codegen", + abstract: "Generate Swift code for your Rive files", + subcommands: [GenerateCommand.self] + ) +} + +struct GenerateCommand: AsyncParsableCommand { + struct Options: ParsableArguments { + @Argument(help: "A list of Rive files to generate code for") + var inputFiles: [URL] + } + + static let configuration: CommandConfiguration = .init( + commandName: "generate", + abstract: "Generate Swift code for your Rive files", + ) + + @OptionGroup var options: Options + + @Option( + help: + "Output directory where the generated files are written. Warning: Replaces any existing files with the same filename." + ) var outputDirectory: URL = .init(fileURLWithPath: FileManager.default.currentDirectoryPath) + + @Flag( + name: .customLong("dry-run"), + help: "Simulate the command and print the operations, without actually affecting the file system." + ) var isDryRun: Bool = false + + func run() async throws { + let files = options.inputFiles + + print( + """ + Swift Rive Code Generator is running with the following configuration: + - input files: \(files) + - Is dry run: \(isDryRun) + - Output directory: \(outputDirectory.path) + - Current directory: \(FileManager.default.currentDirectoryPath) + """ + ) + + let generator = Generator(isDryRun: isDryRun) + + try await withThrowingTaskGroup(of: Void.self) { group in + for file in files { + group.addTask { + try await generator.load(file) + } + } + + try await group.waitForAll() + } + + try await generator.generate(outputDirectory: outputDirectory, as: "Rive+Generated.swift") + } +} diff --git a/Sources/rive-codegen/Generator.swift b/Sources/rive-codegen/Generator.swift new file mode 100644 index 00000000..92e37f11 --- /dev/null +++ b/Sources/rive-codegen/Generator.swift @@ -0,0 +1,210 @@ +import Foundation +import RiveRuntime + +struct RiveDocument { + struct Artboard { + let name: String + let isDefault: Bool + let defaultViewModel: String? + } + + struct ViewModel { + struct Property { + enum PropertyType { + case none + case string + case number + case boolean + case color + case list + case `enum`(String) + case trigger + case viewModel(String) + case integer + case symbolListIndex + case assetImage + } + + let name: String + let type: PropertyType + } + + let name: String + let properties: [Property] + } + + struct Enum { + let name: String + let values: [String] + } + + let path: URL + let name: String + let enums: [Enum] + let artboards: [Artboard] + let viewModels: [ViewModel] +} + +actor Generator: Sendable { + let isDryRun: Bool + var documents: [RiveDocument] = [] + + init(isDryRun: Bool = false) { + self.isDryRun = isDryRun + } + + func load(_ url: URL) async throws { + let riveFile = try RiveRuntime.RiveFile(data: Data(contentsOf: url), loadCdn: false) + + let defaultArtboard = try riveFile.artboard() + let artboards = try riveFile.artboardNames().map { try riveFile.artboard(fromName: $0) }.map { artboard in + RiveDocument.Artboard( + name: artboard.name(), + isDefault: artboard.name() == defaultArtboard.name(), + defaultViewModel: riveFile.defaultViewModel(for: artboard).map { $0.name } + ) + } + + var enums: [RiveDocument.Enum] = [] + + let riveViewModels = Array(0.. RiveDocument.ViewModel? in + guard let instance = viewModel.createDefaultInstance() ?? viewModel.createInstance() else { return nil } + + let createEnum = { (propertyName: String) -> String in + let enumInstance = instance.enumProperty(fromPath: propertyName)! + // TODO: Find a way to extract the real enum name from the Rive file + let name = "Enum\(enums.count + 1)" + + enums.append( + RiveDocument.Enum(name: name, values: enumInstance.values) + ) + + return name + } + + let findViewModelName = { (propertyName: String) -> String in + let viewModelInstance = instance.viewModelInstanceProperty(fromPath: propertyName)! + + // TODO: Find a better way to match view models + return riveViewModels.first(where: { $0.properties.map { $0.name } == viewModelInstance.properties.map { $0.name } })?.name ?? "UnknownViewModel" + } + + let properties = viewModel.properties.map { property in + let type: RiveDocument.ViewModel.Property.PropertyType = switch property.type { + case .none: .none + case .string: .string + case .number: .number + case .boolean: .boolean + case .color: .color + case .list: .list + case .trigger: .trigger + case .integer: .integer + case .assetImage: .assetImage + case .symbolListIndex: .symbolListIndex + case .enum: .enum(createEnum(property.name)) + case .viewModel: .viewModel(findViewModelName(property.name)) + @unknown default: fatalError("Unknown property type: \(property.type)") + } + + return RiveDocument.ViewModel.Property(name: property.name, type: type) + } + + return RiveDocument.ViewModel(name: viewModel.name, properties: properties) + } + + documents.append( + RiveDocument( + path: url, + name: url.lastPathComponent.withoutExtension().pascalCased(), + enums: enums, + artboards: artboards, + viewModels: viewModels + ) + ) + } + + func generate(outputDirectory: URL, as fileName: String) async throws { + let data = """ + // Auto-generated file. Do not edit manually. + // swiftlint:disable all + + import Foundation + import RiveRuntime + + #if os(macOS) + import AppKit + typealias RiveColor = NSColor + #else + import UIKit + typealias RiveColor = UIColor + #endif + + enum Rive { + \(documents.map { generate(for: $0) }.joined(separator: "\n\n\t\t")) + } + """.data(using: .utf8)! + + let fileManager = FileManager.default + let destinationURL = outputDirectory.appendingPathComponent(fileName) + + if !isDryRun, let existingData = try? Data(contentsOf: destinationURL), existingData == data { + print("File \(destinationURL.lastPathComponent) already up to date.") + return + } + + if isDryRun { + print("Dry run enabled. Would write data to \(destinationURL.lastPathComponent).") + print(String(data: data, encoding: .utf8)!) + + } else { + print("Writing data to file \(destinationURL.lastPathComponent)...") + + try fileManager.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + try data.write(to: destinationURL) + } + } + + private func generate(for document: RiveDocument) -> String { + var declaration = "final class \(document.name.pascalCased()): RiveViewModel {\n" + + for riveEnum in document.enums { + declaration += """ + \t\tenum \(riveEnum.name.pascalCased()): String, CaseIterable { + \(riveEnum.values.map { "\t\t\tcase `\($0)` = \"\($0)\"" }.joined(separator: "\n")) + \t\t}\n\n + """ + } + + for viewModel in document.viewModels { + declaration += """ + \t\tstruct \(viewModel.name.pascalCased()) { + \t\t\t\(viewModel.properties.map { generate(for: $0) }.joined(separator: "\n\t\t\t")) + \t\t}\n\n + """ + } + + declaration += "\n\t}" + return declaration + } + + private func generate(for property: RiveDocument.ViewModel.Property) -> String { + let type = switch property.type { + case .none: "Any" + case .list: "[Any]" // TODO: What type are lists? + case .integer: "Int" + case .string: "String" + case .number: "Double" + case .boolean: "Bool" + case .color: "RiveColor" + case let .enum(name): name + case let .viewModel(name): name + case .symbolListIndex: "Int" // TODO: What type is this? + case .assetImage: "RiveRenderImage" + case .trigger: "RiveDataBindingViewModel.Instance.TriggerProperty" + } + + return "var `\(property.name)`: \(type)" + } +} diff --git a/Sources/rive-codegen/helpers.swift b/Sources/rive-codegen/helpers.swift new file mode 100644 index 00000000..ef9cf6a0 --- /dev/null +++ b/Sources/rive-codegen/helpers.swift @@ -0,0 +1,18 @@ +import Foundation +import ArgumentParser + +extension URL: @retroactive ExpressibleByArgument { + public init?(argument: String) { + self.init(string: argument) + } +} + +extension String { + func withoutExtension() -> String { + split(separator: ".").dropLast().joined(separator: ".") + } + + func pascalCased() -> String { + components(separatedBy: CharacterSet(charactersIn: "-_")).map { $0.capitalized }.joined() + } +}