diff --git a/Package.swift b/Package.swift index a854e46..fe7fb4d 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), @@ -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: [ diff --git a/Plugins/Rewrite colour resource strings/plugin.swift b/Plugins/Rewrite colour resource strings/plugin.swift new file mode 100644 index 0000000..855509d --- /dev/null +++ b/Plugins/Rewrite colour resource strings/plugin.swift @@ -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))") + } + } +} diff --git a/Plugins/plugin.swift b/Plugins/Rewrite image resource strings/plugin.swift similarity index 92% rename from Plugins/plugin.swift rename to Plugins/Rewrite image resource strings/plugin.swift index 94b918b..c1db95d 100644 --- a/Plugins/plugin.swift +++ b/Plugins/Rewrite image resource strings/plugin.swift @@ -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() @@ -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() diff --git a/README.md b/README.md index 699c0a4..205fd79 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) @@ -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. diff --git a/Sources/ResourceRewriterForXcode.swift b/Sources/ResourceRewriterForXcode.swift index cfc18f3..4a84330 100644 --- a/Sources/ResourceRewriterForXcode.swift +++ b/Sources/ResourceRewriterForXcode.swift @@ -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) } } @@ -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 { @@ -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) { diff --git a/Tests/ColourTests.swift b/Tests/ColourTests.swift new file mode 100644 index 0000000..ab66b6b --- /dev/null +++ b/Tests/ColourTests.swift @@ -0,0 +1,162 @@ +// +// ColourTests.swift +// +// +// Created by Iggy Drougge on 2024-01-22. +// + +import XCTest +import SwiftParser +@testable import ResourceRewriterForXcode + +final class ColourTests: XCTestCase { + + let rewriter = RewriteColourLiteral() + + func testUIKitColour() throws { + let input = Parser.parse(source: #"UIColor(named: "abc")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"UIColor(resource: .abc)"# + ) + } + + func testUIKitColourWithExtraArguments() throws { + let input = Parser.parse(source: #"UIColor(named: "abc", in: .module, with: nil)"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"UIColor(resource: .abc)"# + ) + } + + func testUIKitColourWithOptionalChaining() throws { + let input = Parser.parse(source: #"UIColor(named: "abc")?.cgColor"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"UIColor(resource: .abc).cgColor"#, + "Trailing question mark should be removed from chained expression." + ) + } + + func testUIKitColourWithForceUnwrap() throws { + let input = Parser.parse(source: #"UIColor(named: "abc")!.cgColor"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"UIColor(resource: .abc).cgColor"#, + "Trailing question mark should be removed from chained expression." + ) + } + + func testSwiftUIColor() throws { + let input = Parser.parse(source: #"Color("abc")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abc)"# + ) + } + + func testLiteralWithTrailingColorInName() throws { + let input = Parser.parse(source: #"Color("abcColor")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abc)"#, + "`Color` should be stripped from end of colour resource names." + ) + } + + func testLiteralWithSpacesInName() throws { + let input = Parser.parse(source: #"Color("abc def")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abcDef)"#, + "Colour names with spaces should be joined into camel cased identifiers." + ) + } + + func testLiteralWithUnderscoresInName() throws { + let input = Parser.parse(source: #"Color("abc_def")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abcDef)"#, + "Colour names with spaces should be joined into camel cased identifiers." + ) + } + + func testLiteralWithHyphensInName() throws { + let input = Parser.parse(source: #"Color("abc-def")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abcDef)"#, + "Colour names with hyphens should be joined into camel cased identifiers." + ) + } + + func testLiteralWithEmptyName() throws { + let input = Parser.parse(source: #"Color("")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color("")"#, + "Empty Colour names should not be altered as they cannot map to any resource." + ) + } + + func testLiteralWithCapitalLeadingLetter() throws { + let input = Parser.parse(source: #"Color("AbcColor")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.abc)"#, + "Leading capital letter should be lowercased." + ) + } + + func testLiteralWithCapitalLeadingLettersAndUnderscore() throws { + let input = Parser.parse(source: #"Color("TEMP_abc_TEMP")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.tempAbcTEMP)"#, + "Colour names with uppercase leading letters separated by non-letters should have the leading run of characters lowercased." + ) + } + + func testLiteralWithCapitalLeadingLettersAndSpaces() throws { + let input = Parser.parse(source: #"Color("ÅÄÖ abc TEMP")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.åäöAbcTEMP)"#, + "Colour names with uppercase leading letters separated by non-letters should have the leading run of characters lowercased." + ) + } + + func testLiteralWithCapitalLeadingLetters() throws { + let input = Parser.parse(source: #"Color("TEMPabcTEMP")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(.temPabcTEMP)"#, + "Colour names with uppercase leading letters followed by lowercase letters should be lowercased, with the uppercase one anterior to the first lowercase one preserving upper case." + ) + } + + func testLiteralWithLeadingNumberInName() throws { + let input = Parser.parse(source: #"Color("123 abc")"#) + let output = rewriter.visit(input) + XCTAssertEqual( + output.description, + #"Color(._123Abc)"#, + "Colour names with leading number should be preceded by underscore." + ) + } +} diff --git a/Tests/ResourceRewriterForXcodeTests.swift b/Tests/ImageTests.swift similarity index 98% rename from Tests/ResourceRewriterForXcodeTests.swift rename to Tests/ImageTests.swift index cad9376..b0f1b9a 100644 --- a/Tests/ResourceRewriterForXcodeTests.swift +++ b/Tests/ImageTests.swift @@ -1,5 +1,5 @@ // -// ResourceRewriterForXcodeTests.swift +// ImageTests.swift // // // Created by Iggy Drougge on 2023-10-17. @@ -9,7 +9,7 @@ import XCTest import SwiftParser @testable import ResourceRewriterForXcode -final class ResourceRewriterForXcodeTests: XCTestCase { +final class ImageTests: XCTestCase { let rewriter = RewriteImageLiteral()