Created
December 8, 2019 07:04
-
-
Save carlosypunto/3d20b8d2092b439f75f460be6b8443d6 to your computer and use it in GitHub Desktop.
Localizable string checker script for use in Xcode project Build Phases
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/xcrun --sdk macosx swift | |
import Foundation | |
let fileManager = FileManager.default | |
//let currentPath = fileManager.currentDirectoryPath | |
let currentPath = "/Users/carlos/Pecunpay/GeltCash/Implementación/Geltcash" | |
struct Tools { | |
static func contents(atPath path: String) -> String { | |
guard | |
let data = fileManager.contents(atPath: path), | |
let content = String(data: data, encoding: .utf8) | |
else { fatalError("Could not read from path: \(path)") } | |
return content | |
} | |
} | |
struct Paths { | |
/// List of files in currentPath - recursive | |
private static let pathFiles: [String] = { | |
guard let enumerator = fileManager.enumerator(atPath: currentPath), | |
let files = enumerator.allObjects as? [String] | |
else { fatalError("Could not locate files in path directory: \(currentPath)") } | |
return files | |
}() | |
/// List of localizable files - not including Localizable files in the Pods | |
static let localizableFiles: [String] = { | |
pathFiles.filter { $0.hasSuffix("Localizable.strings") && !$0.contains("Pods") } | |
}() | |
/// List of executable files | |
static let executableFiles: [String] = { | |
pathFiles.filter { | |
!$0.localizedCaseInsensitiveContains("test") && !$0.contains("Pods") && !$0.contains("scripts") && NSString(string: $0).pathExtension == "swift" | |
} | |
}() | |
} | |
struct Files { | |
static var pattern = "NSLocalizedString\\(@?\"(\\w+)\"" | |
static let stringFiles: [LocalizationStringsFile] = Paths.localizableFiles.map(LocalizationStringsFile.init(path:)) | |
static let codeFiles: [LocalizationCodeFile] = Paths.executableFiles.compactMap { | |
let path = currentPath + "/" + $0 | |
let content = Tools.contents(atPath: path).components(separatedBy: .newlines) | |
var returnMatches: [String:[Match]] = [:] | |
guard | |
let regex = try? NSRegularExpression(pattern: pattern, options: []) | |
else { fatalError("Regex not formatted correctly: \(pattern)")} | |
for (idx, line) in content.enumerated() { | |
let matches = regex | |
.matches( | |
in: line, | |
options: [.withoutAnchoringBounds], | |
range: NSRange(location: 0, length: line.utf16.count)) | |
for result in matches { | |
let range = result.range(at: 1) | |
guard let rangeInLine = Range(result.range(at: 1), in: line) else { fatalError("Incorrect range match") } | |
let key = String(line[rangeInLine]) | |
let match = Match(line: idx + 1, location: range.location, length: range.length) | |
if let output = returnMatches[key] { | |
var newOutput = output | |
newOutput.append(match) | |
returnMatches[key] = newOutput | |
} else { | |
returnMatches[key] = [match] | |
} | |
} | |
} | |
return returnMatches.isEmpty ? nil : LocalizationCodeFile(path: currentPath + "/" + $0, matches: returnMatches) | |
} | |
} | |
protocol Pathable { | |
var path: String { get } | |
} | |
struct Match { | |
let line: Int | |
let location: Int | |
let length: Int | |
} | |
struct LocalizationCodeFile: Pathable { | |
let path: String | |
let matches: [String:[Match]] | |
var keys: [String] { | |
return matches.keys.map { String($0) } | |
} | |
} | |
struct LocalizationStringsFile: Pathable { | |
let path: String | |
let kv: [String: String] | |
let matches: [String: Match] | |
let duplicateMatches: [String: [Match]] | |
let formatWarningMessages: [String] | |
var keys: [String] { | |
return Array(kv.keys) | |
} | |
init(path: String) { | |
self.path = currentPath + "/" + path | |
let content = Tools.contents(atPath: self.path) | |
let lines = content.components(separatedBy: .newlines) | |
let keysPattern = "\"([^\"]*?)\"(?= =)" | |
let valuesPattern = "(?<== )\"(.*?)\"(?=;)" | |
guard let keysRegex = try? NSRegularExpression(pattern: keysPattern, options: []) else { fatalError("Regex not formatted correctly: \(keysPattern)")} | |
guard let valuesRegex = try? NSRegularExpression(pattern: valuesPattern, options: []) else { fatalError("Regex not formatted correctly: \(valuesPattern)")} | |
var result: [String: String] = [:] | |
var allMatches: [String: Match] = [:] | |
var duplicateMatches: [String: [Match]] = [:] | |
var warningsMessages: [String] = [] | |
for (idx, line) in lines.enumerated() { | |
let matches = keysRegex | |
.matches( | |
in: line, | |
options: [.withoutAnchoringBounds], | |
range: NSRange(location: 0, length: line.utf16.count)) | |
let keys = matches.map { | |
String(line[Range($0.range(at: 1), in: line)!]) | |
} | |
let keysMatches: [Match] = matches.map { | |
let range = $0.range(at: 1) | |
return Match(line: idx + 1, location: range.location, length: range.length) | |
} | |
let values = valuesRegex | |
.matches( | |
in: line, | |
options: [.withoutAnchoringBounds], | |
range: NSRange(location: 0, length: line.utf16.count)) | |
.map { String(line[Range($0.range(at: 1), in: line)!]) } | |
if matches.count != keys.count && matches.count != keysMatches.count && matches.count != values.count { | |
fatalError("Error parsing contents: Make sure all keys and values are in correct format without comments in file") | |
} | |
for (idx, key) in keys.enumerated() { | |
if result[key] != nil { | |
var duplicateMatchesArray = duplicateMatches[key] ?? [] | |
duplicateMatchesArray.append(keysMatches[idx]) | |
duplicateMatches[key] = duplicateMatchesArray | |
} else { | |
if values.count > idx { | |
result[key] = values[idx] | |
} else { | |
let match = keysMatches[idx] | |
warningsMessages.append("\(self.path):\(match.line):\(match.location): warning: is not correctly formated") | |
} | |
allMatches[key] = keysMatches[idx] | |
} | |
} | |
} | |
self.kv = result | |
self.duplicateMatches = duplicateMatches | |
self.matches = allMatches | |
self.formatWarningMessages = warningsMessages | |
} | |
} | |
struct Validator { | |
let localizationFiles: [LocalizationStringsFile] | |
let codeFiles: [LocalizationCodeFile] | |
// no consistent Localization files | |
func validateForBadFormmatedLocalizationFiles() { | |
let formatWarningMessages = localizationFiles.reduce([String]()) { | |
var result = $0 | |
for message in $1.formatWarningMessages { | |
result.append(message) | |
} | |
return result | |
} | |
if !formatWarningMessages.isEmpty { | |
for message in formatWarningMessages { | |
print(message) | |
} | |
print("error: There are bad formated Localizable strings") | |
abort() | |
} | |
} | |
// no consistent Localization files | |
func validateForDuplicateKeysInAllLocalizationFiles() { | |
var foundDuplicatesWarningsMessages = Set<String>() | |
let duplicateKeys = localizationFiles.reduce(Set<String>()) { set, localizationStringsFile -> Set<String> in | |
var result: Set<String> = set | |
for duplicateMatch in localizationStringsFile.duplicateMatches { | |
result.insert(duplicateMatch.key) | |
} | |
return result | |
} | |
for duplicateKey in duplicateKeys { | |
for file in localizationFiles { | |
for matchesDict in file.matches where matchesDict.key == duplicateKey { | |
foundDuplicatesWarningsMessages.insert("\(file.path):\(matchesDict.value.line):\(matchesDict.value.location): warning: Key \"\(duplicateKey)\" is duplicate") | |
} | |
for duplicateMatchesDict in file.duplicateMatches where duplicateMatchesDict.key == duplicateKey { | |
for match in duplicateMatchesDict.value { | |
foundDuplicatesWarningsMessages.insert("\(file.path):\(match.line):\(match.location): warning: Key \"\(duplicateKey)\" is duplicate") | |
} | |
} | |
} | |
} | |
if !foundDuplicatesWarningsMessages.isEmpty { | |
for message in foundDuplicatesWarningsMessages { | |
print(message) | |
} | |
print("error: There are key duplicates in \(foundDuplicatesWarningsMessages)") | |
abort() | |
} | |
} | |
// no consistent Localization files | |
func validateAllKeyArePresentInAllLocalizationFiles() { | |
guard let base = localizationFiles.first, localizationFiles.count > 1 else { return } | |
var extraKeys = Set<String>() | |
for baseFile in localizationFiles { | |
for extraKey in Set(base.keys).symmetricDifference(baseFile.keys) { | |
extraKeys.insert(extraKey) | |
} | |
} | |
for extraKey in extraKeys { | |
for localizationFile in localizationFiles { | |
if let match = localizationFile.matches[extraKey] { | |
print("\(localizationFile.path):\(match.line):\(match.location): warning: This \"\(extraKey)\" key is not present in all localization files") | |
} | |
} | |
} | |
} | |
// missing keys | |
func validateAllUsedInCodeKeysArePresentInAllLocalizationFiles() { | |
guard let baseFile = localizationFiles.first else { fatalError("Could not locate base localization file") } | |
let baseKeys = Set(baseFile.keys) | |
var allFilesExtraKeys = Set<String>() | |
for baseFile in codeFiles { | |
let extraKeys = Set(baseFile.matches.keys).subtracting(baseKeys) | |
if !extraKeys.isEmpty { | |
for extraKey in extraKeys { | |
if let matches = baseFile.matches[extraKey] { | |
for match in matches { | |
allFilesExtraKeys.insert(extraKey) | |
print("\(baseFile.path):\(match.line):\(match.location): warning: This key is not present in Localizable.strings files") | |
} | |
} | |
} | |
} | |
} | |
if !allFilesExtraKeys.isEmpty { | |
var output = "warning: Keys not present in Localizable.strings:\n\n" | |
for key in allFilesExtraKeys { | |
output += "\"\(key)\" = \"\(key)\";\n" | |
} | |
print(output) | |
} | |
} | |
// dead keys | |
func validateAllLocalizationFilesKeysAreUsedInCode() { | |
let allCodeFileKeys = codeFiles.flatMap { $0.matches.keys } | |
for baseFile in localizationFiles { | |
let baseKeys = Set(baseFile.matches.keys) | |
let deadKeys = baseKeys.subtracting(allCodeFileKeys) | |
if !deadKeys.isEmpty { | |
for deadKey in deadKeys { | |
if let match = baseFile.matches[deadKey] { | |
print("\(baseFile.path):\(match.line):\(match.location): warning: This key is not beig used in code.") | |
} | |
} | |
} | |
} | |
} | |
} | |
struct Program { | |
static let stringFiles = Files.stringFiles | |
static let codeFiles = Files.codeFiles | |
static func run() { | |
let validator = Validator(localizationFiles: stringFiles, codeFiles: codeFiles) | |
validator.validateForBadFormmatedLocalizationFiles() | |
validator.validateForDuplicateKeysInAllLocalizationFiles() | |
validator.validateAllKeyArePresentInAllLocalizationFiles() | |
validator.validateAllUsedInCodeKeysArePresentInAllLocalizationFiles() | |
validator.validateAllLocalizationFilesKeysAreUsedInCode() | |
} | |
} | |
let customPattern = "\"([^\"]+)\".localized" | |
Files.pattern = customPattern | |
Program.run() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment