import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract' import type { RevealPlan } from './reveal' import type { BlockKind, BlockView, Inline, Occurrence, PropertyItem, PropertyRow, Segment } from './types' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript' import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh' import { occurrencePlan, revealPlan, scrollTopToShow } from './reveal' import { collapsedIds, propertyItemId, propertyRowId } from './viewState' function name(text: string, offset: number): Segment { return { text, role: 'variable', offset } } function word(text: string): Segment { return { text, role: 'keyword' } } function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView { return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation } } function factBlock(id: string, kind: BlockKind, startLine: number, properties: PropertyRow[], children: BlockView[] = []): BlockView { return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, properties } } /** * 0 from luffy_agent.core.db import get_db * 2 def load(path, user): * 3 if not user: * 4 return get_db(path) * 8 LIMIT = 3 */ const BLOCKS: BlockView[] = [ factBlock('0', 'import', 0, [ { label: '来源', value: [name('luffy_agent', 5), { text: '.', role: 'type' }, name('core', 17)] }, { label: '导入项', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 34)]] }] }, ]), factBlock('1', 'function', 2, [ { label: '名称', value: [{ text: 'load', role: 'function', offset: 46 }] }, { label: '异步', value: [{ text: '否', role: 'storage' }] }, { label: '输入参数', value: [{ text: '2 个', role: 'hint' }], items: [{ cells: [[name('path', 51)], []] }, { cells: [[name('user', 57)], []] }] }, ], [ textBlock('1.0', 'if', 3, [word('如果'), word('非'), name('user', 78)], [ textBlock('1.0.0', 'return', 4, [word('返回'), name('get_db', 102), name('path', 109)]), ]), ]), factBlock('2', 'const', 8, [{ label: '名称', value: [name('LIMIT', 130)] }]), ] describe('用键盘跳到高亮的一处出现:滚到哪块、展开哪些', () => { const nowhere = (): boolean => false const at = (start: number, end: number): Occurrence => ({ start, end, kind: 'read' }) it('在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { expect(occurrencePlan(BLOCKS, at(102, 108), nowhere)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('在属性行展开的子行里(导入项、参数):连那一行一起展开;在属性行的值里不用展开', () => { expect(occurrencePlan(BLOCKS, at(34, 40), nowhere)).toEqual({ id: '0', expand: ['0/p1'] }) expect(occurrencePlan(BLOCKS, at(51, 55), nowhere)).toEqual({ id: '1', expand: ['1/p2'] }) expect(occurrencePlan(BLOCKS, at(130, 135), nowhere)).toEqual({ id: '2', expand: [] }) }) it('和段重叠就算(点分模块名的一节、比名字长的段);页面上哪儿都没有时没有', () => { expect(occurrencePlan(BLOCKS, at(18, 20), nowhere)).toEqual({ id: '0', expand: [] }) expect(occurrencePlan(BLOCKS, at(62, 70), nowhere)).toBeUndefined() }) it('被筛掉的、正在编辑的、被盖住的块里的不算;只跳过那一块,它下面画出来的子块照常算', () => { const hidden = new Set(['1.0', '1.0.0']) expect(occurrencePlan(BLOCKS, at(102, 108), id => hidden.has(id))).toBeUndefined() expect(occurrencePlan(BLOCKS, at(51, 55), id => id === '1')).toBeUndefined() expect(occurrencePlan(BLOCKS, at(102, 108), id => id === '1')).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('同一处在页面上显示几次:取页面上的第一处;Markdown 里的不算', () => { const blocks: BlockView[] = [ factBlock('0', 'function', 0, [{ label: '说明', value: { markdown: '见 `get_db`' } }], [ textBlock('0.0', 'return', 1, [word('返回'), name('get_db', 30)]), ]), factBlock('1', 'class', 2, [{ label: '字段', value: [{ text: '1 个', role: 'hint' }], columns: ['字段'], items: [{ cells: [[name('get_db', 30)]] }] }]), ] expect(occurrencePlan(blocks, at(30, 36), nowhere)).toEqual({ id: '0.0', expand: ['0'] }) expect(occurrencePlan(blocks, at(30, 36), id => id === '0.0')).toEqual({ id: '1', expand: ['1/p0'] }) }) }) describe('跳到定义:滚到哪块、展开哪些', () => { it('名字在嵌套块的译文里:目标是那块,展开它所有的祖先块', () => { expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) it('名字在框里(套了几层都算)也是显示着它', () => { const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] } const blocks: BlockView[] = [ textBlock('0', 'return', 0, [word('返回'), { style: 'iteration', label: '列表', segments: [word('遍历'), name('grid', 20), word('取'), inner] }]), ] expect(revealPlan(blocks, 5, 40)).toEqual({ id: '0', expand: [] }) expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] }) }) it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => { const blocks: BlockView[] = [ { id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } }, factBlock('1', 'function', 1, [ { label: '说明', value: { markdown: '同 `get_db`' } }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('get_db', 30)]] }] }, ]), ] expect(revealPlan(blocks, 0, 30)).toEqual({ id: '1', expand: ['1/p1'] }) }) it('翻译插件没标 defines、名字在属性行展开的子行里(参数):闪整块,连那一行一起展开', () => { expect(revealPlan(BLOCKS, 2, 57)).toEqual({ id: '1', expand: ['1/p2'] }) expect(revealPlan(BLOCKS, 0, 34)).toEqual({ id: '0', expand: ['0/p1'] }) }) it('名字在属性行的值里:不用展开属性行', () => { expect(revealPlan(BLOCKS, 2, 46)).toEqual({ id: '1', expand: [] }) expect(revealPlan(BLOCKS, 8, 130)).toEqual({ id: '2', expand: [] }) }) it('页面上没有这个名字:按行找包含它的块,也展开祖先', () => { expect(revealPlan(BLOCKS, 3, 999)).toEqual({ id: '1.0', expand: ['1'] }) expect(revealPlan(BLOCKS, 6, 999)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) expect(revealPlan(BLOCKS, 20, 999)).toEqual({ id: '2', expand: [] }) }) it('函数块默认折叠:目标在函数体里时,要展开的块里有这个函数;展开过一次就记住,不再挡着', () => { const plan = revealPlan(BLOCKS, 4, 102) const hidden = (folds: Record): string[] => plan?.expand.filter(id => collapsedIds(BLOCKS, folds).has(id)) ?? [] expect(hidden({})).toEqual(['1']) expect(hidden({ 1: false })).toEqual([]) expect(hidden({ '1': false, '1.0': true })).toEqual(['1.0']) }) it('按行找:同一行有几块取外层那块;行在所有块之前取第一块;没有块时没有目标', () => { const sameLine = [textBlock('0', 'if', 1, [word('如果')], [textBlock('0.0', 'return', 1, [word('返回')])])] expect(revealPlan(sameLine, 1, 999)).toEqual({ id: '0', expand: [] }) expect(revealPlan(sameLine, 0, 999)).toEqual({ id: '0', expand: [] }) expect(revealPlan([], 0, 0)).toBeUndefined() }) }) describe('跳到定义:闪哪一处(defines)', () => { const range = (startIndex: number, endIndex: number): { startIndex: number, endIndex: number } => ({ startIndex, endIndex }) /** * 0 def f(*args): f 在 4,*args 在 6..11 * 1 a = 1; b = 2 一条语句声明两个变量,a 在 20,b 在 27 */ const blocks: BlockView[] = [ { ...factBlock('0', 'function', 0, [ { label: '名称', value: [name('f', 4)], defines: range(4, 5) }, { label: '输入参数', value: [{ text: '1 个', role: 'hint' }], items: [{ cells: [[name('*args', 6)]], defines: range(6, 11) }] }, ]), defines: range(4, 5), }, factBlock('1', 'variable', 1, [ { label: '名称', value: [name('a', 20)], defines: range(20, 21) }, { label: '名称', value: [name('b', 27)], defines: range(27, 28) }, ]), ] it('定义是块自己的名字:闪整块,属性行也标了它也一样', () => { expect(revealPlan(blocks, 0, 4)).toEqual({ id: '0', expand: [] }) }) it('defines 是一段范围:定义落在中间也算(*args 的定义在 args 上)', () => { expect(revealPlan(blocks, 0, 7)).toEqual({ id: '0', row: '0/p1/0', expand: ['0/p1'] }) }) it('一条语句声明几个变量:只闪那个变量的名称行', () => { expect(revealPlan(blocks, 1, 27)).toEqual({ id: '1', row: '1/p1', expand: [] }) }) }) describe('跳到定义:TS / Python 源码里闪哪一处', () => { const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) /** 不填 defines 的第三方翻译插件:属性表照旧,行和子行都不标讲的是哪个定义。 */ function withoutDefines(translator: Translator): Translator { const strip = (items: PropertyItem[]): PropertyItem[] => items.map(({ cells, items: nested }) => nested ? { cells, items: strip(nested) } : { cells }) return { ...translator, describe: (block, source) => translator.describe(block, source).map(({ label, value, items }) => items ? { label, value, items: strip(items) } : { label, value }), } } /** parentId 下面一层层的子行里 id 是 target 的那个。 */ function findItem(items: PropertyItem[] | undefined, parentId: string, target: string): PropertyItem | undefined { for (const [index, item] of (items ?? []).entries()) { const id = propertyItemId(parentId, index) const found = id === target ? item : findItem(item.items, id, target) if (found) return found } return undefined } function flatten(list: BlockView[]): BlockView[] { return list.flatMap(view => [view, ...flatten(view.children)]) } function text(segments: Segment[]): string { return segments.map(segment => segment.text).join('') } /** 闪的那一处:整块写成它的第一行源码,属性行写成「属性名: 值」,子行各列用 | 隔开。 */ function flashed(views: BlockView[], plan: RevealPlan | undefined): string { const block = flatten(views).find(view => view.id === plan?.id) if (!block || !plan) return '' if (plan.row !== undefined && 'properties' in block) { for (const [index, row] of block.properties.entries()) { const rowId = propertyRowId(block.id, index) if (plan.row === rowId) return `${row.label}: ${text(row.value)}` const item = findItem(row.items, rowId, plan.row) if (item) return item.cells.map(text).join(' | ') } } return block.code.split('\n')[0] } /** 定义提供者说定义在 source 里 needle 开头那个名字上:预览滚到哪、展开哪些、闪哪一处。 */ async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> { const offset = source.indexOf(needle) expect(offset, needle).toBeGreaterThanOrEqual(0) const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) return { plan, flashed: flashed(views, plan) } } const PY = [ 'from app.util import fmt, clean as tidy', '', 'def mask_phone(phone: str) -> str:', ' if len(phone) < 9:', ' return phone', ' for i in range(3):', ' print(i)', ' return fmt(phone)', '', 'LIMIT = 3', 'a, b = 1, 2', ].join('\n') const TS = [ 'import { readFile as rf, stat } from \'node:fs\'', '', 'export function load(path: string, { encoding }: Options): string {', ' for (const line of lines) {', ' use(line, stat)', ' }', ' return rf(path, encoding)', '}', '', 'let a = 1, b = 2', 'const LIMIT = 3', ].join('\n') it('python:参数只闪「输入参数」下它那个子行,导入项只闪它那一项', async () => { const at = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, PY, needle) expect(await at('phone: str')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'phone | 字符串 | ' }) expect(await at('tidy')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' }) expect(await at('clean')).toEqual({ plan: { id: '0', row: '0/p1/1', expand: ['0/p1'] }, flashed: 'clean 作为 tidy' }) }) it('python:函数名、变量名(一个解构模式也算一个)是块自己的名字,循环变量在一行译文里:都闪整块', async () => { const at = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, PY, needle) expect(await at('mask_phone')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'def mask_phone(phone: str) -> str:' }) expect(await at('LIMIT')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'LIMIT = 3' }) expect(await at('b = 1')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'a, b = 1, 2' }) expect(await at('i in range')).toEqual({ plan: { id: '1.1', expand: ['1'] }, flashed: 'for i in range(3):' }) }) it('python:推导式的变量只在框里显示:闪框所在的那块', async () => { const source = ['def ids(users):', ' return [u.id for u in users]'].join('\n') expect(await jump(py, 'python', pythonZhTranslator, source, 'u in users')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'return [u.id for u in users]' }) }) it('python:翻译插件没标 defines 时闪整块,名字在子行里照样展开那一行', async () => { const at = (needle: string): ReturnType => jump(py, 'python', withoutDefines(pythonZhTranslator), PY, needle) expect(await at('phone: str')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'def mask_phone(phone: str) -> str:' }) expect(await at('tidy')).toEqual({ plan: { id: '0', expand: ['0/p1'] }, flashed: 'from app.util import fmt, clean as tidy' }) }) it('typeScript:参数(解构参数里的名字也算)、导入项、多变量声明里的一个,只闪它那一行', async () => { const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, TS, needle) expect(await at('path: string')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'path | 字符串 | ' }) expect(await at('encoding }')).toEqual({ plan: { id: '1', row: '1/p3/1', expand: ['1/p3'] }, flashed: '{ encoding } | Options | ' }) expect(await at('rf,')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'readFile 作为 rf' }) expect(await at('stat }')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'stat' }) expect(await at('b = 2')).toEqual({ plan: { id: '2', row: '2/p3', expand: [] }, flashed: '名称: b' }) }) it('typeScript:函数名、只声明一个的常量是块自己的名字,循环变量在一行译文里:都闪整块', async () => { const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, TS, needle) expect(await at('load')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) expect(await at('LIMIT')).toEqual({ plan: { id: '3', expand: [] }, flashed: 'const LIMIT = 3' }) expect(await at('line of')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'for (const line of lines) {' }) }) it('类:类名是块自己的名字闪整块,泛型参数只闪「类型参数」一行;类默认折叠,跳到类里的方法、参数要先展开它', async () => { const source = [ 'export class Repo extends Base {', ' load(id: T) {', ' return id', ' }', '}', ].join('\n') const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, source, needle) expect(await at('Repo')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'export class Repo extends Base {' }) expect(await at('T>')).toEqual({ plan: { id: '0', row: '0/p3', expand: [] }, flashed: '类型参数: T' }) expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' }) expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' }) const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator)) expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n') const atPy = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, pySource, needle) expect(await atPy('NotFound')).toEqual({ plan: { id: '0', expand: [] }, flashed: 'class NotFound(AppException):' }) expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' }) }) it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => { const source = [ 'from sqlalchemy.orm import Mapped, mapped_column', '', 'class User(Base):', ' __tablename__ = "users"', ' name: Mapped[str] = mapped_column(comment="名字")', '', ' def greet(self):', ' return self.name', ].join('\n') const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { const offset = source.indexOf(needle) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) return { plan, flashed: flashed(views, plan) } } expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' }) expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' }) expect(at('greet')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'def greet(self):' }) expect([...collapsedIds(views, {})].sort()).toEqual(['1', '1.0']) }) it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => { const source = [ 'export const routes = {', ' home: { path: \'/\', page: Home },', ' about,', '}', 'use(routes.home.path)', ].join('\n') const at = (needle: string): ReturnType => jump(ts, 'typescript', typescriptZhTranslator, source, needle) expect(await at('home:')).toEqual({ plan: { id: '0', row: '0/p2/0', expand: ['0/p2'] }, flashed: 'home | 对象 2 项' }) expect(await at('path:')).toEqual({ plan: { id: '0', row: '0/p2/0/0', expand: ['0/p2', '0/p2/0'] }, flashed: 'path | \'/\'' }) expect(await at('about,')).toEqual({ plan: { id: '0', row: '0/p2/1', expand: ['0/p2'] }, flashed: 'about | about' }) expect(await at('Home')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' }) const plain = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), source, needle) expect(await plain('path:')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'export const routes = {' }) const pySource = 'CONFIG = {"db": {"host": host}}' const atPy = (needle: string): ReturnType => jump(py, 'python', pythonZhTranslator, pySource, needle) expect(await atPy('host}')).toEqual({ plan: { id: '0', expand: ['0/p2', '0/p2/0'] }, flashed: 'CONFIG = {"db": {"host": host}}' }) }) it('typeScript:翻译插件没标 defines 时闪整块', async () => { const at = (needle: string): ReturnType => jump(ts, 'typescript', withoutDefines(typescriptZhTranslator), TS, needle) expect(await at('path: string')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'export function load(path: string, { encoding }: Options): string {' }) expect(await at('b = 2')).toEqual({ plan: { id: '2', expand: [] }, flashed: 'let a = 1, b = 2' }) }) }) describe('把目标块滚进视口', () => { it('整块已经在视口里:不滚', () => { expect(scrollTopToShow({ top: 120, bottom: 160 }, 100, 500)).toBeUndefined() }) it('在视口外:放到视口正中', () => { expect(scrollTopToShow({ top: 1000, bottom: 1040 }, 0, 500)).toBe(770) expect(scrollTopToShow({ top: 80, bottom: 120 }, 400, 500)).toBe(0) }) it('露了一半也算在视口外;比视口还高就顶部对齐', () => { expect(scrollTopToShow({ top: 580, bottom: 620 }, 100, 500)).toBe(350) expect(scrollTopToShow({ top: 900, bottom: 1600 }, 0, 500)).toBe(900) }) })