Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
idrougge committed Oct 18, 2023
0 parents commit f9cd2b1
Show file tree
Hide file tree
Showing 6 changed files with 525 additions and 0 deletions.
9 changes: 9 additions & 0 deletions .gitignore
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
51 changes: 51 additions & 0 deletions Package.swift
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")
]
)
]
)
52 changes: 52 additions & 0 deletions Plugins/plugin.swift
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))")
}
}
}
47 changes: 47 additions & 0 deletions README.md
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.
194 changes: 194 additions & 0 deletions Sources/ResourceRewriterForXcode.swift
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()
}
Loading

0 comments on commit f9cd2b1

Please sign in to comment.