prism branch T-1704/bugfix-search-… commits 2 files 2 touched lines +161 / -20 tests 5/5 + 97/97 green

Pre-push review: T-1704 diacritic-insensitive search highlights

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.

At a glance

  • findOffsetsfindMatchRanges: 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.
  • ASCII fast path (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.
  • Parity test asserts content, not just count: a new test-only bridge hook searchHighlightTexts lets the suite verify the ranges land on résumé/café, catching offset-shift regressions a count check would miss.
  • Documented limitations mostly verified: ø/đ and Hangul claims check out against Foundation; the ß/ligature ICU case-fold class was wrongly claimed as shared with native — comment corrected editorially in this review.
  • Must-fix before push: the parity fixtures are all NFC, so the length-shifting offset-map branch (decomposed source, mark absorption) is untested — one NFD fixture case closes it.
  • Verified locally: WebSearchParityTests 5/5, WebSearchBridgeTests+WebSearchWiringTests+SearchServiceTests+SearchCoordinatorTests 97/97, make lint clean.

Verdict

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.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

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.

Why It Matters

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.

Key Concepts

  • Diacritic: the accent mark on a letter (é, ü, ñ). “Diacritic-insensitive” search treats e and é as the same letter.
  • Folding: converting text to a canonical comparison form — here, splitting accented letters into base letter + accent mark, throwing the marks away, and lowercasing.
  • The offset problem: highlights are painted by character position. Folding changes the text's length, so the code keeps a map from every folded character back to where it came from in the original text.

Changes Overview

Two files: prism/Resources/WebRenderer/prism-search.js (the in-page highlight painter) and prismTests/WebRendering/WebSearchParityTests.swift (live-harness parity suite).

Implementation Approach

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.
  • An ASCII-only regex short-circuit returns map: null (identity), keeping the old single-toLowerCase() cost for unaccented text.
  • renderHighlights consumes {start, end} ranges instead of assuming query.length.

Trade-offs

  • Native sends exact rendered-text ranges was rejected: native lacks the rendered DOM text (chrome skipping, comment visibility are page-side), so it cannot compute rendered offsets without duplicating the DOM walk.
  • Full ICU-equivalent folding (ø→o, đ→d) was not attempted: those have no canonical decomposition, so exact ICU parity needs a lookup table. Native still owns counts; the combining-mark class covers the reported divergence.

Technical Deep Dive

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 — 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.

Architecture Impact

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.

Potential Issues

  • foldWithMap(query) is recomputed per section inside the windowed refresh loop — negligible for short queries but a free hoist if ever profiled.
  • A query that folds to the empty string (pure combining marks) returns no ranges; native would likewise find nothing sensible, but this path is untested.
  • If native ever gains .widthInsensitive or ICU-table folds, the JS side must follow — the parity suite's content assertion is the tripwire.

Important changes — detailed

prism-search.js: findOffsets → findMatchRanges with folded→source offset map

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)

Takeaway. When two layers must share a comparison contract and one is offset-based, fold with a parallel offset map instead of refusing to fold. Push the source offset once per folded output unit; map exclusive ends via the next surviving unit, with a length fallback at the tail — trailing deleted characters (combining marks) are absorbed for free.
Rationale. Folding shifts UTF-16 offsets that Custom Highlight ranges depend on, which is why it was originally omitted. The map makes the shift trackable; the alternative (native shipping rendered-text ranges) would require duplicating the page-side DOM walk natively. Stated in the commit body and PR description.

prism-search.js: ASCII fast path (map: null identity)

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

Takeaway. A null map as an explicit 'identity mapping' signal is cheaper and clearer than building a real identity array — callers branch once instead of allocating O(n).
Rationale. Added in response to PR review feedback (commit 4c90007) to restore the fast path for pure-ASCII text, where no decomposition is possible and lowercasing is length-preserving.

prism-search.js: renderHighlights consumes variable-length ranges

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)

Takeaway. When a producer's unit of work changes shape (offset → range), audit every consumer for baked-in length assumptions.
Rationale. Direct consequence of match lengths varying under folding — the range must come from the map, not from the query.

searchHighlightTexts bridge probe + content-parity assertion

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)

Takeaway. For offset-mapping code, test the content the offsets select, not just how many selections exist. The bidirectional case matrix (accented query on plain text and vice versa) exercises both fold directions.
Rationale. Added in commit 4c90007 after PR review flagged that count parity alone could not catch an offset-shift regression.

Key decisions

Fold in JS with an offset map rather than shipping ranges from native.

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.

No ICU lookup table: ø, đ and friends stay unfolded.

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.

ASCII fast path signalled by map: null instead of an identity array.

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.

Lowercase applied after mark-stripping in foldCodePoint.

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 + retained dot) and desynchronize from native. The code takes the correct order, though the ordering rationale itself is not spelled out in comments.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorWebSearchParityTests.swift:112 fixture normalizationAll 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.
minorprism-search.js fold-limitations commentThe 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.
minorTest coverage adjacent gapscurrentGlobalMatchIndex 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.
minorprism-search.js locale asymmetryNative 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.
minorDocumentation conventionsNo 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.
nitTest helper duplicationThe 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.
nitprism-search.js micro-nitstext.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.
noneCode reuseNo 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.

Per-file diffs

Click to expand.

prism/Resources/WebRenderer/prism-search.js Modified +110 / -20
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;+    }; })();
prismTests/WebRendering/WebSearchParityTests.swift Modified +71 / -0
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

Things to double-check

German ß and compatibility ligatures stay counted-but-unhighlighted.

Native ICU case folding matches strasse against straße and folds ligatures like ; 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.

Queries that fold to the empty string.

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.