Mark Lilback Mark Lilback - 1 month ago 18
Swift Question

strange string range behavior using NSRegularExpression matches

I'm trying to parse a raw HTTP response and I'm getting an incorrect range when trying to convert a NSRange to a Range. Here is the relevant code from a playground:

public extension NSRange {
public func toStringRange(_ str: String) -> Range<String.Index>? {
guard str.characters.count >= length - location && location < str.characters.count else { return nil }
let fromIdx = str.characters.index(str.startIndex, offsetBy: self.location)
print("from: \(self.location) = \(fromIdx)")
let toIdx = str.characters.index(fromIdx, offsetBy: self.length)
return fromIdx..<toIdx
}
}

let responseString = "HTTP/1.0 200 OK\r\nContent-Length: 193\r\nContent-Type: application/json\r\n"
let responseRange = NSRange(location: 0, length: responseString.characters.count)
let responseRegex = try! NSRegularExpression(pattern: "^(HTTP/1.\\d) (\\d+) (.*?\r\n)(.*)", options: [.anchorsMatchLines])
guard let matchResult = responseRegex.firstMatch(in: responseString, options: [], range: responseRange),
matchResult.numberOfRanges == 5,
let versionRange = matchResult.rangeAt(1).toStringRange(responseString),
let statusRange = matchResult.rangeAt(2).toStringRange(responseString),
let headersRange = matchResult.rangeAt(4).toStringRange(responseString)
else { fatalError() }


The output from the print in toStringRange() is

from: 0 = Index(_base: Swift.String.UnicodeScalarView.Index(_position: 0), _countUTF16: 1)
from: 9 = Index(_base: Swift.String.UnicodeScalarView.Index(_position: 9), _countUTF16: 1)
from: 17 = Index(_base: Swift.String.UnicodeScalarView.Index(_position: 18), _countUTF16: 1)


Why is the 3rd toStringRange() call returning a string range that starts at 18 instead of 17?

Answer

Your conversion method from NSRange to Range<String.Index> does not work correctly for extended grapheme clusters and characters outside of the "basic multilingual plane" (emojis, flags, etc).

NSRange counts UTF-16 code points (corresponding to the unichar representation in NSString). Range<String.Index> counts Swift Characters which represent extended grapheme clusters.

In your concrete case, "\r\n" counts as two UTF-16 code points, but as a single Character, and that causes the unwanted "shift".

Here is a simplified example:

let responseString = "OK\r\nContent-Length"

let nsRange = (responseString as NSString).range(of: "Content")
print(nsRange.location, nsRange.length) // 4 7

if let sRange1 = nsRange.toStringRange(responseString) {
    print(responseString.substring(with: sRange1)) // "ontent-"
}

Using the method

extension String {
    func range(from nsRange: NSRange) -> Range<String.Index>? {
        guard
            let from16 = utf16.index(utf16.startIndex, offsetBy: nsRange.location, limitedBy: utf16.endIndex),
            let to16 = utf16.index(from16, offsetBy: nsRange.length, limitedBy: utf16.endIndex),
            let from = String.Index(from16, within: self),
            let to = String.Index(to16, within: self)
            else { return nil }
        return from ..< to
    }
}

from NSRange to Range<String.Index> you'll get the expected result:

if let sRange2 = responseString.range(from: nsRange) {
    print(responseString.substring(with: sRange2)) // "Content"
}