You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-ios/Scripts/LintLocalizableStrings.swift

1018 lines
48 KiB
Swift

#!/usr/bin/xcrun --sdk macosx swift
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
//
/// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample
/// The main differences are:
/// 1. Changes to the localized usage regex
/// 2. Addition to excluded unlocalized cases
/// 3. Functionality to update and copy localized permission requirement strings to infoPlist.xcstrings
import Foundation
import Dispatch
typealias JSON = [String:AnyHashable]
extension ProjectState {
/// Linting control commands
enum LintControl: String, CaseIterable {
/// Add `// stringlint:disable` to the top of a source file (before imports) to make this script ignore a file
case disable = "stringlint:disable"
/// Add `// stringlint:ignore` after a line to ignore it
case ignoreLine = "stringlint:ignore"
/// Add `// stringlint:ignore_start` and `// stringlint:ignore_stop` before and after a
/// lines of code to make this script ignore the contents for string linting purposes
case ignoreStart = "stringlint:ignore_start"
case ignoreStop = "stringlint:ignore_stop"
/// Add `// stringlint:ignore_contents` before anything with curly braces (eg. function, class, closure, etc.)
/// to everything within the curly braces
case ignoreContents = "stringlint:ignore_contents"
}
static let primaryLocalisation: String = "en"
static let permissionStrings: Set<String> = [
"permissionsStorageSend",
"permissionsFaceId",
"cameraGrantAccessDescription",
"permissionsAppleMusic",
"permissionsStorageSave",
"permissionsMicrophoneAccessRequiredIos"
]
static let permissionStringsMap: [String: String] = [
"permissionsStorageSend": "NSPhotoLibraryUsageDescription",
"permissionsFaceId": "NSFaceIDUsageDescription",
"cameraGrantAccessDescription": "NSCameraUsageDescription",
"permissionsAppleMusic": "NSAppleMusicUsageDescription",
"permissionsStorageSave": "NSPhotoLibraryAddUsageDescription",
"permissionsMicrophoneAccessRequiredIos": "NSMicrophoneUsageDescription"
]
static let validSourceSuffixes: Set<String> = [".swift", ".m"]
static let excludedPaths: Set<String> = [
"build/", // Files under the build folder (CI)
"Pods/", // The pods folder
"Protos/", // The protobuf files
".xcassets/", // Asset bundles
".app/", // App build directories
".appex/", // Extension build directories
"tests/", // Exclude test directories
"_SharedTestUtilities/", // Exclude shared test directory
"external/" // External dependencies
]
static let excludedPhrases: Set<String> = [ "", " ", " ", ",", ", ", "null", "\"", "@[0-9a-fA-F]{66}", "^[0-9A-Fa-f]+$", "/" ]
static let excludedUnlocalizedStringLineMatching: [MatchType] = [
.prefix("#import", caseSensitive: false),
.prefix("@available(", caseSensitive: false),
.prefix("print(", caseSensitive: false),
.prefix("Log.Category =", caseSensitive: false),
.contains("fatalError(", caseSensitive: false),
.contains("precondition(", caseSensitive: false),
.contains("preconditionFailure(", caseSensitive: false),
.contains("logMessage:", caseSensitive: false),
.contains(".logging(", caseSensitive: false),
.contains("owsFailDebug(", caseSensitive: false),
.contains("error: .other(", caseSensitive: false),
.contains("#imageLiteral(resourceName:", caseSensitive: false),
.contains("[UIImage imageNamed:", caseSensitive: false),
.contains("Image(", caseSensitive: false),
.contains("image:", caseSensitive: false),
.contains("logo:", caseSensitive: false),
.contains("UIFont(name:", caseSensitive: false),
.contains(".dateFormat =", caseSensitive: false),
.contains("accessibilityLabel =", caseSensitive: false),
.contains("accessibilityValue =", caseSensitive: false),
.contains("accessibilityIdentifier =", caseSensitive: false),
.contains("accessibilityIdentifier:", caseSensitive: false),
.contains("accessibilityLabel:", caseSensitive: false),
.contains("Accessibility(identifier:", caseSensitive: false),
.contains("Accessibility(label:", caseSensitive: false),
.contains(".withAccessibility(identifier:", caseSensitive: false),
.contains(".withAccessibility(label:", caseSensitive: false),
.contains("NSAttributedString.Key(", caseSensitive: false),
.contains("Notification.Name(", caseSensitive: false),
.contains("Notification.Key(", caseSensitive: false),
.contains("DispatchQueue(", caseSensitive: false),
.and(
.prefix("static let identifier: String = ", caseSensitive: false),
.previousLine(numEarlier: 2, .suffix(": Migration {", caseSensitive: false))
),
.and(
.contains("identifier:", caseSensitive: false),
.previousLine(.contains("Accessibility(", caseSensitive: false))
),
.and(
.contains("label:", caseSensitive: false),
.previousLine(.contains("Accessibility(", caseSensitive: false))
),
.and(
.contains("label:", caseSensitive: false),
.previousLine(numEarlier: 2, .contains("Accessibility(", caseSensitive: false))
),
.contains("SQL(", caseSensitive: false),
.contains("forResource:", caseSensitive: false),
.contains("imageName:", caseSensitive: false),
.contains("systemName:", caseSensitive: false),
.contains(".userInfo[", caseSensitive: false),
.contains("payload[", caseSensitive: false),
.contains(".infoDictionary?[", caseSensitive: false),
.contains("accessibilityId:", caseSensitive: false),
.contains("SNUIKit.localizedString(for:", caseSensitive: false),
.and(
.contains("id:", caseSensitive: false),
.previousLine(numEarlier: 1, .regex(Regex.crypto))
),
.and(
.contains("identifier:", caseSensitive: false),
.previousLine(numEarlier: 1, .contains("Dependencies.create", caseSensitive: false))
),
.belowLineContaining("PreviewProvider"),
.belowLineContaining("#Preview"),
.belowLineContaining(": Migration {"),
.regex(Regex.logging),
.regex(Regex.errorCreation),
.regex(Regex.databaseTableName),
.regex(Regex.enumCaseDefinition),
.regex(Regex.imageInitialization),
.regex(Regex.variableToStringConversion)
]
}
// Execute the desired actions
let targetActions: Set<ScriptAction> = {
let args = CommandLine.arguments
// The first argument is the file name
guard args.count > 1 else { return [.lintStrings] }
return Set(args.suffix(from: 1).map { (ScriptAction(rawValue: $0) ?? .lintStrings) })
}()
print("------------ Searching Through Files ------------")
let projectState: ProjectState = ProjectState(path:
ProcessInfo.processInfo.environment["PROJECT_DIR"] ??
FileManager.default.currentDirectoryPath
)
print("------------ Processing \(projectState.localizationFile.path) ------------")
targetActions.forEach { $0.perform(projectState: projectState) }
// MARK: - ScriptAction
enum ScriptAction: String {
case validateFilesCopied = "validate"
case lintStrings = "lint"
case updatePermissionStrings = "update"
func perform(projectState: ProjectState) {
// Perform the action
switch self {
case .validateFilesCopied:
print("------------ Checking Copied Files ------------")
guard
let builtProductsPath: String = ProcessInfo.processInfo.environment["BUILT_PRODUCTS_DIR"],
let productName: String = ProcessInfo.processInfo.environment["FULL_PRODUCT_NAME"],
let productPathInfo = try? URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
.resourceValues(forKeys: [.isSymbolicLinkKey, .isAliasFileKey]),
let finalProductUrl: URL = try? { () -> URL in
let possibleAliasUrl: URL = URL(fileURLWithPath: "\(builtProductsPath)/\(productName)")
guard productPathInfo.isSymbolicLink == true || productPathInfo.isAliasFile == true else {
return possibleAliasUrl
}
return try URL(resolvingAliasFileAt: possibleAliasUrl, options: URL.BookmarkResolutionOptions())
}(),
let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
at: finalProductUrl,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
),
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> = projectState.localizationFile.locales
.subtracting(localizationFiles)
guard missingFiles.isEmpty else {
return Output.error("Translations missing from \(productName): \(missingFiles.joined(separator: ", "))")
}
break
case .lintStrings:
guard !projectState.localizationFile.strings.isEmpty else {
return print("------------ Nothing to lint ------------")
}
var allKeys: [String] = []
var tokensForKey: [String: Set<String>] = [:]
var duplicates: [String] = []
projectState.localizationFile.strings.forEach { key, value in
if allKeys.contains(key) {
duplicates.append(key)
} else {
allKeys.append(key)
}
// Add warning for probably faulty translation
if let localizations: JSON = (value as? JSON)?["localizations"] as? JSON {
if let original: String = ((localizations["en"] as? JSON)?["stringUnit"] as? JSON)?["value"] as? String {
let processedOriginal: String = original.removingUnwantedScalars()
let tokensInOriginal: [String] = processedOriginal
.matches(of: Regex.dynamicStringVariable)
.map { match in
String(processedOriginal[match.range])
.trimmingCharacters(in: CharacterSet(charactersIn: "{}"))
}
let numberOfTokensOrignal: Int = tokensInOriginal.count
// Only add to the dict if there are tokens
if !tokensInOriginal.isEmpty {
tokensForKey[key] = Set(tokensInOriginal)
}
// Check that the number of tokens match (including 0 tokens)
localizations.forEach { locale, translation in
if let phrase: String = ((translation as? JSON)?["stringUnit"] as? JSON)?["value"] as? String {
// Zero-width characters can mess with regex matching so we need to clean them
// out before matching
let numberOfTokensPhrase: Int = phrase
.removingUnwantedScalars()
.matches(of: Regex.dynamicStringVariable)
.count
if numberOfTokensPhrase != numberOfTokensOrignal {
Output.warning("\(key) in \(locale) may be faulty ('\(original)' contains \(numberOfTokensOrignal) vs. '\(phrase)' contains \(numberOfTokensPhrase))")
}
}
}
}
}
}
// Add warnings for any duplicate keys
duplicates.forEach { Output.duplicate(key: $0) }
// Process the source code
print("------------ Processing Source Files ------------")
let results = projectState.lintSourceFiles()
print("------------ Processed \(results.sourceFiles.count) File(s), Ignored \(results.ignoredPaths.count) File(s) ------------")
var totalUnlocalisedStrings: Int = 0
results.sourceFiles.forEach { file in
// Add logs for unlocalized strings
file.unlocalizedPhrases.forEach { phrase in
totalUnlocalisedStrings += 1
Output.warning(phrase, "Found unlocalized string '\(phrase.key)'")
}
// Add errors for missing localized strings
let missingKeys: Set<String> = Set(file.keyPhrase.keys).subtracting(Set(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")
}
}
// Add errors for incorrect/missing tokens
file.keyPhrase.forEach { key, phrase in
guard
let tokens: Set<String> = tokensForKey[key],
tokens != phrase.providedTokens
else { return }
let extra: Set<String> = phrase.providedTokens.subtracting(tokens)
let missing: Set<String> = tokens.subtracting(phrase.providedTokens)
let tokensString: String = tokens.map { "{\($0)}" }.joined(separator: ", ")
let providedString: String = {
let result: String = phrase.providedTokens.map { "{\($0)}" }.joined(separator: ", ")
guard !result.isEmpty else { return "no tokens" }
return "'\(result)'"
}()
let description: String = [
(!extra.isEmpty || !missing.isEmpty ? " (" : nil),
(!extra.isEmpty ? "Extra: '\(extra.map { "{\($0)}" }.joined(separator: ", "))'" : nil),
(!extra.isEmpty && !missing.isEmpty ? ", " : ""),
(!missing.isEmpty ? "Missing: '\(missing.map { "{\($0)}" }.joined(separator: ", "))'" : nil),
(!extra.isEmpty || !missing.isEmpty ? ")" : nil)
].compactMap { $0 }.joined()
Output.error(phrase, "Localized phrase '\(key)' requires the token(s) '\(tokensString)' and has \(providedString)\(description)")
}
}
print("------------ Found \(totalUnlocalisedStrings) unlocalized string(s) ------------")
break
case .updatePermissionStrings:
print("------------ Updating permission strings ------------")
var strings: JSON = projectState.infoPlistLocalizationFile.strings
var updatedInfoPlistJSON: JSON = projectState.infoPlistLocalizationFile.json
ProjectState.permissionStrings.forEach { key in
guard let nsKey: String = ProjectState.permissionStringsMap[key] else { return }
if
let stringsData: Data = try? JSONSerialization.data(withJSONObject: (projectState.localizationFile.strings[key] as! JSON), options: [ .fragmentsAllowed ]),
let stringsJSONString: String = String(data: stringsData, encoding: .utf8)
{
let updatedStringsJSONString = stringsJSONString.replacingOccurrences(of: "{app_name}", with: "Session")
if
let updatedStringsData: Data = updatedStringsJSONString.data(using: .utf8),
let updatedStrings: JSON = try? JSONSerialization.jsonObject(with: updatedStringsData, options: [ .fragmentsAllowed ]) as? JSON
{
strings[nsKey] = updatedStrings
}
}
}
updatedInfoPlistJSON["strings"] = strings
guard updatedInfoPlistJSON != projectState.infoPlistLocalizationFile.json else {
return
}
if let data: Data = try? JSONSerialization.data(withJSONObject: updatedInfoPlistJSON, options: [ .fragmentsAllowed, .sortedKeys ]) {
do {
try data.write(to: URL(fileURLWithPath: projectState.infoPlistLocalizationFile.path), options: [.atomic])
} catch {
fatalError("Could not write to InfoPlist.xcstrings, error: \(error)")
}
}
break
}
print("------------ Complete ------------")
}
}
// MARK: - Functionality
enum Regex {
// Initializing these as static variables means we don't init them every time they are used
// which can speed up processing
static let comment = #/\/\/[^"]*(?:"[^"]*"[^"]*)*/#
static let allStrings = #/"[^"\\]*(?:\\.[^"\\]*)*"/#
static let localizedString = #/^(?:\.put(?:Number)?\([^)]+\))*\.localized/#
static let localizedFunctionCall = #/\.localized(?:Formatted)?(?:Deformatted)?\(.*\)/#
static let localizationHelperCall = #/LocalizationHelper\(template:\s*(?:"[^"]+"|(?!self\b)[A-Za-z_]\w*)\s*\)/#
static let logging = #/(?:SN)?Log.*\(/#
static let errorCreation = #/Error.*\(/#
static let databaseTableName = #/.*static var databaseTableName: String/#
static let enumCaseDefinition = #/case [^:]* = /#
static let imageInitialization = #/(?:UI)?Image\((?:named:)?(?:imageName:)?(?:systemName:)?.*\)/#
static let variableToStringConversion = #/"\\(.*)"/#
static let localizedParameter = #/^(?:\.put(?:Number)?\([^)]+\))*/#
static let localizedParameterToken = #/(?:\.put\(key:\s*"(?<token>[^"]+)")/#
static let crypto = #/Crypto.*\(/#
static let dynamicStringVariable = #/\{\w+\}/#
/// Returns a list of strings that match regex pattern from content
///
/// - Parameters:
/// - pattern: regex pattern
/// - content: content to match
/// - Returns: list of results
static func matches(_ regex: some RegexComponent, content: String) -> [String] {
return content.matches(of: regex).map { match in
String(content[match.range])
}
}
}
// MARK: - Output
enum Output {
static func error(_ error: String) {
print("error: \(error)")
}
static func error(_ location: Locatable, _ error: String) {
print("\(location.location): error: \(error)")
}
static func warning(_ warning: String) {
print("warning: \(warning)")
}
static func warning(_ location: Locatable, _ warning: String) {
print("\(location.location): warning: \(warning)")
}
static func duplicate(
_ duplicate: KeyedLocatable,
original: KeyedLocatable
) {
print("\(duplicate.location): error: duplicate key '\(original.key)'")
// Looks like the `note:` doesn't work the same as when XCode does it unfortunately so we can't
// currently include the reference to the original entry
// print("\(original.location): note: previously found here")
}
static func duplicate(key: String) {
print("Error: duplicate key '\(key)'")
}
}
// MARK: - ProjectState
struct ProjectState {
let queue = DispatchQueue(label: "session.stringlint", attributes: .concurrent)
let group = DispatchGroup()
let validFileUrls: [URL]
let localizationFile: XCStringsFile
let infoPlistLocalizationFile: XCStringsFile
init(path: String) {
guard
let enumerator: FileManager.DirectoryEnumerator = FileManager.default.enumerator(
at: URL(fileURLWithPath: path),
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
),
let fileUrls: [URL] = enumerator.allObjects as? [URL]
else { fatalError("Could not locate files in path directory: \(path)") }
// Get a list of valid URLs
let lowerCaseExcludedPaths: Set<String> = Set(ProjectState.excludedPaths.map { $0.lowercased() })
validFileUrls = fileUrls.filter { fileUrl in
((try? fileUrl.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) &&
!lowerCaseExcludedPaths.contains { fileUrl.path.lowercased().contains($0) }
}
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") }
.map { XCStringsFile(path: $0.path) }
.last!
}
func lintSourceFiles() -> (sourceFiles: [SourceFile], ignoredPaths: [String]) {
let resultLock: NSLock = NSLock()
let lowerCaseSourceSuffixes: Set<String> = Set(ProjectState.validSourceSuffixes.map { $0.lowercased() })
var results: [(path: String, file: SourceFile?)] = []
validFileUrls
.filter { fileUrl in lowerCaseSourceSuffixes.contains(".\(fileUrl.pathExtension)") }
.forEach { fileUrl in
queue.async(group: group) {
let file: SourceFile? = SourceFile(path: fileUrl.path)
resultLock.lock()
results.append((fileUrl.path, file))
resultLock.unlock()
}
}
group.wait()
let sourceFiles: [SourceFile] = results.compactMap { _, file in file }
let ignoredPaths: [String] = results.filter { _, file in file == nil }.map { path, _ in path }
return (sourceFiles, ignoredPaths)
}
}
protocol Locatable {
var location: String { get }
}
protocol KeyedLocatable: Locatable {
var key: String { get }
}
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 }
init(path: String) {
self.name = (path
.replacingOccurrences(of: ".xcstrings", with: "")
.components(separatedBy: "/")
.last ?? "Unknown")
self.path = path
self.json = XCStringsFile.parse(path)
self.strings = self.json["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 {
guard
let data: Data = FileManager.default.contents(atPath: path),
let json: JSON = try? JSONSerialization.jsonObject(with: data, options: [ .fragmentsAllowed ]) as? JSON
else { fatalError("Could not read from path: \(path)") }
return json
}
}
// MARK: - SourceFile
struct SourceFile: Locatable {
struct LintState {
var isDisabled: Bool = false
var isInIgnoredSection: Bool = false
var isInIgnoredContents: Bool = false
var ignoredContentsDepth: Int = 0
}
struct TemplateStringState {
var key: String
var lineNumber: Int
var chainedCalls: [String]
let isExplicitLocalizationMatch: Bool
let possibleKeyPhrases: [Phrase]
}
struct Phrase: KeyedLocatable, Equatable {
let term: String
let providedTokens: Set<String>
let filePath: String
let lineNumber: Int
var key: String { term }
var location: String { "\(filePath):\(lineNumber)" }
}
let path: String
let keyPhrase: [String: Phrase]
let unlocalizedKeyPhrase: [String: Phrase]
let phrases: [Phrase]
let unlocalizedPhrases: [Phrase]
var location: String { path }
init?(path: String) {
guard let result = SourceFile.parse(path) else { return nil }
self.path = path
self.keyPhrase = result.keyPhrase
self.unlocalizedKeyPhrase = result.unlocalizedKeyPhrase
self.phrases = result.phrases
self.unlocalizedPhrases = result.unlocalizedPhrases
}
static func parse(_ path: String) -> (keyPhrase: [String: Phrase], phrases: [Phrase], unlocalizedKeyPhrase: [String: Phrase], unlocalizedPhrases: [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)") }
// If the file has the lint supression before the first import then ignore the file
let preImportContent: String = (content.components(separatedBy: "import").first ?? "")
guard !preImportContent.contains(ProjectState.LintControl.disable.rawValue) else {
return nil
}
// Otherwise continue and process the file
let lines: [String] = content.components(separatedBy: .newlines)
var keyPhrase: [String: Phrase] = [:]
var unlocalizedKeyPhrase: [String: Phrase] = [:]
var phrases: [Phrase] = []
var unlocalizedPhrases: [Phrase] = []
var lintState = LintState()
var templateState: TemplateStringState?
lines.enumerated().forEach { lineNumber, line in
let trimmedLine: String = line.trimmingCharacters(in: .whitespacesAndNewlines)
// Check for lint control commands
if let controlCommand: ProjectState.LintControl = checkLintControl(line: trimmedLine) {
updateLintState(&lintState, command: controlCommand, line: trimmedLine)
return
}
// Track function depth for ignored functions
if lintState.isInIgnoredContents {
updateContentsDepth(&lintState, line: trimmedLine)
}
// Skip linting if disabled
guard !shouldSkipLinting(state: lintState) else { return }
// Skip lines without quotes or an explicit LocalizationHelper definition if we
// aren't in template construction (optimization)
guard
trimmedLine.contains("\"") ||
trimmedLine.contains("LocalizationHelper(template:") ||
templateState != nil
else { return }
// Skip explicitly excluded lines
guard
!ProjectState.excludedUnlocalizedStringLineMatching
.contains(where: { $0.matches(trimmedLine, lineNumber, lines) })
else { return }
// Process the line for strings
processLine(
line: line,
lineNumber: lineNumber,
path: path,
keyPhrase: &keyPhrase,
unlocalizedKeyPhrase: &unlocalizedKeyPhrase,
phrases: &phrases,
unlocalizedPhrases: &unlocalizedPhrases,
templateState: &templateState,
lines: lines
)
}
return (keyPhrase, phrases, unlocalizedKeyPhrase, unlocalizedPhrases)
}
private static func checkLintControl(line: String) -> ProjectState.LintControl? {
/// Need to sort by length to ensure we don't unintentionally detect one control mechanism over another
/// due to it containing the full other command - eg. `disable_start` vs `disable`
ProjectState.LintControl.allCases
.sorted { lhs, rhs in lhs.rawValue.count > rhs.rawValue.count }
.first { line.contains($0.rawValue) }
}
private static func updateLintState(_ state: inout LintState, command: ProjectState.LintControl, line: String) {
switch command {
case .disable: state.isDisabled = true
case .ignoreStart: state.isInIgnoredSection = true
case .ignoreStop: state.isInIgnoredSection = false
case .ignoreContents:
guard !state.isInIgnoredContents else { return }
state.isInIgnoredContents = true
state.ignoredContentsDepth = 0
case .ignoreLine: break // Handle single-line ignore in the caller
}
}
private static func updateContentsDepth(_ state: inout LintState, line: String) {
if line.contains("{") {
state.ignoredContentsDepth += 1
}
if line.contains("}") {
state.ignoredContentsDepth -= 1
if state.ignoredContentsDepth == 0 {
state.isInIgnoredContents = false
}
}
}
private static func shouldSkipLinting(state: LintState) -> Bool {
state.isDisabled || state.isInIgnoredSection || state.isInIgnoredContents
}
private static func processLine(
line: String,
lineNumber: Int,
path: String,
keyPhrase: inout [String: Phrase],
unlocalizedKeyPhrase: inout [String: Phrase],
phrases: inout [Phrase],
unlocalizedPhrases: inout [Phrase],
templateState: inout TemplateStringState?,
lines: [String]
) {
// Handle commented sections
let commentMatches = line.matches(of: Regex.comment)
let targetLine: String = (commentMatches.isEmpty ?
line :
String(line[..<commentMatches[0].range.lowerBound])
)
switch templateState {
case .none:
// Extract the strings and remove any excluded phrases
var isExplicitLocalizationMatch: Bool = false
var keyMatches: [String] = extractMatches(from: targetLine, with: Regex.allStrings)
.filter { !ProjectState.excludedPhrases.contains($0) }
if let explicitLocalizationMatch = targetLine.firstMatch(of: Regex.localizationHelperCall) {
keyMatches.append(String(targetLine[explicitLocalizationMatch.range]))
isExplicitLocalizationMatch = true
}
if !keyMatches.isEmpty {
// Iterate through each match to determine localization
for match in keyMatches {
let explicitStringRange = targetLine.range(of: "\"\(match)\"")
// Find the range of the matched string
if let range = explicitStringRange {
// Check if .localized or a template func is called immediately following
// this specific string
let afterString = targetLine[range.upperBound...]
let isLocalized: Bool = (afterString.firstMatch(of: Regex.localizedString) != nil)
if isLocalized {
// Add as a localized phrase
let phrase = Phrase(
term: match,
providedTokens: Set(targetLine
.matches(of: Regex.localizedParameterToken)
.map { String($0.output.token) }),
filePath: path,
lineNumber: lineNumber + 1 // Files are 1-indexed so add 1 to lineNumber
)
keyPhrase[match] = phrase
phrases.append(phrase)
return
}
else if (match != keyMatches.last) {
// There is another string after this one so this couldn't be localized
// or a multi-line template
let unlocalizedPhrase = Phrase(
term: match,
providedTokens: [],
filePath: path,
lineNumber: lineNumber + 1 // Files are 1-indexed so add 1 to lineNumber
)
unlocalizedKeyPhrase[match] = unlocalizedPhrase
unlocalizedPhrases.append(unlocalizedPhrase)
continue
}
}
// If it doesn't match one of the two cases above or isn't an explicit string
// then look ahead to verify if put/putNumber/localized are called in the next lines
let lookAheadLimit: Int = 2
var isTemplateChain: Bool = false
for offset in 1...lookAheadLimit {
let lookAheadIndex: Int = lineNumber + offset
if lookAheadIndex < lines.count {
let lookAheadLine = lines[lookAheadIndex].trimmingCharacters(in: .whitespacesAndNewlines)
if
lookAheadLine.hasPrefix(".put(") ||
lookAheadLine.hasPrefix(".putNumber(") ||
lookAheadLine.hasPrefix(".localized")
{
isTemplateChain = true
break
}
}
}
if isTemplateChain {
var possibleKeyPhrases: [Phrase] = []
// If the match was due to an explicit `LocalizationHelper(template:)`
// then we need to look back through the code to find the definition
// of the template value (assuming it's a variable)
if isExplicitLocalizationMatch {
let variableName: String = keyMatches[0]
.replacingOccurrences(of: "LocalizationHelper(template:", with: "")
.trimmingCharacters(in: CharacterSet(charactersIn: ")"))
.trimmingCharacters(in: .whitespacesAndNewlines)
// Note: Files are 1-indexed so need to `$0.lineNumber - 1`
possibleKeyPhrases = unlocalizedPhrases
.filter { lines[$0.lineNumber - 1].contains("\(variableName) = ") }
}
templateState = TemplateStringState(
key: keyMatches[0],
lineNumber: lineNumber,
chainedCalls: [],
isExplicitLocalizationMatch: isExplicitLocalizationMatch,
possibleKeyPhrases: possibleKeyPhrases
)
return
}
else if explicitStringRange != nil {
// We didn't find any of the expected functions when looking ahead
// so we can assume it's an unlocalised string
let unlocalizedPhrase = Phrase(
term: match,
providedTokens: [],
filePath: path,
lineNumber: lineNumber + 1 // Files are 1-indexed so add 1 to lineNumber
)
unlocalizedKeyPhrase[match] = unlocalizedPhrase
unlocalizedPhrases.append(unlocalizedPhrase)
}
}
}
case .some(let state):
let trimmedLine: String = targetLine.trimmingCharacters(in: CharacterSet(charactersIn: ","))
let localizedMatch = trimmedLine.firstMatch(of: Regex.localizedFunctionCall)
let lineEndsInLocalized: Bool? = localizedMatch.map { match in
let matchString: String = String(trimmedLine[match.range])
// Need to make sure the parentheses are balanced as `localized())` would be
// considered a valid match when we don't want it to be for the purposes of this
return (
match.range.upperBound == trimmedLine.endIndex &&
matchString.count(where: { $0 == "(" }) == matchString.count(where: { $0 == ")" })
)
}
switch (localizedMatch, lineEndsInLocalized, targetLine.firstMatch(of: Regex.localizedParameter)) {
// If the string contains only a `localized` call, or contains both a `localized`
// call and also a `.put(Number)` but ends with the `localized` call then assume
// we finishing the localized string (as opposed to localizing the value for a
// token to be included in the string)
case (.some, true, _), (.some, false, .none):
// We finished the change so add as a localized phrase(s)
let keys: [String] = (state.isExplicitLocalizationMatch && !state.possibleKeyPhrases.isEmpty ?
state.possibleKeyPhrases.map { $0.key } :
[state.key]
)
keys.forEach { key in
let phrase = Phrase(
term: key,
providedTokens: Set(state.chainedCalls
.compactMap { callLine -> String? in
guard
let tokenName = callLine
.firstMatch(of: Regex.localizedParameterToken)?
.output
.token
else { return nil }
return String(tokenName)
}),
filePath: path,
lineNumber: state.lineNumber + 1 // Files are 1-indexed so add 1 to lineNumber
)
keyPhrase[key] = phrase
phrases.append(phrase)
}
templateState = nil
// If it was an explicit LocalizationHelper template (provided with a variable)
// then we want to remove those values from the unlocalized strings
if state.isExplicitLocalizationMatch && !state.possibleKeyPhrases.isEmpty {
state.possibleKeyPhrases.forEach { phrase in
unlocalizedKeyPhrase.removeValue(forKey: phrase.key)
}
unlocalizedPhrases = unlocalizedPhrases.filter {
!state.possibleKeyPhrases.contains($0)
}
}
default:
// The chain is still going to append the line
templateState?.chainedCalls.append(targetLine)
}
}
}
private static func extractMatches(from line: String, with regex: some RegexComponent) -> [String] {
return line.matches(of: regex).map { match in
// Clean up the matches
return String(line[match.range])
.removingPrefixIfPresent("NSLocalizedString(@\"")
.removingPrefixIfPresent("NSLocalizedString(\"")
.removingPrefixIfPresent("\"")
.removingSuffixIfPresent("\".localized")
.removingSuffixIfPresent("\")")
.removingSuffixIfPresent("\"")
}
}
private static func createPhrases(
from matches: Set<String>,
isUnlocalized: Bool,
lineNumber: Int,
path: String,
keyPhrase: inout [String: Phrase],
unlocalizedKeyPhrase: inout [String: Phrase],
phrases: inout [Phrase],
unlocalizedPhrases: inout [Phrase]
) {
matches.forEach { match in
let result = Phrase(
term: match,
providedTokens: [],
filePath: path,
lineNumber: lineNumber + 1
)
if !isUnlocalized {
keyPhrase[match] = result
phrases.append(result)
} else {
unlocalizedKeyPhrase[match] = result
unlocalizedPhrases.append(result)
}
}
}
}
}
indirect enum MatchType {
case and(MatchType, MatchType)
case prefix(String, caseSensitive: Bool)
case suffix(String, caseSensitive: Bool)
case contains(String, caseSensitive: Bool)
case regex(any RegexComponent)
case previousLine(numEarlier: Int, MatchType)
case nextLine(numLater: Int, MatchType)
case belowLineContaining(String)
static func previousLine(_ type: MatchType) -> MatchType { return .previousLine(numEarlier: 1, type) }
static func nextLine(_ type: MatchType) -> MatchType { return .nextLine(numLater: 1, type) }
func matches(_ value: String, _ index: Int, _ lines: [String]) -> Bool {
switch self {
case .and(let firstMatch, let secondMatch):
guard firstMatch.matches(value, index, lines) else { return false }
return secondMatch.matches(value, index, lines)
case .prefix(let prefix, false):
return value
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix(prefix.lowercased())
case .prefix(let prefix, true):
return value
.trimmingCharacters(in: .whitespacesAndNewlines)
.hasPrefix(prefix)
case .suffix(let suffix, false):
return value
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
.hasSuffix(suffix.lowercased())
case .suffix(let suffix, true):
return value
.trimmingCharacters(in: .whitespacesAndNewlines)
.hasSuffix(suffix)
case .contains(let other, false): return value.lowercased().contains(other.lowercased())
case .contains(let other, true): return value.contains(other)
case .regex(let regex): return !Regex.matches(regex, content: value).isEmpty
case .previousLine(let numEarlier, let type):
guard index >= numEarlier else { return false }
let targetIndex: Int = (index - numEarlier)
return type.matches(lines[targetIndex], targetIndex, lines)
case .nextLine(let numLater, let type):
guard index + numLater < lines.count else { return false }
let targetIndex: Int = (index + numLater)
return type.matches(lines[targetIndex], targetIndex, lines)
case .belowLineContaining(let other):
return lines[0..<index].contains(where: { $0.lowercased().contains(other.lowercased()) })
}
}
}
extension String {
func removingPrefixIfPresent(_ value: String) -> String {
guard hasPrefix(value) else { return self }
return String(self.suffix(from: self.index(self.startIndex, offsetBy: value.count)))
}
func removingSuffixIfPresent(_ value: String) -> String {
guard hasSuffix(value) else { return self }
return String(self.prefix(upTo: self.index(self.endIndex, offsetBy: -value.count)))
}
func removingUnwantedScalars() -> String {
let unwantedScalars: Set<Unicode.Scalar> = [
"\u{200B}", // ZERO WIDTH SPACE
"\u{200C}", // ZERO WIDTH NON-JOINER
"\u{200D}", // ZERO WIDTH JOINER
"\u{FEFF}" // ZERO WIDTH NO-BREAK SPACE
]
return String(self.unicodeScalars.filter { !unwantedScalars.contains($0) }.map { Character($0) })
}
}