Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ListFormatter #4794

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
1 change: 1 addition & 0 deletions CoreFoundation/Base.subproj/ForSwiftFoundationOnly.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
#include <CoreFoundation/CFURLComponents.h>
#include <CoreFoundation/CFRunArray.h>
#include <CoreFoundation/CFDateComponents.h>
#include <CoreFoundation/CFListFormatter.h>

#if TARGET_OS_WIN32
#define NOMINMAX
Expand Down
27 changes: 27 additions & 0 deletions CoreFoundation/Locale.subproj/CFListFormatter.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,22 @@
#define BUFFER_SIZE 256
#define RESULT_BUFFER_SIZE 768

#if TARGET_OS_WASI
#define LOCK() do {} while (0)
#define UNLOCK() do {} while (0)
#else
#include <dispatch/dispatch.h>

#define LOCK() do { dispatch_semaphore_wait(formatter->_lock, DISPATCH_TIME_FOREVER); } while(0)
#define UNLOCK() do { dispatch_semaphore_signal(formatter->_lock); } while(0)
#endif

struct __CFListFormatter {
CFRuntimeBase _base;
CFLocaleRef _locale;
#if !TARGET_OS_WASI
dispatch_semaphore_t _lock;
#endif
};

static void __CFListFormatterDeallocate(CFTypeRef cf) {
Expand Down Expand Up @@ -64,10 +77,24 @@ CFListFormatterRef _CFListFormatterCreate(CFAllocatorRef allocator, CFLocaleRef
}

memory->_locale = CFRetain(locale);
#if !TARGET_OS_WASI
memory->_lock = dispatch_semaphore_create(1);
#endif

return memory;
}

void _CFListFormatterSetLocale(CFListFormatterRef formatter, CFLocaleRef locale) {
assert(locale != NULL);

LOCK();
if (locale != formatter->_locale) {
CFRelease(formatter->_locale);
formatter->_locale = CFLocaleCreateCopy(kCFAllocatorSystemDefault, locale);
}
UNLOCK();
}

CFStringRef _CFListFormatterCreateStringByJoiningStrings(CFAllocatorRef allocator, const CFListFormatterRef formatter, const CFArrayRef strings) {
CFAssert1(strings != NULL, __kCFLogAssertion, "%s(): strings should not be NULL", __PRETTY_FUNCTION__);
if (strings == NULL) {
Expand Down
6 changes: 6 additions & 0 deletions CoreFoundation/Locale.subproj/CFListFormatter.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ typedef struct CF_BRIDGED_TYPE(id) __CFListFormatter *CFListFormatterRef;
CF_EXPORT
CFTypeID _CFListFormatterGetTypeID(void);

CF_EXPORT
CFListFormatterRef _Nullable _CFListFormatterCreate(CFAllocatorRef allocator, CFLocaleRef locale);

CF_EXPORT
void _CFListFormatterSetLocale(CFListFormatterRef formatter, CFLocaleRef locale);

CF_EXPORT
CFStringRef _Nullable _CFListFormatterCreateStringByJoiningStrings(CFAllocatorRef allocator, CFListFormatterRef formatter, const CFArrayRef strings);

CF_ASSUME_NONNULL_END
Expand Down
8 changes: 8 additions & 0 deletions Foundation.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,8 @@
90E645DF1E4C89A400D0D47C /* TestNSCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E645DE1E4C89A400D0D47C /* TestNSCache.swift */; };
91B668A32252B3C5001487A1 /* FileManager+POSIX.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B668A22252B3C5001487A1 /* FileManager+POSIX.swift */; };
91B668A52252B3E7001487A1 /* FileManager+Win32.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91B668A42252B3E7001487A1 /* FileManager+Win32.swift */; };
9ED688592A5CD6F000CEBE96 /* ListFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED688582A5CD6F000CEBE96 /* ListFormatter.swift */; };
9ED6885B2A5CD71400CEBE96 /* TestListFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ED6885A2A5CD71400CEBE96 /* TestListFormatter.swift */; };
9F0DD3521ECD73D000F68030 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F0041781ECD5962004138BD /* main.swift */; };
9F0DD3571ECD783500F68030 /* SwiftFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B5D885D1BBC938800234F36 /* SwiftFoundation.framework */; };
A058C2021E529CF100B07AA1 /* TestMassFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A058C2011E529CF100B07AA1 /* TestMassFormatter.swift */; };
Expand Down Expand Up @@ -1134,6 +1136,8 @@
90E645DE1E4C89A400D0D47C /* TestNSCache.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestNSCache.swift; sourceTree = "<group>"; };
91B668A22252B3C5001487A1 /* FileManager+POSIX.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+POSIX.swift"; sourceTree = "<group>"; };
91B668A42252B3E7001487A1 /* FileManager+Win32.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+Win32.swift"; sourceTree = "<group>"; };
9ED688582A5CD6F000CEBE96 /* ListFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListFormatter.swift; sourceTree = "<group>"; };
9ED6885A2A5CD71400CEBE96 /* TestListFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestListFormatter.swift; sourceTree = "<group>"; };
9F0041781ECD5962004138BD /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
9F0DD33F1ECD734200F68030 /* xdgTestHelper.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = xdgTestHelper.app; sourceTree = BUILT_PRODUCTS_DIR; };
9F0DD34F1ECD737B00F68030 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1894,6 +1898,7 @@
3EA9D66F1EF0532D00B362D6 /* TestJSONEncoder.swift */,
5EB6A15C1C188FC40037DCB8 /* TestJSONSerialization.swift */,
BD8042151E09857800487EB8 /* TestLengthFormatter.swift */,
9ED6885A2A5CD71400CEBE96 /* TestListFormatter.swift */,
A058C2011E529CF100B07AA1 /* TestMassFormatter.swift */,
7D8BD738225ED1480057CF37 /* TestMeasurement.swift */,
BF8E65301DC3B3CB005AB5C3 /* TestNotification.swift */,
Expand Down Expand Up @@ -2161,6 +2166,7 @@
EADE0B641BD15DFF00C49C64 /* JSONSerialization.swift */,
49D55FA025E84FE5007BD3B3 /* JSONSerialization+Parser.swift */,
EADE0B661BD15DFF00C49C64 /* LengthFormatter.swift */,
9ED688582A5CD6F000CEBE96 /* ListFormatter.swift */,
5BD70FB11D3D4CDC003B9BF8 /* Locale.swift */,
EADE0B681BD15DFF00C49C64 /* MassFormatter.swift */,
5BECBA371D1CAD7000B39B1F /* Measurement.swift */,
Expand Down Expand Up @@ -2993,6 +2999,7 @@
AA9E0E0B21FA6C5600963F4C /* PropertyListEncoder.swift in Sources */,
5BD70FB41D3D4F8B003B9BF8 /* Calendar.swift in Sources */,
5BA9BEBD1CF4F3B8009DBD6C /* Notification.swift in Sources */,
9ED6885B2A5CD71400CEBE96 /* TestListFormatter.swift in Sources */,
5BD70FB21D3D4CDC003B9BF8 /* Locale.swift in Sources */,
EADE0BB71BD15E0000C49C64 /* Stream.swift in Sources */,
5BF7AEBF1BCD51F9008F214A /* NSURL.swift in Sources */,
Expand All @@ -3015,6 +3022,7 @@
EADE0BB31BD15E0000C49C64 /* NSRegularExpression.swift in Sources */,
EADE0BA41BD15E0000C49C64 /* LengthFormatter.swift in Sources */,
5BDC3FCA1BCF176100ED97BB /* NSCFArray.swift in Sources */,
9ED688592A5CD6F000CEBE96 /* ListFormatter.swift in Sources */,
EADE0BB21BD15E0000C49C64 /* Progress.swift in Sources */,
EADE0B961BD15DFF00C49C64 /* DateIntervalFormatter.swift in Sources */,
5B5BFEAC1E6CC0C200AC8D9E /* NSCFBoolean.swift in Sources */,
Expand Down
1 change: 1 addition & 0 deletions Sources/Foundation/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ add_library(Foundation
JSONSerialization.swift
JSONSerialization+Parser.swift
LengthFormatter.swift
ListFormatter.swift
Locale.swift
MassFormatter.swift
Measurement.swift
Expand Down
99 changes: 99 additions & 0 deletions Sources/Foundation/ListFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//

@_implementationOnly import CoreFoundation

/* NSListFormatter provides locale-correct formatting of a list of items using the appropriate separator and conjunction. Note that the list formatter is unaware of the context where the joined string will be used, e.g., in the beginning of the sentence or used as a standalone string in the UI, so it will not provide any sort of capitalization customization on the given items, but merely join them as-is. The string joined this way may not be grammatically correct when placed in a sentence, and it should only be used in a standalone manner.
*/
open class ListFormatter: Formatter {
private let cfFormatter: CFListFormatter

/* Specifies the locale to format the items. Defaults to autoupdatingCurrentLocale. Also resets to autoupdatingCurrentLocale on assignment of nil.
*/
private var _locale: Locale = .autoupdatingCurrent
open var locale: Locale! {
get { return _locale }
set { _locale = newValue ?? .autoupdatingCurrent }
}

/* Specifies how each object should be formatted. If not set, the object is formatted using its instance method in the following order: -descriptionWithLocale:, -localizedDescription, and -description.
*/
/*@NSCopying*/ open var itemFormatter: Formatter?

public override init() {
self.cfFormatter = _CFListFormatterCreate(kCFAllocatorSystemDefault, CFLocaleCopyCurrent())!
super.init()
}

public required init?(coder: NSCoder) {
self.cfFormatter = _CFListFormatterCreate(kCFAllocatorSystemDefault, CFLocaleCopyCurrent())!
Copy link
Member

Choose a reason for hiding this comment

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

Most of the SCF does not support initWithCoder. Shall we follow precedent and fatalError() here?

super.init(coder: coder)
}

open override func copy(with zone: NSZone? = nil) -> Any {
let copied = ListFormatter()
copied.locale = locale
Copy link
Member

Choose a reason for hiding this comment

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

I believe that we should just use the copy method instead

copied.itemFormatter = itemFormatter?.copy(with: zone) as? Formatter
return copied
}

/* Convenience method to return a string constructed from an array of strings using the list format specific to the current locale. It is recommended to join only disjointed strings that are ready to display in a bullet-point list. Sentences, phrases with punctuations, and appositions may not work well when joined together.
*/
open class func localizedString(byJoining strings: [String]) -> String {
let formatter = ListFormatter()
return formatter.string(from: strings)!
}

/* Convenience method for -stringForObjectValue:. Returns a string constructed from an array in the locale-aware format. Each item is formatted using the itemFormatter. If the itemFormatter does not apply to a particular item, the method will fall back to the item's -descriptionWithLocale: or -localizedDescription if implemented, or -description if not.

Returns nil if `items` is nil or if the list formatter cannot generate a string representation for all items in the array.
*/
open func string(from items: [Any]) -> String? {
let strings = items.map { item in
if let string = itemFormatter?.string(for: item) {
return string
}

// Use the item’s `description(withLocale:)` if implemented
if let item = item as? NSArray {
return item.description(withLocale: locale)
} else if let item = item as? NSDecimalNumber {
return item.description(withLocale: locale)
} else if let item = item as? NSDictionary {
return item.description(withLocale: locale)
} else if let item = item as? NSNumber {
return item.description(withLocale: locale)
} else if let item = item as? NSOrderedSet {
return item.description(withLocale: locale)
} else if let item = item as? NSSet {
return item.description(withLocale: locale)
}

// Use the item’s `localizedDescription` if implemented
if let item = item as? Error {
return item.localizedDescription
}

return String(describing: item)
}

_CFListFormatterSetLocale(cfFormatter, locale._cfObject)
return _CFListFormatterCreateStringByJoiningStrings(kCFAllocatorSystemDefault, cfFormatter, strings._cfObject)?._swiftObject
}

/* Inherited from NSFormatter. `obj` must be an instance of NSArray. Returns nil if `obj` is nil, not an instance of NSArray, or if the list formatter cannot generate a string representation for all objects in the array.
*/
open override func string(for obj: Any?) -> String? {
guard let list = obj as? [Any] else {
return nil
}

return string(from: list)
}
}
1 change: 1 addition & 0 deletions Tests/Foundation/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ target_sources(TestFoundation PRIVATE
Tests/TestJSONEncoder.swift
Tests/TestJSONSerialization.swift
Tests/TestLengthFormatter.swift
Tests/TestListFormatter.swift
Tests/TestMassFormatter.swift
Tests/TestMeasurement.swift
Tests/TestNotificationCenter.swift
Expand Down
108 changes: 108 additions & 0 deletions Tests/Foundation/Tests/TestListFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2014 - 2023 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See http://swift.org/LICENSE.txt for license information
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
//

#if NS_FOUNDATION_ALLOWS_TESTABLE_IMPORT
#if canImport(SwiftFoundation) && !DEPLOYMENT_RUNTIME_OBJC
@testable import SwiftFoundation
#else
@testable import Foundation
#endif
#endif

class TestListFormatter: XCTestCase {
private var formatter: ListFormatter!

override func setUp() {
super.setUp()

formatter = ListFormatter()
}

override func tearDown() {
formatter = nil

super.tearDown()
}

func test_locale() throws {
XCTAssertEqual(formatter.locale, Locale.autoupdatingCurrent)

formatter.locale = Locale(identifier: "en_US_POSIX")
Copy link
Member

Choose a reason for hiding this comment

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

This is not portable (en_US is fine, en_US_POSIX is not a valid locale on Windows).

XCTAssertEqual(formatter.locale, Locale(identifier: "en_US_POSIX"))

formatter.locale = nil
XCTAssertEqual(formatter.locale, Locale.autoupdatingCurrent)
}

func test_copy() throws {
formatter.itemFormatter = NumberFormatter()

let copied = try XCTUnwrap(formatter.copy() as? ListFormatter)
XCTAssertEqual(formatter.locale, copied.locale)
XCTAssert(copied.itemFormatter is NumberFormatter)

copied.locale = Locale(identifier: "en_US_POSIX")
copied.itemFormatter = DateFormatter()
XCTAssertNotEqual(formatter.locale, copied.locale)
XCTAssert(formatter.itemFormatter is NumberFormatter)
XCTAssertFalse(copied.itemFormatter is NumberFormatter)
}

func test_stringFromItemsWithItemFormatter() throws {
let numberFormatter = NumberFormatter()
numberFormatter.locale = Locale(identifier: "en_US_POSIX")
numberFormatter.numberStyle = .percent

formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.itemFormatter = numberFormatter
XCTAssertEqual(formatter.string(from: [1, 2, 3]), "100%, 200%, and 300%")
}

func test_stringFromDescriptionsWithLocale() throws {
formatter.locale = Locale(identifier: "en_US")
XCTAssertEqual(formatter.string(from: [1000, 2000, 3000]), "1,000, 2,000, and 3,000")
}

func test_stringFromLocalizedDescriptions() throws {
struct Item: LocalizedError {
let errorDescription: String? = "item"
}

formatter.locale = Locale(identifier: "en_US_POSIX")
XCTAssertEqual(formatter.string(from: [Item(), Item(), Item()]), "item, item, and item")
}

func test_stringFromItems() throws {
struct Item {}

formatter.locale = Locale(identifier: "en_US_POSIX")
XCTAssertEqual(formatter.string(from: [Item(), Item(), Item()]), "Item(), Item(), and Item()")
}

func test_stringForList() throws {
XCTAssertEqual(formatter.string(for: [42]), "42")
}

func test_stringForNonList() throws {
XCTAssertNil(formatter.string(for: 42))
}

static var allTests: [(String, (TestListFormatter) -> () throws -> Void)] {
return [
("test_locale", test_locale),
("test_copy", test_copy),
("test_stringFromItemsWithItemFormatter", test_stringFromItemsWithItemFormatter),
("test_stringFromDescriptionsWithLocale", test_stringFromDescriptionsWithLocale),
("test_stringFromLocalizedDescriptions", test_stringFromLocalizedDescriptions),
("test_stringFromItems", test_stringFromItems),
("test_stringForList", test_stringForList),
("test_stringForNonList", test_stringForNonList),
]
}
}
1 change: 1 addition & 0 deletions Tests/Foundation/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ var allTestCases = [
testCase(TestNSKeyedArchiver.allTests),
testCase(TestNSKeyedUnarchiver.allTests),
testCase(TestLengthFormatter.allTests),
testCase(TestListFormatter.allTests),
testCase(TestNSLocale.allTests),
testCase(TestNotificationCenter.allTests),
testCase(TestNotificationQueue.allTests),
Expand Down