319 lines
18 KiB
TypeScript
319 lines
18 KiB
TypeScript
import type { BlockView } from './blockView'
|
||
import type { LanguageAdapter, SourceRange } from './contract'
|
||
import type { ReferenceSources, SymbolNode } from './symbolLinks'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { createPythonAdapter } from './languages/python'
|
||
import { createTypeScriptAdapter } from './languages/typescript'
|
||
import { importsIn } from './libraryRules'
|
||
import { importedNames, outsideUses, referenceScope, resolveReference, splitReference } from './symbolLinks'
|
||
import { readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
|
||
|
||
const py = createPythonAdapter(WASM_DIR)
|
||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||
|
||
function symbol(name: string, start: number, end: number, children: SymbolNode[] = []): SymbolNode {
|
||
return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children }
|
||
}
|
||
|
||
/**
|
||
* 假的语言扩展:几个文件的文档符号,本文件几处名字的定义,本文件代码里用到、本文件又没定义的名字(名字 → 各处的起点),
|
||
* 语言自带的名字;记下问过哪些。
|
||
*/
|
||
function sources(
|
||
files: Record<string, SymbolNode[]>,
|
||
definitions: Record<number, { uri: string, at: number, length: number }> = {},
|
||
uses: Record<string, number[]> = {},
|
||
builtins: string[] = [],
|
||
): ReferenceSources & { asked: number[] } {
|
||
const asked: number[] = []
|
||
return {
|
||
uri: 'file:///here.py',
|
||
asked,
|
||
symbols: async uri => files[uri] ?? [],
|
||
definition: async (offset) => {
|
||
asked.push(offset)
|
||
const found = definitions[offset]
|
||
return found && { uri: found.uri, selection: { startIndex: found.at, endIndex: found.at + found.length } }
|
||
},
|
||
uses: name => (uses[name] ?? []).map(start => ({ startIndex: start, endIndex: start + name.length })),
|
||
builtin: name => builtins.includes(name),
|
||
}
|
||
}
|
||
|
||
describe('不带路径的引用:拆名字', () => {
|
||
it('拆开 Class.member:名字和成员,单个名字没有成员', () => {
|
||
expect(splitReference('Agent.owner_id')).toEqual({ head: 'Agent', members: ['owner_id'] })
|
||
expect(splitReference('USER')).toEqual({ head: 'USER', members: [] })
|
||
})
|
||
})
|
||
|
||
describe('不带路径的引用:指到哪', () => {
|
||
const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }]
|
||
|
||
it('本文件定义的优先于导入的;不问语言扩展', async () => {
|
||
const found = sources({ 'file:///here.py': [symbol('Agent', 100, 200)] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
|
||
expect(await resolveReference('Agent', undefined, imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 105 } })
|
||
expect(found.asked).toEqual([])
|
||
})
|
||
|
||
it('本文件的 Class.member 在类底下找成员;类有、成员没有就是找不到,不再去导入里找', async () => {
|
||
const here = sources({ 'file:///here.py': [symbol('Agent', 100, 200, [symbol('owner_id', 120, 140)])] }, { 20: { uri: 'file:///models.py', at: 5, length: 5 } })
|
||
expect(await resolveReference('Agent.owner_id', undefined, imports, here)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 120, endIndex: 128 } })
|
||
expect(await resolveReference('Agent.missing', undefined, imports, here)).toBeUndefined()
|
||
expect(here.asked).toEqual([])
|
||
})
|
||
|
||
it('导入语句里的名字不算本文件定义的:问那个名字的定义,Class.member 在定义所在文件的那个类底下找', async () => {
|
||
const files = {
|
||
'file:///here.py': [symbol('Agent', 20, 25)],
|
||
'file:///models.py': [symbol('Other', 0, 4, [symbol('owner_id', 1, 3)]), symbol('Agent', 10, 90, [symbol('owner_id', 30, 50)])],
|
||
}
|
||
const found = sources(files, { 20: { uri: 'file:///models.py', at: 10, length: 5 } })
|
||
expect(await resolveReference('Agent', undefined, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } })
|
||
expect(await resolveReference('Agent.owner_id', undefined, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 30, endIndex: 38 } })
|
||
expect(found.asked).toEqual([20, 20])
|
||
})
|
||
|
||
it('导入的是整个模块(定义落在文件开头)时成员从顶层找', async () => {
|
||
const files = { 'file:///errors.py': [symbol('NotFound', 40, 80)] }
|
||
const found = sources(files, { 7: { uri: 'file:///errors.py', at: 0, length: 0 } })
|
||
expect(await resolveReference('errors.NotFound', undefined, [{ name: 'errors', offset: 7, range: { startIndex: 7, endIndex: 13 } }], found))
|
||
.toEqual({ uri: 'file:///errors.py', selection: { startIndex: 40, endIndex: 48 } })
|
||
})
|
||
|
||
it('本文件没定义、也没导入,或语言扩展答不上:找不到', async () => {
|
||
expect(await resolveReference('Nowhere', undefined, imports, sources({}))).toBeUndefined()
|
||
expect(await resolveReference('Agent', undefined, imports, sources({}))).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('不带路径的引用:从被注释的声明起一层层往外找(KDoc)', () => {
|
||
const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }]
|
||
/** services.py 的样子:AgentService 里套着类 Config 和几个方法,get_default_agent 里有个局部变量;顶层还有个同名的 helper。 */
|
||
const here = (): ReturnType<typeof sources> => sources({
|
||
'file:///here.py': [
|
||
symbol('Agent', 20, 25),
|
||
symbol('AgentService', 100, 900, [
|
||
symbol('Config', 120, 180, [symbol('timeout', 140, 150)]),
|
||
symbol('get_agent_admitted', 200, 300),
|
||
symbol('get_default_agent', 400, 500, [symbol('agent', 450, 460)]),
|
||
symbol('helper', 600, 700),
|
||
]),
|
||
symbol('helper', 950, 990),
|
||
],
|
||
}, { 20: { uri: 'file:///models.py', at: 10, length: 5 } })
|
||
/** 各个声明的名字在源码里的下标:注释写在它的说明里时从这儿往外找。 */
|
||
const SERVICE = 100
|
||
const DEFAULT_AGENT = 400
|
||
const HELPER = 950
|
||
const at = (uri: string, start: number, name: string): unknown => ({ uri, selection: { startIndex: start, endIndex: start + name.length } })
|
||
|
||
it('方法说明里的 [同类的另一个方法] 找得到', async () => {
|
||
expect(await resolveReference('get_agent_admitted', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted'))
|
||
})
|
||
|
||
it('类说明里的 [成员] 找得到;方法说明里的 [自己里面的名字] 也找得到', async () => {
|
||
expect(await resolveReference('get_default_agent', SERVICE, imports, here())).toEqual(at('file:///here.py', 400, 'get_default_agent'))
|
||
expect(await resolveReference('agent', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 450, 'agent'))
|
||
})
|
||
|
||
it('近的先认:方法说明里的 [helper] 是同类的方法,顶层函数说明里的是顶层函数', async () => {
|
||
expect(await resolveReference('helper', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 600, 'helper'))
|
||
expect(await resolveReference('helper', HELPER, imports, here())).toEqual(at('file:///here.py', 950, 'helper'))
|
||
})
|
||
|
||
it('顶层的、导入的照样找得到;导入语句里的名字不算本文件定义的', async () => {
|
||
const found = here()
|
||
expect(await resolveReference('AgentService', DEFAULT_AGENT, imports, found)).toEqual(at('file:///here.py', 100, 'AgentService'))
|
||
expect(await resolveReference('Agent', DEFAULT_AGENT, imports, found)).toEqual(at('file:///models.py', 10, 'Agent'))
|
||
expect(found.asked).toEqual([20])
|
||
})
|
||
|
||
it('同一个名字写在两块的说明里:方法说明里找得到同类的方法,顶层函数说明里、不在哪个声明的注释里都找不到', async () => {
|
||
expect(await resolveReference('get_agent_admitted', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted'))
|
||
expect(await resolveReference('get_agent_admitted', HELPER, imports, here())).toBeUndefined()
|
||
expect(await resolveReference('get_agent_admitted', undefined, imports, here())).toBeUndefined()
|
||
})
|
||
|
||
it('写成 Class.member 的:第一段同样一层层找,再往下找成员', async () => {
|
||
expect(await resolveReference('Config.timeout', DEFAULT_AGENT, imports, here())).toEqual(at('file:///here.py', 140, 'timeout'))
|
||
expect(await resolveReference('AgentService.get_agent_admitted', HELPER, imports, here())).toEqual(at('file:///here.py', 200, 'get_agent_admitted'))
|
||
expect(await resolveReference('Config.timeout', HELPER, imports, here())).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('不带路径的引用:本文件代码里用到的、语言自带的名字', () => {
|
||
const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }]
|
||
const BUILTINS = 'file:///typeshed/builtins.pyi'
|
||
/** load、save 两个函数,helper 里没用到 ValueError;ValueError 在文件顶层、load 里、save 里各用了一处,Agent 是导入的、代码里也用到了。 */
|
||
const here = (definitions: Record<number, { uri: string, at: number, length: number }> = {
|
||
50: { uri: BUILTINS, at: 1000, length: 10 },
|
||
150: { uri: BUILTINS, at: 1000, length: 10 },
|
||
450: { uri: BUILTINS, at: 1000, length: 10 },
|
||
20: { uri: 'file:///models.py', at: 10, length: 5 },
|
||
}): ReturnType<typeof sources> => sources({
|
||
'file:///here.py': [symbol('Agent', 20, 25), symbol('load', 100, 300), symbol('save', 400, 600), symbol('helper', 700, 800)],
|
||
[BUILTINS]: [symbol('ValueError', 1000, 1200, [symbol('args', 1100, 1150)])],
|
||
}, definitions, { ValueError: [50, 150, 450], Agent: [20, 900] }, ['ValueError', 'str'])
|
||
const LOAD = 100
|
||
const SAVE = 400
|
||
const HELPER = 700
|
||
const valueError = { uri: BUILTINS, selection: { startIndex: 1000, endIndex: 1010 } }
|
||
|
||
it('问写在代码里的那处的定义(同 ⌘ + 点击它):先挑用在这段注释说明的那个声明里面的,没有再挑整个文件的第一处', async () => {
|
||
const inLoad = here()
|
||
expect(await resolveReference('ValueError', LOAD, imports, inLoad)).toEqual(valueError)
|
||
expect(inLoad.asked).toEqual([150])
|
||
const inSave = here()
|
||
expect(await resolveReference('ValueError', SAVE, imports, inSave)).toEqual(valueError)
|
||
expect(inSave.asked).toEqual([450])
|
||
const elsewhere = here()
|
||
expect(await resolveReference('ValueError', HELPER, imports, elsewhere)).toEqual(valueError)
|
||
expect(await resolveReference('ValueError', undefined, imports, elsewhere)).toEqual(valueError)
|
||
expect(elsewhere.asked).toEqual([50, 50])
|
||
})
|
||
|
||
it('写成 Class.member 的:在定义所在文件的那个类底下找成员,没有这个成员是找不到,不当语言自带的', async () => {
|
||
expect(await resolveReference('ValueError.args', LOAD, imports, here())).toEqual({ uri: BUILTINS, selection: { startIndex: 1100, endIndex: 1104 } })
|
||
expect(await resolveReference('ValueError.missing', LOAD, imports, here())).toBeUndefined()
|
||
})
|
||
|
||
it('本文件定义的、导入的照旧先认:导入的名字代码里也用到了,问的还是导入语句里那处', async () => {
|
||
const found = here()
|
||
expect(await resolveReference('Agent', LOAD, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } })
|
||
expect(await resolveReference('load', SAVE, imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 104 } })
|
||
expect(found.asked).toEqual([20])
|
||
})
|
||
|
||
it('代码里没写到的语言自带的名字是 builtin(当普通文字),Class.member 看第一段;不是语言自带的找不到', async () => {
|
||
const found = here()
|
||
expect(await resolveReference('str', LOAD, imports, found)).toBe('builtin')
|
||
expect(await resolveReference('str.join', LOAD, imports, found)).toBe('builtin')
|
||
expect(await resolveReference('Nowhere', LOAD, imports, found)).toBeUndefined()
|
||
expect(found.asked).toEqual([])
|
||
})
|
||
|
||
it('代码里写到了、语言扩展却答不上:语言自带的是 builtin,不标成找不到', async () => {
|
||
expect(await resolveReference('ValueError', LOAD, imports, here({}))).toBe('builtin')
|
||
})
|
||
})
|
||
|
||
describe('本文件代码里用到、本文件又没定义的名字(取自语言插件认的绑定)', () => {
|
||
async function usesIn(adapter: LanguageAdapter, languageId: string, source: string): Promise<Map<string, SourceRange[]>> {
|
||
const tree = await runtime.parse(adapter, languageId, source)
|
||
try {
|
||
return outsideUses(adapter.bindings!(tree.rootNode, source, languageId), source)
|
||
}
|
||
finally {
|
||
tree.delete()
|
||
}
|
||
}
|
||
const at = (source: string, text: string, name: string): SourceRange => {
|
||
const start = source.indexOf(text)
|
||
return { startIndex: start, endIndex: start + name.length }
|
||
}
|
||
|
||
it('python:内置异常、内置函数照源码顺序记下各处;注释、字符串、属性名里的不算,本文件定义的不在', async () => {
|
||
const source = [
|
||
'import os',
|
||
'',
|
||
'def load(path):',
|
||
' """读 path,不对时 @throws [ValueError]"""',
|
||
' # ValueError 也写在注释里',
|
||
' if not os.path.exists(path):',
|
||
' raise FileNotFoundError(path)',
|
||
' data = config.ValueError',
|
||
' raise ValueError(len(data))',
|
||
'',
|
||
'def check():',
|
||
' raise ValueError("ValueError")',
|
||
'',
|
||
].join('\n')
|
||
const uses = await usesIn(py, 'python', source)
|
||
expect(uses.get('ValueError')).toEqual([at(source, 'ValueError(len', 'ValueError'), at(source, 'ValueError("', 'ValueError')])
|
||
expect(uses.get('FileNotFoundError')).toEqual([at(source, 'FileNotFoundError', 'FileNotFoundError')])
|
||
expect(uses.get('len')).toEqual([at(source, 'len(', 'len')])
|
||
expect(['path', 'data', 'load', 'check'].filter(name => uses.has(name))).toEqual([])
|
||
})
|
||
|
||
it('typeScript:throw 的 Error、用到的 JSON 记下;注释、字符串、属性名里的不算,本文件定义的不在', async () => {
|
||
const source = [
|
||
'// Error 写在注释里',
|
||
'export function parse(text: string): unknown {',
|
||
' const data = JSON.parse(text)',
|
||
' if (data.Error)',
|
||
' throw new Error(\'Error\')',
|
||
' return data',
|
||
'}',
|
||
'',
|
||
].join('\n')
|
||
const uses = await usesIn(ts, 'typescript', source)
|
||
expect(uses.get('Error')).toEqual([at(source, 'Error(', 'Error')])
|
||
expect(uses.get('JSON')).toEqual([at(source, 'JSON', 'JSON')])
|
||
expect(['parse', 'text', 'data'].filter(name => uses.has(name))).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('语言插件给的语言自带的名字', () => {
|
||
it('python 的是 dir(builtins) 里公开的:内置异常、类型、函数、常量;认异常类照旧只看异常那部分', async () => {
|
||
expect(['ValueError', 'FileNotFoundError', 'str', 'dict', 'len', 'print', 'None', 'NotImplemented'].filter(name => !py.builtins?.has(name))).toEqual([])
|
||
expect(['os', 'Path', 'self', '__import__'].filter(name => py.builtins?.has(name))).toEqual([])
|
||
const exceptions = await withBlocks(py, 'python', 'class A(ValueError):\n pass\n\nclass B(str):\n pass\n', blocks => blocks.map(block => block.kind))
|
||
expect(exceptions).toEqual(['exception', 'class'])
|
||
})
|
||
|
||
it('typeScript 的是 ECMAScript 标准的全局对象,浏览器、Node 另加的不算', () => {
|
||
expect(['Error', 'TypeError', 'RangeError', 'Promise', 'Array', 'Map', 'Set', 'Object', 'JSON', 'Math'].filter(name => !ts.builtins?.has(name))).toEqual([])
|
||
expect(['window', 'document', 'process', 'Buffer'].filter(name => ts.builtins?.has(name))).toEqual([])
|
||
})
|
||
})
|
||
|
||
describe('注释写在哪块里:从哪个声明起往外找', () => {
|
||
function block(anchor: string, defines: number | undefined, children: BlockView[] = []): BlockView {
|
||
return { anchor, children, ...(defines !== undefined && { defines: { startIndex: defines, endIndex: defines + 1 } }) } as BlockView
|
||
}
|
||
const blocks = [
|
||
block('class AgentService', 100, [
|
||
block('class AgentService > function get_default_agent', 400, [
|
||
block('class AgentService > function get_default_agent > if @3fa1c2d0', undefined),
|
||
]),
|
||
]),
|
||
block('comment @0b1c2d3e', undefined),
|
||
]
|
||
|
||
it('有名字的块(声明)是它自己的名字;没名字的(注释、语句)取外层最近的有名字的块', () => {
|
||
expect(referenceScope(blocks, 'class AgentService')).toBe(100)
|
||
expect(referenceScope(blocks, 'class AgentService > function get_default_agent')).toBe(400)
|
||
expect(referenceScope(blocks, 'class AgentService > function get_default_agent > if @3fa1c2d0')).toBe(400)
|
||
})
|
||
|
||
it('顶层没名字的块、块树里没有的块:没有,只从文件顶层找', () => {
|
||
expect(referenceScope(blocks, 'comment @0b1c2d3e')).toBeUndefined()
|
||
expect(referenceScope(blocks, 'class Gone')).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('导入引进来的名字(取自预览已经读出的导入 facts)', () => {
|
||
it('python:from 导入问原名的位置、别名按别名认,整个模块按第一节认、起了别名问最后一节,import * 没有', async () => {
|
||
const source = 'from a.b import C, D as E\nimport os.path\nimport x.y as z\nfrom m import *\n'
|
||
const names = await withBlocks(py, 'python', source, blocks => importedNames(importsIn(blocks)))
|
||
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1), source.slice(item.range.startIndex, item.range.endIndex)])).toEqual([
|
||
['C', 'C', 'C'],
|
||
['E', 'D', 'D as E'],
|
||
['os', 'o', 'os.path'],
|
||
['z', 'y', 'x.y as z'],
|
||
])
|
||
})
|
||
|
||
it('typeScript:具名、默认、命名空间导入', async () => {
|
||
const source = 'import d, { b as c } from \'y\'\nimport * as ns from \'z\'\n'
|
||
const names = await withBlocks(ts, 'typescript', source, blocks => importedNames(importsIn(blocks)))
|
||
expect(names.map(item => [item.name, source.slice(item.offset, item.offset + 1)])).toEqual([['d', 'd'], ['c', 'b'], ['ns', 'n']])
|
||
})
|
||
|
||
it('gate.py 导入的 require_desktop_client、AppException 都在,本文件定义的 LocalRuntimeDisabled 不在', async () => {
|
||
const names = await withBlocks(py, 'python', readFixture('localRuntimeGate.py'), blocks => importedNames(importsIn(blocks)))
|
||
expect(names.map(item => item.name)).toEqual(['annotations', 'Request', 'get_local_runtime_enabled', 'AppException', 'require_desktop_client'])
|
||
})
|
||
})
|