From c27de77d6c4745ae9e71154684e1d8d37ef38910 Mon Sep 17 00:00:00 2001 From: Oleksii Zghurskyi Date: Sat, 16 Aug 2025 12:50:28 +0300 Subject: [PATCH] Localizable macro --- Package.swift | 30 +++++ README.md | 70 +++++++++- Sources/Localizable/Localizable.swift | 45 +++++++ Sources/LocalizableClient/main.swift | 23 ++++ .../LocalizableDiagnostic.swift | 23 ++++ .../LocalizableMacros/LocalizableMacro.swift | 125 ++++++++++++++++++ 6 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 Package.swift create mode 100644 Sources/Localizable/Localizable.swift create mode 100644 Sources/LocalizableClient/main.swift create mode 100644 Sources/LocalizableMacros/LocalizableDiagnostic.swift create mode 100644 Sources/LocalizableMacros/LocalizableMacro.swift diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..119e962 --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "Localizable", + platforms: [ + .macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8), .macCatalyst(.v13), .visionOS(.v1) + ], + products: [ + .library(name: "Localizable", targets: ["Localizable"]), + .executable(name: "LocalizableClient", targets: ["LocalizableClient"]) + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "600.0.0") + ], + targets: [ + .macro( + name: "LocalizableMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ] + ), + .target(name: "Localizable", dependencies: ["LocalizableMacros"]), + .executableTarget(name: "LocalizableClient", dependencies: ["Localizable"]) + ] +) diff --git a/README.md b/README.md index 93ad7d7..549a847 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# Localizable \ No newline at end of file +# Localizable + +Macros-based Swift library for type-safe localization without stringly-typed keys. + +## Overview + +Localizable allows you to define localization keys using enums, and automatically generates static constants and functions for accessing localized strings. This makes your code type-safe, easy to read, and avoids errors from hardcoded keys. + +## Installation + +To add Localizable to your project, use Swift Package Manager (SPM). + +### Adding to an Xcode Project + +1. Open your project in Xcode. +2. Navigate to the `File` menu and select `Add Package Dependencies`. +3. Enter the repository URL: `https://github.com/angd-dev/localizable.git`. +4. Choose the version to install (e.g. `1.0.0`). +5. Add the library to your target module. + +### Adding to Package.swift + +If you are using Swift Package Manager with a Package.swift file, add the dependency like this: + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "YourProject", + dependencies: [ + .package(url: "https://github.com/angd-dev/localizable.git", from: "1.0.0") + ], + targets: [ + .target( + name: "YourTarget", + dependencies: [ + .product(name: "Localizable", package: "localizable") + ] + ) + ] +) +``` + +## Usage + +Define your localization keys inside a nested enum marked with @Localizable. The macro will generate static properties and methods for you. + +```swift +import Localizable + +extension String { + @Localizable enum Login { + private enum Strings { + case welcome + case title(String) + case message(msg1: String, msg2: Int) + } + } +} + +let text1 = String.Login.welcome +let text2 = String.Login.title("Some text") +let text3 = String.Login.message(msg1: "Hello", msg2: 42) +``` + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. diff --git a/Sources/Localizable/Localizable.swift b/Sources/Localizable/Localizable.swift new file mode 100644 index 0000000..3e29483 --- /dev/null +++ b/Sources/Localizable/Localizable.swift @@ -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" +) diff --git a/Sources/LocalizableClient/main.swift b/Sources/LocalizableClient/main.swift new file mode 100644 index 0000000..86ace7c --- /dev/null +++ b/Sources/LocalizableClient/main.swift @@ -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) diff --git a/Sources/LocalizableMacros/LocalizableDiagnostic.swift b/Sources/LocalizableMacros/LocalizableDiagnostic.swift new file mode 100644 index 0000000..aea5259 --- /dev/null +++ b/Sources/LocalizableMacros/LocalizableDiagnostic.swift @@ -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 + } +} diff --git a/Sources/LocalizableMacros/LocalizableMacro.swift b/Sources/LocalizableMacros/LocalizableMacro.swift new file mode 100644 index 0000000..4cd397c --- /dev/null +++ b/Sources/LocalizableMacros/LocalizableMacro.swift @@ -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] +}