From 2e722e3fd7977121016efcba3bf3e58682db2ff3 Mon Sep 17 00:00:00 2001 From: bkellam Date: Sun, 27 Jul 2025 16:33:05 -0700 Subject: [PATCH] wip --- .../tools/searchCodeToolComponent.tsx | 4 +- packages/web/src/features/chat/tools.ts | 44 ++++- packages/web/src/features/chat/utils.test.ts | 163 +++++++++++++++++- packages/web/src/features/chat/utils.ts | 36 ++++ 4 files changed, 237 insertions(+), 10 deletions(-) diff --git a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx index a4d961fa..a7792ade 100644 --- a/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx +++ b/packages/web/src/features/chat/components/chatThread/tools/searchCodeToolComponent.tsx @@ -22,11 +22,11 @@ export const SearchCodeToolComponent = ({ part }: { part: SearchCodeToolUIPart } case 'input-streaming': return 'Searching...'; case 'input-available': - return Searching for {part.input.query}; + return Searching for {part.input.queryRegexp}; case 'output-error': return '"Search code" tool call failed'; case 'output-available': - return Searched for {part.input.query}; + return Searched for {part.input.queryRegexp}; } }, [part]); diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts index 4149635a..3a6be519 100644 --- a/packages/web/src/features/chat/tools.ts +++ b/packages/web/src/features/chat/tools.ts @@ -6,7 +6,7 @@ import { isServiceError } from "@/lib/utils"; import { getFileSource } from "../search/fileSourceApi"; import { findSearchBasedSymbolDefinitions, findSearchBasedSymbolReferences } from "../codeNav/actions"; import { FileSourceResponse } from "../search/types"; -import { addLineNumbers } from "./utils"; +import { addLineNumbers, buildSearchQuery } from "./utils"; import { toolNames } from "./constants"; // @NOTE: When adding a new tool, follow these steps: @@ -139,13 +139,43 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({ description: `Fetches code that matches the provided regex pattern in \`query\`. This is NOT a semantic search. Results are returned as an array of matching files, with the file's URL, repository, and language.`, inputSchema: z.object({ - query: z.string().describe("The regex pattern to search for in the code"), + queryRegexp: z + .string() + .describe(`The regex pattern to search for in the code. + +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: + +\`foo\` - Match files with regex /foo/ +\`foo bar\` - Match files with regex /foo/ and /bar/ +\`"foo bar"\` - Match files with regex /foo bar/ +\`console\.log\` - Match files with regex /console\.log/ + +Multiple expressions can be or'd together with or, negated with -, or grouped with (). Examples: +\`foo or bar\` - Match files with regex /foo/ or /bar/ +\`foo -bar\` - Match files with regex /foo/ but not /bar/ +\`foo (bar or baz)\` - Match files with regex /foo/ and either /bar/ or /baz/ +`), + repoNamesFilterRegexp: z + .array(z.string()) + .describe(`Filter results from repos that match the regex. By default all repos are searched.`) + .optional(), + languageNamesFilter: z + .array(z.string()) + .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`) + .optional(), + fileNamesFilterRegexp: z + .array(z.string()) + .describe(`Filter results from filepaths that match the regex. When this option is not specified, all files are searched.`) + .optional(), }), - execute: async ({ query: _query }) => { - let query = `${_query}`; - if (selectedRepos.length > 0) { - query += ` reposet:${selectedRepos.join(',')}`; - } + execute: async ({ queryRegexp: _query, repoNamesFilterRegexp, languageNamesFilter, fileNamesFilterRegexp }) => { + const query = buildSearchQuery({ + query: _query, + repoNamesFilter: selectedRepos, + repoNamesFilterRegexp, + languageNamesFilter, + fileNamesFilterRegexp, + }); const response = await search({ query, diff --git a/packages/web/src/features/chat/utils.test.ts b/packages/web/src/features/chat/utils.test.ts index 72390378..3ebd4e31 100644 --- a/packages/web/src/features/chat/utils.test.ts +++ b/packages/web/src/features/chat/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, vi } from 'vitest' -import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences } from './utils' +import { fileReferenceToString, getAnswerPartFromAssistantMessage, groupMessageIntoSteps, repairReferences, buildSearchQuery } from './utils' import { FILE_REFERENCE_REGEX, ANSWER_TAG } from './constants'; import { SBChatMessage, SBChatMessagePart } from './types'; @@ -350,4 +350,165 @@ test('repairReferences handles malformed inline code blocks', () => { const input = 'See `@file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts`} for details.'; const expected = 'See @file:{github.com/sourcebot-dev/sourcebot::packages/web/src/auth.ts} for details.'; expect(repairReferences(input)).toBe(expected); +}); + +test('buildSearchQuery returns base query when no filters provided', () => { + const result = buildSearchQuery({ + query: 'console.log' + }); + + expect(result).toBe('console.log'); +}); + +test('buildSearchQuery adds repoNamesFilter correctly', () => { + const result = buildSearchQuery({ + query: 'function test', + repoNamesFilter: ['repo1', 'repo2'] + }); + + expect(result).toBe('function test reposet:repo1,repo2'); +}); + +test('buildSearchQuery adds single repoNamesFilter correctly', () => { + const result = buildSearchQuery({ + query: 'function test', + repoNamesFilter: ['myrepo'] + }); + + expect(result).toBe('function test reposet:myrepo'); +}); + +test('buildSearchQuery ignores empty repoNamesFilter', () => { + const result = buildSearchQuery({ + query: 'function test', + repoNamesFilter: [] + }); + + expect(result).toBe('function test'); +}); + +test('buildSearchQuery adds languageNamesFilter correctly', () => { + const result = buildSearchQuery({ + query: 'class definition', + languageNamesFilter: ['typescript', 'javascript'] + }); + + expect(result).toBe('class definition ( lang:typescript or lang:javascript )'); +}); + +test('buildSearchQuery adds single languageNamesFilter correctly', () => { + const result = buildSearchQuery({ + query: 'class definition', + languageNamesFilter: ['python'] + }); + + expect(result).toBe('class definition ( lang:python )'); +}); + +test('buildSearchQuery ignores empty languageNamesFilter', () => { + const result = buildSearchQuery({ + query: 'class definition', + languageNamesFilter: [] + }); + + expect(result).toBe('class definition'); +}); + +test('buildSearchQuery adds fileNamesFilterRegexp correctly', () => { + const result = buildSearchQuery({ + query: 'import statement', + fileNamesFilterRegexp: ['*.ts', '*.js'] + }); + + expect(result).toBe('import statement ( file:*.ts or file:*.js )'); +}); + +test('buildSearchQuery adds single fileNamesFilterRegexp correctly', () => { + const result = buildSearchQuery({ + query: 'import statement', + fileNamesFilterRegexp: ['*.tsx'] + }); + + expect(result).toBe('import statement ( file:*.tsx )'); +}); + +test('buildSearchQuery ignores empty fileNamesFilterRegexp', () => { + const result = buildSearchQuery({ + query: 'import statement', + fileNamesFilterRegexp: [] + }); + + expect(result).toBe('import statement'); +}); + +test('buildSearchQuery adds repoNamesFilterRegexp correctly', () => { + const result = buildSearchQuery({ + query: 'bug fix', + repoNamesFilterRegexp: ['org/repo1', 'org/repo2'] + }); + + expect(result).toBe('bug fix ( repo:org/repo1 or repo:org/repo2 )'); +}); + +test('buildSearchQuery adds single repoNamesFilterRegexp correctly', () => { + const result = buildSearchQuery({ + query: 'bug fix', + repoNamesFilterRegexp: ['myorg/myrepo'] + }); + + expect(result).toBe('bug fix ( repo:myorg/myrepo )'); +}); + +test('buildSearchQuery ignores empty repoNamesFilterRegexp', () => { + const result = buildSearchQuery({ + query: 'bug fix', + repoNamesFilterRegexp: [] + }); + + expect(result).toBe('bug fix'); +}); + +test('buildSearchQuery combines multiple filters correctly', () => { + const result = buildSearchQuery({ + query: 'authentication', + repoNamesFilter: ['backend', 'frontend'], + languageNamesFilter: ['typescript', 'javascript'], + fileNamesFilterRegexp: ['*.ts', '*.js'], + repoNamesFilterRegexp: ['org/auth-*'] + }); + + expect(result).toBe( + 'authentication reposet:backend,frontend ( lang:typescript or lang:javascript ) ( file:*.ts or file:*.js ) ( repo:org/auth-* )' + ); +}); + +test('buildSearchQuery handles mixed empty and non-empty filters', () => { + const result = buildSearchQuery({ + query: 'error handling', + repoNamesFilter: [], + languageNamesFilter: ['python'], + fileNamesFilterRegexp: [], + repoNamesFilterRegexp: ['error/*'] + }); + + expect(result).toBe('error handling ( lang:python ) ( repo:error/* )'); +}); + +test('buildSearchQuery handles empty base query', () => { + const result = buildSearchQuery({ + query: '', + repoNamesFilter: ['repo1'], + languageNamesFilter: ['typescript'] + }); + + expect(result).toBe(' reposet:repo1 ( lang:typescript )'); +}); + +test('buildSearchQuery handles query with special characters', () => { + const result = buildSearchQuery({ + query: 'console.log("hello world")', + repoNamesFilter: ['test-repo'] + }); + + expect(result).toBe('console.log("hello world") reposet:test-repo'); }); \ No newline at end of file diff --git a/packages/web/src/features/chat/utils.ts b/packages/web/src/features/chat/utils.ts index d84835ef..6207cb0e 100644 --- a/packages/web/src/features/chat/utils.ts +++ b/packages/web/src/features/chat/utils.ts @@ -329,4 +329,40 @@ export const getAnswerPartFromAssistantMessage = (message: SBChatMessage, isStre } return undefined; +} + +export const buildSearchQuery = (options: { + query: string, + repoNamesFilter?: string[], + repoNamesFilterRegexp?: string[], + languageNamesFilter?: string[], + fileNamesFilterRegexp?: string[], +}) => { + const { + query: _query, + repoNamesFilter, + repoNamesFilterRegexp, + languageNamesFilter, + fileNamesFilterRegexp, + } = options; + + let query = `${_query}`; + + if (repoNamesFilter && repoNamesFilter.length > 0) { + query += ` reposet:${repoNamesFilter.join(',')}`; + } + + if (languageNamesFilter && languageNamesFilter.length > 0) { + query += ` ( lang:${languageNamesFilter.join(' or lang:')} )`; + } + + if (fileNamesFilterRegexp && fileNamesFilterRegexp.length > 0) { + query += ` ( file:${fileNamesFilterRegexp.join(' or file:')} )`; + } + + if (repoNamesFilterRegexp && repoNamesFilterRegexp.length > 0) { + query += ` ( repo:${repoNamesFilterRegexp.join(' or repo:')} )`; + } + + return query; } \ No newline at end of file