Skip to content

Commit

Permalink
Merge pull request #2 from idrougge/colour
Browse files Browse the repository at this point in the history
Add support for rewriting colour literals as well
  • Loading branch information
idrougge authored Feb 26, 2024
2 parents f9cd2b1 + 50a6e8d commit fe94bf2
Show file tree
Hide file tree
Showing 7 changed files with 358 additions and 16 deletions.
18 changes: 18 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ let package = Package(
products: [
.plugin(name: "Rewrite image resource strings",
targets: ["Rewrite image resource strings"]),
.plugin(name: "Rewrite colour resource strings",
targets: ["Rewrite colour resource strings"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
Expand Down Expand Up @@ -41,6 +43,22 @@ let package = Package(
.target(name: "ResourceRewriterForXcode"),
]
),
.plugin(
name: "Rewrite colour resource strings",
capability: .command(
intent: .sourceCodeFormatting,
permissions: [
.writeToPackageDirectory(reason:
"""
Your `UIColor(named:)` calls will be rewritten as `UIColor(resource:)` calls.
Please commit before running.
""")
]
),
dependencies: [
.target(name: "ResourceRewriterForXcode"),
]
),
.testTarget(
name: "ResourceRewriterForXcodeTests",
dependencies: [
Expand Down
52 changes: 52 additions & 0 deletions Plugins/Rewrite colour resource strings/plugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// plugin.swift
//
//
// Created by Iggy Drougge on 2024-01-22.
//

import PackagePlugin
import XcodeProjectPlugin
import Foundation

@main
struct ColourPlugin: 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 = CollectionOfOne("colours") + 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 = CollectionOfOne("colours") + 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))")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ struct ResourceRewriterPlugin: CommandPlugin, XcodeCommandPlugin {
let tool = try context.tool(named: "ResourceRewriterForXcode")
let process = Process()
process.executableURL = URL(fileURLWithPath: tool.path.string)
process.arguments = files.map(\.path.string)
process.arguments = CollectionOfOne("images") + files.map(\.path.string)
try process.run()
process.waitUntilExit()

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

Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# Resource Rewriter for Xcode 15+

This plugin lets you automatically rewrite UIKit/SwiftUI image instantations from unreliable string-based inits such as:
This plugin lets you automatically rewrite UIKit/SwiftUI image and colour instantations from unreliable string-based inits such as:
```swift
UIImage(named: "some image")
Image("some image")
UIImage(named: "some icon")
Image("some icon")
UIColor(named: "light blue green")
Color("light blue green")
```
into `ImageResource` literals (as introduced in Xcode 15) such as:
into `ImageResource` and `ColorResource` literals (as introduced in Xcode 15) such as:
```swift
UIImage(resource: .someImage)
Image(.someImage)
UIImage(resource: .someIcon)
Image(.someIcon)
UIColor(resource: .lightBlueGreen)
Color(.lightBlueGreen)
```

## Installation
Expand All @@ -27,7 +31,7 @@ dependencies: [

## 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.
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.

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

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

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.
After you are done, you are free to remove this dependency again, possibly introducing a linter rule forbidding calls to string-based asset 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.
* 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.
* 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.
110 changes: 108 additions & 2 deletions Sources/ResourceRewriterForXcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,24 @@ import Foundation

@main
struct RewriteTool: ParsableCommand {
enum Mode: String, ExpressibleByArgument {
case images, colours
}

@Argument var mode: Mode
@Argument var files: [String] = []

mutating func run() throws {
let rewriter = switch mode {
case .images: RewriteImageLiteral()
case .colours: RewriteColourLiteral()
}

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)
let converted = rewriter.visit(sources)
try converted.description.write(to: resource, atomically: true, encoding: .utf8)
}
}
Expand Down Expand Up @@ -121,6 +131,102 @@ class RewriteImageLiteral: SyntaxRewriter {
}
}

class RewriteColourLiteral: 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("UIColor"): return rewriteUIKitColour(node)
case .identifier("Color"): return rewriteSwiftUIColour(node)
case _: return super.visit(node)
}
}

// Since `UIColor(named:)` returns an optional, and `UIColor(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("UIColor") = calledExpression.baseName.tokenKind
else {
return super.visit(node)
}
return rewriteUIKitColour(expression)
}

// Since `UIColor(named:)` returns an optional, and `UIColor(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("UIColor") = calledExpression.baseName.tokenKind
else {
return super.visit(node)
}
return rewriteUIKitColour(expression)
}

private func rewriteUIKitColour(_ 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 rewriteSwiftUIColour(_ node: FunctionCallExprSyntax) -> ExprSyntax {
guard let calledExpression = node.calledExpression.as(DeclReferenceExprSyntax.self),
case .identifier("Color") = 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 {
Expand All @@ -146,7 +252,7 @@ private func normaliseLiteralName(_ name: String) -> String {
resourceName = "_" + resourceName
}

return path + resourceName
return path + resourceName.decomposedStringWithCanonicalMapping
}

private func extractPathComponents(from name: String) -> (path: String, name: String) {
Expand Down
Loading

0 comments on commit fe94bf2

Please sign in to comment.