From 715a6fd7ec9801c10ded8b6be3b6474699d3614e Mon Sep 17 00:00:00 2001 From: Ryan ZHAO <> Date: Thu, 15 Aug 2024 17:03:30 +1000 Subject: [PATCH] WIP: update the lint strings script --- Scripts/LintLocalizableStrings.swift | 301 ++++++++++----------------- Session.xcodeproj/project.pbxproj | 2 +- 2 files changed, 116 insertions(+), 187 deletions(-) diff --git a/Scripts/LintLocalizableStrings.swift b/Scripts/LintLocalizableStrings.swift index 2e97ea947..afd7894ff 100755 --- a/Scripts/LintLocalizableStrings.swift +++ b/Scripts/LintLocalizableStrings.swift @@ -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 = ["Localizable.strings"] + static let primaryLocalisation: String = "en" static let permissionStrings: Set = [ "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 = Set(fileUrls .filter { $0.path.hasSuffix(".lproj") } .map { $0.lastPathComponent.replacingOccurrences(of: ".lproj", with: "") }) - let missingFiles: Set = Set(projectState.localizationFiles - .map { $0.name }) + let missingFiles: Set = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 { diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 77c9b8eb7..929ca985d 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -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 */ = {