├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Stringly │ └── main.swift ├── StringlyCLI │ ├── CLI.swift │ ├── Commands │ │ ├── GenerateCommand.swift │ │ └── GenerateFileCommand.swift │ ├── FileType.swift │ ├── FileWriter.swift │ ├── GenerateError.swift │ ├── Loader.swift │ └── PlatformType.swift └── StringlyKit │ ├── Generator.swift │ ├── Generators │ ├── StringsDictGenerator.swift │ ├── StringsGenerator.swift │ └── SwiftGenerator.swift │ ├── StringGroup.swift │ └── StringLocalization.swift └── Tests ├── Fixtures ├── Strings.swift ├── Strings.toml ├── Strings.yml ├── de.lproj │ └── Strings.strings └── en.lproj │ ├── Strings.strings │ └── Strings.stringsdict ├── StringlyCLITests ├── StringDiff.swift ├── StringlyCLITests.swift └── TestHelpers.swift └── StringlyKitTests └── StringlyKitTests.swift /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: {} 4 | pull_request: {} 5 | jobs: 6 | run: 7 | runs-on: macos-11 8 | name: Xcode ${{ matrix.xcode }} 9 | strategy: 10 | matrix: 11 | xcode: ["13.0"] 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: Set Xcode 15 | run: | 16 | echo "Available Xcode versions:" 17 | ls /Applications | grep Xcode 18 | echo "Choosing Xcode_${{ matrix.xcode }}.app" 19 | sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app 20 | xcodebuild -version 21 | swift --version 22 | swift package --version 23 | - name: Resolve 24 | run: swift package resolve 25 | - name: Build 26 | run: swift build 27 | - name: Test 28 | run: swift test 2>&1 | xcpretty 29 | - name: Compile Strings.swift 30 | run: swiftc Tests/Fixtures/Strings.swift 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | .swiftpm 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yonas Kolb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Codability", 6 | "repositoryURL": "https://github.com/yonaskolb/Codability", 7 | "state": { 8 | "branch": null, 9 | "revision": "eb5bac78e0679f521c3f058c1eb9be0dd657dadd", 10 | "version": "0.2.1" 11 | } 12 | }, 13 | { 14 | "package": "NetTime", 15 | "repositoryURL": "https://github.com/dduan/NetTime", 16 | "state": { 17 | "branch": null, 18 | "revision": "cc71922e621731b59b2b909eac7d6748c4a5a752", 19 | "version": "0.2.2" 20 | } 21 | }, 22 | { 23 | "package": "PathKit", 24 | "repositoryURL": "https://github.com/kylef/PathKit", 25 | "state": { 26 | "branch": null, 27 | "revision": "3bfd2737b700b9a36565a8c94f4ad2b050a5e574", 28 | "version": "1.0.1" 29 | } 30 | }, 31 | { 32 | "package": "Rainbow", 33 | "repositoryURL": "https://github.com/onevcat/Rainbow", 34 | "state": { 35 | "branch": null, 36 | "revision": "9c52c1952e9b2305d4507cf473392ac2d7c9b155", 37 | "version": "3.1.5" 38 | } 39 | }, 40 | { 41 | "package": "Spectre", 42 | "repositoryURL": "https://github.com/kylef/Spectre.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "26cc5e9ae0947092c7139ef7ba612e34646086c7", 46 | "version": "0.10.1" 47 | } 48 | }, 49 | { 50 | "package": "SwiftCLI", 51 | "repositoryURL": "https://github.com/jakeheis/SwiftCLI", 52 | "state": { 53 | "branch": null, 54 | "revision": "2e949055d9797c1a6bddcda0e58dada16cc8e970", 55 | "version": "6.0.3" 56 | } 57 | }, 58 | { 59 | "package": "TOMLDeserializer", 60 | "repositoryURL": "https://github.com/dduan/TOMLDeserializer", 61 | "state": { 62 | "branch": null, 63 | "revision": "ea6b941e53cdedc9f4737903769eb9f2cf8d2acf", 64 | "version": "0.2.4" 65 | } 66 | }, 67 | { 68 | "package": "Yams", 69 | "repositoryURL": "https://github.com/jpsim/Yams", 70 | "state": { 71 | "branch": null, 72 | "revision": "b08dba4bcea978bf1ad37703a384097d3efce5af", 73 | "version": "1.0.2" 74 | } 75 | } 76 | ] 77 | }, 78 | "version": 1 79 | } 80 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Stringly", 8 | products: [ 9 | .executable(name: "stringly", targets: ["Stringly"]), 10 | .library(name: "StringlyKit", targets: ["StringlyKit"]) 11 | ], 12 | dependencies: [ 13 | .package(url: "https://github.com/jpsim/Yams", from: "1.0.0"), 14 | .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), 15 | .package(url: "https://github.com/onevcat/Rainbow", from: "3.1.0"), 16 | .package(url: "https://github.com/jakeheis/SwiftCLI", from: "6.0.3"), 17 | .package(url: "https://github.com/dduan/TOMLDeserializer", from: "0.2.4"), 18 | .package(url: "https://github.com/yonaskolb/Codability", from: "0.2.1"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Stringly", 23 | dependencies: ["StringlyCLI"]), 24 | .target( 25 | name: "StringlyCLI", 26 | dependencies: [ 27 | "Yams", 28 | "Rainbow", 29 | "SwiftCLI", 30 | "PathKit", 31 | "TOMLDeserializer", 32 | "StringlyKit", 33 | ]), 34 | .target( 35 | name: "StringlyKit", 36 | dependencies: ["Codability"]), 37 | .testTarget( 38 | name: "StringlyCLITests", 39 | dependencies: ["StringlyCLI", "PathKit"]), 40 | .testTarget( 41 | name: "StringlyKitTests", 42 | dependencies: ["StringlyKit", "PathKit"]), 43 | ] 44 | ) 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Stringly 2 | 3 | Stringly generates type safe localization files from a source `yaml`,`json`, or `toml` file. At the moment only outputs for Apple platforms are supported, but a generator for Android's R.strings is easy to add 4 | 5 | - ✅ **Multi-language** support 6 | - ✅ **Named placeholders** 7 | - ✅ **Plural** support 8 | - ✅ Compile safe **Swift** accessors 9 | 10 | ## Usage 11 | 12 | See help 13 | ``` 14 | stringly help 15 | ``` 16 | To generate all files in all languages 17 | ``` 18 | stringly generate Strings.yml 19 | ``` 20 | To generate a single file in a certain langage 21 | ``` 22 | stringly generate-file Strings.yml Strings.strings --language de 23 | ``` 24 | 25 | ## Installing 26 | 27 | Make sure Xcode 13 is installed first. 28 | 29 | ### [Mint](https://github.com/yonaskolb/mint) 30 | ```sh 31 | mint install yonaskolb/stringly 32 | ``` 33 | 34 | ### Swift Package Manager 35 | 36 | **Use as CLI** 37 | 38 | ```shell 39 | git clone https://github.com/yonaskolb/Stringly.git 40 | cd Stringly 41 | swift run stringly 42 | ``` 43 | 44 | **Use as dependency** 45 | 46 | Add the following to your Package.swift file's dependencies: 47 | 48 | ```swift 49 | .package(url: "https://github.com/yonaskolb/Stringly.git", from: "0.7.0"), 50 | ``` 51 | 52 | And then import wherever needed: `import StringlyKit` 53 | 54 | ## Example 55 | 56 | Given a source `Strings.yml`: 57 | ```yml 58 | auth: # grouping of strings 59 | loginButton: Log In # If you don't specify a language it defaults to a base language 60 | emailTitle: 61 | en: Email # specifying a language 62 | passwordTitle: 63 | en: Password 64 | de: Passwort # multiple languages 65 | error: # infinitely nested groups 66 | wrongEmailPassword: Incorrect email/password combination 67 | home: 68 | title: Hello {name} # this is a placeholder. Without a type defaults to %@ on apple platforms 69 | postCount: "Total posts: {postCount:d}" # the placeholder now has a type %d 70 | day: "Day: {}" # an unnamed placeholder 71 | escaped: Text with escaped \{braces} # escape braces in text by using \{ 72 | articles: # this is a pluralized string 73 | en: You have {articleCount:d} # placeholder will be replaced with pluralization 74 | en.articleCount: # supports pluralizing multiple placeholders in a single string 75 | none: no articles 76 | one: one article 77 | other: {articleCount:d} articles 78 | ``` 79 | 80 | This generates `.swift`, `.strings`, and `.stringsdict` files for multiple languages. 81 | 82 | The Swift file then allows usage like this: 83 | ```swift 84 | errorLabel.text = Strings.auth.error.wrongEmailPassword 85 | welcomeLabel.text = Strings.home.title(name: "John") 86 | postsLabel.text = Strings.home.postCount(postCount: 10) 87 | day.text = Strings.home.day("Monday") 88 | articleLabel.text = Strings.home.articles(articleCount: 4) 89 | ``` 90 | 91 | ## Future Directions 92 | - Comments and other data for keys 93 | - Generate files for other platforms like Android `R.string` file or translation specific files 94 | - Importing of translation files 95 | -------------------------------------------------------------------------------- /Sources/Stringly/main.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import SwiftCLI 4 | import StringlyCLI 5 | 6 | let cli = StringlyCLI() 7 | let status = cli.run() 8 | exit(status) 9 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/CLI.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 17/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | import PathKit 11 | 12 | public class StringlyCLI { 13 | 14 | let version = "0.9.0" 15 | let cli: CLI 16 | 17 | public init() { 18 | cli = CLI(name: "stringly", version: version, description: "Generates localization files from a spec", commands: [ 19 | GenerateCommand(), 20 | GenerateFileCommand(), 21 | ]) 22 | } 23 | 24 | public func run(arguments: [String] = []) -> Int32 { 25 | if arguments.isEmpty { 26 | return cli.go() 27 | } else { 28 | return cli.go(with: arguments) 29 | } 30 | } 31 | } 32 | 33 | extension Path: ConvertibleFromString { 34 | public init?(input: String) { 35 | self.init(input) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/Commands/GenerateCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 17/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | import StringlyKit 11 | import PathKit 12 | import Rainbow 13 | 14 | class GenerateCommand: Command { 15 | 16 | let name: String = "generate" 17 | let shortDescription: String = "Generates all required localization files for a given platform" 18 | let platform = Key("--platform", "-p", description: "The platform to generate files for. Defaults to apple") 19 | 20 | @Param 21 | var sourcePath: Path 22 | let directoryPath = Key("--directory", "-d", description: "The directory to generate the files in. Defaults to the directory the source path is in") 23 | let baseLanguage = Key("--base", "-b", description: "The base language to use. Defaults to en") 24 | 25 | func execute() throws { 26 | let sourcePath = self.sourcePath.normalize() 27 | let directoryPath = self.directoryPath.value ?? sourcePath.parent() 28 | let baseLanguage = self.baseLanguage.value ?? "en" 29 | let platform = self.platform.value ?? .apple 30 | 31 | let strings = try Loader.loadStrings(from: sourcePath, baseLanguage: baseLanguage) 32 | let languages = strings.getLanguages() 33 | 34 | switch platform { 35 | case .apple: 36 | for language in languages { 37 | try FileWriter.write(fileType: .strings, strings: strings, language: language, destinationPath: directoryPath + "\(language).lproj/Strings.strings") 38 | 39 | if strings.languageHasPlurals(language) { 40 | try FileWriter.write(fileType: .stringsDict, strings: strings, language: language, destinationPath: directoryPath + "\(language).lproj/Strings.stringsdict") 41 | } 42 | } 43 | try FileWriter.write(fileType: .swift, strings: strings, language: baseLanguage, destinationPath: directoryPath + "Strings.swift") 44 | case .android: 45 | fatalError("Android not yet supported".red) 46 | } 47 | 48 | } 49 | } 50 | 51 | 52 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/Commands/GenerateFileCommand.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 17/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | import StringlyKit 11 | import PathKit 12 | import Yams 13 | import TOMLDeserializer 14 | import Rainbow 15 | 16 | class GenerateFileCommand: Command { 17 | 18 | let name: String = "generate-file" 19 | let shortDescription: String = "Generates a specific file for a language" 20 | let longDescription: String = """ 21 | Generates a single localization file in a single language from a source file. If no destination path is passed the file content will be written to stdout 22 | """ 23 | 24 | let language = Key("--language", "-l", description: "The language to generate. Defaults to en") 25 | let baseLanguage = Key("--base", "-b", description: "The base language to use. Defaults to en") 26 | 27 | let type = Key("--type", "-t", description: "The file type to generate. Defaults to inferring from the destination file extension") 28 | 29 | @Param 30 | var sourcePath: Path 31 | @Param 32 | var destinationPath: Path? 33 | 34 | func execute() throws { 35 | let sourcePath = self.sourcePath.normalize() 36 | let destinationPath = self.destinationPath?.normalize() 37 | let language = self.language.value ?? "en" 38 | let baseLanguage = self.baseLanguage.value ?? "en" 39 | 40 | let strings = try Loader.loadStrings(from: sourcePath, baseLanguage: baseLanguage) 41 | 42 | let fileType: FileType 43 | if let type = self.type.value { 44 | fileType = type 45 | } else { 46 | if let destinationPath = destinationPath, let type = FileType(path: destinationPath) { 47 | fileType = type 48 | } else { 49 | throw GenerateError.unknownFileType(destinationPath?.extension ?? "") 50 | } 51 | } 52 | 53 | try FileWriter.write(fileType: fileType, strings: strings, language: language, destinationPath: destinationPath) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/FileType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | import PathKit 11 | import StringlyKit 12 | 13 | public enum FileType: String, ConvertibleFromString { 14 | case strings 15 | case stringsDict 16 | case swift 17 | 18 | init?(path: Path) { 19 | switch path.extension?.lowercased() { 20 | case "strings": self = .strings 21 | case "stringsdict": self = .stringsDict 22 | case "swift": self = .swift 23 | default: return nil 24 | } 25 | } 26 | 27 | var generator: Generator { 28 | switch self { 29 | case .strings: return StringsGenerator() 30 | case .stringsDict: return StringsDictGenerator() 31 | case .swift: return SwiftGenerator() 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/FileWriter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | import PathKit 10 | import SwiftCLI 11 | import StringlyKit 12 | 13 | public struct FileWriter { 14 | 15 | public static func write(fileType: FileType, strings: StringGroup, language: String, destinationPath: Path?) throws { 16 | do { 17 | let generator = fileType.generator 18 | let content = try generator.generate(stringGroup: strings, language: language) 19 | try write(content: content, to: destinationPath) 20 | } catch { 21 | throw GenerateError.encodingError(error) 22 | } 23 | } 24 | 25 | static func write(content: String, to destinationPath: Path?) throws { 26 | if let destinationPath = destinationPath { 27 | do { 28 | try destinationPath.parent().mkpath() 29 | if destinationPath.exists, try destinationPath.read() == content { 30 | // same content, don't write 31 | } else { 32 | try destinationPath.write(content) 33 | } 34 | } catch { 35 | throw GenerateError.writingError(error) 36 | } 37 | } else { 38 | Term.stdout.print(content) 39 | } 40 | } 41 | 42 | } 43 | 44 | 45 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/GenerateError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | 11 | enum GenerateError: ProcessError { 12 | 13 | case sourceParseError(Error) 14 | case unstructuredContent 15 | case missingSource 16 | case encodingError(Error) 17 | case writingError(Error) 18 | case unknownFileType(String) 19 | 20 | var exitStatus: Int32 { 1 } 21 | 22 | var message: String? { 23 | return description.red 24 | } 25 | 26 | var description: String { 27 | switch self { 28 | case .sourceParseError: return "Failed to parse source file" 29 | case .unstructuredContent: return "Source file has unstructured content" 30 | case .missingSource: return "Source file does not exist" 31 | case .encodingError: return "Failed to encode file" 32 | case .writingError: return "Failed to write file" 33 | case .unknownFileType(let type): return "Unknown file type \"\(type)\"" 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/Loader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | import StringlyKit 10 | import TOMLDeserializer 11 | import PathKit 12 | import Yams 13 | 14 | public struct Loader { 15 | 16 | public static func loadStrings(from sourcePath: Path, baseLanguage: String) throws -> StringGroup { 17 | if !sourcePath.exists { 18 | throw GenerateError.missingSource 19 | } 20 | let sourceString: String = try sourcePath.read() 21 | let dictionary: [String: Any] 22 | do { 23 | switch sourcePath.extension { 24 | case "toml", "tml": 25 | dictionary = try TOMLDeserializer.tomlTable(with: sourceString) 26 | default: 27 | let yaml = try Yams.load(yaml: sourceString) 28 | guard let dict = yaml as? [String: Any] else { 29 | throw GenerateError.unstructuredContent 30 | } 31 | dictionary = dict 32 | } 33 | } catch { 34 | throw GenerateError.sourceParseError(error) 35 | } 36 | 37 | let strings = StringGroup(dictionary, baseLanguage: baseLanguage) 38 | return strings 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/StringlyCLI/PlatformType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | 11 | public enum PlatformType: String, ConvertibleFromString { 12 | case apple 13 | case android 14 | } 15 | -------------------------------------------------------------------------------- /Sources/StringlyKit/Generator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 2/1/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol Generator { 11 | 12 | func generate(stringGroup: StringGroup, language: String) throws -> String 13 | } 14 | -------------------------------------------------------------------------------- /Sources/StringlyKit/Generators/StringsDictGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 22/10/19. 6 | // 7 | 8 | import Foundation 9 | import Codability 10 | 11 | struct StringsDict: Encodable { 12 | var keys: [String: FormatKey] 13 | 14 | func encode(to encoder: Encoder) throws { 15 | var container = encoder.container(keyedBy: RawCodingKey.self) 16 | 17 | for (key, format) in keys { 18 | try container.encode(format, forKey: .init(string: key)) 19 | } 20 | } 21 | 22 | struct FormatKey: Encodable { 23 | var format: String 24 | var rules: [String: Rule] 25 | 26 | func encode(to encoder: Encoder) throws { 27 | var container = encoder.container(keyedBy: RawCodingKey.self) 28 | try container.encode(format, forKey: "NSStringLocalizedFormatKey") 29 | for (name, variable) in rules { 30 | try container.encode(variable, forKey: .init(string: name)) 31 | } 32 | } 33 | } 34 | 35 | struct Rule: Encodable { 36 | var format: String 37 | var plurals: [StringLocalization.Plural: String] 38 | var ruleType = "NSStringPluralRuleType" 39 | 40 | func encode(to encoder: Encoder) throws { 41 | var container = encoder.container(keyedBy: RawCodingKey.self) 42 | try container.encode(ruleType, forKey: "NSStringFormatSpecTypeKey") 43 | try container.encode(format, forKey: "NSStringFormatValueTypeKey") 44 | for (plural, value) in plurals { 45 | try container.encode(value, forKey: .init(string: plural.rawValue)) 46 | } 47 | } 48 | } 49 | } 50 | 51 | public struct StringsDictGenerator: Generator { 52 | 53 | public init() {} 54 | 55 | public func generate(stringGroup: StringGroup, language: String) throws -> String { 56 | 57 | let encoder = PropertyListEncoder() 58 | encoder.outputFormat = .xml 59 | 60 | var keys: [String: StringsDict.FormatKey] = [:] 61 | 62 | func handleGroup(_ group: StringGroup) { 63 | for (key, string) in group.strings { 64 | guard let language = string.languages[language], !language.plurals.isEmpty else { continue } 65 | var formatString = language.string 66 | for placeholder in string.placeholders { 67 | formatString = formatString.replacingOccurrences(of: placeholder.originalPlaceholder, with: "%#@\(placeholder.name)@") 68 | } 69 | var format = StringsDict.FormatKey(format: formatString, rules: [:]) 70 | for (placeholderString, plurals) in language.plurals { 71 | guard let placeholder = string.getPlaceholder(name: placeholderString), 72 | let placeholderType = placeholder.type else { continue } 73 | let plurals = plurals.mapValues { 74 | string.replacePlaceholders($0) { $0.applePattern } 75 | } 76 | let rule = StringsDict.Rule(format: placeholderType, plurals: plurals) 77 | format.rules[placeholderString] = rule 78 | } 79 | let stringKey = "\(group.pathString)\(group.path.isEmpty ? "" : ".")\(key)" 80 | keys[stringKey] = format 81 | } 82 | 83 | for group in group.groups { 84 | handleGroup(group) 85 | } 86 | } 87 | handleGroup(stringGroup) 88 | 89 | let stringsDict = StringsDict(keys: keys) 90 | 91 | let data = try encoder.encode(stringsDict) 92 | return String(data: data, encoding: .utf8) ?? "" 93 | } 94 | 95 | } 96 | -------------------------------------------------------------------------------- /Sources/StringlyKit/Generators/StringsGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 22/10/19. 6 | // 7 | 8 | import Foundation 9 | import Codability 10 | 11 | public struct StringsGenerator: Generator { 12 | 13 | public init() {} 14 | 15 | public func generate(stringGroup: StringGroup, language: String) throws -> String { 16 | let description = "// This file was auto-generated with https://github.com/yonaskolb/Stringly" 17 | return "\(description)\n\(lines(stringGroup, language: language).joined(separator: "\n"))" 18 | } 19 | 20 | func lines(_ stringGroup: StringGroup, language: String) -> [String] { 21 | var array: [String] = [] 22 | 23 | array += stringGroup.strings 24 | .compactMap { (key, localisationString) in 25 | guard let language = localisationString.languages[language] else { return nil } 26 | let key = "\(stringGroup.pathString)\(stringGroup.path.isEmpty ? "" : ".")\(key)" 27 | let string = localisationString.replacePlaceholders(language.string) { $0.applePattern } 28 | return "\"\(key)\" = \"\(string)\";" 29 | } 30 | .sorted() 31 | 32 | let sortedGroups = stringGroup.groups 33 | .map { group -> [String] in 34 | 35 | // let comment = "\n/*** \(group.pathString.uppercased()) \(String(repeating: "*", count: 50 - group.pathString.count))/" 36 | // let commentChar = "#" 37 | // let lineLength = 50 38 | // let spacing = lineLength - group.pathString.count - 4 39 | // let commentLine = String(repeating: commentChar, count: lineLength) 40 | // let middleLine = "\(String(repeating: " ", count: Int(Double(spacing)/2 + 0.5)))\(group.pathString) \(String(repeating: " ", count: Int(Double(spacing)/2 + 0.5)))" 41 | // let comment = "\n\(commentLine)\n\(commentChar)\(middleLine)\(commentChar)\n\(commentLine)" 42 | if group.strings.values.contains(where: { $0.hasLanguage(language) }) { 43 | let comment = "\n// \(group.pathString.uppercased())" 44 | return [comment] + lines(group, language: language) 45 | } else { 46 | return lines(group, language: language) 47 | } 48 | } 49 | 50 | let groupLines = sortedGroups.reduce([]) { $0 + $1 } 51 | array += groupLines 52 | return array 53 | } 54 | 55 | } 56 | 57 | extension StringLocalization.Placeholder { 58 | 59 | var applePattern: String { 60 | return "%" + (type ?? "@") 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/StringlyKit/Generators/SwiftGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 26/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct SwiftGenerator: Generator { 11 | 12 | let namespace = "Strings" 13 | var tableName: String { namespace } 14 | 15 | public init() {} 16 | 17 | public func generate(stringGroup: StringGroup, language: String) -> String { 18 | 19 | 20 | let content = parseGroup(stringGroup, language: language).replacingOccurrences(of: "\n", with: "\n ") 21 | 22 | let file = """ 23 | // This file was auto-generated with https://github.com/yonaskolb/Stringly 24 | // swiftlint:disable all 25 | 26 | import Foundation 27 | 28 | public enum \(namespace) {\(content) 29 | } 30 | 31 | public protocol StringGroup { 32 | static var localizationKey: String { get } 33 | } 34 | 35 | extension StringGroup { 36 | 37 | public static func string(for key: String, _ args: CVarArg...) -> String { 38 | return \(namespace).localized(key: "\\(localizationKey).\\(key)", args: args) 39 | } 40 | } 41 | 42 | extension \(namespace) { 43 | 44 | /// The bundle uses for localization 45 | public static var bundle: Bundle = Bundle(for: BundleToken.self) 46 | 47 | /// Allows overriding any particular key, for A/B tests for example. Values need to be correct for the current language 48 | public static var overrides: [String: String] = [:] 49 | 50 | fileprivate static func localized(_ key: String, in group: String, _ args: CVarArg...) -> String { 51 | return \(namespace).localized(key: "\\(group).\\(key)", args: args) 52 | } 53 | 54 | fileprivate static func localized(_ key: String, _ args: CVarArg...) -> String { 55 | return \(namespace).localized(key: key, args: args) 56 | } 57 | 58 | fileprivate static func localized(key: String, args: [CVarArg]) -> String { 59 | let format = overrides[key] ?? NSLocalizedString(key, tableName: "\(tableName)", bundle: bundle, comment: "") 60 | return String(format: format, locale: Locale.current, arguments: args) 61 | } 62 | } 63 | 64 | private final class BundleToken {} 65 | """ 66 | return file 67 | } 68 | 69 | func parseGroup(_ group: StringGroup, language: String) -> String { 70 | 71 | var content = "" 72 | if !group.path.isEmpty { 73 | content += "public static let localizationKey = \"\(group.pathString)\"" 74 | } 75 | let strings = group.strings.sorted { $0.key < $1.key } 76 | for (key, localizedString) in strings { 77 | let placeholders: [(name: String, type: String, named: Bool)] = localizedString.placeholders.enumerated().map { index, placeholder in 78 | let name = placeholder.hasName ? placeholder.name : "p\(index)" 79 | let type = PlaceholderType(string: placeholder.type ?? "@")?.rawValue ?? "CVarArg" 80 | return (name, type, placeholder.hasName) 81 | } 82 | 83 | let name = key 84 | var key = "\"\(name)\"" 85 | if !group.path.isEmpty { 86 | key += ", in: localizationKey" 87 | } 88 | 89 | let line: String 90 | if placeholders.isEmpty { 91 | line = "public static let \(name) = \(namespace).localized(\(key))" 92 | } else { 93 | let params = placeholders 94 | .map { "\($0.named ? "" : "_ ")\($0.name): \($0.type)" } 95 | .joined(separator: ", ") 96 | 97 | let callingParams = placeholders 98 | .map { $0.name } 99 | .joined(separator: ", ") 100 | 101 | line = """ 102 | public static func \(name)(\(params)) -> String { 103 | \(namespace).localized(\(key), \(callingParams)) 104 | } 105 | """ 106 | } 107 | let languageString = localizedString.languages[language]! 108 | let comment = localizedString.replacePlaceholders(languageString.string) { "**{\(languageString.plurals.isEmpty ? "" : "pluralized ")\($0.name)}**"} 109 | content += "\n/// \(comment)\n\(line)" 110 | } 111 | 112 | for group in group.groups { 113 | content += """ 114 | 115 | 116 | public enum \(group.path.last!): StringGroup { 117 | \(parseGroup(group, language: language).replacingOccurrences(of: "\n", with: "\n ")) 118 | } 119 | """ 120 | } 121 | return content 122 | } 123 | 124 | } 125 | 126 | fileprivate enum PlaceholderType: String { 127 | case object = "String" 128 | case float = "Float" 129 | case int = "Int" 130 | case char = "CChar" 131 | case cString = "UnsafePointer" 132 | case pointer = "UnsafeRawPointer" 133 | 134 | static let unknown = pointer 135 | 136 | init?(string: String) { 137 | guard let firstChar = string.lowercased().first else { 138 | return nil 139 | } 140 | switch firstChar { 141 | case "@": 142 | self = .object 143 | case "a", "e", "f", "g": 144 | self = .float 145 | case "d", "i", "o", "u", "x": 146 | self = .int 147 | case "c": 148 | self = .char 149 | case "s": 150 | self = .cString 151 | case "p": 152 | self = .pointer 153 | default: 154 | return nil 155 | } 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Sources/StringlyKit/StringGroup.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 17/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct StringGroup: Equatable { 11 | public var path: [String] = [] 12 | public var groups: [StringGroup] = [] 13 | public var strings: [String: StringLocalization] = [:] 14 | 15 | public var pathString: String { path.joined(separator: ".") } 16 | 17 | public init(_ dictionary: [String: Any], baseLanguage: String) { 18 | self.init(dictionary: dictionary, depth: [], baseLanguage: baseLanguage) 19 | } 20 | 21 | init(dictionary: [String: Any], depth: [String], baseLanguage: String) { 22 | path = depth 23 | for (key, value) in dictionary { 24 | if let dictionary = value as? [String: Any] { 25 | if dictionary.keys.contains(baseLanguage) { 26 | let localization = StringLocalization(dictionary) 27 | strings[key] = localization 28 | } else { 29 | let group = StringGroup(dictionary: dictionary, depth: depth + [key], baseLanguage: baseLanguage) 30 | groups.append(group) 31 | } 32 | } else if let string = value as? String { 33 | strings[key] = StringLocalization(language: baseLanguage, string: string) 34 | } 35 | } 36 | self.groups.sort { $0.pathString < $1.pathString } 37 | } 38 | 39 | public init(path: [String] = [], groups: [StringGroup] = [], strings: [String: StringLocalization] = [:]) { 40 | self.path = path 41 | self.groups = groups 42 | self.strings = strings 43 | } 44 | 45 | public var hasPlurals: Bool { 46 | strings.values.contains { $0.hasPlurals } || groups.contains { $0.hasPlurals } 47 | } 48 | 49 | public var hasPlaceholders: Bool { 50 | strings.values.contains { $0.hasPlaceholders } || groups.contains { $0.hasPlaceholders } 51 | } 52 | 53 | public func languageHasPlurals(_ language: String) -> Bool { 54 | strings.values.contains { $0.languageHasPlurals(language) } || groups.contains { $0.languageHasPlurals(language) } 55 | } 56 | 57 | public func hasLanguage(_ language: String) -> Bool { 58 | strings.values.contains { $0.hasLanguage(language) } || groups.contains { $0.hasLanguage(language) } 59 | } 60 | 61 | public func getLanguages() -> Set { 62 | Set( 63 | strings.values.reduce([]) { $0 + $1.languages.keys } + 64 | groups.reduce([]) { $0 + Array($1.getLanguages()) } 65 | ) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/StringlyKit/StringLocalization.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 27/10/19. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct StringLocalization: Equatable { 11 | public let languages: [String: Language] 12 | public let placeholders: [Placeholder] 13 | 14 | var defaultLanguage: Language { 15 | languages["base"] ?? languages["en"]! 16 | } 17 | 18 | var hasPlurals: Bool { 19 | languages.values.contains { !$0.plurals.isEmpty } 20 | } 21 | 22 | var hasPlaceholders: Bool { 23 | !placeholders.isEmpty 24 | } 25 | 26 | func hasLanguage(_ language: String) -> Bool { 27 | languages[language] != nil 28 | } 29 | 30 | func languageHasPlurals(_ language: String) -> Bool { 31 | if let language = languages[language] { 32 | return !language.plurals.isEmpty 33 | } else { 34 | return false 35 | } 36 | } 37 | 38 | func getLanguages() -> Set { 39 | Set(languages.keys) 40 | } 41 | 42 | public init(language: String, string: String) { 43 | self.languages = [language: Language(code: language, string: string, plurals: [:])] 44 | self.placeholders = Self.parsePlaceholders(string) 45 | } 46 | 47 | public init(languages: [String: Language], placeholders: [Placeholder] = []) { 48 | self.languages = languages 49 | self.placeholders = placeholders 50 | } 51 | 52 | public init(language: String, string: String, placeholders: [Placeholder]) { 53 | self.languages = [language: Language(code: language, string: string, plurals: [:])] 54 | self.placeholders = placeholders 55 | } 56 | 57 | public struct Language: Equatable { 58 | public let code: String 59 | public var string: String 60 | public var plurals: [String: [Plural: String]] 61 | 62 | public init(code: String, string: String, plurals: [String: [Plural: String]] = [:]) { 63 | self.code = code 64 | self.string = string 65 | self.plurals = plurals 66 | } 67 | } 68 | 69 | public struct Placeholder: Equatable { 70 | public var name: String 71 | public var type: String? 72 | 73 | public var originalPlaceholder: String { 74 | "{\(name)\(type.map { ":\($0)" } ?? "")}" 75 | } 76 | 77 | public init(name: String, type: String? = nil) { 78 | self.name = name 79 | self.type = type 80 | } 81 | 82 | public var hasName: Bool { !name.isEmpty } 83 | } 84 | 85 | public enum Plural: String, CaseIterable { 86 | case zero 87 | case one 88 | case two 89 | case few 90 | case many 91 | case other 92 | } 93 | 94 | static let regex = try! NSRegularExpression(pattern: #"\{(\S*)\}"#, options: []) 95 | 96 | static func parsePlaceholders(_ string: String) -> [Placeholder] { 97 | guard string.contains("{") else { return [] } 98 | let range = NSRange(string.startIndex.. 1, 106 | let precedingCharRange = Range(NSRange(location: nsRange.location-2, length: 1), in: string), 107 | String(string[precedingCharRange]) == "\\" { 108 | // exclude escaped placeholders 109 | continue 110 | } 111 | let placeholder = String(string[placeholderRange]) 112 | let placeholderParts = placeholder.split(separator: ":").map(String.init) 113 | switch placeholderParts.count { 114 | case 0: 115 | placeholders.append(Placeholder(name: placeholder)) 116 | case 1: 117 | placeholders.append(Placeholder(name: placeholder)) 118 | case 2: 119 | placeholders.append(Placeholder(name: placeholderParts[0], type: placeholderParts[1])) 120 | default: 121 | fatalError("Placeholder cannot contain more than one \":\"") 122 | } 123 | } 124 | } 125 | return placeholders 126 | } 127 | 128 | public static func en(_ string: String) -> StringLocalization { 129 | StringLocalization(language: "en", string: string) 130 | } 131 | 132 | public static func base(_ string: String) -> StringLocalization { 133 | StringLocalization(language: "base", string: string) 134 | } 135 | 136 | public func getPlaceholder(name: String) -> Placeholder? { 137 | placeholders.first { $0.name == name } 138 | } 139 | 140 | public func replacePlaceholders(_ string: String, pattern: (Placeholder) -> String) -> String { 141 | guard string.contains("{") else { return string } 142 | var string = string 143 | for placeholder in placeholders { 144 | string = string.replacingOccurrences(of: placeholder.originalPlaceholder, with: pattern(placeholder)) 145 | } 146 | string = string.replacingOccurrences(of: #"\\\{(\S+)\}"#, with: "{$1}", options: .regularExpression) 147 | return string 148 | } 149 | 150 | } 151 | 152 | extension StringLocalization { 153 | init(_ dictionary: [String: Any]) { 154 | var placeholders: [Placeholder] = [] 155 | 156 | var languages: [String: Language] = [:] 157 | for (key, value) in dictionary { 158 | 159 | let keyParts = key.components(separatedBy: ".") 160 | let code = keyParts[0] 161 | var language = languages[code] ?? Language(code: code, string: "", plurals: [:]) 162 | switch keyParts.count { 163 | case 1: 164 | if let string = value as? String { 165 | language.string = string 166 | let stringPlaceholders = Self.parsePlaceholders(string) 167 | for placeholder in stringPlaceholders { 168 | if !placeholders.contains { $0.name == placeholder.name } { 169 | placeholders.append(placeholder) 170 | } 171 | } 172 | } 173 | case 2: 174 | let placeholder = keyParts[1] 175 | if let pluralDictionary = value as? [String: String] { 176 | for (pluralString, pluralValue) in pluralDictionary { 177 | if let plural = Plural(rawValue: pluralString) { 178 | language.plurals[placeholder, default: [:]][plural] = pluralValue 179 | } 180 | } 181 | } 182 | 183 | default: 184 | break 185 | } 186 | languages[code] = language 187 | } 188 | self.placeholders = placeholders 189 | self.languages = languages 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Tests/Fixtures/Strings.swift: -------------------------------------------------------------------------------- 1 | // This file was auto-generated with https://github.com/yonaskolb/Stringly 2 | // swiftlint:disable all 3 | 4 | import Foundation 5 | 6 | public enum Strings { 7 | /// Ok 8 | public static let ok = Strings.localized("ok") 9 | 10 | public enum auth: StringGroup { 11 | public static let localizationKey = "auth" 12 | /// Email 13 | public static let emailTitle = Strings.localized("emailTitle", in: localizationKey) 14 | /// Log In 15 | public static let loginButton = Strings.localized("loginButton", in: localizationKey) 16 | /// Password 17 | public static let passwordTitle = Strings.localized("passwordTitle", in: localizationKey) 18 | 19 | public enum error: StringGroup { 20 | public static let localizationKey = "auth.error" 21 | /// Incorrect email/password combination 22 | public static let wrongEmailPassword = Strings.localized("wrongEmailPassword", in: localizationKey) 23 | } 24 | } 25 | 26 | public enum languages: StringGroup { 27 | public static let localizationKey = "languages" 28 | /// Hello 29 | public static let greeting = Strings.localized("greeting", in: localizationKey) 30 | } 31 | 32 | public enum placeholders: StringGroup { 33 | public static let localizationKey = "placeholders" 34 | /// Text with escaped {braces} 35 | public static let escaped = Strings.localized("escaped", in: localizationKey) 36 | /// **{name}** with number **{number}** 37 | public static func hello(name: String, number: Int) -> String { 38 | Strings.localized("hello", in: localizationKey, name, number) 39 | } 40 | /// Text **{}** 41 | public static func unnamed(_ p0: String) -> String { 42 | Strings.localized("unnamed", in: localizationKey, p0) 43 | } 44 | } 45 | 46 | public enum plurals: StringGroup { 47 | public static let localizationKey = "plurals" 48 | /// There **{pluralized appleCount}** in the garden 49 | public static func apples(appleCount: Int) -> String { 50 | Strings.localized("apples", in: localizationKey, appleCount) 51 | } 52 | } 53 | } 54 | 55 | public protocol StringGroup { 56 | static var localizationKey: String { get } 57 | } 58 | 59 | extension StringGroup { 60 | 61 | public static func string(for key: String, _ args: CVarArg...) -> String { 62 | return Strings.localized(key: "\(localizationKey).\(key)", args: args) 63 | } 64 | } 65 | 66 | extension Strings { 67 | 68 | /// The bundle uses for localization 69 | public static var bundle: Bundle = Bundle(for: BundleToken.self) 70 | 71 | /// Allows overriding any particular key, for A/B tests for example. Values need to be correct for the current language 72 | public static var overrides: [String: String] = [:] 73 | 74 | fileprivate static func localized(_ key: String, in group: String, _ args: CVarArg...) -> String { 75 | return Strings.localized(key: "\(group).\(key)", args: args) 76 | } 77 | 78 | fileprivate static func localized(_ key: String, _ args: CVarArg...) -> String { 79 | return Strings.localized(key: key, args: args) 80 | } 81 | 82 | fileprivate static func localized(key: String, args: [CVarArg]) -> String { 83 | let format = overrides[key] ?? NSLocalizedString(key, tableName: "Strings", bundle: bundle, comment: "") 84 | return String(format: format, locale: Locale.current, arguments: args) 85 | } 86 | } 87 | 88 | private final class BundleToken {} -------------------------------------------------------------------------------- /Tests/Fixtures/Strings.toml: -------------------------------------------------------------------------------- 1 | [auth] 2 | loginButton = "Log In" 3 | passwordTitle = "Password" 4 | emailTitle = "Email" 5 | 6 | [auth.error] 7 | wrongEmailPassword = "Incorrect email/password combination" 8 | 9 | [welcome] 10 | title = "Hello %@" 11 | 12 | [languages] 13 | 14 | [languages.greeting] 15 | en = "Hello" 16 | de = "Hallo" 17 | 18 | [placeholders] 19 | simple = "Hello {name:s} from {location:s}" 20 | 21 | [placeholders.greeting] 22 | en = "Hello {name:s} from {location:s}" 23 | 24 | [plurals] 25 | 26 | [plurals.apples] 27 | en = "There {appleCount:s} in the garden" 28 | 29 | [plurals.apples."en.appleCount"] 30 | one = "is 1 apple" 31 | other = "are {appleCount:s} apples" -------------------------------------------------------------------------------- /Tests/Fixtures/Strings.yml: -------------------------------------------------------------------------------- 1 | ok: Ok 2 | auth: 3 | loginButton: Log In 4 | passwordTitle: Password 5 | emailTitle: Email 6 | error: 7 | wrongEmailPassword: Incorrect email/password combination 8 | languages: 9 | greeting: 10 | en: Hello 11 | de: Hallo 12 | placeholders: 13 | hello: "{name} with number {number:u}" 14 | escaped: Text with escaped \{braces} 15 | unnamed: Text {} 16 | plurals: 17 | apples: 18 | en: There {appleCount:u} in the garden 19 | en.appleCount: 20 | one: is 1 apple 21 | other: are {appleCount:u} apples 22 | -------------------------------------------------------------------------------- /Tests/Fixtures/de.lproj/Strings.strings: -------------------------------------------------------------------------------- 1 | // This file was auto-generated with https://github.com/yonaskolb/Stringly 2 | 3 | // LANGUAGES 4 | "languages.greeting" = "Hallo"; -------------------------------------------------------------------------------- /Tests/Fixtures/en.lproj/Strings.strings: -------------------------------------------------------------------------------- 1 | // This file was auto-generated with https://github.com/yonaskolb/Stringly 2 | "ok" = "Ok"; 3 | 4 | // AUTH 5 | "auth.emailTitle" = "Email"; 6 | "auth.loginButton" = "Log In"; 7 | "auth.passwordTitle" = "Password"; 8 | 9 | // AUTH.ERROR 10 | "auth.error.wrongEmailPassword" = "Incorrect email/password combination"; 11 | 12 | // LANGUAGES 13 | "languages.greeting" = "Hello"; 14 | 15 | // PLACEHOLDERS 16 | "placeholders.escaped" = "Text with escaped {braces}"; 17 | "placeholders.hello" = "%@ with number %u"; 18 | "placeholders.unnamed" = "Text %@"; 19 | 20 | // PLURALS 21 | "plurals.apples" = "There %u in the garden"; -------------------------------------------------------------------------------- /Tests/Fixtures/en.lproj/Strings.stringsdict: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | plurals.apples 6 | 7 | NSStringLocalizedFormatKey 8 | There %#@appleCount@ in the garden 9 | appleCount 10 | 11 | NSStringFormatSpecTypeKey 12 | NSStringPluralRuleType 13 | NSStringFormatValueTypeKey 14 | u 15 | one 16 | is 1 apple 17 | other 18 | are %u apples 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/StringlyCLITests/StringDiff.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // https://gist.github.com/kristopherjohnson/543687c763cd6e524c91 4 | 5 | /// Find first differing character between two strings 6 | /// 7 | /// :param: s1 First String 8 | /// :param: s2 Second String 9 | /// 10 | /// :returns: .DifferenceAtIndex(i) or .NoDifference 11 | public func firstDifferenceBetweenStrings(_ s1: String, _ s2: String) -> FirstDifferenceResult { 12 | let len1 = s1.count 13 | let len2 = s2.count 14 | 15 | let lenMin = min(len1, len2) 16 | 17 | for i in 0.. String { 41 | let firstDifferenceResult = firstDifferenceBetweenStrings(s1, s2) 42 | 43 | func diffString(at index: Int, _ s1: String, _ s2: String) -> String { 44 | let markerArrow = "\u{2b06}" // "⬆" 45 | let ellipsis = "\u{2026}" // "…" 46 | 47 | /// Given a string and a range, return a string representing that substring. 48 | /// 49 | /// If the range starts at a position other than 0, an ellipsis 50 | /// will be included at the beginning. 51 | /// 52 | /// If the range ends before the actual end of the string, 53 | /// an ellipsis is added at the end. 54 | func windowSubstring(_ s: String, _ range: NSRange) -> String { 55 | let validRange = NSMakeRange(range.location, min(range.length, s.count - range.location)) 56 | let substring = (s as NSString).substring(with: validRange) 57 | 58 | let prefix = range.location > 0 ? ellipsis : "" 59 | let suffix = (s.count - range.location > range.length) ? ellipsis : "" 60 | 61 | return "\(prefix)\(substring)\(suffix)" 62 | } 63 | 64 | // Show this many characters before and after the first difference 65 | let windowLength = previewPrefixLength + 1 + previewSuffixLength 66 | 67 | let windowIndex = max(index - previewPrefixLength, 0) 68 | let windowRange = NSMakeRange(windowIndex, windowLength) 69 | 70 | let sub1 = windowSubstring(s1, windowRange) 71 | let sub2 = windowSubstring(s2, windowRange) 72 | 73 | let markerPosition = min(previewSuffixLength, index) + (windowIndex > 0 ? 1 : 0) 74 | 75 | let markerPrefix = String(repeating: " ", count: markerPosition) 76 | let markerLine = "\(markerPrefix)\(markerArrow)" 77 | 78 | return "Difference at index \(index):\n\(sub1)\n\(sub2)\n\(markerLine)" 79 | } 80 | 81 | switch firstDifferenceResult { 82 | case .NoDifference: return "No difference" 83 | case let .DifferenceAtIndex(index): return diffString(at: index, s1, s2) 84 | } 85 | } 86 | 87 | /// Result type for firstDifferenceBetweenStrings() 88 | public enum FirstDifferenceResult { 89 | /// Strings are identical 90 | case NoDifference 91 | 92 | /// Strings differ at the specified index. 93 | /// 94 | /// This could mean that characters at the specified index are different, 95 | /// or that one string is longer than the other 96 | case DifferenceAtIndex(Int) 97 | } 98 | -------------------------------------------------------------------------------- /Tests/StringlyCLITests/StringlyCLITests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import StringlyCLI 3 | import PathKit 4 | 5 | final class StringlyTests: XCTestCase { 6 | 7 | static let fixturePath = Path(#file).parent().parent() + "Fixtures" 8 | 9 | static let stringsYamlPath = fixturePath + "Strings.yml" 10 | 11 | func generateFileDiff(destination: Path, language: String = "en", file: StaticString = #file, line: UInt = #line) throws { 12 | let previousFile: String = try destination.read() 13 | 14 | let cli = StringlyCLI() 15 | let output = cli.run(arguments: ["generate-file", Self.stringsYamlPath.string, destination.string, "--language", language]) 16 | XCTAssertEqual(0, output, file: file, line: line) 17 | 18 | let newFile: String = try destination.read() 19 | if newFile != previousFile { 20 | let message = prettyFirstDifferenceBetweenStrings(newFile, previousFile) 21 | XCTFail("\(destination.lastComponent) has changed:\n\(message)", file: file, line: line) 22 | } 23 | } 24 | 25 | func testStringsGeneration() throws { 26 | try generateFileDiff(destination: Self.fixturePath + "en.lproj/Strings.strings") 27 | } 28 | 29 | func testStringsDictGeneration() throws { 30 | try generateFileDiff(destination: Self.fixturePath + "en.lproj/Strings.stringsdict") 31 | } 32 | 33 | func testSwiftGeneration() throws { 34 | try generateFileDiff(destination: Self.fixturePath + "Strings.swift") 35 | } 36 | 37 | func testTomlParsing() throws { 38 | let strings = try Loader.loadStrings(from: Self.fixturePath + "Strings.toml", baseLanguage: "en") 39 | XCTAssertNotNil(strings) 40 | } 41 | 42 | func testXGenerate() throws { 43 | let cli = StringlyCLI() 44 | let output = cli.run(arguments: ["generate", Self.stringsYamlPath.string]) 45 | XCTAssertEqual(0, output) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Tests/StringlyCLITests/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Yonas Kolb on 17/10/19. 6 | // 7 | 8 | import Foundation 9 | import SwiftCLI 10 | 11 | extension CLI { 12 | 13 | static func capture(_ block: () -> ()) -> (String, String) { 14 | let out = CaptureStream() 15 | let err = CaptureStream() 16 | 17 | Term.stdout = out 18 | Term.stderr = err 19 | block() 20 | Term.stdout = WriteStream.stdout 21 | Term.stderr = WriteStream.stderr 22 | 23 | out.closeWrite() 24 | err.closeWrite() 25 | 26 | return (out.readAll(), err.readAll()) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /Tests/StringlyKitTests/StringlyKitTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import StringlyKit 3 | import PathKit 4 | 5 | final class StringlyTests: XCTestCase { 6 | 7 | static let fixturePath = Path(#file).parent().parent() + "Fixtures" 8 | static let yamlPath = fixturePath + "Strings.yml" 9 | static let tomlPath = fixturePath + "Strings.toml" 10 | static let stringsPath = fixturePath + "Strings.strings" 11 | 12 | func testParsing() throws { 13 | let dictionary: [String: Any] = [ 14 | "group": [ 15 | "simple": "value", 16 | "group2": [ 17 | "simple2": "value" 18 | ] 19 | ], 20 | "placeholders": [ 21 | "string": "Hello {name} how many {numbers:u}", 22 | "escaped": "A \\{brace}", 23 | "unnamed": "Text {}", 24 | ] 25 | ] 26 | let strings = StringGroup(dictionary, baseLanguage: "en") 27 | let expectedString = StringGroup(groups: [ 28 | StringGroup( 29 | path: ["group"], 30 | groups: [ 31 | StringGroup( 32 | path: ["group", "group2"], 33 | strings: ["simple2" : .en("value")] 34 | ) 35 | ], 36 | strings: ["simple": .en("value")] 37 | ), 38 | StringGroup( 39 | path: ["placeholders"], 40 | strings: [ 41 | "string": StringLocalization( 42 | language: "en", 43 | string: "Hello {name} how many {numbers:u}", 44 | placeholders: [ 45 | StringLocalization.Placeholder(name: "name"), 46 | StringLocalization.Placeholder(name: "numbers", type: "u") 47 | ]), 48 | "escaped": StringLocalization( 49 | language: "en", 50 | string: "A \\{brace}", 51 | placeholders: [] 52 | ), 53 | "unnamed": StringLocalization( 54 | language: "en", 55 | string: "Text {}", 56 | placeholders: [StringLocalization.Placeholder(name: ""),] 57 | ), 58 | ]) 59 | ]) 60 | XCTAssertEqual(strings, expectedString) 61 | } 62 | } 63 | --------------------------------------------------------------------------------