Skip to content

Commit

Permalink
feat(languages): add languages.registerInlayHintsProvider
Browse files Browse the repository at this point in the history
  • Loading branch information
chemzqm committed May 3, 2022
1 parent 32cad71 commit 3a26df0
Show file tree
Hide file tree
Showing 14 changed files with 918 additions and 2 deletions.
2 changes: 2 additions & 0 deletions doc/coc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
4 changes: 4 additions & 0 deletions history.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 2022-05-04

- Add `languages.registerInlayHintsProvider()` for inlay hint support.

# 2022-04-25

- Add `LinkedEditing` support
Expand Down
1 change: 1 addition & 0 deletions plugin/coc.vim
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
233 changes: 233 additions & 0 deletions src/__tests__/handler/inlayHint.test.ts
Original file line number Diff line number Diff line change
@@ -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<Disposable> {
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<void>((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('<C-y>')
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)
})
})
})

File renamed without changes.
3 changes: 3 additions & 0 deletions src/handler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
113 changes: 113 additions & 0 deletions src/handler/inlayHint/buffer.ts
Original file line number Diff line number Diff line change
@@ -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<void>()
public readonly onDidRefresh: Event<void> = 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<InlayHintWithProvider> {
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<void> {
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()
}
}
Loading

0 comments on commit 3a26df0

Please sign in to comment.