LCOV - code coverage report
Current view: top level - dashql-app/src/agent - nl_identifier_plugin.ts Coverage Total Hit
Test: DashQL 915828b Lines: 95.1 % 182 173
Test Date: 2026-11-06 19:30:32

            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 : }
        

Generated by: LCOV version 2.0-1