Localizable macro
This commit is contained in:
30
Package.swift
Normal file
30
Package.swift
Normal file
@@ -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"])
|
||||||
|
]
|
||||||
|
)
|
||||||
68
README.md
68
README.md
@@ -1 +1,69 @@
|
|||||||
# Localizable
|
# 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.
|
||||||
|
|||||||
45
Sources/Localizable/Localizable.swift
Normal file
45
Sources/Localizable/Localizable.swift
Normal 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"
|
||||||
|
)
|
||||||
23
Sources/LocalizableClient/main.swift
Normal file
23
Sources/LocalizableClient/main.swift
Normal 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)
|
||||||
23
Sources/LocalizableMacros/LocalizableDiagnostic.swift
Normal file
23
Sources/LocalizableMacros/LocalizableDiagnostic.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
125
Sources/LocalizableMacros/LocalizableMacro.swift
Normal file
125
Sources/LocalizableMacros/LocalizableMacro.swift
Normal 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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user