UNPKG

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
// 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';