import type { BlockView } from './blockView' import type { DefinitionStart, IndexedName, NameSources, ShownName, TextRange } from './nameResolution' import { describe, expect, it } from 'vitest' import { readBlockViews } from './blockView' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { displayNameIndex, DocumentNames, indexedNames, renderedNames, resolveNames, shiftNames } from './nameResolution' import { enumRule } from './rules/enum' import { MemoryIndexHost, propertyLines, runtime, WASM_DIR } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' import { WorkspaceIndexes } from './workspaceIndex' const py = createPythonAdapter(WASM_DIR) const ts = createTypeScriptAdapter(WASM_DIR) const ROUTES = [ 'async def delete_item(', ' item_id: int,', ' current_user: User = Depends(get_current_user), # @Comment 当前用户', '):', ' if current_user.role != UserRole.ADMIN:', ' raise Forbidden()', '', ].join('\n') function read(source: string): ReturnType { return readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { rules: [enumRule], report: () => {} }) } function allProperties(blocks: readonly BlockView[]): string[] { return blocks.flatMap(block => [...('properties' in block ? propertyLines(block.properties) : []), ...allProperties(block.children)]) } /** 源码里第 n 个 text 的下标 */ function at(source: string, text: string, n = 0): number { let index = -1 for (let i = 0; i <= n; i++) index = source.indexOf(text, index + 1) return index } describe('中文名记号不是说明文字:宿主把语言插件认出的记号交给翻译插件去掉', () => { it('python 枚举成员的行尾注释:只有记号时没有说明,记号前面的字照常是说明;readBlockViews 交出这份源码起了中文名的定义', async () => { const source = [ 'import enum', 'class Role(str, enum.Enum):', ' ADMIN = "admin" # 管理员账号 @Comment 管理员', ' MEMBER = "member" # @Comment 普通成员', ' GUEST = "guest" # 游客', ].join('\n') const { blocks, displayNames } = await read(source) expect(allProperties(blocks).filter(line => line.startsWith(' ') && line.includes('|'))).toEqual([ ' ADMIN | "admin" | 管理员账号', ' MEMBER | "member" | ', ' GUEST | "guest" | 游客', ]) expect(displayNames.map(({ name, text }) => `${source.slice(name.startIndex, name.endIndex)} → ${text}`)).toEqual(['ADMIN → 管理员', 'MEMBER → 普通成员']) }) it('typescript 枚举成员同样去掉;一个成员都没有说明时没有说明这一列', async () => { const source = 'export enum Role {\n ADMIN = \'admin\', // @Comment 管理员\n USER = \'user\', // @Comment 普通用户\n}\n' const { blocks } = await readBlockViews(runtime, ts, typescriptZhTranslator, 'typescript', source) expect(allProperties(blocks).filter(line => line.startsWith(' '))).toEqual([' ADMIN | \'admin\'', ' USER | \'user\'']) }) }) /** 假的语言扩展:各处出现按名字文字认(测试里的名字都不重名),定义按表查。 */ function fakeSources(source: string, options: { highlights?: Record definitions?: Record indexed?: Record asked?: string[] } = {}): NameSources { return { uri: 'file:///w/routes.py', occurrences: async offset => options.highlights?.[offset] ?? [], definitions: async (offset) => { options.asked?.push(`definition ${offset}`) return options.definitions?.[offset] ?? [] }, indexed: async (name) => { options.asked?.push(`indexed ${name}`) return options.indexed?.[name] ?? [] }, } } describe('中文名落到哪些名字上', () => { it('renderedNames:一行译文、属性表的值和各层子行里带下标的名字,下标 → 原文', async () => { const { blocks } = await read(ROUTES) const rendered = renderedNames(blocks) expect(rendered.get(at(ROUTES, 'current_user'))).toBe('current_user') expect(rendered.get(at(ROUTES, 'current_user.role'))).toBe('current_user') expect(rendered.get(at(ROUTES, 'ADMIN'))).toBe('ADMIN') expect(rendered.get(at(ROUTES, 'delete_item'))).toBe('delete_item') }) it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)里带下标的名字也算', async () => { const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n' const rendered = renderedNames((await read(source)).blocks) expect(rendered.get(at(source, 'id='))).toBe('id') expect(rendered.get(at(source, 'user.id'))).toBe('user') }) it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => { const { blocks, displayNames } = await read(ROUTES) const definition = at(ROUTES, 'current_user') const use = at(ROUTES, 'current_user.role') const sources = fakeSources(ROUTES, { highlights: { [definition]: [{ start: definition, end: definition + 12 }, { start: use, end: use + 12 }, { start: 3, end: 5 }, { start: at(ROUTES, 'ADMIN'), end: at(ROUTES, 'ADMIN') + 2 }] } }) const reports: [ShownName[], boolean][] = [] const settled = await resolveNames(displayNames, blocks, sources, (names, final) => reports.push([names, final])) expect(settled).toBe(true) const expected = [ { offset: definition, length: 12, name: '当前用户', definition: true }, { offset: use, length: 12, name: '当前用户' }, ] expect(reports).toEqual([[expected, false], [expected, true]]) }) it('别的文件定义的:原名在索引里(别的文件)的名字才问定义,定义处正好是索引里那个定义才换', async () => { const { blocks } = await read(ROUTES) const admin = at(ROUTES, 'ADMIN') const role = at(ROUTES, 'role') const asked: string[] = [] const models = 'file:///w/models.py' const sources = fakeSources(ROUTES, { asked, indexed: { ADMIN: [{ location: { uri: models, start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }], role: [{ location: { uri: models, start: { line: 11, character: 4 }, end: { line: 11, character: 8 } }, text: '角色' }], User: [{ location: { uri: 'file:///w/routes.py', start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, text: '本文件的不算' }], }, definitions: { [admin]: [{ uri: models, line: 6, character: 4 }], // 定义提供者说 role 在别处(同名的另一个定义) [role]: [{ uri: models, line: 20, character: 4 }], }, }) const reports: ShownName[][] = [] const settled = await resolveNames([], blocks, sources, names => reports.push(names)) expect(settled).toBe(true) expect(reports.at(-1)).toEqual([{ offset: admin, length: 5, name: '管理员' }]) expect(asked.filter(line => line.startsWith('definition')).sort()).toEqual([`definition ${admin}`, `definition ${role}`].sort()) }) it('语言扩展还没起来:定义处自己都查不到出现,或要问的定义一个都没答,算没答完(过一会儿再查)', async () => { const { blocks, displayNames } = await read(ROUTES) expect(await resolveNames(displayNames, blocks, fakeSources(ROUTES), () => {})).toBe(false) const admin = at(ROUTES, 'ADMIN') const indexed = { ADMIN: [{ location: { uri: 'file:///w/models.py', start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }] } expect(await resolveNames([], blocks, fakeSources(ROUTES, { indexed }), () => {})).toBe(false) expect(await resolveNames([], blocks, fakeSources(ROUTES, { indexed, definitions: { [admin]: [{ uri: 'file:///w/other.py', line: 0, character: 0 }] } }), () => {})).toBe(true) }) }) describe('中文名跟着文档版本走', () => { const name = (offset: number, text = '甲', length = 3): ShownName => ({ offset, length, name: text }) it('shiftNames:改动在前面的往后挪,碰到名字的丢掉,后面的不动;紧贴在名字前面插字也往后挪', () => { const names = [name(10), name(20), name(30)] expect(shiftNames(names, [{ offset: 0, length: 0, text: 'xx' }])).toEqual([name(12), name(22), name(32)]) expect(shiftNames(names, [{ offset: 21, length: 1, text: '' }])).toEqual([name(10), name(29)]) expect(shiftNames(names, [{ offset: 33, length: 5, text: '' }, { offset: 10, length: 0, text: 'y' }])).toEqual([name(11), name(21), name(31)]) }) it('每个版本查一次;手上没有时查到一步交一步,有上一版挪过来的就等查完再换;查的时候文档又改了,查到的丢掉', async () => { const delivered: string[] = [] const names = new DocumentNames((version, list) => delivered.push(`${version}:${list.map(item => item.offset).join(',')}`), []) let runs = 0 let finish: (() => void) | undefined const run = (list: ShownName[]) => async (report: (names: ShownName[], final: boolean) => void): Promise => { runs++ report(list.slice(0, 1), false) await new Promise((resolve) => { finish = resolve }) report(list, true) return true } names.resolve(1, run([name(10), name(20)])) names.resolve(1, run([])) expect(runs).toBe(1) finish?.() await Promise.resolve() await Promise.resolve() expect(delivered).toEqual(['1:10', '1:10,20']) expect(names.known(1)).toEqual([name(10), name(20)]) names.edited(2, [{ offset: 0, length: 0, text: 'x' }]) expect(names.known(2)).toEqual([name(11), name(21)]) expect(names.known(1)).toEqual([]) names.resolve(2, run([name(11), name(21), name(40)])) // 版本 2 查到一半文档又改了:查到的丢掉,版本 3 先用挪过的 names.edited(3, [{ offset: 0, length: 1, text: '' }]) finish?.() await Promise.resolve() await Promise.resolve() expect(delivered).toEqual(['1:10', '1:10,20']) expect(names.known(3)).toEqual([name(10), name(20)]) }) it('没答完隔一会儿再查,按给的间隔、最多那么多次;答完了就不再查', async () => { const scheduled: number[] = [] const pending: (() => void)[] = [] const names = new DocumentNames(() => {}, [5, 10], (task, ms) => { scheduled.push(ms) pending.push(task) return setTimeout(() => {}, 0) }) let runs = 0 names.resolve(1, async () => { runs++ return false }) for (let i = 0; i < 4; i++) { await Promise.resolve() pending.shift()?.() } await Promise.resolve() expect(runs).toBe(3) expect(scheduled).toEqual([5, 10]) names.dispose() }) }) describe('工作区里的中文名索引', () => { it('python:别的文件里起了中文名的定义按原名查,给出定义处名字的位置和中文名;没写中文名的文件不解析', async () => { const host = new MemoryIndexHost({ 'app/models.py': 'import enum\n\n\nclass UserRole(str, enum.Enum):\n """角色。"""\n\n ADMIN = "admin" # @Comment 管理员\n', 'app/plain.py': 'ADMIN = 1\n', }, py) const indexes = new WorkspaceIndexes(host) const found = await indexedNames((index, key) => indexes.find(index, key), py, 'ADMIN') expect(found).toEqual([{ location: { uri: 'file:///w/app/models.py', start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }]) expect(await indexedNames((index, key) => indexes.find(index, key), py, 'UserRole')).toEqual([]) expect(host.reads.sort()).toEqual(['app/models.py', 'app/plain.py']) indexes.dispose() }) it('一种 languageId 一份索引;语言插件不认中文名、不管这种文件时没有', () => { expect(displayNameIndex(py, 'python')?.id).toBe('displayNames:python') expect(displayNameIndex(ts, 'typescriptreact')?.include).toBe('**/*.tsx') expect(displayNameIndex(py, 'typescript')).toBeUndefined() const { displayNames: _, ...plain } = py expect(displayNameIndex(plain, 'python')).toBeUndefined() }) it('查到的项带着认的时候给的中文名,库规则的 lookup 照旧只给位置', async () => { const host = new MemoryIndexHost({ 'm.py': 'LIMIT = 1 # @Comment 上限\n' }, py) const indexes = new WorkspaceIndexes(host) const index = displayNameIndex(py, 'python')! expect((await indexes.find(index, 'LIMIT')).map(({ entry }) => entry.text)).toEqual(['上限']) expect(await indexes.lookup(index, 'LIMIT')).toEqual([{ uri: 'file:///w/m.py', start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }]) indexes.dispose() }) })