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

            Line data    Source code
       1              : import * as dashql from '@ankoh/dashql-core';
       2              : 
       3              : import { EditorState, Facet, StateField, StateEffect, StateEffectType, Text, Transaction } from '@codemirror/state';
       4              : 
       5              : import { deriveFocusFromEditorUpdate, SemanticUserFocus } from '../scripts/focus.js';
       6              : import { CompletionPatch, computePatches, UpdatePatchStartingFrom } from './dashql_completion_patches.js';
       7              : 
       8              : export const DASHQL_COMPLETION_LIMIT = 10;
       9            1 : const LOG_PREFIX = '[session-editor-debug]';
      10              : 
      11              : /// A script key
      12              : export type DashQLScriptKey = number;
      13              : /// A collection of FlatBuffers for a script
      14              : export interface DashQLScriptBuffers {
      15              :     /// The parsed script
      16              :     parsed: dashql.FlatBufferPtr<dashql.buffers.parser.ParsedScript> | null;
      17              :     /// The analyzed script
      18              :     analyzed: dashql.FlatBufferPtr<dashql.buffers.analyzer.AnalyzedScript> | null;
      19              : }
      20              : 
      21              : /// A completion status
      22            1 : export enum DashQLCompletionStatus {
      23            1 :     AVAILABLE,
      24            1 :     SELECTED_CANDIDATE,
      25            1 :     SELECTED_CATALOG_OBJECT,
      26            1 : }
      27              : 
      28              : /// A pending, staged formatting rewrite shown as an in-place diff.
      29              : ///
      30              : /// The editor already holds the *new* (target) text; `priorText` is the verbatim text that was in
      31              : /// the script before the rewrite, restored on reject. `diffBuffer` is the statement-level semantic
      32              : /// diff (source = prior, target = new) used to highlight the changed statements and sub-ranges.
      33              : /// Whole-suggestion accept/reject: accept keeps the new text, reject restores `priorText`, and any
      34              : /// genuine user edit auto-accepts.
      35              : export interface DashQLPendingDiff {
      36              :     /// The verbatim text before the rewrite (restored on reject)
      37              :     priorText: string;
      38              :     /// The semantic diff from the prior text (source) to the new text (target)
      39              :     diffBuffer: dashql.FlatBufferPtr<dashql.buffers.diff.ScriptDiff>;
      40              : }
      41              : 
      42              : /// A completion state
      43              : export interface DashQLCompletionState {
      44              :     /// The status
      45              :     status: DashQLCompletionStatus;
      46              :     /// Show as a passive inline hint only, without the dropdown list or candidate navigation.
      47              :     passiveHint: boolean;
      48              :     /// The completion buffer
      49              :     buffer: dashql.FlatBufferPtr<dashql.buffers.completion.Completion>;
      50              :     /// The currently selected candidate id.
      51              :     /// 0 if there are no candidates.
      52              :     candidateId: number;
      53              :     /// The patches to apply the candidate
      54              :     candidatePatch: CompletionPatch[];
      55              :     /// The currently selected catalog object id.
      56              :     /// 0 if there are no objects.
      57              :     catalogObjectId: number;
      58              :     /// The patches to apply the catalog object
      59              :     catalogObjectPatch: CompletionPatch[];
      60              :     /// Override cursor position after applying qualification/function-call patches.
      61              :     catalogObjectCursorOffset: number | null;
      62              : }
      63              : 
      64              : /// A state that is pushed from the processor to the outside
      65              : export interface DashQLProcessorUpdateOut {
      66              :     /// The key of the currently active script
      67              :     scriptKey: DashQLScriptKey;
      68              :     /// The currently active editor session
      69              :     scriptSession?: dashql.DashQLScriptSession | null;
      70              :     /// The latest plain editor-session snapshot.
      71              :     editorUpdate?: dashql.buffers.editor.EditorUpdateT | null;
      72              :     /// The previous processed script buffers (if any)
      73              :     scriptBuffers: DashQLScriptBuffers | null;
      74              :     /// The completion candidate state (if any)
      75              :     scriptCompletion: DashQLCompletionState | null;
      76              :     /// The pending staged rewrite shown as an in-place diff (if any)
      77              :     scriptPendingDiff: DashQLPendingDiff | null;
      78              : };
      79              : /// A state that is propagated from the outside into processor
      80              : export type DashQLProcessorUpdateIn = DashQLProcessorUpdateOut & {
      81              :     keywordCase?: dashql.buffers.formatting.KeywordCase;
      82              :     /// The derive focus info
      83              :     derivedFocus: SemanticUserFocus | null;
      84              : 
      85              :     /// Resolve the editor session of a session script by its catalog entry id for code actions.
      86              :     lookupScriptSession?: (scriptKey: DashQLScriptKey) => dashql.DashQLScriptSession | null;
      87              :     /// Navigate to a session script definition.
      88              :     onNavigateToScript?: (scriptKey: DashQLScriptKey) => void;
      89              : 
      90              :     /// This callback is called when the editor updates the script, the cursor, completions.
      91              :     /// The callee is responsible for keeping FlatBufferPtrs alive and clean them up once they get overwritten.
      92              :     onUpdate: (out: DashQLProcessorUpdateOut) => void;
      93              : }
      94              : /// The state of a DashQL processor
      95              : export type DashQLProcessorState = DashQLProcessorUpdateIn;
      96              : 
      97              : export function analyzeScript(
      98              :     script: dashql.DashQLScript,
      99              :     reportError: (error: unknown) => void = console.error,
     100              : ): DashQLScriptBuffers {
     101              :     try {
     102              :         script.analyze();
     103              :         return {
     104              :             parsed: script.getParsed(),
     105              :             analyzed: script.getAnalyzed(),
     106              :         };
     107              :     } catch (e: unknown) {
     108              :         reportError(e);
     109              :     }
     110              :     return { parsed: null, analyzed: null };
     111              : }
     112              : 
     113            0 : function editorUpdateMessage(update: dashql.buffers.editor.EditorUpdateT): string {
     114            0 :     return typeof update.statusMessage === 'string' && update.statusMessage.length > 0
     115            0 :         ? update.statusMessage
     116            0 :         : `editor update failed with status ${update.status}`;
     117            0 : }
     118              : 
     119            1 : function eventAction(transaction: Transaction): dashql.buffers.editor.EditorEventAction {
     120            1 :     const userEvent = transaction.annotation(Transaction.userEvent);
     121            1 :     if (userEvent?.startsWith('input.type')) return dashql.buffers.editor.EditorEventAction.TYPE;
     122            1 :     if (userEvent?.startsWith('input.paste')) return dashql.buffers.editor.EditorEventAction.PASTE;
     123            1 :     if (userEvent?.startsWith('delete.')) return dashql.buffers.editor.EditorEventAction.DELETE;
     124            1 :     if (userEvent === 'undo') return dashql.buffers.editor.EditorEventAction.UNDO;
     125            1 :     if (userEvent === 'redo') return dashql.buffers.editor.EditorEventAction.REDO;
     126            1 :     return dashql.buffers.editor.EditorEventAction.APPLY;
     127            1 : }
     128              : 
     129              : /// Build one portable event from CodeMirror's pre-change ranges and post-change selection.
     130            1 : export function transactionToEditorEvent(
     131            1 :     transaction: Transaction,
     132            1 :     expectedTextVersion: bigint,
     133            1 : ): dashql.buffers.editor.EditorEventT {
     134            1 :     const changes: dashql.buffers.editor.EditorTextChangeT[] = [];
     135            1 :     transaction.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
     136            1 :         changes.push(new dashql.buffers.editor.EditorTextChangeT(
     137            1 :             BigInt(fromA),
     138            1 :             BigInt(toA),
     139            1 :             inserted.sliceString(0),
     140            1 :         ));
     141            1 :     });
     142            1 :     const selection = transaction.newSelection.main;
     143            1 :     const userEvent = transaction.annotation(Transaction.userEvent);
     144            1 :     return new dashql.buffers.editor.EditorEventT(
     145            1 :         expectedTextVersion,
     146            1 :         changes,
     147            1 :         new dashql.buffers.editor.EditorSelectionT(
     148            1 :             BigInt(selection.anchor),
     149            1 :             BigInt(selection.head),
     150            1 :         ),
     151            1 :         userEvent == null
     152            1 :             ? dashql.buffers.editor.EditorEventOrigin.SYSTEM
     153            1 :             : dashql.buffers.editor.EditorEventOrigin.USER,
     154            1 :         transaction.docChanged
     155            1 :             ? dashql.buffers.editor.EditorEventIntent.EDIT
     156            1 :             : dashql.buffers.editor.EditorEventIntent.SELECTION,
     157            1 :         eventAction(transaction),
     158            1 :         transaction.docChanged,
     159            1 :     );
     160            1 : }
     161              : 
     162              : /// Disables completions, e.g. while the editor holds natural language instead of SQL.
     163              : /// Takes effect with the transaction after the one that enables it, abort a running completion along with it.
     164            1 : export const DashQLCompletionDisabled = Facet.define<boolean, boolean>({ combine: values => values.some(v => v) });
     165              : 
     166              : /// Effect to update the state attached to a CodeMirror editor
     167            1 : export const DashQLUpdateEffect: StateEffectType<DashQLProcessorUpdateIn> = StateEffect.define<DashQLProcessorUpdateIn>();
     168              : 
     169              : /// Effect to start a completion
     170            1 : export const DashQLCompletionStartEffect: StateEffectType<null> = StateEffect.define<null>();
     171              : /// Effect to stop a completion without applying
     172            1 : export const DashQLCompletionAbortEffect: StateEffectType<null> = StateEffect.define<null>();
     173              : /// Effect to preview a different candidate
     174            1 : export const DashQLCompletionPreviewCandidateEffect: StateEffectType<number> = StateEffect.define<number>();
     175              : /// Effect to select a completion candidate
     176            1 : export const DashQLCompletionSelectCandidateEffect: StateEffectType<null> = StateEffect.define<null>();
     177              : /// Effect to select a catalog object
     178            1 : export const DashQLCompletionSelectCatalogObjectEffect: StateEffectType<null> = StateEffect.define<null>();
     179              : 
     180              : /// Effect to select the next candidate
     181            1 : export const DashQLCompletionNextCandidateEffect: StateEffectType<null> = StateEffect.define<null>();
     182              : /// Effect to select the previous candidate
     183            1 : export const DashQLCompletionPreviousCandidateEffect: StateEffectType<null> = StateEffect.define<null>();
     184              : /// Effect to select the next candidate variant
     185            1 : export const DashQLCompletionNextCandidateVariantEffect: StateEffectType<null> = StateEffect.define<null>();
     186              : /// Effect to select the previous candidate variant
     187            1 : export const DashQLCompletionPreviousCandidateVariantEffect: StateEffectType<null> = StateEffect.define<null>();
     188              : 
     189              : /// Effect to accept a pending diff (keep the new text, clear the overlay)
     190            1 : export const DashQLDiffAcceptEffect: StateEffectType<null> = StateEffect.define<null>();
     191              : /// Effect to reject a pending diff (restore the prior text, clear the overlay)
     192            1 : export const DashQLDiffRejectEffect: StateEffectType<null> = StateEffect.define<null>();
     193              : 
     194              : // Copy an object if it equals another object
     195            1 : function copyLazily(nextState: DashQLProcessorState, prevState: DashQLProcessorState): DashQLProcessorState {
     196            1 :     return nextState === prevState ? { ...prevState } : nextState;
     197            1 : };
     198              : 
     199              : /// A processor for DashQL scripts
     200            1 : export const DashQLProcessorPlugin: StateField<DashQLProcessorState> = StateField.define<DashQLProcessorState>({
     201              :     // Create the initial state
     202            1 :     create: () => {
     203              :         // By default, the DashQL script is not configured
     204              :         const config: DashQLProcessorState = {
     205            1 :             scriptKey: 0,
     206            1 :             scriptSession: null,
     207            1 :             editorUpdate: null,
     208            1 :             scriptBuffers: null,
     209            1 :             scriptCompletion: null,
     210            1 :             scriptPendingDiff: null,
     211              : 
     212            1 :             derivedFocus: null as SemanticUserFocus | null,
     213            1 :             keywordCase: dashql.buffers.formatting.KeywordCase.LOWER,
     214            1 :             lookupScriptSession: undefined,
     215            1 :             onNavigateToScript: undefined,
     216              : 
     217            1 :             onUpdate: () => { },
     218              :         };
     219            1 :         return config;
     220            1 :     },
     221              :     // Mirror the DashQL state
     222            1 :     update: (prevState: DashQLProcessorState, transaction: Transaction) => {
     223            1 :         let state: DashQLProcessorState = prevState;
     224              : 
     225              :         // Did the selection change?
     226            1 :         const prevSelection = transaction.startState.selection.asSingle();
     227            1 :         const newSelection = transaction.newSelection.asSingle();
     228            1 :         const selectionChanged = !prevSelection.eq(newSelection);
     229            1 :         const selection: number | null = newSelection.main.to;
     230              : 
     231              :         // Did the user provide us with a new DashQL script?
     232            1 :         let externalUpdate = false;
     233            1 :         for (const effect of transaction.effects) {
     234              :             // DashQL update effect?
     235            1 :             if (effect.is(DashQLUpdateEffect)) {
     236            1 :                 const keepLocalCompletion = state.scriptSession === effect.value.scriptSession
     237            1 :                     && state.editorUpdate?.stateRevision === effect.value.editorUpdate?.stateRevision;
     238            1 :                 state = {
     239            1 :                     ...state,
     240            1 :                     ...effect.value,
     241            1 :                     keywordCase: effect.value.keywordCase ?? dashql.buffers.formatting.KeywordCase.LOWER,
     242              :                     // Completion is editor-owned interaction state. An asynchronous session
     243              :                     // round-trip must not resurrect a completion that was locally dismissed.
     244            1 :                     scriptCompletion: keepLocalCompletion
     245            1 :                         ? state.scriptCompletion
     246            1 :                         : effect.value.scriptCompletion,
     247            1 :                 };
     248              : 
     249              :                 // Script changed?
     250              :                 // Signaled either through a completely new script or through a new script buffer
     251            1 :                 if (
     252            1 :                     prevState.scriptSession !== state.scriptSession ||
     253            1 :                     prevState.editorUpdate?.stateRevision !== state.editorUpdate?.stateRevision
     254              :                 ) {
     255            1 :                     return state;
     256              :                 }
     257              : 
     258              :                 // Is a redundant update?
     259            1 :                 const redundantUpdate = prevState.scriptSession == effect.value.scriptSession
     260            1 :                     && prevState.editorUpdate?.stateRevision == effect.value.editorUpdate?.stateRevision
     261            1 :                     && prevState.scriptPendingDiff == effect.value.scriptPendingDiff
     262            1 :                     && prevState.derivedFocus == effect.value.derivedFocus
     263            1 :                     && prevState.keywordCase == state.keywordCase
     264            1 :                     && !transaction.docChanged
     265            1 :                     && !selectionChanged;
     266              : 
     267            1 :                 if (redundantUpdate) {
     268            1 :                     return prevState;
     269              :                 }
     270            1 :                 if (prevState.keywordCase !== state.keywordCase && state.scriptCompletion != null) {
     271            1 :                     state = tryStartCompletion(
     272            1 :                         state, prevState,
     273            1 :                         state.scriptSession?.completeAtCursor(DASHQL_COMPLETION_LIMIT, state.keywordCase) ?? null,
     274            1 :                         transaction.newDoc, Number(state.editorUpdate?.primaryCursorState?.textOffset ?? 0),
     275            1 :                     );
     276              :                     // Persist the new buffer so the session can release the superseded one.
     277            1 :                     state.onUpdate(state);
     278            1 :                     return state;
     279            1 :                 }
     280            1 :                 externalUpdate = true;
     281            1 :             }
     282              :         }
     283              : 
     284              :         // No editor session at all?
     285              :         // Then abort early, nothing to do here
     286            1 :         if (state.scriptSession == null) {
     287            1 :             const hasProjectedEditorUpdate = transaction.effects.some(effect =>
     288            1 :                 effect.is(DashQLUpdateEffect) && effect.value.editorUpdate != null
     289            1 :             );
     290            1 :             const isReadOnly = transaction.startState.facet(EditorState.readOnly);
     291            1 :             if ((transaction.docChanged || selectionChanged) && !hasProjectedEditorUpdate && !isReadOnly) {
     292            1 :                 console.warn(`${LOG_PREFIX} ignored CodeMirror transaction without editor session`, {
     293            1 :                     scriptKey: state.scriptKey,
     294            1 :                     docChanged: transaction.docChanged,
     295            1 :                     selectionChanged,
     296            1 :                     documentLength: transaction.newDoc.length,
     297            1 :                     cursorOffset: selection,
     298            1 :                 });
     299              :             }
     300            1 :             return state;
     301            1 :         }
     302              : 
     303              :         // Mirror the entire CodeMirror transaction through one portable editor event. Change ranges
     304              :         // are measured in the pre-change document; the selection is measured in the new document.
     305            1 :         if (transaction.docChanged || selectionChanged || state.editorUpdate?.primaryCursorState == null) {
     306            1 :             state = copyLazily(state, prevState);
     307            1 :             const scriptSession = state.scriptSession!;
     308            1 :             const expectedTextVersion = scriptSession.getTextVersion();
     309            1 :             const update = scriptSession.apply(transactionToEditorEvent(
     310            1 :                 transaction,
     311            1 :                 expectedTextVersion,
     312            1 :             ));
     313            1 :             console.debug(`${LOG_PREFIX} applied CodeMirror transaction`, {
     314            1 :                 scriptKey: state.scriptKey,
     315            1 :                 docChanged: transaction.docChanged,
     316            1 :                 selectionChanged,
     317            1 :                 expectedTextVersion,
     318            1 :                 resultTextVersion: update.textVersion,
     319            1 :                 resultStateRevision: update.stateRevision,
     320            1 :                 status: update.status,
     321            1 :                 statusMessage: update.statusMessage,
     322            1 :                 analysisAvailable: update.analysisAvailable,
     323            1 :                 analysisUpdated: update.analysisUpdated,
     324            1 :                 documentLength: transaction.newDoc.length,
     325            1 :                 cursorOffset: selection,
     326            1 :             });
     327            1 :             if (update.status !== dashql.buffers.editor.EditorUpdateStatus.OK) {
     328            1 :                 console.error(`${LOG_PREFIX} native editor session rejected CodeMirror transaction`, {
     329            1 :                     scriptKey: state.scriptKey,
     330            1 :                     expectedTextVersion,
     331            1 :                     status: update.status,
     332            1 :                     statusMessage: update.statusMessage,
     333            1 :                 });
     334            1 :                 throw new Error(editorUpdateMessage(update));
     335            1 :             }
     336            1 :             state.editorUpdate = update;
     337            1 :             state.derivedFocus = state.scriptCompletion == null
     338            1 :                 ? deriveFocusFromEditorUpdate(state.scriptKey, update)
     339            1 :                 : state.derivedFocus;
     340            1 :             if (transaction.docChanged) state.scriptBuffers = null;
     341            1 :         }
     342              : 
     343              :         // Check additional completion effects
     344            1 :         state = updateCompletion(state, prevState, transaction);
     345              : 
     346              :         // Check pending-diff effects (accept / reject) and auto-accept on genuine user edits
     347            1 :         state = updateDiff(state, prevState, transaction, externalUpdate);
     348              : 
     349              :         // Did anything change?
     350              :         // Then tell the user about it.
     351              :         // It's the responsibility of the user to persist anything here and cleanup whatever is now dead.
     352              :         // We cannot do that on behalf of the user since CodeMirror lacks "destroy" lifecycle hooks.
     353            1 :         if (prevState !== state && !externalUpdate) {
     354            1 :             console.debug(`${LOG_PREFIX} emitting processor update`, {
     355            1 :                 scriptKey: state.scriptKey,
     356            1 :                 textVersion: state.editorUpdate?.textVersion,
     357            1 :                 stateRevision: state.editorUpdate?.stateRevision,
     358            1 :                 analysisAvailable: state.editorUpdate?.analysisAvailable,
     359            1 :                 analysisUpdated: state.editorUpdate?.analysisUpdated,
     360            1 :                 cursorOffset: state.editorUpdate?.primaryCursorState?.textOffset?.toString(),
     361            1 :             });
     362            1 :             state.onUpdate(state);
     363            1 :         }
     364            1 :         return state;
     365            1 :     },
     366            1 : });
     367              : 
     368              : // Helper to start a completion
     369            1 : function tryStartCompletion(state: DashQLProcessorState, prevState: DashQLProcessorState, buffer: dashql.FlatBufferPtr<dashql.buffers.completion.Completion> | null, text: Text, cursor: number) {
     370            1 :     if (!buffer) {
     371            0 :         return state;
     372              :     }
     373            1 :     if (buffer.read().candidatesLength() == 0) {
     374              :         // No candidates?
     375              :         // Drop the completion...
     376            1 :         state = copyLazily(state, prevState);
     377            1 :         state.scriptCompletion = null;
     378            1 :     } else {
     379              :         // Mark the new completion available
     380            1 :         state = copyLazily(state, prevState);
     381            1 :         const completionBuffer = buffer.read();
     382            1 :         state.scriptCompletion = {
     383            1 :             status: DashQLCompletionStatus.AVAILABLE,
     384            1 :             passiveHint: isPassiveHint(completionBuffer),
     385            1 :             buffer: buffer,
     386            1 :             candidateId: 0,
     387            1 :             candidatePatch: [],
     388            1 :             catalogObjectId: 0,
     389            1 :             catalogObjectPatch: [],
     390            1 :             catalogObjectCursorOffset: null,
     391            1 :         };
     392            1 :         state.scriptCompletion = computePatches(state.scriptCompletion, text, cursor, UpdatePatchStartingFrom.Candidate);
     393            1 :     }
     394            1 :     return state;
     395            1 : };
     396              : 
     397            1 : function isPassiveHint(buffer: dashql.buffers.completion.Completion): boolean {
     398            1 :     const count = buffer.candidatesLength();
     399            1 :     if (count === 0) return false;
     400            1 :     const first = buffer.candidates(0)!;
     401            1 :     const targetLoc = first.targetLocation();
     402            1 :     return targetLoc != null && targetLoc.length() === 0;
     403            1 : }
     404              : 
     405              : // Helper to determine if a user event triggers completions.
     406              : // For events, refer to https://codemirror.net/docs/ref/
     407            1 : function cursorUtf16Offset(state: DashQLProcessorState): number {
     408            1 :     return Number(state.editorUpdate?.primaryCursorState?.textOffset ?? 0);
     409            1 : }
     410              : 
     411            1 : function typingAtTokenStart(transaction: Transaction, prevState: DashQLProcessorState) {
     412            1 :     const cursor = prevState.editorUpdate?.primaryCursorState;
     413            1 :     return transaction.isUserEvent("input.type")
     414            1 :         && transaction.startState.selection.main.empty
     415            1 :         && cursor?.scannerRelativePosition === dashql.buffers.cursor.RelativeSymbolPosition.BEGIN_OF_SYMBOL
     416            1 :         && cursor.scannerSymbolCompletable;
     417            1 : }
     418              : 
     419            1 : function backwardDeleteInsideCompletableToken(state: DashQLProcessorState) {
     420            1 :     const portable = state.editorUpdate?.primaryCursorState;
     421            1 :     switch (portable?.scannerRelativePosition) {
     422            0 :         case dashql.buffers.cursor.RelativeSymbolPosition.MID_OF_SYMBOL:
     423            1 :         case dashql.buffers.cursor.RelativeSymbolPosition.END_OF_SYMBOL:
     424            1 :             return portable.scannerSymbolCompletable;
     425            1 :         default:
     426            1 :             return false;
     427            1 :     }
     428            1 : }
     429              : 
     430            1 : function userEventCanStartCompletion(transaction: Transaction, prevState: DashQLProcessorState) {
     431            1 :     switch (transaction.annotation(Transaction.userEvent)) {
     432            1 :         case "delete.selection":
     433              :             // Deleting a selection before the cursor can leave the same token under the caret.
     434            1 :             return transaction.changes.mapPos(cursorUtf16Offset(prevState)) !== transaction.newSelection.main.head
     435            1 :                 || backwardDeleteInsideCompletableToken(prevState);
     436            0 :         case "delete.forward":
     437            0 :             return true;
     438            1 :         case "delete.backward":
     439            1 :             return backwardDeleteInsideCompletableToken(prevState);
     440            0 :         case "input.paste":
     441            0 :         case "delete.cut":
     442            0 :         case "input.drop":
     443            0 :             return false;
     444            1 :     }
     445            1 :     return transaction.isUserEvent("input.type") && !typingAtTokenStart(transaction, prevState);
     446            1 : }
     447              : 
     448              : 
     449              : // Helper to update a completion based on a transaction
     450            1 : function updateCompletion(state: DashQLProcessorState, prevState: DashQLProcessorState, transaction: Transaction): DashQLProcessorState {
     451              :     // We need a script and script cursor to complete.
     452            1 :     if (!state.scriptSession || !state.editorUpdate?.primaryCursorState) {
     453            1 :         return state;
     454              :     }
     455            1 :     if (transaction.startState.facet(DashQLCompletionDisabled)) {
     456            1 :         if (state.scriptCompletion != null) {
     457            1 :             state = copyLazily(state, prevState);
     458            1 :             state.scriptCompletion = null;
     459            1 :         }
     460            1 :         return state;
     461            1 :     }
     462            1 :     const scriptSession = state.scriptSession;
     463            1 :     const cursorOffset = cursorUtf16Offset(state);
     464              : 
     465              :     // Check additional completion effects
     466            1 :     for (const effect of transaction.effects) {
     467            1 :         if (effect.is(DashQLCompletionStartEffect)) {
     468            1 :             const buffer = scriptSession.completeAtCursor(DASHQL_COMPLETION_LIMIT, state.keywordCase);
     469            1 :             state = tryStartCompletion(state, prevState, buffer, transaction.newDoc, cursorOffset);
     470            1 :             continue;
     471              : 
     472            1 :         }
     473              : 
     474              :         // All other effects require an active completion
     475            1 :         if (state.scriptCompletion == null) {
     476            1 :             continue;
     477              :         }
     478            1 :         const completionBuffer = state.scriptCompletion.buffer.read();
     479              : 
     480            1 :         const resetCompletion = () => {
     481            1 :             state = copyLazily(state, prevState);
     482            1 :             state.scriptCompletion = null;
     483            1 :         };
     484              : 
     485            1 :         if (effect.is(DashQLCompletionAbortEffect)) {
     486            1 :             resetCompletion();
     487            1 :             break;
     488              : 
     489            1 :         } else if (effect.is(DashQLCompletionNextCandidateEffect) && state.scriptCompletion) {
     490            1 :             state = copyLazily(state, prevState);
     491            1 :             state.scriptCompletion = { ...state.scriptCompletion! };
     492            1 :             const c = state.scriptCompletion?.buffer.read();
     493            1 :             const candidateCount = c?.candidatesLength() ?? 0;
     494            1 :             if (candidateCount > 1 && state.scriptCompletion?.candidateId !== undefined) {
     495            1 :                 state.scriptCompletion.candidateId += 1;
     496            1 :                 state.scriptCompletion.catalogObjectId = 0;
     497            1 :                 if (state.scriptCompletion.candidateId == candidateCount) {
     498            1 :                     state.scriptCompletion.candidateId = 0;
     499              :                 }
     500            1 :                 state.scriptCompletion = computePatches(state.scriptCompletion, transaction.newDoc, transaction.newSelection.main.anchor, UpdatePatchStartingFrom.Candidate)
     501            1 :             }
     502            1 :             break;
     503              : 
     504            1 :         } else if (effect.is(DashQLCompletionPreviousCandidateEffect) && state.scriptCompletion) {
     505            1 :             state = copyLazily(state, prevState);
     506            1 :             state.scriptCompletion = { ...state.scriptCompletion! };
     507            1 :             const c = state.scriptCompletion?.buffer.read();
     508            1 :             const candidateCount = c?.candidatesLength() ?? 0;
     509            1 :             if (candidateCount > 1 && state.scriptCompletion?.candidateId !== undefined) {
     510            1 :                 state.scriptCompletion.candidateId -= 1;
     511            1 :                 state.scriptCompletion.catalogObjectId = 0;
     512            1 :                 if (state.scriptCompletion.candidateId < 0) {
     513            1 :                     state.scriptCompletion.candidateId = candidateCount - 1;
     514              :                 }
     515            1 :                 state.scriptCompletion = computePatches(state.scriptCompletion, transaction.newDoc, transaction.newSelection.main.anchor, UpdatePatchStartingFrom.Candidate)
     516            1 :             }
     517            1 :             break;
     518              : 
     519            1 :         } else if (effect.is(DashQLCompletionNextCandidateVariantEffect) && state.scriptCompletion) {
     520            1 :             state = copyLazily(state, prevState);
     521            1 :             state.scriptCompletion = { ...state.scriptCompletion! };
     522            1 :             const completion = state.scriptCompletion?.buffer.read();
     523            1 :             const candidate = completion.candidates(state.scriptCompletion.candidateId);
     524            1 :             const objectCount = candidate?.catalogObjectsLength() ?? 0;
     525            1 :             if (objectCount > 1 && state.scriptCompletion.catalogObjectId !== undefined) {
     526            1 :                 state.scriptCompletion.catalogObjectId += 1;
     527            1 :                 if (state.scriptCompletion.catalogObjectId == objectCount) {
     528            1 :                     state.scriptCompletion.catalogObjectId = 0;
     529              :                 }
     530            1 :                 state.scriptCompletion = computePatches(state.scriptCompletion, transaction.newDoc, transaction.newSelection.main.anchor, UpdatePatchStartingFrom.CatalogObject)
     531            1 :             }
     532            1 :             break;
     533              : 
     534            1 :         } else if (effect.is(DashQLCompletionPreviousCandidateVariantEffect) && state.scriptCompletion) {
     535            1 :             state = copyLazily(state, prevState);
     536            1 :             state.scriptCompletion = { ...state.scriptCompletion! };
     537            1 :             const completion = state.scriptCompletion?.buffer.read();
     538            1 :             const candidate = completion.candidates(state.scriptCompletion.candidateId);
     539            1 :             const objectCount = candidate?.catalogObjectsLength() ?? 0;
     540            1 :             if (objectCount > 1 && state.scriptCompletion.catalogObjectId !== undefined) {
     541            1 :                 state.scriptCompletion.catalogObjectId -= 1;
     542            1 :                 if (state.scriptCompletion.catalogObjectId < 0) {
     543            1 :                     state.scriptCompletion.catalogObjectId = objectCount - 1;
     544              :                 }
     545            1 :                 state.scriptCompletion = computePatches(state.scriptCompletion, transaction.newDoc, transaction.newSelection.main.anchor, UpdatePatchStartingFrom.CatalogObject)
     546            1 :             }
     547            1 :             break;
     548              : 
     549            1 :         } else if (effect.is(DashQLCompletionPreviewCandidateEffect)) {
     550              :             // Effect to switch the previews completion candidate
     551            1 :             if (state.scriptCompletion.status == DashQLCompletionStatus.AVAILABLE && !transaction.docChanged) {
     552              :                 // XXX This is not correct, we need to update the ids
     553            1 :                 state = tryStartCompletion(state, prevState, state.scriptCompletion.buffer, transaction.newDoc, cursorOffset);
     554            1 :             } else {
     555            1 :                 resetCompletion();
     556            1 :                 break;
     557            1 :             }
     558              : 
     559            1 :         } else if (effect.is(DashQLCompletionSelectCandidateEffect)) {
     560              :             // Clear completion if the candidate index is invalid
     561            1 :             if (state.scriptCompletion.candidateId >= completionBuffer.candidatesLength()) {
     562            1 :                 resetCompletion();
     563            1 :                 break;
     564            1 :             }
     565            1 :             state = copyLazily(state, prevState);
     566            1 :             state.scriptCompletion = {
     567            1 :                 ...state.scriptCompletion!,
     568            1 :                 status: DashQLCompletionStatus.SELECTED_CANDIDATE,
     569            1 :                 candidatePatch: [],
     570            1 :                 catalogObjectPatch: [],
     571            1 :                 catalogObjectCursorOffset: null,
     572            1 :             };
     573            1 :             state.scriptCompletion = computePatches(state.scriptCompletion, transaction.newDoc, transaction.newSelection.main.anchor, UpdatePatchStartingFrom.CatalogObject);
     574              : 
     575            1 :         } else if (effect.is(DashQLCompletionSelectCatalogObjectEffect)) {
     576              :             // Clear completion if the candidate index is invalid
     577            1 :             if (state.scriptCompletion.candidateId >= completionBuffer.candidatesLength()) {
     578            1 :                 resetCompletion();
     579            1 :                 break;
     580            1 :             }
     581              :             // Clear completion if the catalog object is invalid
     582            1 :             const ca = completionBuffer.candidates(state.scriptCompletion.candidateId)!;
     583            1 :             if (state.scriptCompletion.catalogObjectId >= ca.catalogObjectsLength()) {
     584            1 :                 resetCompletion();
     585            1 :                 break;
     586            1 :             }
     587              : 
     588            1 :             state = copyLazily(state, prevState);
     589            1 :             state.scriptCompletion = {
     590            1 :                 ...state.scriptCompletion!,
     591            1 :                 status: DashQLCompletionStatus.SELECTED_CATALOG_OBJECT,
     592            1 :                 candidatePatch: [],
     593            1 :                 catalogObjectPatch: [],
     594            1 :                 catalogObjectCursorOffset: null,
     595            1 :             };
     596              : 
     597            1 :         }
     598            1 :     }
     599              : 
     600            1 :     if (transaction.docChanged && state.scriptCompletion == prevState.scriptCompletion) {
     601            1 :         if (typingAtTokenStart(transaction, prevState)) {
     602            1 :             state = copyLazily(state, prevState);
     603            1 :             state.scriptCompletion = null;
     604            1 :             return state;
     605            1 :         }
     606            1 :         if (transaction.isUserEvent("delete.backward") && !backwardDeleteInsideCompletableToken(prevState)) {
     607            1 :             state = copyLazily(state, prevState);
     608            1 :             state.scriptCompletion = null;
     609            1 :             return state;
     610            1 :         }
     611              : 
     612              :         // We don't have an ongoing completion and there is a new user-input?
     613              :         // Get a completion going.
     614            1 :         const noActiveCompletion = !state.scriptCompletion || state.scriptCompletion.status != DashQLCompletionStatus.AVAILABLE;
     615            1 :         if (noActiveCompletion && userEventCanStartCompletion(transaction, prevState)) {
     616            1 :             const buffer = scriptSession.completeAtCursor(DASHQL_COMPLETION_LIMIT, state.keywordCase);
     617            1 :             state = tryStartCompletion(state, prevState, buffer, transaction.newDoc, cursorOffset);
     618            1 :         }
     619              : 
     620              :         // Doc changed, there is a completion, and the completion did not change?
     621              :         // Update the completion if it's ongoing, clear it if it is completed.
     622            1 :         else if (state.scriptCompletion != null) {
     623            1 :             switch (state.scriptCompletion.status) {
     624            1 :                 case DashQLCompletionStatus.AVAILABLE:
     625              :                     // If a delete left the cursor between tokens, the word the hint was
     626              :                     // anchored on is gone. Clear the completion instead of regenerating it.
     627            1 :                     const newRelPos = state.editorUpdate?.primaryCursorState?.scannerRelativePosition;
     628            1 :                     const isDelete = transaction.annotation(Transaction.userEvent)?.startsWith("delete.") ?? false;
     629            1 :                     const cursorBetweenTokens =
     630            1 :                         newRelPos === dashql.buffers.cursor.RelativeSymbolPosition.AFTER_SYMBOL ||
     631            1 :                         newRelPos === dashql.buffers.cursor.RelativeSymbolPosition.BEFORE_SYMBOL;
     632            1 :                     if (isDelete && cursorBetweenTokens) {
     633            1 :                         state = copyLazily(state, prevState);
     634            1 :                         state.scriptCompletion = null;
     635            1 :                         break;
     636            1 :                     }
     637            1 :                     const buffer = scriptSession.completeAtCursor(DASHQL_COMPLETION_LIMIT, state.keywordCase);
     638            1 :                     state = tryStartCompletion(state, prevState, buffer, transaction.newDoc, cursorOffset);
     639            1 :                     break;
     640            1 :                 default:
     641            1 :                     state = copyLazily(state, prevState);
     642            1 :                     state.scriptCompletion = null;
     643              :                     break;
     644            1 :             }
     645              :         }
     646            1 :     }
     647            1 :     return state;
     648            1 : }
     649              : 
     650              : // Helper to update the pending diff based on a transaction.
     651              : //
     652              : // Handles the explicit accept/reject effects and auto-accepts as soon as the user genuinely edits
     653              : // the document. The transaction that *starts* a diff carries new script buffers via
     654              : // `DashQLUpdateEffect` and returns early (the projection-revision guard in `update`) before this runs,
     655              : // so here we only ever see later user edits or accept/reject effects.
     656            1 : function updateDiff(state: DashQLProcessorState, prevState: DashQLProcessorState, transaction: Transaction, externalUpdate: boolean): DashQLProcessorState {
     657              :     // Handle the explicit accept / reject effects.
     658            1 :     for (const effect of transaction.effects) {
     659            1 :         if (effect.is(DashQLDiffAcceptEffect) || effect.is(DashQLDiffRejectEffect)) {
     660              :             // Clearing the pending diff drops the overlay. Accept keeps the current (new) text;
     661              :             // reject relies on the dispatcher having restored the prior text via `changes` in the
     662              :             // same transaction (already mirrored to the rope by the docChanged branch in `update`).
     663              :             // The FlatBufferPtr is owned by the session's ScriptData and freed there once the
     664              :             // cleared state round-trips back through UPDATE_FROM_PROCESSOR.
     665            1 :             if (state.scriptPendingDiff != null) {
     666            1 :                 state = copyLazily(state, prevState);
     667            1 :                 state.scriptPendingDiff = null;
     668            1 :             }
     669            1 :             return state;
     670            1 :         }
     671              :     }
     672              : 
     673              :     // No pending diff? Nothing to auto-accept.
     674            1 :     if (state.scriptPendingDiff == null) {
     675            1 :         return state;
     676              :     }
     677              : 
     678              :     // Auto-accept as soon as the user genuinely edits the document. User-originated doc changes
     679              :     // always carry a userEvent annotation; an external text replacement does not (and is
     680              :     // additionally guarded by `externalUpdate`), so staging a rewrite never self-accepts.
     681            1 :     if (transaction.docChanged && !externalUpdate && transaction.annotation(Transaction.userEvent) != null) {
     682            1 :         state = copyLazily(state, prevState);
     683            1 :         state.scriptPendingDiff = null;
     684            1 :     }
     685            1 :     return state;
     686            1 : }
        

Generated by: LCOV version 2.0-1