-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit f9cd2b1
Showing
6 changed files
with
525 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
.DS_Store | ||
/.build | ||
/Packages | ||
xcuserdata/ | ||
DerivedData/ | ||
.swiftpm/configuration/registries.json | ||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata | ||
.netrc | ||
.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
// swift-tools-version: 5.9 | ||
// The swift-tools-version declares the minimum version of Swift required to build this package. | ||
|
||
import PackageDescription | ||
|
||
let package = Package( | ||
name: "Resource Rewriter for Xcode", | ||
platforms: [ | ||
.macOS(.v13), | ||
], | ||
products: [ | ||
.plugin(name: "Rewrite image resource strings", | ||
targets: ["Rewrite image resource strings"]), | ||
], | ||
dependencies: [ | ||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"), | ||
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), | ||
], | ||
targets: [ | ||
.executableTarget( | ||
name: "ResourceRewriterForXcode", | ||
dependencies: [ | ||
.product(name: "ArgumentParser", package: "swift-argument-parser"), | ||
.product(name: "SwiftSyntax", package: "swift-syntax"), | ||
.product(name: "SwiftParser", package: "swift-syntax"), | ||
] | ||
), | ||
.plugin( | ||
name: "Rewrite image resource strings", | ||
capability: .command( | ||
intent: .sourceCodeFormatting, | ||
permissions: [ | ||
.writeToPackageDirectory(reason: | ||
""" | ||
Your `UIImage(named:)` calls will be rewritten as `UImage(resource:)` calls. | ||
Please commit before running. | ||
""") | ||
] | ||
), | ||
dependencies: [ | ||
.target(name: "ResourceRewriterForXcode"), | ||
] | ||
), | ||
.testTarget( | ||
name: "ResourceRewriterForXcodeTests", | ||
dependencies: [ | ||
.target(name: "ResourceRewriterForXcode") | ||
] | ||
) | ||
] | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
// | ||
// plugin.swift | ||
// ResourceRewriterForXcode | ||
// | ||
// Created by Iggy Drougge on 2023-10-16. | ||
// | ||
|
||
import PackagePlugin | ||
import XcodeProjectPlugin | ||
import Foundation | ||
|
||
@main | ||
struct ResourceRewriterPlugin: CommandPlugin, XcodeCommandPlugin { | ||
func performCommand(context: PluginContext, arguments: [String]) async throws { | ||
var argumentExtractor = ArgumentExtractor(arguments) | ||
let targetNames = argumentExtractor.extractOption(named: "target") | ||
let sourceModules = try context.package.targets(named: targetNames).compactMap(\.sourceModule) | ||
let files = sourceModules.flatMap { $0.sourceFiles(withSuffix: "swift") } | ||
let tool = try context.tool(named: "ResourceRewriterForXcode") | ||
let process = Process() | ||
process.executableURL = URL(fileURLWithPath: tool.path.string) | ||
process.arguments = files.map(\.path.string) | ||
try process.run() | ||
process.waitUntilExit() | ||
|
||
switch (process.terminationReason, process.terminationStatus) { | ||
case (.exit, EXIT_SUCCESS): | ||
print("String literals were successfully rewritten as resources.") | ||
case (let reason, let status): | ||
Diagnostics.error("Process terminated with error: \(reason) (\(status))") | ||
} | ||
} | ||
|
||
func performCommand(context: XcodePluginContext, arguments: [String]) throws { | ||
let sourceFiles = context.xcodeProject.filePaths.filter { file in | ||
file.extension == "swift" | ||
} | ||
let tool = try context.tool(named: "ResourceRewriterForXcode") | ||
let process = Process() | ||
process.executableURL = URL(fileURLWithPath: tool.path.string) | ||
process.arguments = sourceFiles.map(\.string) | ||
try process.run() | ||
process.waitUntilExit() | ||
|
||
switch (process.terminationReason, process.terminationStatus) { | ||
case (.exit, EXIT_SUCCESS): | ||
print("String literals were successfully rewritten as resources.") | ||
case (let reason, let status): | ||
Diagnostics.error("Process terminated with error: \(reason) (\(status))") | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
# Resource Rewriter for Xcode 15+ | ||
|
||
This plugin lets you automatically rewrite UIKit/SwiftUI image instantations from unreliable string-based inits such as: | ||
```swift | ||
UIImage(named: "some image") | ||
Image("some image") | ||
``` | ||
into `ImageResource` literals (as introduced in Xcode 15) such as: | ||
```swift | ||
UIImage(resource: .someImage) | ||
Image(.someImage) | ||
``` | ||
|
||
## Installation | ||
|
||
* In Xcode, go to **File → Add Package Dependencies** and enter the URL for this repository. | ||
* 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. | ||
|
||
![Add to Target: None](https://github.com/idrougge/ResourceRewriterForXcode/assets/17124673/284a44ab-9cb8-402f-bec8-211332fde658) | ||
|
||
* 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: | ||
```swift | ||
dependencies: [ | ||
.package(url: "https://github.com/idrougge/ResourceRewriterForXcode.git", branch: "main"), | ||
] | ||
``` | ||
|
||
## Usage | ||
|
||
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. | ||
|
||
![Project menu](https://github.com/idrougge/ResourceRewriterForXcode/assets/17124673/604c9023-a9e4-4bb3-8c0e-4af256feb159) | ||
|
||
## Cleanup | ||
|
||
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. | ||
|
||
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. | ||
|
||
After you are done, you are free to remove this dependency again, possibly introducing a linter rule forbidding calls to string-based image inits. | ||
|
||
## Limitations | ||
|
||
* Short-hand calls such as `image = .init(named: "Something")` aren't handled. | ||
* Any image name built with string interpolation or concatenation is untouched as those must be resolved at run-time. | ||
* 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. | ||
* 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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,194 @@ | ||
// | ||
// RewriteTool.swift | ||
// | ||
// | ||
// Created by Iggy Drougge on 2023-10-16. | ||
// | ||
|
||
import ArgumentParser | ||
import SwiftSyntax | ||
import SwiftParser | ||
import Foundation | ||
|
||
@main | ||
struct RewriteTool: ParsableCommand { | ||
@Argument var files: [String] = [] | ||
|
||
mutating func run() throws { | ||
for file in files { | ||
let resource = URL(filePath: file) | ||
let contents = try String(contentsOf: resource) | ||
let sources = Parser.parse(source: contents) | ||
let converted = RewriteImageLiteral().visit(sources) | ||
try converted.description.write(to: resource, atomically: true, encoding: .utf8) | ||
} | ||
} | ||
} | ||
|
||
class RewriteImageLiteral: SyntaxRewriter { | ||
override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax { | ||
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self) | ||
else { | ||
return super.visit(node) | ||
} | ||
switch calledExpression.baseName.tokenKind { | ||
case .identifier("UIImage"): return rewriteUIKitImage(node) | ||
case .identifier("Image"): return rewriteSwiftUIImage(node) | ||
case _: return super.visit(node) | ||
} | ||
} | ||
|
||
// Since `UIImage(named:)` returns an optional, and `UIImage(resource:)` does not, we need to remove the trailing question mark. | ||
override func visit(_ node: OptionalChainingExprSyntax) -> ExprSyntax { | ||
guard let expression = node.expression.as(FunctionCallExprSyntax.self), | ||
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self), | ||
case .identifier("UIImage") = calledExpression.baseName.tokenKind | ||
else { | ||
return super.visit(node) | ||
} | ||
return rewriteUIKitImage(expression) | ||
} | ||
|
||
// Since `UIImage(named:)` returns an optional, and `UIImage(resource:)` does not, we need to remove force unwrap exclamation marks. | ||
override func visit(_ node: ForceUnwrapExprSyntax) -> ExprSyntax { | ||
guard let expression = node.expression.as(FunctionCallExprSyntax.self), | ||
let calledExpression = expression.calledExpression.as(DeclReferenceExprSyntax.self), | ||
case .identifier("UIImage") = calledExpression.baseName.tokenKind | ||
else { | ||
return super.visit(node) | ||
} | ||
return rewriteUIKitImage(expression) | ||
} | ||
|
||
private func rewriteUIKitImage(_ node: FunctionCallExprSyntax) -> ExprSyntax { | ||
guard let argument = node.arguments.first, | ||
argument.label?.text == "named", | ||
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self), | ||
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed. | ||
!value.isEmpty | ||
else { | ||
return super.visit(node) | ||
} | ||
|
||
var node = node | ||
|
||
let resourceName = normaliseLiteralName(value) | ||
|
||
let expression = MemberAccessExprSyntax( | ||
period: .periodToken(), | ||
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName)) | ||
) | ||
|
||
let newArgument = LabeledExprSyntax( | ||
label: .identifier("resource"), | ||
colon: .colonToken(trailingTrivia: .space), | ||
expression: expression | ||
) | ||
|
||
node.arguments = LabeledExprListSyntax([newArgument]) | ||
|
||
return super.visit(node) | ||
} | ||
|
||
private func rewriteSwiftUIImage(_ node: FunctionCallExprSyntax) -> ExprSyntax { | ||
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self), | ||
case .identifier("Image") = calledExpression.baseName.tokenKind, | ||
let argument = node.arguments.first, | ||
argument.label == .none, | ||
let stringLiteralExpression = argument.expression.as(StringLiteralExprSyntax.self), | ||
let value = stringLiteralExpression.representedLiteralValue, // String interpolation is not allowed. | ||
!value.isEmpty | ||
else { return super.visit(node) } | ||
|
||
var node = node | ||
|
||
let resourceName = normaliseLiteralName(value) | ||
|
||
let expression = MemberAccessExprSyntax( | ||
period: .periodToken(), | ||
declName: DeclReferenceExprSyntax(baseName: .identifier(resourceName)) | ||
) | ||
|
||
let newArgument = LabeledExprSyntax( | ||
label: .none, | ||
colon: .none, | ||
expression: expression | ||
) | ||
|
||
node.arguments = LabeledExprListSyntax([newArgument]) | ||
|
||
return super.visit(node) | ||
} | ||
} | ||
|
||
private let separators = CharacterSet(charactersIn: " _-") | ||
|
||
private func normaliseLiteralName(_ name: String) -> String { | ||
let (path, name) = extractPathComponents(from: name) | ||
|
||
let components = name.components(separatedBy: separators) | ||
|
||
guard let head = components.first.map(lowercaseFirst) | ||
else { return String() } | ||
|
||
let tail = components | ||
.dropFirst() | ||
.map(uppercaseFirst) | ||
.joined() | ||
|
||
var resourceName = head + tail | ||
|
||
if resourceName.hasSuffix("Image") || resourceName.hasSuffix("Color") { | ||
resourceName.removeLast(5) | ||
} | ||
|
||
if resourceName.first!.isNumber, resourceName.first!.isASCII { | ||
resourceName = "_" + resourceName | ||
} | ||
|
||
return path + resourceName | ||
} | ||
|
||
private func extractPathComponents(from name: String) -> (path: String, name: String) { | ||
// If literal contains a slash, it maps to a child type of `ImageResource`: | ||
// "Images/abc_def" → ".Images.abcDef" | ||
var pathComponents = name.components(separatedBy: "/") | ||
let name = pathComponents.last ?? name | ||
pathComponents = pathComponents.dropLast().map(uppercaseFirst(in:)) | ||
if !pathComponents.isEmpty { | ||
pathComponents.append("") // Add empty portion for trailing dot when joining. | ||
} | ||
let path = pathComponents.joined(separator: ".") | ||
|
||
return (path, name) | ||
} | ||
|
||
private func lowercaseFirst(in string: some StringProtocol) -> any StringProtocol { | ||
// If first letter is lower-case or non-alphabetic, return string as is. | ||
guard let first = string.first, | ||
first.isUppercase | ||
else { return string } | ||
// If the entire string is uppercase, just lowercase it all. | ||
if string.allSatisfy(\.isUppercase) { | ||
return string.lowercased() | ||
} | ||
// Split string where lower case begins. | ||
let tail = string.drop(while: \.isUppercase) | ||
// If only initial letter is uppercase, lower case it and return. "Abc" → "abc" | ||
if string.index(after: string.startIndex) == tail.startIndex { | ||
return first.lowercased() + string.dropFirst() | ||
} | ||
// If tail is not empty, string consists of a sequence of uppercase characters | ||
// followed by one or several lowercase characters. Lowercase all but the last | ||
// uppercase character, concatenating it with the lowercase ones. "ABcd" → "aBcd" | ||
if tail.startIndex != string.endIndex { | ||
return string[..<tail.startIndex].dropLast().lowercased() + string[..<tail.startIndex].suffix(1) + tail | ||
} | ||
// Otherwise, just lowercase initial letter. | ||
return first.lowercased() + string.dropFirst() | ||
} | ||
|
||
private func uppercaseFirst(in string: some StringProtocol) -> String { | ||
guard let first = string.first else { return String() } | ||
return first.uppercased() + string.dropFirst() | ||
} |
Oops, something went wrong.