✨ feat(vscode): 代码阅读加枚举卡片,Python enum 库规则和 TypeScript enum 画成同一张表

This commit is contained in:
cruldra committed 2026-09-26 20:10:36 +08:00
1 parent 2251e67ef8
commit de355661f0
24 files changed
+858 -83

No files matched your search

+3 -3
View File
@@ -1,4 +1,4 @@
import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
import type { Call } from './pythonTree'
import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -482,7 +482,7 @@ function appOfConf(node: Node | null | undefined, attribute: string): Node | und
}
/** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleBlock {
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleCard {
const { words, context } = env
const { labels, heads } = words
const entries = entriesOf(schedule)
@@ -506,7 +506,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N
// ─── Celery 应用 ──────────────────────────────────────────────────────────────
/** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleBlock {
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleCard {
const { words, context } = env
const { labels } = words
const parameters = new Map<string, Node>()
@@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => {
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
it('目录里有规则', () => {
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts']))
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts']))
})
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
+260
View File
@@ -0,0 +1,260 @@
import type { BlockView } from '../blockView'
import type { Inline, PropertyRow, 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 { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { celeryRule } from './celery'
import { enumRule } from './enum'
import { sqlalchemyRule } from './sqlalchemy'
const py = createPythonAdapter(WASM_DIR)
const ts = createTypeScriptAdapter(WASM_DIR)
const SNAPSHOT = readFixture('enumModels.py')
/** 和扩展里注册的顺序一样。 */
const RULES = [sqlalchemyRule, celeryRule, enumRule]
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) }))
}
function tsViews(source: string): Promise<BlockView[]> {
return withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator, { rules: RULES, report: () => {} }))
}
function properties(view: BlockView | undefined): PropertyRow[] {
if (!view || !('properties' in view))
throw new Error(`块 ${view?.id} 不是属性表`)
return view.properties
}
function named(list: BlockView[], name: string): BlockView {
const found = list.find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`)
if (!found)
throw new Error(`没有 ${name}`)
return found
}
/** 属性表里所有带下标的词(连表格里的格子)。 */
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
const RULE_CASES = [
'from enum import Enum as E, IntFlag, auto, member, nonmember',
'import enum',
'',
'',
'class Color(E):',
' """颜色。"""',
'',
' _ignore_ = ["TEMP"]',
' __slots__ = ()',
' _order_ = "RED GREEN"',
' # 红色',
' # 最常用',
' RED = auto()',
' GREEN = 2 # 绿',
' """绿色。"""',
' TEMP = 9',
' __secret = 1',
' helper = lambda self: 1',
' label = property(lambda self: self.name)',
' table = nonmember({})',
'',
' def describe(self) -> str:',
' return self.name',
'',
' BLUE = member(3)',
' _private = 5',
' ALIAS: int = 4',
' _value_: int',
'',
' class Meta:',
' pass',
'',
' # 收尾',
'',
'',
'@enum.unique',
'class Perm(IntFlag):',
' R = 4',
' W = 2',
'',
].join('\n')
describe('enum 库规则(Python)', () => {
it('luffy 的枚举:和原型一样的枚举卡片,说明取行尾注释,类的 docstring 当说明', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', 'enum', 'enum', 'enum', 'enum', 'enum'])
const commission = named(list, 'CommissionStatus')
expect(commission.badge).toBeUndefined()
expect(propertyLines(properties(commission))).toEqual([
'名称: CommissionStatus',
'访问级别: 公共',
'继承: str, enum.Enum',
'成员: 5 个',
' PENDING | "pending" | 待结算',
' AVAILABLE | "available" | 可提现',
' WITHDRAWING | "withdrawing" | 提现中',
' WITHDRAWN | "withdrawn" | 已提现',
' INVALID | "invalid" | 已失效',
])
expect(properties(commission).at(-1)?.columns).toEqual(['成员', '值', '说明'])
expect(propertyLines(properties(named(list, 'SecretScope')))).toEqual([
'名称: SecretScope',
'说明: 条目作用域: user 全局 / app 级 (同名覆盖 user).',
'访问级别: 公共',
'继承: enum.StrEnum',
'成员: 2 个',
' USER | "user"',
' APP | "app"',
])
expect(properties(named(list, 'SecretScope')).at(-1)?.columns).toEqual(['成员', '值'])
})
it('筛选:种类是 enum、叫「枚举」,访问级别同原来的类;卡片默认的编辑范围是整个类', async () => {
const list = await views(SNAPSHOT)
const commission = named(list, 'CommissionStatus')
expect(commission.attributes).toEqual({ kind: 'enum', access: 'public' })
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
.toContainEqual({ id: 'enum', name: '枚举', count: 5 })
const lines = SNAPSHOT.split('\n')
expect(lines[commission.startLine]).toBe('class CommissionStatus(str, enum.Enum):')
expect(lines[commission.endLine]).toBe(' INVALID = "invalid" # 已失效')
})
it('成员照 Python 的规则认:dunder、sunder、私有名、_ignore_ 里的、lambda、property、nonmember、只写注解的不是成员;auto() 写「自动」,member(x) 的值是 x', async () => {
const list = await views(RULE_CASES)
const color = named(list, 'Color')
expect(propertyLines(properties(color))).toEqual([
'名称: Color',
'说明: 颜色。',
'访问级别: 公共',
'继承: E',
'成员: 5 个',
' RED | 自动 | 红色 最常用',
' GREEN | 2 | 绿 绿色。',
' BLUE | 3 | ',
' _private | 5 | ',
' ALIAS | 4 | ',
])
const auto = properties(color).at(-1)?.items?.[0].cells[1]
expect(auto).toEqual([{ text: '自动', role: 'hint' }])
expect(propertyLines(properties(named(list, 'Perm')))).toEqual([
'名称: Perm',
'访问级别: 公共',
'装饰器: @enum.unique',
'继承: IntFlag',
'成员: 2 个',
' R | 4',
' W | 2',
])
})
it('方法、嵌套类、不是成员的属性不管写在哪都照常是子块;成员前面的注释并进卡片,最后面的注释留下;卡片「编辑」写回整个类', async () => {
const list = await views(RULE_CASES)
const color = named(list, 'Color')
const lines = RULE_CASES.split('\n')
expect(color.children.map(child => lines[child.startLine].trim())).toEqual([
'_ignore_ = ["TEMP"]',
'__slots__ = ()',
'_order_ = "RED GREEN"',
'TEMP = 9',
'__secret = 1',
'helper = lambda self: 1',
'label = property(lambda self: self.name)',
'table = nonmember({})',
'def describe(self) -> str:',
'_value_: int',
'class Meta:',
'# 收尾',
])
expect([lines[color.startLine], lines[color.endLine]]).toEqual(['class Color(E):', ' # 收尾'])
expect(replaceLines(RULE_CASES, color.startLine, color.endLine, color.code)).toBe(RULE_CASES)
const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, color.code.replace('GREEN = 2', 'GREEN = 20'))
expect(edited).toBe(RULE_CASES.replace('GREEN = 2', 'GREEN = 20'))
})
it('跳到定义、高亮出现处:卡片是类名的定义,成员行是成员名的定义,带下标的词都指着源码里的这个名字', async () => {
const list = await views(SNAPSHOT)
const commission = named(list, 'CommissionStatus')
expect(SNAPSHOT.slice(commission.defines?.startIndex, commission.defines?.endIndex)).toBe('CommissionStatus')
const members = properties(commission).at(-1)?.items ?? []
expect(members.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual(['PENDING', 'AVAILABLE', 'WITHDRAWING', 'WITHDRAWN', 'INVALID'])
const linked = linkedSegments(properties(commission))
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['CommissionStatus', 'str', 'enum', 'Enum', 'PENDING', '"pending"']))
for (const segment of linked)
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
})
it('不是 enum 的枚举类型不认:没导入 enum、父类是 SQLAlchemy 的 Enum、父类不是枚举类型', async () => {
const plain = await views('class Color(Enum):\n RED = 1\n')
expect(plain.map(view => view.kind)).toEqual(['class'])
const other = await views('import enum\nfrom sqlalchemy import Enum\n\n\nclass A(Enum):\n X = 1\n\n\nclass B(enum.auto):\n Y = 1\n\n\nclass C(str):\n Z = 1\n')
expect(other.map(view => view.kind)).toEqual(['import', 'import', 'class', 'class', 'class'])
const aliased = await views('import enum as e\n\n\nclass A(e.IntEnum):\n X = 1\n')
expect(aliased.map(view => view.kind)).toEqual(['import', 'enum'])
})
})
describe('枚举:TypeScript 和 Python 同一张卡片', () => {
it('同样的成员:种类、种类名、属性表的行、列名、格子都一样(TypeScript 没有「继承」)', async () => {
const tsSource = 'export enum Status {\n PENDING = "pending", // 待结算\n /** 可提现 */\n AVAILABLE = "available",\n}\n'
const [tsEnum] = await tsViews(tsSource)
const [pending] = properties(tsEnum).at(-1)?.items ?? []
expect(pending.cells[0]).toEqual([{ text: 'PENDING', role: 'variable', offset: tsSource.indexOf('PENDING') }])
expect(pending.defines).toEqual({ startIndex: tsSource.indexOf('PENDING'), endIndex: tsSource.indexOf('PENDING') + 'PENDING'.length })
const [, pyEnum] = await views('import enum\n\n\nclass Status(enum.Enum):\n PENDING = "pending" # 待结算\n # 可提现\n AVAILABLE = "available"\n')
const shape = (view: BlockView): unknown => {
const rows = properties(view).filter(row => row.label !== '继承')
return {
kind: view.kind,
badge: view.badge,
attributes: view.attributes,
lines: propertyLines(rows),
columns: rows.map(row => row.columns),
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(cell).map(segment => segment.role))),
}
}
expect(shape(tsEnum)).toEqual(shape(pyEnum))
expect(propertyLines(properties(tsEnum))).toEqual([
'名称: Status',
'访问级别: 公共',
'成员: 2 个',
' PENDING | "pending" | 待结算',
' AVAILABLE | "available" | 可提现',
])
})
it('typeScript 没写值的成员写算出来的数,算不出来写「自动」,都是说明词', async () => {
const [direction] = await tsViews('enum Direction { Up, Down = 5, Left, Mask = 1 << 2, After }')
expect(propertyLines(properties(direction))).toEqual([
'名称: Direction',
'访问级别: 私有',
'成员: 5 个',
' Up | 0',
' Down | 5',
' Left | 6',
' Mask | 1 << 2',
' After | 自动',
])
expect(properties(direction).at(-1)?.items?.map(item => item.cells[1])).toEqual([
[{ text: '0', role: 'hint' }],
[{ text: '5', role: 'number' }],
[{ text: '6', role: 'hint' }],
expect.any(Array),
[{ text: '自动', role: 'hint' }],
])
})
})
+152
View File
@@ -0,0 +1,152 @@
import type { ClassBlock, CodeBlock, EnumMemberFacts, ImportFacts, LibraryRule, Node, RuleFacts } from '../contract'
import { leadingComments, withLeadingComments } from './children'
import { stringValue, syntaxChildren } from './pythonTree'
/**
* Python 自带 enum 库的规则:父类里有 enum 的枚举类型(Enum、IntEnum、StrEnum、Flag、IntFlag、ReprEnum;写成 `enum.X`、
* 从 enum 导入的名字或它的别名都算,可以和别的父类混着写:`class Status(str, enum.Enum)`)的类,认成契约里的枚举块(EnumFacts):
* 和 TypeScript 的 enum 是同一种块、同一张属性表,用词归翻译插件,规则只给 enum 库的知识——哪些类是枚举、哪些赋值是成员。
* 成员照 Python 的规则认(见 memberOf);成员连同讲它的注释并进来,方法、嵌套类、别的属性照常是子块。
* 父类是同一文件里别的枚举类(`class Sub(Base)`)的认不出来:规则只看这一块。
*/
const LIBRARY = 'enum'
const ENUM_TYPES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag', 'ReprEnum'])
/** 值是这几种内置描述符的类属性不是成员。 */
const DESCRIPTORS = new Set(['property', 'classmethod', 'staticmethod'])
/** 这份源码里怎么叫 enum 库的东西:库自己(`import enum`、`import enum as e`),和从它导入的名字(本地名 → 原名)。 */
interface Names {
modules: Set<string>
imported: Map<string, string>
}
function namesOf(imports: readonly ImportFacts[]): Names {
const names: Names = { modules: new Set(), imported: new Map() }
for (const facts of imports) {
const fromEnum = facts.sources.some(source => source.text === LIBRARY)
for (const item of facts.items) {
if (item.kind === 'module' && item.module.text === LIBRARY)
names.modules.add(item.alias?.text ?? LIBRARY)
else if (item.kind === 'named' && fromEnum)
names.imported.set(item.alias?.text ?? item.name.text, item.name.text)
}
}
return names
}
/** 节点指的是 enum 库里的哪个名字:`enum.Enum`、从 enum 导入的 `Enum`(别名换回原名);别的是 undefined。 */
function enumName(node: Node | null | undefined, names: Names): string | undefined {
if (node?.type === 'identifier')
return names.imported.get(node.text)
const object = node?.type === 'attribute' ? node.childForFieldName('object') : null
return object?.type === 'identifier' && names.modules.has(object.text) ? node?.childForFieldName('attribute')?.text : undefined
}
/** 调用的是 enum 库里的哪个函数(`auto()`、`enum.nonmember(...)`)。 */
function enumCall(node: Node | null | undefined, names: Names): string | undefined {
return node?.type === 'call' ? enumName(node.childForFieldName('function'), names) : undefined
}
/** 类主体里给一个名字赋值的子块(`A = 1`、`A: int = 1`、只写注解的 `A: int`):名字和值。 */
function assignmentOf(block: CodeBlock): { name: Node, value?: Node } | undefined {
if (block.kind !== 'variable' && block.kind !== 'field')
return undefined
const assignment = block.node.firstNamedChild
const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
if (name?.type !== 'identifier')
return undefined
return { name, value: assignment?.childForFieldName('right') ?? undefined }
}
/** `_ignore_` 里列的名字:空格隔开的字符串,或字符串的列表、元组。 */
function ignoredNames(children: readonly CodeBlock[]): Set<string> {
const value = children.map(assignmentOf).find(assignment => assignment?.name.text === '_ignore_')?.value
const text = stringValue(value)
if (text !== undefined)
return new Set(text.split(/\s+/).filter(Boolean))
const items = value?.type === 'list' || value?.type === 'tuple' ? syntaxChildren(value).map(stringValue) : []
return new Set(items.filter((item): item is string => item !== undefined))
}
/** 名字能当成员:不是 `__x__`(dunder)、`_x_`(enum 保留的 sunder)、`__x`(私有,会被改名)。 */
function memberName(name: string): boolean {
const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__')
const sunder = name.length > 2 && name.startsWith('_') && name.endsWith('_') && name[1] !== '_' && name.at(-2) !== '_'
const privateName = name.startsWith('__') && !name.endsWith('__')
return !dunder && !sunder && !privateName
}
/**
* 一个子块是不是成员,是的话它的值:名字能当成员、不在 `_ignore_` 里,赋了值,值不是函数(lambda)、内置描述符
* (property、classmethod、staticmethod)、`nonmember(...)`、连着赋值(`A = B = 1`)。
* `auto()` 的值交给库自动取(没有 value),`member(x)` 的值是 x。
*/
function memberOf(block: CodeBlock, ignored: ReadonlySet<string>, names: Names): { name: Node, value?: Node } | undefined {
const assignment = assignmentOf(block)
const value = assignment?.value
if (!assignment || !value || !memberName(assignment.name.text) || ignored.has(assignment.name.text))
return undefined
const callee = value.type === 'call' ? value.childForFieldName('function') : null
if (value.type === 'lambda' || value.type === 'assignment' || (callee?.type === 'identifier' && DESCRIPTORS.has(callee.text)))
return undefined
switch (enumCall(value, names)) {
case 'nonmember':
return undefined
case 'auto':
return { name: assignment.name }
case 'member':
return { name: assignment.name, value: syntaxChildren(value.childForFieldName('arguments'))[0] }
default:
return { name: assignment.name, value }
}
}
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */
function commentNodes(block: CodeBlock): Node[] {
if (block.node.type !== 'comment')
return syntaxChildren(block.node).slice(0, 1)
const nodes: Node[] = []
for (let node: Node | null = block.node; node?.type === 'comment' && node.endIndex <= block.header.endIndex; node = node.nextSibling)
nodes.push(node)
return nodes
}
/** 成员的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、紧跟在它后面的属性 docstring。 */
function memberDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] {
const trailing = block.node.nextSibling
const doc = block.kind === 'variable' ? block.facts.doc : block.kind === 'field' ? block.doc : undefined
return [
...leadingComments(children, block).flatMap(commentNodes),
...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []),
...(doc ? [doc] : []),
]
}
function enumBlock(block: ClassBlock, names: Names): RuleFacts | undefined {
const { facts, children } = block
if (!facts.name || !facts.bases.some(base => ENUM_TYPES.has(enumName(base.value, names) ?? '')))
return undefined
const ignored = ignoredNames(children)
const members: EnumMemberFacts[] = []
const blocks: CodeBlock[] = []
for (const child of children) {
const member = memberOf(child, ignored, names)
if (!member)
continue
members.push({ ...member, docs: memberDocs(children, child) })
blocks.push(child)
}
return {
as: { kind: 'enum', facts: { name: facts.name, doc: facts.doc, access: facts.access, decorators: facts.decorators, bases: facts.bases, members } },
absorbs: withLeadingComments(children, blocks),
}
}
export const enumRule: LibraryRule = {
id: 'enum',
label: 'Python 枚举(enum)',
languageIds: ['python'],
libraries: [LIBRARY],
claim: (block, context) => block.kind === 'class' ? enumBlock(block, namesOf(context.imports)) : undefined,
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, ThemeColor, TokenRole } from '../contract'
import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, TokenRole } from '../contract'
import type { Call } from './pythonTree'
import { withLeadingComments } from './children'
import { aliasesOf, callOf, canonicalName, lastName, stringValue, syntaxChildren } from './pythonTree'
@@ -414,7 +414,7 @@ function tableRow(label: string, heads: string[], items: PropertyItem[], env: En
return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : []
}
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock {
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard {
const { words, context } = env
const { facts } = block
const { labels } = words