diff --git a/CHANGELOG.md b/CHANGELOG.md index b6601e6..30dda3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b Every entry says what changed as a caller sees it, why, and what to watch for — the rules are [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog). +## Unreleased + +### Added + +- **A query that fails on an unquoted value with an operator in it says to quote it.** `url:a.com/x?y=1` used to + fail with only `query.invalid-syntax at position 14`: `/`, `?`, `=` and `:` are query syntax. The error now ends + with `put the value in quotes: url:"a.com/x?y=1"`, also in `details.alternative`. A failure that already carries + an alternative keeps its own. + ## 0.38.0 — 16.10.2026 ### Added diff --git a/src/query/parser.test.ts b/src/query/parser.test.ts index f27f006..e44e551 100644 --- a/src/query/parser.test.ts +++ b/src/query/parser.test.ts @@ -84,6 +84,16 @@ describe("a list's query", () => { expect(failure(() => parseLucene('"open')).message).toMatch(/^query: /) }) + it("tells to quote a value that holds an operator", () => { + expect(failure(() => parseLucene("link:click?id=1")).message).toBe( + 'query: query.invalid-syntax at position 14 — put the value in quotes: link:"click?id=1"', + ) + expect(failure(() => parseLucene("alpha url:a.com/x")).details.alternative).toBe( + 'put the value in quotes: url:"a.com/x"', + ) + expect(failure(() => parseLucene('"open')).details.alternative).toBeUndefined() + }) + it("reads the terms lists need: quoted values with a colon, comparisons, presence, relative dates", () => { expect(failure(() => parseLucene("subject:note:42", { defaultField: "none" })).details.reason).toBe( "query.invalid-syntax", diff --git a/src/query/parser.ts b/src/query/parser.ts index 26dbda9..b62738a 100644 --- a/src/query/parser.ts +++ b/src/query/parser.ts @@ -133,8 +133,20 @@ export interface ParseOptions extends QueryContext { defaultField?: string } +// A value written without quotes that holds an operator (`url:a.com/x?y=1`) fails at a character the reader does not +// see as special; name the fix instead. +const quoteHint = (text: string, at: number): string | undefined => { + for (const match of text.matchAll(/(?<=^|[\s(])([\p{L}\p{N}_.-]+):([^\s"()]+)/gu)) { + const [whole, field, value = ""] = match + if (at < match.index || at > match.index + whole.length) continue + return /[:=/?*~^@<>![\]{}\\]/u.test(value) ? `put the value in quotes: ${field}:"${value}"` : undefined + } + return undefined +} + export const parseLucene = (text: string, { defaultField = "text", ...context }: ParseOptions = {}): QueryAst => { - const fail: Fail = (reason, span, alternative) => queryError(reason, span, alternative, context) + const fail: Fail = (reason, span, alternative) => + queryError(reason, span, alternative ?? quoteHint(text, span.start), context) if (text.length > QUERY_LIMITS.bytes || new TextEncoder().encode(text).length > QUERY_LIMITS.bytes) fail("query.too-large", { start: 0, end: text.length }) const tokens = tokensOf(text, fail)