diff --git a/src/shared/ai-vault-search-query-operators.test.ts b/src/shared/ai-vault-search-query-operators.test.ts index aee51d11cb7..981540aff0a 100644 --- a/src/shared/ai-vault-search-query-operators.test.ts +++ b/src/shared/ai-vault-search-query-operators.test.ts @@ -50,22 +50,36 @@ describe('what counts as an operator', () => { // panel's old hand-rolled tokenizer answered differently, so the change of // behaviour is a decision on the record rather than a surprise. describe('the shapes where the panel parser used to answer differently', () => { - it('drops an operator with an empty quoted value instead of searching for `""`', () => { - // The old tokenizer kept the quotes as the value, which matched no repo at - // all, so `repo:""` silently emptied the list. - expect(splitAiVaultSearchQuery('repo:"" x').repoTerms).toEqual([]) - expect(parseVaultQuery('repo:"" x').repoTerms).toEqual([]) + it.each([ + ['repo:"" x', 'repoTerms'], + ['path:"" x', 'pathTerms'] + ] as const)('drops the empty operator value in %s instead of filtering on `""`', (query, key) => { + // The old tokenizer kept the quote characters as the value, so `repo:""` + // filtered on a label no session has and silently emptied the list. An + // operator with nothing in it is not a narrowing. + expect(splitAiVaultSearchQuery(query)[key]).toEqual([]) + expect(parseVaultQuery(query)[key]).toEqual([]) }) - it('reads a quote that does not end a word as ordinary text', () => { - // `"foo"bar` was two tokens, `foo` and `bar`; it is one now. A closing quote - // has to end a word, which is what keeps the apostrophes in `it's ... thing's` - // from swallowing an operator between them. - expect(parseVaultQuery('"foo"bar').terms).toEqual(['"foo"bar']) + it.each(['"" empty', "'' empty"])('reads the empty quotes in %s as an empty term', (query) => { + // Same reason one level up: the old parser searched for the two characters + // and found nothing, where an empty term matches everything and leaves the + // rest of the query to do the work. + expect(parseVaultQuery(query).terms).toEqual(['', 'empty']) }) - it('reads a bare pair of quotes as an empty term, not as the characters', () => { - expect(parseVaultQuery('"" empty').terms).toEqual(['', 'empty']) + it.each([ + ['"foo"bar', { terms: ['foo', 'bar'], repoTerms: [], pathTerms: [] }], + ['"a b"c', { terms: ['a b', 'c'], repoTerms: [], pathTerms: [] }], + ['repo:"a"b', { terms: ['b'], repoTerms: ['a'], pathTerms: [] }], + ['path:"a"b', { terms: ['b'], repoTerms: [], pathTerms: ['a'] }], + ['repo:"a b"c d', { terms: ['c', 'd'], repoTerms: ['a b'], pathTerms: [] }] + ])('reads %s exactly as the panel always has', (query, expected) => { + // A closing quote does not have to end a word. Requiring it turned each of + // these into one term carrying its own quote characters, which matches + // nothing; the apostrophe case below is protected by the token start, not + // by that rule. + expect(parseVaultQuery(query)).toEqual(expected) }) }) diff --git a/src/shared/ai-vault-search-query-operators.ts b/src/shared/ai-vault-search-query-operators.ts index ff30b5d0fb1..2bba59f6fbb 100644 --- a/src/shared/ai-vault-search-query-operators.ts +++ b/src/shared/ai-vault-search-query-operators.ts @@ -57,8 +57,13 @@ function isBoundary(char: string | undefined): boolean { } /** - * Why the closing quote must end a word: otherwise the apostrophes in - * `it's a repo:orca thing's` open a span that swallows the operator between them. + * A quoted span, or null when this is not one. + * + * What keeps the apostrophes in `it's a repo:orca thing's` from opening a span + * that swallows the operator is the caller: this only ever runs at a token + * start, and the quote in `it's` is not at one. The closing quote is then just + * the next one, wherever it falls, so `"a b"c` reads as the panel has always + * read it — the span, then the rest as its own token. */ function readQuoted(query: string, at: number): { value: string; end: number } | null { const quote = query[at] @@ -66,9 +71,7 @@ function readQuoted(query: string, at: number): { value: string; end: number } | return null } const close = query.indexOf(quote, at + 1) - return close === -1 || !isBoundary(query[close + 1]) - ? null - : { value: query.slice(at + 1, close), end: close + 1 } + return close === -1 ? null : { value: query.slice(at + 1, close), end: close + 1 } } function readBare(query: string, at: number): string {