Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/FengNiaoKit/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 31 additions & 1 deletion Sources/FengNiaoKit/FengNiao.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
58 changes: 54 additions & 4 deletions Sources/FengNiaoKit/FileSearchRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
let nsstring = NSString(string: content)
var result = Set<String>()
let pattern = #"(?<![A-Za-z0-9_])(UIImage|UIColor|NSImage|NSColor|Image|Color)?\s*\.\s*([A-Za-z0-9_]+)"#
let reg = try! NSRegularExpression(pattern: pattern, options: [])
let matches = reg.matches(in: content, options: [], range: content.fullRange)
for match in matches {
var nestedSymbolRanges: [NSRange] = []

// Pattern 1: Nested member access like `Image(.Icons.Settings.logo)`
// Process this first and track their ranges to exclude them from simple pattern matching
let nestedPattern = #"(?:Image|UIImage|NSImage|Color|UIColor|NSColor)\s*\(\s*\.([A-Za-z0-9_.]+)\s*\)"#
let nestedReg = try! NSRegularExpression(pattern: nestedPattern, options: [])
let nestedMatches = nestedReg.matches(in: content, options: [], range: content.fullRange)
for match in nestedMatches {
let chainRange = match.range(at: 1)
guard chainRange.location != NSNotFound else { continue }
let chain = nsstring.substring(with: chainRange)
result.insert(".\(chain)")

// Track the full range of this nested pattern to exclude from simple matching
nestedSymbolRanges.append(match.range)
}

// Pattern 1b: ImageResource member access like `ImageResource.homeIcon` or `ImageResource.Icons.Settings.logo`
// This is similar to nested but without parentheses
let imageResourcePattern = #"ImageResource\s*\.([A-Za-z0-9_.]+)"#
let imageResourceReg = try! NSRegularExpression(pattern: imageResourcePattern, options: [])
let imageResourceMatches = imageResourceReg.matches(in: content, options: [], range: content.fullRange)
for match in imageResourceMatches {
let chainRange = match.range(at: 1)
guard chainRange.location != NSNotFound else { continue }
let chain = nsstring.substring(with: chainRange)
result.insert(".\(chain)")

// Track the full range to exclude from simple matching
nestedSymbolRanges.append(match.range)
}

// Pattern 2: Simple member access like `UIImage.icFlag` or `.icFlagHighlighted`
// Only match if not inside a nested Image(...) construct
let simplePattern = #"(?<![A-Za-z0-9_])(UIImage|UIColor|NSImage|NSColor|Image|Color)?\s*\.\s*([A-Za-z0-9_]+)"#
let simpleReg = try! NSRegularExpression(pattern: simplePattern, options: [])
let simpleMatches = simpleReg.matches(in: content, options: [], range: content.fullRange)

for match in simpleMatches {
// Skip if this match is inside a nested pattern
let matchRange = match.range
var isInsideNested = false
for nestedRange in nestedSymbolRanges {
if NSLocationInRange(matchRange.location, nestedRange) {
isInsideNested = true
break
}
}

guard !isInsideNested else { continue }

let identifierRange = match.range(at: 2)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
result.insert(".\(identifier)")
}

return result
}
}
Expand Down
127 changes: 127 additions & 0 deletions Tests/FengNiaoKitTests/SearchRuleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,131 @@ struct SearchRuleTests {
let result = searcher.search(in: content)
#expect(result.isEmpty)
}

@Test("Swift member access rule applies to nested member access patterns")
func swiftMemberAccessRuleAppliesToNestedMemberAccessPatterns() {
let searcher = SwiftMemberAccessSearchRule()
let content = """
Image(.Icons.Navigation.menuIcon)
let icon = Image(.Images.Animals.dogFace)
UIImage(.Background.landscapeCard)
Color(.Theme.Primary.background)
"""
let result = searcher.search(in: content)
let expected: Set<String> = [
".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<String> = [
".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<String> = [
".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<String> = [
".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<String> = [
".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<String> = [
".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<String> = [
".directIcon",
".resourceIcon",
".simpleIcon",
".Icons.Settings.logo",
".Icons.Dashboard.quickAction"
]
#expect(result == expected)
}
}
17 changes: 17 additions & 0 deletions Tests/FengNiaoKitTests/StringExtensionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}