Line data Source code
1 : import { EditorState, Extension, Facet, Prec, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
2 : import { Decoration, DecorationSet, EditorView, KeyBinding, ViewPlugin, ViewUpdate, WidgetType, keymap } from '@codemirror/view';
3 :
4 : import {
5 : CatalogNameIndex,
6 : CatalogNameKind,
7 : EMPTY_CATALOG_NAME_INDEX,
8 : findIdentifierMatches,
9 : isIdentifierCandidate,
10 : lookupIdentifier,
11 : lookupPrefix,
12 : } from './catalog_name_index.js';
13 : import { findScriptReferences, SCRIPT_REFERENCE_PREFIX } from './script_references.js';
14 : import { DashQLCompletionDisabled } from '../session/editor/dashql_processor.js';
15 :
16 : import * as styles from './nl_identifier_plugin.module.css';
17 :
18 : /// An unqualified word needs this many characters before we hint a completion
19 1 : const HINT_MIN_PREFIX_LENGTH = 4;
20 : /// A prefix with more distinct candidates is too ambiguous to hint
21 1 : const HINT_MAX_CANDIDATES = 5;
22 :
23 : /// A character of an identifier word
24 1 : const WORD_CHAR = /[\p{L}\p{N}_$]/u;
25 : /// The first character of an identifier word
26 1 : const WORD_START = /[\p{L}_]/u;
27 :
28 : /// The catalog name index of the editor, replaced by reconfiguring the editor
29 1 : export const NLIdentifierIndex = Facet.define<CatalogNameIndex, CatalogNameIndex>({
30 1 : combine: values => values[0] ?? EMPTY_CATALOG_NAME_INDEX,
31 1 : });
32 :
33 : /// The display names of the session scripts that the text can reference, replaced by reconfiguring the editor
34 1 : export const NLScriptNames = Facet.define<readonly string[], readonly string[]>({
35 1 : combine: values => values[0] ?? [],
36 1 : });
37 :
38 : /// A completion hint shown as ghost text after the cursor
39 : export interface NLIdentifierHint {
40 : /// The cursor the hint is anchored at
41 : cursor: number;
42 : /// The text that accepting the hint inserts at the cursor
43 : insert: string;
44 : }
45 :
46 : /// Effect to drop the completion hint
47 1 : const NLIdentifierHintDismissEffect = StateEffect.define<null>();
48 :
49 : /// Rank a candidate name, lower is better
50 1 : function rankCandidate(index: CatalogNameIndex, key: string, mentionedTables: Set<string>): number {
51 1 : let rank = 2;
52 1 : for (const name of index.byKey.get(key) ?? []) {
53 1 : if (name.kind === CatalogNameKind.Column && name.table != null && mentionedTables.has(name.table.toLowerCase())) {
54 1 : return 0;
55 : }
56 1 : if (name.kind === CatalogNameKind.Table) {
57 1 : rank = 1;
58 : }
59 1 : }
60 1 : return rank;
61 1 : }
62 :
63 : /// A character of the name of a script reference
64 1 : const SCRIPT_NAME_CHAR = /[\p{L}\p{N}_$-]/u;
65 :
66 : /// The hint for the name of a script reference `:foo` that ends at the cursor: the rest of the name of the only
67 : /// script, or of the shortest of a few scripts, that starts with the typed name. Undefined if the cursor is not at a
68 : /// script reference.
69 1 : function computeScriptReferenceHint(state: EditorState, text: string, offset: number, cursor: number): NLIdentifierHint | null | undefined {
70 1 : let begin = offset;
71 1 : while (begin > 0 && SCRIPT_NAME_CHAR.test(text[begin - 1])) {
72 1 : --begin;
73 : }
74 1 : if (begin === 0 || text[begin - 1] !== SCRIPT_REFERENCE_PREFIX) {
75 1 : return undefined;
76 : }
77 : // Not a cast or a time, `x::text` and `12:30`
78 1 : if (begin > 1 && (WORD_CHAR.test(text[begin - 2]) || text[begin - 2] === SCRIPT_REFERENCE_PREFIX)) {
79 0 : return undefined;
80 : }
81 1 : const typed = text.substring(begin, offset).toLowerCase();
82 1 : const names = state.facet(NLScriptNames)
83 1 : .filter(name => name.toLowerCase().startsWith(typed) && name.length > typed.length);
84 1 : if (names.length === 0 || names.length > HINT_MAX_CANDIDATES || (typed.length === 0 && names.length > 1)) {
85 1 : return null;
86 : }
87 1 : const name = [...names].sort((l, r) => l.length - r.length || (l < r ? -1 : 1))[0];
88 1 : return { cursor, insert: name.substring(typed.length) };
89 1 : }
90 :
91 : /// Compute the completion hint for the word that ends at the cursor, if we are confident enough
92 1 : export function computeNLIdentifierHint(state: EditorState): NLIdentifierHint | null {
93 1 : const selection = state.selection.main;
94 1 : if (!selection.empty) {
95 0 : return null;
96 : }
97 1 : const cursor = selection.head;
98 1 : const line = state.doc.lineAt(cursor);
99 1 : const text = line.text;
100 1 : const offset = cursor - line.from;
101 : // The word must end at the cursor
102 1 : if (offset < text.length && WORD_CHAR.test(text[offset])) {
103 0 : return null;
104 : }
105 : // A script reference names a script, not a catalog object
106 1 : const reference = computeScriptReferenceHint(state, text, offset, cursor);
107 1 : if (reference !== undefined) {
108 1 : return reference;
109 : }
110 1 : let begin = offset;
111 1 : while (begin > 0 && WORD_CHAR.test(text[begin - 1])) {
112 1 : --begin;
113 : }
114 1 : const word = text.substring(begin, offset);
115 1 : if (word.length === 0 || !WORD_START.test(word[0])) {
116 1 : return null;
117 : }
118 1 : const index = state.facet(NLIdentifierIndex);
119 :
120 : // A qualified word completes the columns of its table
121 1 : let qualifier: string | null = null;
122 1 : if (begin > 0 && text[begin - 1] === '.') {
123 1 : let qualifierBegin = begin - 1;
124 1 : while (qualifierBegin > 0 && WORD_CHAR.test(text[qualifierBegin - 1])) {
125 1 : --qualifierBegin;
126 : }
127 1 : qualifier = text.substring(qualifierBegin, begin - 1).toLowerCase();
128 1 : }
129 1 : let keys: string[];
130 1 : if (qualifier != null) {
131 1 : const columns = index.columnsByTable.get(qualifier);
132 1 : if (columns == null || columns.has(word.toLowerCase())) {
133 0 : return null;
134 : }
135 1 : keys = lookupPrefix(index, word, Number.MAX_SAFE_INTEGER).filter(key => columns.has(key)).slice(0, HINT_MAX_CANDIDATES + 1);
136 1 : } else {
137 1 : if (word.length < HINT_MIN_PREFIX_LENGTH || !isIdentifierCandidate(word) || lookupIdentifier(index, word) != null) {
138 1 : return null;
139 : }
140 1 : keys = lookupPrefix(index, word, HINT_MAX_CANDIDATES + 1);
141 1 : }
142 1 : if (keys.length === 0 || keys.length > HINT_MAX_CANDIDATES) {
143 1 : return null;
144 : }
145 :
146 : // Prefer the columns of tables that the text already mentions
147 1 : const mentionedTables = new Set<string>();
148 1 : const wordBegin = line.from + begin;
149 1 : for (const match of findIdentifierMatches(index, state.doc.toString())) {
150 1 : if (match.to < wordBegin || match.from > cursor) {
151 1 : for (const name of match.names) {
152 1 : if (name.kind === CatalogNameKind.Table) mentionedTables.add(name.name.toLowerCase());
153 : }
154 : }
155 : }
156 1 : const ranked = keys
157 1 : .map(key => ({ key, rank: rankCandidate(index, key, mentionedTables) }))
158 1 : .sort((l, r) => l.rank - r.rank || l.key.length - r.key.length || (l.key < r.key ? -1 : 1));
159 1 : const name = index.byKey.get(ranked[0].key)![0].name;
160 1 : const insert = name.substring(word.length);
161 1 : return insert.length > 0 ? { cursor, insert } : null;
162 1 : }
163 :
164 : /// The completion hint, recomputed only when the user types
165 1 : export const NLIdentifierHintField = StateField.define<NLIdentifierHint | null>({
166 1 : create: () => null,
167 1 : update: (hint, tr) => {
168 1 : for (const effect of tr.effects) {
169 1 : if (effect.is(NLIdentifierHintDismissEffect)) return null;
170 : }
171 1 : if (tr.docChanged) {
172 1 : return tr.isUserEvent('input.type') ? computeNLIdentifierHint(tr.state) : null;
173 : }
174 1 : if (tr.selection != null || tr.startState.facet(NLIdentifierIndex) !== tr.state.facet(NLIdentifierIndex)) {
175 1 : return null;
176 : }
177 1 : return hint;
178 1 : },
179 1 : provide: field => EditorView.decorations.from(field, hint => hint == null
180 1 : ? Decoration.none
181 1 : : Decoration.set([Decoration.widget({ widget: new HintWidget(hint.insert), side: 1 }).range(hint.cursor)])),
182 1 : });
183 :
184 : /// The ghost text of a completion hint
185 1 : class HintWidget extends WidgetType {
186 1 : constructor(readonly text: string) { super(); }
187 :
188 0 : eq(other: HintWidget) {
189 0 : return other.text === this.text;
190 0 : }
191 1 : toDOM() {
192 1 : const span = document.createElement('span');
193 1 : span.className = styles.hint;
194 1 : span.textContent = this.text;
195 1 : return span;
196 1 : }
197 1 : }
198 :
199 1 : const TableDecoration = Decoration.mark({ class: styles.identifier_table });
200 1 : const ColumnDecoration = Decoration.mark({ class: styles.identifier_column });
201 1 : const ScriptDecoration = Decoration.mark({ class: styles.identifier_script });
202 :
203 : /// Mark the finished words that name catalog objects and the finished references to session scripts
204 1 : function buildHighlights(state: EditorState): DecorationSet {
205 1 : const text = state.doc.toString();
206 1 : const selection = state.selection.main;
207 : // The word the user is still typing is not finished
208 1 : const unfinished = (from: number, to: number) => selection.empty && selection.head > from && selection.head <= to;
209 1 : const marks: { from: number; to: number; decoration: Decoration }[] = [];
210 1 : const references = findScriptReferences(state.facet(NLScriptNames), text);
211 1 : for (const reference of references) {
212 1 : if (!unfinished(reference.from, reference.to)) {
213 1 : marks.push({ from: reference.from, to: reference.to, decoration: ScriptDecoration });
214 : }
215 : }
216 1 : for (const match of findIdentifierMatches(state.facet(NLIdentifierIndex), text)) {
217 : // A script name is not a catalog name, even if a table is named alike
218 1 : if (unfinished(match.from, match.to) || references.some(reference => reference.from < match.to && match.from < reference.to)) {
219 1 : continue;
220 : }
221 1 : marks.push({ from: match.from, to: match.to, decoration: match.kind === CatalogNameKind.Table ? TableDecoration : ColumnDecoration });
222 1 : }
223 1 : marks.sort((l, r) => l.from - r.from);
224 1 : const builder = new RangeSetBuilder<Decoration>();
225 1 : for (const mark of marks) {
226 1 : builder.add(mark.from, mark.to, mark.decoration);
227 : }
228 1 : return builder.finish();
229 1 : }
230 :
231 1 : const NLIdentifierHighlights = ViewPlugin.fromClass(class {
232 1 : decorations: DecorationSet;
233 :
234 1 : constructor(view: EditorView) {
235 1 : this.decorations = buildHighlights(view.state);
236 1 : }
237 1 : update(update: ViewUpdate) {
238 1 : if (update.docChanged || update.selectionSet
239 1 : || update.startState.facet(NLIdentifierIndex) !== update.state.facet(NLIdentifierIndex)
240 1 : || update.startState.facet(NLScriptNames) !== update.state.facet(NLScriptNames)) {
241 1 : this.decorations = buildHighlights(update.state);
242 : }
243 1 : }
244 1 : }, { decorations: plugin => plugin.decorations });
245 :
246 : /// Tab accepts the hint
247 1 : function acceptHint(view: EditorView): boolean {
248 1 : const hint = view.state.field(NLIdentifierHintField, false);
249 1 : if (hint == null) {
250 0 : return false;
251 : }
252 1 : view.dispatch({
253 1 : changes: { from: hint.cursor, insert: hint.insert },
254 1 : selection: { anchor: hint.cursor + hint.insert.length },
255 1 : userEvent: 'input.complete',
256 1 : });
257 1 : return true;
258 1 : }
259 :
260 : /// Escape drops the hint
261 1 : function dismissHint(view: EditorView): boolean {
262 1 : if (view.state.field(NLIdentifierHintField, false) == null) {
263 0 : return false;
264 : }
265 1 : view.dispatch({ effects: NLIdentifierHintDismissEffect.of(null) });
266 1 : return true;
267 1 : }
268 :
269 1 : const KEYBINDINGS: KeyBinding[] = [
270 1 : { key: 'Tab', run: acceptHint },
271 1 : { key: 'Escape', run: dismissHint },
272 1 : ];
273 :
274 : /// Highlights and completes catalog identifiers in natural language text, and highlights script references.
275 : /// Reads the names from the NLIdentifierIndex and NLScriptNames facets.
276 1 : export const NLIdentifierExtensions = [
277 1 : NLIdentifierHintField,
278 1 : NLIdentifierHighlights,
279 1 : Prec.highest(keymap.of(KEYBINDINGS)),
280 1 : ];
281 :
282 : /// The assistance for natural language: no SQL completion, the names of the index are highlighted and completed,
283 : /// the references to the session scripts are highlighted
284 1 : export function naturalLanguageAssist(index: CatalogNameIndex, scriptNames: readonly string[]): Extension {
285 1 : return [DashQLCompletionDisabled.of(true), NLIdentifierIndex.of(index), NLScriptNames.of(scriptNames), ...NLIdentifierExtensions];
286 1 : }
|