Skip to content

Commit a16b6f0

Browse files
committed
fix(snippets): insert nested snippet with cursor change
Cancel snippet session and remove insert range to make sure cursor position is correct for nested snippet.
1 parent 2c0247f commit a16b6f0

File tree

4 files changed

+113
-133
lines changed

4 files changed

+113
-133
lines changed

src/__tests__/snippets/manager.test.ts

+35-14
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
import { Neovim } from '@chemzqm/neovim'
22
import path from 'path'
3-
import { InsertTextMode, Position, Range, TextEdit } from 'vscode-languageserver-protocol'
3+
import { CompletionItem, Disposable, InsertTextFormat, InsertTextMode, Position, Range, TextEdit } from 'vscode-languageserver-protocol'
44
import commandManager from '../../commands'
55
import events from '../../events'
6+
import languages from '../../languages'
67
import Document from '../../model/document'
8+
import { CompletionItemProvider } from '../../provider'
79
import snippetManager, { SnippetManager } from '../../snippets/manager'
810
import { SnippetString } from '../../snippets/string'
11+
import { disposeAll } from '../../util'
912
import window from '../../window'
1013
import workspace from '../../workspace'
1114
import helper from '../helper'
1215

1316
let nvim: Neovim
1417
let doc: Document
18+
let disposables: Disposable[] = []
19+
1520
beforeAll(async () => {
1621
await helper.setup()
1722
nvim = helper.nvim
@@ -24,6 +29,7 @@ afterAll(async () => {
2429
})
2530

2631
afterEach(async () => {
32+
disposeAll(disposables)
2733
await helper.reset()
2834
})
2935

@@ -172,6 +178,34 @@ describe('snippet provider', () => {
172178
let last = await nvim.getVar('last')
173179
expect(last).toBe('i')
174180
})
181+
182+
it('should insert nested snippet on CompleteDone with correct position', async () => {
183+
await snippetManager.insertSnippet('`!p snip.rv = " " * (10 - len(t[1]))`${1:inner}', true, Range.create(0, 0, 0, 0), InsertTextMode.asIs, {})
184+
let bufnr = await nvim.call('bufnr', ['%']) as number
185+
let session = snippetManager.getSession(bufnr)
186+
expect(session.isActive).toBe(true)
187+
let line = await nvim.line
188+
expect(line).toBe(' inner')
189+
let provider: CompletionItemProvider = {
190+
provideCompletionItems: async (): Promise<CompletionItem[]> => [{
191+
label: 'bar',
192+
insertTextFormat: InsertTextFormat.Snippet,
193+
textEdit: { range: Range.create(0, 5, 0, 6), newText: '${1:foobar}' },
194+
preselect: true
195+
}]
196+
}
197+
disposables.push(languages.registerCompletionItemProvider('edits', 'edit', null, provider))
198+
await nvim.input('b')
199+
await helper.waitPopup()
200+
let res = await helper.items()
201+
let idx = res.findIndex(o => o.source?.name == 'edits')
202+
await helper.confirmCompletion(idx)
203+
await session.synchronize()
204+
let m = await nvim.mode
205+
expect(m.mode).toBe('s')
206+
line = await nvim.line
207+
expect(line).toBe(' foobar')
208+
})
175209
})
176210

177211
describe('nextPlaceholder()', () => {
@@ -533,19 +567,6 @@ describe('snippet provider', () => {
533567
})
534568
})
535569

536-
describe('synchronizeSession', () => {
537-
it('should synchronize range on session synchronize', async () => {
538-
let active = await snippetManager.insertSnippet('foo foo$1', true)
539-
expect(active).toBe(true)
540-
let buf = await nvim.buffer
541-
let range = Range.create(0, 4, 0, 7)
542-
let session = snippetManager.getSession(buf.id)
543-
nvim.call('setline', ['.', 'foo" foo'], true)
544-
let newRange = await snippetManager.synchronizeSession(session, range)
545-
expect(newRange).toEqual(Range.create(0, 5, 0, 8))
546-
})
547-
})
548-
549570
describe('dispose()', () => {
550571
it('should dispose', async () => {
551572
let active = await snippetManager.insertSnippet('${1:foo}')

src/__tests__/snippets/snippet.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -565,7 +565,7 @@ describe('CocSnippet', () => {
565565
let p = c.getPlaceholderByIndex(index)
566566
expect(p != null).toBe(true)
567567
p.marker.setOnlyChild(new Text(value))
568-
await c.tmSnippet.update(nvim, p.marker, ultisnip?.noPython ? [] : ['context = None'], CancellationToken.None)
568+
await c.tmSnippet.update(nvim, p.marker, CancellationToken.None)
569569
expect(c.tmSnippet.toString()).toBe(result)
570570
return c
571571
}
@@ -593,7 +593,7 @@ describe('CocSnippet', () => {
593593
let p = c.getPlaceholderByIndex(2)
594594
expect(p).toBeDefined()
595595
p.marker.setOnlyChild(new Text('foo'))
596-
await c.tmSnippet.update(nvim, p.marker, ['context = None'], CancellationToken.None)
596+
await c.tmSnippet.update(nvim, p.marker, CancellationToken.None)
597597
let t = c.tmSnippet.toString()
598598
expect(t.startsWith(first)).toBe(true)
599599
expect(t.split('\n').map(s => s.endsWith('foo'))).toEqual([true, true, true])

src/snippets/manager.ts

+73-115
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,20 @@ import BufferSync from '../model/bufferSync'
77
import { StatusBarItem } from '../model/status'
88
import { UltiSnippetOption } from '../types'
99
import { defaultValue, disposeAll } from '../util'
10-
import { Mutex } from '../util/mutex'
1110
import { deepClone } from '../util/object'
1211
import { emptyRange, toValidRange } from '../util/position'
1312
import { Disposable } from '../util/protocol'
14-
import { getPositionFromEdits } from '../util/textedit'
1513
import window from '../window'
1614
import workspace from '../workspace'
1715
import { executePythonCode, generateContextId, getInitialPythonCode, hasPython } from './eval'
1816
import { SnippetConfig, SnippetSession } from './session'
1917
import { SnippetString } from './string'
20-
import { getAction, normalizeSnippetString, reduceTextEdit, shouldFormat, SnippetFormatOptions, UltiSnippetContext } from './util'
18+
import { getAction, normalizeSnippetString, shouldFormat, SnippetFormatOptions, UltiSnippetContext } from './util'
2119

2220
export class SnippetManager {
2321
private disposables: Disposable[] = []
2422
private _statusItem: StatusBarItem
2523
private bufferSync: BufferSync<SnippetSession>
26-
private mutex: Mutex = new Mutex()
2724
private config: SnippetConfig
2825

2926
public init() {
@@ -112,120 +109,84 @@ export class SnippetManager {
112109
* Insert snippet to specific buffer, ultisnips not supported, and the placeholder is not selected
113110
*/
114111
public async insertBufferSnippet(bufnr: number, snippet: string | SnippetString, range: Range, insertTextMode?: InsertTextMode): Promise<boolean> {
115-
let release = await this.mutex.acquire()
116-
try {
117-
let document = workspace.getAttachedDocument(bufnr)
118-
const session = this.bufferSync.getItem(bufnr)
119-
range = await this.synchronizeSession(session, range)
120-
range = toValidRange(range)
121-
const currentLine = document.getline(range.start.line)
122-
const snippetStr = SnippetString.isSnippetString(snippet) ? snippet.value : snippet
123-
const inserted = await this.normalizeInsertText(document.bufnr, snippetStr, currentLine, insertTextMode)
124-
let isActive = await session.start(inserted, range, false)
125-
release()
126-
return isActive
127-
} catch (e) {
128-
release()
129-
throw e
130-
}
131-
}
132-
133-
/**
134-
* Synchronize session when needed (ex: snippet insert during TextChange),
135-
* the range could be changed
136-
*/
137-
public async synchronizeSession(session: SnippetSession, range: Range): Promise<Range> {
138-
let { document, isActive } = session
139-
if (!isActive) return range
140-
let disposable = document.onDocumentChange(e => {
141-
let changes = e.contentChanges
142-
let { start, end } = range
143-
changes.forEach(change => {
144-
let edit = reduceTextEdit(TextEdit.replace(change.range, change.text), e.original)
145-
start = getPositionFromEdits(start, [edit])
146-
end = getPositionFromEdits(end, [edit])
147-
})
148-
range = Range.create(start, end)
149-
})
150-
await session.forceSynchronize()
151-
disposable.dispose()
152-
return range
112+
let document = workspace.getAttachedDocument(bufnr)
113+
const session = this.bufferSync.getItem(bufnr)
114+
session.cancel()
115+
range = toValidRange(range)
116+
const line = document.getline(range.start.line)
117+
const snippetStr = SnippetString.isSnippetString(snippet) ? snippet.value : snippet
118+
const inserted = await this.normalizeInsertText(document.bufnr, snippetStr, line, insertTextMode)
119+
return await session.start(inserted, range, false)
153120
}
154121

155122
/**
156123
* Insert snippet at current cursor position
157124
*/
158125
public async insertSnippet(snippet: string | SnippetString, select = true, range?: Range, insertTextMode?: InsertTextMode, ultisnip?: UltiSnippetOption): Promise<boolean> {
159126
let { nvim } = workspace
160-
let release = await this.mutex.acquire()
161-
try {
162-
let document = workspace.getAttachedDocument(workspace.bufnr)
163-
const session = this.bufferSync.getItem(document.bufnr)
164-
let context: UltiSnippetContext
165-
if (range) await this.synchronizeSession(session, range)
166-
range = await this.toRange(range)
167-
const currentLine = document.getline(range.start.line)
168-
const snippetStr = SnippetString.isSnippetString(snippet) ? snippet.value : snippet
169-
const inserted = await this.normalizeInsertText(document.bufnr, snippetStr, currentLine, insertTextMode, ultisnip)
170-
let usePy = false
171-
if (ultisnip != null) {
172-
usePy = hasPython(ultisnip) || inserted.includes('`!p')
173-
const bufnr = document.bufnr
174-
context = Object.assign({ range: deepClone(range), line: currentLine }, ultisnip, { id: generateContextId(bufnr) })
175-
if (usePy) {
176-
if (session.placeholder) {
177-
let { start, end } = session.placeholder.range
178-
let last = {
179-
current_text: session.placeholder.value,
180-
start: { line: start.line, col: start.character, character: start.character },
181-
end: { line: end.line, col: end.character, character: end.character }
182-
}
183-
this.nvim.setVar('coc_last_placeholder', last, true)
184-
} else {
185-
this.nvim.call('coc#compat#del_var', ['coc_last_placeholder'], true)
186-
}
187-
const codes = getInitialPythonCode(context)
188-
let preExpand = getAction(ultisnip, 'preExpand')
189-
if (preExpand) {
190-
await executePythonCode(nvim, codes.concat(['snip = coc_ultisnips_dict["PreExpandContext"]()', preExpand]))
191-
const [valid, pos] = await nvim.call('pyxeval', 'snip.getResult()') as [boolean, [number, number]]
192-
// need remove the trigger
193-
if (valid) {
194-
let count = range.end.character - range.start.character
195-
let end = Position.create(pos[0], pos[1])
196-
let start = Position.create(pos[0], Math.max(0, pos[1] - count))
197-
range = Range.create(start, end)
198-
} else {
199-
// trigger removed already
200-
let start = Position.create(pos[0], pos[1])
201-
range = Range.create(start, deepClone(start))
202-
}
203-
} else {
204-
await executePythonCode(nvim, codes)
127+
let document = workspace.getAttachedDocument(workspace.bufnr)
128+
const session = this.bufferSync.getItem(document.bufnr)
129+
let context: UltiSnippetContext
130+
session.cancel()
131+
range = await this.toRange(range)
132+
const currentLine = document.getline(range.start.line)
133+
const snippetStr = SnippetString.isSnippetString(snippet) ? snippet.value : snippet
134+
const inserted = await this.normalizeInsertText(document.bufnr, snippetStr, currentLine, insertTextMode, ultisnip)
135+
let usePy = false
136+
if (ultisnip != null) {
137+
usePy = hasPython(ultisnip) || inserted.includes('`!p')
138+
const bufnr = document.bufnr
139+
context = Object.assign({ range: deepClone(range), line: currentLine }, ultisnip, { id: generateContextId(bufnr) })
140+
if (usePy) {
141+
if (session.placeholder) {
142+
let { start, end } = session.placeholder.range
143+
let last = {
144+
current_text: session.placeholder.value,
145+
start: { line: start.line, col: start.character, character: start.character },
146+
end: { line: end.line, col: end.character, character: end.character }
205147
}
148+
this.nvim.setVar('coc_last_placeholder', last, true)
149+
} else {
150+
this.nvim.call('coc#compat#del_var', ['coc_last_placeholder'], true)
206151
}
207-
// same behavior as Ultisnips
208-
const { start } = range
209-
this.nvim.call('coc#cursor#move_to', [start.line, start.character], true)
210-
if (!emptyRange(range)) {
211-
await document.applyEdits([TextEdit.del(range)])
212-
if (session.isActive) {
213-
await session.synchronize()
214-
// the cursor position may changed on session synchronize.
215-
let pos = await window.getCursorPosition()
216-
range = Range.create(pos, pos)
152+
const codes = getInitialPythonCode(context)
153+
let preExpand = getAction(ultisnip, 'preExpand')
154+
if (preExpand) {
155+
await executePythonCode(nvim, codes.concat(['snip = coc_ultisnips_dict["PreExpandContext"]()', preExpand]))
156+
const [valid, pos] = await nvim.call('pyxeval', 'snip.getResult()') as [boolean, [number, number]]
157+
// need remove the trigger
158+
if (valid) {
159+
let count = range.end.character - range.start.character
160+
let end = Position.create(pos[0], pos[1])
161+
let start = Position.create(pos[0], Math.max(0, pos[1] - count))
162+
range = Range.create(start, end)
217163
} else {
218-
range.end = Position.create(start.line, start.character)
164+
// trigger removed already
165+
let start = Position.create(pos[0], pos[1])
166+
range = Range.create(start, deepClone(start))
219167
}
168+
} else {
169+
await executePythonCode(nvim, codes)
220170
}
221171
}
222-
await session.start(inserted, range, select, context)
223-
release()
224-
return session.isActive
225-
} catch (e) {
226-
release()
227-
throw e
228172
}
173+
// same behavior as Ultisnips
174+
// range could outside snippet range when session synchronize is canceled
175+
const { start } = range
176+
this.nvim.call('coc#cursor#move_to', [start.line, start.character], true)
177+
if (!emptyRange(range)) {
178+
await document.applyEdits([TextEdit.del(range)])
179+
if (session.isActive) {
180+
await session.synchronize()
181+
// the cursor position may changed on session synchronize.
182+
let pos = await window.getCursorPosition()
183+
range = Range.create(pos, pos)
184+
} else {
185+
range.end = Position.create(start.line, start.character)
186+
}
187+
}
188+
await session.start(inserted, range, select, context)
189+
return session.isActive
229190
}
230191

231192
public async selectCurrentPlaceholder(triggerAutocmd = true): Promise<void> {
@@ -286,18 +247,10 @@ export class SnippetManager {
286247
/**
287248
* Exposed for snippet preview
288249
*/
289-
public async resolveSnippet(snippetString: string, ultisnip?: UltiSnippetOption): Promise<string> {
250+
public async resolveSnippet(snippetString: string, ultisnip?: UltiSnippetOption): Promise<string | undefined> {
290251
let session = this.bufferSync.getItem(workspace.bufnr)
291252
if (!session) return
292-
let release = await this.mutex.acquire()
293-
try {
294-
let res = await session.resolveSnippet(this.nvim, snippetString, ultisnip)
295-
release()
296-
return res
297-
} catch (e) {
298-
release()
299-
throw e
300-
}
253+
return await session.resolveSnippet(this.nvim, snippetString, ultisnip)
301254
}
302255

303256
public async normalizeInsertText(bufnr: number, snippetString: string, currentLine: string, insertTextMode: InsertTextMode, ultisnip?: Partial<UltiSnippetOption>): Promise<string> {
@@ -306,7 +259,12 @@ export class SnippetManager {
306259
inserted = snippetString
307260
} else {
308261
const currentIndent = currentLine.match(/^\s*/)[0]
309-
const formatOptions = window.activeTextEditor ? window.activeTextEditor.options : await workspace.getFormatOptions(bufnr) as SnippetFormatOptions
262+
let formatOptions: SnippetFormatOptions
263+
if (bufnr == window.activeTextEditor?.bufnr) {
264+
formatOptions = window.activeTextEditor.options
265+
} else {
266+
formatOptions = await workspace.getFormatOptions(bufnr) as SnippetFormatOptions
267+
}
310268
let opts: Partial<UltiSnippetOption> = ultisnip ?? {}
311269
// trim when option not exists
312270
formatOptions.trimTrailingWhitespace = opts.trimTrailingWhitespace !== false

src/snippets/snippet.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Position, Range } from 'vscode-languageserver-types'
44
import events from '../events'
55
import { LinesTextDocument } from '../model/textdocument'
66
import { TabStopInfo } from '../types'
7-
import { defaultValue, waitNextTick } from '../util'
7+
import { defaultValue, waitWithToken } from '../util'
88
import { adjacentPosition, comparePosition, emptyRange, getEnd, positionInRange, rangeInRange, samePosition } from '../util/position'
99
import { CancellationToken } from '../util/protocol'
1010
import { getPyBlockCode, getResetPythonCode, hasPython } from './eval'
@@ -359,6 +359,7 @@ export class CocSnippet {
359359
}
360360

361361
public async onMarkerUpdate(marker: Marker, token: CancellationToken): Promise<void> {
362+
let ts = Date.now()
362363
while (marker != null) {
363364
if (marker instanceof Placeholder) {
364365
let snip = marker.snippet
@@ -371,7 +372,7 @@ export class CocSnippet {
371372
}
372373
}
373374
// Avoid document change fired during document change event, which may cause unexpected behavior.
374-
await waitNextTick()
375+
await waitWithToken(Math.max(0, 16 - Date.now() + ts), token)
375376
if (token.isCancellationRequested) return
376377
this.synchronize()
377378
}

0 commit comments

Comments
 (0)