diff --git a/README.md b/README.md
index bab28f4..b23db9d 100644
--- a/README.md
+++ b/README.md
@@ -697,6 +697,7 @@ Support levels:
init(data: Data, encoding: StringEncoding)
init(bytes: [UInt8], encoding: StringEncoding)
init(contentsOf: URL)
+static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String
var capitalized: String
var deletingLastPathComponent: String
func replacingOccurrences(of search: String, with replacement: String) -> String
diff --git a/Sources/SkipFoundation/Bundle.swift b/Sources/SkipFoundation/Bundle.swift
index 78606ee..d6312a4 100644
--- a/Sources/SkipFoundation/Bundle.swift
+++ b/Sources/SkipFoundation/Bundle.swift
@@ -264,10 +264,23 @@ public class Bundle : Hashable, SwiftCustomBridged {
let table = tableName ?? "Localizable"
var locTable = localizedTables[table]
if locTable == nil {
- let resURL = url(forResource: table, withExtension: "strings")
- let resTable = resURL == nil ? nil : try? PropertyListSerialization.propertyList(from: Data(contentsOf: resURL!), format: nil)
- locTable = Self.stringFormatsTable(from: resTable)
- localizedTables[table] = locTable!
+ let newTable = mutableMapOf>()
+ let resTypes: [(extension: String, format: PropertyListSerialization.PropertyListFormat)] = [
+ (extension: "strings", format: .openStep),
+ (extension: "stringsdict", format: .xml)
+ ]
+ for resType in resTypes {
+ if let resURL = url(forResource: table, withExtension: resType.extension),
+ let resData = try? Data(contentsOf: resURL),
+ let resTable = try? PropertyListSerialization.propertyList(from: resData, format: resType.format) {
+ for (sKey, sValue) in resTable where newTable[sKey] == nil {
+ newTable[sKey] = Triple(sValue, sValue.kotlinFormatString, MarkdownNode.from(string: sValue))
+ }
+ }
+ }
+
+ locTable = newTable
+ localizedTables[table] = newTable
}
if let formats = locTable?[key] {
return formats
diff --git a/Sources/SkipFoundation/PropertyListSerialization.swift b/Sources/SkipFoundation/PropertyListSerialization.swift
index 94aa18b..1e8db82 100644
--- a/Sources/SkipFoundation/PropertyListSerialization.swift
+++ b/Sources/SkipFoundation/PropertyListSerialization.swift
@@ -1,24 +1,149 @@
// Copyright 2023–2025 Skip
// SPDX-License-Identifier: LGPL-3.0-only WITH LGPL-3.0-linking-exception
#if SKIP
+import android.util.Xml
+import org.xmlpull.v1.XmlPullParser
+import java.io.ByteArrayInputStream
public class PropertyListSerialization {
+
@available(*, unavailable)
public static func propertyList(_ propertyList: Any, isValidFor: PropertyListSerialization.PropertyListFormat) -> Bool {
fatalError()
}
+ public static func propertyList(from data: Data, options: PropertyListSerialization.ReadOptions = [], format: Any?) throws -> [String: String]? {
+ let text = data.utf8String
+ guard let text = text else {
+ // should this throw an error?
+ return nil
+ }
- public static func propertyList(from: Data, options: PropertyListSerialization.ReadOptions = [], format: Any?) throws -> [String: String]? {
// TODO: auto-detect format from data content if the format argument is unset
- return try openStepPropertyList(from: from, options: options)
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+ if (format as? PropertyListFormat) == .xml || trimmed.hasPrefix(" [String: String]? {
- var dict: Dictionary = [:]
+ private static func convertStringsDictToICUDict(from data: Data) throws -> [String: String]? {
+ let parser = Xml.newPullParser()
+ parser.setInput(ByteArrayInputStream(data.platformValue), "UTF-8")
+
+ var result: [String: String] = [:]
+ var dictStack: [[String: Any]] = []
+ var keyStack: [String] = []
+ var textAccumulator = ""
+
+ var eventType = parser.getEventType()
+ while eventType != XmlPullParser.END_DOCUMENT {
+
+ let tagName = parser.getName()
+ switch eventType {
+ case XmlPullParser.START_TAG:
+ textAccumulator = ""
+ if tagName == "dict" {
+ dictStack.append([:])
+ }
+
+ case XmlPullParser.TEXT:
+ textAccumulator += parser.getText() ?? ""
+
+ case XmlPullParser.END_TAG:
+ let content = textAccumulator.trimmingCharacters(in: .whitespacesAndNewlines)
+ switch tagName {
+ case "key":
+ keyStack.append(content)
+ case "string":
+ if let key = keyStack.popLast(), !dictStack.isEmpty {
+ dictStack[dictStack.count - 1][key] = content
+ }
+ case "dict":
+ if let finishedDict = dictStack.popLast() {
+ if let parentKey = keyStack.popLast(), !dictStack.isEmpty {
+ dictStack[dictStack.count - 1][parentKey] = finishedDict
+ } else {
+ for (key, value) in finishedDict {
+ /* SKIP NOWARN */
+ if let stringsDict = value as? [String: Any],
+ let icuString = convertStringsDictToICUString(stringsDict) {
+ result[key] = icuString
+ }
+ }
+ }
+ }
+ default:
+ break
+ }
+
+ textAccumulator = ""
+ default:
+ break
+ }
+
+ eventType = parser.next()
+ }
+
+ return result
+ }
+
+ private static func convertStringsDictToICUString(_ stringsDict: [String: Any]) -> String? {
+ guard let formatKey = stringsDict["NSStringLocalizedFormatKey"] as? String else {
+ return nil
+ }
+
+ var result: String = formatKey
+
+ /* SKIP NOWARN */
+ for (key, varDict) in stringsDict.compactMapValues({ $0 as? [String: Any] }) {
+ guard let specType = varDict["NSStringFormatSpecTypeKey"] as? String, specType == "NSStringPluralRuleType"
+ else {
+ continue
+ }
+
+ let categories = ["zero", "one", "two", "few", "many", "other"]
+ let rules = categories.compactMap { (category: String) -> String? in
+ guard let pluralString = varDict[category] as? String else { return nil }
+ var cleaned = pluralString!
+ for index in 1...10 {
+ cleaned = cleaned
+ .replacingOccurrences(of: "%\(index)$@", with: "{\(index-1)}")
+ .replacingOccurrences(of: "%\(index)$d", with: "{\(index-1)}")
+ }
+ let icuCategory = category == "zero" ? "=0" : category
+ return "\(icuCategory){\(cleaned)}"
+ }.joined(separator: " ")
- let text = from.utf8String
+ if !rules.isEmpty {
+ let pattern = "%#@\(key)@"
+ let replacement = "{0, plural, \(rules)}"
+
+ if result.contains(pattern) {
+ result = result.replacingOccurrences(of: pattern, with: replacement)
+ } else if result.contains("%#@") {
+ let parts = result.components(separatedBy: "%#@")
+ if parts.count > 1 {
+ let prefix = parts[0]
+ let remaining = parts[1]
+ let subParts = remaining.components(separatedBy: "@")
+ if subParts.count > 1 {
+ let suffix = subParts[1...].joined(separator: "@")
+ result = prefix + replacement + suffix
+ }
+ }
+ }
+ }
+ }
+
+ return result
+ }
+
+ private static func openStepPropertyList(from data: Data, options: PropertyListSerialization.ReadOptions = []) throws -> [String: String]? {
+ var dict: Dictionary = [:]
+ let text = data.utf8String
guard let text = text else {
// should this throw an error?
return nil
diff --git a/Sources/SkipFoundation/String.swift b/Sources/SkipFoundation/String.swift
index b1ba9bb..41108cd 100644
--- a/Sources/SkipFoundation/String.swift
+++ b/Sources/SkipFoundation/String.swift
@@ -15,6 +15,8 @@
//===----------------------------------------------------------------------===//
#if SKIP
+import android.icu.text.MessageFormat
+import java.util.ArrayList
public typealias NSString = kotlin.String
public func NSString(string: String) -> NSString { string }
@@ -108,6 +110,23 @@ extension String {
fatalError("unsupported")
}
+ public static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String {
+ let list = ArrayList()
+ for argument in arguments { list.add(argument) }
+ let platformArguments = list.toArray()
+
+ let locale = Locale.current.platformValue
+ if format.contains("{0, plural,") {
+ return MessageFormat(format, locale).format(platformArguments)
+ }
+
+ if !arguments.isEmpty {
+ return java.lang.String.format(locale, format, platformArguments)
+ }
+
+ return format
+ }
+
public func trimmingCharacters(in set: CharacterSet) -> String {
return trim { set.platformValue.contains(UInt32($0.code)) }
}
diff --git a/Tests/SkipFoundationTests/Foundation/LocaleTests.swift b/Tests/SkipFoundationTests/Foundation/LocaleTests.swift
index 890a96b..b1ea50c 100644
--- a/Tests/SkipFoundationTests/Foundation/LocaleTests.swift
+++ b/Tests/SkipFoundationTests/Foundation/LocaleTests.swift
@@ -127,6 +127,44 @@ final class LocaleTests: XCTestCase {
])
}
+ func testLocalizableStringsDictParsing() throws {
+ let locstr = #"""
+
+
+
+
+ repeats_every_day
+
+ NSStringLocalizedFormatKey
+ %#@num_days@
+ num_days
+
+ NSStringFormatSpecTypeKey
+ NSStringPluralRuleType
+ NSStringFormatValueTypeKey
+ d
+ zero
+ Ne se répète jamais
+ one
+ Se répète quotidiennement
+ other
+ Se répète tous les %d jours
+
+
+
+
+ """#
+
+ let data = try XCTUnwrap(locstr.data(using: .utf8))
+ let plist = try PropertyListSerialization.propertyList(from: data, options: [], format: nil)
+
+ // SKIP NOWARN
+ let dict = try XCTUnwrap(plist as? [String: Any])
+
+ XCTAssertEqual(1, dict.count)
+ XCTAssertNotNil(dict["repeats_every_day"])
+ }
+
func testLocaleFormats() throws {
#if !SKIP
// TODO
@@ -804,6 +842,29 @@ final class LocaleTests: XCTestCase {
// "sk" is not localized at all, so the expected behavior is to fall back to base.lproj, which is en, and so "lower-case" will be translated as "UPPER-CASE"
XCTAssertEqual("UPPER-CASE", String(localized: LocalizedStringResource("lower-case", locale: Locale(identifier: "sk"), bundle: moduleBundle)))
}
+
+ func testLocalizedPluralStringFormatting() throws {
+ #if os(macOS)
+ if !isJava && Bundle.module.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "en") == nil {
+ throw XCTSkip("Localizable.xcstrings not compiled to strings/stringsdict")
+ }
+ #endif
+
+ let bundleDesc = LocalizedStringResource.BundleDescription.atURL(Bundle.module.bundleURL)
+ var resource = LocalizedStringResource("repeats_every_day", bundle: bundleDesc)
+
+ // English
+ resource.locale = Locale(identifier: "en")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 0), "Repeats never")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 1), "Repeats daily")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 5), "Repeats every 5 days")
+
+ // French
+ resource.locale = Locale(identifier: "fr")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 0), "Ne se répète jamais")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 1), "Se répète quotidiennement")
+ XCTAssertEqual(String.localizedStringWithFormat(String(localized: resource), 5), "Se répète tous les 5 jours")
+ }
}
#if !SKIP
diff --git a/Tests/SkipFoundationTests/Resources/Localizable.xcstrings b/Tests/SkipFoundationTests/Resources/Localizable.xcstrings
index 51cad1b..ea412a5 100644
--- a/Tests/SkipFoundationTests/Resources/Localizable.xcstrings
+++ b/Tests/SkipFoundationTests/Resources/Localizable.xcstrings
@@ -128,6 +128,239 @@
}
}
},
+ "repeats_every_day" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "ar" : {
+ "variations" : {
+ "plural" : {
+ "few" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يتكرر كل %1$d أيام"
+ }
+ },
+ "many" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يتكرر كل %1$d يوماً"
+ }
+ },
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يتكرر يومياً"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يتكرر كل %1$d يوم"
+ }
+ },
+ "two" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "يتكرر كل يومين"
+ }
+ },
+ "zero" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "لا يتكرر أبداً"
+ }
+ }
+ }
+ }
+ },
+ "en" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Repeats daily"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Repeats every %1$d days"
+ }
+ },
+ "zero" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Repeats never"
+ }
+ }
+ }
+ }
+ },
+ "fr" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se répète quotidiennement"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Se répète tous les %1$d jours"
+ }
+ },
+ "zero" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Ne se répète jamais"
+ }
+ }
+ }
+ }
+ },
+ "he" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חוזר כל יום"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "חוזר כל %1$d ימים"
+ }
+ }
+ }
+ }
+ },
+ "ja" : {
+ "variations" : {
+ "plural" : {
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%1$d日ごとに繰り返し"
+ }
+ }
+ }
+ }
+ },
+ "pt-BR" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Repete diariamente"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Repete a cada %1$d dias"
+ }
+ }
+ }
+ }
+ },
+ "ru" : {
+ "variations" : {
+ "plural" : {
+ "few" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторяется каждые %1$d дня"
+ }
+ },
+ "many" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторяется каждые %1$d дней"
+ }
+ },
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторяется каждый %1$d день"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторяется каждые %1$d дней"
+ }
+ }
+ }
+ }
+ },
+ "sv" : {
+ "variations" : {
+ "plural" : {
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Upprepas varje dag"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Upprepas var %1$d dag"
+ }
+ }
+ }
+ }
+ },
+ "uk" : {
+ "variations" : {
+ "plural" : {
+ "few" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторюється кожні %1$d дні"
+ }
+ },
+ "many" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторюється кожні %1$d днів"
+ }
+ },
+ "one" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторюється щодня"
+ }
+ },
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Повторюється кожні %1$d днів"
+ }
+ }
+ }
+ }
+ },
+ "zh-Hans" : {
+ "variations" : {
+ "plural" : {
+ "other" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "每 %1$d 天重复一次"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"Settings" : {
}