Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
A Swift script that generates enums for each strings file your project contains. Compile-time localization validation !
#!/usr/bin/env xcrun --sdk macosx swift
//
// AutoLocalizationManagerCreator
//
// Created by Dorian Grolaux on 25/04/2019.
// Copyright © 2019 Dorian Grolaux. All rights reserved.
//
import Foundation
// MARK: Convenience
extension String {
func indented(depth: Int) -> String {
var indentation = ""
for _ in 0..<depth {
indentation += " "
}
return indentation + self
}
}
// MARK: Constants
let currentFolderURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
let projectFolderURL = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true, relativeTo: currentFolderURL)
let baseLPROJURL = URL(fileURLWithPath: "en.lproj", isDirectory: true, relativeTo: projectFolderURL)
var outputFolderURL: URL?
let outputFolderName: String = CommandLine.arguments[2]
let LocalizationManagerCodeString = """
//
// LocalizationManager.swift
//
// Generated by LocalizationManagerGenerator script.
// Created by Dorian Grolaux
// https://gist.github.com/MrSkwiggs/9cffc243a77a0b3088ab019fa0939b5e
//
//
import Foundation
protocol LocalizationManager {
static var tableName: String { get }
var localizableKey: String { get }
var arguments: [CVarArg]? { get }
func localized() -> String
}
extension LocalizationManager {
func localized() -> String {
let template = NSLocalizedString(localizableKey, tableName: Self.tableName, comment: "")
return String(format: template, arguments: arguments ?? [])
}
}
"""
// MARK: Models
struct PathedKey: Hashable {
let key: String
let baseValue: String
/// Line number at which the key is present in its base strings file
let line: Int
func formatSpecifiersInBaseValue() -> [String] {
var formatSpecifiers: [String] = []
var previousNumberOfPercentChars: Int = 0
baseValue.forEach { character in
if !previousNumberOfPercentChars.isMultiple(of: 2) { // %%@ is not a String, it prints: %@
switch character {
case "@": formatSpecifiers.append("String")
case "d": formatSpecifiers.append("Int")
case "%": break // Nothing to do...
case " ": fatalError("Usage of a single %, followed by a space. If you want to show a single % use %%")
default: fatalError("Usage of a single %, followed by a character that we don't handle as format specifier")
}
}
if character == "%" {
previousNumberOfPercentChars += 1
} else {
previousNumberOfPercentChars = 0
}
}
if !previousNumberOfPercentChars.isMultiple(of: 2) { // The last character was a % prefixed by an even amount of %
fatalError("Usage of a single %, as the last character of the key results in nothing showing up for it. Use %% to show a single %")
}
return formatSpecifiers
}
}
struct CodeFile {
let code: String
let name: String
}
private struct StringsFile {
let name: String
let pathedKeys: [PathedKey]
init?(name: String, pathedKeys: [PathedKey]?) {
guard let pathedKeys = pathedKeys else {
return nil
}
self.name = name
self.pathedKeys = pathedKeys
}
}
// MARK: Global vars
var allMissingKeys = [PathedKey: (file: String, lprojs: [String])]()
var allMissingFiles = [String: [String]]()
// MARK: Workers
private struct EnumGenerator {
private init() {}
static func stringsFile(forFileAt fileURL: URL) -> StringsFile? {
guard let data = FileManager.default.contents(atPath: fileURL.path),
let fileContent = String(data: data, encoding: .utf8) else {
return nil
}
let lines = fileContent.components(separatedBy: CharacterSet.newlines)
var lineIndex = 1 // Xcode starts counting at 1
var pathedKeys = [PathedKey]()
for line in lines {
let scanner = Scanner.init(string: line)
scanner.charactersToBeSkipped = CharacterSet.whitespaces
while !scanner.isAtEnd {
var key: NSString?
var value: NSString?
scanner.scanUpTo("\"", into: nil)
scanner.scanUpTo("=", into: &key)
scanner.scanUpTo("\"", into: nil)
scanner.scanUpTo(";", into: &value)
if let key = key, let value = value {
let keyString = key.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).replacingOccurrences(of: "\"", with: "") as String
let valueString = value.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).replacingOccurrences(of: "\"", with: "") as String
let pathedKey = PathedKey(key: keyString, baseValue: valueString, line: lineIndex)
pathedKeys.append(pathedKey)
}
}
lineIndex += 1
}
return StringsFile(name: fileURL.lastPathComponent, pathedKeys: pathedKeys)
}
static func generate(forFileAt fileURL: URL) -> CodeFile? {
let fileName = fileURL.lastPathComponent
guard fileName.starts(with: "Localized") else {
return nil
}
let enumName = EnumGenerator.enumName(forFileName: fileName)
let tableName = EnumGenerator.tableName(forFileName: fileName)
let stringsFile = EnumGenerator.stringsFile(forFileAt: fileURL)
var code = makeHeader(forEnumName: enumName)
code += makeTableComputedVar(forTableName: tableName)
code += makeCases(for: stringsFile!.pathedKeys)
code += makeLocalizableKeys(for: stringsFile!.pathedKeys)
code += makeArgumentsList(for: stringsFile!.pathedKeys)
code += makeFooter()
return CodeFile(code: code, name: enumName + ".swift")
}
private static func enumName(forFileName fileName: String) -> String {
return String(fileName.dropLast(".strings".count)) + "Strings"
}
private static func tableName(forFileName fileName: String) -> String {
return String(fileName.dropLast(".strings".count))
}
private static func makeHeader(forEnumName enumName: String) -> String {
return """
//
// \(enumName).swift
//
// Generated by LocalizationManagerGenerator script.
// Created by Dorian Grolaux
//
//
import Foundation
enum \(enumName): LocalizationManager {
"""
}
private static func makeTableComputedVar(forTableName tableName: String) -> String {
return """
static var tableName: String {
return \"\(tableName)\"
}
"""
}
enum CaseNameParameterType {
case none
case unnamed
case named
}
private static func makeCaseName(for pathedKey: PathedKey, parameterType: CaseNameParameterType) -> String {
let components = pathedKey.key.replacingOccurrences(of: ".", with: "_").split(separator: "_")
var caseName = components.reduce("", { (result, component) -> String in
guard !result.isEmpty else {
return result + component
}
var component = component
return result + component.removeFirst().uppercased() + component
})
let formatSpecifiersInBaseValue = pathedKey.formatSpecifiersInBaseValue()
if formatSpecifiersInBaseValue.count == 0 {
return caseName // No parameters -> nothing to add
}
switch parameterType {
case .none:
break // Nothing to add
case .unnamed:
caseName += "(" + formatSpecifiersInBaseValue.joined(separator: ", ") + ")"
case .named:
caseName += "(" + namedParameterList(for: pathedKey, includingLet: true).joined(separator: ", ") + ")"
}
return caseName
}
private static func namedParameterList(for pathedKey: PathedKey, includingLet: Bool) -> [String] {
var index:Int = 1
return pathedKey.formatSpecifiersInBaseValue().map { specifier in
let currentIndex = index
index += 1
if includingLet {
return "let arg\(currentIndex)"
} else {
return "arg\(currentIndex)"
}
}
}
private static func makeCases(for pathedKeys: [PathedKey]) -> String {
var result = "\n"
pathedKeys.forEach {
let caseName = makeCaseName(for: $0, parameterType: .unnamed)
result += "\n" + "/// \"\($0.baseValue)\"".indented(depth: 1)
result += "\n" + "case \(caseName)".indented(depth: 1)
}
return result
}
private static func makeLocalizableKeys(for pathedKeys: [PathedKey]) -> String {
var result = "\n"
result += "\n" + "var localizableKey: String {".indented(depth: 1)
result += "\n" + "get {".indented(depth: 2)
result += "\n" + "switch self {".indented(depth: 3)
pathedKeys.forEach {
let caseName = makeCaseName(for: $0, parameterType: .none)
result += "\n" + "case .\(caseName): return \"\($0.key)\"".indented(depth: 4)
}
result += "\n" + "}".indented(depth: 3)
result += "\n" + "}".indented(depth: 2)
result += "\n" + "}".indented(depth: 1)
return result
}
private static func makeArgumentsList(for pathedKeys: [PathedKey]) -> String {
var result = "\n"
result += "\n" + "var arguments: [CVarArg]? {".indented(depth: 1)
result += "\n" + "get {".indented(depth: 2)
result += "\n" + "switch self {".indented(depth: 3)
pathedKeys.forEach {
let caseName = makeCaseName(for: $0, parameterType: .named)
let parameters = $0.formatSpecifiersInBaseValue().count
if parameters > 0 {
result += "\n" + "case .\(caseName): return [\(namedParameterList(for: $0, includingLet: false).joined(separator: ", "))]".indented(depth: 4)
} else {
result += "\n" + "case .\(caseName): return nil".indented(depth: 4)
}
}
result += "\n" + "}".indented(depth: 3)
result += "\n" + "}".indented(depth: 2)
result += "\n" + "}".indented(depth: 1)
return result
}
private static func makeFooter() -> String {
return "\n}"
}
}
// MARK: Business logic
private func write(file: CodeFile, inFolderAt folderURL: URL) {
let fileURL = URL(fileURLWithPath: file.name, relativeTo: folderURL)
try? file.code.write(to: fileURL, atomically: true, encoding: .utf8)
}
private func echo(_ string: String) {
let process = Process()
process.launchPath = "/bin/sh"
process.arguments = ["-c", "echo \"\(string.description)\""]
process.launch()
}
private func getStringFileURLs(forFolderAt folderURL: URL) -> [URL] {
let enumerator = FileManager.default.enumerator(atPath: baseLPROJURL.path)
return (enumerator?.allObjects as! [String]).filter { $0.contains(".strings") }.map { URL(fileURLWithPath: $0, relativeTo: folderURL) }
}
private func generateAllLocalizationManagers() {
print("Generating LocalizationManagers...")
let stringFiles = getStringFileURLs(forFolderAt: baseLPROJURL)
stringFiles.forEach {
guard let file = EnumGenerator.generate(forFileAt: $0) else {
return
}
print("Generating \(file.name) at \($0.lastPathComponent)")
write(file: file, inFolderAt: outputFolderURL!)
}
print("Done Generating")
}
private func setupOutputFolder() {
var isDirectory = ObjCBool(true)
let outputPath = "\(projectFolderURL.path)/\(outputFolderName)"
let exists = FileManager.default.fileExists(atPath: outputPath, isDirectory: &isDirectory)
if !exists || !isDirectory.boolValue {
try? FileManager.default.createDirectory(atPath: outputPath, withIntermediateDirectories: false)
}
outputFolderURL = URL(fileURLWithPath: outputPath, isDirectory: true, relativeTo: currentFolderURL)
}
private func validateLPROJs() {
print("Validating String files...")
let baseStringFiles: [StringsFile] = getStringFileURLs(forFolderAt: baseLPROJURL)
.compactMap { EnumGenerator.stringsFile(forFileAt: $0) }
.sorted(by: { $0.name < $1.name })
let allOtherLPROJS = try! FileManager.default.contentsOfDirectory(at: projectFolderURL, includingPropertiesForKeys: nil).filter { $0.lastPathComponent.contains(".lproj") && !$0.lastPathComponent.starts(with: "Base") }
allOtherLPROJS.forEach { lprojURL in
print("Validating \(lprojURL.lastPathComponent)")
let extraStringFiles = getStringFileURLs(forFolderAt: lprojURL)
.compactMap { EnumGenerator.stringsFile(forFileAt: $0) }
.sorted(by: { $0.name < $1.name })
baseStringFiles.forEach { baseStringFile in
guard let extraStringFile = extraStringFiles.first(where: { $0.name == baseStringFile.name }) else {
guard var val = allMissingFiles[baseStringFile.name] else {
allMissingFiles[baseStringFile.name] = [lprojURL.lastPathComponent]
return
}
val.append(lprojURL.lastPathComponent)
allMissingFiles[baseStringFile.name] = val
return
}
baseStringFile.pathedKeys.forEach { key in
if !extraStringFile.pathedKeys.contains(where: {$0.key == key.key}) {
guard var val = allMissingKeys[key] else {
allMissingKeys[key] = (file: baseStringFile.name, lprojs: [lprojURL.lastPathComponent])
return
}
val.lprojs.append(lprojURL.lastPathComponent)
allMissingKeys[key] = val
}
}
}
}
allMissingKeys.forEach { (key: PathedKey, value: (file: String, lprojs: [String])) in
echo("$SRCROOT/\(CommandLine.arguments[1])/en.lproj/\(value.file):\(key.line): warning: Key \\\"\(key.key)\\\" is missing for \(value.lprojs.joined(separator: ", "))")
}
allMissingFiles.forEach {
echo("SRCROOT/\(CommandLine.arguments[1])/en.lproj/\($0.key):0: warning: File \\\"\($0.key)\\\" is missing for \($0.value.joined(separator: ", "))")
}
print("Done Validating")
}
func setup() {
setupOutputFolder()
write(file: CodeFile(code: LocalizationManagerCodeString, name: "LocalizationManager.swift"), inFolderAt: outputFolderURL!)
validateLPROJs()
}
setup()
generateAllLocalizationManagers()
print("All done!")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment