From d630f0e2582bc8d7d457d8cb1f0f31db58f06960 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Thu, 4 Apr 2024 02:35:09 +0200 Subject: [PATCH 1/6] Set minimum macOS version to version 10.15 (Catalina) --- Package.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Package.swift b/Package.swift index 394e2ee..b87f298 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "SwiftPolyglot", + platforms: [ + .macOS(.v10_15) + ], products: [ .executable(name: "swiftpolyglot", targets: ["SwiftPolyglot"]), ], From be4b15eda153e788db54af132d7a4e85d6272dc9 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Thu, 4 Apr 2024 02:33:54 +0200 Subject: [PATCH 2/6] Add MissingTranslation struct --- .../MissingTranslation.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Sources/SwiftPolyglotCore/MissingTranslation.swift diff --git a/Sources/SwiftPolyglotCore/MissingTranslation.swift b/Sources/SwiftPolyglotCore/MissingTranslation.swift new file mode 100644 index 0000000..78ea93e --- /dev/null +++ b/Sources/SwiftPolyglotCore/MissingTranslation.swift @@ -0,0 +1,30 @@ +struct MissingTranslation { + enum Category { + case deviceMissingOrNotTranslated(forDevice: String, inLanguage: String) + case missingOrNotTranslated(inLanguage: String) + case missingTranslation(forLanguage: String) + case missingTranslationForAllLanguages + case pluralMissingOrNotTranslated(forPluralForm: String, inLanguage: String) + } + + let category: Category + let filePath: String + let originalString: String +} + +extension MissingTranslation { + var message: String { + switch category { + case let .deviceMissingOrNotTranslated(device, language): + return "'\(originalString)' device '\(device)' is missing or not translated in '\(language)' in file: \(filePath)" + case let .missingOrNotTranslated(language): + return "'\(originalString)' is missing or not translated in '\(language)' in file: \(filePath)" + case let .missingTranslation(language): + return "'\(originalString)' is missing translations for language '\(language)' in file: \(filePath)" + case .missingTranslationForAllLanguages: + return "'\(originalString)' is not translated in any language in file: \(filePath)" + case let .pluralMissingOrNotTranslated(pluralForm, language): + return "'\(originalString)' plural form '\(pluralForm)' is missing or not translated in '\(language)' in file: \(filePath)" + } + } +} From 6230f53e4dc7453177c8d6bd79a5807266c09263 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Thu, 4 Apr 2024 02:35:25 +0200 Subject: [PATCH 3/6] Add concurrency to core functionality --- .../SwiftPolyglotCore/SwiftPolyglotCore.swift | 249 ++++++++++-------- 1 file changed, 140 insertions(+), 109 deletions(-) diff --git a/Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift b/Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift index 79a5bdd..7a92008 100644 --- a/Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift +++ b/Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift @@ -18,165 +18,196 @@ public struct SwiftPolyglotCore { self.isRunningInAGitHubAction = isRunningInAGitHubAction } - public func run() throws { - var missingTranslations = false + public func run() async throws { + let stringCatalogFileURLs: [URL] = getStringCatalogURLs(from: filePaths) - try searchDirectory(for: languageCodes, missingTranslations: &missingTranslations) + let missingTranslations: [MissingTranslation] = try await withThrowingTaskGroup(of: [MissingTranslation].self) { taskGroup in + for fileURL in stringCatalogFileURLs { + taskGroup.addTask { + let strings: [String: [String: Any]] = extractStrings( + from: fileURL, + isRunningInAGitHubAction: isRunningInAGitHubAction + ) - if missingTranslations, logsErrorOnMissingTranslation { - throw SwiftPolyglotError.missingTranslations - } else if missingTranslations { - print("Completed with missing translations.") - } else { - print("All translations are present.") - } - } + let missingTranslations: [MissingTranslation] = try await getMissingTranslations(from: strings, in: fileURL.path) - private func checkDeviceVariations( - devices: [String: [String: Any]], - originalString: String, - lang: String, - fileURL: URL, - missingTranslations: inout Bool - ) { - for (device, value) in devices { - guard let stringUnit = value["stringUnit"] as? [String: Any], - let state = stringUnit["state"] as? String, state == "translated" - else { - logWarning( - file: fileURL.path, - message: "'\(originalString)' device '\(device)' is missing or not translated in \(lang) in file: \(fileURL.path)" - ) - missingTranslations = true - continue + let missingTranslationsLogs: [String] = missingTranslations.map { missingTranslation in + if isRunningInAGitHubAction { + return logForGitHubAction( + missingTranslation: missingTranslation, + logWithError: logsErrorOnMissingTranslation + ) + } else { + return missingTranslation.message + } + } + + missingTranslationsLogs.forEach { print($0) } + + return missingTranslations + } } - } - } - private func checkPluralizations( - pluralizations: [String: [String: Any]], - originalString: String, - lang: String, - fileURL: URL, - missingTranslations: inout Bool - ) { - for (pluralForm, value) in pluralizations { - guard let stringUnit = value["stringUnit"] as? [String: Any], - let state = stringUnit["state"] as? String, state == "translated" - else { - logWarning( - file: fileURL.path, - message: "'\(originalString)' plural form '\(pluralForm)' is missing or not translated in \(lang) in file: \(fileURL.path)" - ) - missingTranslations = true - continue + return try await taskGroup.reduce(into: [MissingTranslation]()) { partialResult, missingTranslations in + partialResult.append(contentsOf: missingTranslations) } } + + if !missingTranslations.isEmpty, logsErrorOnMissingTranslation { + throw SwiftPolyglotError.missingTranslations + } else if !missingTranslations.isEmpty { + print("Completed with missing translations.") + } else { + print("All translations are present.") + } } - private func checkTranslations(in fileURL: URL, for languages: [String], missingTranslations: inout Bool) throws { - guard let data = try? Data(contentsOf: fileURL), - let jsonObject = try? JSONSerialization.jsonObject(with: data), - let jsonDict = jsonObject as? [String: Any], - let strings = jsonDict["strings"] as? [String: [String: Any]] + private func extractStrings(from fileURL: URL, isRunningInAGitHubAction: Bool) -> [String: [String: Any]] { + guard + let data = try? Data(contentsOf: fileURL), + let jsonObject = try? JSONSerialization.jsonObject(with: data), + let jsonDict = jsonObject as? [String: Any], + let strings = jsonDict["strings"] as? [String: [String: Any]] else { if isRunningInAGitHubAction { print("::warning file=\(fileURL.path)::Could not process file at path: \(fileURL.path)") } else { print("Could not process file at path: \(fileURL.path)") } - return + + return [:] } + return strings + } + + private func getMissingTranslations( + from strings: [String: [String: Any]], + in filePath: String + ) async throws -> [MissingTranslation] { + var missingTranslations: [MissingTranslation] = [] + for (originalString, translations) in strings { guard let localizations = translations["localizations"] as? [String: [String: Any]] else { - logWarning( - file: fileURL.path, - message: "'\(originalString)' is not translated in any language in file: \(fileURL.path)" + missingTranslations.append( + MissingTranslation( + category: .missingTranslationForAllLanguages, + filePath: filePath, + originalString: originalString + ) ) - missingTranslations = true + continue } - for lang in languages { + for lang in languageCodes { guard let languageDict = localizations[lang] else { - logWarning( - file: fileURL.path, - message: "'\(originalString)' is missing translations for language: \(lang) in file: \(fileURL.path)" + missingTranslations.append( + MissingTranslation( + category: .missingTranslation(forLanguage: lang), + filePath: filePath, + originalString: originalString + ) ) - missingTranslations = true + continue } if let variations = languageDict["variations"] as? [String: [String: [String: Any]]] { - try checkVariations( - variations: variations, - originalString: originalString, - lang: lang, - fileURL: fileURL, - missingTranslations: &missingTranslations + missingTranslations.append( + contentsOf: + try getMissingTranslationsFromVariations( + variations, + originalString: originalString, + lang: lang, + filePath: filePath + ) ) - } else if let stringUnit = languageDict["stringUnit"] as? [String: Any], - let state = stringUnit["state"] as? String, state != "translated" + } else if + let stringUnit = languageDict["stringUnit"] as? [String: Any], + let state = stringUnit["state"] as? String, + state != "translated" { - logWarning( - file: fileURL.path, - message: "'\(originalString)' is missing or not translated in \(lang) in file: \(fileURL.path)" + missingTranslations.append( + MissingTranslation( + category: .missingOrNotTranslated(inLanguage: lang), + filePath: filePath, + originalString: originalString + ) ) - missingTranslations = true } } } + + return missingTranslations } - private func checkVariations( - variations: [String: [String: [String: Any]]], + private func getMissingTranslationsFromVariations( + _ variations: [String: [String: [String: Any]]], originalString: String, lang: String, - fileURL: URL, - missingTranslations: inout Bool - ) throws { + filePath: String + ) throws -> [MissingTranslation] { + var missingTranslations: [MissingTranslation] = [] + for (variationKey, variationDict) in variations { if variationKey == "plural" { - checkPluralizations( - pluralizations: variationDict, - originalString: originalString, - lang: lang, - fileURL: fileURL, - missingTranslations: &missingTranslations - ) + for (pluralForm, value) in variationDict { + guard + let stringUnit = value["stringUnit"] as? [String: Any], + let state = stringUnit["state"] as? String, + state == "translated" + else { + missingTranslations.append( + MissingTranslation( + category: .pluralMissingOrNotTranslated(forPluralForm: pluralForm, inLanguage: lang), + filePath: filePath, + originalString: originalString + ) + ) + + continue + } + } } else if variationKey == "device" { - checkDeviceVariations( - devices: variationDict, - originalString: originalString, - lang: lang, - fileURL: fileURL, - missingTranslations: &missingTranslations - ) + for (device, value) in variationDict { + guard + let stringUnit = value["stringUnit"] as? [String: Any], + let state = stringUnit["state"] as? String, + state == "translated" + else { + missingTranslations.append( + MissingTranslation( + category: .deviceMissingOrNotTranslated(forDevice: device, inLanguage: lang), + filePath: filePath, + originalString: originalString + ) + ) + + continue + } + } } else { throw SwiftPolyglotError.unsupportedVariation(variation: variationKey) } } + + return missingTranslations } - - private func logWarning(file: String, message: String) { - if isRunningInAGitHubAction { - if logsErrorOnMissingTranslation { - print("::error file=\(file)::\(message)") - } else { - print("::warning file=\(file)::\(message)") - } - } else { - print(message) + + private func getStringCatalogURLs(from filePaths: [String]) -> [URL] { + filePaths.compactMap { filePath in + guard filePath.hasSuffix(".xcstrings") else { return nil } + + return URL(fileURLWithPath: filePath) } } - private func searchDirectory(for languages: [String], missingTranslations: inout Bool) throws { - for filePath in filePaths { - if filePath.hasSuffix(".xcstrings") { - let fileURL = URL(fileURLWithPath: filePath) - try checkTranslations(in: fileURL, for: languages, missingTranslations: &missingTranslations) - } + private func logForGitHubAction(missingTranslation: MissingTranslation, logWithError: Bool) -> String { + if logWithError { + return "::error file=\(missingTranslation.filePath)::\(missingTranslation.message)" + } else { + return "::warning file=\(missingTranslation.filePath)::\(missingTranslation.message)" } } } From f4edccedff304f394f547eeb65920c74917d2d81 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Mon, 13 May 2024 21:27:22 +0200 Subject: [PATCH 4/6] Adopt AsyncParsableCommand protocol to provide an asynchronous entry point --- Sources/SwiftPolyglot/SwiftPolyglot.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftPolyglot/SwiftPolyglot.swift b/Sources/SwiftPolyglot/SwiftPolyglot.swift index bc50d52..eafcd7d 100755 --- a/Sources/SwiftPolyglot/SwiftPolyglot.swift +++ b/Sources/SwiftPolyglot/SwiftPolyglot.swift @@ -3,7 +3,7 @@ import Foundation import SwiftPolyglotCore @main -struct SwiftPolyglot: ParsableCommand { +struct SwiftPolyglot: AsyncParsableCommand { static let configuration: CommandConfiguration = .init(commandName: "swiftpolyglot") @Flag(help: "Log errors instead of warnings for missing translations.") @@ -12,7 +12,7 @@ struct SwiftPolyglot: ParsableCommand { @Argument(help: "Specify the language(s) to be checked.") private var languages: [String] - func run() throws { + func run() async throws { guard let enumerator = FileManager.default.enumerator(atPath: FileManager.default.currentDirectoryPath), let filePaths = enumerator.allObjects as? [String] @@ -28,7 +28,7 @@ struct SwiftPolyglot: ParsableCommand { ) do { - try swiftPolyglotCore.run() + try await swiftPolyglotCore.run() } catch { throw RuntimeError.coreError(description: error.localizedDescription) } From 8d5c247bf0942ac5cd2f07fe735e92a4f4494922 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Thu, 11 Apr 2024 20:48:12 +0200 Subject: [PATCH 5/6] Add XCTest extension for testing async throwing expressions --- .../XCTest+AsyncThrowingExpression.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 Tests/SwiftPolyglotCoreTests/XCTest+AsyncThrowingExpression.swift diff --git a/Tests/SwiftPolyglotCoreTests/XCTest+AsyncThrowingExpression.swift b/Tests/SwiftPolyglotCoreTests/XCTest+AsyncThrowingExpression.swift new file mode 100644 index 0000000..8d07d75 --- /dev/null +++ b/Tests/SwiftPolyglotCoreTests/XCTest+AsyncThrowingExpression.swift @@ -0,0 +1,77 @@ +import XCTest + +/// Asserts that an asynchronous expression do not throw an error. +/// (Intended to function as a drop-in asynchronous version of `XCTAssertNoThrow`.) +/// +/// Example usage: +/// +/// await assertNoThrowAsync( +/// try await sut.function() +/// ) { error in +/// XCTAssertEqual(error as? MyError, MyError.specificError) +/// } +/// +/// - Parameters: +/// - expression: An asynchronous expression that can throw an error. +/// - message: An optional description of a failure. +/// - file: The file where the failure occurs. The default is the file path of the test case where this function is being called. +/// - line: The line number where the failure occurs. The default is the line number where this function is being called. +public func XCTAssertNoThrowAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + } catch { + // expected no error to be thrown, but it was + let customMessage = message() + if customMessage.isEmpty { + XCTFail("Asynchronous call did throw an error.", file: file, line: line) + } else { + XCTFail(customMessage, file: file, line: line) + } + } +} + +/// Asserts that an asynchronous expression throws an error. +/// (Intended to function as a drop-in asynchronous version of `XCTAssertThrowsError`.) +/// +/// Example usage: +/// +/// await assertThrowsAsyncError( +/// try await sut.function() +/// ) { error in +/// XCTAssertEqual(error as? MyError, MyError.specificError) +/// } +/// +/// - Parameters: +/// - expression: An asynchronous expression that can throw an error. +/// - message: An optional description of a failure. +/// - file: The file where the failure occurs. The default is the file path of the test case where this function is being called. +/// - line: The line number where the failure occurs. The default is the line number where this function is being called. +/// - errorHandler: An optional handler for errors that `expression` throws. +/// +/// from: https://gitlab.com/-/snippets/2567566 +public func XCTAssertThrowsErrorAsync( + _ expression: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (_ error: any Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + // expected error to be thrown, but it was not + let customMessage = message() + if customMessage.isEmpty { + XCTFail("Asynchronous call did not throw an error.", file: file, line: line) + } else { + XCTFail(customMessage, file: file, line: line) + } + } catch { + errorHandler(error) + } +} + From 463e5aefe62324424d9bb4872573ea57f1ff2c07 Mon Sep 17 00:00:00 2001 From: Pere Bohigas Date: Thu, 11 Apr 2024 20:58:33 +0200 Subject: [PATCH 6/6] Add concurrency to tests --- .../SwiftPolyglotCoreTests.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/SwiftPolyglotCoreTests/SwiftPolyglotCoreTests.swift b/Tests/SwiftPolyglotCoreTests/SwiftPolyglotCoreTests.swift index ab86650..081e88d 100644 --- a/Tests/SwiftPolyglotCoreTests/SwiftPolyglotCoreTests.swift +++ b/Tests/SwiftPolyglotCoreTests/SwiftPolyglotCoreTests.swift @@ -2,7 +2,7 @@ import XCTest final class SwiftPolyglotCoreTests: XCTestCase { - func testStringCatalogFullyTranslated() throws { + func testStringCatalogFullyTranslated() async throws { guard let stringCatalogFilePath = Bundle.module.path( forResource: "FullyTranslated", @@ -21,10 +21,10 @@ final class SwiftPolyglotCoreTests: XCTestCase { isRunningInAGitHubAction: false ) - XCTAssertNoThrow(try swiftPolyglotCore.run()) + await XCTAssertNoThrowAsync(try await swiftPolyglotCore.run()) } - func testStringCatalogVariationsFullyTranslated() throws { + func testStringCatalogVariationsFullyTranslated() async throws { guard let stringCatalogFilePath = Bundle.module.path( forResource: "VariationsFullyTranslated", @@ -43,10 +43,10 @@ final class SwiftPolyglotCoreTests: XCTestCase { isRunningInAGitHubAction: false ) - XCTAssertNoThrow(try swiftPolyglotCore.run()) + await XCTAssertNoThrowAsync(try await swiftPolyglotCore.run()) } - func testStringCatalogWithMissingTranslations() throws { + func testStringCatalogWithMissingTranslations() async throws { guard let stringCatalogFilePath = Bundle.module.path( forResource: "WithMissingTranslations", @@ -65,10 +65,10 @@ final class SwiftPolyglotCoreTests: XCTestCase { isRunningInAGitHubAction: false ) - XCTAssertThrowsError(try swiftPolyglotCore.run()) + await XCTAssertThrowsErrorAsync(try await swiftPolyglotCore.run()) } - func testStringCatalogWithMissingVariations() throws { + func testStringCatalogWithMissingVariations() async throws { guard let stringCatalogFilePath = Bundle.module.path( forResource: "VariationsWithMissingTranslations", @@ -87,6 +87,6 @@ final class SwiftPolyglotCoreTests: XCTestCase { isRunningInAGitHubAction: false ) - XCTAssertThrowsError(try swiftPolyglotCore.run()) + await XCTAssertThrowsErrorAsync(try await swiftPolyglotCore.run()) } }