LCOV - code coverage report
Current view: top level - dashql-app/src/session/editor - dashql_completion_patches.ts Coverage Total Hit
Test: DashQL 915828b Lines: 86.6 % 187 162
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : import { DEFAULT_DATABASE_NAME } from '../../connections/catalog/catalog_sql_generator.js';
       3              : 
       4              : import { ChangeSpec, Text } from '@codemirror/state';
       5              : import { VariantKind } from '../../utils/variant.js';
       6              : import { DashQLCompletionState } from './dashql_processor.js';
       7              : 
       8            1 : export const PATCH_INSERT_TEXT = Symbol("INSERT_TEXT");
       9            1 : export const PATCH_DELETE_TEXT = Symbol("REMOVE_TEXT");
      10              : 
      11            1 : export enum CompletionPatchTarget {
      12            1 :     Candidate = 1,
      13            1 :     CatalogObject = 2,
      14            1 : }
      15              : 
      16              : export type CompletionPatchVariant =
      17              :     | VariantKind<typeof PATCH_INSERT_TEXT, InsertTextPatch>
      18              :     | VariantKind<typeof PATCH_DELETE_TEXT, RemoveTextPatch>;
      19              : 
      20              : export type CompletionPatch = CompletionPatchVariant & {
      21              :     /// The patch target
      22              :     target: CompletionPatchTarget;
      23              : };
      24              : 
      25            1 : export enum TextAnchor {
      26            1 :     Right = -1,
      27            1 :     Left = 1,
      28            1 : }
      29              : 
      30              : interface InsertTextPatch {
      31              :     /// The location
      32              :     at: number;
      33              :     /// The completion text
      34              :     text: string;
      35              :     /// The text anchor of the hint
      36              :     textAnchor: TextAnchor;
      37              : }
      38              : 
      39              : interface RemoveTextPatch {
      40              :     /// Remove text at a location
      41              :     at: number;
      42              :     /// Remove `length` characters
      43              :     length: number;
      44              : }
      45              : 
      46              : /// Given two strings, derive the required patches to get from `have` to `want`.
      47              : /// If `have` is a proper substring of `want`, emit before/after inserts around it.
      48              : /// Otherwise, delete `have` entirely and insert `want` with the anchor `insertAnchor`.
      49            1 : export function completionDiff(at: number, have: string, want: string, target: CompletionPatchTarget, cursor: number, insertAnchor: TextAnchor = TextAnchor.Left): CompletionPatch[] {
      50            1 :     const out: CompletionPatch[] = [];
      51            1 :     const idx = have.length > 0 ? want.indexOf(have) : -1;
      52            1 :     if (idx >= 0) {
      53            1 :         if (idx > 0) {
      54            1 :             out.push({
      55            1 :                 target,
      56            1 :                 type: PATCH_INSERT_TEXT,
      57            1 :                 value: { at, text: want.substring(0, idx), textAnchor: TextAnchor.Right },
      58            1 :             });
      59              :         }
      60            1 :         const after = idx + have.length;
      61            1 :         if (after < want.length) {
      62            1 :             out.push({
      63            1 :                 target,
      64            1 :                 type: PATCH_INSERT_TEXT,
      65            1 :                 value: { at: at + have.length, text: want.substring(after), textAnchor: TextAnchor.Left },
      66            1 :             });
      67              :         }
      68            1 :     } else {
      69            1 :         if (have.length > 0) {
      70            1 :             out.push({
      71            1 :                 target,
      72            1 :                 type: PATCH_DELETE_TEXT,
      73            1 :                 value: { at, length: have.length },
      74            1 :             });
      75              :         }
      76            1 :         out.push({
      77            1 :             target,
      78            1 :             type: PATCH_INSERT_TEXT,
      79            1 :             value: { at: at + have.length, text: want, textAnchor: insertAnchor },
      80            1 :         });
      81            1 :     }
      82            1 :     return out;
      83            1 : }
      84              : 
      85              : /// Helper to read a qualified name
      86            1 : export function unpackQualifiedObjectName(co: dashql.buffers.completion.CompletionCandidateObject): string[] {
      87            1 :     const out = [];
      88            1 :     for (let i = 0; i < co.qualifiedNameLength(); ++i) {
      89            1 :         const name = co.qualifiedName(i);
      90            1 :         out.push(name);
      91            1 :     }
      92            1 :     return out;
      93            1 : }
      94              : 
      95            1 : export function shouldAutoQualifyNonDefaultTable(co: dashql.buffers.completion.CompletionCandidateObject): boolean {
      96            1 :     if (co.objectType() !== dashql.buffers.completion.CompletionCandidateObjectType.TABLE) return false;
      97            1 :     if (co.qualifiedNameLength() !== 3) return false;
      98            1 :     return co.qualifiedName(0)?.toLowerCase() !== DEFAULT_DATABASE_NAME;
      99            1 : }
     100              : 
     101            1 : function copyLazily(nextState: DashQLCompletionState, prevState: DashQLCompletionState): DashQLCompletionState {
     102            1 :     return nextState === prevState ? { ...prevState } : nextState;
     103            1 : };
     104              : 
     105            1 : export enum UpdatePatchStartingFrom {
     106            1 :     Candidate = 0,
     107            1 :     CatalogObject = 1,
     108            1 : }
     109              : 
     110            1 : export function computePatches(prevState: DashQLCompletionState, text: Text, cursor: number = 0, updateFrom: UpdatePatchStartingFrom = UpdatePatchStartingFrom.Candidate): DashQLCompletionState {
     111            1 :     const buffer = prevState.buffer.read();
     112            1 :     let nextState = prevState;
     113              : 
     114              :     /// Invalid candidate id?
     115            1 :     const candidateId = prevState.candidateId;
     116            1 :     if (candidateId >= buffer.candidatesLength()) {
     117            0 :         return nextState;
     118              :     }
     119            1 :     const candidate = buffer.candidates(candidateId)!;
     120              : 
     121              :     // Read locations (every patch will need them)
     122            1 :     const targetLoc = candidate.targetLocation();
     123            1 :     const qualifiedLoc = candidate.targetLocationQualified();
     124            1 :     if (targetLoc == null || qualifiedLoc == null) {
     125            0 :         return nextState;
     126              :     }
     127            1 :     const targetFrom = targetLoc.offset();
     128            1 :     const originalTargetTo = targetLoc.offset() + targetLoc.length();
     129            1 :     const originalQualifiedTo = qualifiedLoc.offset() + qualifiedLoc.length();
     130            1 :     const candidateText = candidate.completionText()!;
     131            1 :     const targetTo = updateFrom <= UpdatePatchStartingFrom.Candidate
     132            1 :         ? originalTargetTo
     133            1 :         : targetFrom + candidateText.length;
     134            1 :     const qualifiedFrom = qualifiedLoc.offset();
     135            1 :     const qualifiedTo = updateFrom <= UpdatePatchStartingFrom.Candidate
     136            1 :         ? originalQualifiedTo
     137            1 :         : originalQualifiedTo + candidateText.length - (originalTargetTo - targetFrom);
     138              : 
     139              :     // Update candidate patch?
     140            1 :     if (updateFrom <= UpdatePatchStartingFrom.Candidate) {
     141            1 :         nextState = copyLazily(nextState, prevState);
     142            1 :         nextState.candidatePatch = [];
     143            1 :         nextState.catalogObjectPatch = [];
     144              : 
     145            1 :         const currentText = text.sliceString(targetFrom, targetTo);
     146            1 :         nextState.candidatePatch = completionDiff(targetFrom, currentText, candidateText, CompletionPatchTarget.Candidate, cursor);
     147            1 :     }
     148              : 
     149              :     // Keyword continuation?
     150            1 :     const keywordContinuation = candidate.keywordContinuation();
     151            1 :     if (keywordContinuation && updateFrom <= UpdatePatchStartingFrom.CatalogObject) {
     152            1 :         nextState = copyLazily(nextState, prevState);
     153            1 :         const insertAt = updateFrom <= UpdatePatchStartingFrom.Candidate
     154            1 :             ? targetTo
     155            0 :             : targetFrom + candidateText.length;
     156            1 :         nextState.catalogObjectPatch = [{
     157            1 :             target: CompletionPatchTarget.CatalogObject,
     158            1 :             type: PATCH_INSERT_TEXT,
     159            1 :             value: { at: insertAt, text: " " + keywordContinuation, textAnchor: TextAnchor.Left },
     160            1 :         }];
     161            1 :         return nextState;
     162            1 :     }
     163              : 
     164              :     // Read catalog object
     165            1 :     const catalogObjectId = prevState.catalogObjectId;
     166            1 :     if (catalogObjectId >= candidate.catalogObjectsLength()) {
     167            1 :         return nextState;
     168              :     }
     169            1 :     const catalogObject = candidate.catalogObjects(catalogObjectId)!;
     170              : 
     171              :     // Update catalog object patch?
     172            1 :     if (updateFrom <= UpdatePatchStartingFrom.CatalogObject
     173            1 :         && (catalogObject.preferQualified() || shouldAutoQualifyNonDefaultTable(catalogObject))) {
     174            1 :         nextState = copyLazily(nextState, prevState);
     175            1 :         nextState.catalogObjectPatch = [];
     176              : 
     177              :         // Qualification prefix
     178            1 :         let name = unpackQualifiedObjectName(catalogObject);
     179            1 :         let qualPrefix = name.slice(0, catalogObject.qualifiedNameTargetIdx());
     180            1 :         if (qualPrefix.length > 0) {
     181            1 :             let have = text.sliceString(qualifiedFrom, targetFrom);
     182            1 :             let want = qualPrefix.join(".") + ".";
     183              :             // The prefix precedes the candidate, an opening quote of the candidate must follow it
     184            1 :             let patch = completionDiff(qualifiedFrom, have, want, CompletionPatchTarget.CatalogObject, cursor, TextAnchor.Right);
     185            1 :             nextState.catalogObjectPatch = patch;
     186            1 :         }
     187              : 
     188              :         // Qualification suffix
     189            1 :         let qualSuffix = name.slice(catalogObject.qualifiedNameTargetIdx() + 1);
     190            1 :         if (qualSuffix.length > 0) {
     191            0 :             let have = text.sliceString(targetTo, qualifiedTo);
     192            0 :             let want = "." + qualSuffix.join(".");
     193            0 :             let patch = completionDiff(targetTo, have, want, CompletionPatchTarget.CatalogObject, cursor);
     194            0 :             nextState.catalogObjectPatch = nextState.catalogObjectPatch.concat(patch);
     195            0 :         }
     196            1 :     }
     197              : 
     198            1 :     return nextState;
     199            1 : }
     200              : 
     201            1 : export function updateCursorWithCompletion(patch: CompletionPatch[], cursorAt: number): number {
     202            1 :     let out = cursorAt;
     203            1 :     for (const p of patch) {
     204            1 :         switch (p.type) {
     205            0 :             case PATCH_DELETE_TEXT:
     206            0 :                 if (p.value.at < cursorAt) {
     207            0 :                     const to = Math.min(p.value.at + p.value.length, cursorAt);
     208            0 :                     const deleteBeforeCursor = to - p.value.at;
     209            0 :                     out -= deleteBeforeCursor;
     210            0 :                 }
     211            0 :                 break;
     212            1 :             case PATCH_INSERT_TEXT:
     213            1 :                 if (p.value.at <= cursorAt) {
     214            1 :                     out += p.value.text.length;
     215              :                 }
     216              :                 break;
     217            1 :         }
     218              :     }
     219            1 :     return out;
     220            1 : }
     221              : 
     222            1 : const PATCH_PRIORITY_MAX = 10000;
     223              : 
     224              : /// Order patches by location. Inserts at the same location are ordered by anchor and target, so that
     225              : /// a qualification prefix lands before the opening quote of a candidate and a qualification suffix
     226              : /// after its closing quote.
     227            1 : export function comparePatches(l: CompletionPatch, r: CompletionPatch): number {
     228            1 :     let a = l.value.at;
     229            1 :     let b = r.value.at;
     230            1 :     if (a != b) {
     231            1 :         return a - b;
     232              :     }
     233            1 :     switch (l.type) {
     234            1 :         case PATCH_INSERT_TEXT:
     235            1 :             a = (l.value.textAnchor as number) * (l.target as number);
     236            1 :             break;
     237            0 :         case PATCH_DELETE_TEXT:
     238            0 :             a = (l.target as number) * PATCH_PRIORITY_MAX;
     239              :             break;
     240            1 :     }
     241            1 :     switch (r.type) {
     242            1 :         case PATCH_INSERT_TEXT:
     243            1 :             b = (r.value.textAnchor as number) * (r.target as number);
     244            1 :             break;
     245            0 :         case PATCH_DELETE_TEXT:
     246            0 :             b = (r.target as number) * PATCH_PRIORITY_MAX
     247              :             break;
     248            1 :     }
     249            1 :     return a - b;
     250            1 : }
     251              : 
     252            1 : export function applyCompletion(patch: CompletionPatch[]): ChangeSpec {
     253            1 :     const out: ChangeSpec[] = [];
     254              :     // CodeMirror applies inserts at the same location in the given order
     255            1 :     for (const p of [...patch].sort(comparePatches)) {
     256            1 :         switch (p.type) {
     257            0 :             case PATCH_DELETE_TEXT:
     258            0 :                 out.push({
     259            0 :                     from: p.value.at,
     260            0 :                     to: p.value.at + p.value.length,
     261            0 :                 });
     262            0 :                 break;
     263            1 :             case PATCH_INSERT_TEXT:
     264            1 :                 out.push({
     265            1 :                     from: p.value.at,
     266            1 :                     insert: p.value.text
     267            1 :                 });
     268              :                 break;
     269            1 :         }
     270              :     }
     271            1 :     return out;
     272            1 : }
        

Generated by: LCOV version 2.0-1