Fixed some string issues

• Updated the string linter logic to error when incorrect parameter names are given (or missed)
• Updated the code based on the latest string changes
• Fixed an issue where the string linter wasn't working
pull/894/head
Morgan Pretty 2 months ago
parent d1fea5d7c2
commit ad821dcbf1

@ -78,6 +78,7 @@ extension ProjectState {
.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),
@ -129,13 +130,13 @@ extension ProjectState {
),
.belowLineContaining("PreviewProvider"),
.belowLineContaining("#Preview"),
.belowLineContaining(": Migration {"),
.regex(Regex.logging),
.regex(Regex.errorCreation),
.regex(Regex.databaseTableName),
.regex(Regex.enumCaseDefinition),
.regex(Regex.imageInitialization),
.regex(Regex.variableToStringConversion),
.regex(Regex.localizedParameter)
.regex(Regex.variableToStringConversion)
]
}
@ -208,6 +209,7 @@ enum ScriptAction: String {
}
var allKeys: [String] = []
var tokensForKey: [String: Set<String>] = [:]
var duplicates: [String] = []
projectState.localizationFile.strings.forEach { key, value in
if allKeys.contains(key) {
@ -219,21 +221,32 @@ enum ScriptAction: String {
// 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 numberOfVarablesOrignal: Int = original
.removingUnwantedScalars()
.matches(of: Regex.dynamicStringVariable)
.count
let numberOfVarablesPhrase: Int = phrase
let numberOfTokensPhrase: Int = phrase
.removingUnwantedScalars()
.matches(of: Regex.dynamicStringVariable)
.count
if numberOfVarablesPhrase != numberOfVarablesOrignal {
Output.warning("\(key) in \(locale) may be faulty ('\(original)' contains \(numberOfVarablesOrignal) vs. '\(phrase)' contains \(numberOfVarablesPhrase))")
if numberOfTokensPhrase != numberOfTokensOrignal {
Output.warning("\(key) in \(locale) may be faulty ('\(original)' contains \(numberOfTokensOrignal) vs. '\(phrase)' contains \(numberOfTokensPhrase))")
}
}
}
@ -265,10 +278,39 @@ enum ScriptAction: String {
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
@ -317,15 +359,17 @@ enum Regex {
static let comment = #/\/\/[^"]*(?:"[^"]*"[^"]*)*/#
static let allStrings = #/"[^"\\]*(?:\\.[^"\\]*)*"/#
static let localizedString = #/^(?:\.put(?:Number)?\([^)]+\))*\.localized/#
static let localizedFunctionCall = #/\.localized(?:Formatted)?\(.*\)/#
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 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.*\(/#
@ -499,10 +543,13 @@ extension ProjectState {
var key: String
var lineNumber: Int
var chainedCalls: [String]
let isExplicitLocalizationMatch: Bool
let possibleKeyPhrases: [Phrase]
}
struct Phrase: KeyedLocatable {
struct Phrase: KeyedLocatable, Equatable {
let term: String
let providedTokens: Set<String>
let filePath: String
let lineNumber: Int
@ -567,8 +614,13 @@ extension ProjectState {
// Skip linting if disabled
guard !shouldSkipLinting(state: lintState) else { return }
// Skip lines without quotes (optimization)
guard trimmedLine.contains("\"") 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
@ -653,14 +705,22 @@ extension ProjectState {
switch templateState {
case .none:
// Extract the strings and remove any excluded phrases
let keyMatches: [String] = extractMatches(from: targetLine, with: Regex.allStrings)
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 = targetLine.range(of: "\"\(match)\"") {
if let range = explicitStringRange {
// Check if .localized or a template func is called immediately following
// this specific string
let afterString = targetLine[range.upperBound...]
@ -670,6 +730,9 @@ extension ProjectState {
// 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
)
@ -682,14 +745,18 @@ extension ProjectState {
// 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
}
else {
// Look ahead to verify if put/putNumber/localized are called in the next lines
}
// 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
@ -711,18 +778,41 @@ extension ProjectState {
}
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: []
chainedCalls: [],
isExplicitLocalizationMatch: isExplicitLocalizationMatch,
possibleKeyPhrases: possibleKeyPhrases
)
return
}
else {
else if explicitStringRange != nil {
if targetLine.contains("LocalizationHelper") {
Output.error("RAWR not a template chain")
}
// 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
)
@ -731,23 +821,68 @@ extension ProjectState {
}
}
}
}
}
case .some(let state):
switch targetLine.firstMatch(of: Regex.localizedFunctionCall) {
case .some:
// We finished the change so add as a localized phrase
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: state.key,
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[state.key] = phrase
keyPhrase[key] = phrase
phrases.append(phrase)
}
templateState = nil
case .none:
// 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)
}
@ -780,6 +915,7 @@ extension ProjectState {
matches.forEach { match in
let result = Phrase(
term: match,
providedTokens: [],
filePath: path,
lineNumber: lineNumber + 1
)

@ -885,6 +885,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// URL Scheme is sessionmessenger://DM?sessionID=1234
// We can later add more parameters like message etc.
// stringlint:ignore_contents
if components.host == "DM" {
let matches: [URLQueryItem] = (components.queryItems ?? [])
.filter { item in item.name == "sessionID" }

@ -619,6 +619,7 @@ extension Attachment {
if let sourceFilename: String = sourceFilename, !sourceFilename.isEmpty {
// Ensure that the filename is a valid filesystem name,
// replacing invalid characters with an underscore.
// stringlint:ignore_start
var normalizedFileName: String = sourceFilename
.trimmingCharacters(in: .whitespacesAndNewlines)
.components(separatedBy: .whitespacesAndNewlines)
@ -629,6 +630,7 @@ extension Attachment {
.joined(separator: "_")
.components(separatedBy: CharacterSet(charactersIn: "<>|\\:()&;?*/~"))
.joined(separator: "_")
// stringlint:ignore_stop
while normalizedFileName.hasPrefix(".") { // stringlint:ignore
normalizedFileName = String(normalizedFileName.substring(from: 1))

@ -534,7 +534,7 @@ public extension ClosedGroup {
case .addedUsers(true, let names, true) where names.count == 2:
return "groupMemberNewYouHistoryTwo"
.put(key: "name", value: names[1]) // The current user will always be the first name
.put(key: "other_name", value: names[1]) // The current user will always be the first name
.localized()
case .addedUsers(false, let names, false):
@ -605,7 +605,7 @@ public extension ClosedGroup {
case .promotedUsers(true, let names) where names.count == 2:
return "groupPromotedYouTwo"
.put(key: "name", value: names[1]) // The current user will always be the first name
.put(key: "other_name", value: names[1]) // The current user will always be the first name
.localized()
case .promotedUsers(false, let names):

@ -155,47 +155,33 @@ public enum GroupInviteMemberJob: JobExecutor {
}
public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> NSAttributedString {
let memberZeroName: String = memberIds.first.map {
profileInfo[$0]?.displayName(for: .group) ??
Profile.truncated(id: $0, truncating: .middle)
}.defaulting(to: "anonymous".localized())
switch memberIds.count {
case 1:
return "groupInviteFailedUser"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(key: "name", value: memberZeroName)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)
case 2:
return "groupInviteFailedTwo"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(
key: "other_name",
value: (
let memberOneName: String = (
profileInfo[memberIds[1]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[1], truncating: .middle)
)
)
return "groupInviteFailedTwo"
.put(key: "name", value: memberZeroName)
.put(key: "other_name", value: memberOneName)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)
default:
return "groupInviteFailedMultiple"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(key: "name", value: memberZeroName)
.put(key: "count", value: memberIds.count - 1)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)

@ -151,47 +151,33 @@ public enum GroupPromoteMemberJob: JobExecutor {
}
public static func failureMessage(groupName: String, memberIds: [String], profileInfo: [String: Profile]) -> NSAttributedString {
let memberZeroName: String = memberIds.first.map {
profileInfo[$0]?.displayName(for: .group) ??
Profile.truncated(id: $0, truncating: .middle)
}.defaulting(to: "anonymous".localized())
switch memberIds.count {
case 1:
return "adminPromotionFailedDescription"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(key: "name", value: memberZeroName)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)
case 2:
return "adminPromotionFailedDescriptionTwo"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(
key: "other_name",
value: (
let memberOneName: String = (
profileInfo[memberIds[1]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[1], truncating: .middle)
)
)
return "adminPromotionFailedDescriptionTwo"
.put(key: "name", value: memberZeroName)
.put(key: "other_name", value: memberOneName)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)
default:
return "adminPromotionFailedDescriptionMultiple"
.put(
key: "name",
value: (
profileInfo[memberIds[0]]?.displayName(for: .group) ??
Profile.truncated(id: memberIds[0], truncating: .middle)
)
)
.put(key: "name", value: memberZeroName)
.put(key: "count", value: memberIds.count - 1)
.put(key: "group_name", value: groupName)
.localizedFormatted(baseFont: ToastController.font)

@ -1,4 +1,6 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import SessionUtilitiesKit

@ -21,7 +21,7 @@ public class TypingIndicators {
private let dependencies: Dependencies
@ThreadSafeObject private var timerQueue: DispatchQueue = DispatchQueue(
label: "org.getsession.typingIndicatorQueue",
label: "org.getsession.typingIndicatorQueue", // stringlint:ignore
qos: .userInteractive
)
@ThreadSafeObject private var outgoing: [String: Indicator] = [:]

@ -326,7 +326,7 @@ private extension String {
var matchEnd = m1.range.location + m1.range.length
if let displayName: String = Profile.displayNameNoFallback(id: publicKey, using: dependencies) {
result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)")
result = (result as NSString).replacingCharacters(in: m1.range, with: "@\(displayName)") // stringlint:ignore
mentions.append((range: NSRange(location: m1.range.location, length: displayName.utf16.count + 1), publicKey: publicKey)) // + 1 to include the @
matchEnd = m1.range.location + displayName.utf16.count
}

@ -1,4 +1,6 @@
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
//
// stringlint:disable
import Foundation
import GRDB

Loading…
Cancel
Save