From d04fca20aa7fcc5f6b3e02fe09b3fd9100f9b3a3 Mon Sep 17 00:00:00 2001 From: Richard Piazza Date: Wed, 27 May 2026 10:31:30 -0500 Subject: [PATCH] Syntax Style (Locale Support + SwiftUI Extensions) --- .../KeyHierarchy+Syntax.swift | 139 +++++++++-- .../TranslationCatalogIO/SyntaxStyle.swift | 6 + Sources/localizer/Catalog+Syntax.swift | 26 +- .../Extensions/SyntaxStyle+localizer.swift | 20 ++ .../DeepKeyHierarchyTests.swift | 232 +++++++++++++++++- .../ShallowKeyHierarchyTests.swift | 162 +++++++++++- 6 files changed, 558 insertions(+), 27 deletions(-) create mode 100644 Sources/TranslationCatalogIO/SyntaxStyle.swift create mode 100644 Sources/localizer/Extensions/SyntaxStyle+localizer.swift diff --git a/Sources/TranslationCatalogIO/KeyHierarchy+Syntax.swift b/Sources/TranslationCatalogIO/KeyHierarchy+Syntax.swift index 278bfb8..afad259 100644 --- a/Sources/TranslationCatalogIO/KeyHierarchy+Syntax.swift +++ b/Sources/TranslationCatalogIO/KeyHierarchy+Syntax.swift @@ -10,9 +10,50 @@ public extension KeyHierarchy { func localizedStringConvertible(rootDeclaration name: String = "LocalizedStrings") -> Data { let sourceFile = SourceFileSyntax { CodeBlockItemListSyntax { - ImportDeclSyntax.localeSupport + ImportDeclSyntax( + path: ImportPathComponentListSyntax { + ImportPathComponentSyntax(name: TokenSyntax("LocaleSupport")) + } + ) + + EnumDeclSyntax.localizedStringConvertibleEnumerationDecl(for: self, named: name) + } + } + + var dataStream = DataOutputStream() + sourceFile.formatted().write(to: &dataStream) + return dataStream.data + } + + /// Data containing a file with a hierarchy of enums extending `LocalizedStringKey`. + func localizedStringKey() -> Data { + let sourceFile = SourceFileSyntax { + CodeBlockItemListSyntax { + ImportDeclSyntax( + path: ImportPathComponentListSyntax { + ImportPathComponentSyntax(name: TokenSyntax("SwiftUI")) + } + ) - EnumDeclSyntax.stringEnumerationDecl(for: self, named: name) + ExtensionDeclSyntax( + leadingTrivia: .newlines(2), + extendedType: TypeSyntax(stringLiteral: "LocalizedStringKey") + ) { + let hierarchy = self + for key in hierarchy.sortedContentsKeys { + if let content = hierarchy.contents[key] { + VariableDeclSyntax.localizedStringKey( + name: key.lowerCamelCased, + value: content.key, + comment: content.defaultValue.isEmpty ? nil : content.defaultValue + ) + } + } + + for node in hierarchy.sortedNodes { + EnumDeclSyntax.localizedStringKeyEnumerationDecl(for: node) + } + } } } @@ -21,11 +62,22 @@ public extension KeyHierarchy { return dataStream.data } - func syntaxTree(rootDeclaration name: String = "LocalizedStrings") -> String { - String( - decoding: localizedStringConvertible(rootDeclaration: name), - as: UTF8.self - ) + func syntaxTree( + style: SyntaxStyle = .localeSupport, + rootDeclaration name: String = "LocalizedStrings" + ) -> String { + switch style { + case .localeSupport: + String( + decoding: localizedStringConvertible(rootDeclaration: name), + as: UTF8.self + ) + case .swiftUI: + String( + decoding: localizedStringKey(), + as: UTF8.self + ) + } } internal var declName: String { @@ -43,16 +95,8 @@ extension [String] { } } -extension ImportDeclSyntax { - static let localeSupport: ImportDeclSyntax = ImportDeclSyntax( - path: ImportPathComponentListSyntax { - ImportPathComponentSyntax(name: TokenSyntax("LocaleSupport")) - } - ) -} - extension EnumDeclSyntax { - static func stringEnumerationDecl( + static func localizedStringConvertibleEnumerationDecl( for hierarchy: KeyHierarchy, named name: String? = nil ) -> EnumDeclSyntax { @@ -73,7 +117,7 @@ extension EnumDeclSyntax { ) { for key in hierarchy.sortedContentsKeys { if let content = hierarchy.contents[key] { - EnumCaseDeclSyntax.stringEnumerationCase( + EnumCaseDeclSyntax.localizedStringConvertibleEnumerationCase( key: key.lowerCamelCased, value: content.defaultValue, comment: content.comment @@ -89,14 +133,37 @@ extension EnumDeclSyntax { } for node in hierarchy.sortedNodes { - stringEnumerationDecl(for: node) + localizedStringConvertibleEnumerationDecl(for: node) + } + } + } + + static func localizedStringKeyEnumerationDecl( + for hierarchy: KeyHierarchy + ) -> EnumDeclSyntax { + EnumDeclSyntax( + leadingTrivia: .newlines(2), + name: TokenSyntax(stringLiteral: hierarchy.declName) + ) { + for key in hierarchy.sortedContentsKeys { + if let content = hierarchy.contents[key] { + VariableDeclSyntax.localizedStringKey( + name: key.lowerCamelCased, + value: content.key, + comment: content.defaultValue.isEmpty ? nil : content.defaultValue + ) + } + } + + for node in hierarchy.sortedNodes { + EnumDeclSyntax.localizedStringKeyEnumerationDecl(for: node) } } } } extension EnumCaseDeclSyntax { - static func stringEnumerationCase(key: String, value: String, comment: String?) -> EnumCaseDeclSyntax { + static func localizedStringConvertibleEnumerationCase(key: String, value: String, comment: String?) -> EnumCaseDeclSyntax { var trivia: Trivia = [] if let comment { trivia = [ @@ -147,6 +214,40 @@ extension VariableDeclSyntax { ) } } + + static func localizedStringKey(name: String, value: String, comment: String?) -> VariableDeclSyntax { + var trivia: Trivia? + if let comment { + trivia = [ + .docLineComment("/// \(comment)"), + .newlines(1), + ] + } + + return VariableDeclSyntax( + leadingTrivia: trivia, + modifiers: DeclModifierListSyntax([ + DeclModifierSyntax(name: .keyword(.static)), + ]), + bindingSpecifier: .keyword(.let) + ) { + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier(name)), + typeAnnotation: TypeAnnotationSyntax( + type: IdentifierTypeSyntax(name: .identifier("LocalizedStringKey")) + ), + initializer: InitializerClauseSyntax( + value: StringLiteralExprSyntax( + openingQuote: .stringQuoteToken(), + segments: StringLiteralSegmentListSyntax([ + .stringSegment(StringSegmentSyntax(content: .stringSegment(value))), + ]), + closingQuote: .stringQuoteToken() + ) + ) + ) + } + } } struct DataOutputStream: TextOutputStream, CustomStringConvertible { diff --git a/Sources/TranslationCatalogIO/SyntaxStyle.swift b/Sources/TranslationCatalogIO/SyntaxStyle.swift new file mode 100644 index 0000000..58e75b7 --- /dev/null +++ b/Sources/TranslationCatalogIO/SyntaxStyle.swift @@ -0,0 +1,6 @@ +public enum SyntaxStyle: CaseIterable { + /// Enumerated structure utilizing `LocalizedStringConvertible` from the LocaleSupport package. + case localeSupport + /// Extension to SwiftUIs `LocalizedStringKey` + case swiftUI +} diff --git a/Sources/localizer/Catalog+Syntax.swift b/Sources/localizer/Catalog+Syntax.swift index 083979e..87dc07f 100644 --- a/Sources/localizer/Catalog+Syntax.swift +++ b/Sources/localizer/Catalog+Syntax.swift @@ -13,6 +13,8 @@ extension Catalog { discussion: """ Generate an enumerated reference to strings. For example: + (Using Style: 'locale-support', default) + import LocaleSupport enum LocalizedStrings: String, LocalizedStringConvertible { /// Title for account screen. case account = "Account" @@ -26,6 +28,18 @@ extension Catalog { } } } + + (Using Style: 'swift-ui') + import SwiftUI + extension LocalizedStringKey { + /// Title for account screen. + static let account: LocalizedStringKey = "ACCOUNT" + + enum Account { + /// Action to navigate to the previous screen. + static let back = "ACCOUNT_BACK" + } + } """, version: "1.0.0", helpNames: .shortAndLong @@ -43,6 +57,9 @@ extension Catalog { @Option(help: "Path to catalog to use in place of the application library.") var path: String? + @Option(help: "Generated reference style.") + var style: SyntaxStyle = .localeSupport + @Flag(help: "Reduce the instance of single-content nodes.") var compressed: Bool = false @@ -72,9 +89,14 @@ extension Catalog { } let syntax = if let name, !name.isEmpty { - keyHierarchy.syntaxTree(rootDeclaration: name) + keyHierarchy.syntaxTree( + style: style, + rootDeclaration: name + ) } else { - keyHierarchy.syntaxTree() + keyHierarchy.syntaxTree( + style: style + ) } print(syntax) diff --git a/Sources/localizer/Extensions/SyntaxStyle+localizer.swift b/Sources/localizer/Extensions/SyntaxStyle+localizer.swift new file mode 100644 index 0000000..c655a2c --- /dev/null +++ b/Sources/localizer/Extensions/SyntaxStyle+localizer.swift @@ -0,0 +1,20 @@ +import ArgumentParser +import Foundation +import TranslationCatalogIO + +extension SyntaxStyle: ExpressibleByArgument { + var argument: String { + switch self { + case .localeSupport: "locale-support" + case .swiftUI: "swift-ui" + } + } + + public init?(argument: String) { + guard let style = SyntaxStyle.allCases.first(where: { $0.argument.caseInsensitiveCompare(argument) == .orderedSame }) else { + return nil + } + + self = style + } +} diff --git a/Tests/TranslationCatalogTests/DeepKeyHierarchyTests.swift b/Tests/TranslationCatalogTests/DeepKeyHierarchyTests.swift index 9177ec6..393a9e2 100644 --- a/Tests/TranslationCatalogTests/DeepKeyHierarchyTests.swift +++ b/Tests/TranslationCatalogTests/DeepKeyHierarchyTests.swift @@ -198,7 +198,7 @@ struct DeepKeyHierarchyTests { """) } - @Test func phantomOnlyCompression() throws { + @Test func localizedStringConvertiblePhantomOnlyCompression() throws { let syntax = try hierarchy .compressed(mergeOrphans: false) .syntaxTree() @@ -291,7 +291,7 @@ struct DeepKeyHierarchyTests { """) } - @Test func orphanOnlyCompression() throws { + @Test func localizedStringConvertibleOrphanOnlyCompression() throws { let syntax = try hierarchy .compressed(mergePhantoms: false) .syntaxTree() @@ -346,7 +346,7 @@ struct DeepKeyHierarchyTests { """) } - @Test func compression() throws { + @Test func localizedStringConvertibleCompression() throws { let syntax = try hierarchy .compressed() .syntaxTree() @@ -394,4 +394,230 @@ struct DeepKeyHierarchyTests { } """) } + + @Test func localizedStringKey() { + let syntax = hierarchy.syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + + enum Payment { + + enum Method { + + enum Add { + /// Add payment method + static let action: LocalizedStringKey = "PAYMENT_METHOD_ADD_ACTION" + + enum Card { + + enum Failure { + /// Your payment method could not be added at this time. + static let message: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_FAILURE_MESSAGE" + } + + enum Success { + /// Your payment method has been added. + static let message: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_SUCCESS_MESSAGE" + } + } + } + + enum Confirm { + /// Are you sure you want to delete this payment method? + static let delete: LocalizedStringKey = "PAYMENT_METHOD_CONFIRM_DELETE" + } + + enum Expiration { + /// Exp + static let abbreviation: LocalizedStringKey = "PAYMENT_METHOD_EXPIRATION_ABBREVIATION" + } + + enum Navigation { + /// Manage Payments + static let title: LocalizedStringKey = "PAYMENT_METHOD_NAVIGATION_TITLE" + } + + enum Remove { + + enum Card { + + enum Failure { + /// Your payment method could not be deleted at this time. + static let message: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_FAILURE_MESSAGE" + } + + enum Success { + /// Your payment method has been deleted. + static let message: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_SUCCESS_MESSAGE" + } + } + } + + enum Section { + /// Payment Methods + static let title: LocalizedStringKey = "PAYMENT_METHOD_SECTION_TITLE" + } + } + } + } + """) + } + + @Test func localizedStringKeyPhantomOnlyCompression() throws { + let syntax = try hierarchy + .compressed(mergeOrphans: false) + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + + enum PaymentMethod { + + enum Add { + /// Add payment method + static let action: LocalizedStringKey = "PAYMENT_METHOD_ADD_ACTION" + + enum Card { + + enum Failure { + /// Your payment method could not be added at this time. + static let message: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_FAILURE_MESSAGE" + } + + enum Success { + /// Your payment method has been added. + static let message: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_SUCCESS_MESSAGE" + } + } + } + + enum Confirm { + /// Are you sure you want to delete this payment method? + static let delete: LocalizedStringKey = "PAYMENT_METHOD_CONFIRM_DELETE" + } + + enum Expiration { + /// Exp + static let abbreviation: LocalizedStringKey = "PAYMENT_METHOD_EXPIRATION_ABBREVIATION" + } + + enum Navigation { + /// Manage Payments + static let title: LocalizedStringKey = "PAYMENT_METHOD_NAVIGATION_TITLE" + } + + enum RemoveCard { + + enum Failure { + /// Your payment method could not be deleted at this time. + static let message: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_FAILURE_MESSAGE" + } + + enum Success { + /// Your payment method has been deleted. + static let message: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_SUCCESS_MESSAGE" + } + } + + enum Section { + /// Payment Methods + static let title: LocalizedStringKey = "PAYMENT_METHOD_SECTION_TITLE" + } + } + } + """) + } + + @Test func localizedStringKeyOrphanOnlyCompression() throws { + let syntax = try hierarchy + .compressed(mergePhantoms: false) + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + + enum Payment { + + enum Method { + /// Are you sure you want to delete this payment method? + static let confirmDelete: LocalizedStringKey = "PAYMENT_METHOD_CONFIRM_DELETE" + /// Exp + static let expirationAbbreviation: LocalizedStringKey = "PAYMENT_METHOD_EXPIRATION_ABBREVIATION" + /// Manage Payments + static let navigationTitle: LocalizedStringKey = "PAYMENT_METHOD_NAVIGATION_TITLE" + /// Payment Methods + static let sectionTitle: LocalizedStringKey = "PAYMENT_METHOD_SECTION_TITLE" + + enum Add { + /// Add payment method + static let action: LocalizedStringKey = "PAYMENT_METHOD_ADD_ACTION" + + enum Card { + /// Your payment method could not be added at this time. + static let failureMessage: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_FAILURE_MESSAGE" + /// Your payment method has been added. + static let successMessage: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_SUCCESS_MESSAGE" + } + } + + enum Remove { + + enum Card { + /// Your payment method could not be deleted at this time. + static let failureMessage: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_FAILURE_MESSAGE" + /// Your payment method has been deleted. + static let successMessage: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_SUCCESS_MESSAGE" + } + } + } + } + } + """) + } + + @Test func localizedStringKeyCompression() throws { + let syntax = try hierarchy + .compressed() + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + + enum PaymentMethod { + /// Are you sure you want to delete this payment method? + static let confirmDelete: LocalizedStringKey = "PAYMENT_METHOD_CONFIRM_DELETE" + /// Exp + static let expirationAbbreviation: LocalizedStringKey = "PAYMENT_METHOD_EXPIRATION_ABBREVIATION" + /// Manage Payments + static let navigationTitle: LocalizedStringKey = "PAYMENT_METHOD_NAVIGATION_TITLE" + /// Payment Methods + static let sectionTitle: LocalizedStringKey = "PAYMENT_METHOD_SECTION_TITLE" + + enum Add { + /// Add payment method + static let action: LocalizedStringKey = "PAYMENT_METHOD_ADD_ACTION" + + enum Card { + /// Your payment method could not be added at this time. + static let failureMessage: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_FAILURE_MESSAGE" + /// Your payment method has been added. + static let successMessage: LocalizedStringKey = "PAYMENT_METHOD_ADD_CARD_SUCCESS_MESSAGE" + } + } + + enum RemoveCard { + /// Your payment method could not be deleted at this time. + static let failureMessage: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_FAILURE_MESSAGE" + /// Your payment method has been deleted. + static let successMessage: LocalizedStringKey = "PAYMENT_METHOD_REMOVE_CARD_SUCCESS_MESSAGE" + } + } + } + """) + } } diff --git a/Tests/TranslationCatalogTests/ShallowKeyHierarchyTests.swift b/Tests/TranslationCatalogTests/ShallowKeyHierarchyTests.swift index 29533ab..e40dbf1 100644 --- a/Tests/TranslationCatalogTests/ShallowKeyHierarchyTests.swift +++ b/Tests/TranslationCatalogTests/ShallowKeyHierarchyTests.swift @@ -154,7 +154,7 @@ struct ShallowKeyHierarchyTests { """) } - @Test func phantomOnlyCompression() throws { + @Test func localizedStringConvertiblePhantomOnlyCompression() throws { let syntax = try hierarchy .compressed(mergeOrphans: false) .syntaxTree() @@ -209,7 +209,7 @@ struct ShallowKeyHierarchyTests { """) } - @Test func orphanOnlyCompression() throws { + @Test func localizedStringConvertibleOrphanOnlyCompression() throws { let key = LocalizationKey( key: "ZULU_ZONE", defaultValue: "zone" @@ -250,7 +250,7 @@ struct ShallowKeyHierarchyTests { """) } - @Test func compression() throws { + @Test func localizedStringConvertibleCompression() throws { let syntax = try hierarchy .compressed() .syntaxTree() @@ -276,4 +276,160 @@ struct ShallowKeyHierarchyTests { } """) } + + @Test func localizedStringKey() { + let syntax = hierarchy.syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + /// Hello World! + static let greeting: LocalizedStringKey = "GREETING" + + enum Application { + /// Lingua + static let name: LocalizedStringKey = "APPLICATION_NAME" + } + + enum Hidden { + static let message: LocalizedStringKey = "HIDDEN_MESSAGE" + } + + enum Platform { + /// Android + static let android: LocalizedStringKey = "PLATFORM_ANDROID" + /// Apple + static let apple: LocalizedStringKey = "PLATFORM_APPLE" + /// Web + static let web: LocalizedStringKey = "PLATFORM_WEB" + + enum Apple { + /// macOS + static let mac: LocalizedStringKey = "PLATFORM_APPLE_MAC" + } + } + + enum Zulu { + + enum Time { + /// definition + static let definition: LocalizedStringKey = "ZULU_TIME_DEFINITION" + } + } + } + """) + } + + @Test func localizedStringKeyPhantomOnlyCompression() throws { + let syntax = try hierarchy + .compressed(mergeOrphans: false) + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + /// Hello World! + static let greeting: LocalizedStringKey = "GREETING" + + enum Application { + /// Lingua + static let name: LocalizedStringKey = "APPLICATION_NAME" + } + + enum Hidden { + static let message: LocalizedStringKey = "HIDDEN_MESSAGE" + } + + enum Platform { + /// Android + static let android: LocalizedStringKey = "PLATFORM_ANDROID" + /// Apple + static let apple: LocalizedStringKey = "PLATFORM_APPLE" + /// Web + static let web: LocalizedStringKey = "PLATFORM_WEB" + + enum Apple { + /// macOS + static let mac: LocalizedStringKey = "PLATFORM_APPLE_MAC" + } + } + + enum ZuluTime { + /// definition + static let definition: LocalizedStringKey = "ZULU_TIME_DEFINITION" + } + } + """) + } + + @Test func localizedStringKeyOrphanOnlyCompression() throws { + let key = LocalizationKey( + key: "ZULU_ZONE", + defaultValue: "zone" + ) + var test = hierarchy + try test.processKey(key, path: [["ZULU"], ["ZONE"]]) + let syntax = try test + .compressed(mergePhantoms: false) + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + /// Lingua + static let applicationName: LocalizedStringKey = "APPLICATION_NAME" + /// Hello World! + static let greeting: LocalizedStringKey = "GREETING" + static let hiddenMessage: LocalizedStringKey = "HIDDEN_MESSAGE" + + enum Platform { + /// Android + static let android: LocalizedStringKey = "PLATFORM_ANDROID" + /// Apple + static let apple: LocalizedStringKey = "PLATFORM_APPLE" + /// macOS + static let appleMac: LocalizedStringKey = "PLATFORM_APPLE_MAC" + /// Web + static let web: LocalizedStringKey = "PLATFORM_WEB" + } + + enum Zulu { + /// definition + static let timeDefinition: LocalizedStringKey = "ZULU_TIME_DEFINITION" + /// zone + static let zone: LocalizedStringKey = "ZULU_ZONE" + } + } + """) + } + + @Test func localizedStringKeyCompression() throws { + let syntax = try hierarchy + .compressed() + .syntaxTree(style: .swiftUI) + #expect(syntax == """ + import SwiftUI + + extension LocalizedStringKey { + /// Lingua + static let applicationName: LocalizedStringKey = "APPLICATION_NAME" + /// Hello World! + static let greeting: LocalizedStringKey = "GREETING" + static let hiddenMessage: LocalizedStringKey = "HIDDEN_MESSAGE" + /// definition + static let zuluTimeDefinition: LocalizedStringKey = "ZULU_TIME_DEFINITION" + + enum Platform { + /// Android + static let android: LocalizedStringKey = "PLATFORM_ANDROID" + /// Apple + static let apple: LocalizedStringKey = "PLATFORM_APPLE" + /// macOS + static let appleMac: LocalizedStringKey = "PLATFORM_APPLE_MAC" + /// Web + static let web: LocalizedStringKey = "PLATFORM_WEB" + } + } + """) + } }