Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Examples/strings-base.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@
<string name="translation_has_invalid_specifier">%s %d</string>
<string name="translation_has_missing_arg">%s %d</string>

<!-- Format specifier flags -->
<string name="points_with_comma">You have %,d points</string>
<string name="change_with_sign">Change: %+d</string>
<string name="code_zero_padded">Code: %05d</string>
<string name="price_with_precision">Price: %.2f</string>
<string name="value_with_width_precision">Value: %10.2f</string>
<string name="combined_flags">Total: %+,d</string>
<string name="translation_missing_comma_flag">Points: %,d</string>
<string name="translation_wrong_flag">Amount: %+d</string>
<string name="translation_wrong_minus_flag">Amount: %+d</string>

<!-- Width and precision test cases -->
<string name="width_only">Value: %10d</string>
<string name="precision_only">Price: %.4f</string>
<string name="width_and_precision">Amount: %8.2f</string>
<string name="width_with_flag">Total: %+10d</string>
<string name="translation_wrong_width">Count: %10d</string>
<string name="translation_wrong_precision">Rate: %.2f</string>
<string name="translation_wrong_width_precision">Score: %8.3f</string>

<!-- Non-translatable (missing from translation, will not show warning) -->
<string name="photos_icon_content_description" translatable="false">Photos</string>

Expand Down
23 changes: 23 additions & 0 deletions Examples/strings-translation.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,29 @@
<string name="translation_has_invalid_specifier">%s %lu</string>
<string name="translation_has_missing_arg">%s</string>

<!-- Format specifier flags -->
<string name="points_with_comma">Tienes %,d puntos</string>
<string name="change_with_sign">Cambio: %+d</string>
<string name="code_zero_padded">Código: %05d</string>
<string name="price_with_precision">Precio: %.2f</string>
<string name="value_with_width_precision">Valor: %10.2f</string>
<string name="combined_flags">Total: %+,d</string>
<string name="translation_missing_comma_flag">Puntos: %d</string>
<string name="translation_wrong_flag">Cantidad: % d</string>
<string name="translation_wrong_minus_flag">Cantidad: %-d</string>

<!-- Width and precision test cases (correct translations) -->
<string name="width_only">Valor: %10d</string>
<string name="precision_only">Precio: %.4f</string>
<string name="width_and_precision">Cantidad: %8.2f</string>
<string name="width_with_flag">Total: %+10d</string>
<!-- Incorrect: wrong width (5 instead of 10) -->
<string name="translation_wrong_width">Cuenta: %5d</string>
<!-- Incorrect: wrong precision (.4 instead of .2) -->
<string name="translation_wrong_precision">Tasa: %.4f</string>
<!-- Incorrect: wrong width and precision (10.2 instead of 8.3) -->
<string name="translation_wrong_width_precision">Puntuación: %10.2f</string>

<!-- Missing from base -->
<string name="missing_from_base">Missing from base</string>

Expand Down
2 changes: 1 addition & 1 deletion Sources/LocheckCommand/FileOrDirectoryArg.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// FileArg.swift
// FileOrDirectoryArg.swift
//
//
// Created by Steve Landey on 8/18/21.
Expand Down
2 changes: 1 addition & 1 deletion Sources/LocheckCommand/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ struct StringCatalog: HasIgnoreWithShorthand, ParsableCommand {
ignore: ignoreWithShorthand,
ignoreWarnings: ignoreWarnings,
treatWarningsAsErrors: treatWarningsAsErrors) { problemReporter in
let catalogFile = try! File(path: self.catalogFile.argument)
let catalogFile = try! File(path: catalogFile.argument)
parseAndValidateStringCatalog(
stringCatalogFile: catalogFile,
problemReporter: problemReporter)
Expand Down
76 changes: 69 additions & 7 deletions Sources/LocheckLogic/Expressions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct Expressions {
pattern: Expressions.stringPairExpression,
options: .anchorsMatchLines)

// MARK: Arguments
// MARK: - Shared

// https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFStrings/formatSpecifiers.html#//apple_ref/doc/uid/TP40004265
private static let lengthModifiers: [String] = [
Expand All @@ -42,8 +42,10 @@ struct Expressions {
]
private static let lengthExpression = lengthModifiers.joined(separator: "|")

// MARK: - iOS Format Specifiers

// https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFStrings/formatSpecifiers.html#//apple_ref/doc/uid/TP40004265
private static let specifiers: [String] = [
private static let iosSpecifiers: [String] = [
// omit %%, it doesn't affect interpolation
"@",
"d",
Expand All @@ -68,16 +70,76 @@ struct Expressions {
"A",
"F",
]
private static let specifierExpression = specifiers.joined(separator: "|")
private static let iosSpecifierExpression = iosSpecifiers.joined(separator: "|")

// Technically length modifiers are invalid for @ and potentially some others, but in practice
// it probably doesn't matter.
private static let nativeArgumentExpression =
"%((?<position>\\d+)\\$)?(?<specifier>(\(lengthExpression))?(\(specifierExpression)))"
static let nativeArgumentRegex = try! NSRegularExpression(
pattern: Expressions.nativeArgumentExpression,
private static let iosArgumentExpression =

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core changes are here to keep iOS backwards compatible and properly handle android separately going forward

"%((?<position>\\d+)\\$)?(?<specifier>(\(lengthExpression))?(\(iosSpecifierExpression)))"
static let iosArgumentRegex = try! NSRegularExpression(
pattern: Expressions.iosArgumentExpression,
options: [])

// MARK: - Android Format Specifiers

// https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Formatter.html
private static let androidSpecifiers: [String] = [
// General
"b", // boolean
"B", // boolean (uppercase)
"h", // hash code (hexadecimal)
"H", // hash code (uppercase)
"s", // string
"S", // string (uppercase)

// Character
"c", // character
"C", // character (uppercase)

// Integral
"d", // decimal integer
"o", // octal integer
"x", // hexadecimal integer
"X", // hexadecimal integer (uppercase)

// Floating Point
"e", // scientific notation
"E", // scientific notation (uppercase)
"f", // decimal floating point
"g", // general (uses e or f)
"G", // general (uppercase)
"a", // hexadecimal floating point
"A", // hexadecimal floating point (uppercase)

// Date/Time (prefix, followed by date/time conversion suffix)
"t", // date/time
"T", // date/time (uppercase)
]
private static let androidSpecifierExpression = androidSpecifiers.joined(separator: "|")

// Android/Java Formatter flags: '-', '+', '0', ',', '(', '#'
// https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Formatter.html
// Flags: '-' (left-justify), '+' (include sign), '0' (zero-pad),
// ',' (grouping separator), '(' (negative in parens), '#' (alternate form)
// Note: Space flag (' ') is intentionally omitted - it's rarely used in localized
// strings and causes false positives with patterns like "80% off" or "%% done"
private static let androidFlagsExpression = "[-+0,(#]*"
private static let androidWidthExpression = "\\d*"
private static let androidPrecisionExpression = "(?:\\.\\d+)?"

// Full format: %[argument_index$][flags][width][.precision]conversion
private static let androidArgumentExpression =
"%((?<position>\\d+)\\$)?(?<specifier>\(androidFlagsExpression)\(androidWidthExpression)\(androidPrecisionExpression)(\(androidSpecifierExpression)))"
static let androidArgumentRegex = try! NSRegularExpression(
pattern: Expressions.androidArgumentExpression,
options: [])

// MARK: - Legacy (backward compatibility)

static let nativeArgumentRegex = iosArgumentRegex

// MARK: - Other

private static let stringsdictArgumentExpression = #"%[0-9]*\$?#@(?<name>.+?)@"#
static let stringsdictArgumentRegex = try! NSRegularExpression(
pattern: Expressions.stringsdictArgumentExpression,
Expand Down
2 changes: 1 addition & 1 deletion Sources/LocheckLogic/Extensions/Sequence+locheck.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Sequence.swift
// Sequence+locheck.swift
//
//
// Created by Steve Landey on 8/27/21.
Expand Down
18 changes: 15 additions & 3 deletions Sources/LocheckLogic/Types/AndroidStringsFile.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,20 @@ public extension AndroidStringsFile {
strings.append(
AndroidString(
key: key,
value: FormatString(string: string, path: path, line: element.lineNumberStart)))
value: FormatString(
string: string,
path: path,
line: element.lineNumberStart,
platform: .android)))
} else {
strings.append(
AndroidString(
key: key,
value: FormatString(string: element.text ?? "", path: path, line: element.lineNumberStart)))
value: FormatString(
string: element.text ?? "",
path: path,
line: element.lineNumberStart,
platform: .android)))
}
case "string-array":
var values = [String]()
Expand Down Expand Up @@ -143,7 +151,11 @@ public extension AndroidStringsFile {
lineNumber: element.lineNumberStart)
continue
}
values[childKey] = FormatString(string: child.text ?? "", path: path, line: element.lineNumberStart)
values[childKey] = FormatString(
string: child.text ?? "",
path: path,
line: element.lineNumberStart,
platform: .android)
}
plurals.append(AndroidPlural(key: key, line: element.lineNumberStart, values: values))
default:
Expand Down
25 changes: 20 additions & 5 deletions Sources/LocheckLogic/Types/FormatString.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,34 @@ import Foundation

/**
Represents a string containing format specifiers. This type is shared by the iOS
and Android validators because they use the same syntax for these kinds of strings.
and Android validators, but uses platform-specific parsing for format specifiers.
*/
struct FormatString: Equatable {
enum Kind: Equatable {
case native // Xcode, Android 1st party
case phrase // https://github.com/square/phrase
}

enum Platform: Equatable {
case ios
case android
}

let string: String
let arguments: [FormatArgument]
let phraseArguments: [String]
let path: String
let line: Int
let kind: Kind
let platform: Platform

init(string: String, path: String, line: Int) {
init(string: String, path: String, line: Int, platform: Platform = .ios) {
self.string = string
self.path = path
self.line = line
self.platform = platform

let nativeArguments = parseNativeArguments(string: string)
let nativeArguments = parseNativeArguments(string: string, platform: platform)
arguments = nativeArguments
if nativeArguments.isEmpty {
// Only use Phrase format if native syntax is not present
Expand All @@ -43,10 +50,18 @@ struct FormatString: Equatable {
}

/// Transform a single string into parsed `FormatSpecifier` objects
private func parseNativeArguments(string: String) -> [FormatArgument] {
private func parseNativeArguments(string: String, platform: FormatString.Platform) -> [FormatArgument] {
var nextImplicitPosition = 1

return Expressions.nativeArgumentRegex
let regex: NSRegularExpression
switch platform {
case .ios:
regex = Expressions.iosArgumentRegex
case .android:
regex = Expressions.androidArgumentRegex
}

return regex
.lo_matches(in: string)
.enumerated()
.compactMap { (i: Int, match: NSTextCheckingResult) -> FormatArgument? in
Expand Down
9 changes: 4 additions & 5 deletions Sources/LocheckLogic/Types/LocalizedStringPair.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,13 @@ extension LocalizedStringPair {
baseString: String,
translationString: String,
path: String,
line: Int
) {
line: Int) {
self.key = key
self.string = "\"\(key)\" = \"\(translationString)\";"
string = "\"\(key)\" = \"\(translationString)\";"
self.path = path
self.line = line
self.base = FormatString(string: baseString, path: path, line: line)
self.translation = FormatString(string: translationString, path: path, line: line)
base = FormatString(string: baseString, path: path, line: line)
translation = FormatString(string: translationString, path: path, line: line)
}

init?(
Expand Down
30 changes: 13 additions & 17 deletions Sources/LocheckLogic/Types/StringCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,68 +89,64 @@ extension StringEntry {
func getLocalizedStringPairs(
key: String,
basePath: String,
sourceLanguage: String
) -> [LocalizedStringPair] {
sourceLanguage: String) -> [LocalizedStringPair] {
var pairs: [LocalizedStringPair] = []

guard let sourceLocalization = localizations[sourceLanguage] else {
return pairs
}

let sourceString = sourceLocalization.stringUnit?.value ?? key

for (language, localization) in localizations {
if language == sourceLanguage {
continue // Skip source language for validation
}

if let stringUnit = localization.stringUnit {
let pair = LocalizedStringPair(
key: key,
baseString: sourceString,
translationString: stringUnit.value,
path: basePath,
line: 1
)
line: 1)
pairs.append(pair)
}

if let variations = localization.variations {
// Handle plural variations
if let pluralForms = variations.plural {
// Get source plural form for comparison (use "other" as fallback)
let sourcePlurals = sourceLocalization.variations?.plural
let fallbackSource = sourcePlurals?["other"]?.stringUnit.value ?? sourceString

for (pluralForm, pluralVariation) in pluralForms {
let sourceForPluralForm = sourcePlurals?[pluralForm]?.stringUnit.value ?? fallbackSource
let pair = LocalizedStringPair(
key: "\(key) (\(pluralForm))",
baseString: sourceForPluralForm,
translationString: pluralVariation.stringUnit.value,
path: basePath,
line: 1
)
line: 1)
pairs.append(pair)
}
}
// Handle device variations

// Handle device variations
if let deviceForms = variations.device {
for (deviceType, deviceVariation) in deviceForms {
let pair = LocalizedStringPair(
key: "\(key) (\(deviceType))",
baseString: sourceString,
translationString: deviceVariation.stringUnit.value,
path: basePath,
line: 1
)
line: 1)
pairs.append(pair)
}
}
}
}

return pairs
}
}
Loading