Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/query/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 13 additions & 1 deletion src/query/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading