Skip to content

Instantly share code, notes, and snippets.

@carlosypunto
Created December 8, 2019 07:04
Show Gist options
  • Save carlosypunto/3d20b8d2092b439f75f460be6b8443d6 to your computer and use it in GitHub Desktop.
Save carlosypunto/3d20b8d2092b439f75f460be6b8443d6 to your computer and use it in GitHub Desktop.
Localizable string checker script for use in Xcode project Build Phases
#!/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