🐛 fix(vscode): 跳到定义时对准名字并闪
This commit is contained in:
1 parent
06df8919c8
commit
fc403d5b20
6 files changed
+307
-50
No files matched your search
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { definitionOffset } from './definitions'
|
||||
|
||||
vi.mock('vscode', () => ({
|
||||
commands: { executeCommand: vi.fn() },
|
||||
EventEmitter: class EventEmitter { event = vi.fn(); fire = vi.fn() },
|
||||
Range: class Range {},
|
||||
Uri: { parse: vi.fn() },
|
||||
window: { createOutputChannel: vi.fn(() => ({ appendLine: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() })) },
|
||||
workspace: { openTextDocument: vi.fn(), asRelativePath: vi.fn() },
|
||||
}))
|
||||
|
||||
const SOURCE = [
|
||||
'export class DeployActionError extends Error {',
|
||||
' status = 1',
|
||||
'}',
|
||||
'export const fetchAppDeployments = async () => 1',
|
||||
].join('\n')
|
||||
|
||||
describe('定义范围的起点不在名字上时,用范围里那个名字', () => {
|
||||
const nameAt = SOURCE.indexOf('fetchAppDeployments')
|
||||
const whole = { start: 0, end: SOURCE.length }
|
||||
|
||||
it('没有选区:在大范围里找到和点击的名字一致的那一处,不用大范围的起点', () => {
|
||||
expect(definitionOffset(SOURCE, whole, undefined, 'fetchAppDeployments')).toBe(nameAt)
|
||||
})
|
||||
|
||||
it('选区起点已经在名字上:用选区,不再往范围更前面找', () => {
|
||||
const earlier = SOURCE.indexOf('DeployActionError')
|
||||
expect(definitionOffset(SOURCE, whole, { start: nameAt, end: nameAt + 'fetchAppDeployments'.length }, 'fetchAppDeployments')).toBe(nameAt)
|
||||
expect(definitionOffset(SOURCE, whole, { start: earlier, end: earlier + 'DeployActionError'.length }, 'DeployActionError')).toBe(earlier)
|
||||
})
|
||||
|
||||
it('选区是一整段、起点不在名字上:仍在这段里找名字', () => {
|
||||
expect(definitionOffset(SOURCE, whole, whole, 'fetchAppDeployments')).toBe(nameAt)
|
||||
})
|
||||
|
||||
it('只是更长名字的前缀不算', () => {
|
||||
const text = 'const fetchAppDeploymentsExtra = fetchAppDeployments'
|
||||
expect(definitionOffset(text, { start: 0, end: text.length }, undefined, 'fetchAppDeployments')).toBe(text.lastIndexOf('fetchAppDeployments'))
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,55 @@ export interface Definition extends DefinitionTarget {
|
||||
lineText: string
|
||||
}
|
||||
|
||||
/** uri 里 position 处名字的定义:交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
|
||||
/**
|
||||
* 定义该落在名字上。选区起点已经在名字上就用它(targetSelectionRange 优先);
|
||||
* 否则在整段范围里找和点击的名字文本一致的那一处。都对不上就退回选区或范围的起点。
|
||||
*/
|
||||
export function definitionOffset(
|
||||
text: string,
|
||||
range: { start: number, end: number },
|
||||
selection: { start: number, end: number } | undefined,
|
||||
name: string,
|
||||
): number {
|
||||
const selected = selection?.start
|
||||
if (name !== '' && selected !== undefined && onName(text, selected, name))
|
||||
return selected
|
||||
if (name !== '') {
|
||||
const found = findName(text, range.start, range.end, name)
|
||||
if (found !== undefined)
|
||||
return found
|
||||
}
|
||||
return selection?.start ?? range.start
|
||||
}
|
||||
|
||||
function onName(text: string, index: number | undefined, name: string): boolean {
|
||||
if (index === undefined || index < 0 || name === '' || index + name.length > text.length)
|
||||
return false
|
||||
if (text.slice(index, index + name.length) !== name)
|
||||
return false
|
||||
return !isIdent(text[index - 1]) && !isIdent(text[index + name.length])
|
||||
}
|
||||
|
||||
function isIdent(ch: string | undefined): boolean {
|
||||
return ch !== undefined && /[\p{L}\p{N}_$]/u.test(ch)
|
||||
}
|
||||
|
||||
/** 范围里第一处和 name 整词一致的下标。name 贴着别的标识符(只是更长名字的前缀)不算。 */
|
||||
function findName(text: string, start: number, end: number, name: string): number | undefined {
|
||||
const sliceEnd = Math.min(text.length, Math.max(start, end))
|
||||
let from = Math.max(0, start)
|
||||
while (from < sliceEnd) {
|
||||
const at = text.indexOf(name, from)
|
||||
if (at < 0 || at + name.length > sliceEnd)
|
||||
return undefined
|
||||
if (onName(text, at, name))
|
||||
return at
|
||||
from = at + 1
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** uri 里 position 处名字的定义:交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。范围起点不在名字上时,改成范围里那个名字。 */
|
||||
export async function languageDefinitions(uri: Uri, position: Position): Promise<FoundLocation[]> {
|
||||
let provided: (Location | LocationLink)[]
|
||||
try {
|
||||
@@ -28,9 +76,54 @@ export async function languageDefinitions(uri: Uri, position: Position): Promise
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
|
||||
return []
|
||||
}
|
||||
return provided.map(item => 'targetUri' in item
|
||||
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
|
||||
: { uri: item.uri, range: item.range })
|
||||
const name = await clickedName(uri, position)
|
||||
const located = await Promise.all(provided.map(async (item): Promise<FoundLocation> => {
|
||||
const link = 'targetUri' in item
|
||||
? { uri: item.targetUri, range: item.targetRange, selection: item.targetSelectionRange }
|
||||
: { uri: item.uri, range: item.range, selection: undefined }
|
||||
return { uri: link.uri, range: await rangeOnName(link.uri, link.range, link.selection, name) }
|
||||
}))
|
||||
return located
|
||||
}
|
||||
|
||||
/** 点击处那个名字的文本。文档打不开、或点的不是一个词时是空串,定义范围就按语言服务给的起点。 */
|
||||
async function clickedName(uri: Uri, position: Position): Promise<string> {
|
||||
try {
|
||||
const document = await workspace.openTextDocument(uri)
|
||||
const word = document.getWordRangeAtPosition(position)
|
||||
return word ? document.getText(word) : ''
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `读不到 ${uri.toString()} 里点击的名字:${err instanceof Error ? err.message : String(err)}` })
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 定义范围的起点不在名字上时,改成范围里真正的名字(见 definitionOffset),只框住这个名字。
|
||||
* 对不上名字就保持原来的范围。目标文件打不开时也保持原样。
|
||||
*/
|
||||
async function rangeOnName(uri: Uri, range: Range, selection: Range | undefined, name: string): Promise<Range> {
|
||||
const fallback = selection ?? range
|
||||
if (name === '')
|
||||
return fallback
|
||||
try {
|
||||
const document = await workspace.openTextDocument(uri)
|
||||
const text = document.getText()
|
||||
const offset = definitionOffset(
|
||||
text,
|
||||
{ start: document.offsetAt(range.start), end: document.offsetAt(range.end) },
|
||||
selection ? { start: document.offsetAt(selection.start), end: document.offsetAt(selection.end) } : undefined,
|
||||
name,
|
||||
)
|
||||
if (!onName(text, offset, name))
|
||||
return fallback
|
||||
return new Range(document.positionAt(offset), document.positionAt(offset + name.length))
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `读不到定义所在的 ${uri.toString()}:${err instanceof Error ? err.message : String(err)}` })
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** 库规则查到的定义(RuleLocation)换成 VS Code 的位置。 */
|
||||
|
||||
Reference in new issue
Block a user