Skip to content

Commit 92893a7

Browse files
improve search tool
1 parent 1055b77 commit 92893a7

File tree

4 files changed

+72
-259
lines changed

4 files changed

+72
-259
lines changed

packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
11
'use client';
22

33
import { SearchCodeToolUIPart } from "@/features/chat/tools";
4-
import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
4+
import { isServiceError } from "@/lib/utils";
55
import { useMemo, useState } from "react";
66
import { FileListItem, ToolHeader, TreeList } from "./shared";
77
import { CodeSnippet } from "@/app/components/codeSnippet";
88
import { Separator } from "@/components/ui/separator";
99
import { SearchIcon } from "lucide-react";
10-
import Link from "next/link";
11-
import { SearchQueryParams } from "@/lib/types";
12-
import { PlayIcon } from "@radix-ui/react-icons";
13-
import { buildSearchQuery } from "@/features/chat/utils";
14-
import { SINGLE_TENANT_ORG_DOMAIN } from "@/lib/constants";
1510

1611
export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }) => {
1712
const [isExpanded, setIsExpanded] = useState(false);
@@ -21,14 +16,7 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }
2116
return '';
2217
}
2318

24-
const query = buildSearchQuery({
25-
query: part.input.queryRegexp,
26-
repoNamesFilterRegexp: part.input.repoNamesFilterRegexp,
27-
languageNamesFilter: part.input.languageNamesFilter,
28-
fileNamesFilterRegexp: part.input.fileNamesFilterRegexp,
29-
});
30-
31-
return query;
19+
return part.input.query;
3220
}, [part]);
3321

3422
const label = useMemo(() => {
@@ -76,15 +64,6 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart }
7664
})}
7765
</TreeList>
7866
)}
79-
<Link
80-
href={createPathWithQueryParams(`/${SINGLE_TENANT_ORG_DOMAIN}/search`,
81-
[SearchQueryParams.query, part.output.query],
82-
)}
83-
className='flex flex-row items-center gap-2 text-sm text-muted-foreground mt-2 ml-auto w-fit hover:text-foreground'
84-
>
85-
<PlayIcon className='h-4 w-4' />
86-
Manually run query
87-
</Link>
8867
</>
8968
)}
9069
<Separator className='ml-[7px] my-2' />

packages/web/src/features/chat/tools.ts

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import { InferToolInput, InferToolOutput, InferUITool, tool, ToolUIPart } from "
44
import { isServiceError } from "@/lib/utils";
55
import { FileSourceResponse, getFileSource } from '@/features/git';
66
import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "../codeNav/api";
7-
import { addLineNumbers, buildSearchQuery } from "./utils";
7+
import { addLineNumbers } from "./utils";
88
import { toolNames } from "./constants";
99
import { listReposQueryParamsSchema } from "@/lib/schemas";
1010
import { ListReposQueryParams } from "@/lib/types";
1111
import { listRepos } from "@/app/api/(server)/repos/listReposApi";
12+
import escapeStringRegexp from "escape-string-regexp";
1213

1314
// @NOTE: When adding a new tool, follow these steps:
1415
// 1. Add the tool to the `toolNames` constant in `constants.ts`.
@@ -114,7 +115,6 @@ export const readFilesTool = tool({
114115
path,
115116
repo: repository,
116117
ref: revision,
117-
// @todo(mt): handle multi-tenancy.
118118
});
119119
}));
120120

@@ -138,58 +138,89 @@ export type ReadFilesToolInput = InferToolInput<typeof readFilesTool>;
138138
export type ReadFilesToolOutput = InferToolOutput<typeof readFilesTool>;
139139
export type ReadFilesToolUIPart = ToolUIPart<{ [toolNames.readFiles]: ReadFilesTool }>
140140

141+
const DEFAULT_SEARCH_LIMIT = 100;
142+
141143
export const createCodeSearchTool = (selectedRepos: string[]) => tool({
142-
description: `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search.
143-
Results are returned as an array of matching files, with the file's URL, repository, and language.`,
144+
description: `Searches for code that matches the provided search query as a substring by default, or as a regular expression if useRegex is true. Useful for exploring remote repositories by searching for exact symbols, functions, variables, or specific code patterns. To determine if a repository is indexed, use the \`listRepos\` tool. By default, searches are global and will search the default branch of all repositories. Searches can be scoped to specific repositories, languages, and branches.`,
144145
inputSchema: z.object({
145-
queryRegexp: z
146+
query: z
146147
.string()
147-
.describe(`The regex pattern to search for in the code.
148-
149-
Queries consist of space-seperated regular expressions. Wrapping expressions in "" combines them. By default, a file must have at least one match for each expression to be included. Examples:
150-
151-
\`foo\` - Match files with regex /foo/
152-
\`foo bar\` - Match files with regex /foo/ and /bar/
153-
\`"foo bar"\` - Match files with regex /foo bar/
154-
\`console.log\` - Match files with regex /console.log/
155-
156-
Multiple expressions can be or'd together with or, negated with -, or grouped with (). Examples:
157-
\`foo or bar\` - Match files with regex /foo/ or /bar/
158-
\`foo -bar\` - Match files with regex /foo/ but not /bar/
159-
\`foo (bar or baz)\` - Match files with regex /foo/ and either /bar/ or /baz/
160-
`),
161-
repoNamesFilterRegexp: z
148+
.describe(`The search pattern to match against code contents. Do not escape quotes in your query.`)
149+
// Escape backslashes first, then quotes, and wrap in double quotes
150+
// so the query is treated as a literal phrase (like grep).
151+
.transform((val) => {
152+
const escaped = val.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
153+
return `"${escaped}"`;
154+
}),
155+
useRegex: z
156+
.boolean()
157+
.describe(`Whether to use regular expression matching to match the search query against code contents. When false, substring matching is used. (default: false)`)
158+
.optional(),
159+
filterByRepos: z
162160
.array(z.string())
163-
.describe(`Filter results from repos that match the regex. By default all repos are searched.`)
161+
.describe(`Scope the search to the provided repositories.`)
164162
.optional(),
165-
languageNamesFilter: z
163+
filterByLanguages: z
166164
.array(z.string())
167-
.describe(`Scope the search to the provided languages. The language MUST be formatted as a GitHub linguist language. Examples: Python, JavaScript, TypeScript, Java, C#, C++, PHP, Go, Rust, Ruby, Swift, Kotlin, Shell, C, Dart, HTML, CSS, PowerShell, SQL, R`)
165+
.describe(`Scope the search to the provided languages.`)
168166
.optional(),
169-
fileNamesFilterRegexp: z
167+
filterByFilepaths: z
170168
.array(z.string())
171-
.describe(`Filter results from filepaths that match the regex. When this option is not specified, all files are searched.`)
169+
.describe(`Scope the search to the provided filepaths.`)
170+
.optional(),
171+
caseSensitive: z
172+
.boolean()
173+
.describe(`Whether the search should be case sensitive (default: false).`)
174+
.optional(),
175+
ref: z
176+
.string()
177+
.describe(`Commit SHA, branch or tag name to search on. If not provided, defaults to the default branch (usually 'main' or 'master').`)
178+
.optional(),
179+
limit: z
180+
.number()
181+
.default(DEFAULT_SEARCH_LIMIT)
182+
.describe(`Maximum number of matches to return (default: ${DEFAULT_SEARCH_LIMIT})`)
172183
.optional(),
173-
limit: z.number().default(10).describe("Maximum number of matches to return (default: 100)"),
174184
}),
175-
execute: async ({ queryRegexp: _query, repoNamesFilterRegexp, languageNamesFilter, fileNamesFilterRegexp, limit }) => {
176-
const query = buildSearchQuery({
177-
query: _query,
178-
repoNamesFilter: selectedRepos,
179-
repoNamesFilterRegexp,
180-
languageNamesFilter,
181-
fileNamesFilterRegexp,
182-
});
185+
execute: async ({
186+
query,
187+
useRegex = false,
188+
filterByRepos: repos = [],
189+
filterByLanguages: languages = [],
190+
filterByFilepaths: filepaths = [],
191+
caseSensitive = false,
192+
ref,
193+
limit = DEFAULT_SEARCH_LIMIT,
194+
}) => {
195+
196+
if (selectedRepos.length > 0) {
197+
query += ` reposet:${selectedRepos.join(',')}`;
198+
}
199+
200+
if (repos.length > 0) {
201+
query += ` (repo:${repos.map(id => escapeStringRegexp(id)).join(' or repo:')})`;
202+
}
203+
204+
if (languages.length > 0) {
205+
query += ` (lang:${languages.join(' or lang:')})`;
206+
}
207+
208+
if (filepaths.length > 0) {
209+
query += ` (file:${filepaths.map(filepath => escapeStringRegexp(filepath)).join(' or file:')})`;
210+
}
211+
212+
if (ref) {
213+
query += ` (rev:${ref})`;
214+
}
183215

184216
const response = await search({
185217
queryType: 'string',
186218
query,
187219
options: {
188-
matches: limit ?? 100,
220+
matches: limit,
189221
contextLines: 3,
190-
whole: false,
191-
isCaseSensitivityEnabled: true,
192-
isRegexEnabled: true,
222+
isCaseSensitivityEnabled: caseSensitive,
223+
isRegexEnabled: useRegex,
193224
}
194225
});
195226

packages/web/src/features/chat/utils.test.ts

Lines changed: 1 addition & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test, vi } from 'vitest'
2-
import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, buildSearchQuery } from './utils'
2+
import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils'
33
import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants';
44
import { SBChatMessage, SBChatMessagePart } from './types';
55

@@ -351,164 +351,3 @@ test('repairReferences handles malformed inline code blocks', () => {
351351
const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.';
352352
expect(repairReferences(input)).toBe(expected);
353353
});
354-
355-
test('buildSearchQuery returns base query when no filters provided', () => {
356-
const result = buildSearchQuery({
357-
query: 'console.log'
358-
});
359-
360-
expect(result).toBe('console.log');
361-
});
362-
363-
test('buildSearchQuery adds repoNamesFilter correctly', () => {
364-
const result = buildSearchQuery({
365-
query: 'function test',
366-
repoNamesFilter: ['repo1', 'repo2']
367-
});
368-
369-
expect(result).toBe('function test reposet:repo1,repo2');
370-
});
371-
372-
test('buildSearchQuery adds single repoNamesFilter correctly', () => {
373-
const result = buildSearchQuery({
374-
query: 'function test',
375-
repoNamesFilter: ['myrepo']
376-
});
377-
378-
expect(result).toBe('function test reposet:myrepo');
379-
});
380-
381-
test('buildSearchQuery ignores empty repoNamesFilter', () => {
382-
const result = buildSearchQuery({
383-
query: 'function test',
384-
repoNamesFilter: []
385-
});
386-
387-
expect(result).toBe('function test');
388-
});
389-
390-
test('buildSearchQuery adds languageNamesFilter correctly', () => {
391-
const result = buildSearchQuery({
392-
query: 'class definition',
393-
languageNamesFilter: ['typescript', 'javascript']
394-
});
395-
396-
expect(result).toBe('class definition ( lang:typescript or lang:javascript )');
397-
});
398-
399-
test('buildSearchQuery adds single languageNamesFilter correctly', () => {
400-
const result = buildSearchQuery({
401-
query: 'class definition',
402-
languageNamesFilter: ['python']
403-
});
404-
405-
expect(result).toBe('class definition ( lang:python )');
406-
});
407-
408-
test('buildSearchQuery ignores empty languageNamesFilter', () => {
409-
const result = buildSearchQuery({
410-
query: 'class definition',
411-
languageNamesFilter: []
412-
});
413-
414-
expect(result).toBe('class definition');
415-
});
416-
417-
test('buildSearchQuery adds fileNamesFilterRegexp correctly', () => {
418-
const result = buildSearchQuery({
419-
query: 'import statement',
420-
fileNamesFilterRegexp: ['*.ts', '*.js']
421-
});
422-
423-
expect(result).toBe('import statement ( file:*.ts or file:*.js )');
424-
});
425-
426-
test('buildSearchQuery adds single fileNamesFilterRegexp correctly', () => {
427-
const result = buildSearchQuery({
428-
query: 'import statement',
429-
fileNamesFilterRegexp: ['*.tsx']
430-
});
431-
432-
expect(result).toBe('import statement ( file:*.tsx )');
433-
});
434-
435-
test('buildSearchQuery ignores empty fileNamesFilterRegexp', () => {
436-
const result = buildSearchQuery({
437-
query: 'import statement',
438-
fileNamesFilterRegexp: []
439-
});
440-
441-
expect(result).toBe('import statement');
442-
});
443-
444-
test('buildSearchQuery adds repoNamesFilterRegexp correctly', () => {
445-
const result = buildSearchQuery({
446-
query: 'bug fix',
447-
repoNamesFilterRegexp: ['org/repo1', 'org/repo2']
448-
});
449-
450-
expect(result).toBe('bug fix ( repo:org/repo1 or repo:org/repo2 )');
451-
});
452-
453-
test('buildSearchQuery adds single repoNamesFilterRegexp correctly', () => {
454-
const result = buildSearchQuery({
455-
query: 'bug fix',
456-
repoNamesFilterRegexp: ['myorg/myrepo']
457-
});
458-
459-
expect(result).toBe('bug fix ( repo:myorg/myrepo )');
460-
});
461-
462-
test('buildSearchQuery ignores empty repoNamesFilterRegexp', () => {
463-
const result = buildSearchQuery({
464-
query: 'bug fix',
465-
repoNamesFilterRegexp: []
466-
});
467-
468-
expect(result).toBe('bug fix');
469-
});
470-
471-
test('buildSearchQuery combines multiple filters correctly', () => {
472-
const result = buildSearchQuery({
473-
query: 'authentication',
474-
repoNamesFilter: ['backend', 'frontend'],
475-
languageNamesFilter: ['typescript', 'javascript'],
476-
fileNamesFilterRegexp: ['*.ts', '*.js'],
477-
repoNamesFilterRegexp: ['org/auth-*']
478-
});
479-
480-
expect(result).toBe(
481-
'authentication reposet:backend,frontend ( lang:typescript or lang:javascript ) ( file:*.ts or file:*.js ) ( repo:org/auth-* )'
482-
);
483-
});
484-
485-
test('buildSearchQuery handles mixed empty and non-empty filters', () => {
486-
const result = buildSearchQuery({
487-
query: 'error handling',
488-
repoNamesFilter: [],
489-
languageNamesFilter: ['python'],
490-
fileNamesFilterRegexp: [],
491-
repoNamesFilterRegexp: ['error/*']
492-
});
493-
494-
expect(result).toBe('error handling ( lang:python ) ( repo:error/* )');
495-
});
496-
497-
test('buildSearchQuery handles empty base query', () => {
498-
const result = buildSearchQuery({
499-
query: '',
500-
repoNamesFilter: ['repo1'],
501-
languageNamesFilter: ['typescript']
502-
});
503-
504-
expect(result).toBe(' reposet:repo1 ( lang:typescript )');
505-
});
506-
507-
test('buildSearchQuery handles query with special characters', () => {
508-
const result = buildSearchQuery({
509-
query: 'console.log("hello world")',
510-
repoNamesFilter: ['test-repo']
511-
});
512-
513-
expect(result).toBe('console.log("hello world") reposet:test-repo');
514-
});

0 commit comments

Comments
 (0)