├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── Sources
├── LocalizableClient
│ └── main.swift
├── LocalizableMacros
│ ├── LocalizableMacroDiagnostic.swift
│ └── LocalizableMacro.swift
└── Localizable
│ └── Localizable.swift
├── Package.resolved
├── README.md
├── Package.swift
├── .gitignore
└── Tests
└── LocalizableTests
└── LocalizableTests.swift
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Sources/LocalizableClient/main.swift:
--------------------------------------------------------------------------------
1 | import Localizable
2 | import Foundation
3 |
4 | @Localizable
5 | internal struct Localization {
6 | private enum Strings {
7 | case login_welcome
8 | case login_message(String)
9 | }
10 | }
11 |
12 | print(Localization.login_welcome)
13 |
14 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/Package.resolved:
--------------------------------------------------------------------------------
1 | {
2 | "pins" : [
3 | {
4 | "identity" : "swift-syntax",
5 | "kind" : "remoteSourceControl",
6 | "location" : "https://github.com/apple/swift-syntax.git",
7 | "state" : {
8 | "revision" : "67c5007099d9ffdd292f421f81f4efe5ee42963e",
9 | "version" : "509.0.0-swift-DEVELOPMENT-SNAPSHOT-2023-07-10-a"
10 | }
11 | }
12 | ],
13 | "version" : 2
14 | }
15 |
--------------------------------------------------------------------------------
/Sources/LocalizableMacros/LocalizableMacroDiagnostic.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntax
2 | import SwiftDiagnostics
3 |
4 | enum LocalizableMacroDiagnostic {
5 | case requiresEnum
6 | }
7 |
8 | extension LocalizableMacroDiagnostic: DiagnosticMessage {
9 | var severity: DiagnosticSeverity { .error }
10 |
11 | var diagnosticID: MessageID {
12 | MessageID(domain: "Swift", id: "Localized.\(self)")
13 | }
14 |
15 | func diagnose(at node: some SyntaxProtocol) -> Diagnostic {
16 | Diagnostic(node: Syntax(node), message: self)
17 | }
18 |
19 | var message: String {
20 | switch self {
21 | case .requiresEnum:
22 | return "'Localizable' macro needs an internal enum which is a set of keys for localization"
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## Localizable Macro
2 | A macro that produces variables and methods, for your localization files.
3 | From the enumeration keys.
4 |
5 | For example, with the following input code:
6 | ```swift
7 | @Localizable
8 | struct Localization {
9 | private enum Strings {
10 | case login_error
11 | case login_welcome(String)
12 | }
13 | }
14 | ```
15 | The macro `Localizable` output will produce:
16 | ```swift
17 | @Localizable
18 | struct Localization {
19 | private enum Strings {
20 | case login_error
21 | case login_welcome(String)
22 | }
23 |
24 | static let login_error = NSLocalizedString("login_error", comment: "")
25 | static func login_welcome(_ value0: String) -> String {
26 | String(format: NSLocalizedString("login_error", comment: ""), value0)
27 | }
28 | }
29 | ```
30 |
--------------------------------------------------------------------------------
/Sources/Localizable/Localizable.swift:
--------------------------------------------------------------------------------
1 | /// A macro that produces variables and methods, for your localization files.
2 | /// From the enumeration keys.
3 | ///
4 | /// For example, with the following input code:
5 | ///```swift
6 | /// @Localizable
7 | /// struct Localization {
8 | /// private enum Strings {
9 | /// case login_error
10 | /// case login_welcome(String)
11 | /// }
12 | /// }
13 | ///```
14 | /// The macro `Localizable` output will produce:
15 | ///```swift
16 | /// @Localizable
17 | /// struct Localization {
18 | /// private enum Strings {
19 | /// case login_error
20 | /// case login_welcome(String)
21 | /// }
22 | ///
23 | /// static let login_error = NSLocalizedString("login_error", comment: "")
24 | /// static func login_welcome(_ value0: String) -> String {
25 | /// String(format: NSLocalizedString("login_error", comment: ""), value0)
26 | /// }
27 | /// }
28 | ///```
29 | @attached(member, names: arbitrary)
30 | public macro Localizable() = #externalMacro(module: "LocalizableMacros", type: "LocalizableMacro")
31 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 | import CompilerPluginSupport
6 |
7 | let package = Package(
8 | name: "Localizable",
9 | platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)],
10 | products: [
11 | // Products define the executables and libraries a package produces, making them visible to other packages.
12 | .library(
13 | name: "Localizable",
14 | targets: ["Localizable"]
15 | ),
16 | .executable(
17 | name: "LocalizableClient",
18 | targets: ["LocalizableClient"]
19 | ),
20 | ],
21 | dependencies: [
22 | // Depend on the latest Swift 5.9 prerelease of SwiftSyntax
23 | .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0-swift-5.9-DEVELOPMENT-SNAPSHOT-2023-04-25-b"),
24 | ],
25 | targets: [
26 | // Targets are the basic building blocks of a package, defining a module or a test suite.
27 | // Targets can depend on other targets in this package and products from dependencies.
28 | // Macro implementation that performs the source transformation of a macro.
29 | .macro(
30 | name: "LocalizableMacros",
31 | dependencies: [
32 | .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
33 | .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
34 | ]
35 | ),
36 |
37 | // Library that exposes a macro as part of its API, which is used in client programs.
38 | .target(name: "Localizable", dependencies: ["LocalizableMacros"]),
39 |
40 | // A client of the library, which is able to use the macro in its own code.
41 | .executableTarget(name: "LocalizableClient", dependencies: ["Localizable"]),
42 |
43 | // A test target used to develop the macro implementation.
44 | .testTarget(
45 | name: "LocalizableTests",
46 | dependencies: [
47 | "LocalizableMacros",
48 | .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
49 | ]
50 | ),
51 | ]
52 | )
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Xcode
2 | #
3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
4 |
5 | ## User settings
6 | xcuserdata/
7 |
8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9)
9 | *.xcscmblueprint
10 | *.xccheckout
11 |
12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4)
13 | build/
14 | DerivedData/
15 | *.moved-aside
16 | *.pbxuser
17 | !default.pbxuser
18 | *.mode1v3
19 | !default.mode1v3
20 | *.mode2v3
21 | !default.mode2v3
22 | *.perspectivev3
23 | !default.perspectivev3
24 |
25 | ## Obj-C/Swift specific
26 | *.hmap
27 |
28 | ## App packaging
29 | *.ipa
30 | *.dSYM.zip
31 | *.dSYM
32 |
33 | ## Playgrounds
34 | timeline.xctimeline
35 | playground.xcworkspace
36 |
37 | # Swift Package Manager
38 | #
39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
40 | # Packages/
41 | # Package.pins
42 | # Package.resolved
43 | # *.xcodeproj
44 | #
45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
46 | # hence it is not needed unless you have added a package configuration file to your project
47 | # .swiftpm
48 |
49 | .build/
50 |
51 | # CocoaPods
52 | #
53 | # We recommend against adding the Pods directory to your .gitignore. However
54 | # you should judge for yourself, the pros and cons are mentioned at:
55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
56 | #
57 | # Pods/
58 | #
59 | # Add this line if you want to avoid checking in source code from the Xcode workspace
60 | # *.xcworkspace
61 |
62 | # Carthage
63 | #
64 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
65 | # Carthage/Checkouts
66 |
67 | Carthage/Build/
68 |
69 | # Accio dependency management
70 | Dependencies/
71 | .accio/
72 |
73 | # fastlane
74 | #
75 | # It is recommended to not store the screenshots in the git repo.
76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed.
77 | # For more information about the recommended setup visit:
78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control
79 |
80 | fastlane/report.xml
81 | fastlane/Preview.html
82 | fastlane/screenshots/**/*.png
83 | fastlane/test_output
84 |
85 | # Code Injection
86 | #
87 | # After new code Injection tools there's a generated folder /iOSInjectionProject
88 | # https://github.com/johnno1962/injectionforxcode
89 |
90 | iOSInjectionProject/
--------------------------------------------------------------------------------
/Sources/LocalizableMacros/LocalizableMacro.swift:
--------------------------------------------------------------------------------
1 | import SwiftCompilerPlugin
2 | import SwiftSyntax
3 | import SwiftSyntaxBuilder
4 | import SwiftSyntaxMacros
5 |
6 | public struct LocalizableMacro: MemberMacro {
7 | public static func expansion(
8 | of node: AttributeSyntax,
9 | providingMembersOf declaration: some DeclGroupSyntax,
10 | in context: some MacroExpansionContext
11 | ) throws -> [DeclSyntax] {
12 | guard let enumDecl = declaration.memberBlock.members.compactMap(asEnumDecl).first else {
13 | context.diagnose(LocalizableMacroDiagnostic.requiresEnum.diagnose(at: declaration))
14 | return []
15 | }
16 | let enumCases = enumDecl.memberBlock.members.flatMap(asEnumCaseDecl)
17 | let enumMembers = enumCases.map(casesToEnumMembers)
18 | return enumMembers
19 | }
20 |
21 | private static func asEnumDecl(_ member: MemberDeclListItemSyntax) -> EnumDeclSyntax? {
22 | member.decl.as(EnumDeclSyntax.self)
23 | }
24 |
25 | private static func asEnumCaseDecl(_ member: MemberDeclListItemSyntax) -> [EnumCaseElementSyntax] {
26 | guard let caseDecl = member.decl.as(EnumCaseDeclSyntax.self) else {
27 | return [EnumCaseElementSyntax]()
28 | }
29 | return Array(caseDecl.elements)
30 | }
31 |
32 | private static func casesToEnumMembers(_ element: EnumCaseElementSyntax) -> DeclSyntax {
33 | if element.associatedValue != nil {
34 | return makeStaticFunc(from: element)
35 | }
36 | return makeStaticField(from: element)
37 | }
38 |
39 | private static func makeStaticField(from element: EnumCaseElementSyntax) -> DeclSyntax {
40 | """
41 | static let \(element.identifier) = NSLocalizedString("\(element.identifier)", comment: "")
42 | """
43 | }
44 |
45 | private static func makeStaticFunc(from element: EnumCaseElementSyntax) -> DeclSyntax {
46 | let parameterList = element.associatedValue?.parameterList ?? []
47 | let syntax = """
48 | static func \(element.identifier)(\(makeFuncParameters(from: parameterList))) -> String {
49 | String(format: NSLocalizedString("\(element.identifier)", comment: ""),\(makeFuncArguments(from: parameterList)))
50 | }
51 | """
52 | return DeclSyntax(stringLiteral: syntax)
53 | }
54 |
55 | private static func makeFuncParameters(from list: EnumCaseParameterListSyntax) -> String {
56 | list
57 | .enumerated()
58 | .map { "_ value\($0): \($1.type.as(SimpleTypeIdentifierSyntax.self)!.name.text)" }
59 | .joined(separator: ",")
60 | }
61 |
62 | private static func makeFuncArguments(from list: EnumCaseParameterListSyntax) -> String {
63 | list
64 | .enumerated()
65 | .map { " value\($0.offset)" }
66 | .joined(separator: ",")
67 | }
68 | }
69 |
70 | @main
71 | struct LocalizablePlugin: CompilerPlugin {
72 | let providingMacros: [Macro.Type] = [
73 | LocalizableMacro.self,
74 | ]
75 | }
76 |
--------------------------------------------------------------------------------
/Tests/LocalizableTests/LocalizableTests.swift:
--------------------------------------------------------------------------------
1 | import SwiftSyntaxMacros
2 | import SwiftSyntaxMacrosTestSupport
3 | import XCTest
4 | import LocalizableMacros
5 |
6 | let testMacros: [String: Macro.Type] = [
7 | "Localizable": LocalizableMacro.self,
8 | ]
9 |
10 | final class LocalizableMacroTests: XCTestCase {
11 |
12 | func testLocalizedStructWithoutEnumString() {
13 | assertMacroExpansion(
14 | """
15 | @Localizable
16 | struct Localization {
17 | }
18 | """,
19 | expandedSource: """
20 | struct Localization {
21 | }
22 | """,
23 | diagnostics: [
24 | .init(message: "'Localizable' macro needs an internal enum which is a set of keys for localization", line: 1, column: 1)
25 | ],
26 | macros: testMacros
27 | )
28 | }
29 |
30 | func testLocalizedStructWithPrivateLocalizationStringEnum() {
31 | assertMacroExpansion(
32 | """
33 | @Localizable
34 | struct Localization {
35 | private enum LocalizationString {
36 | case next
37 | case prev
38 | }
39 | }
40 | """,
41 | expandedSource: """
42 | struct Localization {
43 | private enum LocalizationString {
44 | case next
45 | case prev
46 | }
47 | static let next = NSLocalizedString("next", comment: "")
48 | static let prev = NSLocalizedString("prev", comment: "")
49 | }
50 | """,
51 | macros: testMacros
52 | )
53 | }
54 |
55 | func testLocalizedStructWithSomeLocalizationStringEnumCases() {
56 | assertMacroExpansion(
57 | """
58 | @Localizable
59 | struct Localization {
60 | enum LocalizationString {
61 | case next
62 | case prev
63 | }
64 | }
65 | """,
66 | expandedSource: """
67 | struct Localization {
68 | enum LocalizationString {
69 | case next
70 | case prev
71 | }
72 | static let next = NSLocalizedString("next", comment: "")
73 | static let prev = NSLocalizedString("prev", comment: "")
74 | }
75 | """,
76 | macros: testMacros
77 | )
78 | }
79 |
80 | func testLocalizedClassWithSomeLocalizationStringEnumCases() {
81 | assertMacroExpansion(
82 | """
83 | @Localizable
84 | class Localization {
85 | enum LocalizationString {
86 | case next
87 | case prev
88 | }
89 | }
90 | """,
91 | expandedSource: """
92 | class Localization {
93 | enum LocalizationString {
94 | case next
95 | case prev
96 | }
97 | static let next = NSLocalizedString("next", comment: "")
98 | static let prev = NSLocalizedString("prev", comment: "")
99 | }
100 | """,
101 | macros: testMacros
102 | )
103 | }
104 |
105 | func testLocalizedEnumWithSomeLocalizationStringEnumCases() {
106 | assertMacroExpansion(
107 | """
108 | @Localizable
109 | enum Localization {
110 | enum LocalizationString {
111 | case next
112 | case prev
113 | }
114 | }
115 | """,
116 | expandedSource: """
117 | enum Localization {
118 | enum LocalizationString {
119 | case next
120 | case prev
121 | }
122 | static let next = NSLocalizedString("next", comment: "")
123 | static let prev = NSLocalizedString("prev", comment: "")
124 | }
125 | """,
126 | macros: testMacros
127 | )
128 | }
129 |
130 | func testLocalizedStructWithLocalizationStringEnumWhereCasesWithParams() {
131 | assertMacroExpansion(
132 | """
133 | @Localizable
134 | struct Localization {
135 | enum LocalizationString {
136 | case next
137 | case prev
138 | case news(String)
139 | case smth(String, String)
140 | }
141 | }
142 | """,
143 | expandedSource: """
144 | struct Localization {
145 | enum LocalizationString {
146 | case next
147 | case prev
148 | case news(String)
149 | case smth(String, String)
150 | }
151 | static let next = NSLocalizedString("next", comment: "")
152 | static let prev = NSLocalizedString("prev", comment: "")
153 | static func news(_ value0: String) -> String {
154 | String(format: NSLocalizedString("news", comment: ""), value0)
155 | }
156 | static func smth(_ value0: String, _ value1: String) -> String {
157 | String(format: NSLocalizedString("smth", comment: ""), value0, value1)
158 | }
159 | }
160 | """,
161 | macros: testMacros
162 | )
163 | }
164 | }
165 |
166 |
--------------------------------------------------------------------------------