diff --git a/Sources/FengNiaoKit/Extensions.swift b/Sources/FengNiaoKit/Extensions.swift index 0a5da1c..21055ce 100644 --- a/Sources/FengNiaoKit/Extensions.swift +++ b/Sources/FengNiaoKit/Extensions.swift @@ -58,13 +58,14 @@ extension String { } /// Convert resource name (snake/kebab case) to generated Swift asset symbol such as `.icChatWhite`. + /// Xcode treats hyphens, underscores, spaces, and dots as word boundaries that trigger camelCase. var generatedAssetSymbolKey: String { if isEmpty { return "." } var ret = "." var shouldUpperNext = false 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 b1a63fc..c5e7321 100644 --- a/Sources/FengNiaoKit/FengNiao.swift +++ b/Sources/FengNiaoKit/FengNiao.swift @@ -125,8 +125,38 @@ public struct FengNiao { let allResources = allResourceFiles() let usedNames = allUsedStringNames() let memberAccessUsedNames = allUsedMemberAccessNames() + + // Match resources against member access symbols + // Handle both simple symbols like ".icFlag" and nested like ".Icons.Settings.logo" let resourcesUsedByGeneratedSymbols = Set( - allResources.keys.filter { memberAccessUsedNames.contains($0.generatedAssetSymbolKey) } + allResources.keys.filter { resourceName in + let resourceSymbol = resourceName.generatedAssetSymbolKey + + // Check for exact match first + if memberAccessUsedNames.contains(resourceSymbol) { + return true + } + + // For nested symbols, check if the resource symbol appears as a component + // e.g., resource "logo" with symbol ".logo" should match member symbol ".Icons.Settings.logo" + // Both resourceSymbol and memberSymbol start with ".", so we can check suffix matching + for memberSymbol in memberAccessUsedNames { + // If memberSymbol ends with resourceSymbol AND + // either they're equal OR there's a dot before the match + // Example: ".Icons.Settings.logo".hasSuffix(".logo") is true + // and it's longer than just ".logo", so it's a valid namespace match + if memberSymbol.hasSuffix(resourceSymbol) && memberSymbol != resourceSymbol { + // Check that it's a proper component boundary (there must be a '.' before the resource symbol) + // Since resourceSymbol starts with '.', we need member to be ".X.Y.resourceSymbol" + // This is true if member has more than just resourceSymbol + if memberSymbol.count > resourceSymbol.count { + return true + } + } + } + + return false + } ) let combinedUsedNames = usedNames.union(resourcesUsedByGeneratedSymbols) diff --git a/Sources/FengNiaoKit/FileSearchRule.swift b/Sources/FengNiaoKit/FileSearchRule.swift index d2f0dac..0650d35 100644 --- a/Sources/FengNiaoKit/FileSearchRule.swift +++ b/Sources/FengNiaoKit/FileSearchRule.swift @@ -76,19 +76,69 @@ struct SwiftImageSearchRule: RegPatternSearchRule { } /// Search for member access patterns like `.icFlag` or `UIImage.icFlag` that Xcode generates for assets. +/// Also handles nested member access patterns like `Image(.Icons.Settings.logo)` that Xcode +/// generates for asset catalog folders. struct SwiftMemberAccessSearchRule: FileSearchRule { func search(in content: String) -> Set { let nsstring = NSString(string: content) var result = Set() - let pattern = #"(? = [ + ".Icons.Navigation.menuIcon", + ".Images.Animals.dogFace", + ".Background.landscapeCard", + ".Theme.Primary.background" + ] + #expect(result == expected) + } + + @Test("Swift member access rule applies to nested patterns with whitespace") + func swiftMemberAccessRuleAppliesToNestedPatternsWithWhitespace() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + Image( .Icons.Navigation.menuIcon ) + let icon = Image( + .Images.Animals.dogFace + ) + UIImage( .Background.landscapeCard ) + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".Icons.Navigation.menuIcon", + ".Images.Animals.dogFace", + ".Background.landscapeCard" + ] + #expect(result == expected) + } + + @Test("Swift member access rule handles both simple and nested patterns together") + func swiftMemberAccessRuleHandlesBothSimpleAndNestedPatternsTogether() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + let flag = UIImage.icFlag + let device = Image(.Icons.Navigation.menuIcon) + let highlighted: UIImage = .icFlagHighlighted + let nested = Image(.Images.Animals.dogFace) + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".icFlag", + ".Icons.Navigation.menuIcon", + ".icFlagHighlighted", + ".Images.Animals.dogFace" + ] + #expect(result == expected) + } + + @Test("Swift member access rule handles deeply nested patterns") + func swiftMemberAccessRuleHandlesDeeplyNestedPatterns() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + Image(.Level1.Level2.Level3.Level4.deepAsset) + Image(.A.B.C.veryNestedIcon) + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".Level1.Level2.Level3.Level4.deepAsset", + ".A.B.C.veryNestedIcon" + ] + #expect(result == expected) + } + + @Test("Swift member access rule handles Image and ImageResource types") + func swiftMemberAccessRuleHandlesImageAndImageResourceTypes() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + Image(.myIcon) + UIImage(.anotherIcon) + NSImage(.macIcon) + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".myIcon", + ".anotherIcon", + ".macIcon" + ] + #expect(result == expected) + } + + @Test("Swift member access rule handles ImageResource dot notation") + func swiftMemberAccessRuleHandlesImageResourceDotNotation() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + Image(ImageResource.homeIcon) + let icon = ImageResource.Icons.Settings.logo + ImageResource .Symbols.plug + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".homeIcon", + ".Icons.Settings.logo", + ".Symbols.plug" + ] + #expect(result == expected) + } + + @Test("Swift member access rule handles mixed ImageResource and direct patterns") + func swiftMemberAccessRuleHandlesMixedImageResourceAndDirectPatterns() { + let searcher = SwiftMemberAccessSearchRule() + let content = """ + Image(.directIcon) + Image(ImageResource.resourceIcon) + UIImage(.simpleIcon) + let nested = Image(.Icons.Settings.logo) + let resource = ImageResource.Icons.Dashboard.quickAction + """ + let result = searcher.search(in: content) + let expected: Set = [ + ".directIcon", + ".resourceIcon", + ".simpleIcon", + ".Icons.Settings.logo", + ".Icons.Dashboard.quickAction" + ] + #expect(result == expected) + } } diff --git a/Tests/FengNiaoKitTests/StringExtensionsTests.swift b/Tests/FengNiaoKitTests/StringExtensionsTests.swift index c338165..2c43e55 100644 --- a/Tests/FengNiaoKitTests/StringExtensionsTests.swift +++ b/Tests/FengNiaoKitTests/StringExtensionsTests.swift @@ -41,4 +41,21 @@ struct StringExtensionsTests { ] #expect(images.map { $0.generatedAssetSymbolKey } == expected) } + + @Test("generatedAssetSymbolKey treats dots as word boundaries") + func generatedAssetSymbolKeyTreatsDotsAsWordBoundaries() { + let images = [ + "empty.icon", + "navigation-menu.row.icon", + "device.battery.status.disconnected", + "wizard.setup.complete" + ] + let expected = [ + ".emptyIcon", + ".navigationMenuRowIcon", + ".deviceBatteryStatusDisconnected", + ".wizardSetupComplete" + ] + #expect(images.map { $0.generatedAssetSymbolKey } == expected) + } }