UNPKG

unified-query

Version:

Composable search input with autocompletion and a rich query-language parser for the Unified Data System

175 lines (174 loc) 7.59 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; completions; /** Compartment so we can hot‑swap the UUID‑name plugin when collections change */ completionsComp = new Compartment(); constructor(opts) { this.opts = opts; this.completions = opts.completions ?? {}; 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.completionsComp.of(uuidNamePlugin(this.entityMap())), autocompletion({ override: [ this.keywordCompletion.bind(this), this.inCompletionSource.bind(this), this.byCompletionSource.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 */ setCompletions(completions) { this.completions = completions; // Reconfigure just the compartment, cheap & isolated this.view.dispatch({ effects: this.completionsComp.reconfigure(uuidNamePlugin(this.entityMap())) }); } destroy() { this.view.destroy(); } entityMap() { const entityMap = new Map(); Object.values(this.completions).reduce((total, next) => [...total, ...next], []).forEach(e => entityMap.set(e.id, e)); return entityMap; } /** * 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*$/ }; } byCompletionSource(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 = /@by(?:\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.completions.by ? [...this.completions.by.values()].map(c => ({ label: c.name, type: c.kind, apply: c.id })) : []; return { from, options }; } // 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.completions.in ? [...this.completions.in.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';