ZhgChg.Li

Swift Run Script in Xcode|Localization & Image Asset Checks Automated

Developers facing localization and image asset omissions can automate detection using Swift-based Run Scripts in Xcode, streamlining project integrity and reducing manual errors effectively.

Swift Run Script in Xcode|Localization & Image Asset Checks Automated
This article was AI-translated — please let me know if anything looks off.

Writing Shell Scripts Directly in Swift in Xcode!

Introducing Localization and Image Assets Missing Check, Using Swift to Build Shell Script

Photo by Glenn Carstens-Peters

Photo by Glenn Carstens-Peters

Reason

Because of my clumsiness, I often forget the “;” when editing localization files, causing language display errors in the app build. Additionally, as development progresses, the localization files grow larger, with duplicate and unused strings mixed in, making things very messy (the same issue applies to Image Assets).

I have always wanted a tool to help handle these issues. Previously, I used the Mac app iOSLocalizationEditor, but it is more like a localization file editor that reads and edits localization file content, without any automatic checking features.

Expected Features

Automatically check for errors, missing or duplicate entries in localizations, and missing Image Assets when building the project.

Solution

To achieve the desired functionality, you need to add a Run Script check in the Build Phases.

But the check script needs to be written in shell script, and since I’m not very proficient with shell scripting, I tried searching online for existing scripts that fully meet my requirements but couldn’t find any. Just as I was about to give up, I suddenly thought:

You can write Shell Scripts in Swift!

More familiar and proficient compared to shell scripts! Following this direction, I indeed found two existing tool scripts!

Two checking tools written by the freshOS team:

It fully meets our expected functional requirements!! Plus, since they are written in Swift, customizing and modifying them is very easy.

Localize 🏁 Localization File Checker Tool

Functionality:

  • Automatic Checks During Build

  • Automatic Localization File Formatting and Organization

  • Check for Missing or Extra Localizations and the Base Language

  • Check for Duplicate Localization Strings

  • Check Untranslated Localization Strings

  • Check Unused Localization Strings

Installation:

  1. Download the Swift Script file for the tool

  2. Place it in the project directory, e.g., ${SRCROOT}/Localize.swift

  3. Open Project Settings → iOS Target → Build Phases → Top left “+” → New Run Script Phase → Paste the script path in the Script content, e.g., ${SRCROOT}/Localize.swift

  1. Open the Localize.swift file in Xcode to configure it. You can find adjustable settings in the upper part of the file:
// Enable the check script
let enabled = true

// Localization files directory
let relativeLocalizableFolders = "/Resources/Languages"

// Project directory (used to search if keys are used in the code)
let relativeSourceFolder = "/Sources"

// Regular expressions for NSLocalized localization keys in code
// Can be extended, no need to change
let patterns = [
    "NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\"", // Swift and Objc Native
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized pattern
]

// Keys to ignore for "unused key" warnings
let ignoredFromUnusedKeys: [String] = []
/* example
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

// Main language
let masterLanguage = "en"

// Enable sorting and cleaning of files a-z
let sanitizeFiles = false

// Project is single or multiple languages
let singleLanguage = false

// Enable check for untranslated keys
let checkForUntranslated = true
  1. Build! Success!

Types of Check Results Alerts:

  • Build Error:

  • [Duplication] Items duplicated in localization files

  • [Unused Key] Items defined in the localization file but not used in the actual code

  • [Missing] Items not defined in the localization files but actually used in the code

  • [Redundant] Items that are redundant in this localization file compared to the main localization file

  • [Missing Translation] The key exists in the main language file but is missing in this language file

  • Build Warning ⚠️ :

  • [Potentially Untranslated] This item is untranslated (same content as the base language file item)

Not finished yet. Now the automatic check prompts are available, but we still need to customize them ourselves.

Customizing Regular Expression Matching:

Looking back at the first item in the patterns section of the Localize.swift script’s top configuration block:

"NSLocalized(Format)?String\\(\\s*@?\"([\\w\\.]+)\""

Matching Swift/ObjC’s NSLocalizedString() method, this regular expression only matches strings in the format "Home.Title"; if we use complete sentences or include Format parameters, they will be mistakenly treated as [Unused Key].

EX: "Hi, %@ welcome to my app", "Hello World!" <- These strings cannot be matched

We can add a new patterns setting or modify the existing patterns to:

"NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\""

The main adjustment is to the matching statement after the NSLocalizedString method, changing it to capture any string until a " appears to stop. You can also click here to customize it according to your needs.

Add Localization File Format Check Feature:

This script only checks the content correspondence of localization files and does not verify if the file format is correct (such as missing a “;”). You need to add this feature yourself if needed!

//....
let formatResult = shell("plutil -lint \(location)")
guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
  let str = "\(path)/\(name).lproj"
            + "/Localizable.strings:1: "
            + "error: [File Invalid] "
            + "This Localizable.strings file format is invalid."
  print(str)
  numberOfErrors += 1
  return
}
//....

func shell(_ command: String) -> String {
    let task = Process()
    let pipe = Pipe()

    task.standardOutput = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/bash"
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!

    return output
}

Add shell() to execute shell scripts, using plutil -lint to check the plist localization file format correctness. If there is an error or missing “;”, it will return an error; if no error, it returns OK as the validation result!

The check can be added after LocalizationFiles->process()-> let location = singleLanguage… around line 135, or refer to the complete modified version I provided at the end.

Other Customizations:

We can customize according to our needs, such as changing errors to warnings or disabling certain checks (e.g., Potentially Untranslated, Unused Key); since the script is in Swift, which we are all familiar with, there’s no fear of breaking or messing it up!

To make an Error ❌ appear during build:

print("ProjectFile.lproj" + "/file:line: " + "error: error message")

To show a Warning ⚠️ during build:

print("ProjectFile.lproj" + "/file:line: " + "warning: warning message")

Final Custom Version:

#!/usr/bin/env xcrun --sdk macosx swift

import Foundation

// WHAT
// 1. Find Missing keys in other Localization files
// 2. Find potentially untranslated keys
// 3. Find Duplicate keys
// 4. Find Unused keys and generate script to delete them all at once

// MARK: Start Of Configurable Section

/*
 You can enable or disable the script whenever you want
 */
let enabled = true

/*
 Put your path here, example ->  Resources/Localizations/Languages
 */
let relativeLocalizableFolders = "/streetvoice/SupportingFiles"

/*
 This is the path of your source folder which will be used in searching
 for the localization keys you actually use in your project
 */
let relativeSourceFolder = "/streetvoice"

/*
 Those are the regex patterns to recognize localizations.
 */
let patterns = [
    "NSLocalized(Format)?String\\(\\s*@?\"([^(\")]+)\"", // Swift and Objc Native
    "Localizations\\.((?:[A-Z]{1}[a-z]*[A-z]*)*(?:\\.[A-Z]{1}[a-z]*[A-z]*)*)", // Laurine Calls
    "L10n.tr\\(key: \"(\\w+)\"", // SwiftGen generation
    "ypLocalized\\(\"(.*)\"\\)",
    "\"(.*)\".localized" // "key".localized pattern
]

/*
 Those are the keys you don't want to be recognized as "unused"
 For instance, Keys that you concatenate will not be detected by the parsing
 so you want to add them here in order not to create false positives :)
 */
let ignoredFromUnusedKeys: [String] = []
/* example
let ignoredFromUnusedKeys = [
    "NotificationNoOne",
    "NotificationCommentPhoto",
    "NotificationCommentHisPhoto",
    "NotificationCommentHerPhoto"
]
*/

let masterLanguage = "base"

/*
 Sanitizing files will remove comments, empty lines and order your keys alphabetically.
 */
let sanitizeFiles = false

/*
 Determines if there are multiple localizations or not.
 */
let singleLanguage = false

/*
 Determines if we should show errors if there's a key within the app
 that does not appear in master translations.
*/
let checkForUntranslated = false

// MARK: End Of Configurable Section

if enabled == false {
    print("Localization check cancelled")
    exit(000)
}

// Detect list of supported languages automatically
func listSupportedLanguages() -> [String] {
    var sl: [String] = []
    let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
    if !FileManager.default.fileExists(atPath: path) {
        print("Invalid configuration: \(path) does not exist.")
        exit(1)
    }
    let enumerator = FileManager.default.enumerator(atPath: path)
    let extensionName = "lproj"
    print("Found these languages:")
    while let element = enumerator?.nextObject() as? String {
        if element.hasSuffix(extensionName) {
            print(element)
            let name = element.replacingOccurrences(of: ".\(extensionName)", with: "")
            sl.append(name)
        }
    }
    return sl
}

let supportedLanguages = listSupportedLanguages()
var ignoredFromSameTranslation: [String: [String]] = [:]
let path = FileManager.default.currentDirectoryPath + relativeLocalizableFolders
var numberOfWarnings = 0
var numberOfErrors = 0

struct LocalizationFiles {
    var name = ""
    var keyValue: [String: String] = [:]
    var linesNumbers: [String: Int] = [:]

    init(name: String) {
        self.name = name
        process()
    }

    mutating func process() {
        if sanitizeFiles {
            removeCommentsFromFile()
            removeEmptyLinesFromFile()
            sortLinesAlphabetically()
        }
        let location = singleLanguage ? "\(path)/Localizable.strings" : "\(path)/\(name).lproj/Localizable.strings"
        
        let formatResult = shell("plutil -lint \(location)")
        guard formatResult.trimmingCharacters(in: .whitespacesAndNewlines).suffix(2) == "OK" else {
            let str = "\(path)/\(name).lproj"
                + "/Localizable.strings:1: "
                + "error: [File Invalid] "
                + "This Localizable.strings file format is invalid."
            print(str)
            numberOfErrors += 1
            return
        }
        
        guard let string = try? String(contentsOfFile: location, encoding: .utf8) else {
            return
        }

        let lines = string.components(separatedBy: .newlines)
        keyValue = [:]

        let pattern = "\"(.*)\" = \"(.+)\";"
        let regex = try? NSRegularExpression(pattern: pattern, options: [])
        var ignoredTranslation: [String] = []

        for (lineNumber, line) in lines.enumerated() {
            let range = NSRange(location: 0, length: (line as NSString).length)

            // Ignored pattern
            let ignoredPattern = "\"(.*)\" = \"(.+)\"; *\\/\\/ *ignore-same-translation-warning"
            let ignoredRegex = try? NSRegularExpression(pattern: ignoredPattern, options: [])
            if let ignoredMatch = ignoredRegex?.firstMatch(in: line,
                                                           options: [],
                                                           range: range) {
                let key = (line as NSString).substring(with: ignoredMatch.range(at: 1))
                ignoredTranslation.append(key)
            }

            if let firstMatch = regex?.firstMatch(in: line, options: [], range: range) {
                let key = (line as NSString).substring(with: firstMatch.range(at: 1))
                let value = (line as NSString).substring(with: firstMatch.range(at: 2))

                if keyValue[key] != nil {
                    let str = "\(path)/\(name).lproj"
                        + "/Localizable.strings:\(linesNumbers[key]!): "
                        + "error: [Duplication] \"\(key)\" "
                        + "is duplicated in \(name.uppercased()) file"
                    print(str)
                    numberOfErrors += 1
                } else {
                    keyValue[key] = value
                    linesNumbers[key] = lineNumber + 1
                }
            }
        }
        print(ignoredFromSameTranslation)
        ignoredFromSameTranslation[name] = ignoredTranslation
    }

    func rebuildFileString(from lines: [String]) -> String {
        return lines.reduce("") { (r: String, s: String) -> String in
            (r == "") ? (r + s) : (r + "\n" + s)
        }
    }

    func removeEmptyLinesFromFile() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            var lines = string.components(separatedBy: .newlines)
            lines = lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
            let s = rebuildFileString(from: lines)
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func removeCommentsFromFile() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            var lines = string.components(separatedBy: .newlines)
            lines = lines.filter { !$0.hasPrefix("//") }
            let s = rebuildFileString(from: lines)
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func sortLinesAlphabetically() {
        let location = "\(path)/\(name).lproj/Localizable.strings"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            let lines = string.components(separatedBy: .newlines)

            var s = ""
            for (i, l) in sortAlphabetically(lines).enumerated() {
                s += l
                if i != lines.count - 1 {
                    s += "\n"
                }
            }
            try? s.write(toFile: location, atomically: false, encoding: .utf8)
        }
    }

    func removeEmptyLinesFromLines(_ lines: [String]) -> [String] {
        return lines.filter { $0.trimmingCharacters(in: .whitespaces) != "" }
    }

    func sortAlphabetically(_ lines: [String]) -> [String] {
        return lines.sorted()
    }
}

// MARK: - Load Localization Files in memory

let masterLocalizationFile = LocalizationFiles(name: masterLanguage)
let localizationFiles = supportedLanguages
    .filter { $0 != masterLanguage }
    .map { LocalizationFiles(name: $0) }

// MARK: - Detect Unused Keys

let sourcesPath = FileManager.default.currentDirectoryPath + relativeSourceFolder
let fileManager = FileManager.default
let enumerator = fileManager.enumerator(atPath: sourcesPath)
var localizedStrings: [String] = []
while let swiftFileLocation = enumerator?.nextObject() as? String {
    // checks the extension
    if swiftFileLocation.hasSuffix(".swift") \\|\\| swiftFileLocation.hasSuffix(".m") \\|\\| swiftFileLocation.hasSuffix(".mm") {
        let location = "\(sourcesPath)/\(swiftFileLocation)"
        if let string = try? String(contentsOfFile: location, encoding: .utf8) {
            for p in patterns {
                let regex = try? NSRegularExpression(pattern: p, options: [])
                let range = NSRange(location: 0, length: (string as NSString).length) // Obj c way
                regex?.enumerateMatches(in: string,
                                        options: [],
                                        range: range,
                                        using: { result, _, _ in
                                            if let r = result {
                                                let value = (string as NSString).substring(with: r.range(at: r.numberOfRanges - 1))
                                                localizedStrings.append(value)
                                            }
                })
            }
        }
    }
}

var masterKeys = Set(masterLocalizationFile.keyValue.keys)
let usedKeys = Set(localizedStrings)
let ignored = Set(ignoredFromUnusedKeys)
let unused = masterKeys.subtracting(usedKeys).subtracting(ignored)
let untranslated = usedKeys.subtracting(masterKeys)

// Here generate Xcode regex Find and replace script to remove dead keys all at once!
var replaceCommand = "\"("
var counter = 0
for v in unused {
    var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[v]!): "
    str += "error: [Unused Key] \"\(v)\" is never used"
    print(str)
    numberOfErrors += 1
    if counter != 0 {
        replaceCommand += "\\|"
    }
    replaceCommand += v
    if counter == unused.count - 1 {
        replaceCommand += ")\" = \".*\";"
    }
    counter += 1
}

print(replaceCommand)

// MARK: - Compare each translation file against master (en)

for file in localizationFiles {
    for k in masterLocalizationFile.keyValue.keys {
        if file.keyValue[k] == nil {
            var str = "\(path)/\(file.name).lproj/Localizable.strings:\(masterLocalizationFile.linesNumbers[k]!): "
            str += "error: [Missing] \"\(k)\" missing from \(file.name.uppercased()) file"
            print(str)
            numberOfErrors += 1
        }
    }

    let redundantKeys = file.keyValue.keys.filter { !masterLocalizationFile.keyValue.keys.contains($0) }

    for k in redundantKeys {
        let str = "\(path)/\(file.name).lproj/Localizable.strings:\(file.linesNumbers[k]!): "
            + "error: [Redundant key] \"\(k)\" redundant in \(file.name.uppercased()) file"

        print(str)
    }
}

if checkForUntranslated {
    for key in untranslated {
        var str = "\(path)/\(masterLocalizationFile.name).lproj/Localizable.strings:1: "
        str += "error: [Missing Translation] \(key) is not translated"

        print(str)
        numberOfErrors += 1
    }
}

print("Number of warnings : \(numberOfWarnings)")
print("Number of errors : \(numberOfErrors)")

if numberOfErrors > 0 {
    exit(1)
}

func shell(_ command: String) -> String {
    let task = Process()
    let pipe = Pipe()

    task.standardOutput = pipe
    task.arguments = ["-c", command]
    task.launchPath = "/bin/bash"
    task.launch()

    let data = pipe.fileHandleForReading.readDataToEndOfFile()
    let output = String(data: data, encoding: .utf8)!

    return output
}

Last but not least, not finished yet!

After we finish debugging our Swift check tool scripts, we need to compile them into executables to reduce build time; otherwise, each build will recompile the scripts (this can save about 90% of the time).

Open the terminal and navigate to the directory containing the checker tool scripts in the project, then run:

swiftc -o Localize Localize.swift

Then go back to Build Phases and change the Script content path to the executable file.

EX: ${SRCROOT}/Localize

Done!

Tool 2. Asset Checker 👮 Image Asset Checking Tool

Functionality:

  • Automatic Checks During Build

  • Check missing images: name is referenced, but the image asset folder does not contain it

  • Check for redundant images: names not used, but image asset directory exists

Installation:

  1. Download the Swift Script file for the tool

  2. Place it in the project directory, e.g., ${SRCROOT}/AssetChecker.swift

  3. Open Project Settings → iOS Target → Build Phases → “+” at the top left → New Run Script Phase → Paste the path in the Script content

${SRCROOT}/AssetChecker.swift ${SRCROOT}/ProjectDirectory ${SRCROOT}/Resources/Images.xcassets
//${SRCROOT}/Resources/Images.xcassets = Your .xcassets location

You can directly pass the configuration parameters in the path: Parameter 1 is the project directory location, Parameter 2 is the image assets directory location; or edit the parameter settings block at the top of AssetChecker.swift just like the localization checking tool:

// Configure me \o/

// Project directory path (used to search if images are used in the code)
var sourcePathOption:String? = nil

// .xcassets directory path
var assetCatalogPathOption:String? = nil

// Unused warning ignore list
let ignoredUnusedNames = [String]()
  1. Build! Success!

Types of Check Results Alerts:

  • Build Error:

  • [Asset Missing] Items are referenced in the code but do not exist in the image asset catalog.

  • Build Warning ⚠️ :

  • [Asset Unused] Items exist in the image asset directory but are not used in the code.
    p.s If the image is loaded via a dynamic variable, the checker cannot detect it. You can add it to ignoredUnusedNames as an exception.

Other operations are the same as the localization checking tool, so they won’t be repeated here; the most important thing is to remember to compile it into an executable after debugging and change the run script content to the executable!

Developing Your Own Tools!

We can refer to the image asset checking tool script:

#!/usr/bin/env xcrun --sdk macosx swift

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)")

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


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


// 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
}


// 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)
            }
        }
    }
    return localizedStrings
}

func listUsedAssetLiterals() -> [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 occurrences
            .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 occurrences
            .flatMap{$0}                                                // Flatten
    #endif
}


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


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


// Generate Error for broken Assets
let broken = used.subtracting(assets)
broken.forEach { print("\(assetCatalogAbsolutePath):: error: [Asset Missing] \($0)") }

if broken.count > 0 {
    exit(1)
}

Compared to the localization checking script, this script is simpler yet includes all the essential features, making it very valuable as a reference!

P.S You might notice the method name localizedStrings() in the code, which suggests the author borrowed it from the localization checking tool logic and forgot to rename it XD

For example:

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

^Method to Receive External Parameters

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

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)
            }
        }
    }
    return localizedStrings
}

func listUsedAssetLiterals() -> [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 occurrences
            .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 occurrences
            .flatMap{$0}                                                // Flatten
    #endif
}

^Method to traverse all project files and perform regex matching

// To show an Error ❌ during build:
print("ProjectFile.lproj" + "/file:line: " + "error: error message")
// To show a Warning ⚠️ during build:
print("ProjectFile.lproj" + "/file:line: " + "warning: warning message")

^print error or warning

You can combine the above programming methods to create your own desired tools.

Summary

After integrating these two checking tools, we can develop with more confidence, greater efficiency, and less redundancy. This experience has been eye-opening, and in the future, any new build run script needs can be directly handled using the most familiar language, Swift!

Improve this page
Edit on GitHub
Originally published on Medium
Read the original
Share this essay
Copy link · share to socials
ZhgChgLi
Author

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

Comments