Skip to content

Commit 8529069

Browse files
refactor(search): one definition of what a search occurrence is (#6905)
Follow-up to #6901, closing two places where the workflow search index and the Note card that mirrors it could drift apart. Neither is a live bug; both are the shape that produced one — the card silently disagreeing with the panel about which hit is which, counted in one place and painted in another. THE SCAN. #6901 shared `foldSearchWhitespace` but left the scan around it duplicated: normalize, then non-overlapping `indexOf` stepping by `max(len, 1)`, written out once in the indexer and once in the renderer package. They agree today. They would stop agreeing the moment either grew whole-word matching, diacritic folding, or a regex mode, and the failure is silent. Both now call one `forEachSearchOccurrence` in `@sim/utils/string` — the only place either package can share, since the card renders from a package that cannot import from `apps/*`. THE DECLARATION. The indexer projects markdown escapes only for a field declaring `searchTextFormat: 'markdown'`; the card projects unconditionally, because it cannot read the block registry. Dropping that one line from the Note config would leave them disagreeing with nothing to catch it, so a test now pins it and explains why. Net negative in lines: this deletes a duplicated loop rather than adding a layer. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2e111f6 commit 8529069

5 files changed

Lines changed: 69 additions & 71 deletions

File tree

apps/sim/lib/workflows/search-replace/indexer.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SEARCH_REPLACE_BLOCK_CONFIGS,
1313
} from '@/lib/workflows/search-replace/search-replace.fixtures'
1414
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
15+
import { NoteBlock } from '@/blocks/blocks/note'
1516

1617
/**
1718
* Uses the real tool registry. Nothing here imports it directly — the dependency
@@ -167,6 +168,19 @@ describe('indexWorkflowSearchMatches', () => {
167168
expect(matches.some((match) => match.target.kind === 'block-name')).toBe(false)
168169
})
169170

171+
describe('the Note body declares the markdown format the card assumes', () => {
172+
/*
173+
* The canvas card projects markdown escapes unconditionally — it renders from a package that
174+
* cannot read the block registry. The indexer projects only when the field says so. Dropping
175+
* the declaration would leave the two disagreeing about what an occurrence is, and the failure
176+
* is silent: the panel counts a hit the card marks somewhere else.
177+
*/
178+
it('keeps searchTextFormat on the Note content field', () => {
179+
const content = NoteBlock.subBlocks.find((subBlock) => subBlock.id === 'content')
180+
expect(content?.searchTextFormat).toBe('markdown')
181+
})
182+
})
183+
170184
describe('a markdown field is searched as it renders', () => {
171185
/*
172186
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a

apps/sim/lib/workflows/search-replace/indexer.ts

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isRecordLike } from '@sim/utils/object'
2-
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
2+
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
33
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
44
import type { SubBlockType } from '@sim/workflow-types/blocks'
55
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
@@ -57,16 +57,6 @@ import {
5757
type ToolParameterConfig,
5858
} from '@/tools/params'
5959

60-
/**
61-
* Whitespace is folded before comparison (see {@link foldSearchWhitespace}):
62-
* the fold is one-to-one, so ranges found in the normalized string index the
63-
* original text correctly.
64-
*/
65-
function normalizeForSearch(value: string, caseSensitive: boolean): string {
66-
const folded = foldSearchWhitespace(value)
67-
return caseSensitive ? folded : folded.toLowerCase()
68-
}
69-
7060
/**
7161
* Ranges of `query` in `value`, always in `value`'s own coordinates.
7262
*
@@ -83,23 +73,21 @@ function findTextRanges(
8373
caseSensitive: boolean,
8474
searchTextFormat?: SubBlockConfig['searchTextFormat']
8575
) {
86-
if (!query) return []
87-
8876
const projection = searchTextFormat === 'markdown' ? projectEscapedMarkdownForSearch(value) : null
89-
const source = normalizeForSearch(projection ? projection.text : value, caseSensitive)
90-
const target = normalizeForSearch(query, caseSensitive)
9177
const ranges: Array<{ start: number; end: number }> = []
9278

93-
let index = source.indexOf(target)
94-
while (index !== -1) {
95-
const end = index + target.length
96-
ranges.push(
97-
projection
98-
? { start: projection.starts[index], end: projection.starts[end] }
99-
: { start: index, end }
100-
)
101-
index = source.indexOf(target, index + Math.max(target.length, 1))
102-
}
79+
forEachSearchOccurrence(
80+
projection ? projection.text : value,
81+
query,
82+
(start, end) => {
83+
ranges.push(
84+
projection
85+
? { start: projection.starts[start], end: projection.starts[end] }
86+
: { start, end }
87+
)
88+
},
89+
caseSensitive
90+
)
10391

10492
return ranges
10593
}

packages/utils/src/string.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,42 @@ export function foldSearchWhitespace(value: string): string {
182182
return value.replace(/\s/g, ' ')
183183
}
184184

185+
/**
186+
* Visits every occurrence of `query` in `text`, without overlaps.
187+
*
188+
* The single definition of what "an occurrence" means for search, shared by the
189+
* workflow search index and by the Note card that has to mark the same hits on
190+
* the canvas. They live in different packages and cannot see each other, so a
191+
* second copy of this loop is a silent disagreement waiting to happen: the
192+
* panel counts a match the card never paints, which is exactly the bug that
193+
* arrived when only the whitespace fold was shared and the scan was not.
194+
*
195+
* Whitespace is folded first (see {@link foldSearchWhitespace}) and the fold is
196+
* one-to-one, so both bounds index the caller's own unfolded string.
197+
*/
198+
export function forEachSearchOccurrence(
199+
text: string,
200+
query: string,
201+
visit: (start: number, end: number) => void,
202+
caseSensitive = false
203+
): void {
204+
if (!query) return
205+
206+
const normalize = (value: string) => {
207+
const folded = foldSearchWhitespace(value)
208+
return caseSensitive ? folded : folded.toLowerCase()
209+
}
210+
const haystack = normalize(text)
211+
const needle = normalize(query)
212+
const step = Math.max(needle.length, 1)
213+
214+
let index = haystack.indexOf(needle)
215+
while (index !== -1) {
216+
visit(index, index + needle.length)
217+
index = haystack.indexOf(needle, index + step)
218+
}
219+
}
220+
185221
/**
186222
* ASCII punctuation a backslash may escape in markdown, per CommonMark. A
187223
* backslash before anything else is a literal backslash.

packages/workflow-renderer/src/note/note-search-highlight.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
*/
1414

1515
import { act } from 'react'
16+
import { forEachSearchOccurrence } from '@sim/utils/string'
1617
import type { Element, ElementContent, Root, RootContent } from 'hast'
1718
import { createRoot, type Root as ReactRoot } from 'react-dom/client'
1819
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
@@ -24,7 +25,6 @@ import {
2425
} from '../index'
2526
import {
2627
countNoteSearchOccurrencesBefore,
27-
forEachNoteSearchOccurrence,
2828
noteSearchHighlightPlugin,
2929
} from './note-search-highlight'
3030

@@ -125,13 +125,13 @@ function paragraphTree(...values: string[]): Root {
125125
describe('note search occurrence scanning', () => {
126126
it('matches case-insensitively, like the workflow search index', () => {
127127
const starts: number[] = []
128-
forEachNoteSearchOccurrence('Secret and secret', 'SECRET', (start) => starts.push(start))
128+
forEachSearchOccurrence('Secret and secret', 'SECRET', (start) => starts.push(start))
129129
expect(starts).toEqual([0, 11])
130130
})
131131

132132
it('does not overlap a self-overlapping query', () => {
133133
const starts: number[] = []
134-
forEachNoteSearchOccurrence('aaaa', 'aa', (start) => starts.push(start))
134+
forEachSearchOccurrence('aaaa', 'aa', (start) => starts.push(start))
135135
expect(starts).toEqual([0, 2])
136136
})
137137

packages/workflow-renderer/src/note/note-search-highlight.ts

Lines changed: 3 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
1+
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
22
import type { Element, Root, Text } from 'hast'
33

44
/**
@@ -41,46 +41,6 @@ export interface NoteSearchRange {
4141
*/
4242
export const NOTE_SEARCH_MARK_INDEX_PROPERTY = 'dataNoteSearchIndex'
4343

44-
/**
45-
* Visits every occurrence of `query` in `text`, case-insensitively and without
46-
* overlaps.
47-
*
48-
* Deliberately the same scan the workflow search indexer runs over the raw
49-
* value (`findTextRanges`), down to folding whitespace with the shared
50-
* {@link foldSearchWhitespace}: counting and marking have to agree on what "the
51-
* third occurrence" means. An overlapping scan here against a non-overlapping
52-
* one there would silently offset every mark in a note whose query
53-
* self-overlaps (`aa` in `aaaa`), and an unfolded one would miss a phrase the
54-
* indexer matched across a line break.
55-
*
56-
* Case sensitivity is not plumbed through: the search panel is the only caller
57-
* of the indexer and never enables it. If it ever does, this is the second
58-
* place that has to change.
59-
*/
60-
export function forEachNoteSearchOccurrence(
61-
text: string,
62-
query: string,
63-
visit: (start: number, end: number) => void
64-
): void {
65-
if (!query) return
66-
67-
/* Folding is length-preserving, so every index below is also a valid index
68-
into the caller's unfolded string. */
69-
const haystack = foldSearchWhitespace(text).toLowerCase()
70-
const needle = foldSearchWhitespace(query).toLowerCase()
71-
const step = Math.max(needle.length, 1)
72-
73-
let index = haystack.indexOf(needle)
74-
while (index !== -1) {
75-
visit(index, index + needle.length)
76-
index = haystack.indexOf(needle, index + step)
77-
}
78-
}
79-
80-
/**
81-
* How many occurrences of `query` start before `offset` in `content` — the
82-
* ordinal of the occurrence that starts there.
83-
*/
8444
/**
8545
* Visits every occurrence of `query` in a note's markdown SOURCE, reporting each
8646
* start in source coordinates.
@@ -96,7 +56,7 @@ export function forEachNoteSourceOccurrence(
9656
visit: (sourceStart: number) => void
9757
): void {
9858
const projection = projectEscapedMarkdownForSearch(content)
99-
forEachNoteSearchOccurrence(projection.text, query, (start) => {
59+
forEachSearchOccurrence(projection.text, query, (start) => {
10060
visit(projection.starts[start])
10161
})
10262
}
@@ -279,7 +239,7 @@ export function noteSearchHighlightPlugin({ query }: NoteSearchHighlightOptions)
279239

280240
for (const run of builder.runs) {
281241
const text = run.map((entry) => entry.node.value).join('')
282-
forEachNoteSearchOccurrence(text, query, (start, end) => {
242+
forEachSearchOccurrence(text, query, (start, end) => {
283243
const current = ordinal
284244
ordinal += 1
285245
for (const entry of run) {

0 commit comments

Comments
 (0)