From 209b08109d208f555f9209744beab4ccbb570b54 Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Thu, 24 Sep 2026 20:22:35 -0700 Subject: [PATCH 1/3] fix(web): unescape quoted keyword search terms The query grammar accepts backslash escapes inside quoted strings, and the syntax docs say `"foo \"bar\""` matches `foo "bar"`. The IR transform only stripped the outer quotes, so keyword (non-regex) search sent the literal pattern `foo \"bar\"` to zoekt and found nothing. Resolve backslash escapes for quoted terms and quoted `content:` values in keyword mode. Regex mode still passes the escapes to the regex engine. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../web/src/features/search/parser.test.ts | 56 +++++++++++++++++++ packages/web/src/features/search/parser.ts | 17 +++++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/web/src/features/search/parser.test.ts b/packages/web/src/features/search/parser.test.ts index 69c4fcb47..d34897ffe 100644 --- a/packages/web/src/features/search/parser.test.ts +++ b/packages/web/src/features/search/parser.test.ts @@ -42,4 +42,60 @@ 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 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..0824fb5b5 100644 --- a/packages/web/src/features/search/parser.ts +++ b/packages/web/src/features/search/parser.ts @@ -71,6 +71,15 @@ const findLinguistLanguage = (value: string): string => { return languageKeyLowerCaseMap.get(value.toLowerCase()) ?? value; } +/** + * Quoted strings may contain backslash escapes (e.g. `\"`). Keyword search + * matches patterns literally, so the escapes are resolved here. In regex mode + * they are left in place for the regex engine to interpret. + */ +const unescapeQuotedString = (value: string): string => { + return value.replace(/\\(.)/g, '$1'); +} + /** * Given a query string, parses it into the query intermediate representation. */ @@ -221,7 +230,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 +261,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 +307,7 @@ const transformTreeToIR = async ({ query: "regexp" } : { substring: { - pattern: value, + pattern: isQuoted ? unescapeQuotedString(value) : value, case_sensitive: isCaseSensitivityEnabled, file_name: false, content: true From ae3ba70eac15ff3acbd54fc95665f5240fd7c8ec Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:57:31 -0700 Subject: [PATCH 2/3] docs: add changelog entry for #1688 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 2426084b1ef8bb422f01302df388e2955f181d4e Mon Sep 17 00:00:00 2001 From: breken-ai <312387581+breken-ai@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:47:19 -0700 Subject: [PATCH 3/3] fix(web): handle quoted string escapes correctly --- .../web/src/features/search/parser.test.ts | 26 +++++++++++++++++++ packages/web/src/features/search/parser.ts | 9 ++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/web/src/features/search/parser.test.ts b/packages/web/src/features/search/parser.test.ts index d34897ffe..50c291ae7 100644 --- a/packages/web/src/features/search/parser.test.ts +++ b/packages/web/src/features/search/parser.test.ts @@ -72,6 +72,32 @@ describe('parseQuerySyntaxIntoIR', () => { }); }); + 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\\""', diff --git a/packages/web/src/features/search/parser.ts b/packages/web/src/features/search/parser.ts index 0824fb5b5..e980e2af2 100644 --- a/packages/web/src/features/search/parser.ts +++ b/packages/web/src/features/search/parser.ts @@ -72,12 +72,13 @@ const findLinguistLanguage = (value: string): string => { } /** - * Quoted strings may contain backslash escapes (e.g. `\"`). Keyword search - * matches patterns literally, so the escapes are resolved here. In regex mode - * they are left in place for the regex engine to interpret. + * 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(/\\(.)/g, '$1'); + return value.replace(/\\(["\\\r\n])/g, '$1'); } /**