From 7453869ac61491506f9fe0e8613bf4d917197ef7 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 7 Nov 2026 23:30:56 -0700 Subject: [PATCH] Move Yaak's sidebar filter onto a shared Rust query engine (#825) Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 11 + Cargo.toml | 2 + apps/yaak-client/components/Sidebar.tsx | 101 +- .../core/Editor/filter/extension.ts | 88 ++ .../core/Editor/filter/format.test.ts | 43 - .../components/core/Editor/filter/format.ts | 7 - .../components/core/Editor/filter/query.ts | 308 ----- crates/yaak-query/Cargo.toml | 37 + crates/yaak-query/bindings/query.ts | 128 ++ crates/yaak-query/build-wasm.cjs | 40 + crates/yaak-query/index.ts | 85 ++ crates/yaak-query/package.json | 15 + crates/yaak-query/pkg/package.json | 17 + crates/yaak-query/pkg/yaak_query.d.ts | 49 + crates/yaak-query/pkg/yaak_query.js | 6 + crates/yaak-query/pkg/yaak_query_bg.js | 609 ++++++++++ crates/yaak-query/pkg/yaak_query_bg.wasm | Bin 0 -> 340382 bytes crates/yaak-query/pkg/yaak_query_bg.wasm.d.ts | 21 + crates/yaak-query/src/ast.rs | 195 +++ crates/yaak-query/src/complete.rs | 378 ++++++ crates/yaak-query/src/eval.rs | 1055 +++++++++++++++++ crates/yaak-query/src/format.rs | 89 ++ crates/yaak-query/src/fuzzy.rs | 206 ++++ crates/yaak-query/src/lexer.rs | 320 +++++ crates/yaak-query/src/lib.rs | 40 + crates/yaak-query/src/parser.rs | 393 ++++++ crates/yaak-query/src/schema.rs | 307 +++++ crates/yaak-query/src/tokens.rs | 100 ++ crates/yaak-query/src/wasm.rs | 161 +++ package-lock.json | 12 + package.json | 1 + vite.config.ts | 2 + 32 files changed, 4430 insertions(+), 396 deletions(-) create mode 100644 apps/yaak-client/components/core/Editor/filter/extension.ts delete mode 100644 apps/yaak-client/components/core/Editor/filter/format.test.ts delete mode 100644 apps/yaak-client/components/core/Editor/filter/format.ts delete mode 100644 apps/yaak-client/components/core/Editor/filter/query.ts create mode 100644 crates/yaak-query/Cargo.toml create mode 100644 crates/yaak-query/bindings/query.ts create mode 100644 crates/yaak-query/build-wasm.cjs create mode 100644 crates/yaak-query/index.ts create mode 100644 crates/yaak-query/package.json create mode 100644 crates/yaak-query/pkg/package.json create mode 100644 crates/yaak-query/pkg/yaak_query.d.ts create mode 100644 crates/yaak-query/pkg/yaak_query.js create mode 100644 crates/yaak-query/pkg/yaak_query_bg.js create mode 100644 crates/yaak-query/pkg/yaak_query_bg.wasm create mode 100644 crates/yaak-query/pkg/yaak_query_bg.wasm.d.ts create mode 100644 crates/yaak-query/src/ast.rs create mode 100644 crates/yaak-query/src/complete.rs create mode 100644 crates/yaak-query/src/eval.rs create mode 100644 crates/yaak-query/src/format.rs create mode 100644 crates/yaak-query/src/fuzzy.rs create mode 100644 crates/yaak-query/src/lexer.rs create mode 100644 crates/yaak-query/src/lib.rs create mode 100644 crates/yaak-query/src/parser.rs create mode 100644 crates/yaak-query/src/schema.rs create mode 100644 crates/yaak-query/src/tokens.rs create mode 100644 crates/yaak-query/src/wasm.rs diff --git a/Cargo.lock b/Cargo.lock index d18d8bebb..90c0664ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11969,6 +11969,17 @@ dependencies = [ "yaak-templates", ] +[[package]] +name = "yaak-query" +version = "0.1.0" +dependencies = [ + "serde", + "serde-wasm-bindgen", + "serde_json", + "ts-rs", + "wasm-bindgen", +] + [[package]] name = "yaak-rpc" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index 40557a8ea..5e29d0da6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/yaak-mcp", "crates/yaak-models", "crates/yaak-plugins", + "crates/yaak-query", "crates/yaak-sse", "crates/yaak-sync", "crates/yaak-templates", @@ -96,6 +97,7 @@ yaak-lifecycle = { path = "crates/yaak-lifecycle" } yaak-mcp = { path = "crates/yaak-mcp" } yaak-models = { path = "crates/yaak-models" } yaak-plugins = { path = "crates/yaak-plugins" } +yaak-query = { path = "crates/yaak-query" } yaak-sse = { path = "crates/yaak-sse" } yaak-sync = { path = "crates/yaak-sync" } yaak-templates = { path = "crates/yaak-templates" } diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index 80d474656..cb3044736 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -63,10 +63,9 @@ import { isSidebarFocused } from "../lib/scopes"; import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import { ContextMenu, Dropdown } from "./core/Dropdown"; -import { type FieldDef, filter } from "@yaakapp-internal/ui/editor"; -import type { Ast } from "./core/Editor/filter/query"; -import { evaluate, parseQuery } from "./core/Editor/filter/query"; -import { formatFieldFilter } from "./core/Editor/filter/format"; +import type { Node as QueryNode, Schema } from "@yaakapp-internal/query"; +import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query"; +import { queryFilter } from "./core/Editor/filter/extension"; import { HttpMethodTag } from "./core/HttpMethodTag"; import { Icon, @@ -111,7 +110,7 @@ function Sidebar({ className }: { className?: string }) { const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id; const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const filterText = useAtomValue(sidebarFilterAtom); - const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; + const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; const wrapperRef = useRef(null); const treeRef = useRef(null); @@ -552,18 +551,18 @@ function Sidebar({ className }: { className?: string }) { const filterCompartmentMountExtRef = useRef(null); if (filterCompartmentMountExtRef.current == null) { filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of( - filter({ fields: allFields ?? [] }), + queryFilter({ schema: sidebarFilterSchema, values: fieldValues }), ); } useEffect(() => { const view = filterRef.current; if (!view) return; - const ext = filter({ fields: allFields ?? [] }); + const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues }); view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext), }); - }, [allFields]); + }, [fieldValues]); if (tree == null || hidden) { return null; @@ -851,16 +850,35 @@ function setSidebarFilterText(text: string) { jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` }); } -function getSidebarSuggestionValue(ast: Ast | null) { +/** The fields `getItemFields` fills in. Bare words match `getItemText` by word prefix */ +const sidebarFilterColumns = [ + "method", + "url", + "name", + "type", + "folder", + "grpc_service", + "grpc_method", +]; +const sidebarFilterSchema: Schema = { + fields: sidebarFilterColumns.map((name) => ({ name, kind: { type: "text" } })), + freeText: "fuzzy", +}; + +function getSidebarSuggestionValue(ast: QueryNode | null, query: string) { if (ast == null) return null; if (ast.type === "Term" || ast.type === "Phrase") { - const value = ast.value.trim(); + const value = ast.text.trim(); return value.length > 0 ? value : null; } if (ast.type === "Field") { - const value = ast.value.trim(); + const raw = + ast.value.type === "text" + ? ast.value.text + : query.slice(ast.valueSpan.start, ast.valueSpan.end); + const value = raw.trim(); return value.length > 0 ? value : null; } @@ -882,7 +900,7 @@ const sidebarSuggestionFieldOrder = [ ]; const sidebarTreeAtom = atom< - [TreeNode, FieldDef[], SidebarFilterSuggestion[]] | null + [TreeNode, Record, SidebarFilterSuggestion[]] | null >((get) => { const allModels = get(memoAllPotentialChildrenAtom); const activeWorkspace = get(activeWorkspaceAtom); @@ -903,15 +921,18 @@ const sidebarTreeAtom = atom< return null; } - const queryAst = parseQuery(filter.text); - const suggestionValue = getSidebarSuggestionValue(queryAst); + const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text); + const matcher = new QueryMatcher(filter.text, sidebarFilterSchema); + + // Every request goes into one flat table (its text, then a cell per column), matched in a + // single call after the tree is built + const leaves: TreeNode[] = []; + const cells: (string | null)[] = []; - // returns true if this node OR any child matches the filter const allFields: Record> = {}; const suggestionFields = new Set(); - const build = (node: TreeNode, depth: number): boolean => { + const build = (node: TreeNode, depth: number) => { const childItems = childrenMap[node.item.id] ?? []; - let matchesSelf = true; const fields = getItemFields(node); const model = node.item.model; const isLeafNode = !(model === "folder" || model === "workspace"); @@ -929,12 +950,12 @@ const sidebarTreeAtom = atom< } } - if (queryAst != null) { - matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields }); + if (isLeafNode && !matcher.isEmpty) { + leaves.push(node); + cells.push(getItemText(node.item)); + for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null); } - let matchesChild = false; - // Recurse to children node.children = !isLeafNode ? [] : undefined; @@ -952,19 +973,21 @@ const sidebarTreeAtom = atom< for (const item of childItems) { const childNode = { item, parent: node, depth }; - const childMatches = build(childNode, depth + 1); - if (childMatches) { - matchesChild = true; - } + build(childNode, depth + 1); node.children.push(childNode); } } + }; - // hide node IFF nothing in its subtree matches - const anyMatch = matchesSelf || matchesChild; - node.hidden = !anyMatch; - - return anyMatch; + // Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden + let matched: Set> | null = null; + const markHidden = (node: TreeNode): boolean => { + let visible = matched == null || matched.has(node); + for (const child of node.children ?? []) { + if (markHidden(child)) visible = true; + } + node.hidden = !visible; + return visible; }; const root: TreeNode = { @@ -974,15 +997,17 @@ const sidebarTreeAtom = atom< depth: 0, }; - // Build tree and mark visibility in one pass build(root, 1); + if (!matcher.isEmpty) { + const rows = matcher.matchingRows(sidebarFilterColumns, cells); + matched = new Set(Array.from(rows, (i) => leaves[i]!)); + } + matcher.free(); + markHidden(root); - const fields: FieldDef[] = []; + const fieldValues: Record = {}; for (const [name, values] of Object.entries(allFields)) { - fields.push({ - name, - values: Array.from(values).filter((v) => v.length < 20), - }); + fieldValues[name] = Array.from(values).filter((v) => v.length < 20); } const suggestions = Array.from(suggestionFields) .sort((a, b) => { @@ -993,9 +1018,9 @@ const sidebarTreeAtom = atom< }) .map((field) => ({ field, - filterText: formatFieldFilter(field, suggestionValue ?? ""), + filterText: formatField(field, suggestionValue ?? ""), })); - return [root, fields, suggestions] as const; + return [root, fieldValues, suggestions] as const; }); const sidebarGitStatusByModelIdAtom = atom>((get) => { diff --git a/apps/yaak-client/components/core/Editor/filter/extension.ts b/apps/yaak-client/components/core/Editor/filter/extension.ts new file mode 100644 index 000000000..003729fe5 --- /dev/null +++ b/apps/yaak-client/components/core/Editor/filter/extension.ts @@ -0,0 +1,88 @@ +import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete"; +import { autocompletion, startCompletion } from "@codemirror/autocomplete"; +import { highlightingFor } from "@codemirror/language"; +import type { EditorState, Extension } from "@codemirror/state"; +import { RangeSetBuilder } from "@codemirror/state"; +import type { DecorationSet, ViewUpdate } from "@codemirror/view"; +import { Decoration, EditorView, ViewPlugin } from "@codemirror/view"; +import type { Tag } from "@lezer/highlight"; +import { tags as t } from "@lezer/highlight"; +import type { Schema, TokenKind } from "@yaakapp-internal/query"; +import { queryCompletions, queryTokens } from "@yaakapp-internal/query"; + +export interface QueryFilterOptions { + schema: Schema; + /** The values seen for each field path, offered after `@field:` */ + values?: Record; +} + +const tokenTags: Record = { + field: t.attributeName, + value: t.attributeValue, + operator: t.operatorKeyword, + quote: t.string, + paren: t.paren, + negation: t.operator, + text: null, +}; + +function buildDecorations(state: EditorState): DecorationSet { + const builder = new RangeSetBuilder(); + for (const token of queryTokens(state.doc.toString())) { + const tag = tokenTags[token.kind]; + const className = tag == null ? null : highlightingFor(state, [tag]); + if (className == null || token.span.start === token.span.end) continue; + builder.add(token.span.start, token.span.end, Decoration.mark({ class: className })); + } + return builder.finish(); +} + +const highlight = ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = buildDecorations(view.state); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = buildDecorations(update.state); + } + } + }, + { decorations: (v) => v.decorations }, +); + +function completionSource({ schema, values }: QueryFilterOptions) { + return (ctx: CompletionContext): CompletionResult | null => { + const result = queryCompletions(ctx.state.doc.toString(), ctx.pos, schema, values); + if (result == null) return null; + + const { start, end } = result.span; + // Field names pop up for something typed; on an empty spot, only when asked for + const onlyFields = result.items.every((item) => item.kind === "field"); + if (onlyFields && start === end && !ctx.explicit) return null; + + const options: Completion[] = result.items.map((item) => ({ + label: item.label, + type: item.kind === "field" ? "property" : "constant", + info: item.info ?? undefined, + apply: (view, _completion, from, to) => { + view.dispatch({ + changes: { from, to, insert: item.insert }, + selection: { anchor: from + item.insert.length }, + }); + if (item.reopen) startCompletion(view); + }, + })); + + // The engine has already filtered and ranked the options + return { from: start, to: end, options, filter: false }; + }; +} + +/** Highlighting and completion for a search box written in Yaak's query language */ +export function queryFilter(options: QueryFilterOptions): Extension { + return [highlight, autocompletion({ override: [completionSource(options)] })]; +} diff --git a/apps/yaak-client/components/core/Editor/filter/format.test.ts b/apps/yaak-client/components/core/Editor/filter/format.test.ts deleted file mode 100644 index 07ba7afe6..000000000 --- a/apps/yaak-client/components/core/Editor/filter/format.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, test } from "vite-plus/test"; -import { formatFieldFilter } from "./format"; -import { evaluate, parseQuery } from "./query"; - -function matchesFormattedUrl(value: string) { - return evaluate(parseQuery(formatFieldFilter("url", value)), { - fields: { url: value }, - }); -} - -describe("formatFieldFilter", () => { - test("keeps URL-like values bare", () => { - expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar"); - expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true); - }); - - test("keeps non-syntax punctuation bare", () => { - expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)"); - expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true); - }); - - test("keeps values that start with an operator token bare", () => { - expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo"); - expect(matchesFormattedUrl("-foo")).toBe(true); - }); - - test("keeps boolean operator words bare", () => { - expect(formatFieldFilter("url", "AND")).toBe("@url:AND"); - expect(formatFieldFilter("url", "or")).toBe("@url:or"); - expect(formatFieldFilter("url", "Not")).toBe("@url:Not"); - expect(matchesFormattedUrl("AND")).toBe(true); - }); - - test("escapes quoted values", () => { - expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""'); - expect(matchesFormattedUrl('say "hi"')).toBe(true); - }); - - test("quotes values that start with a quote", () => { - expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""'); - expect(matchesFormattedUrl('"hi"')).toBe(true); - }); -}); diff --git a/apps/yaak-client/components/core/Editor/filter/format.ts b/apps/yaak-client/components/core/Editor/filter/format.ts deleted file mode 100644 index f1fc7215e..000000000 --- a/apps/yaak-client/components/core/Editor/filter/format.ts +++ /dev/null @@ -1,7 +0,0 @@ -const bareFieldValue = /^[^\s"]\S*$/; - -export function formatFieldFilter(field: string, value: string) { - const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`; - return `@${field}:${filterValue}`; -} diff --git a/apps/yaak-client/components/core/Editor/filter/query.ts b/apps/yaak-client/components/core/Editor/filter/query.ts deleted file mode 100644 index 12bf4a4bb..000000000 --- a/apps/yaak-client/components/core/Editor/filter/query.ts +++ /dev/null @@ -1,308 +0,0 @@ -// query.ts -// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value. - -import { fuzzyMatch } from "fuzzbunny"; -///////////////////////// -// AST -///////////////////////// - -export type Ast = - | { type: "Term"; value: string } // foo - | { type: "Phrase"; value: string } // "hi there" - | { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase" - | { type: "Not"; node: Ast } // -foo or NOT foo - | { type: "And"; left: Ast; right: Ast } // a AND b - | { type: "Or"; left: Ast; right: Ast }; // a OR b - -///////////////////////// -// Tokenizer -///////////////////////// -type Tok = - | { kind: "LPAREN" } - | { kind: "RPAREN" } - | { kind: "AND" } - | { kind: "OR" } - | { kind: "NOT" } // explicit NOT - | { kind: "MINUS" } // unary minus before term/phrase/paren group - | { kind: "COLON" } - | { kind: "WORD"; text: string } // bareword (unquoted) - | { kind: "PHRASE"; text: string } // "quoted phrase" - | { kind: "EOF" }; - -const isSpace = (c: string) => /\s/.test(c); -const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c); -const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c); - -export function tokenize(input: string): Tok[] { - const toks: Tok[] = []; - let i = 0; - const n = input.length; - - const peek = () => input[i] ?? ""; - const advance = () => input[i++]; - - const readWord = () => { - let s = ""; - while (i < n && isWordChar(peek())) s += advance(); - return s; - }; - - const readFieldValue = () => { - let s = ""; - while (i < n && !isSpace(peek())) s += advance(); - return s; - }; - - const readPhrase = () => { - // assumes current char is opening quote - advance(); // consume opening " - let s = ""; - while (i < n) { - const c = advance(); - if (c === `"`) break; - if (c === "\\" && i < n) { - // escape \" and \\ (simple) - const next = advance(); - s += next; - } else { - s += c; - } - } - return s; - }; - - while (i < n) { - const c = peek(); - - if (isSpace(c)) { - i++; - continue; - } - - if (c === "(") { - toks.push({ kind: "LPAREN" }); - i++; - continue; - } - if (c === ")") { - toks.push({ kind: "RPAREN" }); - i++; - continue; - } - if (c === ":") { - toks.push({ kind: "COLON" }); - i++; - if (peek() && !isSpace(peek()) && peek() !== `"`) { - toks.push({ kind: "WORD", text: readFieldValue() }); - } - continue; - } - if (c === `"`) { - const text = readPhrase(); - toks.push({ kind: "PHRASE", text }); - continue; - } - if (c === "-") { - toks.push({ kind: "MINUS" }); - i++; - continue; - } - - // WORD / AND / OR / NOT - if (isWordStart(c)) { - const w = readWord(); - const upper = w.toUpperCase(); - if (upper === "AND") toks.push({ kind: "AND" }); - else if (upper === "OR") toks.push({ kind: "OR" }); - else if (upper === "NOT") toks.push({ kind: "NOT" }); - else toks.push({ kind: "WORD", text: w }); - continue; - } - - // Unknown char—skip to be forgiving - i++; - } - - toks.push({ kind: "EOF" }); - return toks; -} - -class Parser { - private i = 0; - constructor(private toks: Tok[]) {} - - private peek(): Tok { - return this.toks[this.i] ?? { kind: "EOF" }; - } - private advance(): Tok { - return this.toks[this.i++] ?? { kind: "EOF" }; - } - private at(kind: Tok["kind"]) { - return this.peek().kind === kind; - } - - // Top-level: parse OR-precedence chain, allowing implicit AND. - parse(): Ast | null { - if (this.at("EOF")) return null; - const expr = this.parseOr(); - if (!this.at("EOF")) { - // Optionally, consume remaining tokens or throw - } - return expr; - } - - // Precedence: NOT (highest), AND, OR (lowest) - private parseOr(): Ast { - let node = this.parseAnd(); - while (this.at("OR")) { - this.advance(); - const rhs = this.parseAnd(); - node = { type: "Or", left: node, right: rhs }; - } - return node; - } - - private parseAnd(): Ast { - let node = this.parseUnary(); - // Implicit AND: if next token starts a primary, treat as AND. - while (this.at("AND") || this.startsPrimary()) { - if (this.at("AND")) this.advance(); - const rhs = this.parseUnary(); - node = { type: "And", left: node, right: rhs }; - } - return node; - } - - private parseUnary(): Ast { - if (this.at("NOT") || this.at("MINUS")) { - this.advance(); - const node = this.parseUnary(); - return { type: "Not", node }; - } - return this.parsePrimaryOrField(); - } - - private startsPrimary(): boolean { - const k = this.peek().kind; - return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT"; - } - - private parsePrimaryOrField(): Ast { - // Parenthesized group - if (this.at("LPAREN")) { - this.advance(); - const inside = this.parseOr(); - // if (!this.at('RPAREN')) throw new Error("Missing closing ')'"); - this.advance(); - return inside; - } - - // Phrase - if (this.at("PHRASE")) { - const t = this.advance() as Extract; - return { type: "Phrase", value: t.text }; - } - - // Field or bare word - if (this.at("WORD")) { - const wordTok = this.advance() as Extract; - - if (this.at("COLON")) { - // field:value or field:"phrase" - this.advance(); // : - let value: string; - if (this.at("PHRASE")) { - const p = this.advance() as Extract; - value = p.text; - } else if (this.at("WORD")) { - const w = this.advance() as Extract; - value = w.text; - } else { - // Anything else after colon is treated literally as a single Term token. - const t = this.advance(); - value = tokText(t); - } - return { type: "Field", field: wordTok.text, value }; - } - - // plain term - return { type: "Term", value: wordTok.text }; - } - - const w = this.advance() as Extract; - return { type: "Phrase", value: "text" in w ? w.text : "" }; - } -} - -function tokText(t: Tok): string { - if ("text" in t) return t.text; - - switch (t.kind) { - case "COLON": - return ":"; - case "LPAREN": - return "("; - case "RPAREN": - return ")"; - default: - return ""; - } -} - -export function parseQuery(q: string): Ast | null { - if (q.trim() === "") return null; - const toks = tokenize(q); - const parser = new Parser(toks); - return parser.parse(); -} - -export type Doc = { - text?: string; - fields?: Record; -}; - -type Technique = "substring" | "fuzzy" | "strict"; - -function includes(hay: string | undefined, needle: string, technique: Technique): boolean { - if (!hay || !needle) return false; - if (technique === "strict") return hay === needle; - if (technique === "fuzzy") return !!fuzzyMatch(hay, needle); - return hay.indexOf(needle) !== -1; -} - -export function evaluate(ast: Ast | null, doc: Doc): boolean { - if (!ast) return true; // Match everything if no query is provided - - const text = (doc.text ?? "").toLowerCase(); - const fieldsNorm: Record = {}; - - for (const [k, v] of Object.entries(doc.fields ?? {})) { - if (!(typeof v === "string" || Array.isArray(v))) continue; - fieldsNorm[k.toLowerCase()] = Array.isArray(v) - ? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase()) - : [String(v ?? "").toLowerCase()]; - } - - const evalNode = (node: Ast): boolean => { - switch (node.type) { - case "Term": - return includes(text, node.value.toLowerCase(), "fuzzy"); - case "Phrase": - // Quoted phrases match exactly - return includes(text, node.value.toLowerCase(), "substring"); - case "Field": { - const vals = fieldsNorm[node.field.toLowerCase()] ?? []; - if (vals.length === 0) return false; - return vals.some((v) => includes(v, node.value.toLowerCase(), "substring")); - } - case "Not": - return !evalNode(node.node); - case "And": - return evalNode(node.left) && evalNode(node.right); - case "Or": - return evalNode(node.left) || evalNode(node.right); - } - }; - - return evalNode(ast); -} diff --git a/crates/yaak-query/Cargo.toml b/crates/yaak-query/Cargo.toml new file mode 100644 index 000000000..cd1813563 --- /dev/null +++ b/crates/yaak-query/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "yaak-query" +version = "0.1.0" +edition = "2024" +publish = false + +# The search query language shared by Yaak's sidebar and Yaak Proxy: grammar, +# parser, schema, evaluator, completion and highlighting. Pure Rust, with a +# WebAssembly build (`@yaakapp-internal/query`) for the frontends. + +[package.metadata.wasm-pack.profile.release] +wasm-opt = false # Matches yaak-templates; wasm-opt has caused errors in CI + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["wasm"] +# The `#[wasm_bindgen]` exports that make up the @yaakapp-internal/query +# package. They only exist when compiling for wasm32, so native builds and +# `cargo test` never see them. Off for crates that link this one into their own +# wasm module and do not want these re-exported from theirs. +wasm = [] + +[dependencies] +serde = { workspace = true, features = ["derive"] } + +# Everything that only exists for the WebAssembly package is target-scoped, so +# a native consumer (the proxy, the CLI) links none of it. +[target.'cfg(target_arch = "wasm32")'.dependencies] +serde-wasm-bindgen = "0.6.5" +serde_json = { workspace = true } +wasm-bindgen = { version = "0.2.100", features = ["serde-serialize"] } + +[dev-dependencies] +serde_json = { workspace = true } +ts-rs = { workspace = true } diff --git a/crates/yaak-query/bindings/query.ts b/crates/yaak-query/bindings/query.ts new file mode 100644 index 000000000..a9efad919 --- /dev/null +++ b/crates/yaak-query/bindings/query.ts @@ -0,0 +1,128 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CompareOp = ">" | ">=" | "<" | "<="; + +export type Completion = { +/** + * What to show: a field's path, or a value as it is. + */ +label: string, +/** + * What replaces the span: `@method:`, `@header.`, or a value quoted when it needs to be. + */ +insert: string, kind: CompletionKind, info: string | null, +/** + * The term is not finished after inserting (a field still needs its value, a map field its + * key), so completion should open again. + */ +reopen: boolean, }; + +export type CompletionKind = "field" | "value"; + +/** + * Completions for the term at the cursor, all replacing `span`. + */ +export type Completions = { span: Span, items: Array, }; + +/** + * Something in a query the schema does not accept. The query still parses and runs; the + * offending term matches nothing. + */ +export type Diagnostic = { "type": "unknownField", path: string, span: Span, } | { "type": "invalidValue", path: string, span: Span, }; + +export type DocValue = boolean | number | string; + +/** + * One value or several. + */ +export type DocValues = DocValue | Array; + +/** + * What a query is matched against: free text for bare words and phrases, and field values by + * lowercase dotted path. A map field's values sit under `name.key` (`header.x-cache`). + */ +export type Document = { text?: string, fields?: { [key in string]?: DocValues }, }; + +/** + * One field: `@name:value`. + */ +export type FieldDef = { +/** + * The field's path, dotted when nested: `method`, `req.header`. + */ +name: string, kind: FieldKind, +/** + * A map of named values, such as headers: `@header.:value` is valid for any key, and + * the key is matched against the document's keys (case-insensitively). + */ +map?: boolean, +/** + * A short note shown with the field in completions. + */ +info?: string, }; + +export type FieldKind = { "type": "text" } | { "type": "number", units: Array, } | { "type": "flag" }; + +/** + * What a field term's value asks for. The shape is decided by the parser alone; the schema + * decides what it means for a given field. + */ +export type FieldValue = { "type": "text", text: string, quoted: boolean, } | { "type": "wildcard", pattern: string, } | { "type": "exists" } | { "type": "compare", op: CompareOp, number: NumberLiteral, } | { "type": "range", low: NumberLiteral, high: NumberLiteral, }; + +/** + * How a bare word matches the document's free text. A quoted phrase is always a substring. + */ +export type FreeText = "fuzzy" | "substring"; + +/** + * A node of the parsed query. + */ +export type Node = { "type": "And", children: Array, span: Span, } | { "type": "Or", children: Array, span: Span, } | { "type": "Not", node: Node, span: Span, } | { "type": "Term", text: string, span: Span, } | { "type": "Phrase", text: string, span: Span, } | { "type": "Field", +/** + * The dotted path, one entry per identifier: `@req.header.accept` is + * `["req", "header", "accept"]`. + */ +path: Array, value: FieldValue, span: Span, +/** + * `@path:`, from the `@` through the colon. + */ +pathSpan: Span, +/** + * The value as written, quotes included. Empty (and at the end of the field) when + * nothing follows the colon. + */ +valueSpan: Span, }; + +/** + * A number as written in a comparison or range, with its unit when it has one (`500ms`, + * `2.5mb`). The schema says which units a field accepts and what they are worth. + */ +export type NumberLiteral = { value: number, unit: string | null, +/** + * The number as it appears in the source, unit included. + */ +raw: string, }; + +/** + * A consumer's searchable fields. + */ +export type Schema = { fields: Array, +/** + * Substring when left out. + */ +freeText?: FreeText, }; + +/** + * A half-open byte range `start..end` in the query source. + */ +export type Span = { start: number, end: number, }; + +export type Token = { kind: TokenKind, span: Span, }; + +export type TokenKind = "field" | "value" | "operator" | "quote" | "paren" | "negation" | "text"; + +/** + * A unit a number may be written in, and how many of the field's base unit it is worth: + * `{ name: "s", factor: 1000 }` for a field counted in milliseconds. + */ +export type Unit = { name: string, factor: number, }; diff --git a/crates/yaak-query/build-wasm.cjs b/crates/yaak-query/build-wasm.cjs new file mode 100644 index 000000000..22a6b65b3 --- /dev/null +++ b/crates/yaak-query/build-wasm.cjs @@ -0,0 +1,40 @@ +const { execSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +if (process.env.SKIP_WASM_BUILD === "1") { + console.log("Skipping wasm-pack build (SKIP_WASM_BUILD=1)"); + return; +} + +// Remap machine-specific paths that rustc embeds into the binary (panic +// location strings), so builds are reproducible across machines +const sysroot = execSync("rustc --print sysroot").toString().trim(); +const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo"); + +execSync("wasm-pack build --target bundler", { + stdio: "inherit", + env: { + ...process.env, + CARGO_BUILD_TARGET: "wasm32-unknown-unknown", + RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`, + }, +}); + +// Rewrite the generated entry to use Vite's ?init import style instead of +// the ES Module Integration style that wasm-pack generates, which Vite/rolldown +// does not support in production builds. +const entry = path.join(__dirname, "pkg", "yaak_query.js"); +fs.writeFileSync( + entry, + [ + 'import init from "./yaak_query_bg.wasm?init";', + 'export * from "./yaak_query_bg.js";', + 'import * as bg from "./yaak_query_bg.js";', + 'const instance = await init({ "./yaak_query_bg.js": bg });', + "bg.__wbg_set_wasm(instance.exports);", + "instance.exports.__wbindgen_start();", + "", + ].join("\n"), +); diff --git a/crates/yaak-query/index.ts b/crates/yaak-query/index.ts new file mode 100644 index 000000000..14ca5657a --- /dev/null +++ b/crates/yaak-query/index.ts @@ -0,0 +1,85 @@ +export * from "./bindings/query"; +import type { Completions, Diagnostic, Document, Node, Schema, Token } from "./bindings/query"; +import { + QueryMatcher as WasmQueryMatcher, + parse_query, + query_completions, + query_diagnostics, + query_format_field, + query_tokens, +} from "./pkg"; + +// Every span and offset here counts UTF-16 code units, like JavaScript strings do. + +/** The parsed tree, or `null` when the query has no terms */ +export function parseQuery(input: string): Node | null { + return parse_query(input) as Node | null; +} + +/** Highlighting spans, in order */ +export function queryTokens(input: string): Token[] { + return query_tokens(input) as Token[]; +} + +/** + * What can complete the term at `cursor`: field names, or after `@field:` the values given for + * that field in `values` + */ +export function queryCompletions( + input: string, + cursor: number, + schema: Schema, + values?: Record, +): Completions | null { + return query_completions(input, cursor, schema, values ?? {}) as Completions | null; +} + +/** The terms the schema cannot run: unknown fields and values that cannot apply */ +export function queryDiagnostics(input: string, schema: Schema): Diagnostic[] { + return query_diagnostics(input, schema) as Diagnostic[]; +} + +/** `@field:value` (or `-@field:value`), with the value quoted when it needs to be */ +export function formatField(field: string, value: string, negated = false): string { + return query_format_field(field, value, negated); +} + +/** The length that marks a cell with no value (`Table::NO_VALUE` in the crate) */ +const NO_VALUE = 0xffffffff; + +/** A query parsed once, to match against many documents */ +export class QueryMatcher { + readonly #inner: WasmQueryMatcher; + + constructor(input: string, schema: Schema) { + this.#inner = new WasmQueryMatcher(input, schema); + } + + /** The query has no terms, so it matches everything */ + get isEmpty(): boolean { + return this.#inner.isEmpty; + } + + matches(doc: Document): boolean { + return this.#inner.matches(JSON.stringify(doc)); + } + + /** + * The indexes of the matching rows, for many documents in one call. `cells` is flat: for + * each row, its free text, then a value (or `null` when it has none) for each of `columns` + */ + matchingRows(columns: string[], cells: (string | null)[]): Uint32Array { + // Packed as one string plus each cell's length, which crosses into wasm far faster than + // JSON: nothing to escape or parse, and the cells are read in place + const lengths = new Uint32Array(cells.length); + for (let i = 0; i < cells.length; i++) { + lengths[i] = cells[i]?.length ?? NO_VALUE; + } + return this.#inner.matchingRows(columns, cells.join(""), lengths); + } + + /** Frees the parsed query now rather than when it is garbage collected */ + free() { + this.#inner.free(); + } +} diff --git a/crates/yaak-query/package.json b/crates/yaak-query/package.json new file mode 100644 index 000000000..da9e0b86a --- /dev/null +++ b/crates/yaak-query/package.json @@ -0,0 +1,15 @@ +{ + "name": "@yaakapp-internal/query", + "version": "1.0.0", + "private": true, + "main": "index.ts", + "scripts": { + "bootstrap": "npm run build", + "build": "run-s build:*", + "build:pack": "node build-wasm.cjs", + "build:clean": "rimraf ./pkg/.gitignore" + }, + "devDependencies": { + "rimraf": "^6.1.2" + } +} diff --git a/crates/yaak-query/pkg/package.json b/crates/yaak-query/pkg/package.json new file mode 100644 index 000000000..a51f42d53 --- /dev/null +++ b/crates/yaak-query/pkg/package.json @@ -0,0 +1,17 @@ +{ + "name": "yaak-query", + "type": "module", + "version": "0.1.0", + "files": [ + "yaak_query_bg.wasm", + "yaak_query.js", + "yaak_query_bg.js", + "yaak_query.d.ts" + ], + "main": "yaak_query.js", + "types": "yaak_query.d.ts", + "sideEffects": [ + "./yaak_query.js", + "./snippets/*" + ] +} \ No newline at end of file diff --git a/crates/yaak-query/pkg/yaak_query.d.ts b/crates/yaak-query/pkg/yaak_query.d.ts new file mode 100644 index 000000000..d326c7931 --- /dev/null +++ b/crates/yaak-query/pkg/yaak_query.d.ts @@ -0,0 +1,49 @@ +/* tslint:disable */ +/* eslint-disable */ + +/** + * A query parsed and resolved once, then matched against many documents. + */ +export class QueryMatcher { + free(): void; + [Symbol.dispose](): void; + /** + * Whether a `Document` (`{ text, fields }`), as JSON, matches. JSON crosses into wasm far + * faster than walking a JS object property by property. + */ + matches(doc_json: string): boolean; + /** + * The indexes of the matching rows, for many documents in one call. The cells (per row, + * its free text, then one per column) are packed as in [`Table::from_packed`]: joined into + * one string, with each one's UTF-16 length, or `Table::NO_VALUE` for no value. + */ + matchingRows(columns: string[], joined: string, lengths: Uint32Array): Uint32Array; + constructor(input: string, schema: any); + /** + * Whether the query has no terms, so it matches everything. + */ + readonly isEmpty: boolean; +} + +/** + * The parsed tree (`Node | null`), with UTF-16 spans. + */ +export function parse_query(input: string): any; + +/** + * `Completions | null` at UTF-16 offset `cursor`. `values` maps field paths to the values seen + * for them (`Record`). + */ +export function query_completions(input: string, cursor: number, schema: any, values: any): any; + +/** + * The terms the schema cannot run (`Diagnostic[]`), with UTF-16 spans. + */ +export function query_diagnostics(input: string, schema: any): any; + +export function query_format_field(field: string, value: string, negated: boolean): string; + +/** + * Highlighting spans (`Token[]`), in UTF-16 offsets. + */ +export function query_tokens(input: string): any; diff --git a/crates/yaak-query/pkg/yaak_query.js b/crates/yaak-query/pkg/yaak_query.js new file mode 100644 index 000000000..d3d8a23f4 --- /dev/null +++ b/crates/yaak-query/pkg/yaak_query.js @@ -0,0 +1,6 @@ +import init from "./yaak_query_bg.wasm?init"; +export * from "./yaak_query_bg.js"; +import * as bg from "./yaak_query_bg.js"; +const instance = await init({ "./yaak_query_bg.js": bg }); +bg.__wbg_set_wasm(instance.exports); +instance.exports.__wbindgen_start(); diff --git a/crates/yaak-query/pkg/yaak_query_bg.js b/crates/yaak-query/pkg/yaak_query_bg.js new file mode 100644 index 000000000..8b94229f4 --- /dev/null +++ b/crates/yaak-query/pkg/yaak_query_bg.js @@ -0,0 +1,609 @@ +/** + * A query parsed and resolved once, then matched against many documents. + */ +export class QueryMatcher { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + QueryMatcherFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_querymatcher_free(ptr, 0); + } + /** + * Whether the query has no terms, so it matches everything. + * @returns {boolean} + */ + get isEmpty() { + const ret = wasm.querymatcher_isEmpty(this.__wbg_ptr); + return ret !== 0; + } + /** + * Whether a `Document` (`{ text, fields }`), as JSON, matches. JSON crosses into wasm far + * faster than walking a JS object property by property. + * @param {string} doc_json + * @returns {boolean} + */ + matches(doc_json) { + const ptr0 = passStringToWasm0(doc_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.querymatcher_matches(this.__wbg_ptr, ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ret[0] !== 0; + } + /** + * The indexes of the matching rows, for many documents in one call. The cells (per row, + * its free text, then one per column) are packed as in [`Table::from_packed`]: joined into + * one string, with each one's UTF-16 length, or `Table::NO_VALUE` for no value. + * @param {string[]} columns + * @param {string} joined + * @param {Uint32Array} lengths + * @returns {Uint32Array} + */ + matchingRows(columns, joined, lengths) { + const ptr0 = passArrayJsValueToWasm0(columns, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(joined, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ptr2 = passArray32ToWasm0(lengths, wasm.__wbindgen_malloc); + const len2 = WASM_VECTOR_LEN; + const ret = wasm.querymatcher_matchingRows(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2); + if (ret[3]) { + throw takeFromExternrefTable0(ret[2]); + } + var v4 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v4; + } + /** + * @param {string} input + * @param {any} schema + */ + constructor(input, schema) { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.querymatcher_new(ptr0, len0, schema); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + this.__wbg_ptr = ret[0]; + QueryMatcherFinalization.register(this, this.__wbg_ptr, this); + return this; + } +} +if (Symbol.dispose) QueryMatcher.prototype[Symbol.dispose] = QueryMatcher.prototype.free; + +/** + * The parsed tree (`Node | null`), with UTF-16 spans. + * @param {string} input + * @returns {any} + */ +export function parse_query(input) { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.parse_query(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * `Completions | null` at UTF-16 offset `cursor`. `values` maps field paths to the values seen + * for them (`Record`). + * @param {string} input + * @param {number} cursor + * @param {any} schema + * @param {any} values + * @returns {any} + */ +export function query_completions(input, cursor, schema, values) { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.query_completions(ptr0, len0, cursor, schema, values); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * The terms the schema cannot run (`Diagnostic[]`), with UTF-16 spans. + * @param {string} input + * @param {any} schema + * @returns {any} + */ +export function query_diagnostics(input, schema) { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.query_diagnostics(ptr0, len0, schema); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} + +/** + * @param {string} field + * @param {string} value + * @param {boolean} negated + * @returns {string} + */ +export function query_format_field(field, value, negated) { + let deferred3_0; + let deferred3_1; + try { + const ptr0 = passStringToWasm0(field, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + const ret = wasm.query_format_field(ptr0, len0, ptr1, len1, negated); + deferred3_0 = ret[0]; + deferred3_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred3_0, deferred3_1, 1); + } +} + +/** + * Highlighting spans (`Token[]`), in UTF-16 offsets. + * @param {string} input + * @returns {any} + */ +export function query_tokens(input) { + const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.query_tokens(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); +} +export function __wbg_Error_bce6d499ff0a4aff(arg0, arg1) { + const ret = Error(getStringFromWasm0(arg0, arg1)); + return ret; +} +export function __wbg_String_8564e559799eccda(arg0, arg1) { + const ret = String(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); +} +export function __wbg___wbindgen_bigint_get_as_i64_410e28c7b761ad83(arg0, arg1) { + const v = arg1; + const ret = typeof(v) === 'bigint' ? v : undefined; + getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); +} +export function __wbg___wbindgen_boolean_get_2304fb8c853028c8(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; +} +export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); +} +export function __wbg___wbindgen_in_07056af4f902c445(arg0, arg1) { + const ret = arg0 in arg1; + return ret; +} +export function __wbg___wbindgen_is_bigint_aeae3893f30ed54e(arg0) { + const ret = typeof(arg0) === 'bigint'; + return ret; +} +export function __wbg___wbindgen_is_function_5cd60d5cf78b4eef(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; +} +export function __wbg___wbindgen_is_null_2042690d351e14f0(arg0) { + const ret = arg0 === null; + return ret; +} +export function __wbg___wbindgen_is_object_b4593df85baada48(arg0) { + const val = arg0; + const ret = typeof(val) === 'object' && val !== null; + return ret; +} +export function __wbg___wbindgen_is_string_dde0fd9020db4434(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; +} +export function __wbg___wbindgen_is_undefined_35bb9f4c7fd651d5(arg0) { + const ret = arg0 === undefined; + return ret; +} +export function __wbg___wbindgen_jsval_eq_c0ed08b3e0f393b9(arg0, arg1) { + const ret = arg0 === arg1; + return ret; +} +export function __wbg___wbindgen_jsval_loose_eq_0ad77b7717db155c(arg0, arg1) { + const ret = arg0 == arg1; + return ret; +} +export function __wbg___wbindgen_number_get_f73a1244370fcc2c(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); +} +export function __wbg___wbindgen_string_get_d109740c0d18f4d7(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); +} +export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); +} +export function __wbg_call_13665d9f14390edc() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; +}, arguments); } +export function __wbg_done_54b8da57023b7ed2(arg0) { + const ret = arg0.done; + return ret; +} +export function __wbg_entries_564a7e8b1e54ede5(arg0) { + const ret = Object.entries(arg0); + return ret; +} +export function __wbg_get_3e9a707ab7d352eb() { return handleError(function (arg0, arg1) { + const ret = Reflect.get(arg0, arg1); + return ret; +}, arguments); } +export function __wbg_get_98fdf51d029a75eb(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; +} +export function __wbg_get_unchecked_1dfe6d05ad91d9b7(arg0, arg1) { + const ret = arg0[arg1 >>> 0]; + return ret; +} +export function __wbg_get_with_ref_key_6412cf3094599694(arg0, arg1) { + const ret = arg0[arg1]; + return ret; +} +export function __wbg_instanceof_ArrayBuffer_53db37b06f6b9afe(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; +} +export function __wbg_instanceof_Map_16f217b9a2a08d8c(arg0) { + let result; + try { + result = arg0 instanceof Map; + } catch (_) { + result = false; + } + const ret = result; + return ret; +} +export function __wbg_instanceof_Uint8Array_abd07d4bd221d50b(arg0) { + let result; + try { + result = arg0 instanceof Uint8Array; + } catch (_) { + result = false; + } + const ret = result; + return ret; +} +export function __wbg_isArray_94898ed3aad6947b(arg0) { + const ret = Array.isArray(arg0); + return ret; +} +export function __wbg_isSafeInteger_01e964d144ad3a55(arg0) { + const ret = Number.isSafeInteger(arg0); + return ret; +} +export function __wbg_iterator_1441b47f341dc34f() { + const ret = Symbol.iterator; + return ret; +} +export function __wbg_length_2591a0f4f659a55c(arg0) { + const ret = arg0.length; + return ret; +} +export function __wbg_length_56fcd3e2b7e0299d(arg0) { + const ret = arg0.length; + return ret; +} +export function __wbg_new_02d162bc6cf02f60() { + const ret = new Object(); + return ret; +} +export function __wbg_new_310879b66b6e95e1() { + const ret = new Array(); + return ret; +} +export function __wbg_new_7ddec6de44ff8f5d(arg0) { + const ret = new Uint8Array(arg0); + return ret; +} +export function __wbg_next_2a4e19f4f5083b0f(arg0) { + const ret = arg0.next; + return ret; +} +export function __wbg_next_6429a146bf756f93() { return handleError(function (arg0) { + const ret = arg0.next(); + return ret; +}, arguments); } +export function __wbg_prototypesetcall_5f9bdc8d75e07276(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); +} +export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) { + arg0[arg1] = arg2; +} +export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) { + arg0[arg1 >>> 0] = arg2; +} +export function __wbg_value_9cc0518af87a489c(arg0) { + const ret = arg0.value; + return ret; +} +export function __wbindgen_cast_0000000000000001(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; +} +export function __wbindgen_cast_0000000000000002(arg0) { + // Cast intrinsic for `I64 -> Externref`. + const ret = arg0; + return ret; +} +export function __wbindgen_cast_0000000000000003(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; +} +export function __wbindgen_cast_0000000000000004(arg0) { + // Cast intrinsic for `U64 -> Externref`. + const ret = BigInt.asUintN(64, arg0); + return ret; +} +export function __wbindgen_init_externref_table() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); +} +const QueryMatcherFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_querymatcher_free(ptr, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayU32FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint32ArrayMemory0().subarray(ptr / 4, ptr / 4 + len); +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + return decodeText(ptr >>> 0, len); +} + +let cachedUint32ArrayMemory0 = null; +function getUint32ArrayMemory0() { + if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) { + cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer); + } + return cachedUint32ArrayMemory0; +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function passArray32ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 4, 4) >>> 0; + getUint32ArrayMemory0().set(arg, ptr / 4); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passArrayJsValueToWasm0(array, malloc) { + const ptr = malloc(array.length * 4, 4) >>> 0; + for (let i = 0; i < array.length; i++) { + const add = addToExternrefTable0(array[i]); + getDataViewMemory0().setUint32(ptr + 4 * i, add, true); + } + WASM_VECTOR_LEN = array.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + + +let wasm; +export function __wbg_set_wasm(val) { + wasm = val; +} diff --git a/crates/yaak-query/pkg/yaak_query_bg.wasm b/crates/yaak-query/pkg/yaak_query_bg.wasm new file mode 100644 index 0000000000000000000000000000000000000000..c71e7d627d96d264cd5b1696e568d644dd54f9ad GIT binary patch literal 340382 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 SS0;OZw6SktlsZc9hy3j@~Qrw8hk( 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

M+taTgze9zk8{cLUEHqaA|EVQs9rK4C{;8j=w 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 z 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 void; +export const parse_query: (a: number, b: number) => [number, number, number]; +export const query_completions: (a: number, b: number, c: number, d: any, e: any) => [number, number, number]; +export const query_diagnostics: (a: number, b: number, c: any) => [number, number, number]; +export const query_format_field: (a: number, b: number, c: number, d: number, e: number) => [number, number]; +export const query_tokens: (a: number, b: number) => [number, number, number]; +export const querymatcher_isEmpty: (a: number) => number; +export const querymatcher_matches: (a: number, b: number, c: number) => [number, number, number]; +export const querymatcher_matchingRows: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => [number, number, number, number]; +export const querymatcher_new: (a: number, b: number, c: any) => [number, number, number]; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/crates/yaak-query/src/ast.rs b/crates/yaak-query/src/ast.rs new file mode 100644 index 000000000..0b1164154 --- /dev/null +++ b/crates/yaak-query/src/ast.rs @@ -0,0 +1,195 @@ +//! The parsed query tree. +//! +//! Every node carries the byte span of its source text, so a consumer can highlight it, point +//! at it in a diagnostic, or rewrite it in place. A consumer that compiles queries (to SQL, say) +//! walks [`Node`] directly; nothing here depends on how the query is evaluated. + +use serde::{Deserialize, Serialize}; + +/// A half-open byte range `start..end` in the query source. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start: start as u32, end: end as u32 } + } + + pub fn start(&self) -> usize { + self.start as usize + } + + pub fn end(&self) -> usize { + self.end as usize + } + + /// The source text this span covers. + pub fn slice<'a>(&self, source: &'a str) -> &'a str { + &source[self.start()..self.end()] + } + + pub(crate) fn to(&self, other: Span) -> Span { + Span { start: self.start.min(other.start), end: self.end.max(other.end) } + } +} + +/// A node of the parsed query. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum Node { + /// Every child matches: terms side by side, or joined with `AND`. + And { children: Vec, span: Span }, + /// Any child matches: terms joined with `OR`. + Or { children: Vec, span: Span }, + /// The node does not match: `-term`, `-(group)`, `-@field:value`. + Not { node: Box, span: Span }, + /// A bare word, matched against the document's free text. + Term { text: String, span: Span }, + /// A double-quoted string, matched against the free text with its spaces kept. + Phrase { text: String, span: Span }, + /// `@path:value`. + Field { + /// The dotted path, one entry per identifier: `@req.header.accept` is + /// `["req", "header", "accept"]`. + path: Vec, + value: FieldValue, + span: Span, + /// `@path:`, from the `@` through the colon. + #[serde(rename = "pathSpan")] + path_span: Span, + /// The value as written, quotes included. Empty (and at the end of the field) when + /// nothing follows the colon. + #[serde(rename = "valueSpan")] + value_span: Span, + }, +} + +impl Node { + pub fn span(&self) -> Span { + match self { + Node::And { span, .. } + | Node::Or { span, .. } + | Node::Not { span, .. } + | Node::Term { span, .. } + | Node::Phrase { span, .. } + | Node::Field { span, .. } => *span, + } + } + + /// Calls `f` on this node and every node below it, parents first. + pub fn walk<'a>(&'a self, f: &mut impl FnMut(&'a Node)) { + f(self); + match self { + Node::And { children, .. } | Node::Or { children, .. } => { + for child in children { + child.walk(f); + } + } + Node::Not { node, .. } => node.walk(f), + Node::Term { .. } | Node::Phrase { .. } | Node::Field { .. } => {} + } + } + + /// Rewrites every span in the tree, for a consumer that counts positions differently (the + /// WebAssembly package hands JavaScript UTF-16 offsets). + pub fn map_spans(&mut self, f: &impl Fn(Span) -> Span) { + match self { + Node::And { children, span } | Node::Or { children, span } => { + *span = f(*span); + for child in children { + child.map_spans(f); + } + } + Node::Not { node, span } => { + *span = f(*span); + node.map_spans(f); + } + Node::Term { span, .. } | Node::Phrase { span, .. } => *span = f(*span), + Node::Field { span, path_span, value_span, .. } => { + *span = f(*span); + *path_span = f(*path_span); + *value_span = f(*value_span); + } + } + } +} + +/// What a field term's value asks for. The shape is decided by the parser alone; the schema +/// decides what it means for a given field. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum FieldValue { + /// Plain text, matched as a case-insensitive substring. `quoted` when it was written in + /// double quotes (escapes already removed). Empty for a trailing `@field:`. + Text { text: String, quoted: bool }, + /// A bare value containing `*`, which must match the whole field value. `*` matches any + /// run of characters, including none. + Wildcard { pattern: String }, + /// `*` alone: the field has a value. Negated, it has none. + Exists, + /// `>`, `>=`, `<` or `<=` followed by a number. + Compare { + op: CompareOp, + number: NumberLiteral, + }, + /// `[low TO high]`, inclusive. + Range { + low: NumberLiteral, + high: NumberLiteral, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum CompareOp { + #[serde(rename = ">")] + Gt, + #[serde(rename = ">=")] + Gte, + #[serde(rename = "<")] + Lt, + #[serde(rename = "<=")] + Lte, +} + +impl CompareOp { + pub fn as_str(&self) -> &'static str { + match self { + CompareOp::Gt => ">", + CompareOp::Gte => ">=", + CompareOp::Lt => "<", + CompareOp::Lte => "<=", + } + } + + pub fn test(&self, left: f64, right: f64) -> bool { + match self { + CompareOp::Gt => left > right, + CompareOp::Gte => left >= right, + CompareOp::Lt => left < right, + CompareOp::Lte => left <= right, + } + } +} + +/// A number as written in a comparison or range, with its unit when it has one (`500ms`, +/// `2.5mb`). The schema says which units a field accepts and what they are worth. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct NumberLiteral { + pub value: f64, + pub unit: Option, + /// The number as it appears in the source, unit included. + pub raw: String, +} diff --git a/crates/yaak-query/src/complete.rs b/crates/yaak-query/src/complete.rs new file mode 100644 index 000000000..9849c50c2 --- /dev/null +++ b/crates/yaak-query/src/complete.rs @@ -0,0 +1,378 @@ +//! What can complete the term at the cursor. + +use crate::ast::Span; +use crate::format::{format_value, is_valid_path}; +use crate::lexer::{TokKind, is_ident_char, lex, lex_quoted}; +use crate::schema::{FieldKind, Schema}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +/// Completions for the term at the cursor, all replacing `span`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Completions { + pub span: Span, + pub items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Completion { + /// What to show: a field's path, or a value as it is. + pub label: String, + /// What replaces the span: `@method:`, `@header.`, or a value quoted when it needs to be. + pub insert: String, + pub kind: CompletionKind, + pub info: Option, + /// The term is not finished after inserting (a field still needs its value, a map field its + /// key), so completion should open again. + pub reopen: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum CompletionKind { + Field, + Value, +} + +/// Completions at byte offset `cursor` of `input`. +/// +/// At the start of a term, or in a word made of identifier characters (with or without its +/// `@`), these are the schema's fields whose path contains what is typed, those starting with +/// it first. After `@map.` they are the keys given in `values` under the map's name. After +/// `@field:` they are the values given in `values` under the field's path that contain what is +/// typed (quotes and escapes aside); the whole value is replaced. A field written without its +/// `@` (`method:post`) gets the term with the `@`. Inside a phrase, after a `)`, or in other +/// text there are none. +pub fn completions( + input: &str, + cursor: usize, + schema: &Schema, + values: &HashMap>, +) -> Option { + let mut cursor = cursor.min(input.len()); + while !input.is_char_boundary(cursor) { + cursor -= 1; + } + + let toks = lex(input); + let Some(tok) = toks.iter().find(|t| t.span.start() < cursor && cursor <= t.span.end()) else { + // Between terms: anything may start here + let at_term_start = input[..cursor].chars().next_back().is_none_or(char::is_whitespace); + return at_term_start + .then(|| field_completions(schema, values, "", Span::new(cursor, cursor))) + .flatten(); + }; + + match &tok.kind { + TokKind::Minus | TokKind::LParen if cursor == tok.span.end() => { + field_completions(schema, values, "", Span::new(cursor, cursor)) + } + TokKind::Word(word) => { + let typed = &input[tok.span.start()..cursor]; + if let Some(fix) = missing_at(word, typed, schema) { + return Some(Completions { span: tok.span, items: vec![fix] }); + } + let partial = typed.strip_prefix('@').unwrap_or(typed); + if !partial.chars().all(|c| is_ident_char(c) || c == '.') { + return None; + } + field_completions(schema, values, partial, Span::new(tok.span.start(), cursor)) + } + TokKind::Field { path, path_span, value_span, .. } => { + if cursor < value_span.start() { + // In the path: replace it through the colon + let partial = &input[tok.span.start() + 1..cursor]; + return field_completions(schema, values, partial, *path_span); + } + value_completions(input, cursor, path, *value_span, schema, values) + } + _ => None, + } +} + +/// A word written like a field but without its `@` (`method:post`), once the cursor is past the +/// colon: the same term with the `@`, replacing the word. Words like `localhost:3000`, whose +/// part before the colon is no field, get nothing. +fn missing_at(word: &str, typed: &str, schema: &Schema) -> Option { + if word.starts_with('@') || !typed.contains(':') { + return None; + } + let (path, value) = word.split_once(':')?; + if !is_valid_path(path) { + return None; + } + let path: Vec = path.split('.').map(String::from).collect(); + schema.resolve(&path)?; + let term = format!("@{word}"); + Some(Completion { + label: term.clone(), + insert: term, + kind: CompletionKind::Field, + info: None, + // With no value yet, go on to the field's values + reopen: value.is_empty(), + }) +} + +fn field_completions( + schema: &Schema, + values: &HashMap>, + partial: &str, + span: Span, +) -> Option { + let needle = partial.to_lowercase(); + let mut items = Vec::new(); + + // Keys under a map: `@header.x-` offers `@header.x-cache:` + if let Some((map_name, key_partial)) = needle.rsplit_once('.') + && let Some(map) = schema.fields.iter().find(|f| f.map && f.name.to_lowercase() == map_name) + { + let keys = lookup(values, map_name).into_iter().flatten(); + for key in ranked(keys, key_partial) { + let path = format!("{}.{}", map.name, key); + if !is_valid_path(&path) { + continue; + } + items.push(Completion { + insert: format!("@{path}:"), + label: path, + kind: CompletionKind::Field, + info: None, + reopen: true, + }); + } + } + + for field in ranked_by(&schema.fields, &needle, |f| f.name.as_str()) { + let insert = + if field.map { format!("@{}.", field.name) } else { format!("@{}:", field.name) }; + items.push(Completion { + label: field.name.clone(), + insert, + kind: CompletionKind::Field, + info: field.info.clone(), + reopen: true, + }); + } + + (!items.is_empty()).then_some(Completions { span, items }) +} + +fn value_completions( + input: &str, + cursor: usize, + path: &[String], + value_span: Span, + schema: &Schema, + values: &HashMap>, +) -> Option { + let field = schema.resolve(path)?; + let typed = &input[value_span.start()..cursor]; + let needle = if typed.starts_with('"') { lex_quoted(typed, 0).0 } else { typed.to_string() }; + + let joined = path.join("."); + let flags = ["true".to_string(), "false".to_string()]; + let candidates: Vec<&String> = match lookup(values, &joined) { + Some(given) => given.iter().collect(), + None if field.def.kind == FieldKind::Flag => flags.iter().collect(), + None => return None, + }; + + let items: Vec = ranked(candidates, &needle.to_lowercase()) + .into_iter() + .map(|value| Completion { + label: value.clone(), + insert: format_value(value), + kind: CompletionKind::Value, + info: None, + reopen: false, + }) + .collect(); + (!items.is_empty()).then_some(Completions { span: value_span, items }) +} + +/// The values a consumer gave for `path`, matched case-insensitively. +fn lookup<'a>(values: &'a HashMap>, path: &str) -> Option<&'a Vec> { + values + .get(path) + .or_else(|| values.iter().find(|(k, _)| k.eq_ignore_ascii_case(path)).map(|(_, v)| v)) +} + +/// The strings containing `needle` (lowercase), those starting with it first, each once. +fn ranked<'a>(items: impl IntoIterator, needle: &str) -> Vec<&'a String> { + let items: Vec<&String> = items.into_iter().collect(); + let mut seen = HashSet::new(); + ranked_by(&items, needle, |s| s.as_str()) + .into_iter() + .copied() + .filter(|s| seen.insert(*s)) + .collect() +} + +fn ranked_by<'a, T>(items: &'a [T], needle: &str, key: impl Fn(&T) -> &str) -> Vec<&'a T> { + let lower = |item: &T| key(item).to_lowercase(); + let starts = items.iter().filter(|i| lower(i).starts_with(needle)); + let contains = items.iter().filter(|i| { + let l = lower(i); + !l.starts_with(needle) && l.contains(needle) + }); + starts.chain(contains).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{FieldDef, FreeText}; + + fn schema() -> Schema { + let text = |name: &str| FieldDef { + name: name.into(), + kind: FieldKind::Text, + map: false, + info: None, + }; + Schema::new( + vec![ + text("method"), + text("url"), + text("name"), + FieldDef { name: "header".into(), kind: FieldKind::Text, map: true, info: None }, + FieldDef { name: "error".into(), kind: FieldKind::Flag, map: false, info: None }, + ], + FreeText::Fuzzy, + ) + } + + fn values() -> HashMap> { + HashMap::from([ + ( + "method".to_string(), + vec!["GET".to_string(), "POST".to_string(), "PATCH".to_string()], + ), + ("name".to_string(), vec!["My Request".to_string(), "Other".to_string()]), + ( + "header".to_string(), + vec![ + "x-cache".to_string(), + "Accept".to_string(), + "bad key".to_string(), + ], + ), + ]) + } + + /// The completion at the `|` in `input`: the replaced text and the inserts. + fn at(input: &str) -> Option<(String, Vec)> { + let cursor = input.find('|').unwrap(); + let input = input.replacen('|', "", 1); + let c = completions(&input, cursor, &schema(), &values())?; + Some((c.span.slice(&input).to_string(), c.items.into_iter().map(|i| i.insert).collect())) + } + + fn inserts(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn fields_at_a_term_start() { + let all = inserts(&["@method:", "@url:", "@name:", "@header.", "@error:"]); + assert_eq!(at("|"), Some(("".into(), all.clone()))); + assert_eq!(at("a |"), Some(("".into(), all.clone()))); + assert_eq!(at("-|"), Some(("".into(), all.clone()))); + assert_eq!(at("(|"), Some(("".into(), all))); + } + + #[test] + fn fields_for_a_partial_word() { + assert_eq!(at("@met|"), Some(("@met".into(), inserts(&["@method:"])))); + assert_eq!(at("met|"), Some(("met".into(), inserts(&["@method:"])))); + // Starts-with first, then contains + assert_eq!(at("@me|"), Some(("@me".into(), inserts(&["@method:", "@name:"])))); + assert_eq!(at("-@ur|"), Some(("@ur".into(), inserts(&["@url:"])))); + assert_eq!(at("x (@na|"), Some(("@na".into(), inserts(&["@name:"])))); + assert_eq!( + at("@e|"), + Some(("@e".into(), inserts(&["@error:", "@method:", "@name:", "@header."]))) + ); + assert_eq!(at("@zz|"), None); + assert_eq!(at("localhost:30|"), None); + } + + #[test] + fn map_keys() { + assert_eq!( + at("@header.|"), + Some(("@header.".into(), inserts(&["@header.x-cache:", "@header.Accept:"]))) + ); + assert_eq!( + at("@header.ac|"), + Some(("@header.ac".into(), inserts(&["@header.Accept:", "@header.x-cache:"]))) + ); + } + + #[test] + fn values_after_the_colon() { + assert_eq!(at("@method:|"), Some(("".into(), inserts(&["GET", "POST", "PATCH"])))); + assert_eq!(at("@method:p|"), Some(("p".into(), inserts(&["POST", "PATCH"])))); + assert_eq!(at("@method:t|"), Some(("t".into(), inserts(&["GET", "POST", "PATCH"])))); + assert_eq!(at("@name:my|"), Some(("my".into(), inserts(&["\"My Request\""])))); + assert_eq!(at("@name:\"my r|"), Some(("\"my r".into(), inserts(&["\"My Request\""])))); + // The whole value is replaced, even past the cursor + assert_eq!(at("@method:p|os x"), Some(("pos".into(), inserts(&["POST", "PATCH"])))); + assert_eq!(at("@method:zz|"), None); + // Fields with no values given offer none, unknown fields neither + assert_eq!(at("@url:|"), None); + assert_eq!(at("@nope:|"), None); + // Flags offer true and false + assert_eq!(at("@error:|"), Some(("".into(), inserts(&["true", "false"])))); + } + + #[test] + fn editing_a_field_name() { + assert_eq!(at("@met|hod:post"), Some(("@method:".into(), inserts(&["@method:"])))); + } + + #[test] + fn suggests_the_at_for_a_field_written_without_it() { + assert_eq!(at("method:post|"), Some(("method:post".into(), inserts(&["@method:post"])))); + assert_eq!(at("x method:p|"), Some(("method:p".into(), inserts(&["@method:p"])))); + assert_eq!(at("-method:po|st"), Some(("method:post".into(), inserts(&["@method:post"])))); + assert_eq!(at("(METHOD:get|"), Some(("METHOD:get".into(), inserts(&["@METHOD:get"])))); + assert_eq!( + at("header.accept:json|"), + Some(("header.accept:json".into(), inserts(&["@header.accept:json"]))) + ); + // With nothing after the colon, the field's values come next + let c = completions("method:", 7, &schema(), &values()).unwrap(); + assert_eq!(c.items[0].insert, "@method:"); + assert!(c.items[0].reopen); + assert!(!completions("method:x", 8, &schema(), &values()).unwrap().items[0].reopen); + // Not a field: plain text, nothing to suggest + assert_eq!(at("localhost:3000|"), None); + assert_eq!(at("nope:x|"), None); + assert_eq!(at("http://x|"), None); + // Before the colon, the word still completes field names + assert_eq!(at("met|hod:post"), Some(("met".into(), inserts(&["@method:"])))); + } + + #[test] + fn nothing_inside_phrases_or_after_parens() { + assert_eq!(at("\"my |"), None); + assert_eq!(at("(a)|"), None); + // A word is replaced up to the cursor + assert_eq!(at("a|b"), Some(("a".into(), inserts(&["@name:", "@header."])))); + } + + #[test] + fn cursor_is_clamped() { + let c = completions("é", 1, &schema(), &values()); + assert!(c.is_none() || c.unwrap().span.end <= 2); + assert!(completions("", 99, &schema(), &values()).is_some()); + } +} diff --git a/crates/yaak-query/src/eval.rs b/crates/yaak-query/src/eval.rs new file mode 100644 index 000000000..85dd998e8 --- /dev/null +++ b/crates/yaak-query/src/eval.rs @@ -0,0 +1,1055 @@ +//! Runs a parsed query in memory, against one document or a whole table of them. + +use crate::ast::{FieldValue, Node}; +use crate::fuzzy::{FuzzyScratch, fuzzy_match_in}; +use crate::schema::{FieldKind, FreeText, Schema, parse_flag, to_base}; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::collections::HashMap; + +/// What a query is matched against: free text for bare words and phrases, and field values by +/// lowercase dotted path. A map field's values sit under `name.key` (`header.x-cache`). +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Document { + #[serde(default)] + #[cfg_attr(test, ts(optional, as = "Option"))] + pub text: String, + #[serde(default)] + #[cfg_attr(test, ts(optional, as = "Option>"))] + pub fields: HashMap, +} + +/// One value or several. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum DocValues { + One(DocValue), + Many(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum DocValue { + Flag(bool), + Number(f64), + Text(String), +} + +impl Document { + pub fn new(text: impl Into) -> Self { + Self { text: text.into(), fields: HashMap::new() } + } + + /// Adds a value under `path`, keeping any already there. + pub fn with(mut self, path: &str, value: impl Into) -> Self { + let value = value.into(); + let path = path.to_lowercase(); + match self.fields.remove(&path) { + None => self.fields.insert(path, DocValues::One(value)), + Some(DocValues::One(first)) => { + self.fields.insert(path, DocValues::Many(vec![first, value])) + } + Some(DocValues::Many(mut values)) => { + values.push(value); + self.fields.insert(path, DocValues::Many(values)) + } + }; + self + } +} + +impl From<&str> for DocValue { + fn from(value: &str) -> Self { + DocValue::Text(value.to_string()) + } +} + +impl From for DocValue { + fn from(value: String) -> Self { + DocValue::Text(value) + } +} + +impl From for DocValue { + fn from(value: f64) -> Self { + DocValue::Number(value) + } +} + +impl From for DocValue { + fn from(value: bool) -> Self { + DocValue::Flag(value) + } +} + +impl DocValue { + fn view(&self) -> Val<'_> { + match self { + DocValue::Text(text) => Val::Text(text), + DocValue::Number(n) => Val::Number(*n), + DocValue::Flag(b) => Val::Flag(*b), + } + } +} + +/// Many documents with the same fields, as one flat run of cells: for each row, its free text, +/// then a text value (or none) per column. Cells may borrow from the input they were read from. +/// Matching a table is much cheaper than matching its rows one document at a time. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Table<'a> { + /// Lowercase field paths, one per column. + columns: Vec, + cells: Vec>>, +} + +impl<'a> Table<'a> { + /// An empty table whose columns hold the fields at these paths. + pub fn new(columns: impl IntoIterator>) -> Self { + let columns = columns.into_iter().map(|c| c.into().to_lowercase()).collect(); + Self { columns, cells: Vec::new() } + } + + /// A table from its cells, already flat (text first, then a cell per column, row after + /// row). `None` when the cells don't fill whole rows. + pub fn from_cells( + columns: impl IntoIterator>, + cells: Vec>>, + ) -> Option { + let table = Self { cells, ..Self::new(columns) }; + table.cells.len().is_multiple_of(table.stride()).then_some(table) + } + + /// A table from cells packed the way JavaScript can produce them cheaply: every cell joined + /// into `joined`, with each cell's length in UTF-16 code units in `lengths` (or + /// [`Table::NO_VALUE`] for a cell with no value). Cells borrow from `joined`. `None` when + /// the lengths don't add up to `joined` or the cells don't fill whole rows. + pub fn from_packed( + columns: impl IntoIterator>, + joined: &'a str, + lengths: &[u32], + ) -> Option { + let mut cells = Vec::with_capacity(lengths.len()); + let ascii = joined.is_ascii(); + let mut start = 0; + for &len in lengths { + if len == Self::NO_VALUE { + cells.push(None); + continue; + } + let end = if ascii { + start + len as usize + } else { + // Walk `len` UTF-16 units forward + let mut units = 0; + let mut end = start; + for c in joined.get(start..)?.chars() { + if units >= len as usize { + break; + } + units += c.len_utf16(); + end += c.len_utf8(); + } + if units != len as usize { + return None; + } + end + }; + cells.push(Some(Cow::Borrowed(joined.get(start..end)?))); + start = end; + } + if start != joined.len() { + return None; + } + Self::from_cells(columns, cells) + } + + /// The length [`Table::from_packed`] reads as a cell with no value. + pub const NO_VALUE: u32 = u32::MAX; + + /// Adds a row: its free text, and a value or `None` per column, in column order. Missing + /// trailing cells are `None`; extra ones are dropped. + pub fn push( + &mut self, + text: impl Into>, + cells: impl IntoIterator>>, + ) { + self.cells.push(Some(text.into())); + let mut cells = cells.into_iter(); + for _ in 0..self.columns.len() { + self.cells.push(cells.next().flatten()); + } + } + + pub fn len(&self) -> usize { + self.cells.len() / self.stride() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + fn stride(&self) -> usize { + self.columns.len() + 1 + } +} + +/// A field value as matching sees it, borrowed from a document or a table. +#[derive(Clone, Copy, Debug)] +enum Val<'a> { + Text(&'a str), + Number(f64), + Flag(bool), +} + +impl Val<'_> { + /// The value as lowercase text. + fn lower(&self) -> Cow<'_, str> { + match self { + Val::Text(text) => Cow::Owned(text.to_lowercase()), + Val::Number(n) => Cow::Owned(format_number(*n)), + Val::Flag(b) => Cow::Borrowed(if *b { "true" } else { "false" }), + } + } + + /// Whether the value, lowercased, contains `needle` (lowercase). + fn contains(&self, needle: &str) -> bool { + match self { + Val::Text(text) => contains_ignore_case(text, needle), + _ => self.lower().contains(needle), + } + } + + fn as_number(&self) -> Option { + match self { + Val::Number(n) => Some(*n), + Val::Text(text) => text.trim().parse().ok(), + Val::Flag(_) => None, + } + } + + fn as_flag(&self) -> Option { + match self { + Val::Flag(b) => Some(*b), + Val::Text(text) => parse_flag(text), + Val::Number(n) => Some(*n != 0.0), + } + } + + fn is_present(&self) -> bool { + match self { + Val::Text(text) => !text.is_empty(), + Val::Number(_) => true, + Val::Flag(b) => *b, + } + } +} + +/// Whether `hay`, lowercased, contains `needle` (lowercase), without copying ASCII text. +fn contains_ignore_case(hay: &str, needle: &str) -> bool { + if !(hay.is_ascii() && needle.is_ascii()) { + return hay.to_lowercase().contains(needle); + } + let (hay, needle) = (hay.as_bytes(), needle.as_bytes()); + needle.is_empty() || hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle)) +} + +/// `text` lowercased into `out`, without a fresh allocation per call. +fn lowercase_into(text: &str, out: &mut String) { + out.clear(); + if text.is_ascii() { + out.push_str(text); + out.make_ascii_lowercase(); + } else { + out.push_str(&text.to_lowercase()); + } +} + +/// `200`, not `200.0`, so a number field's text matches what the user sees. +fn format_number(n: f64) -> String { + if n.fract() == 0.0 && n.abs() < 1e15 { format!("{}", n as i64) } else { n.to_string() } +} + +/// Where field values come from: a [`Document`], or a row of a [`Table`]. +trait FieldSource { + /// Whether any value under `path` (lowercase) passes `test`. With `any_key`, `path` is a + /// map field and the values of every key under it count. + fn any_value(&self, path: &str, any_key: bool, test: &mut dyn FnMut(Val) -> bool) -> bool; +} + +impl FieldSource for Document { + fn any_value(&self, path: &str, any_key: bool, test: &mut dyn FnMut(Val) -> bool) -> bool { + self.fields.iter().filter(|(key, _)| path_matches(key, path, any_key)).any(|(_, values)| { + match values { + DocValues::One(v) => test(v.view()), + DocValues::Many(vs) => vs.iter().any(|v| test(v.view())), + } + }) + } +} + +struct RowSource<'t, 'a> { + columns: &'t [String], + /// The row's cells, after its text. + cells: &'t [Option>], +} + +impl FieldSource for RowSource<'_, '_> { + fn any_value(&self, path: &str, any_key: bool, test: &mut dyn FnMut(Val) -> bool) -> bool { + self.columns.iter().zip(self.cells).any(|(column, cell)| { + path_matches(column, path, any_key) + && cell.as_deref().is_some_and(|v| test(Val::Text(v))) + }) + } +} + +/// Whether a document key is `path`, or with `any_key`, any key under it (`path.`). Paths +/// are ASCII identifiers, so comparing ASCII case-insensitively is exact. +fn path_matches(key: &str, path: &str, any_key: bool) -> bool { + let (key, path) = (key.as_bytes(), path.as_bytes()); + if key.eq_ignore_ascii_case(path) { + return true; + } + any_key + && key.len() > path.len() + 1 + && key[..path.len()].eq_ignore_ascii_case(path) + && key[path.len()] == b'.' +} + +/// Whether `doc` matches the query. `None` (an empty query) matches everything. To match many +/// documents, build a [`Matcher`] once instead. +pub fn evaluate(root: Option<&Node>, schema: &Schema, doc: &Document) -> bool { + Matcher::new(root, schema).matches(doc) +} + +/// A query resolved against a schema once (fields looked up, text lowercased), to match many +/// documents. +#[derive(Clone, Debug, PartialEq)] +pub struct Matcher { + plan: Option, + fuzzy: bool, + /// Whether any term reads the free text, which then has to be lowercased per document. + uses_text: bool, +} + +#[derive(Clone, Debug, PartialEq)] +enum Plan { + And(Vec), + Or(Vec), + Not(Box), + /// A bare word, lowercased and trimmed. + Term(String), + /// A phrase, lowercased. + Phrase(String), + Field { + /// The lowercase path the document stores the values under. + path: String, + /// A map field with no key: every key's values. + any_key: bool, + kind: FieldKind, + /// With its text and wildcard pattern lowercased. + value: FieldValue, + }, + /// A term that can match nothing: an unknown field, an empty word or phrase. + Never, +} + +/// Buffers reused from one document to the next. +#[derive(Default)] +struct Scratch { + text: String, + fuzzy: FuzzyScratch, +} + +impl Matcher { + pub fn new(root: Option<&Node>, schema: &Schema) -> Self { + let mut uses_text = false; + let plan = root.map(|root| plan_node(root, schema, &mut uses_text)); + Self { plan, fuzzy: schema.free_text == FreeText::Fuzzy, uses_text } + } + + /// Whether the query has no terms, so it matches everything. + pub fn is_empty(&self) -> bool { + self.plan.is_none() + } + + pub fn matches(&self, doc: &Document) -> bool { + self.matches_source(&doc.text, doc, &mut Scratch::default()) + } + + /// The indexes of the rows that match, in order. + pub fn matching_rows(&self, table: &Table) -> Vec { + let mut scratch = Scratch::default(); + let rows = table.cells.chunks_exact(table.stride()); + rows.enumerate() + .filter(|(_, row)| { + let text = row[0].as_deref().unwrap_or_default(); + let source = RowSource { columns: &table.columns, cells: &row[1..] }; + self.matches_source(text, &source, &mut scratch) + }) + .map(|(i, _)| i as u32) + .collect() + } + + fn matches_source(&self, text: &str, source: &impl FieldSource, scratch: &mut Scratch) -> bool { + let Some(plan) = &self.plan else { return true }; + if self.uses_text { + lowercase_into(text, &mut scratch.text); + } + let Scratch { text, fuzzy } = scratch; + self.eval(plan, text, source, fuzzy) + } + + fn eval( + &self, + plan: &Plan, + text: &str, + source: &impl FieldSource, + fuzzy: &mut FuzzyScratch, + ) -> bool { + match plan { + Plan::And(children) => children.iter().all(|c| self.eval(c, text, source, fuzzy)), + Plan::Or(children) => children.iter().any(|c| self.eval(c, text, source, fuzzy)), + Plan::Not(node) => !self.eval(node, text, source, fuzzy), + Plan::Term(term) if self.fuzzy => fuzzy_match_in(text, term, fuzzy), + Plan::Term(term) | Plan::Phrase(term) => text.contains(term.as_str()), + Plan::Field { path, any_key, kind, value } => { + let mut any = + |test: &mut dyn FnMut(Val) -> bool| source.any_value(path, *any_key, test); + match kind { + FieldKind::Text => match_text(&mut any, value), + FieldKind::Number { units } => match_number(&mut any, value, units), + FieldKind::Flag => match_flag(&mut any, value), + } + } + Plan::Never => false, + } + } +} + +fn plan_node(node: &Node, schema: &Schema, uses_text: &mut bool) -> Plan { + let mut children = + |nodes: &[Node]| nodes.iter().map(|n| plan_node(n, schema, uses_text)).collect(); + match node { + Node::And { children: nodes, .. } => Plan::And(children(nodes)), + Node::Or { children: nodes, .. } => Plan::Or(children(nodes)), + Node::Not { node, .. } => Plan::Not(Box::new(plan_node(node, schema, uses_text))), + Node::Term { text, .. } => { + let term = text.trim().to_lowercase(); + if term.is_empty() { + return Plan::Never; + } + *uses_text = true; + Plan::Term(term) + } + Node::Phrase { text, .. } => { + if text.is_empty() { + return Plan::Never; + } + *uses_text = true; + Plan::Phrase(text.to_lowercase()) + } + Node::Field { path, value, .. } => { + let Some(field) = schema.resolve(path) else { + return Plan::Never; + }; + let value = match value { + FieldValue::Text { text, quoted } => { + FieldValue::Text { text: text.to_lowercase(), quoted: *quoted } + } + FieldValue::Wildcard { pattern } => { + FieldValue::Wildcard { pattern: pattern.to_lowercase() } + } + other => other.clone(), + }; + Plan::Field { + path: field.document_path(), + any_key: field.def.map && field.key.is_none(), + kind: field.def.kind.clone(), + value, + } + } + } +} + +/// Asks whether any of a field's values passes a test. +type AnyValue<'s> = dyn FnMut(&mut dyn FnMut(Val) -> bool) -> bool + 's; + +/// `value`'s text and wildcard pattern are already lowercase (see [`plan_node`]). +fn match_text(any: &mut AnyValue, value: &FieldValue) -> bool { + match value { + FieldValue::Text { text: needle, .. } => { + !needle.is_empty() && any(&mut |v| v.contains(needle)) + } + FieldValue::Wildcard { pattern } => any(&mut |v| wildcard_match(pattern, &v.lower())), + FieldValue::Exists => any(&mut |v| v.is_present()), + // A text field holding numbers, such as a status shown as text + FieldValue::Compare { op, number } => { + number.unit.is_none() + && any(&mut |v| v.as_number().is_some_and(|n| op.test(n, number.value))) + } + FieldValue::Range { low, high } => { + low.unit.is_none() + && high.unit.is_none() + && any(&mut |v| v.as_number().is_some_and(|n| n >= low.value && n <= high.value)) + } + } +} + +fn match_number(any: &mut AnyValue, value: &FieldValue, units: &[crate::Unit]) -> bool { + match value { + FieldValue::Text { text, quoted } => { + if text.is_empty() { + return false; + } + // `@status:404` is equality; anything else matches the number as text + match (!quoted).then(|| crate::lexer::parse_number(text)).flatten() { + Some(number) => to_base(&number, units) + .is_some_and(|target| any(&mut |v| v.as_number() == Some(target))), + None => match_text(any, value), + } + } + FieldValue::Wildcard { .. } => match_text(any, value), + FieldValue::Exists => any(&mut |v| v.as_number().is_some()), + FieldValue::Compare { op, number } => to_base(number, units) + .is_some_and(|target| any(&mut |v| v.as_number().is_some_and(|n| op.test(n, target)))), + FieldValue::Range { low, high } => match (to_base(low, units), to_base(high, units)) { + (Some(low), Some(high)) => { + any(&mut |v| v.as_number().is_some_and(|n| n >= low && n <= high)) + } + _ => false, + }, + } +} + +fn match_flag(any: &mut AnyValue, value: &FieldValue) -> bool { + let mut is_set = || any(&mut |v| v.as_flag() == Some(true)); + match value { + FieldValue::Exists => is_set(), + FieldValue::Text { text, .. } => match parse_flag(text) { + Some(true) => is_set(), + Some(false) => !is_set(), + None => false, + }, + _ => false, + } +} + +/// Whether `pattern`, where `*` matches any run of characters, matches the whole of `text`. +pub(crate) fn wildcard_match(pattern: &str, text: &str) -> bool { + let mut parts = pattern.split('*'); + let first = parts.next().unwrap_or_default(); + let Some(mut rest) = text.strip_prefix(first) else { + return false; + }; + let parts: Vec<&str> = parts.collect(); + let Some((last, middle)) = parts.split_last() else { + // No `*` at all: the whole text must equal the pattern + return rest.is_empty(); + }; + for part in middle { + match rest.find(part) { + Some(i) => rest = &rest[i + part.len()..], + None => return false, + } + } + rest.len() >= last.len() && rest.ends_with(last) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::schema::{FieldDef, Unit}; + use crate::{format_field, parse}; + + fn text_field(name: &str) -> FieldDef { + FieldDef { name: name.into(), kind: FieldKind::Text, map: false, info: None } + } + + /// The fields and free-text mode of Yaak's sidebar. + fn sidebar() -> Schema { + Schema::new( + [ + "method", + "url", + "name", + "type", + "folder", + "grpc_service", + "grpc_method", + ] + .into_iter() + .map(text_field) + .collect(), + FreeText::Fuzzy, + ) + } + + fn proxy_like() -> Schema { + Schema::new( + vec![ + text_field("url"), + FieldDef { name: "header".into(), kind: FieldKind::Text, map: true, info: None }, + FieldDef { + name: "status".into(), + kind: FieldKind::Number { units: vec![] }, + map: false, + info: None, + }, + FieldDef { + name: "duration".into(), + kind: FieldKind::Number { + units: vec![ + Unit { name: "ms".into(), factor: 1.0 }, + Unit { name: "s".into(), factor: 1000.0 }, + ], + }, + map: false, + info: None, + }, + FieldDef { name: "error".into(), kind: FieldKind::Flag, map: false, info: None }, + ], + FreeText::Substring, + ) + } + + /// An HTTP request in Yaak's sidebar, built the way `getItemFields` and `getItemText` do. + fn request(method: &str, name: &str, url: &str, folder: Option<&str>) -> Document { + let mut doc = Document::new(format!("{method} {name}")) + .with("method", method.to_uppercase()) + .with("url", url) + .with("name", name) + .with("type", "http"); + if let Some(folder) = folder { + doc = doc.with("folder", folder); + } + doc + } + + fn grpc(name: &str, service: &str, method: &str) -> Document { + Document::new(name) + .with("grpc_method", method) + .with("grpc_service", service) + .with("url", "localhost:50051") + .with("name", name) + .with("type", "grpc") + } + + fn matches(query: &str, schema: &Schema, doc: &Document) -> bool { + evaluate(parse(query).as_ref(), schema, doc) + } + + fn sidebar_matches(query: &str, doc: &Document) -> bool { + matches(query, &sidebar(), doc) + } + + #[test] + fn empty_query_matches_everything() { + assert!(sidebar_matches("", &Document::default())); + assert!(sidebar_matches(" ", &Document::default())); + } + + #[test] + fn bare_words_are_fuzzy_in_the_sidebar() { + let doc = request("GET", "My Request", "https://yaak.app", None); + assert!(sidebar_matches("my", &doc)); + assert!(sidebar_matches("REQ", &doc)); + assert!(sidebar_matches("mr", &doc)); + assert!(sidebar_matches("get", &doc)); + assert!(!sidebar_matches("rq", &doc)); + // Bare words match the free text only, not the URL + assert!(!sidebar_matches("yaak", &doc)); + } + + #[test] + fn bare_words_are_substrings_in_substring_mode() { + let doc = Document::new("GET /v1/charges 200"); + let schema = proxy_like(); + assert!(matches("rges", &schema, &doc)); + assert!(matches("v1 charges", &schema, &doc)); + assert!(!matches("gv", &schema, &doc)); + } + + #[test] + fn phrases_are_case_insensitive_substrings() { + let doc = request("GET", "My Request", "", None); + assert!(sidebar_matches("\"my req\"", &doc)); + assert!(sidebar_matches("\"MY REQ\"", &doc)); + assert!(!sidebar_matches("\"mr\"", &doc)); + assert!(!sidebar_matches("\"\"", &doc)); + } + + #[test] + fn fields() { + let post = request("post", "Create user", "https://api.yaak.app/users", Some("Users")); + let get = request("get", "List users", "https://api.yaak.app/users", Some("Admin")); + let rpc = grpc("Say hello", "helloworld.Greeter", "SayHello"); + + assert!(sidebar_matches("@method:post", &post)); + assert!(sidebar_matches("@METHOD:Po", &post)); + assert!(!sidebar_matches("@method:post", &get)); + assert!(sidebar_matches("@url:yaak.app/users", &post)); + assert!(sidebar_matches("@folder:users", &post)); + assert!(!sidebar_matches("@folder:users", &get)); + assert!(sidebar_matches("@folder:users OR @folder:admin", &get)); + assert!(sidebar_matches("@type:grpc", &rpc)); + assert!(!sidebar_matches("-@type:grpc", &rpc)); + assert!(sidebar_matches("-@type:grpc", &post)); + assert!(sidebar_matches("@grpc_service:greeter @grpc_method:hello", &rpc)); + // A field the document lacks matches nothing, and its negation everything + assert!(!sidebar_matches("@grpc_method:hello", &post)); + assert!(sidebar_matches("-@grpc_method:hello", &post)); + // `@` is required: without it, the term is free text + assert!(!sidebar_matches("method:post", &post)); + assert!(sidebar_matches("localhost:50051", &Document::new("localhost:50051"))); + // An unknown field matches nothing + assert!(!sidebar_matches("@nope:post", &post)); + // A trailing field with no value yet matches nothing, as before + assert!(!sidebar_matches("@method:", &post)); + } + + #[test] + fn groups_and_negation() { + let a = Document::new("alpha"); + let b = Document::new("beta"); + let ac = Document::new("alpha charlie"); + assert!(sidebar_matches("(alpha OR beta) -charlie", &a)); + assert!(sidebar_matches("(alpha OR beta) -charlie", &b)); + assert!(!sidebar_matches("(alpha OR beta) -charlie", &ac)); + assert!(sidebar_matches("-(alpha OR beta)", &Document::new("delta"))); + assert!(sidebar_matches("alpha charlie", &ac)); + assert!(!sidebar_matches("alpha charlie", &a)); + } + + #[test] + fn wildcards() { + assert!(wildcard_match("*.github.com", "api.github.com")); + assert!(!wildcard_match("*.github.com", "github.com")); + assert!(wildcard_match("a*", "a")); + assert!(wildcard_match("*", "")); + assert!(wildcard_match("a*b*c", "aXXbYYc")); + assert!(!wildcard_match("a*b*c", "aXXbYYcd")); + assert!(wildcard_match("a**c", "ac")); + assert!(!wildcard_match("ab*ba", "aba")); + + let doc = Document::default().with("url", "https://API.github.com/repos"); + let schema = proxy_like(); + assert!(matches("@url:https://api.github.com/*", &schema, &doc)); + assert!(matches("@url:*GITHUB*", &schema, &doc)); + assert!(!matches("@url:github*", &schema, &doc)); + // Quoted, `*` is literal + assert!(!matches("@url:\"*github*\"", &schema, &doc)); + assert!(matches("@url:\"*\"", &schema, &Document::default().with("url", "a*b"))); + } + + #[test] + fn presence() { + let schema = proxy_like(); + let with = Document::default().with("header.x-cache", "HIT").with("status", 200.0); + let without = Document::default().with("url", "x").with("status", 200.0); + assert!(matches("@header.x-cache:*", &schema, &with)); + assert!(!matches("@header.x-cache:*", &schema, &without)); + assert!(matches("-@header.x-cache:*", &schema, &without)); + assert!(matches("@status:*", &schema, &with)); + assert!(!matches("@url:*", &schema, &Document::default().with("url", ""))); + } + + #[test] + fn map_fields() { + let schema = proxy_like(); + let doc = Document::default() + .with("header.content-type", "application/json") + .with("header.x-cache", "HIT"); + assert!(matches("@header.content-type:json", &schema, &doc)); + assert!(matches("@header.Content-Type:JSON", &schema, &doc)); + assert!(!matches("@header.content-type:xml", &schema, &doc)); + assert!(!matches("@header.accept:json", &schema, &doc)); + // The map with no key looks at every key + assert!(matches("@header:hit", &schema, &doc)); + assert!(matches("@header:*", &schema, &doc)); + } + + #[test] + fn numbers() { + let schema = proxy_like(); + let doc = Document::default().with("status", 404.0).with("duration", 1500.0); + assert!(matches("@status:404", &schema, &doc)); + assert!(!matches("@status:40", &schema, &doc)); + assert!(matches("@status:40*", &schema, &doc)); + assert!(matches("@status:>=400", &schema, &doc)); + assert!(!matches("@status:<400", &schema, &doc)); + assert!(matches("@status:[400 TO 499]", &schema, &doc)); + assert!(matches("@status:[404 TO 404]", &schema, &doc)); + assert!(!matches("@status:[200 TO 299]", &schema, &doc)); + assert!(matches("@duration:>1s", &schema, &doc)); + assert!(matches("@duration:>1000ms", &schema, &doc)); + assert!(matches("@duration:1.5s", &schema, &doc)); + assert!(matches("@duration:[1s TO 2s]", &schema, &doc)); + assert!(!matches("@duration:>2s", &schema, &doc)); + // A unit the field does not take matches nothing + assert!(!matches("@duration:>1m", &schema, &doc)); + assert!(!matches("@status:>1ms", &schema, &doc)); + } + + #[test] + fn comparisons_on_text_fields() { + let doc = Document::default().with("url", "404"); + assert!(matches("@url:>400", &proxy_like(), &doc)); + assert!(!matches("@url:>400", &proxy_like(), &Document::default().with("url", "x"))); + } + + #[test] + fn flags() { + let schema = proxy_like(); + let failed = Document::default().with("error", true); + let ok = Document::default().with("error", false); + assert!(matches("@error:*", &schema, &failed)); + assert!(!matches("@error:*", &schema, &ok)); + assert!(matches("@error:true", &schema, &failed)); + assert!(matches("@error:false", &schema, &ok)); + assert!(matches("@error:false", &schema, &Document::default())); + assert!(!matches("@error:maybe", &schema, &failed)); + } + + #[test] + fn multiple_values() { + let doc = Document::default().with("url", "a").with("url", "b"); + assert!(matches("@url:b", &proxy_like(), &doc)); + } + + /// A table gives the same answers as matching its rows one document at a time. + #[test] + fn tables_match_like_documents() { + let columns = [ + "method", + "url", + "name", + "type", + "folder", + "grpc_service", + "grpc_method", + ]; + let docs = [ + request("post", "Create user", "https://api.yaak.app/users", Some("Users")), + request("get", "List users", "https://api.yaak.app/users", Some("Admin")), + request("GET", "My Request", "https://yaak.app", None), + grpc("Say hello", "helloworld.Greeter", "SayHello"), + Document::new("Folder"), + ]; + let mut table = Table::new(columns); + for doc in &docs { + let cells = columns.iter().map(|c| match doc.fields.get(*c) { + Some(DocValues::One(DocValue::Text(v))) => Some(Cow::Borrowed(v.as_str())), + _ => None, + }); + table.push(doc.text.as_str(), cells); + } + assert_eq!(table.len(), docs.len()); + + for query in [ + "", + "user", + "mr", + "\"my req\"", + "@method:post", + "-@type:grpc", + "@folder:users OR @folder:admin", + "(users OR hello) -create", + "@grpc_method:*", + "-@folder:*", + "@url:*yaak.app*", + "@nope:x", + "-@nope:x", + "method:post", + ] { + let matcher = Matcher::new(parse(query).as_ref(), &sidebar()); + let expected: Vec = (0..docs.len() as u32) + .filter(|&i| sidebar_matches(query, &docs[i as usize])) + .collect(); + assert_eq!(matcher.matching_rows(&table), expected, "{query:?}"); + } + assert!(Matcher::new(None, &sidebar()).is_empty()); + } + + #[test] + fn table_cells_line_up_with_columns() { + let mut table = Table::new(["Method", "url"]); + table.push("a", [Some("GET".into())]); + table.push("b", [None, Some("x".into()), Some("ignored".into())]); + assert_eq!(table.len(), 2); + let rows = |q: &str| Matcher::new(parse(q).as_ref(), &sidebar()).matching_rows(&table); + assert_eq!(rows("@method:get"), vec![0]); + assert_eq!(rows("@url:x"), vec![1]); + assert_eq!(rows("-@url:*"), vec![0]); + assert_eq!(rows("b"), vec![1]); + } + + #[test] + fn tables_from_flat_cells() { + let cells = vec![ + Some("GET List".into()), + Some("GET".into()), + None, + Some("POST".into()), + ]; + let table = Table::from_cells(["method"], cells.clone()).unwrap(); + assert_eq!(table.len(), 2); + let rows = |q: &str| Matcher::new(parse(q).as_ref(), &sidebar()).matching_rows(&table); + assert_eq!(rows("@method:post"), vec![1]); + assert_eq!(rows("list"), vec![0]); + // Cells that don't fill whole rows + assert!(Table::from_cells(["method", "url"], cells).is_none()); + assert!(Table::from_cells(["method"], Vec::new()).unwrap().is_empty()); + } + + /// Packs cells the way the JavaScript side does: joined, with UTF-16 lengths. + fn pack(cells: &[Option<&str>]) -> (String, Vec) { + let joined = cells.iter().flatten().copied().collect(); + let lengths = cells + .iter() + .map(|c| c.map_or(Table::NO_VALUE, |c| c.encode_utf16().count() as u32)) + .collect(); + (joined, lengths) + } + + #[test] + fn tables_from_packed_cells() { + let rows = |table: &Table, q: &str| { + Matcher::new(parse(q).as_ref(), &sidebar()).matching_rows(table) + }; + + let (joined, lengths) = pack(&[ + Some("GET List"), + Some("GET"), + Some(""), + None, + Some("POST"), + Some("x"), + ]); + let table = Table::from_packed(["method", "url"], &joined, &lengths).unwrap(); + assert_eq!(table.len(), 2); + assert_eq!(rows(&table, "@method:post"), vec![1]); + assert_eq!(rows(&table, "@url:x"), vec![1]); + assert_eq!(rows(&table, "list"), vec![0]); + assert_eq!(rows(&table, "-@method:*"), Vec::::new()); + + // Non-ASCII text, including a character outside the BMP (two UTF-16 units) + let (joined, lengths) = pack(&[ + Some("Über 🦀 crab"), + Some("GET"), + Some("Café"), + Some("x"), + None, + Some("y"), + ]); + let table = Table::from_packed(["method", "name"], &joined, &lengths).unwrap(); + assert_eq!(rows(&table, "🦀"), vec![0]); + assert_eq!(rows(&table, "@name:café"), vec![0]); + assert_eq!(rows(&table, "@name:y"), vec![1]); + + // Lengths that don't add up, or don't fill whole rows + assert!(Table::from_packed(["method"], "abc", &[1, 1]).is_none()); + assert!(Table::from_packed(["method"], "abc", &[2, 2]).is_none()); + assert!(Table::from_packed(["method"], "abc", &[3]).is_none()); + assert!(Table::from_packed(["method"], "é", &[2, 0]).is_none()); + assert!(Table::from_packed(["method"], "", &[]).unwrap().is_empty()); + } + + #[test] + fn non_ascii_text_matches_case_insensitively() { + let doc = Document::new("ÜBER Café").with("name", "ÜBER Café"); + assert!(sidebar_matches("über", &doc)); + assert!(sidebar_matches("@name:café", &doc)); + assert!(sidebar_matches("\"über c\"", &doc)); + assert!(!sidebar_matches("@name:cafe", &doc)); + } + + #[test] + fn document_from_json() { + let doc: Document = serde_json::from_str( + r#"{ "text": "x", "fields": { "url": "a", "tags": ["b", "c"], "status": 200, "error": true } }"#, + ) + .unwrap(); + assert_eq!(doc.fields["url"], DocValues::One(DocValue::Text("a".into()))); + assert_eq!(doc.fields["status"], DocValues::One(DocValue::Number(200.0))); + assert_eq!(doc.fields["error"], DocValues::One(DocValue::Flag(true))); + assert!(matches("@status:200 @error:true", &proxy_like(), &doc)); + } + + // The cases from Yaak's former `format.test.ts`: a formatted field filter matches the value + // it was made from. Values that contain query syntax are now quoted so they stay literal. + + fn matches_formatted_url(value: &str) -> bool { + matches( + &format_field("url", value, false), + &sidebar(), + &Document::default().with("url", value), + ) + } + + #[test] + fn format_keeps_url_like_values_bare() { + assert_eq!(format_field("url", "yaak.app/foo-bar", false), "@url:yaak.app/foo-bar"); + assert!(matches_formatted_url("yaak.app/foo-bar")); + } + + #[test] + fn format_quotes_values_with_query_syntax() { + // `*` is a wildcard and `)` closes a group, so this one is quoted now + assert_eq!(format_field("url", "yaa$&#*@tsrna(*)", false), "@url:\"yaa$&#*@tsrna(*)\""); + assert!(matches_formatted_url("yaa$&#*@tsrna(*)")); + assert_eq!(format_field("url", "yaa$&#@tsrna(", false), "@url:yaa$&#@tsrna("); + assert!(matches_formatted_url("yaa$&#@tsrna(")); + } + + #[test] + fn format_keeps_values_that_start_with_an_operator_token_bare() { + assert_eq!(format_field("url", "-foo", false), "@url:-foo"); + assert!(matches_formatted_url("-foo")); + } + + #[test] + fn format_keeps_boolean_operator_words_bare() { + assert_eq!(format_field("url", "AND", false), "@url:AND"); + assert_eq!(format_field("url", "or", false), "@url:or"); + assert_eq!(format_field("url", "Not", false), "@url:Not"); + assert!(matches_formatted_url("AND")); + } + + #[test] + fn format_escapes_quoted_values() { + assert_eq!(format_field("url", "say \"hi\"", false), "@url:\"say \\\"hi\\\"\""); + assert!(matches_formatted_url("say \"hi\"")); + } + + #[test] + fn format_quotes_values_that_start_with_a_quote() { + assert_eq!(format_field("url", "\"hi\"", false), "@url:\"\\\"hi\\\"\""); + assert!(matches_formatted_url("\"hi\"")); + } + + #[test] + fn formatted_values_round_trip() { + for value in [ + "a b", + "*", + ">5", + "[1 TO 2]", + "x)", + "back\\slash", + "\\\"", + "tab\there", + "é ü", + "a*b", + "<=3ms", + "\\", + ] { + assert!(matches_formatted_url(value), "{value:?} did not round-trip"); + } + } +} diff --git a/crates/yaak-query/src/format.rs b/crates/yaak-query/src/format.rs new file mode 100644 index 000000000..a6cc2d8ba --- /dev/null +++ b/crates/yaak-query/src/format.rs @@ -0,0 +1,89 @@ +//! Writes field terms back out as query text. + +use crate::ast::FieldValue; +use crate::lexer::{classify_bare, is_ident_char}; + +/// A term matching `value` in `field`: `@field:value`, or `-@field:value` when `negated`. The +/// value stays bare when it would read back as the same literal text, and is quoted (with `"` +/// and `\` escaped) when it holds whitespace, a quote, a `)`, a `*`, or the shape of a +/// comparison or range. +pub fn format_field(field: &str, value: &str, negated: bool) -> String { + let sign = if negated { "-" } else { "" }; + format!("{sign}@{field}:{}", format_value(value)) +} + +/// A field value as it should be written after the colon. +pub fn format_value(value: &str) -> String { + if is_bare_value(value) { value.to_string() } else { quote(value) } +} + +/// `value` in double quotes, with `"` and `\` escaped. +pub fn quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + if matches!(c, '"' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +fn is_bare_value(value: &str) -> bool { + let Some(first) = value.chars().next() else { + return false; + }; + if first == '"' || first == '[' { + return false; + } + if value.chars().any(|c| c.is_whitespace() || c == ')') { + return false; + } + matches!(classify_bare(value), FieldValue::Text { .. }) +} + +/// Whether `name` can be written as a field path: identifiers joined by dots. +pub fn is_valid_path(name: &str) -> bool { + !name.is_empty() + && name.split('.').all(|part| !part.is_empty() && part.chars().all(is_ident_char)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bare_or_quoted() { + assert_eq!(format_value("post"), "post"); + assert_eq!(format_value("a b"), "\"a b\""); + assert_eq!(format_value(""), "\"\""); + assert_eq!(format_value("*"), "\"*\""); + assert_eq!(format_value("a*"), "\"a*\""); + assert_eq!(format_value(">5"), "\">5\""); + assert_eq!(format_value(">x"), ">x"); + assert_eq!(format_value("[1 TO 2]"), "\"[1 TO 2]\""); + assert_eq!(format_value("[x"), "\"[x\""); + assert_eq!(format_value("a)"), "\"a)\""); + assert_eq!(format_value("a(b"), "a(b"); + assert_eq!(format_value("a\"b"), "a\"b"); + assert_eq!(format_value("a\\b"), "a\\b"); + assert_eq!(format_value("x\\ y"), "\"x\\\\ y\""); + } + + #[test] + fn negated() { + assert_eq!(format_field("type", "grpc", true), "-@type:grpc"); + assert_eq!(format_field("name", "My Request", true), "-@name:\"My Request\""); + } + + #[test] + fn paths() { + assert!(is_valid_path("method")); + assert!(is_valid_path("req.header.x-cache")); + assert!(!is_valid_path("")); + assert!(!is_valid_path("a..b")); + assert!(!is_valid_path("a b")); + } +} diff --git a/crates/yaak-query/src/fuzzy.rs b/crates/yaak-query/src/fuzzy.rs new file mode 100644 index 000000000..153606cc8 --- /dev/null +++ b/crates/yaak-query/src/fuzzy.rs @@ -0,0 +1,206 @@ +//! Word-prefix matching, ported from fuzzbunny's `fuzzyMatchSanitized` so Yaak's sidebar keeps +//! the behaviour it had when it called fuzzbunny directly. +//! +//! A search matches when it is a substring of the target. Failing that, it matches when it can +//! be split into runs that each start a word or sit at a punctuation mark, in order: `mr` finds +//! "My Request", `usam` finds "United States of America", but `rq` does not find "request". +//! Spaces in either string never break a run. +//! +//! Only whether it matches is kept; fuzzbunny's scores and highlight ranges are not used. + +/// Whether `search` matches `target`, both already lowercased and trimmed. +pub fn fuzzy_match(target: &str, search: &str) -> bool { + fuzzy_match_in(target, search, &mut FuzzyScratch::default()) +} + +/// Buffers [`fuzzy_match_in`] reuses from one call to the next. +#[derive(Default)] +pub(crate) struct FuzzyScratch { + target: Vec, + search: Vec, + skips: Vec, +} + +/// [`fuzzy_match`], reusing `scratch` instead of allocating per call. ASCII text, the usual case, +/// is matched byte by byte without copying. +pub(crate) fn fuzzy_match_in(target: &str, search: &str, scratch: &mut FuzzyScratch) -> bool { + if target.is_empty() { + return false; + } + if search.is_empty() { + return true; + } + + // A search starting with a quote wants a substring only; the closing quote is optional + let quoted = search.starts_with('"'); + let search = if quoted { + let inner = &search[1..]; + inner.strip_suffix('"').unwrap_or(inner) + } else { + search + }; + + if target.contains(search) { + return true; + } + + // One character that is not a substring can't match fuzzily either + let mut chars = search.chars(); + if quoted || (chars.next().is_some() && chars.next().is_none()) { + return false; + } + + let skips = &mut scratch.skips; + if target.is_ascii() && search.is_ascii() { + return word_prefix_match(target.as_bytes(), search.as_bytes(), skips); + } + scratch.target.clear(); + scratch.target.extend(target.chars()); + scratch.search.clear(); + scratch.search.extend(search.chars()); + word_prefix_match(&scratch.target, &scratch.search, skips) +} + +/// A unit of text the matcher walks: a byte of ASCII text, or a char. +trait Unit: Copy + PartialEq { + /// fuzzbunny counts lowercase letters, digits, and every char code from 127 up. + fn is_alpha_num(self) -> bool; + /// ASCII punctuation: `!` to `/`, `:` to `@`, `[` to `` ` ``, `{` to `~`. + fn is_punctuation(self) -> bool; + fn is_space(self) -> bool; +} + +impl Unit for char { + fn is_alpha_num(self) -> bool { + self.is_ascii_lowercase() || self.is_ascii_digit() || self as u32 >= 127 + } + + fn is_punctuation(self) -> bool { + matches!(self, '!'..='/' | ':'..='@' | '['..='`' | '{'..='~') + } + + fn is_space(self) -> bool { + self == ' ' + } +} + +impl Unit for u8 { + fn is_alpha_num(self) -> bool { + (self as char).is_alpha_num() + } + + fn is_punctuation(self) -> bool { + (self as char).is_punctuation() + } + + fn is_space(self) -> bool { + self == b' ' + } +} + +/// The word-prefix fallback, once `search` is known not to be a plain substring. +fn word_prefix_match(target: &[T], search: &[T], skips: &mut Vec) -> bool { + target_skips(target, skips); + (0..skips.len() - 1) + .any(|skip| target[skips[skip]] == search[0] && prefix_match(skip, search, target, skips)) +} + +/// The indices where a word starts or a punctuation mark sits, then the target's length, so +/// every run lies between `skips[i]` and `skips[i + 1]`. +fn target_skips(target: &[T], skips: &mut Vec) { + skips.clear(); + let mut was_alpha_num = false; + for (i, &c) in target.iter().enumerate() { + let alpha_num = c.is_alpha_num(); + if (alpha_num && !was_alpha_num) || c.is_punctuation() { + skips.push(i); + } + was_alpha_num = alpha_num; + } + skips.push(target.len()); +} + +fn prefix_match(mut skip: usize, search: &[T], target: &[T], skips: &[usize]) -> bool { + let mut search_idx = 0; + while skip < skips.len() - 1 { + let mut target_idx = skips[skip]; + let end = skips[skip + 1]; + while target_idx < end && search_idx < search.len() { + let t = target[target_idx]; + let s = search[search_idx]; + if t == s { + target_idx += 1; + search_idx += 1; + } else if t.is_space() { + target_idx += 1; + } else if s.is_space() { + search_idx += 1; + } else { + break; + } + } + if search_idx == search.len() { + return true; + } + skip += 1; + } + false +} + +#[cfg(test)] +mod tests { + use super::fuzzy_match; + + fn m(target: &str, search: &str) -> bool { + fuzzy_match(&target.to_lowercase(), &search.trim().to_lowercase()) + } + + #[test] + fn substring_first() { + assert!(m("My Request", "req")); + assert!(m("My Request", "quest")); + assert!(m("/v1/charges", "rges")); + assert!(m("abc", "abc")); + } + + #[test] + fn word_prefixes() { + assert!(m("My Request", "mr")); + assert!(m("My Request", "myreq")); + assert!(m("United States of America", "usam")); + assert!(m("api.github.com", "apigi")); + // Each run must continue from the start of a word: "gh" is not a prefix of "github" + assert!(!m("api.github.com", "apigh")); + assert!(m("POST Create User", "postcu")); + assert!(m("get_user_by_id", "gubi")); + } + + #[test] + fn not_any_subsequence() { + assert!(!m("My Request", "rq")); + assert!(!m("request", "rqst")); + assert!(!m("My Request", "mx")); + assert!(!m("abc", "x")); + } + + #[test] + fn spaces_do_not_break_a_run() { + assert!(m("My Request", "my r")); + assert!(m("foo bar", "f b")); + } + + #[test] + fn empty_and_quoted() { + assert!(!m("", "a")); + assert!(m("abc", "")); + assert!(m("las vegas", "\"la")); + assert!(!m("los angeles", "\"la\"")); + assert!(m("los angeles", "la")); + } + + #[test] + fn unicode_is_alphanumeric() { + assert!(m("Über Café", "üc")); + assert!(m("日本 語", "日語")); + } +} diff --git a/crates/yaak-query/src/lexer.rs b/crates/yaak-query/src/lexer.rs new file mode 100644 index 000000000..b28ae24ee --- /dev/null +++ b/crates/yaak-query/src/lexer.rs @@ -0,0 +1,320 @@ +//! Splits a query into tokens. Never fails: anything it does not recognise is text. + +use crate::ast::{CompareOp, FieldValue, NumberLiteral, Span}; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum TokKind { + LParen, + RParen, + /// `-` at the start of a term. It negates only what follows it directly. + Minus, + Or, + And, + Word(String), + Phrase { + text: String, + }, + Field { + path: Vec, + path_span: Span, + value: FieldValue, + value_span: Span, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Tok { + pub kind: TokKind, + pub span: Span, +} + +pub(crate) fn lex(input: &str) -> Vec { + let mut toks = Vec::new(); + let mut i = 0; + while let Some(c) = input[i..].chars().next() { + if c.is_whitespace() { + i += c.len_utf8(); + continue; + } + let (kind, end) = match c { + '(' => (TokKind::LParen, i + 1), + ')' => (TokKind::RParen, i + 1), + '-' => (TokKind::Minus, i + 1), + '"' => { + let (text, end) = lex_quoted(input, i); + (TokKind::Phrase { text }, end) + } + '@' => match lex_field(input, i) { + Some(field) => field, + None => lex_word(input, i), + }, + _ => lex_word(input, i), + }; + toks.push(Tok { kind, span: Span::new(i, end) }); + i = end; + } + toks +} + +/// Whether `c` ends a bare free-text word. +fn ends_word(c: char) -> bool { + c.is_whitespace() || matches!(c, '(' | ')' | '"') +} + +/// Whether `c` ends a bare field value. Parentheses inside a value are kept, except a closing +/// one, which closes the group the field sits in. +fn ends_value(c: char) -> bool { + c.is_whitespace() || c == ')' +} + +fn run_until(input: &str, start: usize, stop: impl Fn(char) -> bool) -> usize { + input[start..].find(stop).map_or(input.len(), |n| start + n) +} + +fn lex_word(input: &str, start: usize) -> (TokKind, usize) { + // The first character is never a word end (the caller checked), so a word is never empty + let first = input[start..].chars().next().map_or(0, char::len_utf8); + let end = run_until(input, start + first, ends_word); + let text = &input[start..end]; + let kind = match text { + "OR" => TokKind::Or, + "AND" => TokKind::And, + _ => TokKind::Word(text.to_string()), + }; + (kind, end) +} + +/// A double-quoted string starting at `start`. `\"` and `\\` are escapes; any other backslash +/// is kept. Without a closing quote it runs to the end of the input. +pub(crate) fn lex_quoted(input: &str, start: usize) -> (String, usize) { + let mut text = String::new(); + let mut chars = input[start + 1..].char_indices(); + while let Some((n, c)) = chars.next() { + match c { + '"' => return (text, start + 1 + n + 1), + '\\' => match input[start + 1 + n + 1..].chars().next() { + Some(next @ ('"' | '\\')) => { + text.push(next); + chars.next(); + } + _ => text.push('\\'), + }, + c => text.push(c), + } + } + (text, input.len()) +} + +pub(crate) fn is_ident_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' || c == '-' +} + +/// `@path:value`, or `None` when the text at `start` is not one (no colon after the path). +fn lex_field(input: &str, start: usize) -> Option<(TokKind, usize)> { + let mut path = Vec::new(); + let mut i = start + 1; + loop { + let end = run_until(input, i, |c| !is_ident_char(c)); + if end == i { + return None; + } + path.push(input[i..end].to_string()); + i = end; + match input[i..].chars().next() { + Some('.') => i += 1, + Some(':') => break, + _ => return None, + } + } + let path_span = Span::new(start, i + 1); + let (value, value_end) = lex_value(input, i + 1); + let kind = TokKind::Field { path, path_span, value, value_span: Span::new(i + 1, value_end) }; + Some((kind, value_end)) +} + +fn lex_value(input: &str, start: usize) -> (FieldValue, usize) { + match input[start..].chars().next() { + None => return (FieldValue::Text { text: String::new(), quoted: false }, start), + Some(c) if ends_value(c) => { + return (FieldValue::Text { text: String::new(), quoted: false }, start); + } + Some('"') => { + let (text, end) = lex_quoted(input, start); + return (FieldValue::Text { text, quoted: true }, end); + } + Some('[') => { + if let Some((low, high, end)) = lex_range(input, start) { + return (FieldValue::Range { low, high }, end); + } + } + Some(_) => {} + } + let end = run_until(input, start, ends_value); + (classify_bare(&input[start..end]), end) +} + +/// What a bare (unquoted) field value means: presence, a comparison, a wildcard pattern, or +/// plain text. +pub(crate) fn classify_bare(raw: &str) -> FieldValue { + if raw == "*" { + return FieldValue::Exists; + } + if let Some((op, number)) = parse_compare(raw) { + return FieldValue::Compare { op, number }; + } + if raw.contains('*') { + return FieldValue::Wildcard { pattern: raw.to_string() }; + } + FieldValue::Text { text: raw.to_string(), quoted: false } +} + +fn parse_compare(raw: &str) -> Option<(CompareOp, NumberLiteral)> { + let (op, rest) = if let Some(rest) = raw.strip_prefix(">=") { + (CompareOp::Gte, rest) + } else if let Some(rest) = raw.strip_prefix("<=") { + (CompareOp::Lte, rest) + } else if let Some(rest) = raw.strip_prefix('>') { + (CompareOp::Gt, rest) + } else if let Some(rest) = raw.strip_prefix('<') { + (CompareOp::Lt, rest) + } else { + return None; + }; + Some((op, parse_number(rest)?)) +} + +/// A number with an optional unit: `-?digits(.digits)?letters?`, the whole of `raw`. +pub(crate) fn parse_number(raw: &str) -> Option { + let body = raw.strip_prefix('-').unwrap_or(raw); + let digits_end = body.find(|c: char| !c.is_ascii_digit()).unwrap_or(body.len()); + if digits_end == 0 { + return None; + } + let mut end = digits_end; + if let Some(fraction) = body[end..].strip_prefix('.') { + let fraction_len = fraction.find(|c: char| !c.is_ascii_digit()).unwrap_or(fraction.len()); + if fraction_len == 0 { + return None; + } + end += 1 + fraction_len; + } + let unit = &body[end..]; + if !unit.chars().all(|c| c.is_ascii_alphabetic()) { + return None; + } + let number_len = raw.len() - body.len() + end; + let value: f64 = raw[..number_len].parse().ok()?; + Some(NumberLiteral { + value, + unit: (!unit.is_empty()).then(|| unit.to_string()), + raw: raw.to_string(), + }) +} + +/// `[low TO high]` starting at `start`, or `None` when the text does not have exactly that +/// shape (it is then a literal value). +fn lex_range(input: &str, start: usize) -> Option<(NumberLiteral, NumberLiteral, usize)> { + let skip_ws = |i: usize| run_until(input, i, |c| !c.is_whitespace()); + let mut i = skip_ws(start + 1); + let low_end = run_until(input, i, |c| c.is_whitespace() || c == ']'); + let low = parse_number(&input[i..low_end])?; + i = skip_ws(low_end); + if i == low_end { + return None; + } + input[i..].strip_prefix("TO")?; + i += 2; + let after_to = skip_ws(i); + if after_to == i { + return None; + } + i = after_to; + let high_end = run_until(input, i, |c| c.is_whitespace() || c == ']'); + let high = parse_number(&input[i..high_end])?; + i = skip_ws(high_end); + input[i..].strip_prefix(']')?; + let end = i + 1; + match input[end..].chars().next() { + None => Some((low, high, end)), + Some(c) if ends_value(c) => Some((low, high, end)), + Some(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn kinds(input: &str) -> Vec { + lex(input).into_iter().map(|t| t.kind).collect() + } + + #[test] + fn numbers() { + let n = parse_number("500ms").unwrap(); + assert_eq!(n.value, 500.0); + assert_eq!(n.unit.as_deref(), Some("ms")); + assert_eq!(parse_number("-2.5").unwrap().value, -2.5); + assert_eq!(parse_number("400").unwrap().unit, None); + assert!(parse_number("").is_none()); + assert!(parse_number("ms").is_none()); + assert!(parse_number("1.").is_none()); + assert!(parse_number("1.5.2").is_none()); + assert!(parse_number("12a3").is_none()); + assert!(parse_number("-").is_none()); + } + + #[test] + fn words_and_operators() { + assert_eq!( + kinds("a OR b AND or"), + vec![ + TokKind::Word("a".into()), + TokKind::Or, + TokKind::Word("b".into()), + TokKind::And, + TokKind::Word("or".into()), + ] + ); + } + + #[test] + fn words_stop_at_parens_and_quotes() { + assert_eq!( + kinds("(a)b\"c d\""), + vec![ + TokKind::LParen, + TokKind::Word("a".into()), + TokKind::RParen, + TokKind::Word("b".into()), + TokKind::Phrase { text: "c d".into() }, + ] + ); + } + + #[test] + fn quoted_escapes() { + assert_eq!(lex_quoted(r#""a \"b\" \\ \n""#, 0), (r#"a "b" \ \n"#.to_string(), 15)); + assert_eq!(lex_quoted(r#""open"#, 0), ("open".to_string(), 5)); + assert_eq!(lex_quoted(r#""trailing\"#, 0), ("trailing\\".to_string(), 10)); + } + + #[test] + fn spans_are_bytes() { + let toks = lex("é @url:ü"); + assert_eq!(toks[0].span, Span::new(0, 2)); + assert_eq!(toks[1].span, Span::new(3, 10)); + } + + #[test] + fn range_needs_its_whole_shape() { + assert!(lex_range("[1 TO 2]", 0).is_some()); + assert!(lex_range("[ 1 TO 2 ]", 0).is_some()); + assert!(lex_range("[1 to 2]", 0).is_none()); + assert!(lex_range("[1TO 2]", 0).is_none()); + assert!(lex_range("[1 TO 2", 0).is_none()); + assert!(lex_range("[1 TO 2]x", 0).is_none()); + assert!(lex_range("[a TO b]", 0).is_none()); + assert!(lex_range("[ base_url ]", 0).is_none()); + } +} diff --git a/crates/yaak-query/src/lib.rs b/crates/yaak-query/src/lib.rs new file mode 100644 index 000000000..14cc87e66 --- /dev/null +++ b/crates/yaak-query/src/lib.rs @@ -0,0 +1,40 @@ +//! The search query language of Yaak and Yaak Proxy. +//! +//! The syntax follows Datadog's log search. Terms side by side must all match; `OR` (upper +//! case) between terms or groups means either; parentheses group; `-` directly before a term or +//! group excludes it. A bare word matches the free text, a double-quoted phrase matches it with +//! its spaces kept, and `@path:value` matches a field. A value may be `*` (the field is present), +//! a `*` wildcard pattern, a comparison like `>=400` or `>500ms`, or a range like +//! `[400 TO 499]`. Everything matches case-insensitively, and nothing in the input is an error. +//! +//! [`parse`] gives the tree, which a consumer may walk to compile it elsewhere (to SQL, say). +//! [`evaluate`] runs it in memory against a [`Document`], with a [`Schema`] saying what the +//! fields are. [`tokens`], [`completions`] and [`format_field`] serve the search box. + +// The wasm-bindgen exports expand to unsafe glue; nothing else in the crate may use unsafe +#![cfg_attr( + not(all(feature = "wasm", target_arch = "wasm32")), + forbid(unsafe_code) +)] +#![cfg_attr(all(feature = "wasm", target_arch = "wasm32"), deny(unsafe_code))] + +pub mod ast; +mod complete; +mod eval; +mod format; +pub mod fuzzy; +mod lexer; +mod parser; +mod schema; +mod tokens; +#[cfg(all(feature = "wasm", target_arch = "wasm32"))] +#[allow(unsafe_code)] +mod wasm; + +pub use ast::{CompareOp, FieldValue, Node, NumberLiteral, Span}; +pub use complete::{Completion, CompletionKind, Completions, completions}; +pub use eval::{DocValue, DocValues, Document, Matcher, Table, evaluate}; +pub use format::{format_field, format_value, is_valid_path, quote}; +pub use parser::parse; +pub use schema::{Diagnostic, FieldDef, FieldKind, FreeText, ResolvedField, Schema, Unit}; +pub use tokens::{Token, TokenKind, tokens}; diff --git a/crates/yaak-query/src/parser.rs b/crates/yaak-query/src/parser.rs new file mode 100644 index 000000000..3ef505af7 --- /dev/null +++ b/crates/yaak-query/src/parser.rs @@ -0,0 +1,393 @@ +//! Builds the [`Node`] tree from tokens. +//! +//! Precedence, tightest first: `-`, then AND (implicit or written), then `OR`. The parser is +//! forgiving and never fails: a stray `)` or a dangling `OR`, `AND` or `-` is ignored, an +//! unclosed `(` closes at the end, and an empty group is dropped. Groups and negations nest +//! at most [`MAX_DEPTH`] deep; past that, `(` and `-` are ignored, so pathological input can't +//! exhaust the stack (the WebAssembly build has a small one). + +use crate::ast::{Node, Span}; +use crate::lexer::{Tok, TokKind, lex}; + +/// How deep groups and negations may nest, together. +pub const MAX_DEPTH: usize = 32; + +/// Parses a query. `None` when it has no terms (empty, only whitespace, or only operators). +pub fn parse(input: &str) -> Option { + let toks = lex(input); + let mut parser = Parser { toks: &toks, i: 0 }; + let mut parts = Vec::new(); + // A `)` with no group to close ends parse_or early; skip it and carry on + loop { + if let Some(node) = parser.parse_or(0) { + parts.push(node); + } + if parser.peek().is_none() { + break; + } + parser.i += 1; + } + join(parts, |children, span| Node::And { children, span }) +} + +struct Parser<'a> { + toks: &'a [Tok], + i: usize, +} + +impl<'a> Parser<'a> { + fn peek(&self) -> Option<&'a Tok> { + self.toks.get(self.i) + } + + fn parse_or(&mut self, depth: usize) -> Option { + let mut parts = Vec::new(); + loop { + if let Some(node) = self.parse_and(depth) { + parts.push(node); + } + match self.peek() { + Some(Tok { kind: TokKind::Or, .. }) => self.i += 1, + _ => break, + } + } + join(parts, |children, span| Node::Or { children, span }) + } + + fn parse_and(&mut self, depth: usize) -> Option { + let mut parts = Vec::new(); + while let Some(tok) = self.peek() { + match tok.kind { + TokKind::Or | TokKind::RParen => break, + TokKind::And => self.i += 1, + _ => { + if let Some(node) = self.parse_unary(depth) { + parts.push(node); + } + } + } + } + join(parts, |children, span| Node::And { children, span }) + } + + /// Always consumes at least one token. + fn parse_unary(&mut self, depth: usize) -> Option { + let tok = self.peek()?; + if tok.kind != TokKind::Minus { + return self.parse_primary(depth); + } + self.i += 1; + // `-` negates only a term, group or another `-` written directly after it + let next = self.peek().filter(|next| { + next.span.start == tok.span.end + && !matches!(next.kind, TokKind::RParen | TokKind::Or | TokKind::And) + }); + if next.is_none() || depth >= MAX_DEPTH { + return None; + } + let node = self.parse_unary(depth + 1)?; + let span = tok.span.to(node.span()); + Some(Node::Not { node: Box::new(node), span }) + } + + /// Always consumes at least one token. + fn parse_primary(&mut self, depth: usize) -> Option { + let tok = self.peek()?; + self.i += 1; + match &tok.kind { + TokKind::Word(text) => Some(Node::Term { text: text.clone(), span: tok.span }), + TokKind::Phrase { text } => Some(Node::Phrase { text: text.clone(), span: tok.span }), + TokKind::Field { path, path_span, value, value_span } => Some(Node::Field { + path: path.clone(), + value: value.clone(), + span: tok.span, + path_span: *path_span, + value_span: *value_span, + }), + // Too deep: the group's contents carry on at this level instead + TokKind::LParen if depth >= MAX_DEPTH => None, + TokKind::LParen => { + let inner = self.parse_or(depth + 1); + if let Some(Tok { kind: TokKind::RParen, .. }) = self.peek() { + self.i += 1; + } + inner + } + // Operators are handled by the callers; reaching one here means it is out of place + TokKind::Minus | TokKind::Or | TokKind::And | TokKind::RParen => None, + } + } +} + +/// One node stays itself; several are joined by `make` with a span covering them all. +fn join(mut parts: Vec, make: impl Fn(Vec, Span) -> Node) -> Option { + match parts.len() { + 0 => None, + 1 => parts.pop(), + _ => { + let span = parts[0].span().to(parts[parts.len() - 1].span()); + Some(make(parts, span)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::{CompareOp, FieldValue}; + + /// A compact rendering of the tree, to keep the tests readable. + fn show(input: &str) -> String { + fn go(node: &Node) -> String { + match node { + Node::And { children, .. } => { + format!("(and {})", children.iter().map(go).collect::>().join(" ")) + } + Node::Or { children, .. } => { + format!("(or {})", children.iter().map(go).collect::>().join(" ")) + } + Node::Not { node, .. } => format!("(not {})", go(node)), + Node::Term { text, .. } => text.clone(), + Node::Phrase { text, .. } => format!("{text:?}"), + Node::Field { path, value, .. } => { + let value = match value { + FieldValue::Text { text, quoted: false } => text.clone(), + FieldValue::Text { text, quoted: true } => format!("{text:?}"), + FieldValue::Wildcard { pattern } => format!("wild({pattern})"), + FieldValue::Exists => "exists".to_string(), + FieldValue::Compare { op, number } => { + format!("{}{}", op.as_str(), number.raw) + } + FieldValue::Range { low, high } => format!("[{} TO {}]", low.raw, high.raw), + }; + format!("@{}={}", path.join("."), value) + } + } + } + parse(input).map_or("none".to_string(), |n| go(&n)) + } + + #[test] + fn empty() { + assert_eq!(show(""), "none"); + assert_eq!(show(" "), "none"); + assert_eq!(show("OR AND"), "none"); + assert_eq!(show("()"), "none"); + assert_eq!(show("-"), "none"); + assert_eq!(show(")"), "none"); + } + + #[test] + fn terms_and_phrases() { + assert_eq!(show("foo"), "foo"); + assert_eq!(show("foo bar"), "(and foo bar)"); + assert_eq!(show("\"hi there\""), "\"hi there\""); + assert_eq!(show("\"hi there"), "\"hi there\""); + assert_eq!(show("a\"b c\"d"), "(and a \"b c\" d)"); + assert_eq!(show(r#""say \"hi\"""#), r#""say \"hi\"""#); + } + + #[test] + fn boolean_operators() { + assert_eq!(show("a OR b"), "(or a b)"); + assert_eq!(show("a AND b"), "(and a b)"); + assert_eq!(show("a b OR c"), "(or (and a b) c)"); + assert_eq!(show("a OR b c"), "(or a (and b c))"); + assert_eq!(show("a OR b OR c"), "(or a b c)"); + // Case-sensitive, as Datadog: lowercase is a word + assert_eq!(show("a or b"), "(and a or b)"); + assert_eq!(show("a and b"), "(and a and b)"); + // NOT is not a keyword + assert_eq!(show("NOT a"), "(and NOT a)"); + } + + #[test] + fn dangling_operators_are_ignored() { + assert_eq!(show("a OR"), "a"); + assert_eq!(show("OR a"), "a"); + assert_eq!(show("a OR OR b"), "(or a b)"); + assert_eq!(show("a AND"), "a"); + assert_eq!(show("a -"), "a"); + assert_eq!(show("- a"), "a"); + assert_eq!(show("(a -)"), "a"); + } + + #[test] + fn groups() { + assert_eq!(show("(a OR b) -c"), "(and (or a b) (not c))"); + assert_eq!(show("-(a OR b)"), "(not (or a b))"); + assert_eq!(show("(a OR b"), "(or a b)"); + assert_eq!(show("a) b"), "(and a b)"); + assert_eq!(show("((a))"), "a"); + assert_eq!(show("x (a (b OR c))"), "(and x (and a (or b c)))"); + } + + #[test] + fn negation() { + assert_eq!(show("-foo"), "(not foo)"); + assert_eq!(show("--foo"), "(not (not foo))"); + assert_eq!(show("-\"a b\""), "(not \"a b\")"); + assert_eq!(show("-@type:grpc"), "(not @type=grpc)"); + assert_eq!(show("a-b"), "a-b"); + assert_eq!(show("-OR a"), "a"); + } + + #[test] + fn fields() { + assert_eq!(show("@method:post"), "@method=post"); + assert_eq!(show("@url:\"a b\""), "@url=\"a b\""); + assert_eq!(show("@header.x-cache:HIT"), "@header.x-cache=HIT"); + assert_eq!(show("@req.header.content-type:json"), "@req.header.content-type=json"); + assert_eq!(show("@folder:x OR @folder:y"), "(or @folder=x @folder=y)"); + assert_eq!(show("@url:https://a.b/c?d=e"), "@url=https://a.b/c?d=e"); + } + + #[test] + fn at_is_required() { + assert_eq!(show("method:post"), "method:post"); + assert_eq!(show("localhost:3000"), "localhost:3000"); + assert_eq!(show("@method"), "@method"); + assert_eq!(show("@"), "@"); + assert_eq!(show("@.a:b"), "@.a:b"); + assert_eq!(show("@a.:b"), "@a.:b"); + assert_eq!(show("@a b:c"), "(and @a b:c)"); + } + + #[test] + fn trailing_field_has_empty_value() { + assert_eq!(show("@method:"), "@method="); + assert_eq!(show("@method: post"), "(and @method= post)"); + assert_eq!(show("(@method:)"), "@method="); + } + + #[test] + fn field_values() { + assert_eq!(show("@url:-foo"), "@url=-foo"); + assert_eq!(show("@url:AND"), "@url=AND"); + assert_eq!(show("@url:a(b"), "@url=a(b"); + assert_eq!(show("(@url:a OR @url:b)"), "(or @url=a @url=b)"); + assert_eq!(show("@url:a\"b"), "@url=a\"b"); + assert_eq!(show("@url:\"a"), "@url=\"a\""); + } + + #[test] + fn special_values() { + assert_eq!(show("@status:*"), "@status=exists"); + assert_eq!(show("-@status:*"), "(not @status=exists)"); + assert_eq!(show("@url:*.github.com"), "@url=wild(*.github.com)"); + assert_eq!(show("@url:\"*.github.com\""), "@url=\"*.github.com\""); + assert_eq!(show("@status:>=400"), "@status=>=400"); + assert_eq!(show("@duration:>500ms"), "@duration=>500ms"); + assert_eq!(show("@status:<1"), "@status=<1"); + assert_eq!(show("@status:[400 TO 499]"), "@status=[400 TO 499]"); + assert_eq!(show("(@status:[400 TO 499])"), "@status=[400 TO 499]"); + // Not quite the shape: literal text or a wildcard + assert_eq!(show("@status:>="), "@status=>="); + assert_eq!(show("@status:>abc"), "@status=>abc"); + assert_eq!(show("@status:**"), "@status=wild(**)"); + assert_eq!(show("@status:[400"), "@status=[400"); + } + + #[test] + fn templates_parse_sanely() { + assert_eq!(show("@url:${[ base_url ]}"), "(and @url=${[ base_url ]})"); + assert_eq!(show("@url:[ base_url ]"), "(and @url=[ base_url ])"); + assert_eq!(show("@url:\"${[ base_url ]}/x\""), "@url=\"${[ base_url ]}/x\""); + } + + #[test] + fn compare_value_details() { + let Some(Node::Field { value: FieldValue::Compare { op, number }, .. }) = + parse("@duration:<=1.5s") + else { + panic!("not a comparison"); + }; + assert_eq!(op, CompareOp::Lte); + assert_eq!(number.value, 1.5); + assert_eq!(number.unit.as_deref(), Some("s")); + } + + #[test] + fn spans() { + let input = "-@url:\"a b\" OR (c"; + let node = parse(input).unwrap(); + let Node::Or { children, span } = &node else { + panic!() + }; + assert_eq!(span.slice(input), input); + let Node::Not { node: field, span } = &children[0] else { + panic!() + }; + assert_eq!(span.slice(input), "-@url:\"a b\""); + let Node::Field { span, path_span, value_span, .. } = field.as_ref() else { + panic!() + }; + assert_eq!(span.slice(input), "@url:\"a b\""); + assert_eq!(path_span.slice(input), "@url:"); + assert_eq!(value_span.slice(input), "\"a b\""); + assert_eq!(children[1].span().slice(input), "c"); + + let input = "@method:"; + let Some(Node::Field { value_span, .. }) = parse(input) else { + panic!() + }; + assert_eq!(value_span, Span::new(8, 8)); + } + + #[test] + fn nesting_is_bounded() { + fn depth(node: &Node) -> usize { + match node { + Node::And { children, .. } | Node::Or { children, .. } => { + children.iter().map(depth).max().unwrap_or(0) + 1 + } + Node::Not { node, .. } => depth(node) + 1, + _ => 1, + } + } + let parens = format!("{}a OR b{}", "(".repeat(5000), ")".repeat(5000)); + let negations = format!("{}a", "-".repeat(5000)); + let mixed = format!("{}a{}", "-(".repeat(5000), ")".repeat(5000)); + for input in [parens, negations, mixed] { + let node = parse(&input).unwrap(); + assert!(depth(&node) <= MAX_DEPTH + 2, "too deep for {:?}", &input[..10]); + } + // Within the limit, nesting is kept exactly + let input = format!("{}a{}", "(".repeat(MAX_DEPTH), ")".repeat(MAX_DEPTH)); + assert_eq!(show(&input), "a"); + assert_eq!(show(&format!("{}a", "-".repeat(2))), "(not (not a))"); + } + + #[test] + fn never_panics_on_odd_input() { + for input in [ + "\"", + "\\", + "@", + "@:", + "@a:", + "-", + "--", + "(", + ")", + "((", + "))", + "[", + "@a:[", + "@a:[1 TO", + "@a:\"", + "@a:\"\\", + "é@ü:ö", + "@a.b.c:", + "@a:>", + "@a:<=", + "-(", + "OR", + "AND OR", + "@a:)", + "🦀 @x:🦀 \"🦀", + ] { + let _ = parse(input); + } + } +} diff --git a/crates/yaak-query/src/schema.rs b/crates/yaak-query/src/schema.rs new file mode 100644 index 000000000..17a1c02fb --- /dev/null +++ b/crates/yaak-query/src/schema.rs @@ -0,0 +1,307 @@ +//! What a consumer's fields are, and how its bare words match. + +use crate::ast::{FieldValue, Node, NumberLiteral, Span}; +use crate::lexer::parse_number; +use serde::{Deserialize, Serialize}; + +/// A consumer's searchable fields. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Schema { + pub fields: Vec, + /// Substring when left out. + #[serde(default)] + #[cfg_attr(test, ts(optional, as = "Option"))] + pub free_text: FreeText, +} + +/// How a bare word matches the document's free text. A quoted phrase is always a substring. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum FreeText { + /// A substring, falling back to word-prefix matching (`mr` finds "My Request"), as + /// fuzzbunny does. + Fuzzy, + /// A case-insensitive substring. + #[default] + Substring, +} + +/// One field: `@name:value`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct FieldDef { + /// The field's path, dotted when nested: `method`, `req.header`. + pub name: String, + pub kind: FieldKind, + /// A map of named values, such as headers: `@header.:value` is valid for any key, and + /// the key is matched against the document's keys (case-insensitively). + #[serde(default)] + #[cfg_attr(test, ts(optional, as = "Option"))] + pub map: bool, + /// A short note shown with the field in completions. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub info: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum FieldKind { + Text, + /// A number, with the units a comparison or range may be written in. A number written + /// without a unit is in the field's base unit. + Number { + #[serde(default)] + units: Vec, + }, + /// True or false: `@name:true`, `@name:false`, and `@name:*` for true. + Flag, +} + +/// A unit a number may be written in, and how many of the field's base unit it is worth: +/// `{ name: "s", factor: 1000 }` for a field counted in milliseconds. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Unit { + pub name: String, + pub factor: f64, +} + +/// A field path resolved against the schema. +#[derive(Clone, Debug, PartialEq)] +pub struct ResolvedField<'a> { + pub def: &'a FieldDef, + /// The key under a map field: `x-cache` for `@header.x-cache`. `None` for the map field + /// itself (`@header:...`, which matches any key) and for plain fields. + pub key: Option, +} + +impl ResolvedField<'_> { + /// The lowercase dotted path a document stores this field's values under. + pub fn document_path(&self) -> String { + match &self.key { + Some(key) => format!("{}.{}", self.def.name.to_lowercase(), key.to_lowercase()), + None => self.def.name.to_lowercase(), + } + } +} + +/// Something in a query the schema does not accept. The query still parses and runs; the +/// offending term matches nothing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum Diagnostic { + /// No field has this path. `span` covers `@path:`. + UnknownField { path: String, span: Span }, + /// The value cannot apply to this field: a unit the field does not take, a number for a + /// text-only flag, and so on. `span` covers the value. + InvalidValue { path: String, span: Span }, +} + +impl Schema { + pub fn new(fields: Vec, free_text: FreeText) -> Self { + Self { fields, free_text } + } + + /// Finds the field a path names, case-insensitively. A path under a map field resolves to + /// that field with the rest of the path as the key; the longest matching map wins. + pub fn resolve(&self, path: &[String]) -> Option> { + let joined = path.join(".").to_lowercase(); + if let Some(def) = self.fields.iter().find(|f| f.name.to_lowercase() == joined) { + return Some(ResolvedField { def, key: None }); + } + self.fields + .iter() + .filter(|f| f.map) + .filter_map(|f| { + let name = f.name.to_lowercase(); + let key = joined.strip_prefix(&name)?.strip_prefix('.')?; + (!key.is_empty()).then_some((name.len(), f, key)) + }) + .max_by_key(|(len, ..)| *len) + .map(|(_, def, key)| ResolvedField { def, key: Some(key.to_string()) }) + } + + /// The terms in a query this schema cannot run, for the UI to mark. + pub fn diagnostics(&self, root: &Node) -> Vec { + let mut out = Vec::new(); + root.walk(&mut |node| { + let Node::Field { path, value, path_span, value_span, .. } = node else { + return; + }; + let joined = path.join("."); + match self.resolve(path) { + None => out.push(Diagnostic::UnknownField { path: joined, span: *path_span }), + Some(field) => { + if !value_fits(&field.def.kind, value) { + out.push(Diagnostic::InvalidValue { path: joined, span: *value_span }); + } + } + } + }); + out + } +} + +/// Whether a value can mean anything for a field of this kind. +fn value_fits(kind: &FieldKind, value: &FieldValue) -> bool { + match (kind, value) { + (FieldKind::Number { units }, FieldValue::Compare { number, .. }) => { + to_base(number, units).is_some() + } + (FieldKind::Number { units }, FieldValue::Range { low, high }) => { + to_base(low, units).is_some() && to_base(high, units).is_some() + } + // A bare number is equality, so its unit has to be one the field takes + (FieldKind::Number { units }, FieldValue::Text { text, quoted: false }) => { + parse_number(text).is_none_or(|number| to_base(&number, units).is_some()) + } + // Text holding numbers compares them as plain numbers, without units + (FieldKind::Text, FieldValue::Compare { number, .. }) => number.unit.is_none(), + (FieldKind::Text, FieldValue::Range { low, high }) => { + low.unit.is_none() && high.unit.is_none() + } + (FieldKind::Flag, FieldValue::Text { text, .. }) => { + text.is_empty() || parse_flag(text).is_some() + } + (FieldKind::Flag, FieldValue::Exists) => true, + (FieldKind::Flag, _) => false, + _ => true, + } +} + +/// A number in the field's base unit, or `None` when its unit is not one the field takes. +pub(crate) fn to_base(number: &NumberLiteral, units: &[Unit]) -> Option { + match &number.unit { + None => Some(number.value), + Some(unit) => units + .iter() + .find(|u| u.name.eq_ignore_ascii_case(unit)) + .map(|u| number.value * u.factor), + } +} + +pub(crate) fn parse_flag(text: &str) -> Option { + match text.to_lowercase().as_str() { + "true" | "yes" | "1" => Some(true), + "false" | "no" | "0" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn field(name: &str, kind: FieldKind, map: bool) -> FieldDef { + FieldDef { name: name.to_string(), kind, map, info: None } + } + + fn schema() -> Schema { + Schema::new( + vec![ + field("url", FieldKind::Text, false), + field("header", FieldKind::Text, true), + field("req.header", FieldKind::Text, true), + field( + "duration", + FieldKind::Number { units: vec![Unit { name: "s".into(), factor: 1000.0 }] }, + false, + ), + field("error", FieldKind::Flag, false), + ], + FreeText::Substring, + ) + } + + fn path(p: &str) -> Vec { + p.split('.').map(String::from).collect() + } + + #[test] + fn resolves_plain_and_map_fields() { + let s = schema(); + assert_eq!(s.resolve(&path("URL")).unwrap().def.name, "url"); + assert_eq!(s.resolve(&path("url")).unwrap().key, None); + + let h = s.resolve(&path("header.X-Cache")).unwrap(); + assert_eq!(h.def.name, "header"); + assert_eq!(h.key.as_deref(), Some("x-cache")); + assert_eq!(h.document_path(), "header.x-cache"); + + let r = s.resolve(&path("req.header.accept")).unwrap(); + assert_eq!(r.def.name, "req.header"); + assert_eq!(r.key.as_deref(), Some("accept")); + + // The map itself, with no key + assert_eq!(s.resolve(&path("header")).unwrap().key, None); + + assert!(s.resolve(&path("nope")).is_none()); + assert!(s.resolve(&path("url.x")).is_none()); + assert!(s.resolve(&path("req")).is_none()); + } + + #[test] + fn reports_unknown_fields_and_bad_values() { + let s = schema(); + let input = "@nope:x @url:y @duration:>5m @duration:>5s @error:maybe @error:true"; + let diagnostics = s.diagnostics(&parse(input).unwrap()); + assert_eq!( + diagnostics, + vec![ + Diagnostic::UnknownField { path: "nope".into(), span: Span::new(0, 6) }, + Diagnostic::InvalidValue { path: "duration".into(), span: Span::new(25, 28) }, + Diagnostic::InvalidValue { path: "error".into(), span: Span::new(50, 55) }, + ] + ); + } + + /// Diagnostics flag exactly the unit mistakes the evaluator rejects. + #[test] + fn reports_units_the_evaluator_rejects() { + let s = schema(); + let invalid = |input: &str| !s.diagnostics(&parse(input).unwrap()).is_empty(); + assert!(invalid("@duration:1m")); + assert!(invalid("@duration:[1m TO 2s]")); + assert!(invalid("@url:>1s")); + assert!(invalid("@url:[1 TO 2s]")); + assert!(!invalid("@duration:1s")); + assert!(!invalid("@duration:1500")); + assert!(!invalid("@duration:abc")); + assert!(!invalid("@duration:\"1m\"")); + assert!(!invalid("@url:>1")); + assert!(!invalid("@url:1m")); + } + + #[test] + fn deserializes_from_json() { + let s: Schema = serde_json::from_str( + r#"{ + "fields": [ + { "name": "method", "kind": { "type": "text" } }, + { "name": "header", "kind": { "type": "text" }, "map": true }, + { "name": "size", "kind": { "type": "number", "units": [{ "name": "kb", "factor": 1024 }] } }, + { "name": "error", "kind": { "type": "flag" }, "info": "Failed" } + ], + "freeText": "fuzzy" + }"#, + ) + .unwrap(); + assert_eq!(s.free_text, FreeText::Fuzzy); + assert!(s.fields[1].map); + assert_eq!(s.fields[3].info.as_deref(), Some("Failed")); + } +} diff --git a/crates/yaak-query/src/tokens.rs b/crates/yaak-query/src/tokens.rs new file mode 100644 index 000000000..11ff84243 --- /dev/null +++ b/crates/yaak-query/src/tokens.rs @@ -0,0 +1,100 @@ +//! Spans for syntax highlighting. + +use crate::ast::Span; +use crate::lexer::{TokKind, lex}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub enum TokenKind { + /// `@path:`, through the colon. + Field, + /// A field's value, quoted or not. + Value, + /// `OR` and `AND`. + Operator, + /// A quoted phrase in free text. + Quote, + Paren, + /// A `-` that negates what follows it. + Negation, + /// A bare free-text word. + Text, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "query.ts"))] +pub struct Token { + pub kind: TokenKind, + pub span: Span, +} + +/// The highlighted pieces of `input`, in order. Whitespace is not covered. +pub fn tokens(input: &str) -> Vec { + let mut out = Vec::new(); + let toks = lex(input); + for (i, tok) in toks.iter().enumerate() { + let kind = match &tok.kind { + TokKind::LParen | TokKind::RParen => TokenKind::Paren, + TokKind::Minus => { + // The parser ignores a `-` with nothing to negate directly after it + let negates = toks.get(i + 1).is_some_and(|next| { + next.span.start == tok.span.end + && !matches!(next.kind, TokKind::RParen | TokKind::Or | TokKind::And) + }); + if negates { TokenKind::Negation } else { TokenKind::Text } + } + TokKind::Or | TokKind::And => TokenKind::Operator, + TokKind::Word(_) => TokenKind::Text, + TokKind::Phrase { .. } => TokenKind::Quote, + TokKind::Field { path_span, value_span, .. } => { + out.push(Token { kind: TokenKind::Field, span: *path_span }); + if value_span.start != value_span.end { + out.push(Token { kind: TokenKind::Value, span: *value_span }); + } + continue; + } + }; + out.push(Token { kind, span: tok.span }); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn show(input: &str) -> Vec<(TokenKind, &str)> { + tokens(input).into_iter().map(|t| (t.kind, t.span.slice(input))).collect() + } + + #[test] + fn kinds_and_spans() { + use TokenKind::*; + assert_eq!( + show("-@method:post OR (\"a b\" c) @url:"), + vec![ + (Negation, "-"), + (Field, "@method:"), + (Value, "post"), + (Operator, "OR"), + (Paren, "("), + (Quote, "\"a b\""), + (Text, "c"), + (Paren, ")"), + (Field, "@url:"), + ] + ); + assert_eq!( + show("@status:[400 TO 499]"), + vec![(Field, "@status:"), (Value, "[400 TO 499]")] + ); + assert_eq!(show("localhost:3000"), vec![(Text, "localhost:3000")]); + assert_eq!(show(""), vec![]); + // A `-` with nothing directly after it negates nothing + assert_eq!(show("- a -"), vec![(Text, "-"), (Text, "a"), (Text, "-")]); + } +} diff --git a/crates/yaak-query/src/wasm.rs b/crates/yaak-query/src/wasm.rs new file mode 100644 index 000000000..fbf0089b3 --- /dev/null +++ b/crates/yaak-query/src/wasm.rs @@ -0,0 +1,161 @@ +//! The `@yaakapp-internal/query` exports. +//! +//! JavaScript counts string positions in UTF-16 code units, so every span going out is +//! converted from bytes, and the cursor coming in is converted to bytes. + +use crate::{Document, Matcher, Schema, Span, Table}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use wasm_bindgen::JsValue; +use wasm_bindgen::prelude::wasm_bindgen; + +/// Byte offset to UTF-16 offset, and back, for one input. +struct Offsets { + /// `utf16[i]` is the UTF-16 offset of byte `i`, for every char boundary and the end. + utf16: Vec, +} + +impl Offsets { + fn new(input: &str) -> Self { + let mut utf16 = vec![0; input.len() + 1]; + let mut units = 0u32; + for (i, c) in input.char_indices() { + for b in 0..c.len_utf8() { + utf16[i + b] = units; + } + units += c.len_utf16() as u32; + } + utf16[input.len()] = units; + Self { utf16 } + } + + fn span(&self, span: Span) -> Span { + Span { start: self.utf16[span.start()], end: self.utf16[span.end()] } + } + + /// The byte offset of UTF-16 offset `pos`, or of the char it falls inside. + fn byte(&self, pos: u32) -> usize { + self.utf16.partition_point(|&u| u < pos).min(self.utf16.len() - 1) + } +} + +fn to_js(value: &T) -> Result { + value + .serialize(&serde_wasm_bindgen::Serializer::json_compatible()) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +fn from_js(value: JsValue) -> Result { + serde_wasm_bindgen::from_value(value).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// The parsed tree (`Node | null`), with UTF-16 spans. +#[wasm_bindgen] +pub fn parse_query(input: &str) -> Result { + let offsets = Offsets::new(input); + let mut root = crate::parse(input); + if let Some(root) = &mut root { + root.map_spans(&|s| offsets.span(s)); + } + to_js(&root) +} + +/// Highlighting spans (`Token[]`), in UTF-16 offsets. +#[wasm_bindgen] +pub fn query_tokens(input: &str) -> Result { + let offsets = Offsets::new(input); + let mut tokens = crate::tokens(input); + for token in &mut tokens { + token.span = offsets.span(token.span); + } + to_js(&tokens) +} + +/// `Completions | null` at UTF-16 offset `cursor`. `values` maps field paths to the values seen +/// for them (`Record`). +#[wasm_bindgen] +pub fn query_completions( + input: &str, + cursor: u32, + schema: JsValue, + values: JsValue, +) -> Result { + let schema: Schema = from_js(schema)?; + let values: HashMap> = + if values.is_undefined() || values.is_null() { HashMap::new() } else { from_js(values)? }; + let offsets = Offsets::new(input); + let mut result = crate::completions(input, offsets.byte(cursor), &schema, &values); + if let Some(result) = &mut result { + result.span = offsets.span(result.span); + } + to_js(&result) +} + +/// The terms the schema cannot run (`Diagnostic[]`), with UTF-16 spans. +#[wasm_bindgen] +pub fn query_diagnostics(input: &str, schema: JsValue) -> Result { + let schema: Schema = from_js(schema)?; + let offsets = Offsets::new(input); + let mut diagnostics = + crate::parse(input).map(|root| schema.diagnostics(&root)).unwrap_or_default(); + for diagnostic in &mut diagnostics { + match diagnostic { + crate::Diagnostic::UnknownField { span, .. } + | crate::Diagnostic::InvalidValue { span, .. } => *span = offsets.span(*span), + } + } + to_js(&diagnostics) +} + +#[wasm_bindgen] +pub fn query_format_field(field: &str, value: &str, negated: bool) -> String { + crate::format_field(field, value, negated) +} + +/// A query parsed and resolved once, then matched against many documents. +#[wasm_bindgen] +pub struct QueryMatcher { + matcher: Matcher, +} + +#[wasm_bindgen] +impl QueryMatcher { + #[wasm_bindgen(constructor)] + pub fn new(input: &str, schema: JsValue) -> Result { + let schema: Schema = from_js(schema)?; + Ok(QueryMatcher { matcher: Matcher::new(crate::parse(input).as_ref(), &schema) }) + } + + /// Whether the query has no terms, so it matches everything. + #[wasm_bindgen(getter, js_name = isEmpty)] + pub fn is_empty(&self) -> bool { + self.matcher.is_empty() + } + + /// Whether a `Document` (`{ text, fields }`), as JSON, matches. JSON crosses into wasm far + /// faster than walking a JS object property by property. + pub fn matches(&self, doc_json: &str) -> Result { + let doc: Document = serde_json::from_str(doc_json).map_err(js_error)?; + Ok(self.matcher.matches(&doc)) + } + + /// The indexes of the matching rows, for many documents in one call. The cells (per row, + /// its free text, then one per column) are packed as in [`Table::from_packed`]: joined into + /// one string, with each one's UTF-16 length, or `Table::NO_VALUE` for no value. + #[wasm_bindgen(js_name = matchingRows)] + pub fn matching_rows( + &self, + columns: Vec, + joined: &str, + lengths: &[u32], + ) -> Result, JsValue> { + let table = Table::from_packed(columns, joined, lengths) + .ok_or_else(|| JsValue::from_str("cells do not line up with the columns"))?; + Ok(self.matcher.matching_rows(&table)) + } +} + +fn js_error(e: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&e.to_string()) +} diff --git a/package-lock.json b/package-lock.json index a4539a7a3..9a34d7914 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "crates/yaak-git", "crates/yaak-models", "crates/yaak-plugins", + "crates/yaak-query", "crates/yaak-sse", "crates/yaak-sync", "crates/yaak-templates", @@ -322,6 +323,13 @@ "name": "@yaakapp-internal/plugins", "version": "1.0.0" }, + "crates/yaak-query": { + "name": "@yaakapp-internal/query", + "version": "1.0.0", + "devDependencies": { + "rimraf": "^6.1.2" + } + }, "crates/yaak-sse": { "name": "@yaakapp-internal/sse", "version": "1.0.0", @@ -5241,6 +5249,10 @@ "resolved": "crates-proxy/yaak-proxy-lib", "link": true }, + "node_modules/@yaakapp-internal/query": { + "resolved": "crates/yaak-query", + "link": true + }, "node_modules/@yaakapp-internal/rpc-schema": { "resolved": "crates/common/yaak-rpc-schema", "link": true diff --git a/package.json b/package.json index 9a83a9673..9361af88a 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "crates/yaak-git", "crates/yaak-models", "crates/yaak-plugins", + "crates/yaak-query", "crates/yaak-sse", "crates/yaak-sync", "crates/yaak-templates", diff --git a/vite.config.ts b/vite.config.ts index d009150e4..88ca8c9af 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ ignorePatterns: [ "**/bindings/**", "**/routeTree.gen.ts", + "crates/yaak-query/pkg/**", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", "plugins/importer-bruno/tests/fixtures/upstream/**", @@ -18,6 +19,7 @@ export default defineConfig({ lint: { ignorePatterns: [ "npm/**", + "crates/yaak-query/pkg/**", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", "**/bindings/gen_*.ts",