diff --git a/Sources/FengNiaoKit/Extensions.swift b/Sources/FengNiaoKit/Extensions.swift index 8ce8e9e..b83ff04 100644 --- a/Sources/FengNiaoKit/Extensions.swift +++ b/Sources/FengNiaoKit/Extensions.swift @@ -59,7 +59,7 @@ extension String { /// Convert resource name (snake/kebab case) to generated Swift asset symbol such as `.icChatWhite`. var generatedAssetSymbolKey: String { - return convertToCamelCase(prefix: ".", uppercaseFirst: false) + return ".\(generatedAssetSymbolPathComponent)" } /// Convert resource name (snake/kebab case) to generated Objective-C asset symbol such as `ACImageNameIcFlag`. @@ -67,6 +67,11 @@ extension String { var objcGeneratedAssetSymbolKey: String { return convertToCamelCase(prefix: "ACImageName", uppercaseFirst: true) } + + /// Convert a resource name to a single generated Swift member-access component. + var generatedAssetSymbolPathComponent: String { + return convertToCamelCase(prefix: "", uppercaseFirst: false) + } /// Convert resource name (snake/kebab case) to camel case with optional prefix. /// - Parameters: @@ -78,7 +83,7 @@ extension String { var shouldUpperNext = uppercaseFirst for character in self { switch character { - case "-", "_", " ": + case "-", "_", " ", ".": shouldUpperNext = true case let c where c.isNumber: shouldUpperNext = true diff --git a/Sources/FengNiaoKit/FengNiao.swift b/Sources/FengNiaoKit/FengNiao.swift index abd3680..622efa9 100644 --- a/Sources/FengNiaoKit/FengNiao.swift +++ b/Sources/FengNiaoKit/FengNiao.swift @@ -126,24 +126,16 @@ public struct FengNiao { let usedNames = allUsedStringNames() let memberAccessUsedNames = allUsedMemberAccessNames() - // Generated asset symbols are an additional conservative usage signal. - // Swift uses `.icFlag`, while Objective-C image symbols use `ACImageNameIcFlag`. let resourcesUsedByGeneratedSymbols = Set( - allResources.keys.filter { resourceKey in - let swiftSymbolKey = resourceKey.generatedAssetSymbolKey - if memberAccessUsedNames.contains(swiftSymbolKey) { - return true - } - let objcImageSymbolKey = resourceKey.objcGeneratedAssetSymbolKey - if memberAccessUsedNames.contains(objcImageSymbolKey) { - return true - } - return false - } + allResources.values + .flatMap { $0 } + .filter { isUsedByGeneratedSymbol($0, memberAccessUsedNames: memberAccessUsedNames) } ) - let combinedUsedNames = usedNames.union(resourcesUsedByGeneratedSymbols) + let unusedPaths = FengNiao.filterUnused(from: allResources, used: usedNames) - return FengNiao.filterUnused(from: allResources, used: combinedUsedNames).map( FileInfo.init ) + return unusedPaths + .subtracting(resourcesUsedByGeneratedSymbols) + .map(FileInfo.init) } // Return a failed list of deleting @@ -235,6 +227,51 @@ public struct FengNiao { } return usedMemberAccessNames(at: projectPath) } + + func isUsedByGeneratedSymbol(_ resourcePath: String, memberAccessUsedNames: Set) -> Bool { + let swiftSymbolKeys = generatedSwiftAssetSymbolKeys(for: resourcePath) + if !swiftSymbolKeys.isDisjoint(with: memberAccessUsedNames) { + return true + } + + let objcSymbolKeys = generatedObjectiveCAssetSymbolKeys(for: resourcePath) + if !objcSymbolKeys.isDisjoint(with: memberAccessUsedNames) { + return true + } + + return false + } + + func generatedSwiftAssetSymbolKeys(for resourcePath: String) -> Set { + let path = Path(resourcePath) + var result: Set = [path.lastComponentWithoutExtension.generatedAssetSymbolKey] + + let components = generatedAssetCatalogSymbolComponents(for: resourcePath) + if !components.isEmpty { + result.insert(".\(components.joined(separator: "."))") + } + + return result + } + + func generatedObjectiveCAssetSymbolKeys(for resourcePath: String) -> Set { + let path = Path(resourcePath) + return [path.lastComponentWithoutExtension.objcGeneratedAssetSymbolKey] + } + + func generatedAssetCatalogSymbolComponents(for resourcePath: String) -> [String] { + let pathComponents = resourcePath.split(separator: "/").map(String.init) + guard let assetCatalogIndex = pathComponents.lastIndex(where: { $0.hasSuffix(".xcassets") }) else { + return [] + } + guard assetCatalogIndex < pathComponents.count - 1 else { + return [] + } + + return pathComponents[(assetCatalogIndex + 1)...].map { + Path($0).lastComponentWithoutExtension.generatedAssetSymbolPathComponent + } + } func usedStringNames(at path: Path) -> Set { guard let subPaths = try? path.children() else { diff --git a/Sources/FengNiaoKit/FileSearchRule.swift b/Sources/FengNiaoKit/FileSearchRule.swift index 9b85b85..4d7668c 100644 --- a/Sources/FengNiaoKit/FileSearchRule.swift +++ b/Sources/FengNiaoKit/FileSearchRule.swift @@ -80,10 +80,57 @@ struct SwiftMemberAccessSearchRule: FileSearchRule { func search(in content: String) -> Set { let nsstring = NSString(string: content) var result = Set() - let pattern = #"(? = ["ic_unused.png"] #expect(fileNames == expected) } + + @Test("treats generated asset catalog symbols as usage in nested folders") + func treatsGeneratedAssetCatalogSymbolsAsUsageInNestedFolders() throws { + let project = fixtures + "GeneratedAssetCatalogSymbol" + let fengniao = FengNiao( + projectPath: project.string, + excludedPaths: [], + resourceExtensions: ["imageset"], + searchInFileExtensions: ["swift"] + ) + let result = try fengniao.unusedFiles() + let fileNames = Set(result.map { $0.fileName }) + let expected: Set = ["unused.imageset"] + #expect(fileNames == expected) + } } diff --git a/Tests/FengNiaoKitTests/SearchRuleTests.swift b/Tests/FengNiaoKitTests/SearchRuleTests.swift index c8ec6e7..de650f5 100644 --- a/Tests/FengNiaoKitTests/SearchRuleTests.swift +++ b/Tests/FengNiaoKitTests/SearchRuleTests.swift @@ -136,6 +136,49 @@ struct SearchRuleTests { #expect(result.isEmpty) } + @Test("Swift member access rule applies to nested asset catalog symbols") + func swiftMemberAccessRuleAppliesToNestedAssetCatalogSymbols() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + let image = Image(.Icons.Settings.logo) + let resource = ImageResource.Symbols.plug + let direct = Image(ImageResource.emptyIcon) + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".Icons.Settings.logo", + ".Symbols.plug", + ".emptyIcon", + ] + #expect(result == expected) + } + + @Test("Swift member access rule applies to nested symbols via type inference") + func swiftMemberAccessRuleAppliesToNestedSymbolsViaTypeInference() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + let r: ImageResource = .Icons.Settings.logo + let img = UIImage(resource: .Symbols.plug) + let view = ContentView(image: .Icons.Settings.logo, fallback: .emptyIcon) + """ + let result = searcher.search(in: content) + #expect(result.contains(".Icons.Settings.logo")) + #expect(result.contains(".Symbols.plug")) + #expect(result.contains(".emptyIcon")) + } + + @Test("Swift member access rule ignores custom type prefixes that only end with asset type names") + func swiftMemberAccessRuleIgnoresCustomTypePrefixesThatOnlyEndWithAssetTypeNames() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + let a = SomeImageResource.icon + let b = FooImage.value + let c = CustomNSImage.tintColor + """ + let result = searcher.search(in: content) + #expect(result.isEmpty) + } + @Test("Objective-C member access rule applies to generated symbols") func objcMemberAccessRuleAppliesToGeneratedSymbols() { let searcher = ObjCMemberAccessSearchRule() diff --git a/Tests/FengNiaoKitTests/StringExtensionsTests.swift b/Tests/FengNiaoKitTests/StringExtensionsTests.swift index 6097961..34cd196 100644 --- a/Tests/FengNiaoKitTests/StringExtensionsTests.swift +++ b/Tests/FengNiaoKitTests/StringExtensionsTests.swift @@ -58,4 +58,22 @@ struct StringExtensionsTests { ] #expect(images.map { $0.objcGeneratedAssetSymbolKey } == expected) } + + @Test("generated asset symbol keys treat dots as word boundaries") + func generatedAssetSymbolKeysTreatDotsAsWordBoundaries() { + let images = [ + "empty.icon", + "navigation-menu.row.icon", + ] + let swiftExpected = [ + ".emptyIcon", + ".navigationMenuRowIcon", + ] + let objcExpected = [ + "ACImageNameEmptyIcon", + "ACImageNameNavigationMenuRowIcon", + ] + #expect(images.map { $0.generatedAssetSymbolKey } == swiftExpected) + #expect(images.map { $0.objcGeneratedAssetSymbolKey } == objcExpected) + } } diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Contents.json b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Icons/Settings/logo.imageset/Contents.json b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Icons/Settings/logo.imageset/Contents.json new file mode 100644 index 0000000..7c58c47 --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Icons/Settings/logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "logo.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Symbols/plug.imageset/Contents.json b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Symbols/plug.imageset/Contents.json new file mode 100644 index 0000000..7356dce --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/Symbols/plug.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "plug.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/empty.icon.imageset/Contents.json b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/empty.icon.imageset/Contents.json new file mode 100644 index 0000000..247974e --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/empty.icon.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "empty.icon.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/unused.imageset/Contents.json b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/unused.imageset/Contents.json new file mode 100644 index 0000000..2619853 --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/Assets.xcassets/unused.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "unused.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Tests/Fixtures/GeneratedAssetCatalogSymbol/ViewController.swift b/Tests/Fixtures/GeneratedAssetCatalogSymbol/ViewController.swift new file mode 100644 index 0000000..869515d --- /dev/null +++ b/Tests/Fixtures/GeneratedAssetCatalogSymbol/ViewController.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct SampleView: View { + var body: some View { + VStack { + Image(.Icons.Settings.logo) + Image(ImageResource.emptyIcon) + Image(ImageResource.Symbols.plug) + } + } +}