|
| 1 | +import { LabIcon } from "@jupyterlab/ui-components"; |
| 2 | +import { |
| 3 | + CompletionTriggerKind, |
| 4 | + ICompletionContext, |
| 5 | + ICompletionProvider, |
| 6 | + ICompletionRequest, |
| 7 | + ICompletionSettings, |
| 8 | + ICompletionsReply, |
| 9 | + IExtendedCompletionItem, |
| 10 | + IIconSource |
| 11 | +} from "./tokens"; |
| 12 | +import { CodeEditor } from "@jupyterlab/codeeditor"; |
| 13 | +import { CompletionHandler } from "@jupyterlab/completer"; |
| 14 | +import ICompletionItemsResponseType = CompletionHandler.ICompletionItemsResponseType; |
| 15 | +import ICompletionItemsReply = CompletionHandler.ICompletionItemsReply; |
| 16 | + |
| 17 | +export interface IMultiSourceCompletionConnectorOptions { |
| 18 | + iconSource: IIconSource; |
| 19 | + providers: ICompletionProvider[]; |
| 20 | + settings: ICompletionSettings; |
| 21 | + context: ICompletionContext; |
| 22 | +} |
| 23 | + |
| 24 | +interface IReplyWithProvider extends ICompletionsReply { |
| 25 | + provider: ICompletionProvider; |
| 26 | +} |
| 27 | + |
| 28 | +export class MultiSourceCompletionConnector implements CompletionHandler.ICompletionItemsConnector { |
| 29 | + |
| 30 | + // signal that this is the new type connector (providing completion items) |
| 31 | + responseType = ICompletionItemsResponseType; |
| 32 | + triggerKind: CompletionTriggerKind; |
| 33 | + |
| 34 | + constructor(protected options: IMultiSourceCompletionConnectorOptions) { |
| 35 | + } |
| 36 | + |
| 37 | + protected get suppress_continuous_hinting_in(): string[] { |
| 38 | + return this.options.settings.suppressContinuousHintingIn; |
| 39 | + } |
| 40 | + |
| 41 | + protected get suppress_trigger_character_in(): string[] { |
| 42 | + return this.options.settings.suppressTriggerCharacterIn; |
| 43 | + } |
| 44 | + |
| 45 | + async fetch( |
| 46 | + request: CompletionHandler.IRequest |
| 47 | + ): Promise<CompletionHandler.ICompletionItemsReply> { |
| 48 | + |
| 49 | + const editor = this.options.context.editor; |
| 50 | + const cursor = editor.getCursorPosition(); |
| 51 | + const token = editor.getTokenForPosition(cursor); |
| 52 | + |
| 53 | + if (this.triggerKind == CompletionTriggerKind.AutoInvoked) { |
| 54 | + if (this.suppress_continuous_hinting_in.indexOf(token.type) !== -1) { |
| 55 | + console.debug('Suppressing completer auto-invoke in', token.type); |
| 56 | + return; |
| 57 | + } |
| 58 | + } else if (this.triggerKind == CompletionTriggerKind.TriggerCharacter) { |
| 59 | + if (this.suppress_trigger_character_in.indexOf(token.type) !== -1) { |
| 60 | + console.debug('Suppressing completer auto-invoke in', token.type); |
| 61 | + return; |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + const promises: Promise<IReplyWithProvider>[] = []; |
| 66 | + |
| 67 | + for (const provider of this.options.providers) { |
| 68 | + const providerSettings = this.options.settings.providers[provider.identifier]; |
| 69 | + if (!providerSettings.enabled) { |
| 70 | + continue; |
| 71 | + } |
| 72 | + |
| 73 | + const wrappedRequest: ICompletionRequest = {triggerKind: this.triggerKind, ...request}; |
| 74 | + |
| 75 | + await provider.isApplicable(wrappedRequest, this.options.context); |
| 76 | + |
| 77 | + let promise = provider.fetch( |
| 78 | + wrappedRequest, |
| 79 | + this.options.context |
| 80 | + ).then(reply => { |
| 81 | + return { |
| 82 | + provider: provider, |
| 83 | + ...reply |
| 84 | + } |
| 85 | + }); |
| 86 | + |
| 87 | + const timeout = providerSettings.timeout; |
| 88 | + |
| 89 | + if (timeout != -1) { |
| 90 | + // implement timeout for the kernel response using Promise.race: |
| 91 | + // an empty completion result will resolve after the timeout |
| 92 | + // if actual kernel response does not beat it to it |
| 93 | + const timeoutPromise = new Promise<IReplyWithProvider>(resolve => { |
| 94 | + return setTimeout( |
| 95 | + () => |
| 96 | + resolve(null), |
| 97 | + timeout |
| 98 | + ); |
| 99 | + }) |
| 100 | + |
| 101 | + promise = Promise.race([promise, timeoutPromise]); |
| 102 | + } |
| 103 | + |
| 104 | + promises.push(promise.catch(p => p)); |
| 105 | + } |
| 106 | + |
| 107 | + const combinedPromise: Promise<ICompletionsReply> = Promise.all(promises).then(replies => { |
| 108 | + return this.mergeReplies(replies.filter(reply => reply != null), this.options.context.editor); |
| 109 | + }); |
| 110 | + |
| 111 | + return combinedPromise.then(reply => { |
| 112 | + const transformedReply = this.suppressIfNeeded(reply, token, cursor); |
| 113 | + this.triggerKind = CompletionTriggerKind.Invoked; |
| 114 | + return transformedReply; |
| 115 | + }); |
| 116 | + } |
| 117 | + |
| 118 | + private iconFor(type: string): LabIcon { |
| 119 | + return (this.options.iconSource.iconFor(type) as LabIcon) || undefined; |
| 120 | + } |
| 121 | + |
| 122 | + protected mergeReplies( |
| 123 | + replies: IReplyWithProvider[], |
| 124 | + editor: CodeEditor.IEditor |
| 125 | + ): ICompletionsReply { |
| 126 | + console.debug('Merging completions:', replies); |
| 127 | + |
| 128 | + replies = replies.filter(reply => { |
| 129 | + if (reply instanceof Error) { |
| 130 | + console.warn( |
| 131 | + `Caught ${reply.source.name} completions error`, |
| 132 | + reply |
| 133 | + ); |
| 134 | + return false; |
| 135 | + } |
| 136 | + // ignore if no matches |
| 137 | + if (!reply.items.length) { |
| 138 | + return false; |
| 139 | + } |
| 140 | + // otherwise keep |
| 141 | + return true; |
| 142 | + }); |
| 143 | + |
| 144 | + // TODO: why sort? should not use sortText instead? |
| 145 | + replies.sort((a, b) => b.source.priority - a.source.priority); |
| 146 | + |
| 147 | + console.debug('Sorted replies:', replies); |
| 148 | + |
| 149 | + const minEnd = Math.min(...replies.map(reply => reply.end)); |
| 150 | + |
| 151 | + // if any of the replies uses a wider range, we need to align them |
| 152 | + // so that all responses use the same range |
| 153 | + const minStart = Math.min(...replies.map(reply => reply.start)); |
| 154 | + const maxStart = Math.max(...replies.map(reply => reply.start)); |
| 155 | + |
| 156 | + if (minStart != maxStart) { |
| 157 | + const cursor = editor.getCursorPosition(); |
| 158 | + const line = editor.getLine(cursor.line); |
| 159 | + |
| 160 | + replies = replies.map(reply => { |
| 161 | + // no prefix to strip, return as-is |
| 162 | + if (reply.start == maxStart) { |
| 163 | + return reply; |
| 164 | + } |
| 165 | + let prefix = line.substring(reply.start, maxStart); |
| 166 | + console.debug(`Removing ${reply.source.name} prefix: `, prefix); |
| 167 | + return { |
| 168 | + ...reply, |
| 169 | + items: reply.items.map(item => { |
| 170 | + item.insertText = item.insertText.startsWith(prefix) |
| 171 | + ? item.insertText.substr(prefix.length) |
| 172 | + : item.insertText; |
| 173 | + return item; |
| 174 | + }) |
| 175 | + }; |
| 176 | + }); |
| 177 | + } |
| 178 | + |
| 179 | + const insertTextSet = new Set<string>(); |
| 180 | + const processedItems = new Array<IExtendedCompletionItem>(); |
| 181 | + |
| 182 | + for (const reply of replies) { |
| 183 | + reply.items.forEach(item => { |
| 184 | + // trimming because: |
| 185 | + // IPython returns 'import' and 'import '; while the latter is more useful, |
| 186 | + // user should not see two suggestions with identical labels and nearly-identical |
| 187 | + // behaviour as they could not distinguish the two either way |
| 188 | + let text = item.insertText.trim(); |
| 189 | + if (insertTextSet.has(text)) { |
| 190 | + return; |
| 191 | + } |
| 192 | + insertTextSet.add(text); |
| 193 | + // extra processing (adding icon/source name) is delayed until |
| 194 | + // we are sure that the item will be kept (as otherwise it could |
| 195 | + // lead to processing hundreds of suggestions - e.g. from numpy |
| 196 | + // multiple times if multiple sources provide them). |
| 197 | + let processedItem = item as IExtendedCompletionItem; |
| 198 | + processedItem.source = reply.source; |
| 199 | + processedItem.provider = reply.provider; |
| 200 | + if (!processedItem.icon) { |
| 201 | + // try to get icon based on type or use source fallback if no icon matched |
| 202 | + processedItem.icon = this.iconFor(processedItem.type) || reply.source.fallbackIcon; |
| 203 | + } |
| 204 | + processedItems.push(processedItem); |
| 205 | + }); |
| 206 | + } |
| 207 | + |
| 208 | + // Return reply with processed items. |
| 209 | + console.debug('Merged: ', processedItems); |
| 210 | + return { |
| 211 | + start: maxStart, |
| 212 | + end: minEnd, |
| 213 | + source: null, |
| 214 | + items: processedItems |
| 215 | + }; |
| 216 | + } |
| 217 | + |
| 218 | + list( |
| 219 | + query: string | undefined |
| 220 | + ): Promise<{ |
| 221 | + ids: CompletionHandler.IRequest[]; |
| 222 | + values: CompletionHandler.ICompletionItemsReply[]; |
| 223 | + }> { |
| 224 | + return Promise.resolve(undefined); |
| 225 | + } |
| 226 | + |
| 227 | + remove(id: CompletionHandler.IRequest): Promise<any> { |
| 228 | + return Promise.resolve(undefined); |
| 229 | + } |
| 230 | + |
| 231 | + save(id: CompletionHandler.IRequest, value: void): Promise<any> { |
| 232 | + return Promise.resolve(undefined); |
| 233 | + } |
| 234 | + |
| 235 | + |
| 236 | + private suppressIfNeeded( |
| 237 | + reply: ICompletionsReply, |
| 238 | + token: CodeEditor.IToken, |
| 239 | + cursor_at_request: CodeEditor.IPosition |
| 240 | + ): ICompletionItemsReply { |
| 241 | + const editor = this.options.context.editor |
| 242 | + if (!editor.hasFocus()) { |
| 243 | + console.debug( |
| 244 | + 'Ignoring completion response: the corresponding editor lost focus' |
| 245 | + ); |
| 246 | + return { |
| 247 | + start: reply.start, |
| 248 | + end: reply.end, |
| 249 | + items: [] |
| 250 | + }; |
| 251 | + } |
| 252 | + |
| 253 | + const cursor_now = editor.getCursorPosition(); |
| 254 | + |
| 255 | + // if the cursor advanced in the same line, the previously retrieved completions may still be useful |
| 256 | + // if the line changed or cursor moved backwards then no reason to keep the suggestions |
| 257 | + if ( |
| 258 | + cursor_at_request.line != cursor_now.line || |
| 259 | + cursor_now.column < cursor_at_request.column |
| 260 | + ) { |
| 261 | + console.debug( |
| 262 | + 'Ignoring completion response: cursor has receded or changed line' |
| 263 | + ); |
| 264 | + return { |
| 265 | + start: reply.start, |
| 266 | + end: reply.end, |
| 267 | + items: [] |
| 268 | + }; |
| 269 | + } |
| 270 | + |
| 271 | + if (this.triggerKind == CompletionTriggerKind.AutoInvoked) { |
| 272 | + if ( |
| 273 | + // do not auto-invoke if no match found |
| 274 | + reply.start == reply.end || |
| 275 | + // do not auto-invoke if only one match found and this match is exactly the same as the current token |
| 276 | + (reply.items.length === 1 && reply.items[0].insertText === token.value) |
| 277 | + ) { |
| 278 | + return { |
| 279 | + start: reply.start, |
| 280 | + end: reply.end, |
| 281 | + items: [] |
| 282 | + }; |
| 283 | + } |
| 284 | + } |
| 285 | + return reply as ICompletionItemsReply; |
| 286 | + } |
| 287 | +} |
0 commit comments