diff --git a/Sources/FoundationInternationalization/Locale/Locale+Components_ICU.swift b/Sources/FoundationInternationalization/Locale/Locale+Components_ICU.swift index 4fb3bd075..424c80459 100644 --- a/Sources/FoundationInternationalization/Locale/Locale+Components_ICU.swift +++ b/Sources/FoundationInternationalization/Locale/Locale+Components_ICU.swift @@ -290,7 +290,7 @@ extension Locale.Region { internal static let _isoRegionCodes: [String] = { var status = U_ZERO_ERROR - let types = [URGN_WORLD, URGN_CONTINENT, URGN_SUBCONTINENT, URGN_TERRITORY] + let types = [URGN_WORLD, URGN_CONTINENT, URGN_SUBCONTINENT, URGN_TERRITORY, URGN_GROUPING] var codes: [String] = [] for t in types { status = U_ZERO_ERROR @@ -302,6 +302,182 @@ extension Locale.Region { } return codes }() + + /// Categories of a region. See https://www.unicode.org/reports/tr35/tr35-35/tr35-info.html#Territory_Data + @available(FoundationPreview 6.2, *) + public struct Category: Codable, Sendable, Hashable, CustomDebugStringConvertible { + public var debugDescription: String { + switch inner { + case .world: + return "world" + case .continent: + return "continent" + case .subcontinent: + return "subcontinent" + case .territory: + return "territory" + case .grouping: + return "grouping" + } + } + + enum Inner { + case world + case continent + case subcontinent + case territory + case grouping + } + + var inner: Inner + fileprivate init(_ inner: Inner) { + self.inner = inner + } + + var uregionType: URegionType { + switch inner { + case .world: + return URGN_WORLD + case .continent: + return URGN_CONTINENT + case .subcontinent: + return URGN_SUBCONTINENT + case .territory: + return URGN_TERRITORY + case .grouping: + return URGN_GROUPING + } + } + + fileprivate init?(uregionType: URegionType) { + switch uregionType { + case URGN_CONTINENT: + self = .init(.continent) + case URGN_WORLD: + self = .init(.world) + case URGN_SUBCONTINENT: + self = .init(.subcontinent) + case URGN_TERRITORY: + self = .init(.territory) + case URGN_GROUPING: + self = .init(.grouping) + default: + return nil + } + } + + /// Category representing the whold world. + public static let world: Category = Category(.world) + + /// Category representing a continent, regions contained directly by world. + public static let continent: Category = Category(.continent) + + /// Category representing a sub-continent, regions contained directly by a continent. + public static let subcontinent: Category = Category(.subcontinent) + + /// Category representing a territory. + public static let territory: Category = Category(.territory) + + /// Category representing a grouping, regions that has a well defined membership. + public static let grouping: Category = Category(.grouping) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let inner: Inner + switch try container.decode(Int.self) { + case 0: + inner = .world + case 1: + inner = .continent + case 2: + inner = .subcontinent + case 3: + inner = .territory + case 4: + inner = .grouping + default: + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Unknown Category")) + } + self = .init(inner) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch inner { + case .world: + try container.encode(0) + case .continent: + try container.encode(1) + case .subcontinent: + try container.encode(2) + case .territory: + try container.encode(3) + case .grouping: + try container.encode(4) + + } + } + } + + /// An array of regions matching the specified categories. + @available(FoundationPreview 6.2, *) + public static func isoRegions(ofCategory category: Category) -> [Locale.Region] { + var status = U_ZERO_ERROR + let values = uregion_getAvailable(category.uregionType, &status) + guard let values, status.isSuccess else { + return [] + } + return ICU.Enumerator(enumerator: values).elements.map { Locale.Region($0) } + } + + /// The category of the region. + @available(FoundationPreview 6.2, *) + public var category: Category? { + var status = U_ZERO_ERROR + let icuRegion = uregion_getRegionFromCode(identifier, &status) + guard status.isSuccess, let icuRegion else { + return nil + } + let type = uregion_getType(icuRegion) + return Category(uregionType: type) + } + + /// An array of the sub-regions, matching the specified category of the region. + @available(FoundationPreview 6.2, *) + public func subRegions(ofCategoy category: Category) -> [Locale.Region] { + var status = U_ZERO_ERROR + let icuRegion = uregion_getRegionFromCode(identifier, &status) + guard let icuRegion, status.isSuccess else { + return [] + } + + status = U_ZERO_ERROR + let enumerator = uregion_getContainedRegionsOfType(icuRegion, category.uregionType, &status) + guard let enumerator, status.isSuccess else { + return [] + } + return ICU.Enumerator(enumerator: enumerator).elements.map { Locale.Region($0) } + } + + /// The subcontinent that contains this region, if any. + @available(FoundationPreview 6.2, *) + public var subcontinent: Locale.Region? { + var status = U_ZERO_ERROR + let icuRegion = uregion_getRegionFromCode(identifier, &status) + guard let icuRegion, status.isSuccess else { + return nil + } + + guard let containing = uregion_getContainingRegionOfType(icuRegion, URGN_SUBCONTINENT) else { + return nil + } + + guard let code = String(validatingCString: uregion_getRegionCode(containing)) else { + return nil + } + + return Locale.Region(code) + } } @available(macOS 13, iOS 16, tvOS 16, watchOS 9, *) diff --git a/Tests/FoundationInternationalizationTests/LocaleRegionTests.swift b/Tests/FoundationInternationalizationTests/LocaleRegionTests.swift new file mode 100644 index 000000000..db475c916 --- /dev/null +++ b/Tests/FoundationInternationalizationTests/LocaleRegionTests.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Testing + +#if FOUNDATION_FRAMEWORK +import Foundation +#else +import FoundationEssentials +import FoundationInternationalization +#endif + +@Suite("Locale.Region Tests") +struct LocaleRegionTests { + @Test func regionCategory() async throws { + #expect(Locale.Region.unknown.category == nil) + #expect(Locale.Region.world.category == .world) + #expect(Locale.Region.unitedStates.category == .territory) + #expect(Locale.Region("EU").category == .grouping) + #expect(Locale.Region("not a region").category == nil) + + let africa = Locale.Region("002") + #expect(africa.category == .continent) + + let continentOfSpain = try #require(Locale.Region.spain.continent) + #expect(continentOfSpain.category == .continent) + } + + @Test func subcontinent() async throws { + #expect(Locale.Region.unknown.subcontinent == nil) + #expect(Locale.Region.world.subcontinent == nil) + #expect(Locale.Region("not a region").subcontinent == nil) + #expect(Locale.Region.argentina.subcontinent == Locale.Region("005")) + } + + @Test func subRegionOfCategory() async throws { + #expect(Locale.Region.unknown.subRegions(ofCategoy: .world) == []) + #expect(Locale.Region.unknown.subRegions(ofCategoy: .territory) == []) + + #expect(Set(Locale.Region.world.subRegions(ofCategoy: .continent)) == Set(Locale.Region.isoRegions(ofCategory: .continent))) + + #expect(Locale.Region.argentina.subRegions(ofCategoy: .continent) == []) + #expect(Locale.Region.argentina.subRegions(ofCategoy: .territory) == Locale.Region.argentina.subRegions) + + #expect(Locale.Region("not a region").subRegions(ofCategoy: .territory) == []) + } +}