WIP: update the lint strings script

pull/1023/head
Ryan ZHAO 8 months ago
parent fcfca0e7f4
commit 715a6fd7ec

@ -18,8 +18,7 @@ extension ProjectState {
/// Adding `// stringlint:disable` to the top of a source file (before imports) or after a string will mean that file/line gets
/// ignored by this script (good for some things like the auto-generated emoji strings or debug strings)
static let lintSuppression: String = "stringlint:disable"
static let primaryLocalisationFile: String = "en"
static let validLocalisationSuffixes: Set<String> = ["Localizable.strings"]
static let primaryLocalisation: String = "en"
static let permissionStrings: Set<String> = [
"permissionsStorageSend",
"permissionsFaceId",
@ -137,7 +136,7 @@ let projectState: ProjectState = ProjectState(
),
loadSourceFiles: targetActions.contains(.lintStrings)
)
print("------------ Processing \(projectState.localizationFiles.count) Localization File(s) ------------")
print("------------ Processing \(projectState.localizationFile) ------------")
targetActions.forEach { $0.perform(projectState: projectState) }
// MARK: - ScriptAction
@ -173,115 +172,116 @@ enum ScriptAction: String {
),
let fileUrls: [URL] = enumerator.allObjects as? [URL]
else { return Output.error("Could not retrieve list of files within built product") }
let localizationFiles: Set<String> = Set(fileUrls
.filter { $0.path.hasSuffix(".lproj") }
.map { $0.lastPathComponent.replacingOccurrences(of: ".lproj", with: "") })
let missingFiles: Set<String> = Set(projectState.localizationFiles
.map { $0.name })
let missingFiles: Set<String> = projectState.localizationFile.locales
.subtracting(localizationFiles)
guard missingFiles.isEmpty else {
return Output.error("Translations missing from \(productName): \(missingFiles.joined(separator: ", "))")
}
break
case .lintStrings:
guard !projectState.localizationFiles.isEmpty else {
return print("------------ Nothing to lint ------------")
}
// Add warnings for any duplicate keys
projectState.localizationFiles.forEach { file in
// Show errors for any duplicates
file.duplicates.forEach { phrase, original in Output.duplicate(phrase, original: original) }
// Show warnings for any phrases missing from the file
let allKeys: Set<String> = Set(file.keyPhrase.keys)
let missingKeysFromOtherFiles: [String: [String]] = projectState.localizationFiles.reduce(into: [:]) { result, otherFile in
guard otherFile.path != file.path else { return }
let missingKeys: Set<String> = Set(otherFile.keyPhrase.keys)
.subtracting(allKeys)
missingKeys.forEach { missingKey in
result[missingKey] = ((result[missingKey] ?? []) + [otherFile.name])
}
}
missingKeysFromOtherFiles.forEach { missingKey, namesOfFilesItWasFound in
Output.warning(file, "Phrase '\(missingKey)' is missing (found in: \(namesOfFilesItWasFound.joined(separator: ", ")))")
}
var maybeFaulty: [String] = []
file.keyPhrase.forEach { key, phrase in
guard let original = projectState.primaryLocalizationFile.keyPhrase[key] else { return }
let numberOfVarablesOrignal = Regex.matches("\\{.*\\}", content: original.value).count
let numberOfVarablesPhrase = Regex.matches("\\{.*\\}", content: phrase.value).count
if numberOfVarablesPhrase != numberOfVarablesOrignal {
maybeFaulty.append(key)
}
}
maybeFaulty.forEach { key in Output.warning(file, "\(key) may be faulty.") }
}
// Process the source code
print("------------ Processing \(projectState.sourceFiles.count) Source File(s) ------------")
let allKeys: Set<String> = Set(projectState.primaryLocalizationFile.keyPhrase.keys)
projectState.sourceFiles.forEach { file in
// Add logs for unlocalised strings
file.unlocalizedPhrases.forEach { phrase in
Output.warning(phrase, "Found unlocalized string '\(phrase.key)'")
}
// Add errors for missing localised strings
let missingKeys: Set<String> = Set(file.keyPhrase.keys).subtracting(allKeys)
missingKeys.forEach { key in
switch file.keyPhrase[key] {
case .some(let phrase): Output.error(phrase, "Localized phrase '\(key)' missing from strings files")
case .none: Output.error(file, "Localized phrase '\(key)' missing from strings files")
}
}
}
break
// guard !projectState.localizationFiles.isEmpty else {
// return print("------------ Nothing to lint ------------")
// }
//
// // Add warnings for any duplicate keys
// projectState.localizationFiles.forEach { file in
// // Show errors for any duplicates
// file.duplicates.forEach { phrase, original in Output.duplicate(phrase, original: original) }
//
// // Show warnings for any phrases missing from the file
// let allKeys: Set<String> = Set(file.keyPhrase.keys)
// let missingKeysFromOtherFiles: [String: [String]] = projectState.localizationFiles.reduce(into: [:]) { result, otherFile in
// guard otherFile.path != file.path else { return }
//
// let missingKeys: Set<String> = Set(otherFile.keyPhrase.keys)
// .subtracting(allKeys)
//
// missingKeys.forEach { missingKey in
// result[missingKey] = ((result[missingKey] ?? []) + [otherFile.name])
// }
// }
//
// missingKeysFromOtherFiles.forEach { missingKey, namesOfFilesItWasFound in
// Output.warning(file, "Phrase '\(missingKey)' is missing (found in: \(namesOfFilesItWasFound.joined(separator: ", ")))")
// }
//
// var maybeFaulty: [String] = []
// file.keyPhrase.forEach { key, phrase in
// guard let original = projectState.primaryLocalizationFile.keyPhrase[key] else { return }
// let numberOfVarablesOrignal = Regex.matches("\\{.*\\}", content: original.value).count
// let numberOfVarablesPhrase = Regex.matches("\\{.*\\}", content: phrase.value).count
// if numberOfVarablesPhrase != numberOfVarablesOrignal {
// maybeFaulty.append(key)
// }
// }
// maybeFaulty.forEach { key in Output.warning(file, "\(key) may be faulty.") }
// }
//
// // Process the source code
// print("------------ Processing \(projectState.sourceFiles.count) Source File(s) ------------")
// let allKeys: Set<String> = Set(projectState.primaryLocalizationFile.keyPhrase.keys)
//
// projectState.sourceFiles.forEach { file in
// // Add logs for unlocalised strings
// file.unlocalizedPhrases.forEach { phrase in
// Output.warning(phrase, "Found unlocalized string '\(phrase.key)'")
// }
//
// // Add errors for missing localised strings
// let missingKeys: Set<String> = Set(file.keyPhrase.keys).subtracting(allKeys)
// missingKeys.forEach { key in
// switch file.keyPhrase[key] {
// case .some(let phrase): Output.error(phrase, "Localized phrase '\(key)' missing from strings files")
// case .none: Output.error(file, "Localized phrase '\(key)' missing from strings files")
// }
// }
// }
// break
case .updatePermissionStrings:
print("------------ Updating permission strings ------------")
var updatedInfoPlistJSON: JSON = projectState.infoPlistLocalizationFile.json
var strings: JSON = updatedInfoPlistJSON["strings"] as! JSON
projectState.localizationFiles.forEach { file in
ProjectState.permissionStrings.forEach { key in
guard let nsKey: String = ProjectState.permissionStringsMap[key] else { return }
var keyPhrases: JSON = strings[nsKey] as! JSON
var localizations: JSON = keyPhrases["localizations"] as! JSON
if let phrase: String = file.keyPhrase[key]?.value {
if let translations: JSON = localizations[file.name] as? JSON {
var stringUnit: JSON = translations["stringUnit"] as! JSON
if (stringUnit["value"] as! String) != phrase {
stringUnit["state"] = "translated"
stringUnit["value"] = phrase
}
} else {
let stringUnit: JSON = [
"state": "translated",
"value": phrase.replacingOccurrences(of: "\"", with: "")
]
localizations[file.name] = ["stringUnit": stringUnit]
}
}
keyPhrases["localizations"] = localizations
strings[nsKey] = keyPhrases
}
}
updatedInfoPlistJSON["strings"] = strings
if let data: Data = try? JSONSerialization.data(withJSONObject: updatedInfoPlistJSON, options: [ .fragmentsAllowed ]) {
do {
try data.write(to: URL(fileURLWithPath: projectState.infoPlistLocalizationFile.path), options: [.atomic])
} catch {
fatalError("Could not write to InfoPlist.xcstrings, error: \(error)")
}
}
break
// print("------------ Updating permission strings ------------")
// var updatedInfoPlistJSON: JSON = projectState.infoPlistLocalizationFile.json
// var strings: JSON = updatedInfoPlistJSON["strings"] as! JSON
// projectState.localizationFiles.forEach { file in
// ProjectState.permissionStrings.forEach { key in
// guard let nsKey: String = ProjectState.permissionStringsMap[key] else { return }
// var keyPhrases: JSON = strings[nsKey] as! JSON
// var localizations: JSON = keyPhrases["localizations"] as! JSON
// if let phrase: String = file.keyPhrase[key]?.value {
// if let translations: JSON = localizations[file.name] as? JSON {
// var stringUnit: JSON = translations["stringUnit"] as! JSON
// if (stringUnit["value"] as! String) != phrase {
// stringUnit["state"] = "translated"
// stringUnit["value"] = phrase
// }
// } else {
// let stringUnit: JSON = [
// "state": "translated",
// "value": phrase.replacingOccurrences(of: "\"", with: "")
// ]
// localizations[file.name] = ["stringUnit": stringUnit]
// }
// }
// keyPhrases["localizations"] = localizations
// strings[nsKey] = keyPhrases
// }
// }
// updatedInfoPlistJSON["strings"] = strings
//
// if let data: Data = try? JSONSerialization.data(withJSONObject: updatedInfoPlistJSON, options: [ .fragmentsAllowed ]) {
// do {
// try data.write(to: URL(fileURLWithPath: projectState.infoPlistLocalizationFile.path), options: [.atomic])
// } catch {
// fatalError("Could not write to InfoPlist.xcstrings, error: \(error)")
// }
// }
}
print("------------ Complete ------------")
@ -344,9 +344,8 @@ enum Output {
// MARK: - ProjectState
struct ProjectState {
let primaryLocalizationFile: LocalizationStringsFile
let localizationFiles: [LocalizationStringsFile]
let sourceFiles: [SourceFile]
let localizationFile: XCStringsFile
let infoPlistLocalizationFile: XCStringsFile
init(path: String, loadSourceFiles: Bool) {
@ -365,17 +364,11 @@ struct ProjectState {
((try? fileUrl.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) &&
!lowerCaseExcludedPaths.contains { fileUrl.path.lowercased().contains($0) }
}
// Localization files
let targetFileSuffixes: Set<String> = Set(ProjectState.validLocalisationSuffixes.map { $0.lowercased() })
self.localizationFiles = validFileUrls
.filter { fileUrl in targetFileSuffixes.contains { fileUrl.path.lowercased().contains($0) } }
.map { LocalizationStringsFile(path: $0.path) }
guard let primaryLocalizationFile: LocalizationStringsFile = self.localizationFiles.first(where: { $0.name == ProjectState.primaryLocalisationFile }) else {
fatalError("Could not locate primary localization file: \(ProjectState.primaryLocalisationFile)")
}
self.primaryLocalizationFile = primaryLocalizationFile
self.localizationFile = validFileUrls
.filter { fileUrl in fileUrl.path.contains("Localizable.xcstrings") }
.map { XCStringsFile(path: $0.path) }
.last!
self.infoPlistLocalizationFile = validFileUrls
.filter { fileUrl in fileUrl.path.contains("InfoPlist.xcstrings") }
@ -405,10 +398,12 @@ protocol KeyedLocatable: Locatable {
extension ProjectState {
// MARK: - XCStringsFile
struct XCStringsFile: Locatable {
let name: String
let path: String
var json: JSON
var strings: JSON
var locales: Set<String> = Set()
var location: String { path }
@ -418,7 +413,12 @@ extension ProjectState {
.components(separatedBy: "/")
.last ?? "Unknown")
self.path = path
self.json = XCStringsFile.parse(path)
self.strings = XCStringsFile.parse(path)["strings"] as! JSON
self.strings.values.forEach { value in
if let localizations: JSON = (value as? JSON)?["localizations"] as? JSON {
self.locales.formUnion(localizations.map{ $0.key })
}
}
}
static func parse(_ path: String) -> JSON {
@ -431,77 +431,6 @@ extension ProjectState {
}
}
// MARK: - LocalizationStringsFile
struct LocalizationStringsFile: Locatable {
struct Phrase: KeyedLocatable {
let key: String
let value: String
let filePath: String
let lineNumber: Int
var location: String { "\(filePath):\(lineNumber)" }
}
let name: String
let path: String
let keyPhrase: [String: Phrase]
let duplicates: [(Phrase, original: Phrase)]
var location: String { path }
init(path: String) {
let result = LocalizationStringsFile.parse(path)
self.name = (path
.replacingOccurrences(of: "/Localizable.strings", with: "")
.replacingOccurrences(of: ".lproj", with: "")
.components(separatedBy: "/")
.last ?? "Unknown")
self.path = path
self.keyPhrase = result.keyPhrase
self.duplicates = result.duplicates
}
static func parse(_ path: String) -> (keyPhrase: [String: Phrase], duplicates: [(Phrase, original: Phrase)]) {
guard
let data: Data = FileManager.default.contents(atPath: path),
let content: String = String(data: data, encoding: .utf8)
else { fatalError("Could not read from path: \(path)") }
let lines: [String] = content.components(separatedBy: .newlines)
var duplicates: [(Phrase, original: Phrase)] = []
var keyPhrase: [String: Phrase] = [:]
lines.enumerated().forEach { lineNumber, line in
guard
let key: String = Regex.matches("\"([^\"]*?)\"(?= =)", content: line).first,
let value: String = Regex.matches("(?<== )\"(.*?)\"(?=;)", content: line).first
else { return }
// Remove the quotation marks around the key
let trimmedKey: String = String(key
.prefix(upTo: key.index(before: key.endIndex))
.suffix(from: key.index(after: key.startIndex)))
// Files are 1-indexed but arrays are 0-indexed so add 1 to the lineNumber
let result: Phrase = Phrase(
key: trimmedKey,
value: value,
filePath: path,
lineNumber: (lineNumber + 1)
)
switch keyPhrase[trimmedKey] {
case .some(let original): duplicates.append((result, original))
case .none: keyPhrase[trimmedKey] = result
}
}
return (keyPhrase, duplicates)
}
}
// MARK: - SourceFile
struct SourceFile: Locatable {

@ -5743,7 +5743,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# \"${SRCROOT}/Scripts/LintLocalizableStrings.swift\" validate\n";
shellScript = "\"${SRCROOT}/Scripts/LintLocalizableStrings.swift\" validate\n";
showEnvVarsInLog = 0;
};
FDC498C02AC1774500EDD897 /* Ensure Localizable.strings included */ = {

Loading…
Cancel
Save