Compare commits

...
Author SHA1 Message Date
Félix MalfaitandCursor 05ae234b3b Improve search to split on hyphens, underscores, dots, and slashes
Search queries containing separator characters (e.g. "jean-pierre",
"my_company", "acme.corp") now match individual sub-parts while still
ranking the exact compound term higher. This uses an OR expression
that combines the original term with the split parts, so
"jean-pierre" becomes (jean-pierre:* | jean:* & pierre:*).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 11:07:37 +01:00
2 changed files with 92 additions and 14 deletions
@@ -1,15 +1,66 @@
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
describe('formatSearchTerms', () => {
it('should format the search terms', () => {
const formattedTerms = formatSearchTerms('my search input', 'and');
expect(formattedTerms).toBe('my:* & search:* & input:*');
it('should format simple search terms with and', () => {
expect(formatSearchTerms('my search input', 'and')).toBe(
'my:* & search:* & input:*',
);
});
it('should format the search terms with or', () => {
const formattedTerms = formatSearchTerms('my search input', 'or');
it('should format simple search terms with or', () => {
expect(formatSearchTerms('my search input', 'or')).toBe(
'my:* | search:* | input:*',
);
});
expect(formattedTerms).toBe('my:* | search:* | input:*');
it('should return empty string for blank input', () => {
expect(formatSearchTerms('', 'and')).toBe('');
expect(formatSearchTerms(' ', 'and')).toBe('');
});
it('should split hyphenated words while preserving original term', () => {
expect(formatSearchTerms('jean-pierre', 'and')).toBe(
'(jean-pierre:* | jean:* & pierre:*)',
);
});
it('should split hyphenated words with or operator', () => {
expect(formatSearchTerms('jean-pierre', 'or')).toBe(
'(jean-pierre:* | jean:* | pierre:*)',
);
});
it('should handle underscores as separators', () => {
expect(formatSearchTerms('my_company', 'and')).toBe(
'(my_company:* | my:* & company:*)',
);
});
it('should handle dots as separators', () => {
expect(formatSearchTerms('acme.corp', 'and')).toBe(
'(acme.corp:* | acme:* & corp:*)',
);
});
it('should handle slashes as separators', () => {
expect(formatSearchTerms('sales/marketing', 'and')).toBe(
'(sales/marketing:* | sales:* & marketing:*)',
);
});
it('should handle multi-part hyphenated words', () => {
expect(formatSearchTerms('word-word-word', 'and')).toBe(
'(word-word-word:* | word:* & word:* & word:*)',
);
});
it('should handle mix of plain and hyphenated words', () => {
expect(formatSearchTerms('jean-pierre martin', 'and')).toBe(
'(jean-pierre:* | jean:* & pierre:*) & martin:*',
);
});
it('should escape tsquery special characters', () => {
expect(formatSearchTerms("o'brien", 'and')).toBe("o\\'brien:*");
});
});
@@ -1,16 +1,43 @@
const TSQUERY_SPECIAL_CHARS = /[\\:'&|!()@<>]/g;
const WORD_SEPARATOR_CHARS = /[-_./]+/;
const escapeForTsQuery = (word: string): string =>
word.replace(TSQUERY_SPECIAL_CHARS, '\\$&');
// Builds a tsquery expression for a single whitespace-delimited token.
// For tokens containing separator characters (hyphens, underscores, dots, slashes),
// produces (originalTerm:* | subPart1:* <op> subPart2:*) so the exact
// compound form ranks higher while individual parts still match.
const formatWordExpression = (word: string, operator: 'and' | 'or'): string => {
const escapedWord = escapeForTsQuery(word);
const subWords = word
.split(WORD_SEPARATOR_CHARS)
.filter((subWord) => subWord.length > 0);
if (subWords.length <= 1) {
return `${escapedWord}:*`;
}
const op = operator === 'and' ? '&' : '|';
const subExpressions = subWords.map(
(subWord) => `${escapeForTsQuery(subWord)}:*`,
);
return `(${escapedWord}:* | ${subExpressions.join(` ${op} `)})`;
};
export const formatSearchTerms = (
searchTerm: string,
operator: 'and' | 'or' = 'and',
) => {
): string => {
if (searchTerm.trim() === '') {
return '';
}
const words = searchTerm.trim().split(/\s+/);
const formattedWords = words.map((word) => {
const escapedWord = word.replace(/[\\:'&|!()@<>]/g, '\\$&');
const op = operator === 'and' ? '&' : '|';
return `${escapedWord}:*`;
});
return formattedWords.join(` ${operator === 'and' ? '&' : '|'} `);
return words
.map((word) => formatWordExpression(word, operator))
.join(` ${op} `);
};