All files / src/cm6 completion-source.ts

100% Statements 24/24
100% Branches 12/12
100% Functions 6/6
100% Lines 24/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114                                            7x 7x                 29x 29x 29x 45x   29x                     142x 29x 29x 29x   29x   29x                     507x                                 507x                     507x   102x   260x   5x   3x   3x   4x   4x   126x      
import {
  snippetCompletion,
  type Completion,
  type CompletionSource,
  type CompletionContext,
} from '@codemirror/autocomplete';
import type { EditorState } from '@codemirror/state';
import { SpelCompletionEngine, type CompletionItem, type ContextSchema } from '@agentix-e/spel-ts';
 
import { toCm6Snippet } from './snippet.js';
 
/**
 * The characters a SpEL name may contain, and therefore the characters that keep the
 * completion list valid.
 *
 * `\w` is not enough for either role. An identifier may be any Unicode letter — the
 * engine accepts them so the natural-language pipeline can emit a field named in
 * Chinese — and a reference is written `#order.amount`, so `#`, `@` and `.` belong to
 * the token as well. Matching only `\w` made the list replace the ASCII tail of
 * `#order.amo` instead of the whole reference, and left `validFor` with no way to
 * notice that a space ends the token.
 */
const NAME_CHARACTER = /[\p{L}\p{N}_$#@.]/u;
const NAME_VALID_FOR = /^[\p{L}\p{N}_$#@.]*$/u;
 
/**
 * The start of the name the cursor sits in, which is the position a completion replaces from.
 *
 * Only the current line is read: a name cannot span a line break, which is also the scope
 * CodeMirror's own `matchBefore` used.
 */
function nameStartAt(state: EditorState, position: number): number {
  const line = state.doc.lineAt(position);
  let start = position;
  while (start > line.from && NAME_CHARACTER.test(state.sliceDoc(start - 1, start))) {
    start--;
  }
  return start;
}
 
/**
 * Adapter: spel-ts CompletionEngine → CM6 CompletionSource.
 *
 * Maps SpelCompletionEngine items to CM6 Completion objects at the cursor position.
 * When a ContextSchema provider is registered, context-aware completions
 * (variables, properties, methods, beans, types) are included.
 */
export function spelCompletion(getContextSchema?: () => ContextSchema | null): CompletionSource {
  return (context: CompletionContext) => {
    const expression = context.state.sliceDoc();
    const position = context.pos;
    const schema = getContextSchema?.() ?? undefined;
 
    const items = SpelCompletionEngine.getCompletions(expression, position, schema);
 
    return {
      // Scanned in place rather than through `context.matchBefore(NAME_PREFIX)`. A pattern that
      // ends in a quantified class anchored at the cursor retries that class from every offset of
      // a long run of name characters whose end the anchor then rejects, which is quadratic: it
      // measured 662 ms for a 32 000-character line, and a single-line document reaches that.
      // Walking back over the run once gives the same range for a single pass.
      //
      // `matchBefore` could answer null when the pattern did not match at the cursor, and the
      // fallback to `position` guarded that contract. The scan cannot fail — an empty run is a
      // run — so the value is `position` in exactly the case the fallback covered.
      from: nameStartAt(context.state, position),
      options: items.map((item) => mapToCM6Completion(item)),
      // Stated rather than omitted, so the list stays open only while the cursor is
      // still inside a name. `() => true` claimed every position continues a token,
      // including the space that ends one.
      validFor: NAME_VALID_FOR,
    };
  };
}
 
/**
 * Map a spel-ts CompletionItem to a CM6 Completion.
 *
 * The engine describes what to insert as a snippet template, so the completion is
 * built with `snippetCompletion`: a plain string `apply` would insert the template
 * verbatim, placeholders and all, typing `T($1)` for the `T(...)` item.
 */
function mapToCM6Completion(item: CompletionItem): Completion {
  return snippetCompletion(toCm6Snippet(item.insertText), {
    label: item.label,
    type: mapKindToCM6Type(item.kind),
    detail: item.detail,
    info: item.documentation,
    // Higher priority items appear first
    boost: item.sortPriority / 100,
  });
}
 
function mapKindToCM6Type(kind: string): string {
  switch (kind) {
    case 'keyword':
      return 'keyword';
    case 'operator':
      return 'operator';
    case 'variable':
      return 'variable';
    case 'property':
      return 'property';
    case 'method':
      return 'method';
    case 'function':
      return 'function';
    case 'type':
      return 'type';
    default:
      return 'text';
  }
}