From 48a7e610318e3171a8c01d9d50958d522f0d7750 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 11:04:52 +0000 Subject: [PATCH] fix(macOS): unify find/replace match scanning with highlight logic Find highlights used query.lowercased() while Replace All and next-match used the raw query, both with .caseInsensitive. That can diverge under locale-sensitive folding so highlighted spans could disagree with what Replace / Replace All actually edits. Route all three through one helper and add unit tests for the range collector. Co-authored-by: Danny Peck --- macOS/SynapseNotes/EditorView.swift | 47 +++++++++---------- ...inkAwareTextViewFindMatchRangesTests.swift | 34 ++++++++++++++ 2 files changed, 57 insertions(+), 24 deletions(-) create mode 100644 macOS/SynapseNotesTests/LinkAwareTextViewFindMatchRangesTests.swift diff --git a/macOS/SynapseNotes/EditorView.swift b/macOS/SynapseNotes/EditorView.swift index 7617eec..81633ba 100644 --- a/macOS/SynapseNotes/EditorView.swift +++ b/macOS/SynapseNotes/EditorView.swift @@ -2655,21 +2655,33 @@ class LinkAwareTextView: NSTextView { } } - private func applySearchHighlights(query: String, focusIndex: Int) { - guard let storage = textStorage, !query.isEmpty else { - clearSearchHighlights() - return - } - let content = storage.string - let needle = query.lowercased() + /// Non-overlapping UTF-16 `NSRange`s of `query` in `content` using a single case-folding + /// strategy (`.caseInsensitive`). Shared by find highlights, Replace All, and next-match so + /// the UI never disagrees with what replace operations will touch. + internal static func caseInsensitiveNonOverlappingMatchRanges( + in content: String, + query: String, + maxCount: Int? = nil + ) -> [NSRange] { + guard !query.isEmpty else { return [] } var matches: [NSRange] = [] var searchStart = content.startIndex while searchStart < content.endIndex, - let range = content.range(of: needle, options: .caseInsensitive, range: searchStart.. 2000 { break } + if let maxCount, matches.count >= maxCount { break } } + return matches + } + + private func applySearchHighlights(query: String, focusIndex: Int) { + guard let storage = textStorage, !query.isEmpty else { + clearSearchHighlights() + return + } + let content = storage.string + let matches = Self.caseInsensitiveNonOverlappingMatchRanges(in: content, query: query, maxCount: 2000) let dimHighlight = NSColor.yellow.withAlphaComponent(0.30) let focusHighlight = NSColor.yellow @@ -2740,13 +2752,7 @@ class LinkAwareTextView: NSTextView { private func replaceAllMatches(query: String, replacement: String) { guard !query.isEmpty, let storage = textStorage else { return } let content = storage.string - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let r = content.range(of: query, options: .caseInsensitive, range: searchStart.. Int { guard let storage = textStorage else { return 0 } let content = storage.string - var matches: [NSRange] = [] - var searchStart = content.startIndex - while searchStart < content.endIndex, - let r = content.range(of: query, options: .caseInsensitive, range: searchStart.. 2000 { break } - } + let matches = Self.caseInsensitiveNonOverlappingMatchRanges(in: content, query: query, maxCount: 2000) if matches.isEmpty { return 0 } if let idx = matches.firstIndex(where: { $0.location >= location }) { return idx diff --git a/macOS/SynapseNotesTests/LinkAwareTextViewFindMatchRangesTests.swift b/macOS/SynapseNotesTests/LinkAwareTextViewFindMatchRangesTests.swift new file mode 100644 index 0000000..864d642 --- /dev/null +++ b/macOS/SynapseNotesTests/LinkAwareTextViewFindMatchRangesTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import Synapse + +/// `LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges` must stay aligned with find +/// highlights and replace-all so ranges never disagree for the same query and body text. +final class LinkAwareTextViewFindMatchRangesTests: XCTestCase { + + func test_emptyQuery_returnsNoRanges() { + let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges( + in: "hello world", + query: "", + maxCount: nil + ) + XCTAssertTrue(ranges.isEmpty) + } + + func test_caseInsensitive_findsAllNonOverlapping() { + let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges( + in: "Aa aa AA", + query: "aA", + maxCount: nil + ) + XCTAssertEqual(ranges.count, 3) + } + + func test_maxCount_stopsAfterFirst() { + let ranges = LinkAwareTextView.caseInsensitiveNonOverlappingMatchRanges( + in: "foo Foo FOO", + query: "foo", + maxCount: 1 + ) + XCTAssertEqual(ranges.count, 1) + } +}