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