Last active
October 29, 2022 23:41
-
-
Save MrSkwiggs/9cffc243a77a0b3088ab019fa0939b5e to your computer and use it in GitHub Desktop.
A Swift script that generates enums for each strings file your project contains. Compile-time localization validation !
This file contains hidden or 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/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