txt-find-replace — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited txt-find-replace (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill covers find/replace integration into text editors on Apple platforms — the UIFindInteraction plumbing on iOS, NSTextFinder on macOS, and the rendering paths that highlight matches without leaving traces in storage or undo. Before relying on a specific protocol method signature, fetch via Sosumi (sosumi.ai/documentation/uikit/uitextsearching) — UITextSearching has gained methods across iOS releases and old recipes drift.
A common bug class here is highlighting through textStorage.addAttribute, which creates undo entries and re-triggers layout. The right surface is temporary attributes on TextKit 1 or rendering attributes on TextKit 2. Both are rendering-only and bypass the storage edit cycle.
UITextView ships with full find support; flipping a flag exposes the system find bar.
textView.isFindInteractionEnabled = true
// Present from a button or keyboard shortcut
textView.findInteraction?.presentFindNavigator(showingReplace: false)
textView.findInteraction?.presentFindNavigator(showingReplace: true) // includes ReplaceThe find bar itself is system-rendered; you don't draw it. Search runs against the text view's own content and highlights are drawn by the system. If the find bar appears but doesn't navigate, it usually means the view is not first responder or is offscreen — the bar attaches to the view it's bound to and stops driving when that view loses keyboard focus.
To present search-and-replace by default, use the showingReplace: parameter; setting it later requires re-presenting.
UITextSearching is the protocol that drives a UIFindInteraction against a non-UITextView view. The interaction calls into your view to perform searches, decorate matches, and apply replacements.
final class CustomEditor: UIView, UITextSearching {
var supportsTextReplacement: Bool { true }
func performTextSearch(queryString: String,
options: UITextSearchOptions,
resultAggregator: any UITextSearchAggregator) {
let text = contentString
var idx = text.startIndex
let opts = stringSearchOptions(from: options)
while let range = text.range(of: queryString, options: opts, range: idx..<text.endIndex) {
resultAggregator.foundRange(convert(range), searchString: queryString, document: nil)
idx = range.upperBound
}
resultAggregator.finishedSearching()
}
func decorate(foundTextRange: UITextRange,
document: UITextSearchDocumentIdentifier?,
usingStyle style: UITextSearchFoundTextStyle) {
switch style {
case .found: addHighlight(foundTextRange, color: .yellow.withAlphaComponent(0.3))
case .highlighted: addHighlight(foundTextRange, color: .systemYellow)
case .normal: removeHighlight(foundTextRange)
@unknown default: break
}
}
func clearAllDecoratedFoundText() { removeAllHighlights() }
func replace(foundTextRange: UITextRange,
document: UITextSearchDocumentIdentifier?,
withText replacementText: String) {
applyReplacement(in: foundTextRange, with: replacementText)
}
func replaceAll(queryString: String,
options: UITextSearchOptions,
withText replacementText: String) {
let ranges = findAllRanges(of: queryString, options: options)
for range in ranges.reversed() {
applyReplacement(in: range, with: replacementText)
}
}
}Three points are easy to get wrong. The aggregator must receive finishedSearching() — without it the find bar shows a spinner and never lands on the first match. The decoration styles include a .normal case for "remove decoration"; conflating it with "no decoration" leaves stale highlights. And replaceAll must walk the matches in reverse so earlier ranges aren't shifted out from under later ones.
To install the interaction:
lazy var findInteraction = UIFindInteraction(sessionDelegate: self)
override init(frame: CGRect) {
super.init(frame: frame)
addInteraction(findInteraction)
}
extension CustomEditor: UIFindInteractionDelegate {
func findInteraction(_ interaction: UIFindInteraction,
sessionFor view: UIView) -> UIFindSession? {
UITextSearchingFindSession(searchableObject: self)
}
}NSTextFinder is the macOS analog. NSTextView adopts NSTextFinderClient already; toggling usesFindBar on the text view enables the find bar without further work.
For non-NSTextView clients, adopt NSTextFinderClient and supply a finder:
final class EditorView: NSView, NSTextFinderClient {
let textFinder = NSTextFinder()
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
textFinder.client = self
textFinder.findBarContainer = enclosingScrollView
textFinder.isIncrementalSearchingEnabled = true
}
var string: String { textStorage.string }
var isEditable: Bool { true }
func stringLength() -> Int { (string as NSString).length }
func string(at characterIndex: Int,
effectiveRange: NSRangePointer,
endsWithSearchBoundary: UnsafeMutablePointer<ObjCBool>) -> String {
effectiveRange.pointee = NSRange(location: 0, length: stringLength())
endsWithSearchBoundary.pointee = true
return string
}
func replaceCharacters(in range: NSRange, with string: String) {
textStorage.replaceCharacters(in: range, with: string)
}
}The endsWithSearchBoundary flag is how NSTextFinder chunks the document. Returning the entire string in one call is fine for small documents; for streamed or paginated content, return the chunk that contains the requested character index.
Highlights drawn through textStorage.addAttribute(.backgroundColor, …) create undo entries, run through processEditing, and persist if the document is saved. Use the rendering-only paths instead.
TextKit 1 — temporary attributes on the layout manager:
let full = NSRange(location: 0, length: textStorage.length)
layoutManager.removeTemporaryAttribute(.backgroundColor, forCharacterRange: full)
for r in matches {
layoutManager.addTemporaryAttribute(.backgroundColor,
value: UIColor.systemYellow.withAlphaComponent(0.3),
forCharacterRange: r)
}TextKit 2 — rendering attributes on the layout manager. These attach to layout fragments, not character ranges in storage; that's why "TK2 rendering attribute applied as character attribute" is a recurring no-op bug.
textLayoutManager.removeRenderingAttribute(.backgroundColor,
for: textLayoutManager.documentRange)
for r in textRanges {
textLayoutManager.addRenderingAttribute(.backgroundColor,
value: UIColor.systemYellow.withAlphaComponent(0.3),
for: r)
}For very long documents with thousands of matches, applying attributes for every match up front costs memory and slows the render pass. Restrict highlights to ranges near the viewport and refresh on scroll:
func highlightVisible(near viewportRange: NSTextRange) {
let extended = extend(viewportRange, by: 2000) // overdraw window
for match in allMatches where extended.contains(match) {
textLayoutManager.addRenderingAttribute(.backgroundColor,
value: highlightColor,
for: match)
}
}The single most common replace-all bug: iterating matches forward. Each replacement shifts subsequent character offsets, and the loop reads stale ranges. Iterate in reverse and wrap the whole batch in a single editing transaction so processEditing fires once:
let matches = findAllRanges(of: query)
textStorage.beginEditing()
for r in matches.reversed() {
textStorage.replaceCharacters(in: r, with: replacement)
}
textStorage.endEditing()For TextKit 2 with NSTextContentStorage, wrap in performEditingTransaction instead of bare beginEditing/endEditing. The replacement still happens through textStorage.replaceCharacters, but the content manager observes the change and updates its element cache once.
Undo grouping happens automatically when the storage edits are inside one transaction. To force "Replace All" to be a single undo, also wrap in undoManager.beginUndoGrouping/endUndoGrouping. See txt-undo.
UITextSearchOptions carries wordMatch and stringCompareOptions (case-insensitive, diacritic-insensitive). It does not declare regex mode — if your editor exposes regex search, route to NSRegularExpression directly rather than relying on string compare options:
func stringSearchOptions(from o: UITextSearchOptions) -> String.CompareOptions {
var s: String.CompareOptions = []
if o.stringCompareOptions.contains(.caseInsensitive) { s.insert(.caseInsensitive) }
if o.stringCompareOptions.contains(.diacriticInsensitive) { s.insert(.diacriticInsensitive) }
return s
}Word match (wordMatch) requires boundary checking that String.range(of:options:) doesn't do natively. Either wrap matches with \b regex when in word-match mode or post-filter for boundaries.
addRenderingAttribute(_:value:for:) on the layout manager.matches.reversed().UIFindInteraction is bound to a view that has lost first-responder status, or is offscreen. The interaction stops driving when its view detaches from the responder chain.String.CompareOptions doesn't have a word-boundary mode. Either use a \b...\b regex or post-filter results for non-word characters at the boundaries.endsWithSearchBoundary = true.txt-selection-menus — selection rect customization, edit menu integrationtxt-uitextinput — when find runs against a custom view that implements UITextInputtxt-undo — single-undo grouping for Replace Alltxt-viewport-rendering — rendering attributes and viewport-aware highlight strategies~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.