Skip to content

Commit f9cd2b1

Browse files
committed
Initial Commit
0 parents  commit f9cd2b1

6 files changed

Lines changed: 525 additions & 0 deletions

File tree

.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.DS_Store
2+
/.build
3+
/Packages
4+
xcuserdata/
5+
DerivedData/
6+
.swiftpm/configuration/registries.json
7+
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
8+
.netrc
9+
.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist

Package.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// swift-tools-version: 5.9
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: "Resource Rewriter for Xcode",
8+
platforms: [
9+
.macOS(.v13),
10+
],
11+
products: [
12+
.plugin(name: "Rewrite image resource strings",
13+
targets: ["Rewrite image resource strings"]),
14+
],
15+
dependencies: [
16+
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
17+
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"),
18+
],
19+
targets: [
20+
.executableTarget(
21+
name: "ResourceRewriterForXcode",
22+
dependencies: [
23+
.product(name: "ArgumentParser", package: "swift-argument-parser"),
24+
.product(name: "SwiftSyntax", package: "swift-syntax"),
25+
.product(name: "SwiftParser", package: "swift-syntax"),
26+
]
27+
),
28+
.plugin(
29+
name: "Rewrite image resource strings",
30+
capability: .command(
31+
intent: .sourceCodeFormatting,
32+
permissions: [
33+
.writeToPackageDirectory(reason:
34+
"""
35+
Your `UIImage(named:)` calls will be rewritten as `UImage(resource:)` calls.
36+
Please commit before running.
37+
""")
38+
]
39+
),
40+
dependencies: [
41+
.target(name: "ResourceRewriterForXcode"),
42+
]
43+
),
44+
.testTarget(
45+
name: "ResourceRewriterForXcodeTests",
46+
dependencies: [
47+
.target(name: "ResourceRewriterForXcode")
48+
]
49+
)
50+
]
51+
)

Plugins/plugin.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//
2+
// plugin.swift
3+
// ResourceRewriterForXcode
4+
//
5+
// Created by Iggy Drougge on 2023-10-16.
6+
//
7+
8+
import PackagePlugin
9+
import XcodeProjectPlugin
10+
import Foundation
11+
12+
@main
13+
struct ResourceRewriterPlugin: 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 = 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 = 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+
}

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Resource Rewriter for Xcode 15+
2+
3+
This plugin lets you automatically rewrite UIKit/SwiftUI image instantations from unreliable string-based inits such as:
4+
```swift
5+
UIImage(named: "some image")
6+
Image("some image")
7+
```
8+
into `ImageResource` literals (as introduced in Xcode 15) such as:
9+
```swift
10+
UIImage(resource: .someImage)
11+
Image(.someImage)
12+
```
13+
14+
## Installation
15+
16+
* In Xcode, go to **File → Add Package Dependencies** and enter the URL for this repository.
17+
* In the following popup, select **Add to Target: None** as the package is for running only in Xcode and not part of your app itself.
18+
19+
![Add to Target: None](https://github.com/idrougge/ResourceRewriterForXcode/assets/17124673/284a44ab-9cb8-402f-bec8-211332fde658)
20+
21+
* In case your application is split into several packages, as is increasingly common, you also need to add the dependency to your package's `Package.swift` file to process images in that package:
22+
```swift
23+
dependencies: [
24+
.package(url: "https://github.com/idrougge/ResourceRewriterForXcode.git", branch: "main"),
25+
]
26+
```
27+
28+
## Usage
29+
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.
31+
32+
![Project menu](https://github.com/idrougge/ResourceRewriterForXcode/assets/17124673/604c9023-a9e4-4bb3-8c0e-4af256feb159)
33+
34+
## Cleanup
35+
36+
As the `UIImage(named:)` init returns an optional and `UIImage(resource:)` does not, you may now have `if let`, `guard let` or nil coalescing (`??`) statements that are no longer necessary. These you will have to fix up by yourself as it is beyond the capabilities of a simple plugin.
37+
38+
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.
39+
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.
41+
42+
## Limitations
43+
44+
* Short-hand calls such as `image = .init(named: "Something")` aren't handled.
45+
* 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.
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
//
2+
// RewriteTool.swift
3+
//
4+
//
5+
// Created by Iggy Drougge on 2023-10-16.
6+
//
7+
8+
import ArgumentParser
9+
import SwiftSyntax
10+
import SwiftParser
11+
import Foundation
12+
13+
@main
14+
struct RewriteTool: ParsableCommand {
15+
@Argument var files: [String] = []
16+
17+
mutating func run() throws {
18+
for file in files {
19+
let resource = URL(filePath: file)
20+
let contents = try String(contentsOf: resource)
21+
let sources = Parser.parse(source: contents)
22+
let converted = RewriteImageLiteral().visit(sources)
23+
try converted.description.write(to: resource, atomically: true, encoding: .utf8)
24+
}
25+
}
26+
}
27+
28+
class RewriteImageLiteral: SyntaxRewriter {
29+
override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
30+
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self)
31+
else {
32+
return super.visit(node)
33+
}
34+
switch calledExpression.baseName.tokenKind {
35+
case .identifier("UIImage"): return rewriteUIKitImage(node)
36+
case .identifier("Image"): return rewriteSwiftUIImage(node)
37+
case _: return super.visit(node)
38+
}
39+
}
40+
41+
// Since `UIImage(named:)` returns an optional, and `UIImage(resource:)` does not, we need to remove the trailing question mark.
42+
override func visit(_ node: OptionalChainingExprSyntax) -> ExprSyntax {
43+
guard let expression = node.expression.as(FunctionCallExprSyntax.self),
44+
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self),
45+
case .identifier("UIImage") = calledExpression.baseName.tokenKind
46+
else {
47+
return super.visit(node)
48+
}
49+
return rewriteUIKitImage(expression)
50+
}
51+
52+
// Since `UIImage(named:)` returns an optional, and `UIImage(resource:)` does not, we need to remove force unwrap exclamation marks.
53+
override func visit(_ node: ForceUnwrapExprSyntax) -> ExprSyntax {
54+
guard let expression = node.expression.as(FunctionCallExprSyntax.self),
55+
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self),
56+
case .identifier("UIImage") = calledExpression.baseName.tokenKind
57+
else {
58+
return super.visit(node)
59+
}
60+
return rewriteUIKitImage(expression)
61+
}
62+
63+
private func rewriteUIKitImage(_ node: FunctionCallExprSyntax) -> ExprSyntax {
64+
guard let argument = node.arguments.first,
65+
argument.label?.text == "named",
66+
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self),
67+
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed.
68+
!value.isEmpty
69+
else {
70+
return super.visit(node)
71+
}
72+
73+
var node = node
74+
75+
let resourceName = normaliseLiteralName(value)
76+
77+
let expression = MemberAccessExprSyntax(
78+
period: .periodToken(),
79+
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName))
80+
)
81+
82+
let newArgument = LabeledExprSyntax(
83+
label: .identifier("resource"),
84+
colon: .colonToken(trailingTrivia: .space),
85+
expression: expression
86+
)
87+
88+
node.arguments = LabeledExprListSyntax([newArgument])
89+
90+
return super.visit(node)
91+
}
92+
93+
private func rewriteSwiftUIImage(_ node: FunctionCallExprSyntax) -> ExprSyntax {
94+
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self),
95+
case .identifier("Image") = calledExpression.baseName.tokenKind,
96+
let argument = node.arguments.first,
97+
argument.label == .none,
98+
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self),
99+
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed.
100+
!value.isEmpty
101+
else { return super.visit(node) }
102+
103+
var node = node
104+
105+
let resourceName = normaliseLiteralName(value)
106+
107+
let expression = MemberAccessExprSyntax(
108+
period: .periodToken(),
109+
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName))
110+
)
111+
112+
let newArgument = LabeledExprSyntax(
113+
label: .none,
114+
colon: .none,
115+
expression: expression
116+
)
117+
118+
node.arguments = LabeledExprListSyntax([newArgument])
119+
120+
return super.visit(node)
121+
}
122+
}
123+
124+
private let separators = CharacterSet(charactersIn: " _-")
125+
126+
private func normaliseLiteralName(_ name: String) -> String {
127+
let (path, name) = extractPathComponents(from: name)
128+
129+
let components = name.components(separatedBy: separators)
130+
131+
guard let head = components.first.map(lowercaseFirst)
132+
else { return String() }
133+
134+
let tail = components
135+
.dropFirst()
136+
.map(uppercaseFirst)
137+
.joined()
138+
139+
var resourceName = head + tail
140+
141+
if resourceName.hasSuffix("Image") || resourceName.hasSuffix("Color") {
142+
resourceName.removeLast(5)
143+
}
144+
145+
if resourceName.first!.isNumber, resourceName.first!.isASCII {
146+
resourceName = "_" + resourceName
147+
}
148+
149+
return path + resourceName
150+
}
151+
152+
private func extractPathComponents(from name: String) -> (path: String, name: String) {
153+
// If literal contains a slash, it maps to a child type of `ImageResource`:
154+
// "Images/abc_def" → ".Images.abcDef"
155+
var pathComponents = name.components(separatedBy: "/")
156+
let name = pathComponents.last ?? name
157+
pathComponents = pathComponents.dropLast().map(uppercaseFirst(in:))
158+
if !pathComponents.isEmpty {
159+
pathComponents.append("") // Add empty portion for trailing dot when joining.
160+
}
161+
let path = pathComponents.joined(separator: ".")
162+
163+
return (path, name)
164+
}
165+
166+
private func lowercaseFirst(in string: some StringProtocol) -> any StringProtocol {
167+
// If first letter is lower-case or non-alphabetic, return string as is.
168+
guard let first = string.first,
169+
first.isUppercase
170+
else { return string }
171+
// If the entire string is uppercase, just lowercase it all.
172+
if string.allSatisfy(\.isUppercase) {
173+
return string.lowercased()
174+
}
175+
// Split string where lower case begins.
176+
let tail = string.drop(while: \.isUppercase)
177+
// If only initial letter is uppercase, lower case it and return. "Abc" → "abc"
178+
if string.index(after: string.startIndex) == tail.startIndex {
179+
return first.lowercased() + string.dropFirst()
180+
}
181+
// If tail is not empty, string consists of a sequence of uppercase characters
182+
// followed by one or several lowercase characters. Lowercase all but the last
183+
// uppercase character, concatenating it with the lowercase ones. "ABcd" → "aBcd"
184+
if tail.startIndex != string.endIndex {
185+
return string[..<tail.startIndex].dropLast().lowercased() + string[..<tail.startIndex].suffix(1) + tail
186+
}
187+
// Otherwise, just lowercase initial letter.
188+
return first.lowercased() + string.dropFirst()
189+
}
190+
191+
private func uppercaseFirst(in string: some StringProtocol) -> String {
192+
guard let first = string.first else { return String() }
193+
return first.uppercased() + string.dropFirst()
194+
}

0 commit comments

Comments
 (0)