Test iOS Unicode highlight boundaries on a cloud Mac
Names in a search results list highlight correctly in English, but the highlight shifts when the text contains diacritics or emoji with skin-tone modifiers. The problem usually is not the font—it is the offset contract. The server supplies byte offsets, the UI expects UTF-16 offsets, and the business logic slices strings using Swift character counts. Running a small set of plain-text checks on a cloud Mac makes the issue easier to isolate than repeatedly clicking through a remote desktop.
This approach works for search snippets, rich-text annotations, and selections in chat content. It requires only the existing Xcode command-line tools, with no online service needed. On a dedicated physical node from XcNode, keep the test inputs alongside the toolchain details as well.
Define the offset unit first
Swift’s String.count counts extended grapheme clusters, represented by Character; utf16.count counts UTF-16 code units; and utf8.count counts bytes. These are not interchangeable. An emoji may combine a base symbol, a skin-tone modifier, and a zero-width joiner. It looks like a single character but contains multiple code units.
Foundation’s NSRange generally uses UTF-16 units in string APIs. Do not use text.count as the upper limit for a valid range, and do not advance a String.Index directly with an integer. At a minimum, the API contract should specify the unit, the version of the original text, the policy for empty ranges, and how a missing match is represented.
A successful conversion only means that the indices can be constructed. It does not guarantee that both ends of the range fall on boundaries of complete user-visible characters. Displaying a highlight requires a second layer of validation.
Any change to the original text should invalidate its old ranges. Do not use old search results to annotate a new string after replacements, case conversion, or deletions.
Reject partial characters with a small script
Save the following as highlight-check.swift. The validator first handles missing and out-of-bounds ranges, then converts the indices, and finally checks that both ends lie on Character boundaries. Checking the length with subtraction also avoids the integer overflow that could occur if the start position and length were added first.
import Foundation
func highlightRange(
_ value: NSRange,
in text: String
) -> Range<String.Index>? {
let total = text.utf16.count
guard value.location != NSNotFound,
value.location >= 0,
value.length >= 0,
value.location <= total,
value.length <= total - value.location,
let range = Range(value, in: text) else {
return nil
}
var boundaries = Set(text.indices)
boundaries.insert(text.endIndex)
guard boundaries.contains(range.lowerBound),
boundaries.contains(range.upperBound) else {
return nil
}
return range
}
let text = "A👩🏽💻e\u{301}Z"
let emoji = (text as NSString).range(of: "👩🏽💻")
precondition(highlightRange(emoji, in: text) != nil)
precondition(highlightRange(NSRange(location: 1, length: 1),
in: text) == nil)
let accent = "e\u{301}"
precondition(highlightRange(NSRange(location: 0, length: 1),
in: accent) == nil)
precondition(highlightRange(NSRange(location: 0, length: 2),
in: accent) != nil)
precondition(highlightRange(NSRange(location: NSNotFound, length: 0),
in: text) == nil)
precondition(highlightRange(NSRange(location: text.utf16.count,
length: 1),
in: text) == nil)
Run xcrun swift highlight-check.swift. A zero exit code means these assertions passed; a failure terminates execution. Do not use optimization options that disable checks. When you move this into a proper test target, replace the assertions with test assertions that include a case identifier so that failing inputs are easy to locate.
This validator allows empty ranges at character boundaries because an editor may need them for insertion points. Callers that only highlight search results should separately reject zero-length matches to prevent a search loop from getting stuck without advancing.
Keep search coordinates in the original text
A common mistake is to create a simplified copy to ignore diacritics, then apply that copy’s offsets to the original text. For example, a precomposed accented character and a letter followed by a combining mark can represent the same text without necessarily having the same UTF-16 length.
If the product needs case-insensitive or diacritic-insensitive matching, search the original text directly so that the returned range still belongs to the original string:
let source = "Cafe\u{301}"
let match = source.range(
of: "cafe",
options: [.caseInsensitive, .diacriticInsensitive],
locale: Locale(identifier: "en_US_POSIX")
)
guard let match else {
fatalError("Expected a search match")
}
let nsRange = NSRange(match, in: source)
precondition(highlightRange(nsRange, in: source) != nil)
precondition(String(source[match]) == source)
The fixed locale makes this sample reproducible; it is not a universal answer for searching every language. Whether name searches should ignore diacritics, and how Turkish case handling should work, are product decisions that need separate test cases. If tokenization or transliteration is required, maintain an explicit mapping to the original text rather than guessing positions from length differences.
Turn failing inputs into regression gates
Cover at least ordinary Chinese characters, surrogate pairs, combining marks, skin-tone emoji, joined emoji sequences, and flag sequences. For each category, check both a complete match and a range that cuts through the character. Then add empty ranges at the beginning and end, ranges extending past the end, and missing results.
Round-trip every valid range: after converting it to a Swift range, convert it back with NSRange(range, in: text) and require an identical result. Also assert the actual selected text so that a valid range pointing to the wrong location does not slip through.
For long text, avoid scanning every boundary again for each match. Build the boundary set once for the same immutable source text and share it across matches; rebuild it when the text changes. Do not reuse the cache across different strings.
Use UI acceptance checks only for rendering evidence
Once the plain-text gates pass, open the fixed search samples in the simulator. Check that highlights do not split combining marks from their base characters, that emoji sequences are not partially colored, and that highlights still align with the original text after line wrapping. Visual checks address rendering issues; they do not replace range assertions.
When an asynchronous search returns, first verify the version of the original text associated with the request, then apply the highlights. Discard old results if the input field has changed. Also distinguish the search range from the final display range: if the display layer must expand a range to cover complete characters, preserve the original match for business logic and use the expanded range only for drawing.
The final acceptance criterion is not “it does not crash.” It is explicit units, valid ranges, complete character boundaries, and the correct matched text. Add real failing strings to the fixed test cases so that the next search algorithm change immediately reveals which behaviors have changed.
Frequently asked questions
Does a successful NSRange conversion make a highlight safe?
No. Check that both endpoints are Character boundaries using text.indices and endIndex, not just that Range conversion succeeds.
Can offsets from an accent-stripped copy be reused?
Not directly. Transformations can change UTF-16 length. Search the original string with the agreed options or maintain an explicit index mapping.
Choose a Dedicated Cloud Mac for Your Development Work
Apple Silicon physical machines, not virtual machines. Rent by the day, week, month, or quarter. Models and prices are published transparently; check real-time availability in the console.