Skip to content
Merged
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
9 changes: 7 additions & 2 deletions Sources/FengNiaoKit/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,19 @@ 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`.
/// Example: "ic_flag" -> "ACImageNameIcFlag"
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:
Expand All @@ -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
Expand Down
67 changes: 52 additions & 15 deletions Sources/FengNiaoKit/FengNiao.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -235,6 +227,51 @@ public struct FengNiao {
}
return usedMemberAccessNames(at: projectPath)
}

func isUsedByGeneratedSymbol(_ resourcePath: String, memberAccessUsedNames: Set<String>) -> 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<String> {
let path = Path(resourcePath)
var result: Set<String> = [path.lastComponentWithoutExtension.generatedAssetSymbolKey]

let components = generatedAssetCatalogSymbolComponents(for: resourcePath)
if !components.isEmpty {
result.insert(".\(components.joined(separator: "."))")
}

return result
}

func generatedObjectiveCAssetSymbolKeys(for resourcePath: String) -> Set<String> {
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<String> {
guard let subPaths = try? path.children() else {
Expand Down
49 changes: 48 additions & 1 deletion Sources/FengNiaoKit/FileSearchRule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,57 @@ 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_])(ImageResource|UIImage|UIColor|NSImage|NSColor|Image|Color)?\s*\.\s*([A-Za-z0-9_]+)"#
var excludedRanges: [NSRange] = []

let nestedPattern = #"(?<![A-Za-z0-9_])(?:UIImage|UIColor|NSImage|NSColor|Image|Color)\s*\(\s*\.([A-Za-z0-9_]+(?:\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 identifierRange = match.range(at: 1)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
.replacingOccurrences(of: #"\s*\.\s*"#, with: ".", options: .regularExpression)
result.insert(".\(identifier)")
excludedRanges.append(match.range)
}

let imageResourcePattern = #"(?<![A-Za-z0-9_])ImageResource\s*\.\s*([A-Za-z0-9_]+(?:\s*\.\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 identifierRange = match.range(at: 1)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
.replacingOccurrences(of: #"\s*\.\s*"#, with: ".", options: .regularExpression)
result.insert(".\(identifier)")
excludedRanges.append(match.range)
}

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SwiftMemberAccessSearchRule currently extracts full nested symbols only for Type( .a.b.c ) and ImageResource.a.b.c. If nested symbols are used directly via type inference (e.g. let r: ImageResource = .Icons.Settings.logo or passed as resource: .Icons.Settings.logo), the fallback regex will only capture the first component (.Icons) and miss the full chain, which can cause used assets to be reported as unused. Consider adding a dedicated pattern that captures dot-prefixed chains (e.g. .A.B.c) regardless of surrounding call syntax, while keeping the negative-lookbehind to avoid regular property access.

Suggested change
let dotPrefixedNestedPattern = #"(?<![A-Za-z0-9_])\.\s*([A-Za-z0-9_]+(?:\s*\.\s*[A-Za-z0-9_]+)+)"#
let dotPrefixedNestedReg = try! NSRegularExpression(pattern: dotPrefixedNestedPattern, options: [])
let dotPrefixedNestedMatches = dotPrefixedNestedReg.matches(in: content, options: [], range: content.fullRange)
for match in dotPrefixedNestedMatches {
let identifierRange = match.range(at: 1)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
.replacingOccurrences(of: #"\s*\.\s*"#, with: ".", options: .regularExpression)
result.insert(".\(identifier)")
excludedRanges.append(match.range)
}

Copilot uses AI. Check for mistakes.
// Catches dot-prefixed nested chains like `.Icons.Settings.logo` regardless of surrounding
// call syntax, so type-inference forms (`let r: ImageResource = .A.B.c`,
// `UIImage(resource: .A.B.c)`) are not split into single-component leaves by the fallback.
let dotPrefixedNestedPattern = #"(?<![A-Za-z0-9_])\.\s*([A-Za-z0-9_]+(?:\s*\.\s*[A-Za-z0-9_]+)+)"#
let dotPrefixedNestedReg = try! NSRegularExpression(pattern: dotPrefixedNestedPattern, options: [])
let dotPrefixedNestedMatches = dotPrefixedNestedReg.matches(in: content, options: [], range: content.fullRange)
for match in dotPrefixedNestedMatches {
if excludedRanges.contains(where: { NSLocationInRange(match.range.location, $0) }) {
continue
}
let identifierRange = match.range(at: 1)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
.replacingOccurrences(of: #"\s*\.\s*"#, with: ".", options: .regularExpression)
result.insert(".\(identifier)")
excludedRanges.append(match.range)
}

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 {
if excludedRanges.contains(where: { NSLocationInRange(match.range.location, $0) }) {
continue
}
let identifierRange = match.range(at: 2)
guard identifierRange.location != NSNotFound else { continue }
let identifier = nsstring.substring(with: identifierRange)
Expand Down
15 changes: 15 additions & 0 deletions Tests/FengNiaoKitTests/GeneratedAssetSymbolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,19 @@ struct GeneratedAssetSymbolTests {
let expected: Set<String> = ["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<String> = ["unused.imageset"]
#expect(fileNames == expected)
}
}
43 changes: 43 additions & 0 deletions Tests/FengNiaoKitTests/SearchRuleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = [
".Icons.Settings.logo",
".Symbols.plug",
".emptyIcon",
]
#expect(result == expected)
}
Comment on lines +139 to +154
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new nested-symbol search test only covers Image(.Icons.Settings.logo) and ImageResource.Symbols.plug/Image(ImageResource.emptyIcon). To prevent regressions for the common type-inference form, consider adding cases like let r: ImageResource = .Icons.Settings.logo and/or UIImage(resource: .Icons.Settings.logo) (or the equivalent API used by your generated symbols). These cases currently aren’t exercised and are where the regex can easily fall back to capturing only the first component.

Copilot uses AI. Check for mistakes.

@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()
Expand Down
18 changes: 18 additions & 0 deletions Tests/FengNiaoKitTests/StringExtensionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "logo.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "plug.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "empty.icon.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "unused.png",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
11 changes: 11 additions & 0 deletions Tests/Fixtures/GeneratedAssetCatalogSymbol/ViewController.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading