Skip to content

Commit fe94bf2

Browse files
authored
Merge pull request #2 from idrougge/colour
Add support for rewriting colour literals as well
2 parents f9cd2b1 + 50a6e8d commit fe94bf2

7 files changed

Lines changed: 358 additions & 16 deletions

File tree

Package.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ let package = Package(
1111
products: [
1212
.plugin(name: "Rewrite image resource strings",
1313
targets: ["Rewrite image resource strings"]),
14+
.plugin(name: "Rewrite colour resource strings",
15+
targets: ["Rewrite colour resource strings"]),
1416
],
1517
dependencies: [
1618
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
@@ -41,6 +43,22 @@ let package = Package(
4143
.target(name: "ResourceRewriterForXcode"),
4244
]
4345
),
46+
.plugin(
47+
name: "Rewrite colour resource strings",
48+
capability: .command(
49+
intent: .sourceCodeFormatting,
50+
permissions: [
51+
.writeToPackageDirectory(reason:
52+
"""
53+
Your `UIColor(named:)` calls will be rewritten as `UIColor(resource:)` calls.
54+
Please commit before running.
55+
""")
56+
]
57+
),
58+
dependencies: [
59+
.target(name: "ResourceRewriterForXcode"),
60+
]
61+
),
4462
.testTarget(
4563
name: "ResourceRewriterForXcodeTests",
4664
dependencies: [
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// plugin.swift
3+
//
4+
//
5+
// Created by Iggy Drougge on 2024-01-22.
6+
//
7+
8+
import PackagePlugin
9+
import XcodeProjectPlugin
10+
import Foundation
11+
12+
@main
13+
struct ColourPlugin: CommandPlugin, XcodeCommandPlugin {
14+
func performCommand(context: PluginContext, arguments: [String]) async throws {
15+
var argumentExtractor = ArgumentExtractor(arguments)
16+
let targetNames = argumentExtractor.extractOption(named: "target")
17+
let sourceModules = try context.package.targets(named: targetNames).compactMap(\.sourceModule)
18+
let files = sourceModules.flatMap { $0.sourceFiles(withSuffix: "swift") }
19+
let tool = try context.tool(named: "ResourceRewriterForXcode")
20+
let process = Process()
21+
process.executableURL = URL(fileURLWithPath: tool.path.string)
22+
process.arguments = CollectionOfOne("colours") + files.map(\.path.string)
23+
try process.run()
24+
process.waitUntilExit()
25+
26+
switch (process.terminationReason, process.terminationStatus) {
27+
case (.exit, EXIT_SUCCESS):
28+
print("String literals were successfully rewritten as resources.")
29+
case (let reason, let status):
30+
Diagnostics.error("Process terminated with error: \(reason) (\(status))")
31+
}
32+
}
33+
34+
func performCommand(context: XcodePluginContext, arguments: [String]) throws {
35+
let sourceFiles = context.xcodeProject.filePaths.filter { file in
36+
file.extension == "swift"
37+
}
38+
let tool = try context.tool(named: "ResourceRewriterForXcode")
39+
let process = Process()
40+
process.executableURL = URL(fileURLWithPath: tool.path.string)
41+
process.arguments = CollectionOfOne("colours") + sourceFiles.map(\.string)
42+
try process.run()
43+
process.waitUntilExit()
44+
45+
switch (process.terminationReason, process.terminationStatus) {
46+
case (.exit, EXIT_SUCCESS):
47+
print("String literals were successfully rewritten as resources.")
48+
case (let reason, let status):
49+
Diagnostics.error("Process terminated with error: \(reason) (\(status))")
50+
}
51+
}
52+
}

Plugins/plugin.swift renamed to Plugins/Rewrite image resource strings/plugin.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ struct ResourceRewriterPlugin: CommandPlugin, XcodeCommandPlugin {
1919
let tool = try context.tool(named: "ResourceRewriterForXcode")
2020
let process = Process()
2121
process.executableURL = URL(fileURLWithPath: tool.path.string)
22-
process.arguments = files.map(\.path.string)
22+
process.arguments = CollectionOfOne("images") + files.map(\.path.string)
2323
try process.run()
2424
process.waitUntilExit()
2525

@@ -38,7 +38,7 @@ struct ResourceRewriterPlugin: CommandPlugin, XcodeCommandPlugin {
3838
let tool = try context.tool(named: "ResourceRewriterForXcode")
3939
let process = Process()
4040
process.executableURL = URL(fileURLWithPath: tool.path.string)
41-
process.arguments = sourceFiles.map(\.string)
41+
process.arguments = CollectionOfOne("images") + sourceFiles.map(\.string)
4242
try process.run()
4343
process.waitUntilExit()
4444

README.md

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
# Resource Rewriter for Xcode 15+
22

3-
This plugin lets you automatically rewrite UIKit/SwiftUI image instantations from unreliable string-based inits such as:
3+
This plugin lets you automatically rewrite UIKit/SwiftUI image and colour instantations from unreliable string-based inits such as:
44
```swift
5-
UIImage(named: "some image")
6-
Image("some image")
5+
UIImage(named: "some icon")
6+
Image("some icon")
7+
UIColor(named: "light blue green")
8+
Color("light blue green")
79
```
8-
into `ImageResource` literals (as introduced in Xcode 15) such as:
10+
into `ImageResource` and `ColorResource` literals (as introduced in Xcode 15) such as:
911
```swift
10-
UIImage(resource: .someImage)
11-
Image(.someImage)
12+
UIImage(resource: .someIcon)
13+
Image(.someIcon)
14+
UIColor(resource: .lightBlueGreen)
15+
Color(.lightBlueGreen)
1216
```
1317

1418
## Installation
@@ -27,7 +31,7 @@ dependencies: [
2731

2832
## Usage
2933

30-
After a rebuild, a secondary click on your project (or package) in the Project Navigator brings up a menu where you will now find the option "Rewrite image resource strings". Select that option and the target where you want your image references to be fixed up.
34+
After a rebuild, a secondary click on your project (or package) in the Project Navigator brings up a menu where you will now find the options "Rewrite image resource strings" and "Rewrite colour resource strings". Select that option and the target where you want your asset references to be fixed up.
3135

3236
![Project menu](https://github.com/idrougge/ResourceRewriterForXcode/assets/17124673/604c9023-a9e4-4bb3-8c0e-4af256feb159)
3337

@@ -37,11 +41,11 @@ As the `UIImage(named:)` init returns an optional and `UIImage(resource:)` does
3741

3842
If you have turned off generated asset symbols, go into your build settings and enable **Generate Asset Symbols** (`ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS`) or the resource names will not resolve.
3943

40-
After you are done, you are free to remove this dependency again, possibly introducing a linter rule forbidding calls to string-based image inits.
44+
After you are done, you are free to remove this dependency again, possibly introducing a linter rule forbidding calls to string-based asset inits.
4145

4246
## Limitations
4347

4448
* Short-hand calls such as `image = .init(named: "Something")` aren't handled.
4549
* Any image name built with string interpolation or concatenation is untouched as those must be resolved at run-time.
46-
* The plugin strives to follow Xcode's pattern for translating string-based image names into `ImageResource` names but there may be cases where this does not match. Please open an issue in that case so it may added.
47-
* Functions or enums that return or accept string names, as well as wrapper functions or generated code must be rewritten manually if you wish to use `ImageResource` for those. You may fork and customise this plugin if such uses permeate your project.
50+
* The plugin strives to follow Xcode's pattern for translating string-based asset names into `ImageResource/ColorResource` names but there may be cases where this does not match. Please open an issue in that case so it may added.
51+
* Functions or enums that return or accept string names, as well as wrapper functions or generated code must be rewritten manually if you wish to use `ImageResource/ColorResource` for those. You may fork and customise this plugin if such uses permeate your project.

Sources/ResourceRewriterForXcode.swift

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,24 @@ import Foundation
1212

1313
@main
1414
struct RewriteTool: ParsableCommand {
15+
enum Mode: String, ExpressibleByArgument {
16+
case images, colours
17+
}
18+
19+
@Argument var mode: Mode
1520
@Argument var files: [String] = []
1621

1722
mutating func run() throws {
23+
let rewriter = switch mode {
24+
case .images: RewriteImageLiteral()
25+
case .colours: RewriteColourLiteral()
26+
}
27+
1828
for file in files {
1929
let resource = URL(filePath: file)
2030
let contents = try String(contentsOf: resource)
2131
let sources = Parser.parse(source: contents)
22-
let converted = RewriteImageLiteral().visit(sources)
32+
let converted = rewriter.visit(sources)
2333
try converted.description.write(to: resource, atomically: true, encoding: .utf8)
2434
}
2535
}
@@ -121,6 +131,102 @@ class RewriteImageLiteral: SyntaxRewriter {
121131
}
122132
}
123133

134+
class RewriteColourLiteral: SyntaxRewriter {
135+
override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
136+
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self)
137+
else {
138+
return super.visit(node)
139+
}
140+
switch calledExpression.baseName.tokenKind {
141+
case .identifier("UIColor"): return rewriteUIKitColour(node)
142+
case .identifier("Color"): return rewriteSwiftUIColour(node)
143+
case _: return super.visit(node)
144+
}
145+
}
146+
147+
// Since `UIColor(named:)` returns an optional, and `UIColor(resource:)` does not, we need to remove the trailing question mark.
148+
override func visit(_ node: OptionalChainingExprSyntax) -> ExprSyntax {
149+
guard let expression = node.expression.as(FunctionCallExprSyntax.self),
150+
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self),
151+
case .identifier("UIColor") = calledExpression.baseName.tokenKind
152+
else {
153+
return super.visit(node)
154+
}
155+
return rewriteUIKitColour(expression)
156+
}
157+
158+
// Since `UIColor(named:)` returns an optional, and `UIColor(resource:)` does not, we need to remove force unwrap exclamation marks.
159+
override func visit(_ node: ForceUnwrapExprSyntax) -> ExprSyntax {
160+
guard let expression = node.expression.as(FunctionCallExprSyntax.self),
161+
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self),
162+
case .identifier("UIColor") = calledExpression.baseName.tokenKind
163+
else {
164+
return super.visit(node)
165+
}
166+
return rewriteUIKitColour(expression)
167+
}
168+
169+
private func rewriteUIKitColour(_ node: FunctionCallExprSyntax) -> ExprSyntax {
170+
guard let argument = node.arguments.first,
171+
argument.label?.text == "named",
172+
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self),
173+
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed.
174+
!value.isEmpty
175+
else {
176+
return super.visit(node)
177+
}
178+
179+
var node = node
180+
181+
let resourceName = normaliseLiteralName(value)
182+
183+
let expression = MemberAccessExprSyntax(
184+
period: .periodToken(),
185+
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName))
186+
)
187+
188+
let newArgument = LabeledExprSyntax(
189+
label: .identifier("resource"),
190+
colon: .colonToken(trailingTrivia: .space),
191+
expression: expression
192+
)
193+
194+
node.arguments = LabeledExprListSyntax([newArgument])
195+
196+
return super.visit(node)
197+
}
198+
199+
private func rewriteSwiftUIColour(_ node: FunctionCallExprSyntax) -> ExprSyntax {
200+
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self),
201+
case .identifier("Color") = calledExpression.baseName.tokenKind,
202+
let argument = node.arguments.first,
203+
argument.label == .none,
204+
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self),
205+
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed.
206+
!value.isEmpty
207+
else { return super.visit(node) }
208+
209+
var node = node
210+
211+
let resourceName = normaliseLiteralName(value)
212+
213+
let expression = MemberAccessExprSyntax(
214+
period: .periodToken(),
215+
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName))
216+
)
217+
218+
let newArgument = LabeledExprSyntax(
219+
label: .none,
220+
colon: .none,
221+
expression: expression
222+
)
223+
224+
node.arguments = LabeledExprListSyntax([newArgument])
225+
226+
return super.visit(node)
227+
}
228+
}
229+
124230
private let separators = CharacterSet(charactersIn: " _-")
125231

126232
private func normaliseLiteralName(_ name: String) -> String {
@@ -146,7 +252,7 @@ private func normaliseLiteralName(_ name: String) -> String {
146252
resourceName = "_" + resourceName
147253
}
148254

149-
return path + resourceName
255+
return path + resourceName.decomposedStringWithCanonicalMapping
150256
}
151257

152258
private func extractPathComponents(from name: String) -> (path: String, name: String) {

0 commit comments

Comments
 (0)