From 3a26df0abc7d432cca7a0ee6b8da3fc1ea5cae42 Mon Sep 17 00:00:00 2001 From: Qiming Zhao Date: Wed, 4 May 2022 04:30:27 +0800 Subject: [PATCH] feat(languages): add languages.registerInlayHintsProvider --- doc/coc.txt | 2 + history.md | 4 + plugin/coc.vim | 1 + src/__tests__/handler/inlayHint.test.ts | 233 ++++++++++++++++++ ...dEditing.test.ts => linkedEditing.test.ts} | 0 src/handler/index.ts | 3 + src/handler/inlayHint/buffer.ts | 113 +++++++++ src/handler/inlayHint/index.ts | 51 ++++ src/inlayHint.ts | 179 ++++++++++++++ src/languages.ts | 34 ++- src/model/regions.ts | 4 + src/provider/index.ts | 37 +++ src/provider/inlayHintManager.ts | 97 ++++++++ typings/index.d.ts | 162 ++++++++++++ 14 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/handler/inlayHint.test.ts rename src/__tests__/handler/{likedEditing.test.ts => linkedEditing.test.ts} (100%) create mode 100644 src/handler/inlayHint/buffer.ts create mode 100644 src/handler/inlayHint/index.ts create mode 100644 src/inlayHint.ts create mode 100644 src/provider/inlayHintManager.ts diff --git a/doc/coc.txt b/doc/coc.txt index c84571a3088..51f23fcc045 100644 --- a/doc/coc.txt +++ b/doc/coc.txt @@ -3073,6 +3073,8 @@ Others~ vim doesn't support change highlight group of cursorline inside popup. *CocSelectedRange* for highlight ranges of outgoing calls. *CocSnippetVisual* for highlight snippet placeholders. +*CocInlayHint* for highlight inlay hint virtual text block, default linked to +|CocHintSign| Semantic highlights~ *coc-semantic-highlights* diff --git a/history.md b/history.md index 5a1b4e04ad6..5fbd5a1f31f 100644 --- a/history.md +++ b/history.md @@ -1,3 +1,7 @@ +# 2022-05-04 + +- Add `languages.registerInlayHintsProvider()` for inlay hint support. + # 2022-04-25 - Add `LinkedEditing` support diff --git a/plugin/coc.vim b/plugin/coc.vim index 63e0fe45018..3d48dc56b17 100644 --- a/plugin/coc.vim +++ b/plugin/coc.vim @@ -410,6 +410,7 @@ function! s:Hi() abort hi default link CocLinkedEditing CocCursorRange hi default link CocHighlightRead CocHighlightText hi default link CocHighlightWrite CocHighlightText + hi default link CocInlayHint CocHintSign " Snippet hi default link CocSnippetVisual Visual " Tree view highlights diff --git a/src/__tests__/handler/inlayHint.test.ts b/src/__tests__/handler/inlayHint.test.ts new file mode 100644 index 00000000000..a5c7947da8e --- /dev/null +++ b/src/__tests__/handler/inlayHint.test.ts @@ -0,0 +1,233 @@ +import { Neovim } from '@chemzqm/neovim' +import { CancellationTokenSource, Disposable, Position, Range } from 'vscode-languageserver-protocol' +import InlayHintHandler from '../../handler/inlayHint/index' +import { InlayHint } from '../../inlayHint' +import languages from '../../languages' +import { isValidInlayHint, sameHint } from '../../provider/inlayHintManager' +import { disposeAll } from '../../util' +import workspace from '../../workspace' +import helper from '../helper' + +let nvim: Neovim +let handler: InlayHintHandler +let disposables: Disposable[] = [] +let ns: number +beforeAll(async () => { + await helper.setup() + nvim = helper.nvim + handler = helper.plugin.getHandler().inlayHintHandler + ns = await nvim.createNamespace('coc-inlayHint') +}) + +afterAll(async () => { + await helper.shutdown() +}) + +afterEach(async () => { + disposeAll(disposables) + await helper.reset() +}) + +describe('InlayHint', () => { + describe('utils', () => { + it('should check same hint', () => { + let hint = InlayHint.create(Position.create(0, 0), 'foo') + expect(sameHint(hint, InlayHint.create(Position.create(0, 0), 'bar'))).toBe(false) + expect(sameHint(hint, InlayHint.create(Position.create(0, 0), [{ value: 'foo' }]))).toBe(true) + }) + + it('should check valid hint', () => { + let hint = InlayHint.create(Position.create(0, 0), 'foo') + expect(isValidInlayHint(hint, Range.create(0, 0, 1, 0))).toBe(true) + expect(isValidInlayHint(InlayHint.create(Position.create(0, 0), ''), Range.create(0, 0, 1, 0))).toBe(false) + expect(isValidInlayHint(InlayHint.create(Position.create(3, 0), 'foo'), Range.create(0, 0, 1, 0))).toBe(false) + expect(isValidInlayHint({ label: 'f' } as any, Range.create(0, 0, 1, 0))).toBe(false) + }) + }) + + describe('provideInlayHints', () => { + it('should not throw when failed', async () => { + disposables.push(languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: () => { + return Promise.reject(new Error('Test failure')) + } + })) + let doc = await workspace.document + let tokenSource = new CancellationTokenSource() + let res = await languages.provideInlayHints(doc.textDocument, Range.create(0, 0, 1, 0), tokenSource.token) + expect(res).toEqual([]) + }) + + it('should merge provide results', async () => { + disposables.push(languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: () => { + return [InlayHint.create(Position.create(0, 0), 'foo')] + } + })) + disposables.push(languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: () => { + return [ + InlayHint.create(Position.create(0, 0), 'foo'), + InlayHint.create(Position.create(1, 0), 'bar'), + InlayHint.create(Position.create(5, 0), 'bad')] + } + })) + let doc = await workspace.document + let tokenSource = new CancellationTokenSource() + let res = await languages.provideInlayHints(doc.textDocument, Range.create(0, 0, 3, 0), tokenSource.token) + expect(res.length).toBe(2) + }) + + it('should resolve inlay hint', async () => { + disposables.push(languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: () => { + return [InlayHint.create(Position.create(0, 0), 'foo')] + }, + resolveInlayHint: hint => { + hint.tooltip = 'tooltip' + return hint + } + })) + let doc = await workspace.document + let tokenSource = new CancellationTokenSource() + let res = await languages.provideInlayHints(doc.textDocument, Range.create(0, 0, 1, 0), tokenSource.token) + let resolved = await languages.resolveInlayHint(res[0], tokenSource.token) + expect(resolved.tooltip).toBe('tooltip') + resolved = await languages.resolveInlayHint(resolved, tokenSource.token) + expect(resolved.tooltip).toBe('tooltip') + }) + + it('should not resolve when cancelled', async () => { + disposables.push(languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: () => { + return [InlayHint.create(Position.create(0, 0), 'foo')] + }, + resolveInlayHint: (hint, token) => { + return new Promise(resolve => { + token.onCancellationRequested(() => { + clearTimeout(timer) + resolve(null) + }) + let timer = setTimeout(() => { + resolve(Object.assign({}, hint, { tooltip: 'tooltip' })) + }, 200) + }) + } + })) + let doc = await workspace.document + let tokenSource = new CancellationTokenSource() + let res = await languages.provideInlayHints(doc.textDocument, Range.create(0, 0, 1, 0), tokenSource.token) + let p = languages.resolveInlayHint(res[0], tokenSource.token) + tokenSource.cancel() + let resolved = await p + expect(resolved.tooltip).toBeUndefined() + }) + }) + + describe('setVirtualText', () => { + async function registerProvider(content: string): Promise { + let doc = await workspace.document + let disposable = languages.registerInlayHintsProvider([{ language: '*' }], { + provideInlayHints: (document, range) => { + let content = document.getText(range) + let lines = content.split(/\r?\n/) + let hints: InlayHint[] = [] + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + if (!line.length) continue + let parts = line.split(/\s+/) + hints.push(...parts.map(s => InlayHint.create(Position.create(range.start.line + i, line.length), s))) + } + return hints + } + }) + await doc.buffer.setLines(content.split(/\n/), { start: 0, end: -1 }) + await doc.synchronize() + return disposable + } + + async function waitRefresh(bufnr: number) { + let buf = handler.getItem(bufnr) + return new Promise((resolve, reject) => { + let timer = setTimeout(() => { + reject(new Error('not refresh after 1s')) + }, 1000) + buf.onDidRefresh(() => { + clearTimeout(timer) + resolve() + }) + }) + } + + it('should not refresh when languageId not match', async () => { + let doc = await workspace.document + disposables.push(languages.registerInlayHintsProvider([{ language: 'javascript' }], { + provideInlayHints: () => { + let hint = InlayHint.create(Position.create(0, 0), 'foo') + return [hint] + } + })) + await nvim.setLine('foo') + await doc.synchronize() + await helper.wait(30) + let markers = await doc.buffer.getExtMarks(ns, 0, -1, { details: true }) + expect(markers.length).toBe(0) + }) + + it('should refresh on text change', async () => { + let buf = await nvim.buffer + let disposable = await registerProvider('foo') + disposables.push(disposable) + await waitRefresh(buf.id) + await buf.setLines(['a', 'b', 'c'], { start: 0, end: -1 }) + await waitRefresh(buf.id) + let markers = await buf.getExtMarks(ns, 0, -1, { details: true }) + expect(markers.length).toBe(3) + let item = handler.getItem(buf.id) + await item.renderRange() + expect(item.current.length).toBe(3) + }) + + it('should refresh on provider dispose', async () => { + let buf = await nvim.buffer + let disposable = await registerProvider('foo bar') + await waitRefresh(buf.id) + disposable.dispose() + let markers = await buf.getExtMarks(ns, 0, -1, { details: true }) + expect(markers.length).toBe(0) + let item = handler.getItem(buf.id) + expect(item.current.length).toBe(0) + await item.renderRange() + expect(item.current.length).toBe(0) + }) + + it('should refresh on scroll', async () => { + let arr = new Array(200) + let content = arr.fill('foo').join('\n') + let buf = await nvim.buffer + let disposable = await registerProvider(content) + disposables.push(disposable) + await waitRefresh(buf.id) + let markers = await buf.getExtMarks(ns, 0, -1, { details: true }) + let len = markers.length + await nvim.command('normal! G') + await waitRefresh(buf.id) + await nvim.input('') + await waitRefresh(buf.id) + markers = await buf.getExtMarks(ns, 0, -1, { details: true }) + expect(markers.length).toBeGreaterThan(len) + }) + + it('should cancel previous render', async () => { + let buf = await nvim.buffer + let disposable = await registerProvider('foo') + disposables.push(disposable) + await waitRefresh(buf.id) + let item = handler.getItem(buf.id) + await item.renderRange() + await item.renderRange() + expect(item.current.length).toBe(1) + }) + }) +}) + diff --git a/src/__tests__/handler/likedEditing.test.ts b/src/__tests__/handler/linkedEditing.test.ts similarity index 100% rename from src/__tests__/handler/likedEditing.test.ts rename to src/__tests__/handler/linkedEditing.test.ts diff --git a/src/handler/index.ts b/src/handler/index.ts index 85449eacd77..0267454b171 100644 --- a/src/handler/index.ts +++ b/src/handler/index.ts @@ -31,6 +31,7 @@ import Symbols from './symbols/index' import { HandlerDelegate } from '../types' import { getSymbolKind } from '../util/convert' import LinkedEditingHandler from './linkedEditing' +import InlayHintHandler from './inlayHint/index' const logger = require('../util/logger')('Handler') export interface CurrentState { @@ -61,6 +62,7 @@ export default class Handler implements HandlerDelegate { public readonly semanticHighlighter: SemanticTokens public readonly workspace: WorkspaceHandler public readonly linkedEditingHandler: LinkedEditingHandler + public readonly inlayHintHandler: InlayHintHandler private labels: { [key: string]: string } private requestStatusItem: StatusBarItem private requestTokenSource: CancellationTokenSource | undefined @@ -95,6 +97,7 @@ export default class Handler implements HandlerDelegate { this.semanticHighlighter = new SemanticTokens(nvim, this) this.selectionRange = new SelectionRange(nvim, this) this.linkedEditingHandler = new LinkedEditingHandler(nvim, this) + this.inlayHintHandler = new InlayHintHandler(nvim, this) this.disposables.push({ dispose: () => { this.callHierarchy.dispose() diff --git a/src/handler/inlayHint/buffer.ts b/src/handler/inlayHint/buffer.ts new file mode 100644 index 00000000000..1d84e54434e --- /dev/null +++ b/src/handler/inlayHint/buffer.ts @@ -0,0 +1,113 @@ +'use strict' +import { Neovim } from '@chemzqm/neovim' +import debounce from 'debounce' +import { CancellationTokenSource, Emitter, Event, Range } from 'vscode-languageserver-protocol' +import languages from '../../languages' +import { SyncItem } from '../../model/bufferSync' +import Document from '../../model/document' +import Regions from '../../model/regions' +import { getLabel, InlayHintWithProvider } from '../../provider/inlayHintManager' +import { positionInRange } from '../../util/position' + +export interface InlayHintConfig { + srcId?: number +} + +const debounceInterval = global.hasOwnProperty('__TEST__') ? 10 : 100 +const highlightGroup = 'CocInlayHint' + +export default class InlayHintBuffer implements SyncItem { + private tokenSource: CancellationTokenSource + private regions = new Regions() + // Saved for resolve and TextEdits in the future. + private currentHints: InlayHintWithProvider[] = [] + private readonly _onDidRefresh = new Emitter() + public readonly onDidRefresh: Event = this._onDidRefresh.event + public render: Function & { clear(): void } + constructor( + private readonly nvim: Neovim, + public readonly doc: Document, + private readonly config: InlayHintConfig + ) { + this.render = debounce(() => { + void this.renderRange() + }, debounceInterval) + this.render() + } + + public get current(): ReadonlyArray { + return this.currentHints + } + + public clearCache(): void { + this.currentHints = [] + this.regions.clear() + this.render.clear() + } + + public onChange(): void { + this.clearCache() + this.cancel() + this.render() + } + + public cancel(): void { + this.render.clear() + if (this.tokenSource) { + this.tokenSource.cancel() + this.tokenSource = null + } + } + + public async renderRange(): Promise { + this.cancel() + if (!languages.hasProvider('inlayHint', this.doc.textDocument)) return + this.tokenSource = new CancellationTokenSource() + let token = this.tokenSource.token + let res = await this.nvim.call('coc#window#visible_range', [this.doc.bufnr]) as [number, number] + if (res == null || this.doc.dirty || token.isCancellationRequested) return + if (this.regions.has(res[0], res[1])) return + let range = Range.create(res[0] - 1, 0, res[1], 0) + let inlayHints = await languages.provideInlayHints(this.doc.textDocument, range, token) + if (inlayHints == null || token.isCancellationRequested) return + this.regions.add(res[0], res[1]) + this.currentHints = this.currentHints.filter(o => positionInRange(o.position, range) !== 0) + this.currentHints.push(...inlayHints) + this.setVirtualText(range, inlayHints) + } + + private setVirtualText(range: Range, inlayHints: InlayHintWithProvider[]): void { + let { nvim, doc } = this + let srcId = this.config.srcId + let buffer = doc.buffer + const chunksMap = {} + for (const item of inlayHints) { + const chunks: [[string, string]] = [[getLabel(item), highlightGroup]] + if (chunksMap[item.position.line] === undefined) { + chunksMap[item.position.line] = chunks + } else { + chunksMap[item.position.line].push([' ', 'Normal']) + chunksMap[item.position.line].push(chunks[0]) + } + } + nvim.pauseNotification() + buffer.clearNamespace(srcId, range.start.line, range.end.line + 1) + for (let key of Object.keys(chunksMap)) { + buffer.setExtMark(srcId, Number(key), 0, { + virt_text: chunksMap[key], + virt_text_pos: 'eol' + }) + } + nvim.resumeNotification(false, true) + this._onDidRefresh.fire() + } + + public clearVirtualText(): void { + let srcId = this.config.srcId + this.doc.buffer.clearNamespace(srcId) + } + + public dispose(): void { + this.cancel() + } +} diff --git a/src/handler/inlayHint/index.ts b/src/handler/inlayHint/index.ts new file mode 100644 index 00000000000..fcc75417434 --- /dev/null +++ b/src/handler/inlayHint/index.ts @@ -0,0 +1,51 @@ +'use strict' +import { Neovim } from '@chemzqm/neovim' +import events from '../../events' +import languages from '../../languages' +import BufferSync from '../../model/bufferSync' +import { HandlerDelegate } from '../../types' +import workspace from '../../workspace' +import InlayHintBuffer, { InlayHintConfig } from './buffer' + +export default class InlayHintHandler { + private config: InlayHintConfig = {} + private buffers: BufferSync | undefined + constructor(nvim: Neovim, handler: HandlerDelegate) { + void nvim.createNamespace('coc-inlayHint').then(id => { + this.config.srcId = id + }) + this.buffers = workspace.registerBufferSync(doc => { + if (!workspace.has('nvim-0.5.0')) return undefined + return new InlayHintBuffer(nvim, doc, this.config) + }) + handler.addDisposable(this.buffers) + handler.addDisposable(languages.onDidInlayHintRefresh(async e => { + for (let item of this.buffers.items) { + if (workspace.match(e, item.doc.textDocument)) { + item.clearCache() + if (languages.hasProvider('inlayHint', item.doc.textDocument)) { + await item.renderRange() + } else { + item.clearVirtualText() + } + } + } + })) + handler.addDisposable(events.on('CursorMoved', bufnr => { + this.refresh(bufnr) + })) + handler.addDisposable(events.on('WinScrolled', async winid => { + let bufnr = await nvim.call('winbufnr', [winid]) + if (bufnr != -1) this.refresh(bufnr) + })) + } + + public getItem(bufnr: number): InlayHintBuffer { + return this.buffers.getItem(bufnr) + } + + public refresh(bufnr: number): void { + let buf = this.buffers.getItem(bufnr) + if (buf) buf.render() + } +} diff --git a/src/inlayHint.ts b/src/inlayHint.ts new file mode 100644 index 00000000000..3f8bd1ceea1 --- /dev/null +++ b/src/inlayHint.ts @@ -0,0 +1,179 @@ +'use strict' +import { MarkupContent, TextEdit, Position, Location, Command } from 'vscode-languageserver-protocol' +import * as Is from './util/is' + +/** + * Inlay hint kinds. + * + * @since 3.17.0 + * @proposed + */ +export namespace InlayHintKind { + + /** + * An inlay hint that for a type annotation. + */ + export const Type = 1 + + /** + * An inlay hint that is for a parameter. + */ + export const Parameter = 2 + + export function is(value: number): value is InlayHintKind { + return value === 1 || value === 2 + } +} + +// eslint-disable-next-line no-redeclare +export type InlayHintKind = 1 | 2 + +/** + * An inlay hint label part allows for interactive and composite labels + * of inlay hints. + * + * @since 3.17.0 + * @proposed + */ +export interface InlayHintLabelPart { + + /** + * The value of this label part. + */ + value: string + + /** + * The tooltip text when you hover over this label part. Depending on + * the client capability `inlayHint.resolveSupport` clients might resolve + * this property late using the resolve request. + */ + tooltip?: string | MarkupContent + + /** + * An optional source code location that represents this + * label part. + * + * The editor will use this location for the hover and for code navigation + * features: This part will become a clickable link that resolves to the + * definition of the symbol at the given location (not necessarily the + * location itself), it shows the hover that shows at the given location, + * and it shows a context menu with further code navigation commands. + * + * Depending on the client capability `inlayHint.resolveSupport` clients + * might resolve this property late using the resolve request. + */ + location?: Location + + /** + * An optional command for this label part. + * + * Depending on the client capability `inlayHint.resolveSupport` clients + * might resolve this property late using the resolve request. + */ + command?: Command +} + +// eslint-disable-next-line no-redeclare +export namespace InlayHintLabelPart { + + export function create(value: string): InlayHintLabelPart { + return { value } + } + + export function is(value: any): value is InlayHintLabelPart { + const candidate: InlayHintLabelPart = value + return Is.objectLiteral(candidate) + && (candidate.tooltip === undefined || Is.string(candidate.tooltip) || MarkupContent.is(candidate.tooltip)) + && (candidate.location === undefined || Location.is(candidate.location)) + && (candidate.command === undefined || Command.is(candidate.command)) + } +} + +/** + * Inlay hint information. + * + * @since 3.17.0 + * @proposed + */ +export interface InlayHint { + + /** + * The position of this hint. + */ + position: Position + + /** + * The label of this hint. A human readable string or an array of + * InlayHintLabelPart label parts. + * + * *Note* that neither the string nor the label part can be empty. + */ + label: string | InlayHintLabelPart[] + + /** + * The kind of this hint. Can be omitted in which case the client + * should fall back to a reasonable default. + */ + kind?: InlayHintKind + + /** + * Optional text edits that are performed when accepting this inlay hint. + * + * *Note* that edits are expected to change the document so that the inlay + * hint (or its nearest variant) is now part of the document and the inlay + * hint itself is now obsolete. + */ + textEdits?: TextEdit[] + + /** + * The tooltip text when you hover over this item. + */ + tooltip?: string | MarkupContent + + /** + * Render padding before the hint. + * + * Note: Padding should use the editor's background color, not the + * background color of the hint itself. That means padding can be used + * to visually align/separate an inlay hint. + */ + paddingLeft?: boolean + + /** + * Render padding after the hint. + * + * Note: Padding should use the editor's background color, not the + * background color of the hint itself. That means padding can be used + * to visually align/separate an inlay hint. + */ + paddingRight?: boolean + + /** + * A data entry field that is preserved on a inlay hint between + * a `textDocument/inlayHint` and a `inlayHint/resolve` request. + */ + data?: any +} + +// eslint-disable-next-line no-redeclare +export namespace InlayHint { + + export function create(position: Position, label: string | InlayHintLabelPart[], kind?: InlayHintKind): InlayHint { + const result: InlayHint = { position, label } + if (kind !== undefined) { + result.kind = kind + } + return result + } + + export function is(value: any): value is InlayHint { + const candidate: InlayHint = value + return Is.objectLiteral(candidate) && Position.is(candidate.position) + && (Is.string(candidate.label) || Is.typedArray(candidate.label, InlayHintLabelPart.is)) + && (candidate.kind === undefined || InlayHintKind.is(candidate.kind)) + && (candidate.textEdits === undefined) || Is.typedArray(candidate.textEdits, TextEdit.is) + && (candidate.tooltip === undefined || Is.string(candidate.tooltip) || MarkupContent.is(candidate.tooltip)) + && (candidate.paddingLeft === undefined || Is.boolean(candidate.paddingLeft)) + && (candidate.paddingRight === undefined || Is.boolean(candidate.paddingRight)) + } +} diff --git a/src/languages.ts b/src/languages.ts index f94d7a67f6e..ea29a24e518 100644 --- a/src/languages.ts +++ b/src/languages.ts @@ -3,7 +3,7 @@ import { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall import { TextDocument } from 'vscode-languageserver-textdocument' import DiagnosticCollection from './diagnostic/collection' import diagnosticManager from './diagnostic/manager' -import { CallHierarchyProvider, CodeActionProvider, CodeLensProvider, CompletionItemProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, DocumentHighlightProvider, DocumentLinkProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, DocumentSymbolProviderMetadata, FoldingContext, FoldingRangeProvider, HoverProvider, ImplementationProvider, LinkedEditingRangeProvider, OnTypeFormattingEditProvider, ReferenceContext, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider, WorkspaceSymbolProvider } from './provider' +import { CallHierarchyProvider, CodeActionProvider, CodeLensProvider, CompletionItemProvider, DeclarationProvider, DefinitionProvider, DocumentColorProvider, DocumentFormattingEditProvider, DocumentHighlightProvider, DocumentLinkProvider, DocumentRangeFormattingEditProvider, DocumentRangeSemanticTokensProvider, DocumentSemanticTokensProvider, DocumentSymbolProvider, DocumentSymbolProviderMetadata, FoldingContext, FoldingRangeProvider, HoverProvider, ImplementationProvider, InlayHintsProvider, LinkedEditingRangeProvider, OnTypeFormattingEditProvider, ReferenceContext, ReferenceProvider, RenameProvider, SelectionRangeProvider, SignatureHelpProvider, TypeDefinitionProvider, WorkspaceSymbolProvider } from './provider' import CallHierarchyManager from './provider/callHierarchyManager' import CodeActionManager from './provider/codeActionManager' import CodeLensManager from './provider/codeLensManager' @@ -28,12 +28,16 @@ import SemanticTokensRangeManager from './provider/semanticTokensRangeManager' import SignatureManager from './provider/signatureManager' import TypeDefinitionManager from './provider/typeDefinitionManager' import WorkspaceSymbolManager from './provider/workspaceSymbolsManager' +import InlayHintManger, { InlayHintWithProvider } from './provider/inlayHintManager' import { ExtendedCodeAction } from './types' +import { disposeAll } from './util' const logger = require('./util/logger')('languages') class Languages { - private _onDidSemanticTokensRefresh = new Emitter() + private readonly _onDidSemanticTokensRefresh = new Emitter() + private readonly _onDidInlayHintRefresh = new Emitter() public readonly onDidSemanticTokensRefresh: Event = this._onDidSemanticTokensRefresh.event + public readonly onDidInlayHintRefresh: Event = this._onDidInlayHintRefresh.event private onTypeFormatManager = new OnTypeFormatManager() private documentLinkManager = new DocumentLinkManager() private documentColorManager = new DocumentColorManager() @@ -58,6 +62,7 @@ class Languages { private semanticTokensManager = new SemanticTokensManager() private semanticTokensRangeManager = new SemanticTokensRangeManager() private linkedEditingManager = new LinkedEditingRangeManager() + private inlayHintManager = new InlayHintManger() public hasFormatProvider(doc: TextDocument): boolean { if (this.formatManager.hasProvider(doc)) { @@ -197,6 +202,21 @@ class Languages { return this.semanticTokensRangeManager.register(selector, provider, legend) } + public registerInlayHintsProvider(selector: DocumentSelector, provider: InlayHintsProvider): Disposable { + let disposables: Disposable[] = [] + disposables.push(this.inlayHintManager.register(selector, provider)) + this._onDidInlayHintRefresh.fire(selector) + if (typeof provider.onDidChangeInlayHints === 'function') { + provider.onDidChangeInlayHints(() => { + this._onDidInlayHintRefresh.fire(selector) + }, null, disposables) + } + return Disposable.create(() => { + disposeAll(disposables) + this._onDidInlayHintRefresh.fire(selector) + }) + } + public registerLinkedEditingRangeProvider(selector: DocumentSelector, provider: LinkedEditingRangeProvider): Disposable { return this.linkedEditingManager.register(selector, provider) } @@ -379,6 +399,14 @@ class Languages { return this.semanticTokensRangeManager.provideDocumentRangeSemanticTokens(document, range, token) } + public async provideInlayHints(document: TextDocument, range: Range, token: CancellationToken): Promise { + return this.inlayHintManager.provideInlayHints(document, range, token) + } + + public async resolveInlayHint(hint: InlayHintWithProvider, token: CancellationToken): Promise { + return this.inlayHintManager.resolveInlayHint(hint, token) + } + public hasLinkedEditing(document: TextDocument): boolean { return this.linkedEditingManager.hasProvider(document) } @@ -443,6 +471,8 @@ class Languages { return this.semanticTokensRangeManager.hasProvider(document) case 'linkedEditing': return this.linkedEditingManager.hasProvider(document) + case 'inlayHint': + return this.inlayHintManager.hasProvider(document) default: throw new Error(`Invalid provider name: ${id}`) } diff --git a/src/model/regions.ts b/src/model/regions.ts index 06666786cf9..a5285373ee6 100644 --- a/src/model/regions.ts +++ b/src/model/regions.ts @@ -17,6 +17,10 @@ export default class Regions { return res } + public clear(): void { + this.ranges = [] + } + public add(start: number, end: number): void { if (start > end) { [start, end] = [end, start] diff --git a/src/provider/index.ts b/src/provider/index.ts index 3c30795ad93..640163f2033 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -2,6 +2,7 @@ import { CallHierarchyIncomingCall, CallHierarchyItem, CallHierarchyOutgoingCall, CancellationToken, CodeAction, CodeActionContext, CodeActionKind, CodeLens, Color, ColorInformation, ColorPresentation, Command, CompletionContext, CompletionItem, CompletionList, Definition, DefinitionLink, DocumentHighlight, DocumentLink, DocumentSymbol, Event, FoldingRange, FormattingOptions, Hover, LinkedEditingRanges, Location, Position, Range, SelectionRange, SemanticTokens, SemanticTokensDelta, SignatureHelp, SignatureHelpContext, SymbolInformation, TextEdit, WorkspaceEdit } from 'vscode-languageserver-protocol' import { TextDocument } from 'vscode-languageserver-textdocument' import { URI } from 'vscode-uri' +import { InlayHint } from '../inlayHint' /** * A provider result represents the values a provider, like the [`HoverProvider`](#HoverProvider), @@ -821,3 +822,39 @@ export interface LinkedEditingRangeProvider { */ provideLinkedEditingRanges(document: TextDocument, position: Position, token: CancellationToken): ProviderResult } + +/** + * The inlay hints provider interface defines the contract between extensions and + * the inlay hints feature. + */ +export interface InlayHintsProvider { + + /** + * An optional event to signal that inlay hints from this provider have changed. + */ + onDidChangeInlayHints?: Event + + /** + * Provide inlay hints for the given range and document. + * + * *Note* that inlay hints that are not {@link Range.contains contained} by the given range are ignored. + * + * @param document The document in which the command was invoked. + * @param range The range for which inlay hints should be computed. + * @param token A cancellation token. + * @return An array of inlay hints or a thenable that resolves to such. + */ + provideInlayHints(document: TextDocument, range: Range, token: CancellationToken): ProviderResult + + /** + * Given an inlay hint fill in {@link InlayHint.tooltip tooltip}, {@link InlayHint.textEdits text edits}, + * or complete label {@link InlayHintLabelPart parts}. + * + * *Note* that the editor will resolve an inlay hint at most once. + * + * @param hint An inlay hint. + * @param token A cancellation token. + * @return The resolved inlay hint or a thenable that resolves to such. It is OK to return the given `item`. When no result is returned, the given `item` will be used. + */ + resolveInlayHint?(hint: T, token: CancellationToken): ProviderResult +} diff --git a/src/provider/inlayHintManager.ts b/src/provider/inlayHintManager.ts new file mode 100644 index 00000000000..66e39600bb4 --- /dev/null +++ b/src/provider/inlayHintManager.ts @@ -0,0 +1,97 @@ +'use strict' +import { v4 as uuid } from 'uuid' +import { CancellationToken, Disposable, DocumentSelector, Range } from 'vscode-languageserver-protocol' +import { TextDocument } from 'vscode-languageserver-textdocument' +import { InlayHint } from '../inlayHint' +import { comparePosition, positionInRange } from '../util/position' +import { InlayHintsProvider } from './index' +import Manager, { ProviderItem } from './manager' +const logger = require('../util/logger')('inlayHintManger') + +export interface InlayHintWithProvider extends InlayHint { + providerId: string + resolved?: boolean +} + +export default class InlayHintManger extends Manager { + + public register(selector: DocumentSelector, provider: InlayHintsProvider): Disposable { + let item: ProviderItem = { + id: uuid(), + selector, + provider + } + this.providers.add(item) + return Disposable.create(() => { + this.providers.delete(item) + }) + } + + /** + * Multiple providers can be registered for a language. In that case providers are asked in + * parallel and the results are merged. A failing provider (rejected promise or exception) will + * not cause a failure of the whole operation. + */ + public async provideInlayHints( + document: TextDocument, + range: Range, + token: CancellationToken + ): Promise { + let items = this.getProviders(document) + if (items.length === 0) return null + let results: InlayHintWithProvider[] = [] + let finished = 0 + await Promise.all(items.map(item => { + let { id, provider } = item + return Promise.resolve(provider.provideInlayHints(document, range, token)).then(hints => { + if (token.isCancellationRequested) return + for (let hint of hints) { + if (!isValidInlayHint(hint, range)) continue + if (finished > 0 && results.findIndex(o => sameHint(o, hint)) != -1) continue + results.push({ + providerId: id, + ...hint + }) + } + finished += 1 + }, e => { + logger.error(`Error on provideInlayHints`, e) + }) + })) + return results + } + + public async resolveInlayHint(hint: InlayHintWithProvider, token: CancellationToken): Promise { + let provider = this.getProviderById(hint.providerId) + if (!provider || typeof provider.resolveInlayHint !== 'function' || hint.resolved === true) return hint + let res = await Promise.resolve(provider.resolveInlayHint(hint, token)) + if (token.isCancellationRequested) return hint + return Object.assign(hint, res, { resolved: true }) + } +} + +export function sameHint(one: InlayHint, other: InlayHint): boolean { + if (comparePosition(one.position, other.position) !== 0) return false + return getLabel(one) === getLabel(other) +} + +export function isValidInlayHint(hint: InlayHint, range: Range): boolean { + if (hint.label.length === 0 || (Array.isArray(hint.label) && hint.label.every(part => part.value.length === 0))) { + logger.warn('INVALID inlay hint, empty label', hint) + return false + } + if (!InlayHint.is(hint)) { + logger.warn('INVALID inlay hint', hint) + return false + } + if (range && positionInRange(hint.position, range) !== 0) { + // console.log('INVALID inlay hint, position outside range', range, hint); + return false + } + return true +} + +export function getLabel(hint: InlayHint): string { + if (typeof hint.label === 'string') return hint.label + return hint.label.map(o => o.value).join(' ') +} diff --git a/typings/index.d.ts b/typings/index.d.ts index c489edac3b1..86135654409 100644 --- a/typings/index.d.ts +++ b/typings/index.d.ts @@ -2094,6 +2094,119 @@ declare module 'coc.nvim' { */ fromRanges: Range[] } + + export type InlayHintKind = 1 | 2 + + /** + * An inlay hint label part allows for interactive and composite labels + * of inlay hints. + * + * @since 3.17.0 + * @proposed + */ + export interface InlayHintLabelPart { + + /** + * The value of this label part. + */ + value: string + + /** + * The tooltip text when you hover over this label part. Depending on + * the client capability `inlayHint.resolveSupport` clients might resolve + * this property late using the resolve request. + */ + tooltip?: string | MarkupContent + + /** + * An optional source code location that represents this + * label part. + * + * The editor will use this location for the hover and for code navigation + * features: This part will become a clickable link that resolves to the + * definition of the symbol at the given location (not necessarily the + * location itself), it shows the hover that shows at the given location, + * and it shows a context menu with further code navigation commands. + * + * Depending on the client capability `inlayHint.resolveSupport` clients + * might resolve this property late using the resolve request. + */ + location?: Location + + /** + * An optional command for this label part. + * + * Depending on the client capability `inlayHint.resolveSupport` clients + * might resolve this property late using the resolve request. + */ + command?: Command + } + + /** + * Inlay hint information. + * + * @since 3.17.0 + * @proposed + */ + export interface InlayHint { + + /** + * The position of this hint. + */ + position: Position + + /** + * The label of this hint. A human readable string or an array of + * InlayHintLabelPart label parts. + * + * *Note* that neither the string nor the label part can be empty. + */ + label: string | InlayHintLabelPart[] + + /** + * The kind of this hint. Can be omitted in which case the client + * should fall back to a reasonable default. + */ + kind?: InlayHintKind + + /** + * Optional text edits that are performed when accepting this inlay hint. + * + * *Note* that edits are expected to change the document so that the inlay + * hint (or its nearest variant) is now part of the document and the inlay + * hint itself is now obsolete. + */ + textEdits?: TextEdit[] + + /** + * The tooltip text when you hover over this item. + */ + tooltip?: string | MarkupContent + + /** + * Render padding before the hint. + * + * Note: Padding should use the editor's background color, not the + * background color of the hint itself. That means padding can be used + * to visually align/separate an inlay hint. + */ + paddingLeft?: boolean + + /** + * Render padding after the hint. + * + * Note: Padding should use the editor's background color, not the + * background color of the hint itself. That means padding can be used + * to visually align/separate an inlay hint. + */ + paddingRight?: boolean + + /** + * A data entry field that is preserved on a inlay hint between + * a `textDocument/inlayHint` and a `inlayHint/resolve` request. + */ + data?: any + } // }} // nvim interfaces {{ @@ -4200,6 +4313,42 @@ declare module 'coc.nvim' { */ provideLinkedEditingRanges(document: LinesTextDocument, position: Position, token: CancellationToken): ProviderResult } + + /** + * The inlay hints provider interface defines the contract between extensions and + * the inlay hints feature. + */ + export interface InlayHintsProvider { + + /** + * An optional event to signal that inlay hints from this provider have changed. + */ + onDidChangeInlayHints?: Event + + /** + * Provide inlay hints for the given range and document. + * + * *Note* that inlay hints that are not {@link Range.contains contained} by the given range are ignored. + * + * @param document The document in which the command was invoked. + * @param range The range for which inlay hints should be computed. + * @param token A cancellation token. + * @return An array of inlay hints or a thenable that resolves to such. + */ + provideInlayHints(document: TextDocument, range: Range, token: CancellationToken): ProviderResult + + /** + * Given an inlay hint fill in {@link InlayHint.tooltip tooltip}, {@link InlayHint.textEdits text edits}, + * or complete label {@link InlayHintLabelPart parts}. + * + * *Note* that the editor will resolve an inlay hint at most once. + * + * @param hint An inlay hint. + * @param token A cancellation token. + * @return The resolved inlay hint or a thenable that resolves to such. It is OK to return the given `item`. When no result is returned, the given `item` will be used. + */ + resolveInlayHint?(hint: T, token: CancellationToken): ProviderResult + } // }} // Classes {{ @@ -5622,6 +5771,19 @@ declare module 'coc.nvim' { * @return A {@link Disposable} that unregisters this provider when being disposed. */ export function registerLinkedEditingRangeProvider(selector: DocumentSelector, provider: LinkedEditingRangeProvider): Disposable + + /** + * Register a inlay hints provider. + * + * Multiple providers can be registered for a language. In that case providers are asked in + * parallel and the results are merged. A failing provider (rejected promise or exception) will + * not cause a failure of the whole operation. + * + * @param selector A selector that defines the documents this provider is applicable to. + * @param provider An inlay hints provider. + * @return A {@link Disposable} that unregisters this provider when being disposed. + */ + export function registerInlayHintsProvider(selector: DocumentSelector, provider: InlayHintsProvider): Disposable } // }}