Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
82 changes: 82 additions & 0 deletions packages/web/src/features/search/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\\"' },
});
});
});
});
18 changes: 15 additions & 3 deletions packages/web/src/features/search/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down