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