diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f4d08c7..efbfea035 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Made the default home page configurable with `DEFAULT_HOME_VIEW_PAGE`, defaulting to Code Search and supporting Ask. [#1677](https://github.com/sourcebot-dev/sourcebot/pull/1677) - Require authentication for the streaming and blocking Ask APIs in Public SaaS deployments. [#1679](https://github.com/sourcebot-dev/sourcebot/pull/1679) +- Fixed keyword search treating backslash escapes in quoted terms (e.g. `"foo \"bar\""`) as literal characters. [#1688](https://github.com/sourcebot-dev/sourcebot/pull/1688) ## [5.1.14] - 2026-09-17 diff --git a/packages/web/src/features/search/parser.test.ts b/packages/web/src/features/search/parser.test.ts index 69c4fcb47..50c291ae7 100644 --- a/packages/web/src/features/search/parser.test.ts +++ b/packages/web/src/features/search/parser.test.ts @@ -42,4 +42,86 @@ describe('parseQuerySyntaxIntoIR', () => { expect(JSON.stringify(ir)).toContain('org/repo-a'); expect(JSON.stringify(ir)).toContain('org/repo-b'); }); + + describe('quoted keyword terms', () => { + const prisma = {} as unknown as PrismaClient; + + it('unescapes escaped quotes so the literal phrase is searched', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: '"foo \\"bar\\""', + options: {}, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'substring', + substring: { pattern: 'foo "bar"' }, + }); + }); + + it('unescapes escaped backslashes', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: '"path\\\\to\\\\file"', + options: {}, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'substring', + substring: { pattern: 'path\\to\\file' }, + }); + }); + + it('unescapes a backslash-escaped newline', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: '"line1\\\nline2"', + options: {}, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'substring', + substring: { pattern: 'line1\nline2' }, + }); + }); + + it('preserves backslashes before other characters', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: '"C:\\temp"', + options: {}, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'substring', + substring: { pattern: 'C:\\temp' }, + }); + }); + + it('unescapes a quoted content: value', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: 'content:"say \\"hi\\""', + options: {}, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'substring', + substring: { pattern: 'say "hi"' }, + }); + }); + + it('leaves escapes to the regex engine in regex mode', async () => { + const ir = await parseQuerySyntaxIntoIR({ + query: '"foo \\"bar\\""', + options: { isRegexEnabled: true }, + prisma, + }); + + expect(ir).toMatchObject({ + query: 'regexp', + regexp: { regexp: 'foo \\"bar\\"' }, + }); + }); + }); }); diff --git a/packages/web/src/features/search/parser.ts b/packages/web/src/features/search/parser.ts index bb3ee5d77..e980e2af2 100644 --- a/packages/web/src/features/search/parser.ts +++ b/packages/web/src/features/search/parser.ts @@ -71,6 +71,16 @@ const findLinguistLanguage = (value: string): string => { return languageKeyLowerCaseMap.get(value.toLowerCase()) ?? value; } +/** + * Quoted strings may escape quotes, backslashes, and line terminators. Keyword + * search matches patterns literally, so those escapes are resolved here. Other + * backslashes remain literal (e.g. in Windows paths). In regex mode escapes + * are left in place for the regex engine to interpret. + */ +const unescapeQuotedString = (value: string): string => { + return value.replace(/\\(["\\\r\n])/g, '$1'); +} + /** * Given a query string, parses it into the query intermediate representation. */ @@ -221,7 +231,7 @@ const transformTreeToIR = async ({ query: "regexp" } : { substring: { - pattern: termText, + pattern: node.type.id === QuotedTerm ? unescapeQuotedString(termText) : termText, case_sensitive: isCaseSensitivityEnabled, file_name: false, content: true @@ -252,7 +262,9 @@ const transformTreeToIR = async ({ } // Get the value part after the colon and remove quotes if present - const value = fullText.substring(colonIndex + 1).replace(/^"|"$/g, ''); + const rawValue = fullText.substring(colonIndex + 1); + const isQuoted = rawValue.length >= 2 && rawValue.startsWith('"') && rawValue.endsWith('"'); + const value = rawValue.replace(/^"|"$/g, ''); switch (prefixTypeId) { case FileExpr: @@ -296,7 +308,7 @@ const transformTreeToIR = async ({ query: "regexp" } : { substring: { - pattern: value, + pattern: isQuoted ? unescapeQuotedString(value) : value, case_sensitive: isCaseSensitivityEnabled, file_name: false, content: true