- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」 - 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它 - 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面 - TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释 - 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
321 lines
17 KiB
TypeScript
321 lines
17 KiB
TypeScript
import type { BlockView, RuleRun } from './blockView'
|
||
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleSlot, RuleWorkspace, Segment } from './contract'
|
||
import { describe, expect, it } from 'vitest'
|
||
import { fileAttributes } from './attributes'
|
||
import { toBlockViews } from './blockView'
|
||
import { replaceLines } from './editText'
|
||
import { createPythonAdapter } from './languages/python'
|
||
import { activeRules, fromLibrary, importsIn, resolveReference } from './libraryRules'
|
||
import { propertyLines, WASM_DIR, withBlocks } from './testSupport'
|
||
import { pythonZhTranslator } from './translators/pythonZh'
|
||
|
||
const py = createPythonAdapter(WASM_DIR)
|
||
|
||
const MODEL = [
|
||
'from sqlalchemy.orm import mapped_column',
|
||
'',
|
||
'class User(Base):',
|
||
' __tablename__ = "users"',
|
||
' # 基本信息',
|
||
' name = mapped_column(String)',
|
||
'',
|
||
' def greet(self) -> str:',
|
||
' return self.name',
|
||
'',
|
||
].join('\n')
|
||
|
||
/** 认领每个类块的假规则:并进它的前两个子块。 */
|
||
function cardRule(id: string, overrides: Partial<RuleBlock> = {}): LibraryRule {
|
||
return {
|
||
id,
|
||
label: id,
|
||
languageIds: ['python'],
|
||
libraries: ['sqlalchemy'],
|
||
claim: block => block.kind === 'class'
|
||
? { kind: `${id}.card`, badge: { label: `${id} 卡片`, color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.slice(0, 1), ...overrides }
|
||
: undefined,
|
||
}
|
||
}
|
||
|
||
const throwing: LibraryRule = {
|
||
id: 'throwing',
|
||
label: '抛错',
|
||
languageIds: ['python'],
|
||
libraries: ['sqlalchemy'],
|
||
claim: () => {
|
||
throw new Error('坏了')
|
||
},
|
||
}
|
||
|
||
async function views(source: string, rules: LibraryRule[], reports: string[] = []): Promise<BlockView[]> {
|
||
const run: RuleRun = { rules, report: message => reports.push(message) }
|
||
return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, run))
|
||
}
|
||
|
||
function flatten(list: BlockView[]): BlockView[] {
|
||
return list.flatMap(view => [view, ...flatten(view.children)])
|
||
}
|
||
|
||
/** 语法树用完即释放,节点要在里面读完。 */
|
||
function activates(source: string, rule: LibraryRule): Promise<boolean> {
|
||
return withBlocks(py, 'python', source, blocks => activeRules([rule], importsIn(blocks)).length > 0)
|
||
}
|
||
|
||
describe('库规则:启用', () => {
|
||
it('来源等于库名、或是它的子模块才算导入了这个库', () => {
|
||
expect(fromLibrary('sqlalchemy', 'sqlalchemy')).toBe(true)
|
||
expect(fromLibrary('sqlalchemy.orm', 'sqlalchemy')).toBe(true)
|
||
expect(fromLibrary('drizzle-orm/pg-core', 'drizzle-orm')).toBe(true)
|
||
expect(fromLibrary('axum::routing', 'axum')).toBe(true)
|
||
expect(fromLibrary('sqlalchemy_utils', 'sqlalchemy')).toBe(false)
|
||
expect(fromLibrary('.sqlalchemy', 'sqlalchemy')).toBe(false)
|
||
})
|
||
|
||
it.each([
|
||
['from sqlalchemy.orm import Mapped, mapped_column', true],
|
||
['import sqlalchemy as sa', true],
|
||
['import os, sqlalchemy', true],
|
||
['def f():\n from sqlalchemy import select\n', true],
|
||
['from sqlalchemy_utils import UUIDType', false],
|
||
['from .sqlalchemy import Base', false],
|
||
['import os', false],
|
||
])('%s → %s', async (source, active) => {
|
||
expect(await activates(source, cardRule('sqlalchemy'))).toBe(active)
|
||
})
|
||
|
||
it('没导入库的文件不问规则', async () => {
|
||
const reports: string[] = []
|
||
const list = await views(MODEL.replace('sqlalchemy.orm', 'other'), [throwing], reports)
|
||
expect(list.map(view => view.kind)).toEqual(['import', 'class'])
|
||
expect(reports).toEqual([])
|
||
})
|
||
|
||
it('libraries 是空的规则不看导入:这种语言的文件都启用', async () => {
|
||
expect(await activates('import os', { ...cardRule('any'), libraries: [] })).toBe(true)
|
||
expect(await activates('x = 1', { ...cardRule('any'), libraries: [] })).toBe(true)
|
||
})
|
||
})
|
||
|
||
describe('库规则:认领', () => {
|
||
it('认领的块换成替身:种类、badge、编辑范围是整个类;规则没并进来的子块(方法)照源码顺序留下,讲并进来的子块的注释跟着并进来', async () => {
|
||
const rule: LibraryRule = {
|
||
...cardRule('orm'),
|
||
claim: (block) => {
|
||
if (block.kind !== 'class')
|
||
return undefined
|
||
const [table, column] = block.children
|
||
return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] }
|
||
},
|
||
}
|
||
const [, card] = await views(MODEL, [rule])
|
||
expect(card).toMatchObject({
|
||
kind: 'orm.card',
|
||
badge: { label: '卡片' },
|
||
startLine: 2,
|
||
endLine: 8,
|
||
code: 'class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)\n\n def greet(self) -> str:\n return self.name',
|
||
attributes: { kind: 'orm.card', access: 'public' },
|
||
properties: [{ label: '名称' }],
|
||
})
|
||
expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'function', 7]])
|
||
})
|
||
|
||
it('两个字段中间写着的方法:预览里照常是卡片的子块;卡片「编辑」写回整个类', async () => {
|
||
const source = [
|
||
'from sqlalchemy.orm import mapped_column',
|
||
'',
|
||
'class User(Base):',
|
||
' id = mapped_column(Integer)',
|
||
'',
|
||
' def greet(self) -> str:',
|
||
' return self.name',
|
||
'',
|
||
' name = mapped_column(String)',
|
||
'',
|
||
].join('\n')
|
||
const fields: LibraryRule = { ...cardRule('orm'), claim: block => block.kind === 'class'
|
||
? { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows: [], absorbs: block.children.filter(child => child.kind === 'variable') }
|
||
: undefined }
|
||
const [, card] = await views(source, [fields])
|
||
expect(card.children.map(child => [child.kind, child.startLine, child.endLine])).toEqual([['function', 5, 6]])
|
||
expect([card.startLine, card.endLine]).toEqual([2, 8])
|
||
expect(replaceLines(source, card.startLine, card.endLine, card.code)).toBe(source)
|
||
const edited = replaceLines(source, card.startLine, card.endLine, card.code.replace('greet', 'hello').replace('String', 'Text'))
|
||
expect(edited).toBe(source.replace('greet', 'hello').replace('String', 'Text'))
|
||
})
|
||
|
||
it('能编辑的范围只在块和它的子孙之间重叠:卡片盖住的是它自己留下的子块,别的块互不重叠', async () => {
|
||
const list = flatten(await views(MODEL, [cardRule('orm', {})]))
|
||
const related = (a: string, b: string): boolean => a.startsWith(`${b}.`) || b.startsWith(`${a}.`)
|
||
for (const a of list) {
|
||
for (const b of list.filter(other => other !== a && !related(a.id, other.id)))
|
||
expect(a.startLine > b.endLine || b.startLine > a.endLine, `${a.id} / ${b.id}`).toBe(true)
|
||
}
|
||
expect(list.filter(view => view.id.startsWith('1.')).every(view => view.startLine >= list[1].startLine && view.endLine <= list[1].endLine)).toBe(true)
|
||
})
|
||
|
||
it('几个规则都认同一块时先注册的算', async () => {
|
||
expect((await views(MODEL, [cardRule('first'), cardRule('second')]))[1].kind).toBe('first.card')
|
||
expect((await views(MODEL, [cardRule('second'), cardRule('first')]))[1].kind).toBe('second.card')
|
||
})
|
||
|
||
it('规则抛错:记下来,接着问下一个规则;都没认就照常显示', async () => {
|
||
const reports: string[] = []
|
||
expect((await views(MODEL, [throwing, cardRule('second')], reports))[1].kind).toBe('second.card')
|
||
expect(reports[1]).toBe('库规则 throwing 看第 3 行的 class 块时出错,这块照常显示:坏了')
|
||
|
||
const alone = await views(MODEL, [throwing])
|
||
expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, headerEndLine: 2, endLine: 8 })
|
||
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'variable', 'function'])
|
||
})
|
||
|
||
it('替身不合约定(种类 id 没带规则 id、并进来的不是子块):记下来,照常显示', async () => {
|
||
const reports: string[] = []
|
||
const stranger: CodeBlock[] = []
|
||
await withBlocks(py, 'python', 'x = 1', (blocks) => {
|
||
stranger.push(...blocks)
|
||
})
|
||
const list = await views(MODEL, [cardRule('bad', { kind: 'card' }), cardRule('worse', { absorbs: stranger })], reports)
|
||
expect(list[1].kind).toBe('class')
|
||
expect(reports).toEqual([
|
||
'库规则 bad 给第 3 行的 class 块的替身不合约定,这块照常显示:种类 id「card」没以「bad.」开头',
|
||
'库规则 worse 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块',
|
||
])
|
||
})
|
||
|
||
it('略去的块不出块,后面的块 id 接着往下数;还有子块、挂着注释的块不能略去,记下来、照常显示', async () => {
|
||
const omitting = (kind: string): LibraryRule => ({ ...cardRule('omit'), claim: block => block.kind === kind ? { omit: true } : undefined })
|
||
const list = await views(MODEL, [omitting('import')])
|
||
expect(list.map(view => [view.id, view.kind])).toEqual([['0', 'class']])
|
||
expect(list[0].children.map(child => child.id)).toEqual(['0.0', '0.1', '0.2'])
|
||
|
||
const reports: string[] = []
|
||
expect((await views(MODEL, [omitting('class')], reports)).map(view => view.kind)).toEqual(['import', 'class'])
|
||
expect(reports).toEqual(['库规则 omit 给第 3 行的 class 块的替身不合约定,这块照常显示:要略去的块还有子块'])
|
||
|
||
const commented: string[] = []
|
||
const [, kept] = await views(MODEL, [omitting('variable')], commented)
|
||
expect(kept.children.map(child => [child.kind, child.startLine])).toEqual([['variable', 4], ['function', 7]])
|
||
expect(commented).toEqual(['库规则 omit 给第 6 行的 variable 块的替身不合约定,这块照常显示:要略去的块挂着注释'])
|
||
})
|
||
|
||
it('挂在卡片子行上的子块:照常转成块,记下挂在哪一行的哪个子行;不是留下的子块、没有那一行 / 子行、放了两处的不合约定', async () => {
|
||
const rows = [
|
||
{ label: '字段', value: [], items: [{ cells: [[{ text: 'name', role: 'variable' as const }]] }], columns: ['名称'] },
|
||
{ label: '方法', value: [], items: [{ cells: [[{ text: 'greet', role: 'function' as const }]] }], columns: ['名称'] },
|
||
]
|
||
const slotting = (slots: (block: CodeBlock) => RuleSlot[], id = 'slot'): LibraryRule => ({
|
||
...cardRule(id),
|
||
claim: block => block.kind === 'class'
|
||
? { kind: `${id}.card`, badge: { label: '卡片', color: { dark: '#fff', light: '#000' }, icon: 'box' }, rows, absorbs: block.children.slice(0, 1), slots: slots(block) }
|
||
: undefined,
|
||
})
|
||
const [, card] = await views(MODEL, [slotting(block => [{ block: block.children[1], row: 0, item: 0 }, { block: block.children[2], row: 1, item: 0 }])])
|
||
expect(card.children.map(child => [child.kind, child.slot])).toEqual([['variable', { row: 0, item: 0 }], ['function', { row: 1, item: 0 }]])
|
||
|
||
const reports: string[] = []
|
||
const bad = await views(MODEL, [
|
||
slotting(block => [{ block: block.children[0], row: 1, item: 0 }], 'absorbed'),
|
||
slotting(block => [{ block: block.children[2], row: 5, item: 0 }], 'row'),
|
||
slotting(block => [{ block: block.children[2], row: 1, item: 1 }], 'item'),
|
||
slotting(block => [{ block: block.children[2], row: 1, item: 0 }, { block: block.children[2], row: 0, item: 0 }], 'twice'),
|
||
], reports)
|
||
expect(bad[1].kind).toBe('class')
|
||
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
|
||
'挂在子行上的块不是留下的子块',
|
||
'挂在卡片第 5 行的块,卡片没有这一行',
|
||
'挂在卡片第 1 行第 1 个子行的块,这一行没有这个子行',
|
||
'同一块挂在了卡片里的几处',
|
||
])
|
||
})
|
||
|
||
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
|
||
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
|
||
...cardRule('lib'),
|
||
claim: block => block.kind === 'class' && block.facts.name
|
||
? { as: { kind: 'enum', facts: { name: block.facts.name, access: block.facts.access, decorators: [], bases: [], members: [] } }, absorbs: absorbs(block) }
|
||
: undefined,
|
||
})
|
||
const [, recognized] = await views(MODEL, [asEnum(block => block.children.slice(0, 1))])
|
||
expect(recognized).toMatchObject({ kind: 'enum', startLine: 2, endLine: 8, attributes: { kind: 'enum', access: 'public' } })
|
||
expect(recognized.badge).toBeUndefined()
|
||
expect(propertyLines('properties' in recognized ? recognized.properties : [])).toEqual(['名称: User', '访问级别: 公共', '成员: 无'])
|
||
expect(recognized.children.map(child => child.kind)).toEqual(['variable', 'function'])
|
||
|
||
const reports: string[] = []
|
||
const stranger: CodeBlock[] = []
|
||
await withBlocks(py, 'python', 'x = 1', blocks => stranger.push(...blocks))
|
||
const [, shown] = await views(MODEL, [asEnum(() => stranger)], reports)
|
||
expect(shown.kind).toBe('class')
|
||
expect(reports).toEqual(['库规则 lib 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块'])
|
||
})
|
||
|
||
it('筛选:规则的种类和别的种类一样计数,名字取规则给的名字(badge.label)', async () => {
|
||
const list = await views(MODEL, [cardRule('orm')])
|
||
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([
|
||
{ id: 'import', name: '导入', count: 1 },
|
||
{ id: 'orm.card', name: 'orm 卡片', count: 1 },
|
||
{ id: 'variable', name: '变量', count: 1 },
|
||
{ id: 'function', name: '函数', count: 1 },
|
||
{ id: 'return', name: '返回', count: 1 },
|
||
])
|
||
})
|
||
})
|
||
|
||
describe('库规则:自己查定义的引用', () => {
|
||
const workspace: RuleWorkspace = { lookup: async () => [] }
|
||
const found: RuleLocation[] = [{ uri: 'file:///w/tasks.py', start: { line: 3, character: 4 }, end: { line: 3, character: 8 } }]
|
||
|
||
/** 认领类块、在名称那一行标一个引用的规则。 */
|
||
function referencing(id: string, reference: Segment, resolve?: LibraryRule['resolve']): LibraryRule {
|
||
return {
|
||
...cardRule(id, { rows: [{ label: '名称', value: [reference] }] }),
|
||
...(resolve && { resolve }),
|
||
}
|
||
}
|
||
|
||
it('引用要标自己的规则、规则要给 resolve、段要带下标,不然这块照常显示', async () => {
|
||
const reports: string[] = []
|
||
const resolve = async (): Promise<RuleLocation[]> => found
|
||
const list = await views(MODEL, [
|
||
referencing('other', { text: 'x', role: 'string', offset: 20, reference: { rule: 'celery', key: 'x' } }, resolve),
|
||
referencing('lazy', { text: 'x', role: 'string', offset: 20, reference: { rule: 'lazy', key: 'x' } }),
|
||
referencing('loose', { text: 'x', role: 'string', reference: { rule: 'loose', key: 'x' } }, resolve),
|
||
], reports)
|
||
expect(list[1].kind).toBe('class')
|
||
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
|
||
'引用「x」标的规则是「celery」,不是自己',
|
||
'标了引用「x」却没给 resolve',
|
||
'引用「x」没带源码下标',
|
||
])
|
||
|
||
const good = await views(MODEL, [referencing('good', { text: 'x', role: 'string', offset: 20, reference: { rule: 'good', key: 'x' } }, resolve)])
|
||
expect(good[1].kind).toBe('good.card')
|
||
})
|
||
|
||
it('查引用交给标它的规则;规则不在、没给 resolve、出错时当作没有定义并记下来', async () => {
|
||
const reports: string[] = []
|
||
const asked: [string, RuleWorkspace][] = []
|
||
const rule = referencing('good', { text: 'x', role: 'string', offset: 20 }, async (key, given) => {
|
||
asked.push([key, given])
|
||
return found
|
||
})
|
||
const broken = referencing('broken', { text: 'x', role: 'string', offset: 20 }, async () => {
|
||
throw new Error('索引坏了')
|
||
})
|
||
const rules = [rule, broken, cardRule('plain')]
|
||
const report = (message: string): number => reports.push(message)
|
||
|
||
expect(await resolveReference({ rule: 'good', key: 'a.b' }, rules, workspace, report)).toEqual(found)
|
||
expect(asked).toEqual([['a.b', workspace]])
|
||
expect(await resolveReference({ rule: 'broken', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||
expect(await resolveReference({ rule: 'plain', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||
expect(await resolveReference({ rule: 'gone', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||
expect(reports).toEqual([
|
||
'库规则 broken 查「a.b」的定义时出错:索引坏了',
|
||
'没有能查引用「a.b」的库规则 plain',
|
||
'没有能查引用「a.b」的库规则 gone',
|
||
])
|
||
})
|
||
})
|