diff --git a/Examples/strings-base.xml b/Examples/strings-base.xml index b94de9a..747c302 100644 --- a/Examples/strings-base.xml +++ b/Examples/strings-base.xml @@ -21,6 +21,26 @@ %s %d %s %d + + You have %,d points + Change: %+d + Code: %05d + Price: %.2f + Value: %10.2f + Total: %+,d + Points: %,d + Amount: %+d + Amount: %+d + + + Value: %10d + Price: %.4f + Amount: %8.2f + Total: %+10d + Count: %10d + Rate: %.2f + Score: %8.3f + Photos diff --git a/Examples/strings-translation.xml b/Examples/strings-translation.xml index 89f5f6b..73d8183 100644 --- a/Examples/strings-translation.xml +++ b/Examples/strings-translation.xml @@ -21,6 +21,29 @@ %s %lu %s + + Tienes %,d puntos + Cambio: %+d + Código: %05d + Precio: %.2f + Valor: %10.2f + Total: %+,d + Puntos: %d + Cantidad: % d + Cantidad: %-d + + + Valor: %10d + Precio: %.4f + Cantidad: %8.2f + Total: %+10d + + Cuenta: %5d + + Tasa: %.4f + + Puntuación: %10.2f + Missing from base diff --git a/Sources/LocheckCommand/FileOrDirectoryArg.swift b/Sources/LocheckCommand/FileOrDirectoryArg.swift index 7a3574f..c98cd56 100644 --- a/Sources/LocheckCommand/FileOrDirectoryArg.swift +++ b/Sources/LocheckCommand/FileOrDirectoryArg.swift @@ -1,5 +1,5 @@ // -// FileArg.swift +// FileOrDirectoryArg.swift // // // Created by Steve Landey on 8/18/21. diff --git a/Sources/LocheckCommand/main.swift b/Sources/LocheckCommand/main.swift index 7fef20d..7f04a95 100644 --- a/Sources/LocheckCommand/main.swift +++ b/Sources/LocheckCommand/main.swift @@ -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) diff --git a/Sources/LocheckLogic/Expressions.swift b/Sources/LocheckLogic/Expressions.swift index f3845ec..7bfa7c9 100644 --- a/Sources/LocheckLogic/Expressions.swift +++ b/Sources/LocheckLogic/Expressions.swift @@ -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] = [ @@ -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", @@ -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 = - "%((?\\d+)\\$)?(?(\(lengthExpression))?(\(specifierExpression)))" - static let nativeArgumentRegex = try! NSRegularExpression( - pattern: Expressions.nativeArgumentExpression, + private static let iosArgumentExpression = + "%((?\\d+)\\$)?(?(\(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 = + "%((?\\d+)\\$)?(?\(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]*\$?#@(?.+?)@"# static let stringsdictArgumentRegex = try! NSRegularExpression( pattern: Expressions.stringsdictArgumentExpression, diff --git a/Sources/LocheckLogic/Extensions/Sequence+locheck.swift b/Sources/LocheckLogic/Extensions/Sequence+locheck.swift index 8f6e7cf..9dee964 100644 --- a/Sources/LocheckLogic/Extensions/Sequence+locheck.swift +++ b/Sources/LocheckLogic/Extensions/Sequence+locheck.swift @@ -1,5 +1,5 @@ // -// Sequence.swift +// Sequence+locheck.swift // // // Created by Steve Landey on 8/27/21. diff --git a/Sources/LocheckLogic/Types/AndroidStringsFile.swift b/Sources/LocheckLogic/Types/AndroidStringsFile.swift index 9f59ced..334e278 100644 --- a/Sources/LocheckLogic/Types/AndroidStringsFile.swift +++ b/Sources/LocheckLogic/Types/AndroidStringsFile.swift @@ -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]() @@ -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: diff --git a/Sources/LocheckLogic/Types/FormatString.swift b/Sources/LocheckLogic/Types/FormatString.swift index 55c3ddd..fde654d 100644 --- a/Sources/LocheckLogic/Types/FormatString.swift +++ b/Sources/LocheckLogic/Types/FormatString.swift @@ -9,7 +9,7 @@ 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 { @@ -17,19 +17,26 @@ struct FormatString: Equatable { 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 @@ -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 diff --git a/Sources/LocheckLogic/Types/LocalizedStringPair.swift b/Sources/LocheckLogic/Types/LocalizedStringPair.swift index 86ad6ca..59d0b4f 100644 --- a/Sources/LocheckLogic/Types/LocalizedStringPair.swift +++ b/Sources/LocheckLogic/Types/LocalizedStringPair.swift @@ -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?( diff --git a/Sources/LocheckLogic/Types/StringCatalog.swift b/Sources/LocheckLogic/Types/StringCatalog.swift index 603137f..9f6a523 100644 --- a/Sources/LocheckLogic/Types/StringCatalog.swift +++ b/Sources/LocheckLogic/Types/StringCatalog.swift @@ -89,39 +89,37 @@ 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( @@ -129,13 +127,12 @@ extension StringEntry { 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( @@ -143,14 +140,13 @@ extension StringEntry { baseString: sourceString, translationString: deviceVariation.stringUnit.value, path: basePath, - line: 1 - ) + line: 1) pairs.append(pair) } } } } - + return pairs } } diff --git a/Sources/LocheckLogic/Types/StringsdictEntry.swift b/Sources/LocheckLogic/Types/StringsdictEntry.swift index 6efb5b8..6b461a8 100644 --- a/Sources/LocheckLogic/Types/StringsdictEntry.swift +++ b/Sources/LocheckLogic/Types/StringsdictEntry.swift @@ -18,7 +18,7 @@ public struct StringsdictEntry: Equatable { let rules: [String: StringsdictRule] // derived from XML func validateRuleVariables(problemReporter: ProblemReporter) { - let checkRule = { (ruleKey: String, variables: [String]) -> Void in + let checkRule = { (ruleKey: String, variables: [String]) in for variable in variables where rules[variable] == nil { problemReporter.report( StringsdictEntryHasMissingVariable( @@ -57,7 +57,7 @@ public struct StringsdictEntry: Equatable { // but that would require us to remember which span of each string maps back to which variable, // which is a lot of extra bookkeeping to do at this stage of the project. - let report = { (problem: Problem, line: Int) -> Void in + let report = { (problem: Problem, line: Int) in problemReporter.report(problem, path: path, lineNumber: line) } @@ -249,7 +249,7 @@ extension StringsdictEntry { line = node.lineNumberStart self.path = path - let report = { (problem: Problem, line: Int) -> Void in + let report = { (problem: Problem, line: Int) in problemReporter.report(problem, path: path, lineNumber: line) } diff --git a/Sources/LocheckLogic/Types/StringsdictFile.swift b/Sources/LocheckLogic/Types/StringsdictFile.swift index a52ae17..20f32ec 100644 --- a/Sources/LocheckLogic/Types/StringsdictFile.swift +++ b/Sources/LocheckLogic/Types/StringsdictFile.swift @@ -1,5 +1,5 @@ // -// Stringsdict.swift +// StringsdictFile.swift // // // Created by Steve Landey on 8/25/21. diff --git a/Sources/LocheckLogic/Types/StringsdictRule.swift b/Sources/LocheckLogic/Types/StringsdictRule.swift index e6cb7a0..04abc7f 100644 --- a/Sources/LocheckLogic/Types/StringsdictRule.swift +++ b/Sources/LocheckLogic/Types/StringsdictRule.swift @@ -23,7 +23,7 @@ extension StringsdictRule { init?(key: String, node: XML.Element, path: String, problemReporter: ProblemReporter) { line = node.lineNumberStart - let report = { (problem: Problem) -> Void in + let report = { (problem: Problem) in problemReporter.report(problem, path: path, lineNumber: node.lineNumberStart) } diff --git a/Sources/LocheckLogic/Validators/parseAndValidateStringCatalog.swift b/Sources/LocheckLogic/Validators/parseAndValidateStringCatalog.swift index 5d8be4a..257f8bb 100644 --- a/Sources/LocheckLogic/Validators/parseAndValidateStringCatalog.swift +++ b/Sources/LocheckLogic/Validators/parseAndValidateStringCatalog.swift @@ -14,39 +14,36 @@ import Foundation public func parseAndValidateStringCatalog( stringCatalogFile: File, problemReporter: ProblemReporter) { - guard let stringCatalog = StringCatalog(path: stringCatalogFile.path, problemReporter: problemReporter) else { return } - + var allPairs: [LocalizedStringPair] = [] - + // Extract all localized string pairs from the catalog for (key, entry) in stringCatalog.strings { let pairs = entry.getLocalizedStringPairs( key: key, basePath: stringCatalogFile.path, - sourceLanguage: stringCatalog.sourceLanguage - ) + sourceLanguage: stringCatalog.sourceLanguage) allPairs.append(contentsOf: pairs) } - + // Group by language for validation let pairsByLanguage = Dictionary(grouping: allPairs) { pair in // Extract language from validation context - we need to infer this from the translation // For now, we'll validate all pairs together as they already contain base/translation comparison - return "all" + "all" } - + // Validate each language group for (_, pairs) in pairsByLanguage { validateStringCatalogPairs( pairs: pairs, sourceLanguage: stringCatalog.sourceLanguage, - problemReporter: problemReporter - ) + problemReporter: problemReporter) } - + // Additional string catalog specific validations validateStringCatalogStructure(stringCatalog: stringCatalog, problemReporter: problemReporter) } @@ -58,7 +55,6 @@ private func validateStringCatalogPairs( pairs: [LocalizedStringPair], sourceLanguage: String, problemReporter: ProblemReporter) { - for pair in pairs { // Check for format argument consistency let baseArgumentPositions = Set(pair.base.arguments.map(\.position)) @@ -134,9 +130,8 @@ private func validateStringCatalogPairs( private func validateStringCatalogStructure( stringCatalog: StringCatalog, problemReporter: ProblemReporter) { - let sourceLanguage = stringCatalog.sourceLanguage - + // Check that source language exists in all entries for (key, entry) in stringCatalog.strings { if entry.localizations[sourceLanguage] == nil { @@ -147,14 +142,14 @@ private func validateStringCatalogStructure( path: "StringCatalog", lineNumber: 1) } - + // Check for incomplete translations let sourceLocalization = entry.localizations[sourceLanguage] for (language, localization) in entry.localizations { if language == sourceLanguage { continue } - + // Check if translation is marked as needs work if let stringUnit = localization.stringUnit, stringUnit.state == "needs_work" { @@ -163,11 +158,10 @@ private func validateStringCatalogStructure( path: "StringCatalog", lineNumber: 1) } - + // Check plural consistency if let sourcePlurals = sourceLocalization?.variations?.plural, let translationPlurals = localization.variations?.plural { - // Ensure critical plural forms are present let requiredForms = ["one", "other"] for form in requiredForms { diff --git a/Sources/LocheckLogic/parseXML.swift b/Sources/LocheckLogic/parseXML.swift index 6cbb17a..f6008f9 100644 --- a/Sources/LocheckLogic/parseXML.swift +++ b/Sources/LocheckLogic/parseXML.swift @@ -11,7 +11,7 @@ import SwiftyXMLParser func parseXML(file: File, problemReporter: ProblemReporter) -> XML.Accessor? { do { - return XML.parse(try file.read()) + return try XML.parse(file.read()) } catch { problemReporter.report( XMLErrorProblem(message: error.localizedDescription), diff --git a/Tests/LocheckCommandTests/ExecutableTests.swift b/Tests/LocheckCommandTests/ExecutableTests.swift index 84fad90..c565552 100644 --- a/Tests/LocheckCommandTests/ExecutableTests.swift +++ b/Tests/LocheckCommandTests/ExecutableTests.swift @@ -276,7 +276,7 @@ class ExecutableTests: XCTestCase { string_array_wrong_item_count: WARNING: 'string_array_wrong_item_count' item count mismatch in Examples: 2 (should be 1) (string_array_item_count_mismatch) translation_has_invalid_specifier: - ERROR: Specifier for argument 2 does not match (should be d, is lu) (string_has_invalid_argument) + WARNING: 'translation_has_invalid_specifier' does not include argument(s) at 2 (string_has_missing_arguments) Base: %s %d Translation: %s %lu translation_has_missing_arg: @@ -287,22 +287,52 @@ class ExecutableTests: XCTestCase { ERROR: 'translation_has_missing_phrase' does not include argument(s): object_name (phrase_has_missing_arguments) Base: Could not add {user_name} to \\"{object_name}\\" Translation: Could not add {user_name} - 6 warnings, 4 errors + translation_missing_comma_flag: + ERROR: Specifier for argument 1 does not match (should be ,d, is d) (string_has_invalid_argument) + Base: Points: %,d + Translation: Puntos: %d + translation_wrong_flag: + WARNING: 'translation_wrong_flag' does not include argument(s) at 1 (string_has_missing_arguments) + Base: Amount: %+d + Translation: Cantidad: % d + translation_wrong_minus_flag: + ERROR: Specifier for argument 1 does not match (should be +d, is -d) (string_has_invalid_argument) + Base: Amount: %+d + Translation: Cantidad: %-d + translation_wrong_precision: + ERROR: Specifier for argument 1 does not match (should be .2f, is .4f) (string_has_invalid_argument) + Base: Rate: %.2f + Translation: Tasa: %.4f + translation_wrong_width: + ERROR: Specifier for argument 1 does not match (should be 10d, is 5d) (string_has_invalid_argument) + Base: Count: %10d + Translation: Cuenta: %5d + translation_wrong_width_precision: + ERROR: Specifier for argument 1 does not match (should be 8.3f, is 10.2f) (string_has_invalid_argument) + Base: Score: %8.3f + Translation: Puntuación: %10.2f + 8 warnings, 8 errors Errors found """) XCTAssertEqual(stderr!, """ - Examples/strings-base.xml:32: error: 'duplicate_entry' appears twice (duplicate_entries) - Examples/strings-translation.xml:29: error: 'duplicate_entry' appears twice (duplicate_entries) - Examples/strings-base.xml:28: warning: 'missing_from_translation' is missing from Examples (key_missing_from_translation) - Examples/strings-translation.xml:25: warning: 'missing_from_base' is missing from the base translation (key_missing_from_base) - Examples/strings-base.xml:40: warning: 'translation_missing_string_array' is missing from Examples (key_missing_from_translation) - Examples/strings-translation.xml:37: warning: 'base_missing_string_array' is missing from the base translation (key_missing_from_base) + Examples/strings-base.xml:52: error: 'duplicate_entry' appears twice (duplicate_entries) + Examples/strings-translation.xml:52: error: 'duplicate_entry' appears twice (duplicate_entries) + Examples/strings-base.xml:48: warning: 'missing_from_translation' is missing from Examples (key_missing_from_translation) + Examples/strings-translation.xml:48: warning: 'missing_from_base' is missing from the base translation (key_missing_from_base) + Examples/strings-base.xml:60: warning: 'translation_missing_string_array' is missing from Examples (key_missing_from_translation) + Examples/strings-translation.xml:60: warning: 'base_missing_string_array' is missing from the base translation (key_missing_from_base) Examples/strings-translation.xml:17: error: 'translation_has_missing_phrase' does not include argument(s): object_name (phrase_has_missing_arguments) - Examples/strings-translation.xml:21: error: Specifier for argument 2 does not match (should be d, is lu) (string_has_invalid_argument) + Examples/strings-translation.xml:21: warning: 'translation_has_invalid_specifier' does not include argument(s) at 2 (string_has_missing_arguments) Examples/strings-translation.xml:22: warning: 'translation_has_missing_arg' does not include argument(s) at 2 (string_has_missing_arguments) - Examples/strings-translation.xml:42: warning: 'string_array_wrong_item_count' item count mismatch in Examples: 2 (should be 1) (string_array_item_count_mismatch) + Examples/strings-translation.xml:31: error: Specifier for argument 1 does not match (should be ,d, is d) (string_has_invalid_argument) + Examples/strings-translation.xml:32: warning: 'translation_wrong_flag' does not include argument(s) at 1 (string_has_missing_arguments) + Examples/strings-translation.xml:33: error: Specifier for argument 1 does not match (should be +d, is -d) (string_has_invalid_argument) + Examples/strings-translation.xml:41: error: Specifier for argument 1 does not match (should be 10d, is 5d) (string_has_invalid_argument) + Examples/strings-translation.xml:43: error: Specifier for argument 1 does not match (should be .2f, is .4f) (string_has_invalid_argument) + Examples/strings-translation.xml:45: error: Specifier for argument 1 does not match (should be 8.3f, is 10.2f) (string_has_invalid_argument) + Examples/strings-translation.xml:65: warning: 'string_array_wrong_item_count' item count mismatch in Examples: 2 (should be 1) (string_array_item_count_mismatch) """) } diff --git a/Tests/LocheckCommandTests/StringCatalogCommandTests.swift b/Tests/LocheckCommandTests/StringCatalogCommandTests.swift index 3f6eb31..e029af1 100644 --- a/Tests/LocheckCommandTests/StringCatalogCommandTests.swift +++ b/Tests/LocheckCommandTests/StringCatalogCommandTests.swift @@ -5,24 +5,24 @@ // Created by Uladzislau Birukou on 9/22/25. // -import XCTest import Files import Foundation +import XCTest final class StringCatalogCommandTests: XCTestCase { private var tempFolder: Folder! - + override func setUp() { super.setUp() let uuid = UUID().uuidString tempFolder = try! Folder.home.createSubfolderIfNeeded(withName: "StringCatalogCommandTests_\(uuid)") } - + override func tearDown() { try? tempFolder.delete() super.tearDown() } - + func testStringCatalogCommandWithValidFile() throws { let validCatalog = """ { @@ -49,26 +49,26 @@ final class StringCatalogCommandTests: XCTestCase { "version" : "1.0" } """ - + let catalogFile = try tempFolder.createFile(named: "test.xcstrings", contents: Data(validCatalog.utf8)) - + // Test the command via executable let binary = productsDirectory.appendingPathComponent("locheck") let process = Process() process.executableURL = binary process.arguments = ["stringcatalog", catalogFile.path] - + let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe - + try process.run() process.waitUntilExit() - + // Valid file should exit with status 0 XCTAssertEqual(process.terminationStatus, 0) } - + func testStringCatalogCommandWithProblematicFile() throws { let problematicCatalog = """ { @@ -95,28 +95,30 @@ final class StringCatalogCommandTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "problematic.xcstrings", contents: Data(problematicCatalog.utf8)) - + + let catalogFile = try tempFolder.createFile( + named: "problematic.xcstrings", + contents: Data(problematicCatalog.utf8)) + // Test the command via executable let binary = productsDirectory.appendingPathComponent("locheck") let process = Process() process.executableURL = binary process.arguments = ["stringcatalog", catalogFile.path] - + let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe - + try process.run() process.waitUntilExit() - + // This file only has warnings, which don't cause non-zero exit by default // So we just check that the process completes successfully // In real usage, you would use --treat-warnings-as-errors to get non-zero exit XCTAssertEqual(process.terminationStatus, 0) } - + func testStringCatalogCommandWithErrorsFile() throws { let catalogWithErrors = """ { @@ -143,54 +145,54 @@ final class StringCatalogCommandTests: XCTestCase { "version" : "1.0" } """ - + let catalogFile = try tempFolder.createFile(named: "errors.xcstrings", contents: Data(catalogWithErrors.utf8)) - + // Test the command via executable let binary = productsDirectory.appendingPathComponent("locheck") let process = Process() process.executableURL = binary process.arguments = ["stringcatalog", catalogFile.path] - + let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe - + try process.run() process.waitUntilExit() - + // File with errors should exit with non-zero status XCTAssertNotEqual(process.terminationStatus, 0) } - + func testStringCatalogCommandHelp() throws { let binary = productsDirectory.appendingPathComponent("locheck") let process = Process() process.executableURL = binary process.arguments = ["stringcatalog", "--help"] - + let pipe = Pipe() process.standardOutput = pipe - + try process.run() process.waitUntilExit() - + let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" - + XCTAssertTrue(output.contains("Validate String Catalog (.xcstrings) files")) XCTAssertEqual(process.terminationStatus, 0) } - + /// Returns path to the built products directory. private var productsDirectory: URL { #if os(macOS) - for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { - return bundle.bundleURL.deletingLastPathComponent() - } - fatalError("couldn't find the products directory") + for bundle in Bundle.allBundles where bundle.bundlePath.hasSuffix(".xctest") { + return bundle.bundleURL.deletingLastPathComponent() + } + fatalError("couldn't find the products directory") #else - return Bundle.main.bundleURL + return Bundle.main.bundleURL #endif } } diff --git a/Tests/LocheckLogicTests/FormatStringTests.swift b/Tests/LocheckLogicTests/FormatStringTests.swift index 00341a1..308f315 100644 --- a/Tests/LocheckLogicTests/FormatStringTests.swift +++ b/Tests/LocheckLogicTests/FormatStringTests.swift @@ -19,4 +19,54 @@ class FormatStringTests: XCTestCase { FormatArgument(specifier: "@", position: 1, isPositionExplicit: false), ]) } + + // MARK: - Escaped percent (%%) tests for Android + + func testAndroidEscapedPercentOnly() { + // "%%" is an escaped percent sign, should have no format arguments + let formatString = FormatString(string: "100%% complete", path: "", line: 0, platform: .android) + XCTAssertEqual(formatString.arguments, []) + } + + func testAndroidEscapedPercentFollowedBySpace() { + // "%% back" should not match "% b" as a format specifier + let formatString = FormatString(string: "[ICON] %s %% back", path: "", line: 0, platform: .android) + XCTAssertEqual( + formatString.arguments, + [ + FormatArgument(specifier: "s", position: 1, isPositionExplicit: false), + ]) + } + + func testAndroidEscapedPercentThenFormatSpecifier() { + // "%%%s" should be "%%" (escaped percent) + "%s" (format specifier) + let formatString = FormatString(string: "100%%%s", path: "", line: 0, platform: .android) + XCTAssertEqual( + formatString.arguments, + [ + FormatArgument(specifier: "s", position: 1, isPositionExplicit: false), + ]) + } + + func testAndroidMultipleEscapedPercents() { + // "%%%%" should be two escaped percents, no format arguments + let formatString = FormatString(string: "100%%%% done", path: "", line: 0, platform: .android) + XCTAssertEqual(formatString.arguments, []) + } + + func testAndroidUnescapedPercentBecomesSpecifier() { + // If a translator mistakenly changes "%%" to "%" before a valid specifier character, + // it becomes a format specifier. This test documents that behavior. + // Base: "100%% discount" -> no arguments (escaped percent) + let base = FormatString(string: "100%% discount", path: "", line: 0, platform: .android) + XCTAssertEqual(base.arguments, []) + + // Translation with typo: "100%d discount" -> HAS an argument (unescaped, %d is a specifier) + let translationWithTypo = FormatString(string: "100%d discount", path: "", line: 0, platform: .android) + XCTAssertEqual( + translationWithTypo.arguments, + [ + FormatArgument(specifier: "d", position: 1, isPositionExplicit: false), + ]) + } } diff --git a/Tests/LocheckLogicTests/StringCatalogTests.swift b/Tests/LocheckLogicTests/StringCatalogTests.swift index aad6608..dc4b515 100644 --- a/Tests/LocheckLogicTests/StringCatalogTests.swift +++ b/Tests/LocheckLogicTests/StringCatalogTests.swift @@ -5,8 +5,8 @@ // Created by Uladzislau Birukou on 9/22/25. // -import XCTest @testable import LocheckLogic +import XCTest final class StringCatalogTests: XCTestCase { func testValidStringCatalog() { @@ -35,20 +35,20 @@ final class StringCatalogTests: XCTestCase { "version" : "1.0" } """ - + let data = json.data(using: .utf8)! let catalog = try! JSONDecoder().decode(StringCatalog.self, from: data) - + XCTAssertEqual(catalog.sourceLanguage, "en") XCTAssertEqual(catalog.version, "1.0") XCTAssertEqual(catalog.strings.count, 1) - + let helloWorldEntry = catalog.strings["hello_world"]! XCTAssertEqual(helloWorldEntry.localizations.count, 2) XCTAssertEqual(helloWorldEntry.localizations["en"]?.stringUnit?.value, "Hello, World!") XCTAssertEqual(helloWorldEntry.localizations["de"]?.stringUnit?.value, "Hallo, Welt!") } - + func testStringCatalogWithPluralForms() { let json = """ { @@ -99,14 +99,14 @@ final class StringCatalogTests: XCTestCase { "version" : "1.0" } """ - + let data = json.data(using: .utf8)! let catalog = try! JSONDecoder().decode(StringCatalog.self, from: data) - + let itemCountEntry = catalog.strings["item_count"]! let enPlurals = itemCountEntry.localizations["en"]?.variations?.plural let dePlurals = itemCountEntry.localizations["de"]?.variations?.plural - + XCTAssertNotNil(enPlurals) XCTAssertNotNil(dePlurals) XCTAssertEqual(enPlurals?["one"]?.stringUnit.value, "%d item") @@ -114,7 +114,7 @@ final class StringCatalogTests: XCTestCase { XCTAssertEqual(dePlurals?["one"]?.stringUnit.value, "%d Element") XCTAssertEqual(dePlurals?["other"]?.stringUnit.value, "%d Elemente") } - + func testStringCatalogGenerateLocalizedPairs() { let json = """ { @@ -141,32 +141,31 @@ final class StringCatalogTests: XCTestCase { "version" : "1.0" } """ - + let data = json.data(using: .utf8)! let catalog = try! JSONDecoder().decode(StringCatalog.self, from: data) - + let greetingEntry = catalog.strings["greeting"]! let pairs = greetingEntry.getLocalizedStringPairs( key: "greeting", basePath: "/test/path", - sourceLanguage: "en" - ) - + sourceLanguage: "en") + XCTAssertEqual(pairs.count, 1) // Only one non-source language (ru) - + let pair = pairs[0] XCTAssertEqual(pair.key, "greeting") XCTAssertEqual(pair.base.string, "Hello, %@!") XCTAssertEqual(pair.translation.string, "Hallo, %@!") XCTAssertEqual(pair.path, "/test/path") - + // Check format arguments are parsed correctly XCTAssertEqual(pair.base.arguments.count, 1) XCTAssertEqual(pair.translation.arguments.count, 1) XCTAssertEqual(pair.base.arguments[0].specifier, "@") XCTAssertEqual(pair.translation.arguments[0].specifier, "@") } - + func testStringCatalogWithFormatArgumentMismatch() { let json = """ { @@ -193,28 +192,27 @@ final class StringCatalogTests: XCTestCase { "version" : "1.0" } """ - + let data = json.data(using: .utf8)! let catalog = try! JSONDecoder().decode(StringCatalog.self, from: data) - + let numbersEntry = catalog.strings["numbers"]! let pairs = numbersEntry.getLocalizedStringPairs( key: "numbers", basePath: "/test/path", - sourceLanguage: "en" - ) - + sourceLanguage: "en") + XCTAssertEqual(pairs.count, 1) - + let pair = pairs[0] - + // Base: %d (position 1), %@ (position 2) XCTAssertEqual(pair.base.arguments.count, 2) XCTAssertEqual(pair.base.arguments[0].position, 1) XCTAssertEqual(pair.base.arguments[0].specifier, "d") XCTAssertEqual(pair.base.arguments[1].position, 2) XCTAssertEqual(pair.base.arguments[1].specifier, "@") - + // Translation: %@ (position 1), %s (position 2) XCTAssertEqual(pair.translation.arguments.count, 2) XCTAssertEqual(pair.translation.arguments[0].position, 1) @@ -222,13 +220,13 @@ final class StringCatalogTests: XCTestCase { XCTAssertEqual(pair.translation.arguments[1].position, 2) XCTAssertEqual(pair.translation.arguments[1].specifier, "s") } - + func testInvalidStringCatalogHandling() { let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + // Test with non-existent file let catalog = StringCatalog(path: "/non/existent/path.xcstrings", problemReporter: problemReporter) - + XCTAssertNil(catalog) XCTAssertTrue(problemReporter.hasError) } diff --git a/Tests/LocheckLogicTests/StringCatalogValidatorTests.swift b/Tests/LocheckLogicTests/StringCatalogValidatorTests.swift index aebecd6..da6d330 100644 --- a/Tests/LocheckLogicTests/StringCatalogValidatorTests.swift +++ b/Tests/LocheckLogicTests/StringCatalogValidatorTests.swift @@ -5,24 +5,24 @@ // Created by Uladzislau Birukou on 9/22/25. // -import XCTest import Files @testable import LocheckLogic +import XCTest final class StringCatalogValidatorTests: XCTestCase { private var tempFolder: Folder! - + override func setUp() { super.setUp() let uuid = UUID().uuidString tempFolder = try! Folder.home.createSubfolderIfNeeded(withName: "StringCatalogTests_\(uuid)") } - + override func tearDown() { try? tempFolder.delete() super.tearDown() } - + func testValidStringCatalogValidation() throws { let validCatalog = """ { @@ -49,16 +49,16 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - + let catalogFile = try tempFolder.createFile(named: "test.xcstrings", contents: Data(validCatalog.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertFalse(problemReporter.hasError) XCTAssertFalse(problemReporter.hasWarning) } - + func testStringCatalogWithMissingArguments() throws { let catalogWithMissingArgs = """ { @@ -85,20 +85,22 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_missing_args.xcstrings", contents: Data(catalogWithMissingArgs.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_missing_args.xcstrings", + contents: Data(catalogWithMissingArgs.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasWarning) // Missing argument should generate warning - + let problems = problemReporter.problems XCTAssertTrue(problems.contains { localProblem in localProblem.problem.kindIdentifier == "string_has_missing_arguments" }) } - + func testStringCatalogWithExtraArguments() throws { let catalogWithExtraArgs = """ { @@ -125,20 +127,22 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_extra_args.xcstrings", contents: Data(catalogWithExtraArgs.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_extra_args.xcstrings", + contents: Data(catalogWithExtraArgs.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasWarning) // Extra argument should generate warning - + let problems = problemReporter.problems XCTAssertTrue(problems.contains { localProblem in localProblem.problem.kindIdentifier == "string_has_extra_arguments" }) } - + func testStringCatalogWithInvalidArgumentSpecifier() throws { let catalogWithInvalidSpecifier = """ { @@ -165,20 +169,22 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_invalid_specifier.xcstrings", contents: Data(catalogWithInvalidSpecifier.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_invalid_specifier.xcstrings", + contents: Data(catalogWithInvalidSpecifier.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasError) // Invalid specifier should generate error - + let problems = problemReporter.problems XCTAssertTrue(problems.contains { localProblem in localProblem.problem.kindIdentifier == "string_has_invalid_argument" }) } - + func testStringCatalogWithNeedsWorkTranslation() throws { let catalogWithNeedsWork = """ { @@ -205,20 +211,22 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_needs_work.xcstrings", contents: Data(catalogWithNeedsWork.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_needs_work.xcstrings", + contents: Data(catalogWithNeedsWork.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasWarning) // "needs work" should generate warning - + let problems = problemReporter.problems XCTAssertTrue(problems.contains { localProblem in localProblem.problem.kindIdentifier == "translation_needs_work" }) } - + func testStringCatalogWithMissingPluralForm() throws { let catalogWithMissingPlural = """ { @@ -263,19 +271,21 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_missing_plural.xcstrings", contents: Data(catalogWithMissingPlural.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_missing_plural.xcstrings", + contents: Data(catalogWithMissingPlural.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasWarning) // Missing "other" form should generate warning - + let problems = problemReporter.problems let missingPluralProblems = problems.filter { $0.problem.kindIdentifier == "missing_plural_form" } XCTAssertTrue(missingPluralProblems.count >= 1) // At least one missing plural form } - + func testStringCatalogWithPluralArgumentConsistency() throws { let catalogWithPlurals = """ { @@ -326,27 +336,30 @@ final class StringCatalogValidatorTests: XCTestCase { "version" : "1.0" } """ - - let catalogFile = try tempFolder.createFile(named: "test_valid_plurals.xcstrings", contents: Data(catalogWithPlurals.utf8)) + + let catalogFile = try tempFolder.createFile( + named: "test_valid_plurals.xcstrings", + contents: Data(catalogWithPlurals.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + // Should not have format specifier errors since all plural forms use %d correctly - let invalidArgumentProblems = problemReporter.problems.filter { $0.problem.kindIdentifier == "string_has_invalid_argument" } + let invalidArgumentProblems = problemReporter.problems + .filter { $0.problem.kindIdentifier == "string_has_invalid_argument" } XCTAssertEqual(invalidArgumentProblems.count, 0) } - + func testInvalidJSONStringCatalog() throws { let invalidJSON = "{ invalid json }" - + let catalogFile = try tempFolder.createFile(named: "invalid.xcstrings", contents: Data(invalidJSON.utf8)) let problemReporter = ProblemReporter(root: "", ignoredProblemIdentifiers: [], ignoreWarnings: false) - + parseAndValidateStringCatalog(stringCatalogFile: catalogFile, problemReporter: problemReporter) - + XCTAssertTrue(problemReporter.hasError) - + let problems = problemReporter.problems XCTAssertTrue(problems.contains { localProblem in localProblem.problem.kindIdentifier == "invalid_file" diff --git a/Tests/LocheckLogicTests/parseAndValidateAndroidStringsTests.swift b/Tests/LocheckLogicTests/parseAndValidateAndroidStringsTests.swift index 0ec45d8..1e6907f 100644 --- a/Tests/LocheckLogicTests/parseAndValidateAndroidStringsTests.swift +++ b/Tests/LocheckLogicTests/parseAndValidateAndroidStringsTests.swift @@ -25,7 +25,7 @@ class ParseAndValidateAndroidStringsTests: XCTestCase { translationLanguageName: "demo", problemReporter: problemReporter) - XCTAssertEqual(problemReporter.problems.count, 10) + XCTAssertEqual(problemReporter.problems.count, 16) let problems = problemReporter.problems.map(\.problem) XCTAssertEqual(problems.map(\.kindIdentifier), [ @@ -36,8 +36,14 @@ class ParseAndValidateAndroidStringsTests: XCTestCase { "key_missing_from_translation", "key_missing_from_base", "phrase_has_missing_arguments", - "string_has_invalid_argument", "string_has_missing_arguments", + "string_has_missing_arguments", + "string_has_invalid_argument", // %,d vs %d + "string_has_missing_arguments", // %+d vs % d (space flag not supported, so no specifier found) + "string_has_invalid_argument", // %+d vs %-d (wrong flag) + "string_has_invalid_argument", // %10d vs %5d (wrong width) + "string_has_invalid_argument", // %.2f vs %.4f (wrong precision) + "string_has_invalid_argument", // %8.3f vs %10.2f (wrong width and precision) "string_array_item_count_mismatch", ])