UNPKG

atom-languageclient

Version:
591 lines (548 loc) 25.4 kB
import Convert from "../convert" import * as Utils from "../utils" import { CancellationTokenSource } from "vscode-jsonrpc" import { ActiveServer } from "../server-manager" import { ObjectArrayFilterer } from "zadeh" import { CompletionContext, CompletionItem, CompletionItemKind, CompletionList, CompletionParams, CompletionTriggerKind, InsertTextFormat, InsertReplaceEdit, LanguageClientConnection, Range, ServerCapabilities, TextEdit, } from "../languageclient" import ApplyEditAdapter from "./apply-edit-adapter" import { Point, TextEditor } from "atom" import * as ac from "atom/autocomplete-plus" import { Suggestion, TextSuggestion, SnippetSuggestion, SuggestionBase } from "../types/autocomplete-extended" /** * Defines the behavior of suggestion acceptance. Assume you have "cons|ole" in the editor ( `|` is the cursor position) * and the autocomplete suggestion is `const`. * * - If `false` -> the edits are inserted : const|ole * - If `true`` -> the edits are replaced: const| */ type ShouldReplace = boolean /** * Holds a list of suggestions generated from the CompletionItem[] list sent by the server, as well as metadata about * the context it was collected in */ interface SuggestionCacheEntry { /** If `true`, the server will send a list of suggestions to replace this one */ isIncomplete: boolean /** The point left of the first character in the original prefix sent to the server */ triggerPoint: Point /** The point right of the last character in the original prefix sent to the server */ originalBufferPoint: Point /** The trigger string that caused the autocomplete (if any) */ triggerChar: string suggestionMap: Map<Suggestion, PossiblyResolvedCompletionItem> } type CompletionItemAdjuster = ( item: CompletionItem, suggestion: ac.AnySuggestion, request: ac.SuggestionsRequestedEvent ) => void class PossiblyResolvedCompletionItem { constructor(public completionItem: CompletionItem, public isResolved: boolean) {} } /** Public: Adapts the language server protocol "textDocument/completion" to the Atom AutoComplete+ package. */ export default class AutocompleteAdapter { public static canAdapt(serverCapabilities: ServerCapabilities): boolean { return serverCapabilities.completionProvider != null } public static canResolve(serverCapabilities: ServerCapabilities): boolean { return ( serverCapabilities.completionProvider != null && serverCapabilities.completionProvider.resolveProvider === true ) } private _suggestionCache: WeakMap<ActiveServer, SuggestionCacheEntry> = new WeakMap() private _cancellationTokens: WeakMap<LanguageClientConnection, CancellationTokenSource> = new WeakMap() /** * Public: Obtain suggestion list for AutoComplete+ by querying the language server using the `textDocument/completion` request. * * @param server An {ActiveServer} pointing to the language server to query. * @param request The {atom$AutocompleteRequest} to satisfy. * @param onDidConvertCompletionItem An optional function that takes a {CompletionItem}, an * {atom$AutocompleteSuggestion} and a {atom$AutocompleteRequest} allowing you to adjust converted items. * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}). * @returns A {Promise} of an {Array} of {atom$AutocompleteSuggestion}s containing the AutoComplete+ suggestions to display. */ public async getSuggestions( server: ActiveServer, request: ac.SuggestionsRequestedEvent, onDidConvertCompletionItem?: CompletionItemAdjuster, minimumWordLength?: number, shouldReplace: ShouldReplace = false ): Promise<ac.AnySuggestion[]> { const triggerChars = server.capabilities.completionProvider != null ? server.capabilities.completionProvider.triggerCharacters || [] : [] // triggerOnly is true if we have just typed in a trigger character, and is false if we // have typed additional characters following a trigger character. const [triggerChar, triggerOnly] = AutocompleteAdapter.getTriggerCharacter(request, triggerChars) if (!this.shouldTrigger(request, triggerChar, minimumWordLength || 0)) { return [] } // Get the suggestions either from the cache or by calling the language server const suggestions = await this.getOrBuildSuggestions( server, request, triggerChar, triggerOnly, shouldReplace, onDidConvertCompletionItem ) // We must update the replacement prefix as characters are added and removed const cache = this._suggestionCache.get(server)! const replacementPrefix = request.editor.getTextInBufferRange([ [cache.triggerPoint.row, cache.triggerPoint.column + cache.triggerChar.length], request.bufferPosition, ]) for (const suggestion of suggestions) { if (suggestion.customReplacmentPrefix) { // having this property means a custom range was provided const len = replacementPrefix.length const preReplacementPrefix = suggestion.customReplacmentPrefix + replacementPrefix.substring(len + cache.originalBufferPoint.column - request.bufferPosition.column, len) // we cannot replace text after the cursor with the current autocomplete-plus API // so we will simply ignore it for now suggestion.replacementPrefix = preReplacementPrefix } else { suggestion.replacementPrefix = replacementPrefix } } const filtered = !(request.prefix === "" || (triggerChar !== "" && triggerOnly)) if (filtered) { // filter the suggestions who have `filterText` property const validSuggestions = suggestions.filter((sgs) => typeof sgs.filterText === "string") as Suggestion[] & { filterText: string }[] // TODO use `ObjectArrayFilterer.setCandidate` in `_suggestionCache` to avoid creating `ObjectArrayFilterer` every time from scratch const objFilterer = new ObjectArrayFilterer(validSuggestions, "filterText") // zadeh returns an array of the selected `Suggestions` return objFilterer.filter(request.prefix) as any as Suggestion[] } else { return suggestions } } private shouldTrigger(request: ac.SuggestionsRequestedEvent, triggerChar: string, minWordLength: number): boolean { return ( request.activatedManually || triggerChar !== "" || minWordLength <= 0 || request.prefix.length >= minWordLength ) } private async getOrBuildSuggestions( server: ActiveServer, request: ac.SuggestionsRequestedEvent, triggerChar: string, triggerOnly: boolean, shouldReplace: ShouldReplace, onDidConvertCompletionItem?: CompletionItemAdjuster ): Promise<Suggestion[]> { const cache = this._suggestionCache.get(server) const triggerColumn = triggerChar !== "" && triggerOnly ? request.bufferPosition.column - triggerChar.length : request.bufferPosition.column - request.prefix.length - triggerChar.length const triggerPoint = new Point(request.bufferPosition.row, triggerColumn) // Do we have complete cached suggestions that are still valid for this request? if ( cache && !cache.isIncomplete && cache.triggerChar === triggerChar && cache.triggerPoint.isEqual(triggerPoint) && cache.originalBufferPoint.isLessThanOrEqual(request.bufferPosition) ) { return Array.from(cache.suggestionMap.keys()) } // Our cached suggestions can't be used so obtain new ones from the language server const completions = await Utils.doWithCancellationToken( server.connection, this._cancellationTokens, (cancellationToken) => server.connection.completion( AutocompleteAdapter.createCompletionParams(request, triggerChar, triggerOnly), cancellationToken ) ) // spec guarantees all edits are on the same line, so we only need to check the columns const triggerColumns: [number, number] = [triggerPoint.column, request.bufferPosition.column] // Setup the cache for subsequent filtered results const isComplete = completions === null || Array.isArray(completions) || !completions.isIncomplete const suggestionMap = this.completionItemsToSuggestions( completions, request, triggerColumns, shouldReplace, onDidConvertCompletionItem ) this._suggestionCache.set(server, { isIncomplete: !isComplete, triggerChar, triggerPoint, originalBufferPoint: request.bufferPosition, suggestionMap, }) return Array.from(suggestionMap.keys()) } /** * Public: Obtain a complete version of a suggestion with additional information the language server can provide by * way of the `completionItem/resolve` request. * * @param server An {ActiveServer} pointing to the language server to query. * @param suggestion An {atom$AutocompleteSuggestion} suggestion that should be resolved. * @param request An {Object} with the AutoComplete+ request to satisfy. * @param onDidConvertCompletionItem An optional function that takes a {CompletionItem}, an * {atom$AutocompleteSuggestion} and a {atom$AutocompleteRequest} allowing you to adjust converted items. * @returns A {Promise} of an {atom$AutocompleteSuggestion} with the resolved AutoComplete+ suggestion. */ public async completeSuggestion( server: ActiveServer, suggestion: ac.AnySuggestion, request: ac.SuggestionsRequestedEvent, onDidConvertCompletionItem?: CompletionItemAdjuster ): Promise<ac.AnySuggestion> { const cache = this._suggestionCache.get(server) if (cache) { const possiblyResolvedCompletionItem = cache.suggestionMap.get(suggestion) if (possiblyResolvedCompletionItem != null && !possiblyResolvedCompletionItem.isResolved) { const resolvedCompletionItem = await server.connection.completionItemResolve( possiblyResolvedCompletionItem.completionItem ) if (resolvedCompletionItem != null) { AutocompleteAdapter.resolveSuggestion(resolvedCompletionItem, suggestion, request, onDidConvertCompletionItem) possiblyResolvedCompletionItem.isResolved = true } } } return suggestion } public static resolveSuggestion( resolvedCompletionItem: CompletionItem, suggestion: ac.AnySuggestion, request: ac.SuggestionsRequestedEvent, onDidConvertCompletionItem?: CompletionItemAdjuster ): void { // only the `documentation` and `detail` properties may change when resolving AutocompleteAdapter.applyDetailsToSuggestion(resolvedCompletionItem, suggestion) if (onDidConvertCompletionItem != null) { onDidConvertCompletionItem(resolvedCompletionItem, suggestion as ac.AnySuggestion, request) } } /** * Public: Get the trigger character that caused the autocomplete (if any). This is required because AutoComplete-plus * does not have trigger characters. Although the terminology is 'character' we treat them as variable length strings * as this will almost certainly change in the future to support '->' etc. * * @param request An {Array} of {atom$AutocompleteSuggestion}s to locate the prefix, editor, bufferPosition etc. * @param triggerChars The {Array} of {string}s that can be trigger characters. * @returns A [{string}, boolean] where the string is the matching trigger character or an empty string if one was not * matched, and the boolean is true if the trigger character is in request.prefix, and false if it is in the word * before request.prefix. The boolean return value has no meaning if the string return value is an empty string. */ public static getTriggerCharacter(request: ac.SuggestionsRequestedEvent, triggerChars: string[]): [string, boolean] { // AutoComplete-Plus considers text after a symbol to be a new trigger. So we should look backward // from the current cursor position to see if one is there and thus simulate it. const buffer = request.editor.getBuffer() const cursor = request.bufferPosition const prefixStartColumn = cursor.column - request.prefix.length for (const triggerChar of triggerChars) { if (request.prefix.endsWith(triggerChar)) { return [triggerChar, true] } if (prefixStartColumn >= triggerChar.length) { // Far enough along a line to fit the trigger char const start = new Point(cursor.row, prefixStartColumn - triggerChar.length) const possibleTrigger = buffer.getTextInRange([start, [cursor.row, prefixStartColumn]]) if (possibleTrigger === triggerChar) { // The text before our trigger is a trigger char! return [triggerChar, false] } } } // There was no explicit trigger char return ["", false] } /** * Public: Create TextDocumentPositionParams to be sent to the language server based on the editor and position from * the AutoCompleteRequest. * * @param request The {atom$AutocompleteRequest} to obtain the editor from. * @param triggerPoint The {atom$Point} where the trigger started. * @returns A {string} containing the prefix including the trigger character. */ public static getPrefixWithTrigger(request: ac.SuggestionsRequestedEvent, triggerPoint: Point): string { return request.editor.getBuffer().getTextInRange([[triggerPoint.row, triggerPoint.column], request.bufferPosition]) } /** * Public: Create {CompletionParams} to be sent to the language server based on the editor and position from the * Autocomplete request etc. * * @param request The {atom$AutocompleteRequest} containing the request details. * @param triggerCharacter The {string} containing the trigger character (empty if none). * @param triggerOnly A {boolean} representing whether this completion is triggered right after a trigger character. * @returns A {CompletionParams} with the keys: * * - `textDocument` the language server protocol textDocument identification. * - `position` the position within the text document to display completion request for. * - `context` containing the trigger character and kind. */ public static createCompletionParams( request: ac.SuggestionsRequestedEvent, triggerCharacter: string, triggerOnly: boolean ): CompletionParams { return { textDocument: Convert.editorToTextDocumentIdentifier(request.editor), position: Convert.pointToPosition(request.bufferPosition), context: AutocompleteAdapter.createCompletionContext(triggerCharacter, triggerOnly), } } /** * Public: Create {CompletionContext} to be sent to the language server based on the trigger character. * * @param triggerCharacter The {string} containing the trigger character or '' if none. * @param triggerOnly A {boolean} representing whether this completion is triggered right after a trigger character. * @returns An {CompletionContext} that specifies the triggerKind and the triggerCharacter if there is one. */ public static createCompletionContext(triggerCharacter: string, triggerOnly: boolean): CompletionContext { if (triggerCharacter === "") { return { triggerKind: CompletionTriggerKind.Invoked } } else { return triggerOnly ? { triggerKind: CompletionTriggerKind.TriggerCharacter, triggerCharacter } : { triggerKind: CompletionTriggerKind.TriggerForIncompleteCompletions, triggerCharacter } } } /** * Public: Convert a language server protocol CompletionItem array or CompletionList to an array of ordered * AutoComplete+ suggestions. * * @param completionItems An {Array} of {CompletionItem} objects or a {CompletionList} containing completion items to * be converted. * @param request The {atom$AutocompleteRequest} to satisfy. * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}). * @param onDidConvertCompletionItem A function that takes a {CompletionItem}, an {atom$AutocompleteSuggestion} and a * {atom$AutocompleteRequest} allowing you to adjust converted items. * @returns A {Map} of AutoComplete+ suggestions ordered by the CompletionItems sortText. */ public completionItemsToSuggestions( completionItems: CompletionItem[] | CompletionList | null, request: ac.SuggestionsRequestedEvent, triggerColumns: [number, number], shouldReplace: ShouldReplace, onDidConvertCompletionItem?: CompletionItemAdjuster ): Map<Suggestion, PossiblyResolvedCompletionItem> { const completionsArray = Array.isArray(completionItems) ? completionItems : (completionItems && completionItems.items) || [] return new Map( completionsArray .sort((a, b) => (a.sortText || a.label).localeCompare(b.sortText || b.label)) .map<[Suggestion, PossiblyResolvedCompletionItem]>((s) => [ AutocompleteAdapter.completionItemToSuggestion( s, {} as Suggestion, request, triggerColumns, shouldReplace, onDidConvertCompletionItem ), new PossiblyResolvedCompletionItem(s, false), ]) ) } /** * Public: Convert a language server protocol CompletionItem to an AutoComplete+ suggestion. * * @param item An {CompletionItem} containing a completion item to be converted. * @param suggestion A {atom$AutocompleteSuggestion} to have the conversion applied to. * @param request The {atom$AutocompleteRequest} to satisfy. * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}). * @param onDidConvertCompletionItem A function that takes a {CompletionItem}, an {atom$AutocompleteSuggestion} and a * {atom$AutocompleteRequest} allowing you to adjust converted items. * @returns The {atom$AutocompleteSuggestion} passed in as suggestion with the conversion applied. */ public static completionItemToSuggestion( item: CompletionItem, suggestion: Suggestion, request: ac.SuggestionsRequestedEvent, triggerColumns: [number, number], shouldReplace: ShouldReplace, onDidConvertCompletionItem?: CompletionItemAdjuster ): Suggestion { AutocompleteAdapter.applyCompletionItemToSuggestion(item, suggestion as TextSuggestion) AutocompleteAdapter.applyTextEditToSuggestion( item.textEdit, request.editor, triggerColumns, request.bufferPosition, suggestion as TextSuggestion, shouldReplace ) AutocompleteAdapter.applySnippetToSuggestion(item, suggestion as SnippetSuggestion) if (onDidConvertCompletionItem != null) { onDidConvertCompletionItem(item, suggestion as ac.AnySuggestion, request) } return suggestion } /** * Public: Convert the primary parts of a language server protocol CompletionItem to an AutoComplete+ suggestion. * * @param item An {CompletionItem} containing the completion items to be merged into. * @param suggestion The {Suggestion} to merge the conversion into. * @returns The {Suggestion} with details added from the {CompletionItem}. */ public static applyCompletionItemToSuggestion(item: CompletionItem, suggestion: TextSuggestion): void { suggestion.text = item.insertText || item.label suggestion.filterText = item.filterText || item.label suggestion.displayText = item.label suggestion.type = AutocompleteAdapter.completionKindToSuggestionType(item.kind) AutocompleteAdapter.applyDetailsToSuggestion(item, suggestion) suggestion.completionItem = item } public static applyDetailsToSuggestion(item: CompletionItem, suggestion: Suggestion): void { suggestion.rightLabel = item.detail // Older format, can't know what it is so assign to both and hope for best if (typeof item.documentation === "string") { suggestion.descriptionMarkdown = item.documentation suggestion.description = item.documentation } if (item.documentation != null && typeof item.documentation === "object") { // Newer format specifies the kind of documentation, assign appropriately if (item.documentation.kind === "markdown") { suggestion.descriptionMarkdown = item.documentation.value } else { suggestion.description = item.documentation.value } } } /** * Public: Applies the textEdit part of a language server protocol CompletionItem to an AutoComplete+ Suggestion via * the replacementPrefix and text properties. * * @param textEdit A {TextEdit} from a CompletionItem to apply. * @param editor An Atom {TextEditor} used to obtain the necessary text replacement. * @param suggestion An {atom$AutocompleteSuggestion} to set the replacementPrefix and text properties of. * @param shouldReplace The behavior of suggestion acceptance (see {ShouldReplace}). */ public static applyTextEditToSuggestion( textEdit: TextEdit | InsertReplaceEdit | undefined, editor: TextEditor, triggerColumns: [number, number], originalBufferPosition: Point, suggestion: TextSuggestion, shouldReplace: ShouldReplace ): void { if (!textEdit) { return } let range: Range if ("range" in textEdit) { range = textEdit.range } else if (shouldReplace) { range = textEdit.replace } else { range = textEdit.insert } if (range.start.character !== triggerColumns[0]) { const atomRange = Convert.lsRangeToAtomRange(range) suggestion.customReplacmentPrefix = editor.getTextInBufferRange([atomRange.start, originalBufferPosition]) } suggestion.text = textEdit.newText } /** * Handle additional text edits after a suggestion insert, e.g. `additionalTextEdits`. * * `additionalTextEdits` are An optional array of additional text edits that are applied when selecting this * completion. Edits must not overlap (including the same insert position) with the main edit nor with themselves. * * Additional text edits should be used to change text unrelated to the current cursor position (for example adding an * import statement at the top of the file if the completion item will insert an unqualified type). */ public static applyAdditionalTextEdits(event: ac.SuggestionInsertedEvent): void { const suggestion = event.suggestion as SuggestionBase const additionalEdits = suggestion.completionItem?.additionalTextEdits const buffer = event.editor.getBuffer() ApplyEditAdapter.applyEdits(buffer, Convert.convertLsTextEdits(additionalEdits)) buffer.groupLastChanges() } /** * Public: Adds a snippet to the suggestion if the CompletionItem contains snippet-formatted text * * @param item An {CompletionItem} containing the completion items to be merged into. * @param suggestion The {atom$AutocompleteSuggestion} to merge the conversion into. */ public static applySnippetToSuggestion(item: CompletionItem, suggestion: SnippetSuggestion): void { if (item.insertTextFormat === InsertTextFormat.Snippet) { suggestion.snippet = item.textEdit != null ? item.textEdit.newText : item.insertText || item.label } } /** * Public: Obtain the textual suggestion type required by AutoComplete+ that most closely maps to the numeric * completion kind supplies by the language server. * * @param kind A {Number} that represents the suggestion kind to be converted. * @returns A {String} containing the AutoComplete+ suggestion type equivalent to the given completion kind. */ public static completionKindToSuggestionType(kind: number | undefined): string { switch (kind) { case CompletionItemKind.Constant: return "constant" case CompletionItemKind.Method: return "method" case CompletionItemKind.Function: case CompletionItemKind.Constructor: return "function" case CompletionItemKind.Field: case CompletionItemKind.Property: return "property" case CompletionItemKind.Variable: return "variable" case CompletionItemKind.Class: return "class" case CompletionItemKind.Struct: case CompletionItemKind.TypeParameter: return "type" case CompletionItemKind.Operator: return "selector" case CompletionItemKind.Interface: return "mixin" case CompletionItemKind.Module: return "module" case CompletionItemKind.Unit: return "builtin" case CompletionItemKind.Enum: case CompletionItemKind.EnumMember: return "enum" case CompletionItemKind.Keyword: return "keyword" case CompletionItemKind.Snippet: return "snippet" case CompletionItemKind.File: case CompletionItemKind.Folder: return "import" case CompletionItemKind.Reference: return "require" default: return "value" } } } /** * Normalizes the given grammar scope for autoComplete package so it always starts with `.` Based on * https://github.com/atom/autocomplete-plus/wiki/Autocomplete-Providers * * @param grammarScope Such as 'source.python' or '.source.python' * @returns The normalized grammarScope such as `.source.python` */ export function grammarScopeToAutoCompleteSelector(grammarScope: string): string { return grammarScope.includes(".") && grammarScope[0] !== "." ? `.${grammarScope}` : grammarScope }