Skip to content

Commit

Permalink
Add Concurrency (#17)
Browse files Browse the repository at this point in the history
* Set minimum macOS version to version 10.15 (Catalina)

* Add MissingTranslation struct

* Add concurrency to core functionality

* Adopt AsyncParsableCommand protocol to provide an asynchronous entry point

* Add XCTest extension for testing async throwing expressions

* Add concurrency to tests
  • Loading branch information
pereBohigas authored May 13, 2024
1 parent 59968c2 commit afeff86
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 120 deletions.
3 changes: 3 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import PackageDescription

let package = Package(
name: "SwiftPolyglot",
platforms: [
.macOS(.v10_15)
],
products: [
.executable(name: "swiftpolyglot", targets: ["SwiftPolyglot"]),
],
Expand Down
6 changes: 3 additions & 3 deletions Sources/SwiftPolyglot/SwiftPolyglot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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]
Expand All @@ -28,7 +28,7 @@ struct SwiftPolyglot: ParsableCommand {
)

do {
try swiftPolyglotCore.run()
try await swiftPolyglotCore.run()
} catch {
throw RuntimeError.coreError(description: error.localizedDescription)
}
Expand Down
30 changes: 30 additions & 0 deletions Sources/SwiftPolyglotCore/MissingTranslation.swift
Original file line number Diff line number Diff line change
@@ -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)"
}
}
}
249 changes: 140 additions & 109 deletions Sources/SwiftPolyglotCore/SwiftPolyglotCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
}
}
Loading

0 comments on commit afeff86

Please sign in to comment.