PR #333 — rendered search highlights now honour the native diacritic-insensitive comparison contract. prism-search.js folds block text (NFD, strip \p{M}, lowercase) with a folded→source offset map so Custom Highlight ranges stay valid despite length changes; a live-harness parity test locks in both count and range content.
findOffsets → findMatchRanges: matching now runs over folded text with a per-UTF-16-unit map back to source offsets, so highlight ranges cover the accented original even though folding changes lengths.map: null identity) restores the single-toLowerCase() path for the common unaccented case — no per-code-point loop unless the text actually contains non-ASCII.searchHighlightTexts lets the suite verify the ranges land on résumé/café, catching offset-shift regressions a count check would miss.WebSearchParityTests 5/5, WebSearchBridgeTests+WebSearchWiringTests+SearchServiceTests+SearchCoordinatorTests 97/97, make lint clean.Needs fixes
The production fix is correct — empirical probes of the fold/offset-map logic pass every constructed edge case (precomposed and decomposed source, surrogate pairs, Turkish İ, trailing combining marks, accented queries in both directions), and 102 targeted tests plus lint are green. One must-fix remains: the regression test's fixtures are all NFC (no combining marks), so every tested mapping is identity-valued — the length-shifting branch of the offset map (map[foldedEnd] absorption of stripped marks in decomposed source), which is the entire point of this fix, is not pinned by any test. Add one NFD fixture case before pushing. A comment-accuracy fix (ß/ligature ICU case-fold gap wrongly claimed as shared with native) was applied editorially in the working tree.
e65e383 Fix T-1704: Rendered search highlights miss diacritic-insensitive matches 4c90007 Address PR #333 review: ASCII fast path, range-content parity assert, edge-case doc working-tree Editorial fix applied in this review: fold-limitations comment corrected (ß/ligature class not shared with native) Prism's search has two halves. The native (Swift) half counts how many times your search term appears in the document — and it is generous about accents: searching for resume also finds résumé. The web half, which paints the yellow highlights on screen, was stricter: it only ignored upper/lower case, not accents. So the counter would say “1 match” but nothing got highlighted, and jumping to the match had nowhere to go.
This change teaches the highlighting code the same accent-ignoring rules the counter uses.
Any document with accented text — French, Spanish, German, Vietnamese names, café menus — could show phantom match counts with missing highlights. Now what is counted is what is highlighted.
e and é as the same letter.Two files: prism/Resources/WebRenderer/prism-search.js (the in-page highlight painter) and prismTests/WebRendering/WebSearchParityTests.swift (live-harness parity suite).
Native counting uses String.CompareOptions [.caseInsensitive, .diacriticInsensitive] (SearchService.searchCompareOptions). The JS re-finding pass previously only lowercased, deliberately — diacritic folding shifts UTF-16 offsets, and the CSS Custom Highlight ranges are offset-based. The fix embraces the shift instead of avoiding it:
foldWithMap(text) walks the text per code point, folding each (NFD normalize → strip \p{M} → lowercase) and pushing the code point's source offset once per folded UTF-16 unit. Result: folded string + parallel offset map.findMatchRanges runs indexOf over folded haystack/query and maps each hit back: start = map[index], end = map[foldedEnd] or haystack.length at the tail. Trailing combining marks fold to nothing, so the next mapped offset absorbs them — résumé in decomposed form yields an 8-unit range for a 6-unit query.map: null (identity), keeping the old single-toLowerCase() cost for unaccented text.renderHighlights consumes {start, end} ranges instead of assuming query.length.The map is keyed by folded UTF-16 unit, valued by source code-point start offset. Correctness hinges on three boundary behaviours, all verified empirically: (1) surrogate pairs advance the source cursor by 2 (cp > 0xFFFF) and push two map entries for the unfolded pair, keeping astral text aligned; (2) an exclusive match end falling at hay.folded.length maps to haystack.length, absorbing trailing stripped marks in decomposed source; (3) an interior match end maps to the start of the next surviving code point, which also absorbs marks because marks never begin a mapped unit. Non-overlap advancement (from = foldedEnd) matches Foundation's range(of:options:) loop in SearchService.countMatches.
foldCodePoint applies lowercase after mark-stripping, so U+0130 (İ) folds to i rather than i̇ — matching Foundation. Known parity gaps are documented in-code: no-decomposition characters (ø, đ, Nordic/Vietnamese stroked letters) stay unfolded on both sides, and multi-unit NFD expansions (Hangul syllable → Jamo) can push the same source offset for several folded units, so a folded match boundary inside such an expansion collapses to one offset — native still owns counts there.
The change stays entirely within the established parity architecture: native owns counts, JS re-finds for painting. The comparison contract is now genuinely mirrored rather than approximated. searchHighlightTexts joins the existing test-probe convention on the bridge (searchHighlightCount, diagnostics) — read-only, no chrome, no message posting, safe in the page-adjacent isolated world.
foldWithMap(query) is recomputed per section inside the windowed refresh loop — negligible for short queries but a free hoist if ever profiled..widthInsensitive or ICU-table folds, the JS side must follow — the parity suite's content assertion is the tripwire.prism/Resources/WebRenderer/prism-search.js
Why it matters. The core correctness fix — counted matches in accented text previously had no highlight and no scroll target. The offset map is the load-bearing invention: it lets matching run over folded text while ranges stay in source coordinates.
What to look at. prism-search.js:58-136 (foldCodePoint, foldWithMap, findMatchRanges)
prism/Resources/WebRenderer/prism-search.js
Why it matters. Keeps the common unaccented case on the pre-fix cost profile — one native toLowerCase() instead of a per-code-point loop, per block, per windowed refresh.
What to look at. prism-search.js:77-99 (ASCII_ONLY, foldWithMap fast path) and the map === null branch in findMatchRanges
prism/Resources/WebRenderer/prism-search.js
Why it matters. The consumer previously assumed every match spans query.length units; with folding, a 6-unit query can cover an 8-unit source range. Missing this would have silently clipped highlights.
What to look at. prism-search.js:270-283 (matches[i].start / matches[i].end replacing offsets[i] + queryLength)
prismTests/WebRendering/WebSearchParityTests.swift
Why it matters. A count-only parity check stays green if every range shifts by a fixed amount within node bounds. Asserting the rendered text of each registered range catches exactly the regression class this fix is most at risk of.
What to look at. prism-search.js:371-381 (bridge.searchHighlightTexts); WebSearchParityTests.swift:107-173 (diacriticInsensitiveCountParity)
Native does not have the rendered DOM text — chrome skipping, comment visibility, and sanitization happen page-side — so it cannot compute rendered offsets without duplicating the DOM walk. The JS-side fold with a folded→source map keeps the existing 'native owns counts, JS re-finds' architecture intact. Stated in the PR description's alternatives-considered section.
Characters without a canonical decomposition cannot be folded by NFD + mark-stripping; matching ICU exactly would need a lookup table. Empirically verified during this review: Foundation's diacritic-insensitive compare does NOT match søren/soren, đavid/david, or œuf/oeuf either, so the ø/đ limitation genuinely is shared and parity holds there. The one gap is ICU full case folding (straße/strasse matches natively, ß cannot fold in JS) — see findings. Native still owns counts, so worst case is a counted-but-unhighlighted match for a rare class, i.e. the pre-fix behaviour confined to a much smaller character set. Stated in commit body and code comments.
Pure-ASCII text lowercases length-preservingly, so the folded→source mapping is the identity. Returning null and branching at the call site avoids allocating an O(n) array per block on the common path. Added in the review-response commit.
Order matters for characters like U+0130 (İ): NFD gives I + combining dot above; stripping first then lowercasing yields i, matching Foundation's fold. Lowercasing first would leave i̇ (i + retained dot) and desynchronize from native. The code takes the correct order, though the ordering rationale itself is not spelled out in comments.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | WebSearchParityTests.swift:112 fixture normalization | All test fixtures are precomposed NFC with zero combining marks (verified programmatically). Each NFC accented character folds 1 UTF-16 unit to 1 unit, so the folded-to-source map is identity-valued in every tested case. The length-shifting paths of the new mapping — end-boundary map lookup, trailing-combining-mark absorption, the code comment's own 'up-to-8-unit range in decomposed source' claim — are never executed by the regression test. An offset regression confined to that branch would ship green. | Report-only review: must-fix is one decomposed fixture case (e.g. "re\u{301}sume\u{301}" / "cafe\u{301}" NFD source, asserting the highlighted text is the full accented spelling). Production code handles it correctly today — verified by direct Node probe of the shipped functions — so this is a small test addition, not a code change. |
| minor | prism-search.js fold-limitations comment | The comment claimed all documented limitations are 'shared with native counting so parity holds'. Verified false for ICU full case folding: native's case-insensitive compare matches strasse against straße (and compatibility ligatures like fi), while toLowerCase() cannot fold ß, leaving that class counted-but-unhighlighted. The ø/đ and Hangul claims were verified accurate against Foundation (søren/soren and đavid/david do NOT match natively). | Comment corrected editorially in the working tree (uncommitted): the limitations list now separates the shared-with-native class (ø/đ, Hangul expansion) from the NOT-shared ICU case-fold class (ß, ligatures). No code behaviour changed. |
| minor | Test coverage adjacent gaps | currentGlobalMatchIndex is nil in the parity test, so the prism-search-current highlight is never registered for an accented match (current-match ordinal on accented text untested); queries typed in decomposed form are also untested (accented-query coverage uses NFC café only). | Worth folding into the same fixture expansion as the major finding; reported for the author. |
| minor | prism-search.js locale asymmetry | Native comparison passes locale: .current (SearchService.swift:70,145); JS toLowerCase() is locale-invariant. On a Turkish-locale device, dotted/dotless I semantics can diverge between counted and highlighted matches. | Theoretical, rare-class divergence; native owns counts, so worst case matches the documented counted-but-unhighlighted class. Reported only — a Known-limitations comment note would suffice. |
| minor | Documentation conventions | No specs/bugfixes report folder for this fix (the full bugfix report lives in the PR description instead — repo-convention drift), and docs/agent-notes/search.md has no bullet on the new folding contract. | Flagged, not blocking; the PR body carries the complete report and the folding contract is documented in-code. |
| nit | Test helper duplication | The searchHighlightCount JS eval snippet is now repeated 6x across two test files, and WebSearchParityTests gains a third copy of the parse/feed/send boilerplate. An evalInt-style helper on WebDocumentLiveHarness would be the natural home. Pre-existing pattern, extended not introduced by this diff. | Acceptable per existing convention; refactor left for a dedicated cleanup. |
| nit | prism-search.js micro-nits | text.substr(i, charLength) is deprecated Annex B (slice is the modern form); foldWithMap(query) builds a discarded offset map and is recomputed per windowed section (loop-invariant, microseconds — bounded by viewport windowing and the scroll debounce); comments at lines 11/56 still reference the retired SearchHighlightApplicator (pre-existing). | All cosmetic or negligible; left for the author. |
| none | Code reuse | No duplicate folding/normalization utilities exist anywhere in the WebRenderer JS assets or Swift services (grep for normalize/\p{M}/NFD confirms prism-search.js is the sole implementation); keeping the fold local matches the repo's self-contained-IIFE convention, and bridge.searchHighlightTexts follows the established searchActiveState/searchHighlightCount test-probe convention. | Nothing to change. |
Click to expand.
diff --git a/prism/Resources/WebRenderer/prism-search.js b/prism/Resources/WebRenderer/prism-search.jsindex 797742a..1db39e8 100644--- a/prism/Resources/WebRenderer/prism-search.js+++ b/prism/Resources/WebRenderer/prism-search.js@@ -55,26 +55,87 @@ // ---- Text matching (mirrors SearchHighlightApplicator) ---------------- - // Case-insensitive occurrence offsets of `query` within `haystack`. Offsets are- // UTF-16 code-unit indices into `haystack`, so they map directly onto the- // concatenated textContent walked below. (Native counting is case- and- // diacritic-insensitive; case-insensitivity is the common path and preserves- // offsets 1:1. Diacritic folding would shift offsets, so it is intentionally not- // applied here — the native count still owns parity, and the host badge/text- // remains navigable.)- function findOffsets(haystack, query) {- var offsets = [];- if (!query) { return offsets; }- var lowerHay = haystack.toLowerCase();- var lowerQuery = query.toLowerCase();+ // Combining marks stripped by the diacritic fold (any Unicode Mark).+ var COMBINING_MARKS = /\p{M}/gu;++ // Folds text the way native comparison does (SearchService.searchCompareOptions =+ // [.caseInsensitive, .diacriticInsensitive]): canonical decomposition, combining+ // marks stripped, lowercased. Known limitations: characters without a canonical+ // decomposition (e.g. ø, đ) are not folded — shared with native counting, so+ // parity holds there — and a code point whose decomposition yields SEVERAL+ // non-combining units (e.g. a Hangul syllable decomposing to multiple Jamo under+ // NFD) pushes the same source offset into the map several times, so a match+ // boundary landing inside such an expansion could collapse a range. NOT shared+ // with native: ICU full case folding (ß↔ss, compatibility ligatures like fi),+ // which native's case-insensitive compare applies but toLowerCase() does not, so+ // those stay counted-but-unhighlighted. Native still owns counts for all these+ // edge cases.+ function foldCodePoint(cp, raw) {+ if (cp < 128) {+ // ASCII fast path: no decomposition possible.+ return raw.toLowerCase();+ }+ return raw.normalize("NFD").replace(COMBINING_MARKS, "").toLowerCase();+ }++ // Pure-ASCII text needs no decomposition and lowercases length-preservingly, so+ // the folded→source mapping is the identity (signalled with map: null).+ var ASCII_ONLY = /^[\x00-\x7F]*$/;++ // Folds `text` and records, for every UTF-16 unit of the folded output, the+ // source offset of the code point that produced it, so folded match offsets+ // map back onto the original text even though folding changes lengths. Returns+ // `map: null` (identity) for pure-ASCII text so the common unaccented case keeps+ // the single native toLowerCase() instead of the per-code-point loop.+ function foldWithMap(text) {+ if (ASCII_ONLY.test(text)) {+ return { folded: text.toLowerCase(), map: null };+ }+ var folded = "";+ var map = [];+ var i = 0;+ while (i < text.length) {+ var cp = text.codePointAt(i);+ var charLength = cp > 0xFFFF ? 2 : 1;+ var foldedChar = foldCodePoint(cp, text.substr(i, charLength));+ for (var j = 0; j < foldedChar.length; j++) { map.push(i); }+ folded += foldedChar;+ i += charLength;+ }+ return { folded: folded, map: map };+ }++ // Occurrence ranges ({ start, end } UTF-16 code-unit indices into `haystack`) of+ // `query` within `haystack` under the native comparison contract (case- AND+ // diacritic-insensitive, T-1704). Matching runs over folded text; each folded+ // match is mapped back to source coordinates, so ranges cover the accented+ // original (including trailing combining marks, which fold to nothing and are+ // absorbed by the next mapped offset). Match lengths therefore vary — `résumé`+ // is a 6-unit query but an up-to-8-unit range in decomposed source text.+ function findMatchRanges(haystack, query) {+ var ranges = [];+ if (!query) { return ranges; }+ var foldedQuery = foldWithMap(query).folded;+ if (!foldedQuery) { return ranges; }+ var hay = foldWithMap(haystack); var from = 0; while (true) {- var index = lowerHay.indexOf(lowerQuery, from);+ var index = hay.folded.indexOf(foldedQuery, from); if (index < 0) { break; }- offsets.push(index);- from = index + lowerQuery.length;+ var foldedEnd = index + foldedQuery.length;+ if (hay.map === null) {+ // Identity mapping (pure-ASCII haystack): folded offsets ARE+ // source offsets.+ ranges.push({ start: index, end: foldedEnd });+ } else {+ ranges.push({+ start: hay.map[index],+ end: foldedEnd < hay.folded.length ? hay.map[foldedEnd] : haystack.length,+ });+ }+ from = foldedEnd; }- return offsets;+ return ranges; } // Collects the block's text nodes (excluding Prism chrome: copy buttons, badges,@@ -212,13 +273,12 @@ if (!section || !sectionInWindow(section)) { continue; } var map = textNodeMap(section);- var offsets = findOffsets(map.text, state.query);- var queryLength = state.query.length;+ var matches = findMatchRanges(map.text, state.query); var currentOrdinal = (entry.current && entry.current.kind === "text") ? entry.current.ordinal : -1; - for (var i = 0; i < offsets.length; i++) {- var range = rangeFor(map, offsets[i], offsets[i] + queryLength);+ for (var i = 0; i < matches.length; i++) {+ var range = rangeFor(map, matches[i].start, matches[i].end); if (!range) { continue; } allHighlight.add(range); if (i === currentOrdinal) {@@ -311,4 +371,17 @@ bridge.searchHighlightCount = function () { return allHighlight ? allHighlight.size : 0; };++ // The rendered text of every range registered on the "all matches" highlight, in+ // registration order. Exposed for the search-parity suite so it can assert the+ // highlights cover the right characters, not just that the count matches — an+ // offset-mapping regression that shifts ranges while preserving the count would+ // pass a count-only check. Returns [] when the API is unavailable.+ bridge.searchHighlightTexts = function () {+ var texts = [];+ if (allHighlight) {+ allHighlight.forEach(function (range) { texts.push(range.toString()); });+ }+ return texts;+ }; })();
diff --git a/prismTests/WebRendering/WebSearchParityTests.swift b/prismTests/WebRendering/WebSearchParityTests.swiftindex 44414d3..002d820 100644--- a/prismTests/WebRendering/WebSearchParityTests.swift+++ b/prismTests/WebRendering/WebSearchParityTests.swift@@ -101,6 +101,77 @@ struct WebSearchParityTests { #expect(total == 3) } + /// Native counting is case- AND diacritic-insensitive+ /// (`SearchService.searchCompareOptions`), so `resume` matches `résumé` and+ /// `café` matches `cafe`. prism-search.js must apply the same folding when it+ /// re-finds the query in the rendered text; lowercasing alone leaves counted+ /// matches without a highlight and breaks current-match scrolling (T-1704).+ @Test("Diacritic-insensitive queries highlight accented matches (T-1704)")+ func diacriticInsensitiveCountParity() async throws {+ let markdown = "Send your résumé to the café.\n\nA plain resume and a cafe reference.\n"+ let parsed = MarkdownBlockParser.parseWithFootnotes(markdown)+ let context = SearchContext(showHTMLComments: false, footnoteData: parsed.footnoteData)++ let harness = try await WebDocumentLiveHarness.make(+ blocks: parsed.blocks,+ footnotes: parsed.footnoteData,+ featureScripts: ["prism-search"]+ )+ _ = try await harness.waitForMessage(type: "ready")++ // Accented and unaccented spellings in both directions: each query must+ // match both the accented and the plain occurrence (native count 2), and+ // the registered ranges must cover exactly those spellings — a count-only+ // check would stay green if the folded→source offset mapping shifted every+ // range by a fixed amount within node bounds.+ let cases: [(query: String, expectedTexts: [String])] = [+ ("resume", ["resume", "résumé"]),+ ("cafe", ["cafe", "café"]),+ ("café", ["cafe", "café"]),+ ]+ for (query, expectedTexts) in cases {+ let counts = parsed.blocks.map {+ SearchService.countMatches(query: query, in: $0, context: context)+ }+ let states = SearchStateFeeder.buildStates(+ query: query,+ blocks: parsed.blocks,+ matchCountsPerBlock: counts,+ currentGlobalMatchIndex: nil,+ context: context+ )+ let nativeTextMatches = states.reduce(0) { $0 + $1.textMatchCount }+ #expect(nativeTextMatches == 2, "query '\(query)': native should count both spellings")++ let json = SearchStateFeeder.encode(query: query, states: states)+ try await harness.send(.setSearchState(json: json))++ let count = try await harness.page.callJavaScript(+ "return window.__prismBridge && window.__prismBridge.searchHighlightCount "+ + "? window.__prismBridge.searchHighlightCount() : -1;",+ contentWorld: harness.bridgeWorld+ )+ let rendered = (count as? Int) ?? Int((count as? Double) ?? -1)+ #expect(+ rendered == nativeTextMatches,+ "query '\(query)': rendered \(rendered) != native \(nativeTextMatches)"+ )++ // Content parity: the highlighted ranges must land on the actual+ // spellings, not merely produce the right number of ranges.+ let texts = try await harness.page.callJavaScript(+ "return window.__prismBridge && window.__prismBridge.searchHighlightTexts "+ + "? window.__prismBridge.searchHighlightTexts() : [];",+ contentWorld: harness.bridgeWorld+ )+ let highlightTexts = (texts as? [String]) ?? []+ #expect(+ highlightTexts.sorted() == expectedTexts,+ "query '\(query)': highlighted text \(highlightTexts) should cover both spellings"+ )+ }+ }+ /// A comment block nested inside a blockquote is emitted both ways and /// hidden by the stylesheet when the toggle is off (T-1638). Its text /// must not become a highlight target while hidden: native counts only
Native ICU case folding matches strasse against straße and folds ligatures like fi; toLowerCase() cannot, and NFD does not decompose them. German text containing ß can therefore reproduce the T-1704 symptom in miniature (counted match, no highlight, shifted ordinal). The corrected comment now documents this; a ß→ss special case in foldCodePoint is the cheap follow-up if it is ever reported. Note it changes folded length (1→2 units), which the offset map already supports.
A query consisting only of combining marks folds to "" and returns no ranges (guarded). Native behaviour for such a query is its own edge case; no test covers it, but the guard prevents the infinite-loop failure mode.