mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Move Yaak's sidebar filter onto a shared Rust query engine (#825)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2ca2bc5f00
commit
7453869ac6
32 files changed
+4430
-396
No files matched your search
Generated
+11
@@ -11969,6 +11969,17 @@ dependencies = [
|
|||||||
"yaak-templates",
|
"yaak-templates",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "yaak-query"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde-wasm-bindgen",
|
||||||
|
"serde_json",
|
||||||
|
"ts-rs",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yaak-rpc"
|
name = "yaak-rpc"
|
||||||
version = "0.0.0"
|
version = "0.0.0"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ members = [
|
|||||||
"crates/yaak-mcp",
|
"crates/yaak-mcp",
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
|
"crates/yaak-query",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
"crates/yaak-sync",
|
"crates/yaak-sync",
|
||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
@@ -96,6 +97,7 @@ yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
|||||||
yaak-mcp = { path = "crates/yaak-mcp" }
|
yaak-mcp = { path = "crates/yaak-mcp" }
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
yaak-models = { path = "crates/yaak-models" }
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||||
|
yaak-query = { path = "crates/yaak-query" }
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
yaak-sse = { path = "crates/yaak-sse" }
|
||||||
yaak-sync = { path = "crates/yaak-sync" }
|
yaak-sync = { path = "crates/yaak-sync" }
|
||||||
yaak-templates = { path = "crates/yaak-templates" }
|
yaak-templates = { path = "crates/yaak-templates" }
|
||||||
|
|||||||
@@ -63,10 +63,9 @@ import { isSidebarFocused } from "../lib/scopes";
|
|||||||
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
|
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
|
||||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||||
import { type FieldDef, filter } from "@yaakapp-internal/ui/editor";
|
import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
|
||||||
import type { Ast } from "./core/Editor/filter/query";
|
import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
|
||||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
import { queryFilter } from "./core/Editor/filter/extension";
|
||||||
import { formatFieldFilter } from "./core/Editor/filter/format";
|
|
||||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||||
import {
|
import {
|
||||||
Icon,
|
Icon,
|
||||||
@@ -111,7 +110,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||||
const filterText = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
@@ -552,18 +551,18 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
||||||
if (filterCompartmentMountExtRef.current == null) {
|
if (filterCompartmentMountExtRef.current == null) {
|
||||||
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
||||||
filter({ fields: allFields ?? [] }),
|
queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const view = filterRef.current;
|
const view = filterRef.current;
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
const ext = filter({ fields: allFields ?? [] });
|
const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
||||||
});
|
});
|
||||||
}, [allFields]);
|
}, [fieldValues]);
|
||||||
|
|
||||||
if (tree == null || hidden) {
|
if (tree == null || hidden) {
|
||||||
return null;
|
return null;
|
||||||
@@ -851,16 +850,35 @@ function setSidebarFilterText(text: string) {
|
|||||||
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
|
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 == null) return null;
|
||||||
|
|
||||||
if (ast.type === "Term" || ast.type === "Phrase") {
|
if (ast.type === "Term" || ast.type === "Phrase") {
|
||||||
const value = ast.value.trim();
|
const value = ast.text.trim();
|
||||||
return value.length > 0 ? value : null;
|
return value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ast.type === "Field") {
|
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;
|
return value.length > 0 ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,7 +900,7 @@ const sidebarSuggestionFieldOrder = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<
|
const sidebarTreeAtom = atom<
|
||||||
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
|
[TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
|
||||||
>((get) => {
|
>((get) => {
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
const allModels = get(memoAllPotentialChildrenAtom);
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
const activeWorkspace = get(activeWorkspaceAtom);
|
||||||
@@ -903,15 +921,18 @@ const sidebarTreeAtom = atom<
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryAst = parseQuery(filter.text);
|
const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text);
|
||||||
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
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<SidebarModel>[] = [];
|
||||||
|
const cells: (string | null)[] = [];
|
||||||
|
|
||||||
// returns true if this node OR any child matches the filter
|
|
||||||
const allFields: Record<string, Set<string>> = {};
|
const allFields: Record<string, Set<string>> = {};
|
||||||
const suggestionFields = new Set<string>();
|
const suggestionFields = new Set<string>();
|
||||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
const build = (node: TreeNode<SidebarModel>, depth: number) => {
|
||||||
const childItems = childrenMap[node.item.id] ?? [];
|
const childItems = childrenMap[node.item.id] ?? [];
|
||||||
let matchesSelf = true;
|
|
||||||
const fields = getItemFields(node);
|
const fields = getItemFields(node);
|
||||||
const model = node.item.model;
|
const model = node.item.model;
|
||||||
const isLeafNode = !(model === "folder" || model === "workspace");
|
const isLeafNode = !(model === "folder" || model === "workspace");
|
||||||
@@ -929,12 +950,12 @@ const sidebarTreeAtom = atom<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryAst != null) {
|
if (isLeafNode && !matcher.isEmpty) {
|
||||||
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
|
leaves.push(node);
|
||||||
|
cells.push(getItemText(node.item));
|
||||||
|
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
let matchesChild = false;
|
|
||||||
|
|
||||||
// Recurse to children
|
// Recurse to children
|
||||||
node.children = !isLeafNode ? [] : undefined;
|
node.children = !isLeafNode ? [] : undefined;
|
||||||
|
|
||||||
@@ -952,19 +973,21 @@ const sidebarTreeAtom = atom<
|
|||||||
|
|
||||||
for (const item of childItems) {
|
for (const item of childItems) {
|
||||||
const childNode = { item, parent: node, depth };
|
const childNode = { item, parent: node, depth };
|
||||||
const childMatches = build(childNode, depth + 1);
|
build(childNode, depth + 1);
|
||||||
if (childMatches) {
|
|
||||||
matchesChild = true;
|
|
||||||
}
|
|
||||||
node.children.push(childNode);
|
node.children.push(childNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// hide node IFF nothing in its subtree matches
|
// Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden
|
||||||
const anyMatch = matchesSelf || matchesChild;
|
let matched: Set<TreeNode<SidebarModel>> | null = null;
|
||||||
node.hidden = !anyMatch;
|
const markHidden = (node: TreeNode<SidebarModel>): boolean => {
|
||||||
|
let visible = matched == null || matched.has(node);
|
||||||
return anyMatch;
|
for (const child of node.children ?? []) {
|
||||||
|
if (markHidden(child)) visible = true;
|
||||||
|
}
|
||||||
|
node.hidden = !visible;
|
||||||
|
return visible;
|
||||||
};
|
};
|
||||||
|
|
||||||
const root: TreeNode<SidebarModel> = {
|
const root: TreeNode<SidebarModel> = {
|
||||||
@@ -974,15 +997,17 @@ const sidebarTreeAtom = atom<
|
|||||||
depth: 0,
|
depth: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build tree and mark visibility in one pass
|
|
||||||
build(root, 1);
|
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<string, string[]> = {};
|
||||||
for (const [name, values] of Object.entries(allFields)) {
|
for (const [name, values] of Object.entries(allFields)) {
|
||||||
fields.push({
|
fieldValues[name] = Array.from(values).filter((v) => v.length < 20);
|
||||||
name,
|
|
||||||
values: Array.from(values).filter((v) => v.length < 20),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const suggestions = Array.from(suggestionFields)
|
const suggestions = Array.from(suggestionFields)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
@@ -993,9 +1018,9 @@ const sidebarTreeAtom = atom<
|
|||||||
})
|
})
|
||||||
.map((field) => ({
|
.map((field) => ({
|
||||||
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<Record<string, GitStatus>>((get) => {
|
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||||
|
|||||||
@@ -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<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenTags: Record<TokenKind, Tag | null> = {
|
||||||
|
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<Decoration>();
|
||||||
|
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)] })];
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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<Tok, { kind: "PHRASE" }>;
|
|
||||||
return { type: "Phrase", value: t.text };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Field or bare word
|
|
||||||
if (this.at("WORD")) {
|
|
||||||
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
|
|
||||||
|
|
||||||
if (this.at("COLON")) {
|
|
||||||
// field:value or field:"phrase"
|
|
||||||
this.advance(); // :
|
|
||||||
let value: string;
|
|
||||||
if (this.at("PHRASE")) {
|
|
||||||
const p = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
|
|
||||||
value = p.text;
|
|
||||||
} else if (this.at("WORD")) {
|
|
||||||
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
|
|
||||||
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<Tok, { kind: "WORD" }>;
|
|
||||||
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<string, unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
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<string, string[]> = {};
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@@ -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 }
|
||||||
Generated
+128
@@ -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<Completion>, };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<DocValue>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.<key>: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<Unit>, } | { "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<Node>, span: Span, } | { "type": "Or", children: Array<Node>, 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<string>, 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<FieldDef>,
|
||||||
|
/**
|
||||||
|
* 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, };
|
||||||
@@ -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"),
|
||||||
|
);
|
||||||
@@ -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<string, string[]>,
|
||||||
|
): 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
+49
@@ -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<string, string[]>`).
|
||||||
|
*/
|
||||||
|
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;
|
||||||
@@ -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();
|
||||||
@@ -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<string, string[]>`).
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
Binary file not shown.
+21
@@ -0,0 +1,21 @@
|
|||||||
|
/* tslint:disable */
|
||||||
|
/* eslint-disable */
|
||||||
|
export const memory: WebAssembly.Memory;
|
||||||
|
export const __wbg_querymatcher_free: (a: number, b: number) => 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;
|
||||||
@@ -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<Node>, span: Span },
|
||||||
|
/// Any child matches: terms joined with `OR`.
|
||||||
|
Or { children: Vec<Node>, span: Span },
|
||||||
|
/// The node does not match: `-term`, `-(group)`, `-@field:value`.
|
||||||
|
Not { node: Box<Node>, 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<String>,
|
||||||
|
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<String>,
|
||||||
|
/// The number as it appears in the source, unit included.
|
||||||
|
pub raw: String,
|
||||||
|
}
|
||||||
@@ -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<Completion>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
/// 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<String, Vec<String>>,
|
||||||
|
) -> Option<Completions> {
|
||||||
|
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<Completion> {
|
||||||
|
if word.starts_with('@') || !typed.contains(':') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (path, value) = word.split_once(':')?;
|
||||||
|
if !is_valid_path(path) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let path: Vec<String> = 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<String, Vec<String>>,
|
||||||
|
partial: &str,
|
||||||
|
span: Span,
|
||||||
|
) -> Option<Completions> {
|
||||||
|
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<String, Vec<String>>,
|
||||||
|
) -> Option<Completions> {
|
||||||
|
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<Completion> = 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<String, Vec<String>>, path: &str) -> Option<&'a Vec<String>> {
|
||||||
|
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<Item = &'a String>, 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<String, Vec<String>> {
|
||||||
|
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<String>)> {
|
||||||
|
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<String> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<char>,
|
||||||
|
search: Vec<char>,
|
||||||
|
skips: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`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<T: Unit>(target: &[T], search: &[T], skips: &mut Vec<usize>) -> 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<T: Unit>(target: &[T], skips: &mut Vec<usize>) {
|
||||||
|
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<T: Unit>(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("日本 語", "日語"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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<Tok> {
|
||||||
|
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<NumberLiteral> {
|
||||||
|
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<TokKind> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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<Node> {
|
||||||
|
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<Node>, make: impl Fn(Vec<Node>, Span) -> Node) -> Option<Node> {
|
||||||
|
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::<Vec<_>>().join(" "))
|
||||||
|
}
|
||||||
|
Node::Or { children, .. } => {
|
||||||
|
format!("(or {})", children.iter().map(go).collect::<Vec<_>>().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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<FieldDef>,
|
||||||
|
/// Substring when left out.
|
||||||
|
#[serde(default)]
|
||||||
|
#[cfg_attr(test, ts(optional, as = "Option<FreeText>"))]
|
||||||
|
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.<key>: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<bool>"))]
|
||||||
|
pub map: bool,
|
||||||
|
/// A short note shown with the field in completions.
|
||||||
|
#[serde(default)]
|
||||||
|
#[cfg_attr(test, ts(optional))]
|
||||||
|
pub info: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<Unit>,
|
||||||
|
},
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FieldDef>, 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<ResolvedField<'_>> {
|
||||||
|
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<Diagnostic> {
|
||||||
|
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<f64> {
|
||||||
|
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<bool> {
|
||||||
|
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<String> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Token> {
|
||||||
|
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, "-")]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<T: Serialize>(value: &T) -> Result<JsValue, JsValue> {
|
||||||
|
value
|
||||||
|
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
|
||||||
|
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_js<T: DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||||
|
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<JsValue, JsValue> {
|
||||||
|
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<JsValue, JsValue> {
|
||||||
|
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<string, string[]>`).
|
||||||
|
#[wasm_bindgen]
|
||||||
|
pub fn query_completions(
|
||||||
|
input: &str,
|
||||||
|
cursor: u32,
|
||||||
|
schema: JsValue,
|
||||||
|
values: JsValue,
|
||||||
|
) -> Result<JsValue, JsValue> {
|
||||||
|
let schema: Schema = from_js(schema)?;
|
||||||
|
let values: HashMap<String, Vec<String>> =
|
||||||
|
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<JsValue, JsValue> {
|
||||||
|
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<QueryMatcher, JsValue> {
|
||||||
|
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<bool, JsValue> {
|
||||||
|
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<String>,
|
||||||
|
joined: &str,
|
||||||
|
lengths: &[u32],
|
||||||
|
) -> Result<Vec<u32>, 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())
|
||||||
|
}
|
||||||
Generated
+12
@@ -64,6 +64,7 @@
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
|
"crates/yaak-query",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
"crates/yaak-sync",
|
"crates/yaak-sync",
|
||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
@@ -322,6 +323,13 @@
|
|||||||
"name": "@yaakapp-internal/plugins",
|
"name": "@yaakapp-internal/plugins",
|
||||||
"version": "1.0.0"
|
"version": "1.0.0"
|
||||||
},
|
},
|
||||||
|
"crates/yaak-query": {
|
||||||
|
"name": "@yaakapp-internal/query",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"rimraf": "^6.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"crates/yaak-sse": {
|
"crates/yaak-sse": {
|
||||||
"name": "@yaakapp-internal/sse",
|
"name": "@yaakapp-internal/sse",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -5241,6 +5249,10 @@
|
|||||||
"resolved": "crates-proxy/yaak-proxy-lib",
|
"resolved": "crates-proxy/yaak-proxy-lib",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@yaakapp-internal/query": {
|
||||||
|
"resolved": "crates/yaak-query",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@yaakapp-internal/rpc-schema": {
|
"node_modules/@yaakapp-internal/rpc-schema": {
|
||||||
"resolved": "crates/common/yaak-rpc-schema",
|
"resolved": "crates/common/yaak-rpc-schema",
|
||||||
"link": true
|
"link": true
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
|
"crates/yaak-query",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
"crates/yaak-sync",
|
"crates/yaak-sync",
|
||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
ignorePatterns: [
|
ignorePatterns: [
|
||||||
"**/bindings/**",
|
"**/bindings/**",
|
||||||
"**/routeTree.gen.ts",
|
"**/routeTree.gen.ts",
|
||||||
|
"crates/yaak-query/pkg/**",
|
||||||
"crates/yaak-templates/pkg/**",
|
"crates/yaak-templates/pkg/**",
|
||||||
"crates/yaak-wasm/pkg/**",
|
"crates/yaak-wasm/pkg/**",
|
||||||
"plugins/importer-bruno/tests/fixtures/upstream/**",
|
"plugins/importer-bruno/tests/fixtures/upstream/**",
|
||||||
@@ -18,6 +19,7 @@ export default defineConfig({
|
|||||||
lint: {
|
lint: {
|
||||||
ignorePatterns: [
|
ignorePatterns: [
|
||||||
"npm/**",
|
"npm/**",
|
||||||
|
"crates/yaak-query/pkg/**",
|
||||||
"crates/yaak-templates/pkg/**",
|
"crates/yaak-templates/pkg/**",
|
||||||
"crates/yaak-wasm/pkg/**",
|
"crates/yaak-wasm/pkg/**",
|
||||||
"**/bindings/gen_*.ts",
|
"**/bindings/gen_*.ts",
|
||||||
|
|||||||
Reference in new issue
Block a user