Skip to content

Commit a0ab9a5

Browse files
icecrasher321claude
andcommitted
fix(file): keep long previews honest, credit forced repeats, drop bad hints
Four review findings, each reproduced before it was changed. A regex match has no length limit, so `abc.*` on a long line produces a match larger than the whole preview budget. The layout passed it through whole and let the final byte cap cut it, which removed the closing marker along with the text — 2048 bytes of output ending mid-line with nothing to say so. The match is now clipped against a budget that reserves that marker, and a clipped match always carries one. A variable repeat was scored at one occurrence when its minimum forces more: `(?:ab){2,5}` cannot match without `abab` in it, but the run was counted as 2 and the pattern rejected against a gate of 3. It now contributes the copies its minimum forces. `\Y`, `\m` and `\M` were rejected with a suggestion to write `\b`, `^` or `$`. Those are different assertions — a non-boundary, and two word edges rather than the line's — so the hint handed back different semantics as a fix. They now say no supported escape means the same thing. `\y`, `\A` and `\Z` keep theirs, which are genuine. Smart case was documented as reacting to any uppercase letter, but it reads literals only, so `\D` and `[A-Z]` do not make a search case-sensitive. The tool, block and generated docs now say what the code does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 0ee6535 commit a0ab9a5

8 files changed

Lines changed: 90 additions & 19 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Search every active workspace file for lines matching a regular expression, and
7676

7777
| Parameter | Type | Required | Description |
7878
| --------- | ---- | -------- | ----------- |
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. |
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 you are searching for; uppercase inside an escape or a character class, such as \D or \[A-Z\], does not make it case-sensitive. |
8080
| `mode` | string | No | How the query is read, chosen by the workflow builder: "regex" \(default\) as a regular expression, or "exact" as verbatim text. |
8181
| `maxResults` | number | No | Hard result cap configured by the workflow builder \(1-200, default 50\). |
8282

apps/sim/blocks/blocks/file.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -908,7 +908,7 @@ export const FileV5Block: BlockConfig<FileParserV3Output> = {
908908
- Get Content is how you read file text. It accepts file objects or canonical file IDs and returns a "contents" array with one extracted text string per file (PDF, DOCX, CSV, etc. are parsed automatically).
909909
- To read the text of files produced by another block, chain into Get Content: set its file input to the upstream file output, e.g. <file.files>, <agent.files>, or <start.files>. Never assume Read (or any file-object output) already contains the text.
910910
- Get Content's "contents" can be large; it is persisted through the execution large-value system automatically, so prefer it over inlining file text any other way.
911-
- Search finds text across all active workspace files and returns structured results with fileId, lineNumber, and text. Lowercase queries are case-insensitive; adding any uppercase letter makes the search case-sensitive.
911+
- Search finds text across all active workspace files and returns structured results with fileId, lineNumber, and text. Queries are case-insensitive until they contain an uppercase letter being searched for; in a regular expression, uppercase inside an escape or character class such as \\D or [A-Z] does not affect this.
912912
- Search reads the query as a line-oriented regular expression: quantifiers, character classes, \\d \\w \\s, alternation, groups, "^" and "$" anchors, and \\b word boundaries. Lookaround, backreferences and patterns spanning a line break are not supported, and a pattern needs at least 3 consecutive literal characters that every match will contain. Set Match to "Exact match" to search for the query text verbatim instead.
913913
- Match is a builder setting, not an agent one: the agent writes the query, and Match decides how every query from that block is read.
914914
- Search is eventually consistent. Check "complete" and "indexStatus" when pending, failed, skipped, or partially indexed files matter to the task.

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@ describe('analyzeFileSearchRegex', () => {
3030
expect(run('(?:xyz)?ab')).toBe(2)
3131
expect(run('foo+bar')).toBe(4)
3232
expect(run('ab{3}cd')).toBe(6)
33-
expect(run('ab{2,4}cd')).toBe(3)
33+
})
34+
35+
/** `(?:ab){2,5}` cannot match without `abab` in it, so the run is 4, not 2. */
36+
it('credits a variable repeat with the copies its minimum forces', () => {
37+
expect(run('(?:ab){2,5}')).toBe(4)
38+
expect(run('(?:abc){2,}')).toBe(6)
39+
expect(run('ab{2,4}cd')).toBe(4)
40+
expect(run('abc*d')).toBe(2)
41+
expect(run('foo+bar')).toBe(4)
3442
})
3543

3644
it('measures a run in characters, not UTF-16 units', () => {
@@ -75,6 +83,10 @@ describe('analyzeFileSearchRegex', () => {
7583
['[[:alpha:]]foo', /POSIX class/],
7684
['\\p{Lu}foo', /Unicode property/],
7785
['\\yfoo\\y', /"\\y" is not supported write "\\b" instead/],
86+
['\\Afoo\\Z', /"\\A" is not supported write "\^" instead/],
87+
['a\\Yb needle', /"\\Y" is not supported, and no supported escape means the same thing/],
88+
['a\\mb needle', /"\\m" is not supported, and no supported escape means the same thing/],
89+
['a\\Mb needle', /"\\M" is not supported, and no supported escape means the same thing/],
7890
['foo\\qbar', /not a supported escape/],
7991
['foo[\\b]bar', /inside "\[\.\.\.\]"/],
8092
['foo(bar', /Unclosed "\("/],

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

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,18 @@ const OPAQUE: LiteralGuarantee = {
6565
/** Escapes whose meaning and spelling are identical in PostgreSQL ARE and JavaScript. */
6666
const SHARED_ESCAPE_LETTERS = new Set(['d', 'D', 'w', 'W', 's', 'S', 't', 'n', 'r', 'f', 'v'])
6767

68-
/** PostgreSQL-only escapes, rejected so one spelling means one thing in both engines. */
69-
const POSTGRES_ONLY_ESCAPES: Record<string, string> = {
68+
/**
69+
* PostgreSQL-only escapes, rejected so one spelling means one thing in both
70+
* engines. Only the three with a genuine equivalent name one: `\Y` is a
71+
* *non*-boundary, and `\m` / `\M` bind to a word edge rather than the line's,
72+
* so pointing them at `\b` / `^` / `$` would hand back different semantics
73+
* under the guise of a fix.
74+
*/
75+
const POSTGRES_ONLY_ESCAPES: Record<string, string | null> = {
7076
y: '\\b',
71-
Y: '\\b',
72-
m: '^',
73-
M: '$',
77+
Y: null,
78+
m: null,
79+
M: null,
7480
A: '^',
7581
Z: '$',
7682
}
@@ -154,9 +160,14 @@ function alternate(left: LiteralGuarantee, right: LiteralGuarantee): LiteralGuar
154160
}
155161

156162
/**
157-
* `atom` repeated between `min` and `max` times. An optional atom guarantees
158-
* nothing, and a variable count guarantees one occurrence but nothing joined
159-
* across the repetition — `fo` + `o+` still guarantees `foo`, never `foo…o`.
163+
* `atom` repeated between `min` and `max` times.
164+
*
165+
* An optional atom guarantees nothing. A variable count is not exact, but it
166+
* still guarantees `min` copies back to back — every match of `(?:ab){2,5}`
167+
* contains `abab` — so a fixed atom contributes that expansion rather than the
168+
* single occurrence it would otherwise be scored at. An atom with no fixed
169+
* string contributes only what one occurrence guarantees, since nothing joins
170+
* across the repetition: `fo` + `o+` guarantees `foo`, never `foo…o`.
160171
*/
161172
function repeat(atom: LiteralGuarantee, min: number, max: number): LiteralGuarantee {
162173
if (min === 0) return { ...OPAQUE, zeroWidth: true }
@@ -167,6 +178,17 @@ function repeat(atom: LiteralGuarantee, min: number, max: number): LiteralGuaran
167178
return { ...literal(expanded), zeroWidth: expanded.length === 0 }
168179
}
169180
}
181+
if (atom.exact) {
182+
const copies = Math.min(min, Math.ceil(FILE_SEARCH_PATTERN_LITERAL_CAP / atom.exact.length))
183+
const expanded = atom.exact.repeat(Math.max(1, copies))
184+
return {
185+
exact: null,
186+
prefix: head(expanded),
187+
suffix: tail(expanded),
188+
best: Math.min(FILE_SEARCH_PATTERN_LITERAL_CAP, runLength(atom.exact) * min),
189+
zeroWidth: false,
190+
}
191+
}
170192
return {
171193
exact: null,
172194
prefix: atom.prefix,
@@ -415,10 +437,12 @@ class FileSearchRegexParser {
415437
`Backreference "\\${next}" is not supported — repeat the group's pattern instead`
416438
)
417439
}
418-
const postgresOnly = POSTGRES_ONLY_ESCAPES[next]
419-
if (postgresOnly) {
440+
if (next in POSTGRES_ONLY_ESCAPES) {
441+
const equivalent = POSTGRES_ONLY_ESCAPES[next]
420442
throw new FileSearchPatternError(
421-
`"\\${next}" is not supported — write "${postgresOnly}" instead`
443+
equivalent
444+
? `"\\${next}" is not supported — write "${equivalent}" instead`
445+
: `"\\${next}" is not supported, and no supported escape means the same thing`
422446
)
423447
}
424448
if (SHARED_ESCAPE_LETTERS.has(next)) return

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,28 @@ describe('workspace file search text utilities', () => {
7979
).toBe('…needle and nearby text…')
8080
})
8181

82+
/**
83+
* A regex match runs to wherever the pattern takes it, so `abc.*` on a long
84+
* line produces a match larger than the whole preview budget.
85+
*/
86+
it('marks a preview whose match alone overruns the budget as truncated', () => {
87+
const line = `head ${'x'.repeat(500)}abc${'y'.repeat(4000)} tail`
88+
const start = line.indexOf('abc')
89+
const preview = createFileSearchPreview(
90+
line,
91+
compileFileSearchPattern('abc.*', 'regex'),
92+
2048,
93+
{
94+
matchRange: { start, end: line.length },
95+
}
96+
)
97+
98+
expect(Buffer.byteLength(preview, 'utf8')).toBeLessThanOrEqual(2048)
99+
expect(preview.endsWith('…')).toBe(true)
100+
expect(preview).toContain('abc')
101+
expect(preview).not.toContain('\uFFFD')
102+
})
103+
82104
it('truncates extracted text on a UTF-8 boundary', () => {
83105
const truncated = truncateUtf8ToBytes('abc🙂def', 6)
84106
expect(truncated).toBe('abc')

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,20 @@ export function createFileSearchPreview(
135135
const { start: matchStart, end: matchEnd } = boundaries.matchRange ??
136136
pattern.findMatchRange(line) ?? { start: 0, end: 0 }
137137
const leadingEllipsis = boundaries.prefixOmitted || matchStart > 0 ? '…' : ''
138-
const trailingEllipsis = boundaries.suffixOmitted || matchEnd < line.length ? '…' : ''
138+
/**
139+
* A regex match has no length limit — `abc.*` matches to the end of the line —
140+
* so the match alone can exceed the budget. Clipping it here, against a budget
141+
* that already reserves the closing marker, is what keeps the excerpt honest:
142+
* letting the final cap do it would drop that marker along with the text and
143+
* leave a truncated line looking complete.
144+
*/
145+
const budgetBeforeMatch = Math.max(0, maxBytes - Buffer.byteLength(`${leadingEllipsis}…`, 'utf8'))
146+
const fullMatch = line.slice(matchStart, matchEnd)
147+
const match = utf8PrefixWithinBudget(fullMatch, budgetBeforeMatch)
148+
const matchClipped = match.length < fullMatch.length
149+
const trailingEllipsis =
150+
matchClipped || boundaries.suffixOmitted || matchEnd < line.length ? '…' : ''
139151
const ellipsisBytes = Buffer.byteLength(leadingEllipsis + trailingEllipsis, 'utf8')
140-
const match = line.slice(matchStart, matchEnd)
141152
const matchBytes = Buffer.byteLength(match, 'utf8')
142153
const surroundingBudget = Math.max(0, maxBytes - ellipsisBytes - matchBytes)
143154
const beforeBudget = Math.floor(surroundingBudget / 2)
@@ -146,6 +157,8 @@ export function createFileSearchPreview(
146157
const after = utf8PrefixWithinBudget(line.slice(matchEnd), afterBudget)
147158
const preview = `${boundaries.prefixOmitted || before.length < matchStart ? '…' : ''}${
148159
before
149-
}${match}${after}${boundaries.suffixOmitted || matchEnd + after.length < line.length ? '…' : ''}`
160+
}${match}${after}${
161+
matchClipped || boundaries.suffixOmitted || matchEnd + after.length < line.length ? '…' : ''
162+
}`
150163
return utf8PrefixWithinBudget(preview, maxBytes)
151164
}

apps/sim/tools/file/search.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const TOOL_DESCRIPTIONS: Record<FileSearchMode, string> = {
2828

2929
const QUERY_DESCRIPTIONS: Record<FileSearchMode, string> = {
3030
regex:
31-
'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.',
31+
'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 you are searching for; uppercase inside an escape or a character class, such as \\D or [A-Z], does not make it case-sensitive.',
3232
exact:
3333
'The exact text to find, 3-512 characters. It is matched verbatim: ".", "*", "(" and every other regular-expression metacharacter is searched for as itself, so nothing needs escaping. Matching is case-insensitive until the text contains an uppercase letter, which makes it case-sensitive.',
3434
}

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)