localized/Sources/LocalizedMacros/LocalizedMacro.swift
2024-02-29 18:01:03 +01:00

244 lines
8.5 KiB
Swift

//
// LocalizedMacro.swift
// Localized
//
// Created by david-swift on 27.02.2024.
//
// swiftlint:disable force_unwrapping force_cast
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.
/// Access a specific language using `Localized.key.language`, or use `Localized.key.string`
/// which automatically uses the system language on Linux, macOS and Windows.
/// 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 {
/// The string literal syntax is invalid.
case invalidStringLiteral
/// The default language syntax is invalid.
case invalidDefaultLanguage
}
/// Expand the `localized` macro.
/// - Parameters:
/// - node: Information about the macro call.
/// - context: The expansion context.
/// - Returns: The enumerations `Localized` and `Loc`.
public static func expansion(
of node: some SwiftSyntax.FreestandingMacroExpansionSyntax,
in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.DeclSyntax] {
guard let `default` = node.argumentList.first?.expression.as(StringLiteralExprSyntax.self) else {
throw LocalizedError.invalidDefaultLanguage
}
guard let syntax = node.argumentList.last?.expression.as(StringLiteralExprSyntax.self) 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))
}
"""
]
}
/// 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