✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片

This commit is contained in:
cruldra committed 2026-09-26 18:29:40 +08:00
1 parent 32be7d54b6
commit 5ece24cef9
26 files changed
+1768 -81

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import type { BlockView, RuleRun } from './blockView'
import type { CodeBlock, LibraryRule, RuleBlock } from './contract'
import { describe, expect, it } from 'vitest'
import { fileAttributes } from './attributes'
import { toBlockViews } from './blockView'
import { createPythonAdapter } from './languages/python'
import { activeRules, fromLibrary, importsIn } from './libraryRules'
import { 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' } }, 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, py, 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('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([])
})
})
describe('库规则:认领', () => {
it('认领的块换成替身:种类、标签、编辑范围到最后一个并进来的子块,夹在中间的注释也并进来,之后的子块留下', 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' } }, 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: 5,
code: 'class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)',
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 list = flatten(await views(MODEL, [cardRule('orm', {})]))
const owners = new Map<number, string[]>()
for (const view of list) {
for (let line = view.startLine; line <= view.endLine; line++)
owners.set(line, [...owners.get(line) ?? [], view.id])
}
expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([])
})
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, endLine: 2 })
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', '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('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', 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: 'comment', name: '注释', count: 1 },
{ id: 'variable', name: '变量', count: 1 },
{ id: 'function', name: '函数', count: 1 },
{ id: 'return', name: '返回', count: 1 },
])
})
})