diff --git a/.gitignore b/.gitignore index 330d167..d5e5013 100644 --- a/.gitignore +++ b/.gitignore @@ -40,11 +40,11 @@ playground.xcworkspace # Packages/ # Package.pins # Package.resolved -# *.xcodeproj +*.xcodeproj # # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm .build/ @@ -57,7 +57,7 @@ playground.xcworkspace # Pods/ # # Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace +*.xcworkspace # Carthage # diff --git a/MergeL10n b/MergeL10n new file mode 100755 index 0000000..9ced0aa Binary files /dev/null and b/MergeL10n differ diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..a8fba1e --- /dev/null +++ b/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "FoundationExtensions", + "repositoryURL": "https://github.com/teufelaudio/FoundationExtensions", + "state": { + "branch": null, + "revision": "b393be572e8e6e424eb38a096ca28b2bf8f3744e", + "version": "0.1.3" + } + }, + { + "package": "swift-argument-parser", + "repositoryURL": "https://github.com/apple/swift-argument-parser", + "state": { + "branch": null, + "revision": "3d79b2b5a2e5af52c14e462044702ea7728f5770", + "version": "0.1.0" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f18afd4 --- /dev/null +++ b/Package.swift @@ -0,0 +1,23 @@ +// swift-tools-version:5.2 +import PackageDescription + +let package = Package( + name: "MergeL10n", + platforms: [.macOS(.v10_15)], + products: [ + .executable(name: "MergeL10n", targets: ["MergeL10n"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser", .upToNextMajor(from: "0.0.6")), + .package(url: "https://github.com/teufelaudio/FoundationExtensions", .upToNextMajor(from: "0.1.1")) + ], + targets: [ + .target( + name: "MergeL10n", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "FoundationExtensionsStatic", package: "FoundationExtensions") + ] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..a626c68 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# MergeL10n + +A description of this package. diff --git a/Sources/MergeL10n/Commands/MergeL10n.swift b/Sources/MergeL10n/Commands/MergeL10n.swift new file mode 100644 index 0000000..d8cf627 --- /dev/null +++ b/Sources/MergeL10n/Commands/MergeL10n.swift @@ -0,0 +1,16 @@ +// +// MergeL10n.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +import ArgumentParser + +struct MergeL10n: ParsableCommand { + static var configuration = CommandConfiguration( + abstract: "Localizable utils", + subcommands: [PseudoToLanguages.self] + ) +} diff --git a/Sources/MergeL10n/Commands/PseudoToLanguages.swift b/Sources/MergeL10n/Commands/PseudoToLanguages.swift new file mode 100644 index 0000000..22fd9c2 --- /dev/null +++ b/Sources/MergeL10n/Commands/PseudoToLanguages.swift @@ -0,0 +1,164 @@ +// +// PseudoToLanguages.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +import ArgumentParser +import Combine +import Foundation +import FoundationExtensions + +struct World { + let fileManager: SimpleFileManager + let environmentVariables: EnvironmentVariables +} + +extension World { + static let `default` = World( + fileManager: .default, + environmentVariables: .default + ) +} + +struct SimpleFileManager { + let fileExists: (String) -> Bool + let readTextFile: (String, String.Encoding) -> Result + let createTextFile: (String, String, String.Encoding) -> Bool +} + +extension SimpleFileManager { + static let `default` = SimpleFileManager( + fileExists: FileManager.default.fileExists, + readTextFile: { path, encoding in + Result { try String(contentsOfFile: path, encoding: encoding) } + }, + createTextFile: { path, contents, encoding in + FileManager.default.createFile(atPath: path, contents: contents.data(using: encoding)) + } + ) +} + +struct EnvironmentVariables { + let get: (String) -> String? + let set: (String, String) -> Void + let loadDotEnv: (String) -> Void +} + +extension EnvironmentVariables { + static let `default` = EnvironmentVariables( + get: { name in (getenv(name)).flatMap { String(utf8String: $0) } }, + set: { key, value in setenv(key, value, 1) }, + loadDotEnv: { path in + guard let file = try? String(contentsOfFile: path, encoding: .utf8) else { return } + file + .split { $0 == "\n" || $0 == "\r\n" } + .map(String.init) + .forEach { fullLine in + let line = fullLine.trimmingCharacters(in: .whitespacesAndNewlines) + + guard line[line.startIndex] != "#" else { return } + guard !line.isEmpty else { return } + let parts = line.split(separator: "=", maxSplits: 1).map(String.init) + let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: .init(arrayLiteral: "\"")) + setenv(key, value, 1) + } + } + ) +} + +struct PseudoToLanguages: ParsableCommand { + @Option(help: "Separated-by-comma list of languages that should be created. Environment Variable SUPPORTED_LANGUAGES is an alternative.") + var languages: String? + + @Option(help: "Separated-by-comma list of paths to base localizable strings folders. In this folder we expect to find zz.lproj directory and files. Environment Variable L10N_BASE_PATHS is an alternative.") + var basePaths: String? + + @Option(default: "en", help: "Which language is used for development. Default: en.") + var developmentLanguage: String + + func run() throws { + let world = World.default + let paramLanguages = self.languages + let paramBasePaths = self.basePaths + let paramDevelopmentLanguage = self.developmentLanguage + + _ = try languagesAndPaths(languages: paramLanguages, paths: paramBasePaths) + .mapResultError(identity) + .contramapEnvironment(\World.environmentVariables) + .flatMapResult { languages, paths -> Reader> in + print("Running MergeL10n with languages: \(languages) and development language: \(paramDevelopmentLanguage)") + + return paths + .traverse { folder in + self.run(folder: folder, languages: languages, developmentLanguage: paramDevelopmentLanguage) + .mapResultError(identity) + .contramapEnvironment(\.fileManager) + } + .mapValue { $0.traverse(identity) } + } + .inject(world) + .get() + } + + private func run(folder: String, languages: [String], developmentLanguage: String) -> Reader>{ + Reader { fileManager in + LocalizedStringFile(basePath: folder, language: "zz") + .read(encoding: .utf16) + .inject(fileManager) + .flatMap { zzEntries in + languages.traverse { language in + LocalizedStringFile.replace( + language: LocalizedStringFile(basePath: folder, language: language), + withKeysFrom: zzEntries, + fillWithEmpty: language == developmentLanguage, + encoding: .utf8 + ).inject(fileManager) + } + }.map(ignore) + } + } + + private func languagesAndPaths(languages: String?, paths: String?) -> Reader> { + + Reader { environmentVariables in + if self.languages == nil || self.basePaths == nil { + // Load the .env file + environmentVariables.loadDotEnv(".env") + } + + return zip( + languages.map(Result.success) + ?? self.supportedLanguagesFromEnvironment().inject(environmentVariables), + paths.map(Result.success) + ?? self.basePathsFromEnvironment().inject(environmentVariables) + ).map { (languages: String, paths: String) -> (languages: [String], paths: [String]) in + ( + languages: languages.split(separator: ",").map(String.init), + paths: paths.split(separator: ",").map(String.init) + ) + } + } + } + + private func supportedLanguagesFromEnvironment() -> Reader> { + Reader { environmentVariables in + environmentVariables.get("SUPPORTED_LANGUAGES") + .toResult(orError: ValidationError( + "Use --languages:\"en,pt\" option or set `SUPPORTED_LANGUAGES` environment variable before running the script. File .env is also an option." + )) + } + } + + private func basePathsFromEnvironment() -> Reader> { + Reader { environmentVariables in + environmentVariables.get("L10N_BASE_PATHS") + .toResult(orError: ValidationError( + "Use --base-paths:\"SomeFolder/Resources,AnotherFolder/Resources\" option or set `L10N_BASE_PATHS` environment variable before running the script. File .env is also an option." + )) + } + } +} diff --git a/Sources/MergeL10n/FunctionalParserLib/Parser.swift b/Sources/MergeL10n/FunctionalParserLib/Parser.swift new file mode 100644 index 0000000..25d64dd --- /dev/null +++ b/Sources/MergeL10n/FunctionalParserLib/Parser.swift @@ -0,0 +1,129 @@ +// +// Parser.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +import Foundation + +// MARK: - Localizable.strings File Parsing + +struct Parser { + let run: (inout Substring) -> A? + + func run(_ str: String) -> (match: A?, rest: Substring) { + var str = str[...] + let match = self.run(&str) + return (match, str) + } + + func map(_ f: @escaping (A) -> B) -> Parser { + Parser { str in + self.run(&str).map(f) + } + } + + func flatMap(_ f: @escaping (A) -> Parser) -> Parser { + Parser { str in + let original = str + let matchA = self.run(&str) + let parserB = matchA.map(f) + guard let matchB = parserB?.run(&str) else { + str = original + return nil + } + return matchB + } + } +} +func literal(_ literal: String) -> Parser { + Parser { str in + guard str.hasPrefix(literal) else { return nil } + str.removeFirst(literal.count) + return () + } +} +let charParser = Parser { str in + guard !str.isEmpty else { return nil } + return str.removeFirst() +} +func always(_ a: A) -> Parser { Parser { _ in a } } +func never() -> Parser { Parser { _ in nil } } +func zip(_ a: Parser, _ b: Parser) -> Parser<(A, B)> { + Parser<(A, B)> { str in + let original = str + guard let matchA = a.run(&str) else { return nil } + guard let matchB = b.run(&str) else { + str = original + return nil + } + return (matchA, matchB) + } +} +func zip(_ a: Parser, _ b: Parser, _ c: Parser ) -> Parser<(A, B, C)> { + zip(a, zip(b, c)).map { a, bc in (a, bc.0, bc.1) } +} +func zip(_ a: Parser, _ b: Parser, _ c: Parser, _ d: Parser ) -> Parser<(A, B, C, D)> { + zip(a, zip(b, c, d)).map { a, bcd in (a, bcd.0, bcd.1, bcd.2) } +} +func zip(_ a: Parser, _ b: Parser, _ c: Parser, _ d: Parser, _ e: Parser) -> Parser<(A, B, C, D, E)> { + zip(a, zip(b, c, d, e)).map { a, bcde in (a, bcde.0, bcde.1, bcde.2, bcde.3) } +} +// swiftlint:disable function_parameter_count line_length +func zip(_ a: Parser, _ b: Parser, _ c: Parser, _ d: Parser, _ e: Parser, _ f: Parser) -> Parser<(A, B, C, D, E, F)> { + zip(a, zip(b, c, d, e, f)).map { a, bcdef in (a, bcdef.0, bcdef.1, bcdef.2, bcdef.3, bcdef.4) } +} +func zip(_ a: Parser, _ b: Parser, _ c: Parser, _ d: Parser, _ e: Parser, _ f: Parser, _ g: Parser) -> Parser<(A, B, C, D, E, F, G)> { + zip(a, zip(b, c, d, e, f, g)).map { a, bcdefg in (a, bcdefg.0, bcdefg.1, bcdefg.2, bcdefg.3, bcdefg.4, bcdefg.5) } +} +// swiftlint:enable function_parameter_count line_length +func prefix(while p: @escaping (Character) -> Bool) -> Parser { + Parser { str in + let prefix = str.prefix(while: p) + str.removeFirst(prefix.count) + return prefix + } +} +let zeroOrMoreSpaces = prefix(while: { $0 == " " }).map { _ in () } +let oneOrMoreSpaces = prefix(while: { $0 == " " }).flatMap { $0.isEmpty ? never() : always(()) } +let zeroOrMoreSpacesOrLines = prefix(while: { $0 == " " || $0 == "\n" }).map { _ in () } +func zeroOrMore(_ p: Parser, separatedBy s: Parser = literal("")) -> Parser<[A]> { + Parser<[A]> { str in + var rest = str + var matches: [A] = [] + while let match = p.run(&str) { + rest = str + matches.append(match) + if s.run(&str) == nil { + return matches + } + } + str = rest + return matches + } +} +func oneOf(_ ps: [Parser]) -> Parser { + Parser { str in + for p in ps { + if let match = p.run(&str) { + return match + } + } + return nil + } +} +func not(_ p: Parser) -> Parser { + Parser { str in + let backup = str + if p.run(&str) != nil { + str = backup + return nil + } + return charParser.run(&str) + } +} +func string(until: Parser) -> Parser { + zeroOrMore(not(until)).map { String($0) } +} diff --git a/Sources/MergeL10n/Models/LocalizedStringEntry.swift b/Sources/MergeL10n/Models/LocalizedStringEntry.swift new file mode 100644 index 0000000..d0f4d54 --- /dev/null +++ b/Sources/MergeL10n/Models/LocalizedStringEntry.swift @@ -0,0 +1,41 @@ +// +// LocalizedStringEntry.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +import Foundation + +struct LocalizedStringEntry: Equatable, Hashable, CustomStringConvertible { + let key: String + let value: String + var comment: String + + func hash(into hasher: inout Hasher) { + hasher.combine(key) + } + + var description: String { + """ + /* \(comment) */ + "\(key)" = "\(value)"; + """ + } +} + +extension LocalizedStringEntry { + static func merge(keysSource: [LocalizedStringEntry], valuesSource: [LocalizedStringEntry], fillWithEmpty: Bool) -> [LocalizedStringEntry] { + keysSource.compactMap { keysSourceEntry in + guard var valuesSourceEntry = valuesSource.first(where: { $0.key == keysSourceEntry.key }) else { + return fillWithEmpty + ? LocalizedStringEntry(key: keysSourceEntry.key, value: "", comment: keysSourceEntry.comment) + : nil + } + + valuesSourceEntry.comment = keysSourceEntry.comment + return valuesSourceEntry + } + } +} diff --git a/Sources/MergeL10n/Models/LocalizedStringFile.swift b/Sources/MergeL10n/Models/LocalizedStringFile.swift new file mode 100644 index 0000000..6aa4b4e --- /dev/null +++ b/Sources/MergeL10n/Models/LocalizedStringFile.swift @@ -0,0 +1,102 @@ +// +// LocalizedStringFile.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +import Foundation +import FoundationExtensions + +struct LocalizedStringFile { + let basePath: String + let language: String + var fullPath: String { + "\(basePath)/\(language).lproj/Localizable.strings" + } + + func read(encoding: String.Encoding) -> Reader> { + Reader { fileManager in + Result.pure(fileManager.fileExists(self.fullPath)) + .flatMap { exists in + exists ? .success(()) : .failure(LocalizedStringFileError.folderNotFound(self)) + } + .flatMap { _ in + fileManager + .readTextFile(self.fullPath, encoding) + .mapError { _ in LocalizedStringFileError.fileCannotBeRead(self) } + } + .flatMap { contents in + self.parser().run(contents).match.toResult(orError: LocalizedStringFileError.fileCannotBeParsed(self)) + } + .flatMap { entries in + entries.isEmpty ? .failure(LocalizedStringFileError.filePossibleEncodingProblemWhileParsing(self)) : .success(entries) + } + } + } + + func save(entries: [LocalizedStringEntry], encoding: String.Encoding) -> Reader> { + Reader { fileManager in + Result.pure( + entries + .map({ $0.description }) + .joined(separator: "\n\n") + ) + .flatMap { contents in + fileManager + .createTextFile(self.fullPath, contents, encoding) + ? .success(()) : .failure(LocalizedStringFileError.fileCannotBeSaved(self)) + } + } + } +} + +extension LocalizedStringFile { + func parser() -> Parser<[LocalizedStringEntry]> { + let comment = zip( + literal("/* "), + string(until: literal(" */")), + literal(" */"), + zeroOrMoreSpacesOrLines + ).map { _, comments, _, _ in comments } + + let localizedStringEntry: Parser = zip( + comment, + literal("\""), + string(until: literal("\" = \"")), + literal("\" = \""), + string(until: literal("\";")), + literal("\";"), + zeroOrMoreSpacesOrLines + ).map { comment, _, key, _, value, _, _ in + LocalizedStringEntry(key: key, value: value, comment: comment) + } + + return zeroOrMore(localizedStringEntry) + } +} + +enum LocalizedStringFileError: Error { + case folderNotFound(LocalizedStringFile) + case fileCannotBeRead(LocalizedStringFile) + case fileCannotBeParsed(LocalizedStringFile) + case filePossibleEncodingProblemWhileParsing(LocalizedStringFile) + case fileCannotBeSaved(LocalizedStringFile) +} + +extension LocalizedStringFile { + static func replace(language: LocalizedStringFile, withKeysFrom pseudoLanguage: [LocalizedStringEntry], fillWithEmpty: Bool, encoding: String.Encoding) + -> Reader> { + language + .read(encoding: encoding) + .mapValue { + $0.map { languageEntries -> [LocalizedStringEntry] in + LocalizedStringEntry.merge(keysSource: pseudoLanguage, valuesSource: languageEntries, fillWithEmpty: fillWithEmpty) + } + } + .flatMapResult { entries in + language.save(entries: entries, encoding: encoding) + } + } +} diff --git a/Sources/MergeL10n/main.swift b/Sources/MergeL10n/main.swift new file mode 100644 index 0000000..6676968 --- /dev/null +++ b/Sources/MergeL10n/main.swift @@ -0,0 +1,9 @@ +// +// main.swift +// MergeL10n +// +// Created by Luiz Barbosa on 17.06.20. +// Copyright © 2020 Lautsprecher Teufel GmbH. All rights reserved. +// + +MergeL10n.main()