Skip to content

Commit 6e0d352

Browse files
icecrasher321claude
andcommitted
fix(file): locate regex matches in PostgreSQL, never in JavaScript
Preview rendering ran the user's compiled pattern with `RegExp.exec` to centre the excerpt on the match. `RegExp` matches by backtracking, and the literal-run gate admits nested quantifiers, so `(a+)+bcd` against a long segment cost 768ms at 40 leading `a`s and doubles with each one — synchronously, on the event loop, once per returned row, and entirely outside the statement timeout that bounds the query which found the row. PostgreSQL runs that same pattern in 0.49ms: its engine does not backtrack, and `regexp_instr` runs inside the read's transaction, so locating a match can never cost more than having found it. Regex mode now selects the match offsets alongside the row and `findMatchRange` returns null for it, which is the interface's contract rather than an omission. Exact mode is unchanged — scanning for a known string is linear. PostgreSQL counts characters where JavaScript slices by UTF-16 unit, so the offsets are converted by walking the segment rather than assuming either width. Also fixes two audit failures: `getErrorMessage` in place of a hand-written `instanceof Error` ternary, and regenerated tool metadata and integration docs for the search params. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 70e7f1d commit 6e0d352

6 files changed

Lines changed: 112 additions & 42 deletions

File tree

apps/docs/content/docs/integrations/file.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,14 @@ Extract the text content of one or more workspace files from selected file objec
7070

7171
### File Search
7272

73-
Search indexed text across active workspace files using literal smart-case substring matching.
73+
Search every active workspace file for lines matching a regular expression, and return each match with its file ID and line number.
7474

7575
#### Input
7676

7777
| Parameter | Type | Required | Description |
7878
| --------- | ---- | -------- | ----------- |
79-
| `query` | string | Yes | Literal text to find \(3-512 characters\). Uppercase Unicode letters make matching case-sensitive. |
79+
| `query` | string | Yes | A regular expression matched against each line, 3-512 characters. Supports "." "*" "+" "?" "\{n,m\}" and their lazy forms, character classes such as "\[a-z\]" and "\[^0-9\]", the classes \d \w \s and \D \W \S, alternation "\|", groups "\(...\)" and "\(?:...\)", the anchors "^" and "$", and the word boundary \b. Lookahead, lookbehind, backreferences, named groups, inline flags such as "\(?i\)", \p\{...\} and POSIX "\[\[:alpha:\]\]" classes are not supported, and a pattern cannot span a line break. The pattern must contain at least 3 consecutive literal characters that every match will include — write "error \d+" rather than "\w+ \d+". Escape any metacharacter you mean literally. Matching is case-insensitive until the pattern contains an uppercase letter, which makes it case-sensitive. |
80+
| `mode` | string | No | How the query is read, chosen by the workflow builder: "regex" \(default\) as a regular expression, or "exact" as verbatim text. |
8081
| `maxResults` | number | No | Hard result cap configured by the workflow builder \(1-200, default 50\). |
8182

8283
#### Output

apps/sim/lib/workspace-files/search/pattern.test.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,28 +79,28 @@ describe('compileFileSearchPattern', () => {
7979
expect(() => compileFileSearchPattern('error \\d+', 'regex')).not.toThrow()
8080
})
8181

82-
it('locates the match so a long line previews around it', () => {
82+
it('never runs the pattern against a segment in JavaScript', () => {
8383
const pattern = compileFileSearchPattern('needle\\d+', 'regex')
8484

85-
expect(pattern.findMatchRange('xxx needle42 yyy')).toEqual({ start: 4, end: 12 })
86-
expect(pattern.findMatchRange('no match here')).toBeNull()
85+
expect(pattern.findMatchRange('xxx needle42 yyy')).toBeNull()
8786
})
8887

89-
it('never returns a range that splits a surrogate pair', () => {
90-
const pattern = compileFileSearchPattern('.needle', 'regex')
91-
const line = `a🙂needle`
92-
const range = pattern.findMatchRange(line)
93-
94-
expect(range).not.toBeNull()
95-
expect([...line.slice(range?.start, range?.end)].join('')).not.toContain('�')
96-
expect(line.slice(range?.start, range?.end)).toBe('🙂needle')
88+
/**
89+
* `RegExp` matches by backtracking, so admitting this pattern and running it
90+
* here would cost seconds on one long segment — growing exponentially with
91+
* the run of `a`s, on the event loop, once per returned row.
92+
*/
93+
it('cannot be driven into backtracking by an admitted pattern', () => {
94+
const pattern = compileFileSearchPattern('(a+)+bcd', 'regex')
95+
const hostile = `${'a'.repeat(60)}X${'z'.repeat(3000)}bcd`
96+
97+
const started = performance.now()
98+
expect(pattern.findMatchRange(hostile)).toBeNull()
99+
expect(performance.now() - started).toBeLessThan(50)
97100
})
98101

99-
it('does not carry match state between calls', () => {
100-
const pattern = compileFileSearchPattern('needle', 'regex')
101-
102-
expect(pattern.findMatchRange('a needle')).toEqual({ start: 2, end: 8 })
103-
expect(pattern.findMatchRange('a needle')).toEqual({ start: 2, end: 8 })
102+
it('still rejects a pattern that does not compile', () => {
103+
expect(() => compileFileSearchPattern('needle(', 'regex')).toThrow(FileSearchPatternError)
104104
})
105105
})
106106
})

apps/sim/lib/workspace-files/search/pattern.ts

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getErrorMessage } from '@sim/utils/errors'
12
import {
23
FILE_SEARCH_MAX_QUERY_LENGTH,
34
FILE_SEARCH_MIN_QUERY_LENGTH,
@@ -47,7 +48,15 @@ export interface CompiledFileSearchPattern {
4748
* to unsplit lines trades those matches for never reporting a false one.
4849
*/
4950
wholeLineOnly: boolean
50-
/** Locates the match inside a segment PostgreSQL already matched. */
51+
/**
52+
* Locates the match inside a segment PostgreSQL already matched — but only
53+
* where locating it is bounded work. Exact mode scans for a known string.
54+
* Regex mode returns `null`: JavaScript matches by backtracking, and an
55+
* admitted pattern like `(a+)+bcd` costs seconds on one long segment and
56+
* grows exponentially, so a caller that needs a regex match located asks
57+
* PostgreSQL, whose engine does not backtrack and whose work is already
58+
* bounded by the read's statement timeout.
59+
*/
5160
findMatchRange(segment: string): FileSearchMatchRange | null
5261
}
5362

@@ -100,7 +109,7 @@ function findLiteralMatchRange(line: string, query: string, caseSensitive: boole
100109
}
101110

102111
/** Pulls a range off a surrogate pair, so slicing it never yields a lone half. */
103-
function alignToCodePoints(line: string, range: FileSearchMatchRange): FileSearchMatchRange {
112+
export function alignToCodePoints(line: string, range: FileSearchMatchRange): FileSearchMatchRange {
104113
let { start, end } = range
105114
const startUnit = line.charCodeAt(start)
106115
if (start > 0 && startUnit >= 0xdc00 && startUnit <= 0xdfff) start -= 1
@@ -131,16 +140,17 @@ function compileRegexPattern(query: string): CompiledFileSearchPattern {
131140
const caseSensitive = isFileSearchCaseSensitive(analysis.literals)
132141

133142
/**
134-
* The subset is chosen so the same source compiles in both engines, and this
135-
* is where that holds: PostgreSQL selects the rows, and this expression finds
136-
* the match inside one to centre the preview on.
143+
* Compiled, never executed. The subset is the intersection of the two engines,
144+
* so this rejects a malformed pattern with a precise message before it costs a
145+
* database round trip — but running it is what the interface's `findMatchRange`
146+
* contract refuses, because compiling a regex is linear and matching with one
147+
* is not.
137148
*/
138-
let matcher: RegExp
139149
try {
140-
matcher = new RegExp(query, caseSensitive ? '' : 'i')
150+
new RegExp(query, caseSensitive ? '' : 'i')
141151
} catch (error) {
142152
throw new FileSearchPatternError(
143-
`Invalid search pattern: ${error instanceof Error ? error.message : 'could not be compiled'}`
153+
`Invalid search pattern: ${getErrorMessage(error, 'could not be compiled')}`
144154
)
145155
}
146156

@@ -150,14 +160,7 @@ function compileRegexPattern(query: string): CompiledFileSearchPattern {
150160
sqlPattern: analysis.postgresSource,
151161
literalText: null,
152162
wholeLineOnly: analysis.anchored,
153-
findMatchRange: (segment) => {
154-
const match = matcher.exec(segment)
155-
if (!match) return null
156-
return alignToCodePoints(segment, {
157-
start: match.index,
158-
end: match.index + match[0].length,
159-
})
160-
},
163+
findMatchRange: () => null,
161164
}
162165
}
163166

apps/sim/lib/workspace-files/search/repository.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import {
1212
FILE_SEARCH_STATEMENT_TIMEOUT_MS,
1313
} from '@/lib/workspace-files/search/constants'
1414
import {
15+
alignToCodePoints,
1516
type CompiledFileSearchPattern,
17+
type FileSearchMatchRange,
1618
FileSearchPatternError,
1719
} from '@/lib/workspace-files/search/pattern'
1820
import { createFileSearchPreview } from '@/lib/workspace-files/search/text'
@@ -105,6 +107,55 @@ function buildMatchExpression(content: SegmentContent, pattern: CompiledFileSear
105107
: sql`${content} ILIKE ${pattern.sqlPattern} ESCAPE '\\'`
106108
}
107109

110+
/**
111+
* Where the match sits inside the segment, located by PostgreSQL.
112+
*
113+
* A regex is never run against a segment in JavaScript: `RegExp` matches by
114+
* backtracking, so an admitted pattern like `(a+)+bcd` takes seconds on one long
115+
* segment and grows exponentially with it, on the event loop, once per returned
116+
* row. PostgreSQL's engine does not backtrack — the same pattern resolves in
117+
* under a millisecond — and this runs inside the read's statement timeout, so
118+
* the cost of locating a match can never exceed the cost of having found it.
119+
*
120+
* Exact mode locates its own match in JavaScript, where scanning for a known
121+
* string is linear, so it selects a constant here rather than paying for a
122+
* second pass.
123+
*/
124+
function buildMatchOffsets(content: SegmentContent, pattern: CompiledFileSearchPattern) {
125+
if (pattern.mode !== 'regex') {
126+
return { matchStart: sql<number>`0`, matchEnd: sql<number>`0` }
127+
}
128+
const flags = pattern.caseSensitive ? '' : 'i'
129+
return {
130+
matchStart: sql<number>`regexp_instr(${content}, ${pattern.sqlPattern}, 1, 1, 0, ${flags})`,
131+
matchEnd: sql<number>`regexp_instr(${content}, ${pattern.sqlPattern}, 1, 1, 1, ${flags})`,
132+
}
133+
}
134+
135+
/**
136+
* PostgreSQL counts characters and JavaScript slices by UTF-16 unit, so an
137+
* astral character shifts every offset after it by one. Walking the segment
138+
* converts between them without assuming either width.
139+
*/
140+
function toSegmentRange(
141+
content: string,
142+
matchStart: number,
143+
matchEnd: number
144+
): FileSearchMatchRange | null {
145+
if (matchStart < 1 || matchEnd <= matchStart) return null
146+
let units = 0
147+
let characters = 0
148+
let start = -1
149+
while (units < content.length) {
150+
if (characters === matchStart - 1) start = units
151+
if (characters === matchEnd - 1) break
152+
units += (content.codePointAt(units) ?? 0) > 0xffff ? 2 : 1
153+
characters += 1
154+
}
155+
if (start < 0) return null
156+
return alignToCodePoints(content, { start, end: units })
157+
}
158+
108159
/** How much of the logical line surrounds a literal match, in the narrower direction. */
109160
function buildSurroundingContext(
110161
content: SegmentContent,
@@ -130,6 +181,7 @@ export async function searchWorkspaceFileIndex({
130181

131182
const content = workspaceFileSearchSegment.content
132183
const matchExpression = buildMatchExpression(content, pattern)
184+
const { matchStart, matchEnd } = buildMatchOffsets(content, pattern)
133185

134186
/**
135187
* A logical line longer than {@link FILE_SEARCH_SEGMENT_CHARS} is stored as
@@ -185,6 +237,8 @@ export async function searchWorkspaceFileIndex({
185237
segmentStart: workspaceFileSearchSegment.segmentStart,
186238
lineLength: workspaceFileSearchSegment.lineLength,
187239
content: workspaceFileSearchSegment.content,
240+
matchStart,
241+
matchEnd,
188242
}
189243
)
190244
.from(workspaceFileSearchSegment)
@@ -274,6 +328,10 @@ export async function searchWorkspaceFileIndex({
274328
text: createFileSearchPreview(row.content, pattern, undefined, {
275329
prefixOmitted: row.segmentStart > 0,
276330
suffixOmitted: row.segmentStart + row.content.length < row.lineLength,
331+
matchRange:
332+
pattern.mode === 'regex'
333+
? toSegmentRange(row.content, row.matchStart, row.matchEnd)
334+
: undefined,
277335
}),
278336
}))
279337
signal?.throwIfAborted()

apps/sim/lib/workspace-files/search/text.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
FILE_SEARCH_SEGMENT_CHARS,
55
FILE_SEARCH_SEGMENT_OVERLAP_CHARS,
66
} from '@/lib/workspace-files/search/constants'
7-
import type { CompiledFileSearchPattern } from '@/lib/workspace-files/search/pattern'
7+
import type {
8+
CompiledFileSearchPattern,
9+
FileSearchMatchRange,
10+
} from '@/lib/workspace-files/search/pattern'
811

912
export interface LogicalLine {
1013
lineNumber: number
@@ -106,17 +109,21 @@ export function truncateUtf8ToBytes(text: string, maxBytes: number): string {
106109
/**
107110
* Renders one matching segment as a bounded, match-centred excerpt.
108111
*
109-
* The pattern locates the match so the excerpt is cut around it rather than at
110-
* the head of the line. A pattern that PostgreSQL matched but that finds no
111-
* range here — the narrow cases where PostgreSQL's and JavaScript's character
112-
* classes disagree, such as a non-ASCII digit under `\d` — still renders, just
112+
* The excerpt is cut around the match rather than at the head of the line.
113+
* `matchRange` carries a match the caller already located — which is how a
114+
* regex match arrives, since only PostgreSQL may run one — and otherwise the
115+
* pattern locates its own. A match that neither can place still renders,
113116
* anchored at the start of the segment.
114117
*/
115118
export function createFileSearchPreview(
116119
line: string,
117120
pattern: CompiledFileSearchPattern,
118121
maxBytes = FILE_SEARCH_MAX_PREVIEW_BYTES,
119-
boundaries: { prefixOmitted?: boolean; suffixOmitted?: boolean } = {}
122+
boundaries: {
123+
prefixOmitted?: boolean
124+
suffixOmitted?: boolean
125+
matchRange?: FileSearchMatchRange | null
126+
} = {}
120127
): string {
121128
const boundaryBytes =
122129
(boundaries.prefixOmitted ? Buffer.byteLength('…', 'utf8') : 0) +
@@ -125,7 +132,8 @@ export function createFileSearchPreview(
125132
return `${boundaries.prefixOmitted ? '…' : ''}${line}${boundaries.suffixOmitted ? '…' : ''}`
126133
}
127134

128-
const { start: matchStart, end: matchEnd } = pattern.findMatchRange(line) ?? { start: 0, end: 0 }
135+
const { start: matchStart, end: matchEnd } = boundaries.matchRange ??
136+
pattern.findMatchRange(line) ?? { start: 0, end: 0 }
129137
const leadingEllipsis = boundaries.prefixOmitted || matchStart > 0 ? '…' : ''
130138
const trailingEllipsis = boundaries.suffixOmitted || matchEnd < line.length ? '…' : ''
131139
const ellipsisBytes = Buffer.byteLength(leadingEllipsis + trailingEllipsis, 'utf8')

apps/sim/tools/generated/tool-metadata.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)