import Foundation extension String { /// "player" -> "Player" var uppercasingFirstCharacter: String { guard let first else { return self } return String(first).uppercased() + dropFirst() } /// "player" -> "players" /// "players" -> "players" var pluralized: String { Inflections.default.pluralize(self) } /// "player" -> "player" /// "players" -> "player" var singularized: String { Inflections.default.singularize(self) } /// "bar" -> "bar" /// "foo12" -> "foo" var digitlessRadical: String { String(prefix(upTo: Inflections.endIndexOfDigitlessRadical(self))) } } /// A type that controls GRDB string inflections. /// /// - note: [**🔥 EXPERIMENTAL**](https://github.com/groue/GRDB.swift/blob/master/README.md#what-are-experimental-features) public struct Inflections: Sendable { private var pluralizeRules: [(NSRegularExpression, String)] = [] private var singularizeRules: [(NSRegularExpression, String)] = [] private var uncountablesRegularExpressions: [String: NSRegularExpression] = [:] // For testability var uncountables: Set { Set(uncountablesRegularExpressions.keys) } // MARK: - Initialization public init() { } // MARK: - Configuration /// Appends a pluralization rule. /// /// var inflections = Inflections() /// inflections.plural("$", "s") /// inflections.pluralize("player") // "players" /// /// - parameters: /// - pattern: A regular expression pattern. /// - options: Regular expression options (defaults to /// `[.caseInsensitive]`). /// - template: A replacement template string. public mutating func plural( _ pattern: String, options: NSRegularExpression.Options = [.caseInsensitive], _ template: String) { let reg = try! NSRegularExpression(pattern: pattern, options: options) pluralizeRules.append((reg, template)) } /// Appends a singularization rule. /// /// var inflections = Inflections() /// inflections.singular("s$", "") /// inflections.singularize("players") // "player" /// /// - parameters: /// - pattern: A regular expression pattern. /// - options: Regular expression options (defaults to /// `[.caseInsensitive]`). /// - template: A replacement template string. public mutating func singular( _ pattern: String, options: NSRegularExpression.Options = [.caseInsensitive], _ template: String) { let reg = try! NSRegularExpression(pattern: pattern, options: options) singularizeRules.append((reg, template)) } /// Appends uncountable words. /// /// var inflections = Inflections() /// inflections.plural("$", "s") /// inflections.uncountableWords(["foo"]) /// inflections.pluralize("foo") // "foo" /// inflections.pluralize("bar") // "bars" public mutating func uncountableWords(_ words: [String]) { for word in words { uncountableWord(word) } } /// Appends an irregular singular/plural pair. /// /// var inflections = Inflections() /// inflections.plural("$", "s") /// inflections.irregularSuffix("man", "men") /// inflections.pluralize("man") // "men" /// inflections.singularizes("women") // "woman" /// /// - parameters: /// - singular: The singular form. /// - plural: The plural form. public mutating func irregularSuffix(_ singular: String, _ plural: String) { let s0 = singular.first! let srest = singular.dropFirst() let p0 = plural.first! let prest = plural.dropFirst() if s0.uppercased() == p0.uppercased() { self.plural("(\(s0))\(srest)$", options: [.caseInsensitive], "$1\(prest)") self.plural("(\(p0))\(prest)$", options: [.caseInsensitive], "$1\(prest)") self.singular("(\(s0))\(srest)$", options: [.caseInsensitive], "$1\(srest)") self.singular("(\(p0))\(prest)$", options: [.caseInsensitive], "$1\(srest)") } else { self.plural("\(s0.uppercased())(?i)\(srest)$", options: [], p0.uppercased() + prest) self.plural("\(s0.lowercased())(?i)\(srest)$", options: [], p0.lowercased() + prest) self.plural("\(p0.uppercased())(?i)\(prest)$", options: [], p0.uppercased() + prest) self.plural("\(p0.lowercased())(?i)\(prest)$", options: [], p0.lowercased() + prest) self.singular("\(s0.uppercased())(?i)\(srest)$", options: [], s0.uppercased() + srest) self.singular("\(s0.lowercased())(?i)\(srest)$", options: [], s0.lowercased() + srest) self.singular("\(p0.uppercased())(?i)\(prest)$", options: [], s0.uppercased() + srest) self.singular("\(p0.lowercased())(?i)\(prest)$", options: [], s0.lowercased() + srest) } } // MARK: - Inflections /// Returns a pluralized string. /// /// Inflections.default.pluralize("player") // "players" public func pluralize(_ string: String) -> String { inflectString(string, with: pluralizeRules) } /// Returns a singularized string. public func singularize(_ string: String) -> String { inflectString(string, with: singularizeRules) } // MARK: - Utils /// Appends an uncountable word. /// /// var inflections = Inflections() /// inflections.plural("$", "s") /// inflections.uncountableWord("foo") /// inflections.pluralize("foo") // "foo" /// inflections.pluralize("bar") // "bars" private mutating func uncountableWord(_ word: String) { let escWord = NSRegularExpression.escapedPattern(for: word) uncountablesRegularExpressions[word] = try! NSRegularExpression( pattern: "\\b\(escWord)\\Z", options: [.caseInsensitive]) } private func isUncountable(_ string: String) -> Bool { let range = NSRange(location: 0, length: string.utf16.count) for (_, reg) in uncountablesRegularExpressions { if reg.firstMatch(in: string, options: [], range: range) != nil { return true } } return false } private func inflectString(_ string: String, with rules: [(NSRegularExpression, String)]) -> String { let indexOfLastWord = Inflections.startIndexOfLastWord(string) let endIndexOfDigitlessRadical = Inflections.endIndexOfDigitlessRadical(string) let lastWord = String(string[indexOfLastWord.. String { if string.isEmpty { return string } let range = NSRange(string.startIndex.. 0 { return String(result) } } return string } /// startIndexOfLastWord("foo") -> "foo" /// startIndexOfLastWord("foo bar") -> "bar" /// startIndexOfLastWord("foo_bar") -> "bar" /// startIndexOfLastWord("fooBar") -> "Bar" static func startIndexOfLastWord(_ string: String) -> String.Index { let range = NSRange(string.startIndex.. "bar" /// "foo12" -> "foo" static func endIndexOfDigitlessRadical(_ string: String) -> String.Index { let digits: ClosedRange = "0"..."9" return string // "foo12" .reversed() // "21oof" .prefix(while: { digits.contains($0) }) // "21" .endIndex // reversed(foo^12) .base // foo^12 } private static let wordBoundaryReg = try! NSRegularExpression(pattern: "\\b\\w+$", options: []) private static let underscoreBoundaryReg = try! NSRegularExpression(pattern: "_[^_]+$", options: []) private static let caseBoundaryReg = try! NSRegularExpression(pattern: "[^A-Z][A-Z]+[a-z1-9]+$", options: []) }