Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
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) <noreply@anthropic.com>
  • Loading branch information
breken-ai and claude committed Sep 25, 2026
commit 209b08109d208f555f9209744beab4ccbb570b54
56 changes: 56 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,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\\"' },
});
});
});
});
17 changes: 14 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,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');
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* Given a query string, parses it into the query intermediate representation.
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down