From 3a0eda789f2609fe087fab0b2cb928e22ee3e014 Mon Sep 17 00:00:00 2001 From: david-swift Date: Sat, 2 Mar 2024 15:11:13 +0100 Subject: [PATCH] Add Swift Package plugin --- Documentation/Generation/README.md | 7 + Documentation/Generation/enums/Generation.md | 10 + Documentation/GenerationLibrary/README.md | 8 + .../enums/Generation.GenerationError.md | 19 ++ .../GenerationLibrary/enums/Generation.md | 81 ++++++ .../LocalizedMacros/structs/LocalizedMacro.md | 67 ----- Documentation/README.md | 8 + Makefile | 2 + Package.swift | 42 ++- Plugins/GenerateLocalized/Plugin.swift | 39 +++ README.md | 45 +++- Sources/Generation/Generation.swift | 26 ++ Sources/GenerationLibrary/Generation.swift | 247 ++++++++++++++++++ Sources/Localized/Localized.swift | 7 +- Sources/LocalizedMacros/LocalizedMacro.swift | 206 +-------------- Tests/MacroTests/Tests.swift | 43 +++ Tests/PluginTests/Localized.yml | 16 ++ Tests/PluginTests/Tests.swift | 25 ++ 18 files changed, 620 insertions(+), 278 deletions(-) create mode 100644 Documentation/Generation/README.md create mode 100644 Documentation/Generation/enums/Generation.md create mode 100644 Documentation/GenerationLibrary/README.md create mode 100644 Documentation/GenerationLibrary/enums/Generation.GenerationError.md create mode 100644 Documentation/GenerationLibrary/enums/Generation.md create mode 100644 Plugins/GenerateLocalized/Plugin.swift create mode 100644 Sources/Generation/Generation.swift create mode 100644 Sources/GenerationLibrary/Generation.swift create mode 100644 Tests/MacroTests/Tests.swift create mode 100644 Tests/PluginTests/Localized.yml create mode 100644 Tests/PluginTests/Tests.swift diff --git a/Documentation/Generation/README.md b/Documentation/Generation/README.md new file mode 100644 index 0000000..4885ef7 --- /dev/null +++ b/Documentation/Generation/README.md @@ -0,0 +1,7 @@ +# Reference Documentation + +## Enums + +- [Generation](enums/Generation.md) + +This file was generated by [SourceDocs](https://github.com/eneko/SourceDocs) \ No newline at end of file diff --git a/Documentation/Generation/enums/Generation.md b/Documentation/Generation/enums/Generation.md new file mode 100644 index 0000000..f083ee9 --- /dev/null +++ b/Documentation/Generation/enums/Generation.md @@ -0,0 +1,10 @@ +**ENUM** + +# `Generation` + +A type containing the generation function for the plugin. + +## Methods +### `main()` + +Generate the Swift code for the plugin. diff --git a/Documentation/GenerationLibrary/README.md b/Documentation/GenerationLibrary/README.md new file mode 100644 index 0000000..2753a4c --- /dev/null +++ b/Documentation/GenerationLibrary/README.md @@ -0,0 +1,8 @@ +# Reference Documentation + +## Enums + +- [Generation](enums/Generation.md) +- [Generation.GenerationError](enums/Generation.GenerationError.md) + +This file was generated by [SourceDocs](https://github.com/eneko/SourceDocs) \ No newline at end of file diff --git a/Documentation/GenerationLibrary/enums/Generation.GenerationError.md b/Documentation/GenerationLibrary/enums/Generation.GenerationError.md new file mode 100644 index 0000000..fc08781 --- /dev/null +++ b/Documentation/GenerationLibrary/enums/Generation.GenerationError.md @@ -0,0 +1,19 @@ +**ENUM** + +# `Generation.GenerationError` + +An error that occurs during code generation. + +## Cases +### `missingTranslationInDefaultLanguage(key:)` + +A translation in the default language missing for a specific key. +Missing translations in other languages will cause the default language to be used. + +### `unknownYMLPasingError` + +An unknown error occured while parsing the YML. + +### `missingDefaultLanguage` + +The default language information is missing. diff --git a/Documentation/GenerationLibrary/enums/Generation.md b/Documentation/GenerationLibrary/enums/Generation.md new file mode 100644 index 0000000..1287720 --- /dev/null +++ b/Documentation/GenerationLibrary/enums/Generation.md @@ -0,0 +1,81 @@ +**ENUM** + +# `Generation` + +Generate the Swift code for the plugin and macro. + +## Properties +### `indentOne` + +Number of spaces for indentation 1. + +### `indentTwo` + +Number of spaces for indentation 2. + +### `indentThree` + +Number of spaces for indentation 3. + +## Methods +### `getCode(yml:)` + +Get the Swift code for the plugin and macro. +- Parameter yml: The YML code. +- Returns: The code. + +### `generateEnumCases(dictionary:)` + +Generate the cases for the `Localized` enumeration. +- Parameter dictionary: The parsed YML. +- Returns: The syntax. + +### `generateStaticLocVariables(dictionary:)` + +Generate the static variables and functions for the `Loc` type. +- Parameter dictionary: The parsed YML. +- Returns: The syntax. + +### `generateTranslations(dictionary:defaultLanguage:)` + +Generate the variables for the translations. +- Parameters: + - dictionary: The parsed YML. + - defaultLanguage: The default language. +- Returns: The syntax. + +### `generateLanguageFunction(dictionary:defaultLanguage:)` + +Generate the function for getting the translated string for a specified language code. +- Parameters: + - dictionary: The parsed YML. + - defaultLanguage: The default language. +- Returns: The syntax. + +### `getLanguages(dictionary:)` + +Get the available languages. +- Parameter dictionary: The parsed YML. +- Returns: The syntax + +### `parse(key:)` + +Parse the key for a phrase. +- Parameter key: The key definition including parameters. +- Returns: The key. + +### `parse(translation:arguments:)` + +Parse the translation for a phrase. +- Parameters: + - translation: The translation without correct escaping. + - arguments: The arguments. +- Returns: The syntax. + +### `indent(_:by:)` + +Indent each line of a text by a certain amount of whitespaces. +- Parameters: + - string: The text. + - count: The indentation. +- Returns: The syntax. diff --git a/Documentation/LocalizedMacros/structs/LocalizedMacro.md b/Documentation/LocalizedMacros/structs/LocalizedMacro.md index 51f7226..6651326 100644 --- a/Documentation/LocalizedMacros/structs/LocalizedMacro.md +++ b/Documentation/LocalizedMacros/structs/LocalizedMacro.md @@ -8,19 +8,6 @@ Access a specific language using `Localized.key.language`, or use `Localized.key which automatically uses the system language on Linux, macOS and Windows. Use `Loc.key` for a quick access to the automatically localized value. -## Properties -### `indentOne` - -Number of spaces for indentation 1. - -### `indentTwo` - -Number of spaces for indentation 2. - -### `indentThree` - -Number of spaces for indentation 3. - ## Methods ### `expansion(of:in:)` @@ -29,57 +16,3 @@ Expand the `localized` macro. - node: Information about the macro call. - context: The expansion context. - Returns: The enumerations `Localized` and `Loc`. - -### `generateEnumCases(dictionary:)` - -Generate the cases for the `Localized` enumeration. -- Parameter dictionary: The parsed YML. -- Returns: The syntax. - -### `generateStaticLocVariables(dictionary:)` - -Generate the static variables and functions for the `Loc` type. -- Parameter dictionary: The parsed YML. -- Returns: The syntax. - -### `generateTranslations(dictionary:)` - -Generate the variables for the translations. -- Parameter dictionary: The parsed YML. -- Returns: The syntax. - -### `generateLanguageFunction(dictionary:defaultLanguage:)` - -Generate the function for getting the translated string for a specified language code. -- Parameters: - - dictionary: The parsed YML. - - defaultLanguage: The syntax for the default language. -- Returns: The syntax. - -### `getLanguages(dictionary:)` - -Get the available languages. -- Parameter dictionary: The parsed YML. -- Returns: The syntax - -### `parse(key:)` - -Parse the key for a phrase. -- Parameter key: The key definition including parameters. -- Returns: The key. - -### `parse(translation:arguments:)` - -Parse the translation for a phrase. -- Parameters: - - translation: The translation without correct escaping. - - arguments: The arguments. -- Returns: The syntax. - -### `indent(_:by:)` - -Indent each line of a text by a certain amount of whitespaces. -- Parameters: - - string: The text. - - count: The indentation. -- Returns: The syntax. diff --git a/Documentation/README.md b/Documentation/README.md index b02961f..b287490 100644 --- a/Documentation/README.md +++ b/Documentation/README.md @@ -7,3 +7,11 @@ Find documentation for the `localized` macro [here](LocalizedMacros/README.md). ## Localized Find documentation for the `System` enumeration [here](Localized/README.md). + +## Generation + +Find documentation for the generation executable used by the plugin [here](Generation/README.md). + +## GenerationLibrary + +Find documentation for the generation used by the plugin and macro [here](GenerationLibrary/README.md). diff --git a/Makefile b/Makefile index a4b27b5..1b0232d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ docs: @sourcedocs generate --min-acl private -r --spm-module Localized --output-folder Documentation/Localized @sourcedocs generate --min-acl private -r --spm-module LocalizedMacros --output-folder Documentation/LocalizedMacros + @sourcedocs generate --min-acl private -r --spm-module Generation --output-folder Documentation/Generation + @sourcedocs generate --min-acl private -r --spm-module GenerationLibrary --output-folder Documentation/GenerationLibrary swiftlint: @swiftlint --autocorrect diff --git a/Package.swift b/Package.swift index c318e74..5c0869d 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,10 @@ let package = Package( .library( name: "Localized", targets: ["Localized"] + ), + .plugin( + name: "GenerateLocalized", + targets: ["GenerateLocalized"] ) ], dependencies: [ @@ -25,13 +29,32 @@ let package = Package( .package(url: "https://github.com/stackotter/swift-macro-toolkit", from: "0.3.1") ], targets: [ + .target( + name: "GenerationLibrary", + dependencies: [ + .product(name: "Yams", package: "Yams") + ] + ), + .executableTarget( + name: "Generation", + dependencies: [ + "GenerationLibrary" + ] + ), .macro( name: "LocalizedMacros", dependencies: [ .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), .product(name: "MacroToolkit", package: "swift-macro-toolkit"), .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - .product(name: "Yams", package: "Yams") + "GenerationLibrary" + ] + ), + .plugin( + name: "GenerateLocalized", + capability: .buildTool(), + dependencies: [ + "Generation" ] ), .target( @@ -41,11 +64,24 @@ let package = Package( ] ), .executableTarget( - name: "LocalizedTests", + name: "MacroTests", dependencies: [ "Localized" ], - path: "Tests" + path: "Tests/MacroTests" + ), + .executableTarget( + name: "PluginTests", + dependencies: [ + "Localized" + ], + path: "Tests/PluginTests", + resources: [ + .process("Localized.yml") + ], + plugins: [ + "GenerateLocalized" + ] ) ] ) diff --git a/Plugins/GenerateLocalized/Plugin.swift b/Plugins/GenerateLocalized/Plugin.swift new file mode 100644 index 0000000..221f514 --- /dev/null +++ b/Plugins/GenerateLocalized/Plugin.swift @@ -0,0 +1,39 @@ +// +// Plugin.swift +// Localized +// +// Created by david-swift on 02.03.24. +// + +import Foundation +import PackagePlugin + +/// The build tool plugin for generating Swift code from the `Localized.yml` file. +@main +struct Plugin: BuildToolPlugin { + + /// Create the commands for generating the code. + /// - Parameters: + /// - context: The plugin context. + /// - target: The target. + /// - Returns: The commands. + func createBuildCommands(context: PluginContext, target: Target) throws -> [Command] { + guard let target = target.sourceModule, + let inputFile = target.sourceFiles.first( + where: { ["Localized.yml", "Localized.yaml"].contains($0.path.lastComponent) } + ) else { + return [] + } + let outputFile = context.pluginWorkDirectory.appending(subpath: "Localized.swift") + return [ + .buildCommand( + displayName: "Generating Localized.swift", + executable: try context.tool(named: "Generation").path, + arguments: [inputFile.path.string, outputFile.string], + inputFiles: [inputFile.path], + outputFiles: [outputFile] + ) + ] + } + +} diff --git a/README.md b/README.md index 2ae0231..ae93f45 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@

-_Localized_ provides a macro for localizing cross-platform Swift code. +_Localized_ provides a Swift package plugin for localizing cross-platform Swift code. Use YML syntax for defining available phrases: @@ -60,7 +60,44 @@ print(Localized.house.fr) ### Definition -Define the available phrases using YML. +Define the available phrases in a file called `Localized.yml`. + +```yml +default: en + +export: + en: Export Document + de: Exportiere das Dokument + +send(message, name): + en: Send (message) to (name). + de: Sende (message) to (name). +``` + +As you can see, you can add parameters using brackets after the key. + +The line `default: en` sets English as the fallback language. + +Then, add the `Localized` dependency, the plugin and the `Localized.yml` resource +to the target in the `Package.swift` file. + +```swift +.executableTarget( + name: "PluginTests", + dependencies: ["Localized"], + resources: [.process("Localized.yml")], + plugins: ["GenerateLocalized"] +) +``` + +
+ + Use the Swift macro alternatively + +If you don't want to have a separate `Localized.yml` resource, you can use the +YML syntax directly in your Swift code using a Swift macro. +Leave out the `resources` and `plugins` lines in the target definition, and +instead of creating a `Localized.yml` file, use the following macro in a Swift file. ```swift #localized(default: "en", yml: """ @@ -74,7 +111,9 @@ send(message, name): """) ``` -As you can see, you can add parameters using brackets after the key. +You cannot have a `defaultLanguage` set in the YML, instead, use the macro parameter. + +
### Usage diff --git a/Sources/Generation/Generation.swift b/Sources/Generation/Generation.swift new file mode 100644 index 0000000..98252c6 --- /dev/null +++ b/Sources/Generation/Generation.swift @@ -0,0 +1,26 @@ +// +// Generation.swift +// Localized +// +// Created by david-swift on 02.03.2024. +// + +import Foundation +import GenerationLibrary + +/// A type containing the generation function for the plugin. +@main +public enum Generation { + + /// Generate the Swift code for the plugin. + public static func main() throws { + let yml = try String(contentsOfFile: CommandLine.arguments[1]) + let content = try GenerationLibrary.Generation.getCode(yml: yml) + let outputPathIndex = 2 + FileManager.default.createFile( + atPath: CommandLine.arguments[outputPathIndex], + contents: .init(("import Localized" + "\n\n" + content[0] + "\n\n" + content[1]).utf8) + ) + } + +} diff --git a/Sources/GenerationLibrary/Generation.swift b/Sources/GenerationLibrary/Generation.swift new file mode 100644 index 0000000..d0f7777 --- /dev/null +++ b/Sources/GenerationLibrary/Generation.swift @@ -0,0 +1,247 @@ +// +// Generation.swift +// Localized +// +// Created by david-swift on 02.03.2024. +// + +import Yams + +/// Generate the Swift code for the plugin and macro. +public enum Generation { + + /// Number of spaces for indentation 1. + static let indentOne = 4 + /// Number of spaces for indentation 2. + static let indentTwo = 8 + /// Number of spaces for indentation 3. + static let indentThree = 12 + + /// An error that occurs during code generation. + public enum GenerationError: Error { + + /// A translation in the default language missing for a specific key. + /// Missing translations in other languages will cause the default language to be used. + case missingTranslationInDefaultLanguage(key: String) + /// An unknown error occured while parsing the YML. + case unknownYMLPasingError + /// The default language information is missing. + case missingDefaultLanguage + + } + + /// Get the Swift code for the plugin and macro. + /// - Parameter yml: The YML code. + /// - Returns: The code. + public static func getCode(yml: String) throws -> [String] { + guard var dict = try Yams.load(yaml: yml) as? [String: Any] else { + throw GenerationError.unknownYMLPasingError + } + guard let defaultLanguage = dict["default"] as? String else { + throw GenerationError.missingDefaultLanguage + } + dict["default"] = nil + guard let dictionary = dict as? [String: [String: String]] else { + throw GenerationError.unknownYMLPasingError + } + return [ + """ + enum Localized { + + static var yml: String { + \""" + \(indent(yml, by: indentTwo)) + \""" + } + + \(generateEnumCases(dictionary: dictionary)) + + var string: String { string(for: System.getLanguage()) } + + \(try generateTranslations(dictionary: dictionary, defaultLanguage: defaultLanguage)) + + \(generateLanguageFunction(dictionary: dictionary, defaultLanguage: defaultLanguage)) + + } + """, + """ + enum Loc { + + \(generateStaticLocVariables(dictionary: dictionary)) + + } + """ + ] + } + + /// Generate the cases for the `Localized` enumeration. + /// - Parameter dictionary: The parsed YML. + /// - Returns: The syntax. + static func generateEnumCases(dictionary: [String: [String: String]]) -> String { + var result = "" + for entry in dictionary { + let key = parse(key: entry.key) + if key.1.isEmpty { + result.append(""" + case \(entry.key) + + """) + } else { + var line = "case \(key.0)(" + for argument in key.1 { + line += "\(argument): String, " + } + line.removeLast(", ".count) + line += ")" + result.append(""" + \(line) + + """) + } + } + return result + } + + /// Generate the static variables and functions for the `Loc` type. + /// - Parameter dictionary: The parsed YML. + /// - Returns: The syntax. + static func generateStaticLocVariables(dictionary: [String: [String: String]]) -> String { + var result = "" + for entry in dictionary { + let key = parse(key: entry.key) + if key.1.isEmpty { + result.append(""" + static var \(entry.key): String { Localized.\(entry.key).string } + + """) + } else { + var line = "static func \(key.0)(" + for argument in key.1 { + line += "\(argument): String, " + } + line.removeLast(", ".count) + line += ") -> String {\n" + indent("Localized.\(key.0)(", by: indentOne) + for argument in key.1 { + line += "\(argument): \(argument), " + } + line.removeLast(", ".count) + line += ").string" + line += "\n}" + result.append(""" + \(line) + + """) + } + } + return result + } + + /// Generate the variables for the translations. + /// - Parameters: + /// - dictionary: The parsed YML. + /// - defaultLanguage: The default language. + /// - Returns: The syntax. + static func generateTranslations(dictionary: [String: [String: String]], defaultLanguage: String) throws -> String { + var result = "" + for language in getLanguages(dictionary: dictionary) { + var variable = indent("var \(language): String {", by: indentOne) + variable += indent("\nswitch self {", by: indentTwo) + for entry in dictionary { + let key = parse(key: entry.key) + guard let valueForLanguage = entry.value[language] ?? entry.value[defaultLanguage] else { + throw GenerationError.missingTranslationInDefaultLanguage(key: key.0) + } + if key.1.isEmpty { + variable += indent("\ncase .\(entry.key):", by: indentTwo) + variable += indent("\n\"\(valueForLanguage)\"", by: indentThree) + } else { + let translation = parse(translation: valueForLanguage, arguments: key.1) + variable += indent("\ncase let .\(entry.key):", by: indentTwo) + variable += indent("\n\"\(translation)\"", by: indentThree) + } + } + variable += indent("\n }\n}", by: indentOne) + result += """ + \(variable) + + """ + } + return result + } + + /// Generate the function for getting the translated string for a specified language code. + /// - Parameters: + /// - dictionary: The parsed YML. + /// - defaultLanguage: The default language. + /// - Returns: The syntax. + static func generateLanguageFunction( + dictionary: [String: [String: String]], + defaultLanguage: String + ) -> String { + var result = "func string(for language: String) -> String {\n" + for language in getLanguages(dictionary: dictionary) where language != defaultLanguage { + result += indent("if language.hasPrefix(\"\(language)\") {", by: indentTwo) + result += indent("\nreturn \(language)", by: indentThree) + result += indent("\n} else", by: indentTwo) + } + result += """ + { + return \(defaultLanguage) + } + } + """ + return result + } + + /// Get the available languages. + /// - Parameter dictionary: The parsed YML. + /// - Returns: The syntax + static func getLanguages(dictionary: [String: [String: String]]) -> [String] { + var languages: Set = [] + for key in dictionary { + languages = languages.union(key.value.map { $0.key }) + } + return .init(languages) + } + + /// Parse the key for a phrase. + /// - Parameter key: The key definition including parameters. + /// - Returns: The key. + static func parse(key: String) -> (String, [String]) { + let parts = key.split(separator: "(") + if parts.count == 1 { + return (key, []) + } + let arguments = parts[1].dropLast().split(separator: ", ").map { String($0) } + return (.init(parts[0]), arguments) + } + + /// Parse the translation for a phrase. + /// - Parameters: + /// - translation: The translation without correct escaping. + /// - arguments: The arguments. + /// - Returns: The syntax. + static func parse(translation: String, arguments: [String]) -> String { + var translation = translation + for argument in arguments { + translation.replace("(\(argument))", with: "\\(\(argument))") + } + return translation + } + + /// Indent each line of a text by a certain amount of whitespaces. + /// - Parameters: + /// - string: The text. + /// - count: The indentation. + /// - Returns: The syntax. + static func indent(_ string: String, by count: Int) -> String { + .init( + string + .components(separatedBy: "\n") + .map { "\n" + Array(repeating: " ", count: count).joined() + $0 } + .joined() + .trimmingPrefix("\n") + ) + } + +} diff --git a/Sources/Localized/Localized.swift b/Sources/Localized/Localized.swift index 65c0360..2ceb04b 100644 --- a/Sources/Localized/Localized.swift +++ b/Sources/Localized/Localized.swift @@ -5,12 +5,7 @@ // Created by david-swift on 27.02.2024. // -/// A macro that produces both a value and a string containing the -/// source code that generated the value. For example, -/// -/// #stringify(x + y) -/// -/// produces a tuple `(x + y, "x + y")`. +/// A macro that takes the YML syntax and converts it into enumerations. @freestanding(declaration, names: named(Localized), named(Loc)) public macro localized(default defaultLanguage: String, yml: String) = #externalMacro( module: "LocalizedMacros", diff --git a/Sources/LocalizedMacros/LocalizedMacro.swift b/Sources/LocalizedMacros/LocalizedMacro.swift index c20d4b0..941dbaf 100644 --- a/Sources/LocalizedMacros/LocalizedMacro.swift +++ b/Sources/LocalizedMacros/LocalizedMacro.swift @@ -5,12 +5,10 @@ // Created by david-swift on 27.02.2024. // -// swiftlint:disable force_unwrapping force_cast - +import GenerationLibrary import MacroToolkit import SwiftSyntax import SwiftSyntaxMacros -import Yams /// Implementation of the `localized` macro, which takes YML /// as a string and converts it into two enumerations. @@ -19,13 +17,6 @@ import Yams /// Use `Loc.key` for a quick access to the automatically localized value. public struct LocalizedMacro: DeclarationMacro { - /// Number of spaces for indentation 1. - static let indentOne = 4 - /// Number of spaces for indentation 2. - static let indentTwo = 8 - /// Number of spaces for indentation 3. - static let indentThree = 12 - /// The errors the expansion can throw. public enum LocalizedError: Error { @@ -45,199 +36,16 @@ public struct LocalizedMacro: DeclarationMacro { of node: some SwiftSyntax.FreestandingMacroExpansionSyntax, in context: some SwiftSyntaxMacros.MacroExpansionContext ) throws -> [SwiftSyntax.DeclSyntax] { - guard let `default` = node.argumentList.first?.expression.as(StringLiteralExprSyntax.self) else { + guard let `default` = node.argumentList.first?.expression.as(StringLiteralExprSyntax.self), + let defaultLanguage = StringLiteral(`default`).value?.description else { throw LocalizedError.invalidDefaultLanguage } - guard let syntax = node.argumentList.last?.expression.as(StringLiteralExprSyntax.self) else { + guard let syntax = node.argumentList.last?.expression.as(StringLiteralExprSyntax.self), + var yml = StringLiteral(syntax).value else { throw LocalizedError.invalidStringLiteral } - let dictionary = try Yams.load(yaml: StringLiteral(syntax).value!) as! [String: [String: String]] - return [ - """ - enum Localized { - - static var yml: String { - \""" - \(raw: indent(StringLiteral(syntax).value!.description, by: indentTwo)) - \""" - } - - \(raw: generateEnumCases(dictionary: dictionary)) - - var string: String { string(for: System.getLanguage()) } - - \(raw: generateTranslations(dictionary: dictionary)) - - \(raw: generateLanguageFunction(dictionary: dictionary, defaultLanguage: `default`)) - - } - """, - """ - enum Loc { - - \(raw: generateStaticLocVariables(dictionary: dictionary)) - - } - """ - ] + yml.append("\n\ndefault: \"\(defaultLanguage)\"") + return try Generation.getCode(yml: yml).map { "\(raw: $0)" } } - /// Generate the cases for the `Localized` enumeration. - /// - Parameter dictionary: The parsed YML. - /// - Returns: The syntax. - static func generateEnumCases(dictionary: [String: [String: String]]) -> String { - var result = "" - for entry in dictionary { - let key = parse(key: entry.key) - if key.1.isEmpty { - result.append(""" - case \(entry.key) - """) - } else { - var line = "case \(key.0)(" - for argument in key.1 { - line += "\(argument): String, " - } - line.removeLast(", ".count) - line += ")" - result.append(""" - \(line) - """) - } - } - return result - } - - /// Generate the static variables and functions for the `Loc` type. - /// - Parameter dictionary: The parsed YML. - /// - Returns: The syntax. - static func generateStaticLocVariables(dictionary: [String: [String: String]]) -> String { - var result = "" - for entry in dictionary { - let key = parse(key: entry.key) - if key.1.isEmpty { - result.append(""" - static var \(entry.key): String { Localized.\(entry.key).string } - """) - } else { - var line = "static func \(key.0)(" - for argument in key.1 { - line += "\(argument): String, " - } - line.removeLast(", ".count) - line += ") -> String {\n" + indent("Localized.\(key.0)(", by: indentOne) - for argument in key.1 { - line += "\(argument): \(argument), " - } - line.removeLast(", ".count) - line += ").string" - line += "\n}" - result.append(""" - \(line) - """) - } - } - return result - } - - /// Generate the variables for the translations. - /// - Parameter dictionary: The parsed YML. - /// - Returns: The syntax. - static func generateTranslations(dictionary: [String: [String: String]]) -> String { - var result = "" - for language in getLanguages(dictionary: dictionary) { - var variable = indent("var \(language): String {", by: indentOne) - variable += indent("\nswitch self {", by: indentTwo) - for entry in dictionary { - let key = parse(key: entry.key) - if key.1.isEmpty { - variable += indent("\ncase .\(entry.key):", by: indentTwo) - variable += indent("\n\"\(entry.value[language]!)\"", by: indentThree) - } else { - let translation = parse(translation: entry.value[language]!, arguments: key.1) - variable += indent("\ncase let .\(entry.key):", by: indentTwo) - variable += indent("\n\"\(translation)\"", by: indentThree) - } - } - variable += indent("\n }\n}", by: indentOne) - result += """ - \(variable) - """ - } - return result - } - - /// Generate the function for getting the translated string for a specified language code. - /// - Parameters: - /// - dictionary: The parsed YML. - /// - defaultLanguage: The syntax for the default language. - /// - Returns: The syntax. - static func generateLanguageFunction( - dictionary: [String: [String: String]], - defaultLanguage: StringLiteralExprSyntax - ) -> String { - let defaultLanguage = StringLiteral(defaultLanguage).value!.description - var result = "func string(for language: String) -> String {\n" - for language in getLanguages(dictionary: dictionary) where language != defaultLanguage { - result += indent("if language.hasPrefix(\"\(language)\") {", by: indentTwo) - result += indent("\nreturn \(language)", by: indentThree) - result += indent("\n} else", by: indentTwo) - } - result += """ - { - return \(defaultLanguage) - } - } - """ - return result - } - - /// Get the available languages. - /// - Parameter dictionary: The parsed YML. - /// - Returns: The syntax - static func getLanguages(dictionary: [String: [String: String]]) -> [String] { - dictionary.first?.value.map { $0.key } ?? [] - } - - /// Parse the key for a phrase. - /// - Parameter key: The key definition including parameters. - /// - Returns: The key. - static func parse(key: String) -> (String, [String]) { - let parts = key.split(separator: "(") - if parts.count == 1 { - return (key, []) - } - let arguments = parts[1].dropLast().split(separator: ", ").map { String($0) } - return (.init(parts[0]), arguments) - } - - /// Parse the translation for a phrase. - /// - Parameters: - /// - translation: The translation without correct escaping. - /// - arguments: The arguments. - /// - Returns: The syntax. - static func parse(translation: String, arguments: [String]) -> String { - var translation = translation - for argument in arguments { - translation.replace("(\(argument))", with: "\\(\(argument))") - } - return translation - } - - /// Indent each line of a text by a certain amount of whitespaces. - /// - Parameters: - /// - string: The text. - /// - count: The indentation. - /// - Returns: The syntax. - static func indent(_ string: String, by count: Int) -> String { - .init( - string - .components(separatedBy: "\n") - .map { "\n" + Array(repeating: " ", count: count).joined() + $0 } - .joined() - .trimmingPrefix("\n") - ) - } } - -// swiftlint:enable force_unwrapping force_cast diff --git a/Tests/MacroTests/Tests.swift b/Tests/MacroTests/Tests.swift new file mode 100644 index 0000000..d0e6a37 --- /dev/null +++ b/Tests/MacroTests/Tests.swift @@ -0,0 +1,43 @@ +// +// Tests.swift +// Localized +// +// Created by david-swift on 27.02.2024. +// + +import Foundation +import Localized + +#localized(default: "en", yml: """ +hello(name): + en: Hello, (name)! + de: Hallo, (name)! + fr: Salut, (name)! + +house: + en: House + de: Haus + fr: Maison + +helloPair(name1, name2): + en: Hello, (name1) and (name2)! + de: Hallo, (name1) und (name2)! + fr: Salut, (name1) et (name2)! +""") + +/// Test cases for the `localized` macro. +@main +enum Tests { + + /// Test the `localized` macro. + static func main() { + print("EN: \(Localized.hello(name: "Peter").en)") + print("DE: \(Localized.hello(name: "Ruedi").de)") + print("SYSTEM: \(Loc.hello(name: "Sams"))") + print("FR: \(Localized.house.fr)") + print("DE_CH: \(Localized.house.string(for: "de_CH"))") + print("SYSTEM: \(Localized.house.string)") + print("EN: \(Localized.helloPair(name1: "Max", name2: "Ruedi").en)") + } + +} diff --git a/Tests/PluginTests/Localized.yml b/Tests/PluginTests/Localized.yml new file mode 100644 index 0000000..0a489b4 --- /dev/null +++ b/Tests/PluginTests/Localized.yml @@ -0,0 +1,16 @@ +default: en + +hello(name): + en: Hello, (name)! + de: Hallo, (name)! + fr: Salut, (name)! + +house: + en: House + de: Haus + fr: Maison + +helloPair(name1, name2): + en: Hello, (name1) and (name2)! + de: Hallo, (name1) und (name2)! + fr: Salut, (name1) et (name2)! \ No newline at end of file diff --git a/Tests/PluginTests/Tests.swift b/Tests/PluginTests/Tests.swift new file mode 100644 index 0000000..9eafff2 --- /dev/null +++ b/Tests/PluginTests/Tests.swift @@ -0,0 +1,25 @@ +// +// Tests.swift +// Localized +// +// Created by david-swift on 27.02.2024. +// + +import Foundation + +/// Test cases for the `GenerateLocalized` plugin. +@main +enum Tests { + + /// Test the `GenerateLocalized` plugin. + static func main() { + print("EN: \(Localized.hello(name: "Peter").en)") + print("DE: \(Localized.hello(name: "Ruedi").de)") + print("SYSTEM: \(Loc.hello(name: "Sams"))") + print("FR: \(Localized.house.fr)") + print("DE_CH: \(Localized.house.string(for: "de_CH"))") + print("SYSTEM: \(Localized.house.string)") + print("EN: \(Localized.helloPair(name1: "Max", name2: "Ruedi").en)") + } + +}