Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,49 @@ let package = Package(
name: "ThemeKitGeneratorTests",
dependencies: ["ThemeKitGenerator"]
),
.plugin(
name: "GenerateTestFixturesPlugin",
capability: .buildTool(),
dependencies: ["ThemeKitGeneratorCLI"],
path: "Plugins/GenerateTestFixturesPlugin"
),
// Generated code compilation verification targets
.target(
name: "GeneratedCodeSwift5",
dependencies: ["ThemeKit"],
path: "Tests/GeneratedCodeSwift5",
swiftSettings: [.swiftLanguageMode(.v5)],
plugins: [.plugin(name: "GenerateTestFixturesPlugin")]
),
.target(
name: "GeneratedCodeSwift5MainActor",
dependencies: ["ThemeKit"],
path: "Tests/GeneratedCodeSwift5MainActor",
swiftSettings: [.swiftLanguageMode(.v5), .defaultIsolation(MainActor.self)],
plugins: [.plugin(name: "GenerateTestFixturesPlugin")]
),
.target(
name: "GeneratedCodeSwift6",
dependencies: ["ThemeKit"],
path: "Tests/GeneratedCodeSwift6",
swiftSettings: [.swiftLanguageMode(.v6)],
plugins: [.plugin(name: "GenerateTestFixturesPlugin")]
),
.target(
name: "GeneratedCodeSwift6MainActor",
dependencies: ["ThemeKit"],
path: "Tests/GeneratedCodeSwift6MainActor",
swiftSettings: [.swiftLanguageMode(.v6), .defaultIsolation(MainActor.self)],
plugins: [.plugin(name: "GenerateTestFixturesPlugin")]
),
.testTarget(
name: "GeneratedCodeCompilationTests",
dependencies: [
"GeneratedCodeSwift5",
"GeneratedCodeSwift5MainActor",
"GeneratedCodeSwift6",
"GeneratedCodeSwift6MainActor",
]
),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import PackagePlugin
import Foundation

@main
struct GenerateTestFixturesPlugin: BuildToolPlugin {
func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
guard let sourceModule = target as? SourceModuleTarget else { return [] }

let configFile = sourceModule.sourceFiles.first { $0.url.lastPathComponent == "theme.json" }
guard let configFile else {
Diagnostics.error("No theme.json found in target \(target.name)")
return []
}

let tool = try context.tool(named: "ThemeKitGeneratorCLI")
let outputDir = context.pluginWorkDirectoryURL
let outputFiles = try computeOutputFiles(configURL: configFile.url, outputDir: outputDir)

return [
.buildCommand(
displayName: "Generate ThemeKit fixtures for \(target.name)",
executable: tool.url,
arguments: [
"--config", configFile.url.path(),
"--output", outputDir.path(),
"--skip-defaults",
],
inputFiles: [configFile.url],
outputFiles: outputFiles
),
]
}

/// Parses theme.json minimally to compute the list of files the generator will produce.
private func computeOutputFiles(configURL: URL, outputDir: URL) throws -> [URL] {
let data = try Data(contentsOf: configURL)
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] ?? [:]
let styles = json["styles"] as? [String: Any] ?? [:]
let config = json["config"] as? [String: Any] ?? [:]

let categoryKeys = styles.keys
let shouldGeneratePreview = config["shouldGeneratePreview"] as? Bool ?? false

// Map JSON category keys to struct names
let categoryStructNames: [String: String] = [
"colors": "ThemeColors",
"gradients": "ThemeGradients",
"meshGradients": "ThemeMeshGradients",
"shadows": "ThemeShadows",
]

var files: [String] = []

// Static files (always generated)
files.append("ThemeShapeStyle.swift")
files.append("Environment+Theme.swift")
files.append("Theme.swift")
files.append("Theme+CopyWith.swift")

// Conditional: ThemeShadowedStyle only when shadows present
if categoryKeys.contains("shadows") {
files.append("ThemeShadowedStyle.swift")
}

// Per-category files
for key in categoryKeys {
guard let structName = categoryStructNames[key] else { continue }
files.append("\(structName).swift")
files.append("\(structName)+CopyWith.swift")
files.append("ShapeStyle+\(structName).swift")
}

// Optional preview file
if shouldGeneratePreview {
files.append("Theme+Preview.swift")
}

return files.map { outputDir.appendingPathComponent($0) }
}
}
11 changes: 8 additions & 3 deletions Sources/ThemeKitGeneratorCLI/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ func run() throws {

var configPath = "theme.json"
var outputPath = "."
var skipDefaults = false

var i = 1
while i < args.count {
Expand All @@ -22,6 +23,8 @@ func run() throws {
throw GeneratorError.missingArgument("--output")
}
outputPath = args[i]
case "--skip-defaults":
skipDefaults = true
default:
break
}
Expand All @@ -42,13 +45,15 @@ func run() throws {

try FileManager.default.createDirectory(at: finalOutputURL, withIntermediateDirectories: true)

for file in result.files {
let filesToWrite = skipDefaults ? result.files.filter { $0.name != "Theme+Defaults.swift" } : result.files

for file in filesToWrite {
let fileURL = finalOutputURL.appendingPathComponent(file.name)
try file.content.write(to: fileURL, atomically: true, encoding: .utf8)
}

print("ThemeKit: Generated \(result.files.count) files in \(finalOutputURL.path):")
for file in result.files {
print("ThemeKit: Generated \(filesToWrite.count) files in \(finalOutputURL.path):")
for file in filesToWrite {
print(" - \(file.name)")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import Testing
import GeneratedCodeSwift5
import GeneratedCodeSwift5MainActor
import GeneratedCodeSwift6
import GeneratedCodeSwift6MainActor

/// Verifies that ThemeKit-generated code compiles under all 4 combinations
/// of Swift language mode (5/6) and default actor isolation (nonisolated/MainActor).
///
/// The primary verification is compilation itself — if these targets build, the
/// generated code is compatible with that configuration. The tests below perform
/// minimal runtime checks to confirm the types are usable.
@Suite("Generated Code Compilation")
struct GeneratedCodeCompilationTests {

@Test func swift5_typesAreUsable() {
let theme = GeneratedCodeSwift5.Theme.default
_ = theme.colors.surface
_ = theme.gradients.primary
_ = theme.meshGradients.aurora
_ = theme.shadows.card
}

@Test func swift5MainActor_typesAreUsable() {
let theme = GeneratedCodeSwift5MainActor.Theme.default
_ = theme.colors.surface
_ = theme.gradients.primary
_ = theme.meshGradients.aurora
_ = theme.shadows.card
}

@Test func swift6_typesAreUsable() {
let theme = GeneratedCodeSwift6.Theme.default
_ = theme.colors.surface
_ = theme.gradients.primary
_ = theme.meshGradients.aurora
_ = theme.shadows.card
}

@Test func swift6MainActor_typesAreUsable() {
let theme = GeneratedCodeSwift6MainActor.Theme.default
_ = theme.colors.surface
_ = theme.gradients.primary
_ = theme.meshGradients.aurora
_ = theme.shadows.card
}
}
57 changes: 57 additions & 0 deletions Tests/GeneratedCodeSwift5/Theme+Defaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import ThemeKit

nonisolated extension Theme {
public static let `default` = Theme(
colors: .`default`,
gradients: .`default`,
meshGradients: .`default`,
shadows: .`default`
)
}

// MARK: - ThemeColors

nonisolated extension ThemeColors {
public static let `default` = ThemeColors(
surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)),
primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1))
)
}

// MARK: - ThemeGradients

nonisolated extension ThemeGradients {
public static let `default` = ThemeGradients(
primary: .init(
light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]),
dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)])
)
)
}

// MARK: - ThemeMeshGradients

nonisolated extension ThemeMeshGradients {
public static let `default` = ThemeMeshGradients(
aurora: .init(
light: .init(width: 2, height: 2, colors: [
Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1),
Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3),
]),
dark: .init(width: 2, height: 2, colors: [
Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1),
Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5),
])
)
)
}

// MARK: - ThemeShadows

nonisolated extension ThemeShadows {
public static let `default` = ThemeShadows(
card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)),
inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2))
)
}
12 changes: 12 additions & 0 deletions Tests/GeneratedCodeSwift5/theme.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"styles": {
"colors": ["surface", {"name": "primary", "style": "primaryColor"}],
"gradients": [{"name": "primary", "style": "primaryGradient"}],
"meshGradients": ["aurora"],
"shadows": ["card", {"name": "inner", "style": "innerGlow"}]
},
"config": {
"outputPath": ".",
"shouldGeneratePreview": true
}
}
57 changes: 57 additions & 0 deletions Tests/GeneratedCodeSwift5MainActor/Theme+Defaults.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import ThemeKit

nonisolated extension Theme {
public static let `default` = Theme(
colors: .`default`,
gradients: .`default`,
meshGradients: .`default`,
shadows: .`default`
)
}

// MARK: - ThemeColors

nonisolated extension ThemeColors {
public static let `default` = ThemeColors(
surface: .init(light: Color(red: 1, green: 1, blue: 1), dark: Color(red: 0, green: 0, blue: 0)),
primary: .init(light: Color(red: 0, green: 0.5, blue: 1), dark: Color(red: 0, green: 0.8, blue: 1))
)
}

// MARK: - ThemeGradients

nonisolated extension ThemeGradients {
public static let `default` = ThemeGradients(
primary: .init(
light: .init(colors: [Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1)]),
dark: .init(colors: [Color(red: 0, green: 0.8, blue: 1), Color(red: 0.3, green: 0, blue: 0.5)])
)
)
}

// MARK: - ThemeMeshGradients

nonisolated extension ThemeMeshGradients {
public static let `default` = ThemeMeshGradients(
aurora: .init(
light: .init(width: 2, height: 2, colors: [
Color(red: 0, green: 0.5, blue: 1), Color(red: 0.5, green: 0, blue: 1),
Color(red: 0, green: 0.8, blue: 0.8), Color(red: 0, green: 0.8, blue: 0.3),
]),
dark: .init(width: 2, height: 2, colors: [
Color(red: 0.3, green: 0, blue: 0.5), Color(red: 0.5, green: 0, blue: 1),
Color(red: 0, green: 0.5, blue: 0.5), Color(red: 0, green: 0.7, blue: 0.5),
])
)
)
}

// MARK: - ThemeShadows

nonisolated extension ThemeShadows {
public static let `default` = ThemeShadows(
card: .init(light: .drop(radius: 4), dark: .drop(radius: 4)),
inner: .init(light: .inner(radius: 2), dark: .inner(radius: 2))
)
}
12 changes: 12 additions & 0 deletions Tests/GeneratedCodeSwift5MainActor/theme.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"styles": {
"colors": ["surface", {"name": "primary", "style": "primaryColor"}],
"gradients": [{"name": "primary", "style": "primaryGradient"}],
"meshGradients": ["aurora"],
"shadows": ["card", {"name": "inner", "style": "innerGlow"}]
},
"config": {
"outputPath": ".",
"shouldGeneratePreview": true
}
}
Loading