unified-query
Version:
Composable search input with autocompletion and a rich query-language parser for the Unified Data System
137 lines (136 loc) • 5.72 kB
JavaScript
// src/index.ts
import { EditorState, Compartment, Prec, EditorSelection } from '@codemirror/state';
import { EditorView, keymap, ViewUpdate } from '@codemirror/view';
import { basicSetup } from 'codemirror';
import { autocompletion, CompletionContext, } from '@codemirror/autocomplete';
import { highlighter, uuidNamePlugin } from './plugins.js';
import { createTheme } from './theme.js';
import { searchLinter } from './lint.js';
import { registry } from './analyzers/registry.js';
// Define available keywords
const KEYWORDS = [
...Object.keys(registry)
].map((k) => '@' + k);
export class Search {
opts;
view;
collections;
/** Compartment so we can hot‑swap the UUID‑name plugin when collections change */
collectionsCompartment = new Compartment();
constructor(opts) {
this.opts = opts;
this.collections = opts.collections ?? new Map();
const singleLine = EditorState.transactionFilter.of(tr => tr.newDoc.lines > 1 ? [] : tr // drop any edit that would add a row
);
const query = opts.query ?? '';
const theme = opts.theme ?? createTheme();
this.view = new EditorView({
state: EditorState.create({
doc: query,
selection: opts.selection ?? EditorSelection.single(query.length),
extensions: [
singleLine,
basicSetup,
Prec.highest(// top-priority key-map (otherwise Enter is ignored)
keymap.of(['Enter', 'ArrowDown', 'ArrowUp'].map(key => ({
key,
run: () => {
opts.onKeydown?.(key);
return true;
}
})).concat([{
key: "Mod-f",
run: () => true // handled -> CM’s search panel won’t open
}]))),
theme,
searchLinter,
highlighter,
// Collections‑aware plugin lives in a compartment for easy re‑config
this.collectionsCompartment.of(uuidNamePlugin(this.collections)),
autocompletion({
override: [
this.keywordCompletion.bind(this),
this.inCompletionSource.bind(this)
]
}),
// listen for text changes
EditorView.updateListener.of((u) => {
if (u.docChanged) {
const q = u.state.doc.toString();
this.opts.onChange(q);
}
})
]
}),
parent: this.opts.element
});
}
focus() {
this.view.focus();
}
/** Replace all collections and refresh dependent plugins */
setCollections(newCollections) {
this.collections = newCollections;
// Reconfigure just the compartment, cheap & isolated
this.view.dispatch({
effects: this.collectionsCompartment.reconfigure(uuidNamePlugin(this.collections))
});
}
destroy() {
this.view.destroy();
}
/**
* A simple keyword completer: suggests on `@…`
* You can expand this list as desired.
*/
keywordCompletion(context) {
const before = context.matchBefore(/@\w*/);
if (!before)
return null;
// Only trigger when explicit or at least one char after '@'
if (before.from === before.to && !context.explicit)
return null;
return {
from: before.from,
to: before.to,
options: KEYWORDS.map((label) => ({ label, type: 'keyword' })),
validFor: /^@\w*$/
};
}
// 2) Create a completion source for "@in <…>"
inCompletionSource(context) {
// Examine only the text before the cursor on the current line – we don't
// care about multiline matches here and this keeps the regex simpler.
const line = context.state.doc.lineAt(context.pos);
const beforeCursor = line.text.slice(0, context.pos - line.from);
/*
* Regex breakdown:
* @in – literal "@in"
* (?:\s+[\w-]+\*)* – zero or more full words w/ optional * already typed (each
* preceded by whitespace)
* \s+([\w-]*) – the *current* (possibly empty) partial word right
* before the cursor which we capture for replacement
* $ – ensure we're at the end of the string (cursor)
*
* Note: Not urgent, but should consider using parsed tokens to simplify completion logic, and in
* case the syntax changes, this completion source should remain the same.
*/
const match = /@in(?:\s+[\w-]+\*?)*\s+([\w-]*)$/.exec(beforeCursor);
// const match = /@in(?:\s+[\w-]+)*\s+([\w-]*)$/.exec(beforeCursor);
if (!match)
return null; // Not in an "@in" clause
const partial = match[1] ?? '';
const from = context.pos - partial.length;
const options = this.collections
? [...this.collections.values()].map(c => ({
label: c.name,
type: c.kind,
apply: c.id
}))
: [];
return { from, options };
}
}
export { toQuery } from './query.js';
export { parse } from './parsers/index.js';
export { createTheme } from './theme.js';