Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,7 @@ Support levels:
<li><code>init(data: Data, encoding: StringEncoding)</code></li>
<li><code>init(bytes: [UInt8], encoding: StringEncoding)</code></li>
<li><code>init(contentsOf: URL)</code></li>
<li><code>static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String</code></li>
<li><code>var capitalized: String</code></li>
<li><code>var deletingLastPathComponent: String</code></li>
<li><code>func replacingOccurrences(of search: String, with replacement: String) -> String</code></li>
Expand Down
21 changes: 17 additions & 4 deletions Sources/SkipFoundation/Bundle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Triple<String, String, MarkdownNode?>>()
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
Expand Down
135 changes: 130 additions & 5 deletions Sources/SkipFoundation/PropertyListSerialization.swift
Original file line number Diff line number Diff line change
@@ -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("<?xml") || trimmed.hasPrefix("<plist") {
return try convertStringsDictToICUDict(from: data)
} else {
return try openStepPropertyList(from: data, options: options)
}
}

static func openStepPropertyList(from: Data, options: PropertyListSerialization.ReadOptions = []) throws -> [String: String]? {
var dict: Dictionary<String, String> = [:]
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<String, String> = [:]

let text = data.utf8String
guard let text = text else {
// should this throw an error?
return nil
Expand Down
19 changes: 19 additions & 0 deletions Sources/SkipFoundation/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -108,6 +110,23 @@ extension String {
fatalError("unsupported")
}

public static func localizedStringWithFormat(_ format: String, _ arguments: Any...) -> String {
let list = ArrayList<Any>()
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)) }
}
Expand Down
61 changes: 61 additions & 0 deletions Tests/SkipFoundationTests/Foundation/LocaleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,44 @@ final class LocaleTests: XCTestCase {
])
}

func testLocalizableStringsDictParsing() throws {
let locstr = #"""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>repeats_every_day</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@num_days@</string>
<key>num_days</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>zero</key>
<string>Ne se répète jamais</string>
<key>one</key>
<string>Se répète quotidiennement</string>
<key>other</key>
<string>Se répète tous les %d jours</string>
</dict>
</dict>
</dict>
</plist>
"""#

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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading