NSRegularExpression on macOS: UTF-16 Ranges, Captures, and Literal Replacement
Use NSRegularExpression safely across Swift Unicode indices, optional captures, literal replacement templates, immutable snapshots, and bounded matching.
Foundation’s NSRegularExpression is useful when a macOS application needs ICU-compatible matching, capture ranges, or template replacement. Its most important integration boundary is not pattern syntax. It is the conversion between Foundation’s UTF-16 ranges and Swift’s string indices, followed by the lifetime of the exact text snapshot those ranges describe.
A search can appear correct on ASCII fixtures and fail as soon as an emoji precedes the match. Another can distinguish no match from a match but incorrectly treat a missing optional capture as an empty value. These failures are preventable when range units, capture participation, and replacement language are explicit parts of the interface.
Keep the coordinate system explicit
An NSRange passed to Foundation string matching uses UTF-16 code-unit coordinates. Swift String.count measures extended grapheme clusters instead. Neither is a byte length, and the counts are not interchangeable.
Build a search range from the actual Swift indices with NSRange(_:in:), and convert a returned range with Range(_:in:) before slicing. Keep both conversions attached to the same immutable string. A range has no inherent knowledge of which document revision produced it.
import Foundation
let text = "🧪 build-42 ready"
let expression = try NSRegularExpression(
pattern: #"[A-Za-z]+(?:-([0-9]+))?"#
)
let searchRange = NSRange(text.startIndex..<text.endIndex, in: text)
let matches = expression.matches(in: text, range: searchRange)
let tokens = matches.compactMap { match -> String? in
guard let range = Range(match.range, in: text) else { return nil }
return String(text[range])
}
precondition(tokens == ["build-42", "ready"])
precondition(searchRange.length == text.utf16.count)
precondition(text.count != text.utf16.count)
This pattern deliberately extracts ASCII token forms. It is not a general identifier grammar or a complete validator for the whole input. A production API should state whether it extracts candidates or accepts an entire record. A match somewhere in the string does not prove the surrounding text is valid.
For UTF-16 offsets received from another subsystem, reject an unconvertible range instead of forcing it into a Swift slice. That subsystem may describe a different revision, a range outside the current string, or a boundary unsuitable for the intended Swift operation. Do not silently clamp an invalid range and call the altered result a successful parse.
Capture absence is not an empty string
The overall match occupies range zero. Numbered capture groups begin at one. A group that did not participate has location NSNotFound; a participating group can legitimately match zero characters. These outcomes have different meanings in a configuration parser or form validator.
let first = matches[0]
let second = matches[1]
let firstCapture = first.range(at: 1)
let secondCapture = second.range(at: 1)
precondition(firstCapture.location != NSNotFound)
precondition(secondCapture.location == NSNotFound)
if let range = Range(firstCapture, in: text) {
precondition(String(text[range]) == "42")
}
Only request capture indices supported by the compiled expression. Keep the pattern and capture interpretation in one component instead of scattering group numbers across callers. If a future pattern introduces a capturing parenthesis, the meaning of later numbers can change even though compilation succeeds. Noncapturing groups are useful for structure that should not become part of the output schema.
Similarly, a zero-length overall match is still a match. Checking length > 0 is not a reliable existence test. Prefer the optional result from firstMatch, or test the not-found sentinel when using rangeOfFirstMatch. If building a custom repeated-search loop, specify how it advances after a zero-length match; searching the same empty boundary repeatedly can create an application-level infinite loop.
Pattern escaping and template escaping solve different problems
Literal text inserted into a pattern needs pattern escaping. Literal text inserted into a replacement template needs template escaping. Reusing one escape function for both contexts can turn a harmless dollar sign into capture expansion or turn user text into regex operators.
let needle = "a.b"
let replacement = "$1\\literal"
let literalSearch = try NSRegularExpression(
pattern: NSRegularExpression.escapedPattern(for: needle)
)
let input = "a.b aXb"
let inputRange = NSRange(input.startIndex..<input.endIndex, in: input)
let output = literalSearch.stringByReplacingMatches(
in: input,
range: inputRange,
withTemplate: NSRegularExpression.escapedTemplate(for: replacement)
)
precondition(output == "$1\\literal aXb")
For an intentional replacement language, document which captures the caller can reference and validate the configuration accordingly. For a plain replacement field, escape the template and preserve the literal value. These are different product features, not a cosmetic choice about backslashes.
Prefer the replacement method returning a new string when the operation does not require in-place mutation. When applying custom edits from previously collected ranges, changing an earlier region shifts later offsets. An editor must use a correct ordered-edit strategy or translate ranges through its document model. Reversing nonoverlapping edits can address offset shifting, but does not solve overlapping edits, stale snapshots, or concurrent mutation.
Share compiled expressions, not mutable input
Apple documents the expression object as immutable and thread safe. This allows reuse of a compiled expression across independent matching operations. It does not make the input string safe to mutate while matching is in progress.
Capture a stable input snapshot before dispatching work. Associate results with that snapshot or a document revision identifier, and reject stale results before applying highlights or replacements to an edited document. A main-thread UI callback receiving an old range should not apply it merely because the current string is long enough.
Compile configuration patterns at an explicit boundary and report thrown compilation errors without losing the original configuration context. Recompiling the same expression for every row adds avoidable work, while keeping a failed expression hidden behind an empty result makes an invalid pattern indistinguishable from a legitimate no-match outcome.
Bound work and interpret enumeration callbacks
enumerateMatches can stop after the application has enough matches. This is useful for previews and bounded result lists. With ordinary options, callbacks represent actual matches. Reporting progress or completion changes the callback protocol: a callback can have no match result, so code must handle that case rather than force unwrap it.
Stopping after ten matches does not establish a hard time limit for finding the first match. Pattern shape and input size still matter. Limit accepted input size, avoid unrestricted user-supplied patterns in latency-sensitive paths, and test near-miss inputs that force the engine to explore expensive alternatives. Progress reporting is not a substitute for a documented universal deadline or guaranteed immediate cancellation.
Keep compilation, matching, result conversion, and UI presentation timings separate. Otherwise a slow layout pass can be misdiagnosed as regex execution, or an expensive pattern can be hidden by measuring only result rendering. Log pattern identifiers and input sizes rather than sensitive matched content.
Validate the integration boundary
Use fixtures containing an emoji before a match, combining marks, empty input, an absent optional group, and a participating empty group. Add a literal search containing pattern metacharacters and a literal replacement containing dollars and backslashes. These test different contracts and should not collapse into one ASCII happy-path assertion.
Test malformed patterns as compilation failures, no-match inputs as ordinary results, and document edits as stale-result rejection. For extraction, verify both the extracted value and its original range. For replacement, verify unchanged surrounding text and the exact replacement bytes after encoding in the application’s output format.
The Swift examples above were executed with Foundation on macOS. Their role is to test range conversion and literal replacement, not to certify arbitrary patterns or all supported operating-system versions. Foundation matching becomes predictable when coordinate systems, immutable snapshots, capture absence, and replacement intent are treated as separate contracts.
Related:
- TextKit 2 on macOS: Viewport Layout, Text Ranges, and Fragment Rendering
- Uniform Type Identifiers on macOS: Document Types, Conformance, and Routing
Sources: