Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/sim/lib/workflows/search-replace/indexer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SEARCH_REPLACE_BLOCK_CONFIGS,
} from '@/lib/workflows/search-replace/search-replace.fixtures'
import { WORKFLOW_SEARCH_SUBFLOW_FIELD_IDS } from '@/lib/workflows/search-replace/subflow-fields'
import { NoteBlock } from '@/blocks/blocks/note'

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

describe('the Note body declares the markdown format the card assumes', () => {
/*
* The canvas card projects markdown escapes unconditionally — it renders from a package that
* cannot read the block registry. The indexer projects only when the field says so. Dropping
* the declaration would leave the two disagreeing about what an occurrence is, and the failure
* is silent: the panel counts a hit the card marks somewhere else.
*/
it('keeps searchTextFormat on the Note content field', () => {
const content = NoteBlock.subBlocks.find((subBlock) => subBlock.id === 'content')
expect(content?.searchTextFormat).toBe('markdown')
})
})

describe('a markdown field is searched as it renders', () => {
/*
* The rich-text editor backslash-escapes every markdown-significant character in prose, so a
Expand Down
38 changes: 13 additions & 25 deletions apps/sim/lib/workflows/search-replace/indexer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isRecordLike } from '@sim/utils/object'
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { DEFAULT_SUBBLOCK_TYPE } from '@sim/workflow-persistence/subblocks'
import type { SubBlockType } from '@sim/workflow-types/blocks'
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
Expand Down Expand Up @@ -57,16 +57,6 @@ import {
type ToolParameterConfig,
} from '@/tools/params'

/**
* Whitespace is folded before comparison (see {@link foldSearchWhitespace}):
* the fold is one-to-one, so ranges found in the normalized string index the
* original text correctly.
*/
function normalizeForSearch(value: string, caseSensitive: boolean): string {
const folded = foldSearchWhitespace(value)
return caseSensitive ? folded : folded.toLowerCase()
}

/**
* Ranges of `query` in `value`, always in `value`'s own coordinates.
*
Expand All @@ -83,23 +73,21 @@ function findTextRanges(
caseSensitive: boolean,
searchTextFormat?: SubBlockConfig['searchTextFormat']
) {
if (!query) return []

const projection = searchTextFormat === 'markdown' ? projectEscapedMarkdownForSearch(value) : null
const source = normalizeForSearch(projection ? projection.text : value, caseSensitive)
const target = normalizeForSearch(query, caseSensitive)
const ranges: Array<{ start: number; end: number }> = []

let index = source.indexOf(target)
while (index !== -1) {
const end = index + target.length
ranges.push(
projection
? { start: projection.starts[index], end: projection.starts[end] }
: { start: index, end }
)
index = source.indexOf(target, index + Math.max(target.length, 1))
}
forEachSearchOccurrence(
projection ? projection.text : value,
query,
(start, end) => {
ranges.push(
projection
? { start: projection.starts[start], end: projection.starts[end] }
: { start, end }
)
},
caseSensitive
)

return ranges
}
Expand Down
36 changes: 36 additions & 0 deletions packages/utils/src/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,42 @@ export function foldSearchWhitespace(value: string): string {
return value.replace(/\s/g, ' ')
}

/**
* Visits every occurrence of `query` in `text`, without overlaps.
*
* The single definition of what "an occurrence" means for search, shared by the
* workflow search index and by the Note card that has to mark the same hits on
* the canvas. They live in different packages and cannot see each other, so a
* second copy of this loop is a silent disagreement waiting to happen: the
* panel counts a match the card never paints, which is exactly the bug that
* arrived when only the whitespace fold was shared and the scan was not.
*
* Whitespace is folded first (see {@link foldSearchWhitespace}) and the fold is
* one-to-one, so both bounds index the caller's own unfolded string.
*/
export function forEachSearchOccurrence(
text: string,
query: string,
visit: (start: number, end: number) => void,
caseSensitive = false
): void {
if (!query) return

const normalize = (value: string) => {
const folded = foldSearchWhitespace(value)
return caseSensitive ? folded : folded.toLowerCase()
}
const haystack = normalize(text)
const needle = normalize(query)
const step = Math.max(needle.length, 1)

let index = haystack.indexOf(needle)
while (index !== -1) {
visit(index, index + needle.length)
index = haystack.indexOf(needle, index + step)
}
}

/**
* ASCII punctuation a backslash may escape in markdown, per CommonMark. A
* backslash before anything else is a literal backslash.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { act } from 'react'
import { forEachSearchOccurrence } from '@sim/utils/string'
import type { Element, ElementContent, Root, RootContent } from 'hast'
import { createRoot, type Root as ReactRoot } from 'react-dom/client'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
Expand All @@ -24,7 +25,6 @@ import {
} from '../index'
import {
countNoteSearchOccurrencesBefore,
forEachNoteSearchOccurrence,
noteSearchHighlightPlugin,
} from './note-search-highlight'

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

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

Expand Down
46 changes: 3 additions & 43 deletions packages/workflow-renderer/src/note/note-search-highlight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { foldSearchWhitespace, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import { forEachSearchOccurrence, projectEscapedMarkdownForSearch } from '@sim/utils/string'
import type { Element, Root, Text } from 'hast'

/**
Expand Down Expand Up @@ -41,46 +41,6 @@ export interface NoteSearchRange {
*/
export const NOTE_SEARCH_MARK_INDEX_PROPERTY = 'dataNoteSearchIndex'

/**
* Visits every occurrence of `query` in `text`, case-insensitively and without
* overlaps.
*
* Deliberately the same scan the workflow search indexer runs over the raw
* value (`findTextRanges`), down to folding whitespace with the shared
* {@link foldSearchWhitespace}: counting and marking have to agree on what "the
* third occurrence" means. An overlapping scan here against a non-overlapping
* one there would silently offset every mark in a note whose query
* self-overlaps (`aa` in `aaaa`), and an unfolded one would miss a phrase the
* indexer matched across a line break.
*
* Case sensitivity is not plumbed through: the search panel is the only caller
* of the indexer and never enables it. If it ever does, this is the second
* place that has to change.
*/
export function forEachNoteSearchOccurrence(
text: string,
query: string,
visit: (start: number, end: number) => void
): void {
if (!query) return

/* Folding is length-preserving, so every index below is also a valid index
into the caller's unfolded string. */
const haystack = foldSearchWhitespace(text).toLowerCase()
const needle = foldSearchWhitespace(query).toLowerCase()
const step = Math.max(needle.length, 1)

let index = haystack.indexOf(needle)
while (index !== -1) {
visit(index, index + needle.length)
index = haystack.indexOf(needle, index + step)
}
}

/**
* How many occurrences of `query` start before `offset` in `content` — the
* ordinal of the occurrence that starts there.
*/
/**
* Visits every occurrence of `query` in a note's markdown SOURCE, reporting each
* start in source coordinates.
Expand All @@ -96,7 +56,7 @@ export function forEachNoteSourceOccurrence(
visit: (sourceStart: number) => void
): void {
const projection = projectEscapedMarkdownForSearch(content)
forEachNoteSearchOccurrence(projection.text, query, (start) => {
forEachSearchOccurrence(projection.text, query, (start) => {
visit(projection.starts[start])
})
}
Expand Down Expand Up @@ -279,7 +239,7 @@ export function noteSearchHighlightPlugin({ query }: NoteSearchHighlightOptions)

for (const run of builder.runs) {
const text = run.map((entry) => entry.node.value).join('')
forEachNoteSearchOccurrence(text, query, (start, end) => {
forEachSearchOccurrence(text, query, (start, end) => {
const current = ordinal
ordinal += 1
for (const entry of run) {
Expand Down
Loading