Home Write Run Script Directly in Swift with Xcode!
Post
Cancel

Write Run Script Directly in Swift with Xcode!

Write Shell Script Directly in Swift with Xcode!

Introducing Localization multi-language and Image Assets missing check, using Swift to create Shell Script

Photo by [Glenn Carstens-Peters](https://unsplash.com/@glenncarstenspeters?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Glenn Carstens-Peters

Background

Because of my clumsiness, I often miss the “;” when editing multi-language files, causing the app to display the wrong language after building. Additionally, as development progresses, the language files become increasingly large, with repeated and unused phrases mixed together, making it very chaotic (the same situation applies to Image Assets).

I have always wanted to find a tool to help handle these issues. Previously, I used iOSLocalizationEditor, a Mac APP, but it is more like a language file editor that reads and edits language file content without automatic checking functionality.

Desired Features

Automatically check for errors, omissions, duplicates in multi-language files, and missing Image Assets when building the project.

Solution

To achieve our desired features, we need to add a Run Script check script in Build Phases.

However, the check script needs to be written using shell script. Since my proficiency in shell script is not very high, I thought of standing on the shoulders of giants and searching for existing scripts online but couldn’t find any that fully met the desired features. Just when I was about to give up, I suddenly thought:

Shell Script can be written in Swift!

Compared to shell script, I am more familiar and proficient with Swift! Following this direction, I indeed found two existing tool scripts!

Two checking tools written by the freshOS team:

They fully meet our desired feature requirements! And since they are written in Swift, customizing and modifying them is very easy.

Localize 🏁 Multi-language File Checking Tool

Features:

  • Automatic check during build
  • Automatic formatting and organizing of language files
  • Check for omissions and redundancies between multi-language and primary language files
  • Check for duplicate phrases in multi-language files
  • Check for untranslated phrases in multi-language files
  • Check for unused phrases in multi-language files

Installation Method:

  1. Download the Swift Script file of the tool
  2. Place it in the project directory, e.g., ${SRCROOT}/Localize.swift
  3. Open project settings → iOS Target → Build Phases → click the “+” in the top left corner → New Run Script Phases → paste the path in the Script content, e.g., ${SRCROOT}/Localize.swift

  1. Use Xcode to open and edit the Localize.swift file for configuration. You can see the configurable items in the upper part of the file: ```swift // Enable the check script let enabled = true

// Localization file directory let relativeLocalizableFolders = “/Resources/Languages”

// Project directory (used to search if the phrases are used in the code) let relativeSourceFolder = “/Sources”

// Regular expression patterns for NSLocalized phrases in the code // You can add your own without changing the existing ones 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 ]

// Phrases to ignore for “unused phrase warning” let ignoredFromUnusedKeys: [String] = [] /* example let ignoredFromUnusedKeys = [ “NotificationNoOne”, “NotificationCommentPhoto”, “NotificationCommentHisPhoto”, “NotificationCommentHerPhoto” ] */

// Main language let masterLanguage = “en”

// Enable a-z sorting and organizing functionality for localization files let sanitizeFiles = false

// Is the project single or multi-language let singleLanguage = false

// Enable check for untranslated phrases let checkForUntranslated = true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
5. Build! Success!

![](/assets/41c49a75a743/1*74osParg9RRi2gcRx9ELuw.png)

**Check result prompt types:**
- **Build Error** ❌ **:**
  - \[Duplication\] The item is duplicated in the localization file
  - \[Unused Key\] The item is defined in the localization file but not used in the actual code
  - \[Missing\] The item is used in the actual code but not defined in the localization file
  - \[Redundant\] The item is redundant in this localization file compared to the main localization file
  - \[Missing Translation\] The item exists in the main localization file but is missing in this localization file
- **Build Warning** ⚠️ **:**
  - \[Potentially Untranslated\] This item is untranslated (same content as the main localization file)

> **_Not done yet, now we have automatic check prompts, but we still need to customize a bit._**

**Custom regular expression matching:**

Looking back at the patterns section in the top configuration block of the check script `Localize.swift`:

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

This matches the `NSLocalizedString()` method in Swift/ObjC, but this regular expression can only match phrases like `"Home.Title"`. If we have full sentences or phrases with format parameters, they will be mistakenly marked as \[Unused Key\].

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

We can add a new pattern setting or change the original pattern to:

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

The main adjustment is to match any string until the `"` appears, stopping there. You can also [click here](https://rubular.com/r/5eXvGy3svsAHyT){:target="_blank"} to customize according to your needs.

**Add Language File Format Check Functionality:**

This script only checks the content of language files for correspondence and does not check if the file format is correct (whether a ";" is missing). If you need this functionality, you need to add it yourself!
```swift
//....
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 correctness of the plist language file format. If there are errors or missing “;”, it will return an error; if there are no errors, it will return OK as the judgment!

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 error to warning or removing a certain check function (EX: Potentially Untranslated, Unused Key); the script is in Swift, which we are all familiar with! No fear of breaking or making mistakes!

To show Error ❌ during build:

1
print("ProjectFile.lproj" + "/File:Line: " + "error: ErrorMessage")

To show Warning ⚠️ during build:

1
print("ProjectFile.lproj" + "/File:Line: " + "warning: WarningMessage")

Final Modified Version:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
#!/usr/bin/env xcrun --sdk macosx swift

import Foundation

// WHAT
// 1. Find Missing keys in other Localisation 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 Localisation 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 wa
                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
}

Finally, it’s not over yet!

When our Swift check tool script is fully debugged, we need to compile it into an executable to reduce build time. Otherwise, it will need to be recompiled every time we build (this can reduce the time by about 90%).

Open the terminal and navigate to the directory where the check tool script is located in the project, then execute:

1
swiftc -o Localize Localize.swift

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

EX: ${SRCROOT}/Localize

Done!

Tool 2. Asset Checker 👮 Image Resource Check Tool

Features:

  • Automatically checks during build
  • Checks for missing images: names are called, but the image resource directory does not contain them
  • Checks for redundant images: names are not used, but the image resource directory contains them

Installation Method:

  1. Download the tool’s Swift Script file
  2. Place it in the project directory EX: ${SRCROOT}/AssetChecker.swift
  3. Open project settings → iOS Target → Build Phases → top left “+” → New Run Script Phases → paste the path in the Script content
1
2
${SRCROOT}/AssetChecker.swift ${SRCROOT}/project_directory ${SRCROOT}/Resources/Images.xcassets
//${SRCROOT}/Resources/Images.xcassets = the location of your .xcassets

You can directly set the parameters in the path, parameter 1: project directory location, parameter 2: image resource directory location; or edit the AssetChecker.swift top parameter setting block like the localization check tool:

1
2
3
4
5
6
7
8
9
10
// Configure me \o/

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

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

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

Check Result Prompt Types:

  • Build Error: - [Asset Missing] The item is called in the code but does not appear in the image resource directory
  • Build Warning ⚠️ : - [Asset Unused] The item is not used in the code but appears in the image resource directory p.s. If the image is provided by a dynamic variable, the check tool will not recognize it. You can add it to ignoredUnusedNames as an exception.

Other operations are the same as the localization check 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!

Develop Your Own Tools!

We can refer to the image resource check tool script:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#!/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 language check script, this script is concise and has all the important functions, making it very valuable for reference!

P.S. You can see the code has the localizedStrings() naming, suspecting the author borrowed the logic from the language check tool and forgot to change the method name XD

Example:

1
2
3
4
5
6
7
8
9
10
for (index, arg) in CommandLine.arguments.enumerated() {
    switch index {
    case 1:
        // Parameter 1
    case 2:
        // Parameter 2
    default:
        break
    }
}

^ Method to receive external parameters

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
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
}

^Traverse all project files and perform regex matching

1
2
3
4
// To make an Error ❌ appear during build:
print("ProjectFile.lproj" + "/file:line: " + "error: error message")
// To make a Warning ⚠️ appear during build:
print("ProjectFile.lproj" + "/file:line: " + "warning: warning message")

^print error or warning

You can refer to the above code methods to create your own desired tools.

Summary

After introducing these two checking tools, we can develop more confidently, efficiently, and reduce redundancy; also, this experience has been eye-opening, and in the future, if there are any new build run script requirements, we can directly use the most familiar language, Swift, to create them!

If you have any questions or feedback, feel free to contact me.

===

本文中文版本

===

This article was first published in Traditional Chinese on Medium ➡️ View Here


This post is licensed under CC BY 4.0 by the author.

iOS 14 Clipboard Data Theft Panic: The Dilemma of Privacy and Convenience

Apple Watch Series 6 Unboxing & Two-Year Usage Experience