Localizable macro

This commit is contained in:
2025-08-16 12:50:28 +03:00
parent 7ee534b69b
commit c27de77d6c
6 changed files with 315 additions and 1 deletions

View File

@@ -0,0 +1,45 @@
import Foundation
/// Generates type-safe localized strings from enum cases.
///
/// - Parameter bundle:
/// Optional `Bundle` used to resolve localized strings.
///
/// Enum cases without associated values become `static let`
/// constants. Cases with associated values become `static func`
/// methods that interpolate arguments. If a String Catalog is
/// used, each key is automatically added to the catalog. By
/// default, the main bundle is used to resolve strings.
///
/// ```swift
/// import Localizable
///
/// extension String {
/// @Localizable enum Login {
/// private enum Strings {
/// case welcome
/// case title(String)
/// case message(msg1: String, msg2: Int)
/// }
/// }
/// }
/// ```
///
/// Expands to members of `String.Login`:
///
/// ```swift
/// static let welcome = String(localized: "Login.welcome")
///
/// static func title(_ value0: String) -> String {
/// String(localized: "Login.title \(value0)")
/// }
///
/// static func message(msg1: String, msg2: Int) -> String {
/// String(localized: "Login.message \(msg1) \(msg2)")
/// }
/// ```
@attached(member, names: arbitrary)
public macro Localizable(bundle: Bundle? = nil) = #externalMacro(
module: "LocalizableMacros",
type: "LocalizableMacro"
)

View File

@@ -0,0 +1,23 @@
import Foundation
import Localizable
extension String {
@Localizable enum Login {
private enum Strings {
case welcome
case title(String)
case message(msg1: String, msg2: Int)
}
}
}
extension String {
@Localizable(bundle: .main)
enum Account {
private enum Strings {
case title
}
}
}
print(String.Login.welcome)

View File

@@ -0,0 +1,23 @@
import SwiftSyntax
import SwiftDiagnostics
enum LocalizableDiagnostic {
case enumRequired
}
extension LocalizableDiagnostic: DiagnosticMessage {
var message: String {
switch self {
case .enumRequired:
"The @Localizable macro requires a nested enum with localization keys."
}
}
var diagnosticID: MessageID {
.init(domain: "LocalizableMacro", id: "\(self)")
}
var severity: DiagnosticSeverity {
.error
}
}

View File

@@ -0,0 +1,125 @@
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftDiagnostics
public struct LocalizableMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let enumDecl = enumDecl(of: declaration) else {
let diagnostic = Diagnostic(
node: Syntax(node),
message: LocalizableDiagnostic.enumRequired
)
context.diagnose(diagnostic)
return []
}
let type = typeName(of: declaration) ?? "Localizable"
let cases = enumCases(of: enumDecl)
let bundle = bundleArgument(of: node)
let access = accessLevel(of: declaration)
return cases.compactMap { element in
if element.parameterClause == nil {
makeField(from: element, type: type, access: access, bundle: bundle)
} else {
makeFunction(from: element, type: type, access: access, bundle: bundle)
}
}
}
}
private extension LocalizableMacro {
static func enumDecl(of declaration: some DeclGroupSyntax) -> EnumDeclSyntax? {
declaration.memberBlock.members.compactMap {
$0.decl.as(EnumDeclSyntax.self)
}.first
}
static func typeName(of decl: some DeclGroupSyntax) -> String? {
if let s = decl.as(StructDeclSyntax.self) { return s.name.text }
if let c = decl.as(ClassDeclSyntax.self) { return c.name.text }
if let e = decl.as(EnumDeclSyntax.self) { return e.name.text }
if let a = decl.as(ActorDeclSyntax.self) { return a.name.text }
if let ext = decl.as(ExtensionDeclSyntax.self) {
let parts = ext.extendedType.trimmedDescription.split(separator: ".")
return parts.last.map(String.init)
}
return nil
}
static func accessLevel(of decl: some DeclGroupSyntax) -> String? {
for modifier in decl.modifiers {
switch modifier.name.text {
case "open": return "public"
case "public": return "public"
case "private": return "private"
case "fileprivate": return "fileprivate"
default: break
}
}
return nil
}
static func enumCases(of enumDecl: EnumDeclSyntax) -> [EnumCaseElementSyntax] {
enumDecl.memberBlock.members.flatMap {
Array($0.decl.as(EnumCaseDeclSyntax.self)?.elements ?? [])
}
}
static func bundleArgument(of node: AttributeSyntax) -> String? {
guard let arguments = node.arguments?.as(LabeledExprListSyntax.self),
let bundle = arguments.first(where: { $0.label?.text == "bundle" })
else { return nil }
return bundle.expression.trimmedDescription
}
static func makeField(from element: EnumCaseElementSyntax, type: String, access: String?, bundle: String?) -> DeclSyntax {
let accessArg = access.map { "\($0) "} ?? ""
let bundleArg = bundle.map { ", bundle: \($0)" } ?? ""
return """
\(raw: accessArg)static let \(element.name) = String(localized: "\(raw: type).\(element.name)"\(raw: bundleArg))
"""
}
static func makeFunction(from element: EnumCaseElementSyntax, type: String, access: String?, bundle: String?) -> DeclSyntax {
let parameterList = element.parameterClause?.parameters ?? []
let accessArg = access.map { "\($0) "} ?? ""
let bundleArg = bundle.map { ", bundle: \($0)" } ?? ""
let parameters = makeFunctionParameters(parameterList)
let arguments = makeFunctionArguments(parameterList)
return """
\(raw: accessArg)static func \(element.name)(\(raw: parameters)) -> String {
String(localized: "\(raw: type).\(element.name) \(raw: arguments)"\(raw: bundleArg))
}
"""
}
static func makeFunctionParameters(_ list: EnumCaseParameterListSyntax) -> String {
list.enumerated().map { idx, p in
let name = p.firstName?.text ?? "_ value\(idx)"
let type = p.type.trimmedDescription
return "\(name): \(type)"
}.joined(separator: ", ")
}
static func makeFunctionArguments(_ list: EnumCaseParameterListSyntax) -> String {
list.enumerated().map { idx, p in
let name = p.firstName?.text ?? "value\(idx)"
return "\\(\(name))"
}.joined(separator: " ")
}
}
@main
struct LocalizablePlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [LocalizableMacro.self]
}