Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Find asset catalogs automatically #5

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion AssetChecker.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'AssetChecker'
s.version = '0.2.1'
s.version = '0.3.1'
s.summary = 'Sanitizes your Assets.xcassets files'
s.description = "AssetChecker is a tiny run script that keeps your Assets.xcassets files clean and emits warnings when something is suspicious."
s.homepage = 'https://github.com/freshos/AssetChecker'
Expand Down
213 changes: 123 additions & 90 deletions Classes/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,132 +3,165 @@
import Foundation

// Configure me \o/
var sourcePathOption:String? = nil
var assetCatalogPathOption:String? = nil
let ignoredUnusedNames = [String]()

for (index, arg) in CommandLine.arguments.enumerated() {
switch index {
case 1:
sourcePathOption = arg
case 2:
assetCatalogPathOption = arg
default:
break
}
}

guard let sourcePath = sourcePathOption else {
print("AssetChecker:: error: Source path was missing!")
exit(0)
}

guard let assetCatalogAbsolutePath = assetCatalogPathOption else {
print("AssetChecker:: error: Asset Catalog path was missing!")
exit(0)
}

print("Searching sources in \(sourcePath) for assets in \(assetCatalogAbsolutePath)")
var sourcePathOption:String?
var ignoredUnusedNames = [String]()
var catalogPath: String?

/* Put here the asset generating false positives,
/* Put here the asset generating false positives,
For instance whne you build asset names at runtime
let ignoredUnusedNames = [
"IconArticle",
"IconMedia",
"voteEN",
"voteES",
"voteFR"
]
]
*/


// MARK : - End Of Configurable Section

func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
/// Attempt to fetch source path from run script arguments
// command line arguments passed as "source:/path/to"
struct CommandLineArg {
let arg: String
let value: String?
}

let commandLineArguments = CommandLine.arguments.map { clArg -> CommandLineArg in
let splitArgs = clArg.split(separator: ":")
let value = splitArgs.indices.contains(1) ? String(splitArgs[1]) : nil
return CommandLineArg(arg: String(splitArgs[0]), value: value)
}

for arg in commandLineArguments {
switch arg.arg {
case "source":
if let sourcePath = arg.value, sourcePathOption == nil {
sourcePathOption = sourcePath
}
case "catalog":
if let catelog = arg.value, catalogPath == nil {
catalogPath = catelog
}
case "ignore":
if let ignoreAssetsNames = arg.value, ignoredUnusedNames.isEmpty {
ignoredUnusedNames = ignoreAssetsNames.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
}
default:
break
}
}

guard let sourcePath = sourcePathOption else {
print("AssetChecker:: error: Source path was missing!")
exit(0)
}

private func elementsInEnumerator(_ enumerator: FileManager.DirectoryEnumerator?) -> [String] {
var elements = [String]()
while let e = enumerator?.nextObject() as? String {
elements.append(e)
}
return elements
}

/// Search for asset catalogs within the source path

// MARK: - List Assets

func listAssets() -> [String] {
let extensionName = "imageset"
let enumerator = FileManager.default.enumerator(atPath: assetCatalogAbsolutePath)
return elementsInEnumerator(enumerator)
.filter { $0.hasSuffix(extensionName) } // Is Asset
.map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // Remove extension
.map { $0.components(separatedBy: "/").last ?? $0 } // Remove folder path
}
let assetCatalogPaths: [String] = {
if let providedCatalog = catalogPath {
return [providedCatalog]
} else {
// detect automatically
return elementsInEnumerator(FileManager.default.enumerator(atPath: sourcePath)).filter { $0.hasSuffix(".xcassets") }
}
}()

print("Searching sources in \(sourcePath) for assets in \(assetCatalogPaths)")

// MARK: - List Used Assets in the codebase

func localizedStrings(inStringFile: String) -> [String] {
var localizedStrings = [String]()
let namePattern = "([\\w-]+)"
let patterns = [
"#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
"UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
"UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call
"\\<image name=\"\(namePattern)\".*", // Storyboard resources
"R.image.\(namePattern)\\(\\)" //R.swift support
]
for p in patterns {
let regex = try? NSRegularExpression(pattern: p, options: [])
let range = NSRange(location:0, length:(inStringFile as NSString).length)
regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
if let r = result {
let value = (inStringFile as NSString).substring(with:r.range(at: 1))
localizedStrings.append(value)
}
}
/// List assets in found asset catalogs
private func listAssets() -> [(asset: String, catalog: String)] {

return assetCatalogPaths.flatMap { (catalog) -> [(asset: String, catalog: String)] in

let extensionName = "imageset"
let enumerator = FileManager.default.enumerator(atPath: catalog)
return elementsInEnumerator(enumerator)
.filter { $0.hasSuffix(extensionName) } // Is Asset
.map { $0.replacingOccurrences(of: ".\(extensionName)", with: "") } // Remove extension
.map { $0.components(separatedBy: "/").last ?? $0 } // Remove folder path
.map { (asset: $0,catalog: catalog)}
}
return localizedStrings
}

func listUsedAssetLiterals() -> [String] {
/// List Assets used in the codebase
private func listUsedAssetLiterals() -> [String: [String]] {
let enumerator = FileManager.default.enumerator(atPath:sourcePath)
print(sourcePath)

#if swift(>=4.1)
return elementsInEnumerator(enumerator)
.filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files
.map { "\(sourcePath)/\($0)" } // Build file paths
.map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents
.compactMap{$0}
.compactMap{$0} // Remove nil entries
.map(localizedStrings) // Find localizedStrings ocurrences
.flatMap{$0} // Flatten
#else
return elementsInEnumerator(enumerator)
.filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") } // Only Swift and Obj-C files
.map { "\(sourcePath)/\($0)" } // Build file paths
.map { try? String(contentsOfFile: $0, encoding: .utf8)} // Get file contents
.flatMap{$0}
.flatMap{$0} // Remove nil entries
.map(localizedStrings) // Find localizedStrings ocurrences
.flatMap{$0} // Flatten
#endif
var assetUsageMap: [String: [String]] = [:]

// Only Swift and Obj-C files
let files = elementsInEnumerator(enumerator)
.filter { $0.hasSuffix(".m") || $0.hasSuffix(".swift") || $0.hasSuffix(".xib") || $0.hasSuffix(".storyboard") }

/// Find sources of assets within the contents of a file
func localizedStrings(inStringFile: String) -> [String] {
var assetStringReferences = [String]()
let namePattern = "([\\w-]+)"
let patterns = [
"#imageLiteral\\(resourceName: \"\(namePattern)\"\\)", // Image Literal
"UIImage\\(named:\\s*\"\(namePattern)\"\\)", // Default UIImage call (Swift)
"UIImage imageNamed:\\s*\\@\"\(namePattern)\"", // Default UIImage call
"\\<image name=\"\(namePattern)\".*", // Storyboard resources
"R.image.\(namePattern)\\(\\)" //R.swift support
]
for p in patterns {
let regex = try? NSRegularExpression(pattern: p, options: [])
let range = NSRange(location:0, length:(inStringFile as NSString).length)
regex?.enumerateMatches(in: inStringFile,options: [], range: range) { result, _, _ in
if let r = result {
let value = (inStringFile as NSString).substring(with:r.range(at: 1))
assetStringReferences.append(value)
}
}
}
return assetStringReferences
}

for filename in files {
// Build file paths
let filepath = "\(sourcePath)/\(filename)"

// Get file contents
if let fileContents = try? String(contentsOfFile: filepath, encoding: .utf8) {
// Find occurrences of asset names
let references = localizedStrings(inStringFile: fileContents)

// assemble the map
for asset in references {
let updatedReferences = assetUsageMap[asset] ?? []
assetUsageMap[asset] = updatedReferences + [filename]
}
}
}

return assetUsageMap
}


// MARK: - Begining of script
let assets = Set(listAssets())
let used = Set(listUsedAssetLiterals() + ignoredUnusedNames)

let availableAssets = listAssets()
let availableAssetNames = Set(availableAssets.map{$0.asset} )
let usedAssets = listUsedAssetLiterals()
let usedAssetNames = Set(usedAssets.keys + ignoredUnusedNames)

// Generate Warnings for Unused Assets
let unused = assets.subtracting(used)
unused.forEach { print("\(assetCatalogAbsolutePath):: warning: [Asset Unused] \($0)") }

let unused = availableAssets.filter({ (asset, catalog) -> Bool in !usedAssetNames.contains(asset) && !ignoredUnusedNames.contains(asset) })
unused.forEach { print("\($1):: warning: [Asset Unused] \($0)") }

// Generate Error for broken Assets
let broken = used.subtracting(assets)
broken.forEach { print("\(assetCatalogAbsolutePath):: error: [Asset Missing] \($0)") }
let broken = usedAssets.filter { (assetName, references) -> Bool in !availableAssetNames.contains(assetName) }
broken.forEach { print("\($1.first ?? $0):: error: [Asset Missing] \($0)") }

if broken.count > 0 {
exit(1)
Expand Down
17 changes: 16 additions & 1 deletion Example/AssetChecker.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

/* Begin PBXBuildFile section */
3333CADC9CDF4D6C0279EAF6 /* Pods_AssetChecker_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BF5C510348421D31756E79F /* Pods_AssetChecker_Tests.framework */; };
5D7631B4214170BA0017A471 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D7631B3214170BA0017A471 /* Media.xcassets */; };
5D8C437E1FC6CB2100DDF7D0 /* run in Resources */ = {isa = PBXBuildFile; fileRef = 5D8C437D1FC6CB2100DDF7D0 /* run */; };
607FACD61AFB9204008FA782 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD51AFB9204008FA782 /* AppDelegate.swift */; };
607FACD81AFB9204008FA782 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 607FACD71AFB9204008FA782 /* ViewController.swift */; };
Expand Down Expand Up @@ -35,6 +36,7 @@
4E3F248C6D57FAF6F94A24BC /* Pods-AssetChecker_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AssetChecker_Tests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AssetChecker_Tests/Pods-AssetChecker_Tests.debug.xcconfig"; sourceTree = "<group>"; };
5512D041F5A3EF8493D7505C /* Pods-AssetChecker_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AssetChecker_Example.debug.xcconfig"; path = "Pods/Target Support Files/Pods-AssetChecker_Example/Pods-AssetChecker_Example.debug.xcconfig"; sourceTree = "<group>"; };
58816321B24F6A6272E52BB1 /* Pods-AssetChecker_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AssetChecker_Tests.release.xcconfig"; path = "Pods/Target Support Files/Pods-AssetChecker_Tests/Pods-AssetChecker_Tests.release.xcconfig"; sourceTree = "<group>"; };
5D7631B3214170BA0017A471 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = "<group>"; };
5D8C437D1FC6CB2100DDF7D0 /* run */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.sh; name = run; path = ../run; sourceTree = "<group>"; };
607FACD01AFB9204008FA782 /* AssetChecker_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AssetChecker_Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
607FACD41AFB9204008FA782 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -71,6 +73,14 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
5D7631B2214170A70017A471 /* Resources */ = {
isa = PBXGroup;
children = (
5D7631B3214170BA0017A471 /* Media.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
607FACC71AFB9204008FA782 = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -100,6 +110,7 @@
607FACD91AFB9204008FA782 /* Main.storyboard */,
607FACDC1AFB9204008FA782 /* Images.xcassets */,
607FACDE1AFB9204008FA782 /* LaunchScreen.xib */,
5D7631B2214170A70017A471 /* Resources */,
607FACD31AFB9204008FA782 /* Supporting Files */,
);
name = "Example for AssetChecker";
Expand Down Expand Up @@ -219,6 +230,7 @@
TargetAttributes = {
607FACCF1AFB9204008FA782 = {
CreatedOnToolsVersion = 6.3.1;
DevelopmentTeam = JEH7PW263G;
LastSwiftMigration = 0900;
};
607FACE41AFB9204008FA782 = {
Expand Down Expand Up @@ -252,6 +264,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
5D7631B4214170BA0017A471 /* Media.xcassets in Resources */,
5D8C437E1FC6CB2100DDF7D0 /* run in Resources */,
607FACDB1AFB9204008FA782 /* Main.storyboard in Resources */,
607FACE01AFB9204008FA782 /* LaunchScreen.xib in Resources */,
Expand Down Expand Up @@ -362,7 +375,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "${PODS_ROOT}/AssetChecker/run --catalog ${SRCROOT}/Resource/Images.xcassets";
shellScript = "${PODS_ROOT}/AssetChecker/run --ignore \"icon_red\"\n";
};
CD3F94CDB64297E52FBE5D36 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
Expand Down Expand Up @@ -536,6 +549,7 @@
baseConfigurationReference = 5512D041F5A3EF8493D7505C /* Pods-AssetChecker_Example.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
DEVELOPMENT_TEAM = JEH7PW263G;
INFOPLIST_FILE = AssetChecker/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MODULE_NAME = ExampleApp;
Expand All @@ -551,6 +565,7 @@
baseConfigurationReference = C2AB56DA4BC27DE54F97B2E5 /* Pods-AssetChecker_Example.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
DEVELOPMENT_TEAM = JEH7PW263G;
INFOPLIST_FILE = AssetChecker/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MODULE_NAME = ExampleApp;
Expand Down
Loading