15

I want to write a function that could be used like this:

let πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦ = "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘§".replacingFirstOccurrence(of: "πŸ‘§", with: "πŸ‘¦")

Given how odd both this string and Swift's String library are, is this possible in Swift?

1
  • 1
    This would indeed be a bit tricky if it needs to work with all emoji, including sequences, while also retaining the invisible characters (zero-width joiners, variation selectors and all other special cases).
    – xoudini
    Commented Apr 25, 2017 at 19:41

2 Answers 2

11

Based on the insights gained at Why are emoji characters like πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦ treated so strangely in Swift strings?, a sensible approach might be to replace Unicode scalars:

extension String {
    func replacingFirstOccurrence(of target: UnicodeScalar, with replacement: UnicodeScalar) -> String {

        let uc = self.unicodeScalars
        guard let idx = uc.index(of: target) else { return self }
        let prefix = uc[uc.startIndex..<idx]
        let suffix = uc[uc.index(after: idx) ..< uc.endIndex]
        return "\(prefix)\(replacement)\(suffix)"
    }
}

Example:

let family1 = "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦"
print(family1.characters.map { Array(String($0).unicodeScalars) })
// [["\u{0001F469}", "\u{200D}"], ["\u{0001F469}", "\u{200D}"], ["\u{0001F467}", "\u{200D}"], ["\u{0001F466}"]]

let family2 = family1.replacingFirstOccurrence(of: "πŸ‘§", with: "πŸ‘¦")
print(family2) // πŸ‘©β€πŸ‘©β€πŸ‘¦β€πŸ‘¦
print(family2.characters.map { Array(String($0).unicodeScalars) })
// [["\u{0001F469}", "\u{200D}"], ["\u{0001F469}", "\u{200D}"], ["\u{0001F466}", "\u{200D}"], ["\u{0001F466}"]]

And here is a possible version which locates and replaces the Unicode scalars of an arbitrary string:

extension String {
    func replacingFirstOccurrence(of target: String, with replacement: String) -> String {
        let uc = self.unicodeScalars
        let tuc = target.unicodeScalars

        // Target empty or too long:
        if tuc.count == 0 || tuc.count > uc.count {
            return self
        }

        // Current search position:
        var pos = uc.startIndex
        // Last possible position of `tuc` within `uc`:
        let end = uc.index(uc.endIndex, offsetBy: tuc.count - 1)

        // Locate first Unicode scalar
        while let from = uc[pos..<end].index(of: tuc.first!) {
            // Compare all Unicode scalars:
            let to = uc.index(from, offsetBy: tuc.count)
            if !zip(uc[from..<to], tuc).contains(where: { $0 != $1 }) {
                let prefix = uc[uc.startIndex..<from]
                let suffix = uc[to ..< uc.endIndex]
                return "\(prefix)\(replacement)\(suffix)"
            }
            // Next search position:
            uc.formIndex(after: &pos)
        }

        // Target not found.
        return self
    }
}
11
  • Martin but why playground print UnicodeScalarView same after you apply replacing ?
    – Oleg Gordiichuk
    Commented Apr 25, 2017 at 20:28
  • @OlegGordiichuk: I inadvertently printed family1 instead of family2, thanks for letting me know.
    – Martin R
    Commented Apr 25, 2017 at 20:30
  • How about this let b = "πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘§".characters.map{String($0) == "πŸ‘§" ? "πŸ‘¦" : $0}
    – Oleg Gordiichuk
    Commented Apr 25, 2017 at 20:30
  • @OlegGordiichuk: That would replace all occurrences, not just the first (if it worked)
    – Martin R
    Commented Apr 25, 2017 at 20:31
  • 4
    @OlegGordiichuk: Have a look at OP's previous question stackoverflow.com/questions/43618487/…, which is exactly about that problem.
    – Martin R
    Commented Apr 25, 2017 at 20:34
7

Using the range(of:options:range:locale:) the solution became quite concise:

extension String {
    func replaceFirstOccurrence(of searchString: String, with replacementString: String) -> String {
        guard let range = self.range(of: searchString, options: .literal) else { return self }
        return self.replacingCharacters(in: range, with: replacementString)
    }
}

This works by first finding the range of searchString within the instance, and if a range is found the range is replaced with replacementString. Otherwise the instance just returns itself. And, since the range(of:) method returns as soon as it finds a match, the returned range is guaranteed to be the first occurrence.

"221".replaceFirstOccurrence(of: "2", with: "3")                // 321
"πŸ‘©β€πŸ‘©β€πŸ‘§β€πŸ‘¦".replaceFirstOccurrence(of: "\u{1f469}", with: "\u{1f468}") // πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦

*To clarify, the last test case converts woman-woman-girl-boy to man-woman-girl-boy.

6
  • 2
    That is indeed elegant and far easier, using the .literal option.
    – Martin R
    Commented Apr 28, 2017 at 3:48
  • 2
    .literal is documented as "Exact character-by-character equivalence", but apparently "character" does not mean a Swift Character in this context. My guess would be that it actually means "exact Unicode scalar equivalence" or "exact UTF-16 equivalence" (since .literal is defined in NSString.CompareOptions and NSString is based on unichar).
    – Martin R
    Commented Apr 28, 2017 at 5:26
  • @MartinR I think you're correct, but since you can't search with malformed UTF-16 in Swift, it essentially does mean unicode scalar equivalence.
    – xoudini
    Commented Apr 28, 2017 at 5:44
  • Indeed, it makes no difference. – Perhaps that information about .literal would be a useful addition to your other answer stackoverflow.com/a/43619065/1187415 ?
    – Martin R
    Commented Apr 28, 2017 at 6:46
  • 1
    This also passes all my tests, and feels much more at-home in my library. Thanks!
    – Ky -
    Commented May 1, 2017 at 19:04

Not the answer you're looking for? Browse other questions tagged or ask your own question.