From 338c94266952c64ea286768d98c37f6e32d54081 Mon Sep 17 00:00:00 2001 From: Adam Law Date: Fri, 20 Mar 2026 20:04:21 +0000 Subject: [PATCH] Fix nested member access and ImageResource pattern detection Fixes incomplete detection of Xcode-generated asset symbols for: 1. Folder-organized assets (nested member access) 2. ImageResource dot notation references 3. Asset names containing dots **Problems Fixed:** 1. **Nested Member Access (Issue #83 follow-up)** PR #84 added support for simple patterns like `.icFlag`, but didn't handle nested patterns like `Image(.Icons.Settings.logo)` that Xcode generates for folder-structured asset catalogs. 2. **ImageResource Pattern** `ImageResource.homeIcon` and `ImageResource.Icons.Settings.logo` patterns were not detected at all. 3. **Dots in Asset Names** Asset names like "empty.icon" or "navigation-menu.row.icon" were incorrectly converted to `.empty.icon` instead of `.emptyIcon`. The generatedAssetSymbolKey function didn't treat dots as word boundaries like Xcode does. **Solution:** 1. Added regex pattern for nested member access inside Image() calls 2. Added regex pattern for ImageResource dot notation 3. Track ranges to exclude nested patterns from simple matching 4. Updated suffix matching logic for namespaced symbols 5. Fixed generatedAssetSymbolKey to treat dots as word boundaries 6. Added comprehensive tests for all patterns **Testing:** - New tests cover: deeply nested patterns (4+ levels), ImageResource notation, whitespace handling, dots in filenames, and mixed usage - Verified on real iOS project with folder-organized assets **Impact:** This fix makes FengNiao work correctly with modern Xcode projects that organize assets in folders and use type-safe asset symbols. --- Sources/FengNiaoKit/Extensions.swift | 3 +- Sources/FengNiaoKit/FengNiao.swift | 32 ++++- Sources/FengNiaoKit/FileSearchRule.swift | 58 +++++++- Tests/FengNiaoKitTests/SearchRuleTests.swift | 127 ++++++++++++++++++ .../StringExtensionsTests.swift | 17 +++ 5 files changed, 231 insertions(+), 6 deletions(-) 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) + } }