import Foundation /// Negotiates the best supported language for a request from its `Accept-Language` header. /// /// Bound to a bundle's catalog languages via ``LanguageList``, an instance is invoked like a /// function — through ``callAsFunction(acceptLanguage:)`` — to resolve a header value to a /// supported language identifier, falling back to the default language. public struct Negotiate: Sendable { // MARK: Properties /// The supported languages and default language, derived from the bundle's String Catalog. private let list: LanguageList // MARK: Initializers /// Creates a language negotiator backed by the given bundle's String Catalog. /// - Parameter bundle: the bundle whose String Catalog defines the supported languages. public init( bundle: Bundle ) { self.list = .init(bundle: bundle) } // MARK: Methods /// Picks the best supported language for the given `Accept-Language` header value. /// /// Invoked by calling the instance directly, for example `negotiate(acceptLanguage: header)`. /// The header is split into its language tags (dropping any `q` weights), then matched against /// the supported languages with `Bundle.preferredLocalizations(from:forPreferences:)`. When the /// header is absent or matches nothing, the default language is returned. /// - Parameter acceptLanguage: the raw `Accept-Language` header value, if any. /// - Returns: the identifier of the supported language to serve. public func callAsFunction( acceptLanguage language: String? ) -> String { guard let language else { return list.default } let tags = tags(from: language) guard !tags.isEmpty else { return list.default } let preferred = Bundle.preferredLocalizations( from: list.all, forPreferences: tags ) return preferred.first ?? list.default } } // MARK: - Helpers private extension Negotiate { // MARK: Methods /// Extracts the ordered language tags from an `Accept-Language` header value. /// /// Each comma-separated entry is reduced to its language tag by dropping the `;q=` weight, and /// blank entries are removed. The original order is kept, which mirrors descending preference /// closely enough for `preferredLocalizations(from:forPreferences:)` to resolve correctly. /// - Parameter acceptLanguage: the raw `Accept-Language` header value. /// - Returns: the ordered, weight-stripped language tags. func tags( from acceptLanguage: String ) -> [String] { acceptLanguage .split(separator: .Separator.comma) .map { entry in entry .split(separator: .Separator.semicolon) .first .map(String.init)? .trimmingCharacters(in: .whitespaces) ?? .empty } .filter { !$0.isEmpty } } } // MARK: - Constants private extension Character { enum Separator { static let comma: Character = "," static let semicolon: Character = ";" } } private extension String { static let empty: String = "" }