✨ feat(vscode): 代码阅读认出 Python 数据类(@dataclass),整块换成「数据类」卡片,选项和字段的默认值、field 参数写成大白话
This commit is contained in:
1 parent
6f2c220908
commit
c5c5d2b517
10 files changed
+709
-78
No files matched your search
@@ -0,0 +1,56 @@
|
||||
# luffy-agent-platform 的 backend/src/luffy_agent/user/repositories.py、commerce/payments/_driver.py、core/sms/services.py、sandbox/runtime.py 节选:dataclasses 库规则的样例
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdminUserRow:
|
||||
"""admin 用户列表单行: 用户 + 归属渠道 + 是否曾被邀请。"""
|
||||
|
||||
user: User
|
||||
channel: AcquisitionChannel
|
||||
referred: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RedirectPayResult:
|
||||
payment_url: str
|
||||
pay_action: Literal["redirect"] = field(init=False, default="redirect")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmsSent:
|
||||
"""短信发送成功。"""
|
||||
|
||||
message_id: str
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SandboxAccess:
|
||||
"""sandbox 类工具的最小资源集 (任何沙箱工具都需要).
|
||||
|
||||
Attributes:
|
||||
sandbox: 本 session 独占的 ``CodeSandbox`` 实例 (Protocol, 实际由
|
||||
adapter 如 daytona / volcengine 提供).
|
||||
sandbox_id: sandbox 唯一 id, 标识工作区所在沙箱.
|
||||
session_id: agent session id (UUID 字符串); 工具签名里声明了
|
||||
``session_id`` 参数的沙箱工具由此注入, 日志/链路追踪也用它.
|
||||
app_id: 沙箱所属应用 id; 工作区仓库、侧车、版本线都按它定位. 不挂应用的
|
||||
临时沙箱 (审阅用的制品工作区、MCP 预热) 为 None.
|
||||
binding: 该沙箱当前的 :class:`SandboxBinding`, 构造时按 ``sandbox_id`` 查出
|
||||
(查不到即已销毁)。不是入参 —— 令牌只能由交出沙箱的一方登记.
|
||||
"""
|
||||
|
||||
sandbox: CodeSandbox
|
||||
sandbox_id: str
|
||||
app_id: str | None = None
|
||||
session_id: str | None = None
|
||||
binding: SandboxBinding = field(init=False, compare=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "binding", sandbox_binding(self.sandbox_id))
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { FileNotes } from './notes/fileNotes'
|
||||
import { CodeQa } from './qa/codeQa'
|
||||
import { CodeReaderRegistry } from './registry'
|
||||
import { celeryRule } from './rules/celery'
|
||||
import { dataclassRule } from './rules/dataclass'
|
||||
import { enumRule } from './rules/enum'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
@@ -98,6 +99,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerLibraryRule(sqlalchemyRule),
|
||||
api.registerLibraryRule(celeryRule),
|
||||
api.registerLibraryRule(enumRule),
|
||||
api.registerLibraryRule(dataclassRule),
|
||||
)
|
||||
return api
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CodeBlock } from '../contract'
|
||||
import type { CodeBlock, Node } from '../contract'
|
||||
import { syntaxChildren } from './pythonTree'
|
||||
|
||||
/** 库规则共用的挑子块的办法。 */
|
||||
|
||||
@@ -19,3 +20,27 @@ export function withLeadingComments(children: readonly CodeBlock[], members: rea
|
||||
return children.filter(child => members.includes(child))
|
||||
.flatMap(member => [...leadingComments(children, member), member])
|
||||
}
|
||||
|
||||
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Python 类主体里一个成员(赋值、只写注解的字段)的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、
|
||||
* 紧跟在它后面的属性 docstring。
|
||||
*/
|
||||
export 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] : []),
|
||||
]
|
||||
}
|
||||
@@ -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', 'enum.ts']))
|
||||
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts', 'dataclass.ts']))
|
||||
})
|
||||
|
||||
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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 { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { celeryRule } from './celery'
|
||||
import { dataclassRule } from './dataclass'
|
||||
import { enumRule } from './enum'
|
||||
import { sqlalchemyRule } from './sqlalchemy'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const SNAPSHOT = readFixture('dataclassModels.py')
|
||||
/** 和扩展里注册的顺序一样。 */
|
||||
const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule]
|
||||
|
||||
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 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 = [
|
||||
'import dataclasses as dc',
|
||||
'import typing',
|
||||
'from dataclasses import KW_ONLY, InitVar, dataclass as data, field',
|
||||
'from typing import ClassVar',
|
||||
'from pydantic.dataclasses import dataclass as pydantic_dataclass',
|
||||
'',
|
||||
'',
|
||||
'@final',
|
||||
'@dc.dataclass(order=True, eq=False, unsafe_hash=True, init=False, repr=False, match_args=False, weakref_slot=True, kw_only=True, frozen=False, slots=SLOTS, extra=1, **OPTIONS)',
|
||||
'class Point(Base, metaclass=Meta):',
|
||||
' """一个点。"""',
|
||||
'',
|
||||
' registry: ClassVar[dict[str, int]] = {}',
|
||||
' limit: typing.ClassVar = 3',
|
||||
' # 横坐标',
|
||||
' # 从左往右',
|
||||
' x: int = 0 # 像素',
|
||||
' y: int = field(default=0, repr=False, compare=False, hash=True, metadata={"unit": "px"})',
|
||||
' """纵坐标"""',
|
||||
' scale: InitVar[float] = 1.0',
|
||||
' seed: InitVar[int]',
|
||||
' label = "p"',
|
||||
'',
|
||||
' def norm(self) -> float:',
|
||||
' return self.x',
|
||||
'',
|
||||
' _: KW_ONLY',
|
||||
' tags: list[str] = field(default_factory=list, init=False, kw_only=False)',
|
||||
' meta: dict = field(default_factory=lambda: {"a": 1}, hash=False, doc="元数据")',
|
||||
' color: str = "red"',
|
||||
'',
|
||||
'',
|
||||
'@data',
|
||||
'class Empty:',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'@pydantic_dataclass',
|
||||
'class NotOurs:',
|
||||
' x: int',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
describe('dataclasses 库规则(Python)', () => {
|
||||
it('luffy 的数据类:「数据类」卡片,选项写成大白话,字段表是 字段 | 类型 | 默认值 | 说明,方法照常是子块', async () => {
|
||||
const list = await views(SNAPSHOT)
|
||||
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', ...Array.from({ length: 4 }, () => 'dataclasses.dataclass')])
|
||||
expect(propertyLines(properties(named(list, 'AdminUserRow')))).toEqual([
|
||||
'名称: AdminUserRow',
|
||||
'说明: admin 用户列表单行: 用户 + 归属渠道 + 是否曾被邀请。',
|
||||
'访问级别: 公共',
|
||||
'选项: 不可修改',
|
||||
'字段: 3 个',
|
||||
' user | User | | ',
|
||||
' channel | AcquisitionChannel | | ',
|
||||
' referred | 布尔 | | ',
|
||||
])
|
||||
expect(propertyLines(properties(named(list, 'RedirectPayResult'))).slice(2)).toEqual([
|
||||
'选项: 不可修改 · 省内存(固定属性)',
|
||||
'字段: 2 个',
|
||||
' payment_url | 字符串 | | ',
|
||||
' pay_action | Literal["redirect"] | "redirect" · 不进构造函数 | ',
|
||||
])
|
||||
expect(propertyLines(properties(named(list, 'SmsSent'))).slice(-2)).toEqual([
|
||||
' message_id | 字符串 | | ',
|
||||
' raw | 字典[字符串, Any] | 每次新建 dict() | ',
|
||||
])
|
||||
const sandbox = named(list, 'SandboxAccess')
|
||||
const rows = properties(sandbox)
|
||||
expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '选项', '字段'])
|
||||
expect(rows[1].value).toEqual({ markdown: expect.stringMatching(/^sandbox 类工具的最小资源集 \(任何沙箱工具都需要\)\.\n\nAttributes:\n/) })
|
||||
expect(rows.at(-1)?.columns).toEqual(['字段', '类型', '默认值', '说明'])
|
||||
expect(propertyLines(rows).slice(-6)).toEqual([
|
||||
'字段: 5 个',
|
||||
' sandbox | CodeSandbox | | ',
|
||||
' sandbox_id | 字符串 | | ',
|
||||
' app_id | 字符串 | 空 | 空 | ',
|
||||
' session_id | 字符串 | 空 | 空 | ',
|
||||
' binding | SandboxBinding | 不进构造函数 · 不参与比较 · 不显示 | ',
|
||||
])
|
||||
expect(sandbox.children.map(child => [child.kind, child.startLine])).toEqual([['function', 52]])
|
||||
})
|
||||
|
||||
it('筛选:种类是 dataclasses.dataclass、叫「数据类」,访问级别同原来的类;种类标签有自己的颜色,默认收起子块', async () => {
|
||||
const list = await views(SNAPSHOT)
|
||||
const sandbox = named(list, 'SandboxAccess')
|
||||
expect(sandbox.attributes).toEqual({ kind: 'dataclasses.dataclass', access: 'public' })
|
||||
expect(sandbox.badge).toEqual({ label: '数据类', color: { dark: '#ef7fb0', light: '#b3306c' } })
|
||||
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
|
||||
.toContainEqual({ id: 'dataclasses.dataclass', name: '数据类', count: 4 })
|
||||
})
|
||||
|
||||
it('编辑范围是整个类,每行只在一块里能编辑;改卡片写回整个类', async () => {
|
||||
const lines = SNAPSHOT.split('\n')
|
||||
const sandbox = named(await views(SNAPSHOT), 'SandboxAccess')
|
||||
expect(lines[sandbox.startLine]).toBe('@dataclass(frozen=True, slots=True)')
|
||||
expect(lines[sandbox.endLine]).toBe(' object.__setattr__(self, "binding", sandbox_binding(self.sandbox_id))')
|
||||
expect(replaceLines(SNAPSHOT, sandbox.startLine, sandbox.endLine, sandbox.code)).toBe(SNAPSHOT)
|
||||
})
|
||||
|
||||
it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => {
|
||||
const sandbox = named(await views(SNAPSHOT), 'SandboxAccess')
|
||||
expect(SNAPSHOT.slice(sandbox.defines?.startIndex, sandbox.defines?.endIndex)).toBe('SandboxAccess')
|
||||
const fields = properties(sandbox).at(-1)?.items ?? []
|
||||
expect(fields.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual(['sandbox', 'sandbox_id', 'app_id', 'session_id', 'binding'])
|
||||
const [fieldName] = fields[0].cells[0] as Segment[]
|
||||
expect(fieldName).toEqual({ text: 'sandbox', role: 'variable', offset: fields[0].defines?.startIndex })
|
||||
const linked = linkedSegments(properties(sandbox))
|
||||
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['SandboxAccess', 'CodeSandbox', 'SandboxBinding', 'binding']))
|
||||
for (const segment of linked)
|
||||
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
|
||||
})
|
||||
|
||||
it('装饰器的各种写法(别名、dataclasses.dataclass、dc.dataclass)都认,pydantic 的 dataclass 不认;选项只讲写了且不是默认的,不认得的照写', async () => {
|
||||
const reports: string[] = []
|
||||
const list = await views(RULE_CASES, reports)
|
||||
expect(reports).toEqual([])
|
||||
expect(list.map(view => view.kind)).toEqual([...Array.from({ length: 5 }, () => 'import'), 'dataclasses.dataclass', 'dataclasses.dataclass', 'class'])
|
||||
const point = named(list, 'Point')
|
||||
expect(propertyLines(properties(point)).slice(0, 6)).toEqual([
|
||||
'名称: Point',
|
||||
'说明: 一个点。',
|
||||
'访问级别: 公共',
|
||||
'继承: Base, metaclass=Meta',
|
||||
'装饰器: @final',
|
||||
'选项: 可比较大小 · 不按字段比较相等 · 可哈希 · 不生成构造函数 · 不生成文字表示 · match 语句里不能按位置匹配 · 可弱引用 · 只能按名字传参 · slots=SLOTS · extra=1 · **OPTIONS',
|
||||
])
|
||||
expect(propertyLines(properties(named(list, 'Empty')))).toEqual(['名称: Empty', '访问级别: 公共', '字段: 0 个'])
|
||||
const src = [
|
||||
'import dataclasses',
|
||||
'',
|
||||
'',
|
||||
'@dataclasses.dataclass',
|
||||
'class A:',
|
||||
' x: int',
|
||||
'',
|
||||
'',
|
||||
'@other.dataclass',
|
||||
'class B:',
|
||||
' x: int',
|
||||
'',
|
||||
].join('\n')
|
||||
expect((await views(src)).map(view => view.kind)).toEqual(['import', 'dataclasses.dataclass', 'class'])
|
||||
})
|
||||
|
||||
it('字段照 dataclasses 的规则认:ClassVar 不是字段,InitVar 写「仅初始化时使用」,KW_ONLY 后面的只能按名字传参;field(...) 的默认值、default_factory、各参数写成大白话', async () => {
|
||||
const point = named(await views(RULE_CASES), 'Point')
|
||||
expect(propertyLines(properties(point)).slice(6)).toEqual([
|
||||
'字段: 7 个',
|
||||
' x | 整数 | 0 | 横坐标 从左往右 像素',
|
||||
' y | 整数 | 0 · 不显示 · 不参与比较 · 参与哈希 · metadata={"unit": "px"} | 纵坐标',
|
||||
' scale | 浮点数 | 1.0 | 仅初始化时使用',
|
||||
' seed | 整数 | | 仅初始化时使用',
|
||||
' tags | 列表[字符串] | 每次新建 list() · 不进构造函数 · 可按位置传参 | ',
|
||||
' meta | 字典 | 每次新建 {"a": 1} · 不参与哈希 · 只能按名字传参 | 元数据',
|
||||
' color | 字符串 | "red" · 只能按名字传参 | ',
|
||||
])
|
||||
})
|
||||
|
||||
it('并进卡片的是字段(连同讲它的注释)和 KW_ONLY 记号;ClassVar、没注解的类属性、方法(写在字段中间也算)照常是子块', async () => {
|
||||
const point = named(await views(RULE_CASES), 'Point')
|
||||
const lines = RULE_CASES.split('\n')
|
||||
expect(point.children.map(child => lines[child.startLine].trim())).toEqual([
|
||||
'registry: ClassVar[dict[str, int]] = {}',
|
||||
'limit: typing.ClassVar = 3',
|
||||
'label = "p"',
|
||||
'def norm(self) -> float:',
|
||||
])
|
||||
expect(lines[point.startLine]).toBe('@final')
|
||||
expect(lines[point.endLine]).toBe(' color: str = "red"')
|
||||
expect(replaceLines(RULE_CASES, point.startLine, point.endLine, point.code.replace('return self.x', 'return self.y')))
|
||||
.toBe(RULE_CASES.replace('return self.x', 'return self.y'))
|
||||
})
|
||||
|
||||
it('没导入 dataclasses 的文件不认', async () => {
|
||||
const list = await views('@dataclass\nclass A:\n x: int\n')
|
||||
expect(list.map(view => view.kind)).toEqual(['class'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { Access, ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
|
||||
import type { Call, LibraryNames } from './pythonTree'
|
||||
import { memberDocs, withLeadingComments } from './children'
|
||||
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
|
||||
import { hint, joined, name, plain, range, word } from './segments'
|
||||
|
||||
/**
|
||||
* Python 自带 dataclasses 库的规则:`@dataclass`、`@dataclass(...)`、`@dataclasses.dataclass(...)`(从 dataclasses 导入时起的别名也算)
|
||||
* 装饰的类整块换成「数据类」卡片:名称 / 说明 / 访问级别 / 继承 / 装饰器(dataclass 以外的)/ 选项 / 字段表,有才显示(字段总有)。
|
||||
* 字段照 dataclasses 的规则认(见 fieldsOf);字段连同讲它的注释、`_: KW_ONLY` 记号并进卡片,方法、ClassVar、没注解的类属性照常是子块。
|
||||
* 用词都在词表里,按翻译插件的显示语言挑。
|
||||
*/
|
||||
|
||||
const LIBRARY = 'dataclasses'
|
||||
/** 注解里认的名字从这几个库来:ClassVar 在 typing,InitVar、KW_ONLY 在 dataclasses。 */
|
||||
const TYPE_LIBRARIES = ['dataclasses', 'typing', 'typing_extensions']
|
||||
const KIND = 'dataclasses.dataclass'
|
||||
const COLOR: ThemeColor = { dark: '#ef7fb0', light: '#b3306c' }
|
||||
|
||||
/** `@dataclass(...)` 各参数不写时的值:写成这个值等于没写,「选项」里不讲。 */
|
||||
const OPTION_DEFAULTS: Record<string, boolean> = {
|
||||
init: true,
|
||||
repr: true,
|
||||
eq: true,
|
||||
order: false,
|
||||
unsafe_hash: false,
|
||||
frozen: false,
|
||||
match_args: true,
|
||||
kw_only: false,
|
||||
slots: false,
|
||||
weakref_slot: false,
|
||||
}
|
||||
/** `field(...)` 各参数不写时的值;hash 不写是跟着 eq、frozen 走,kw_only 不写是跟着类走,都没有这种值。 */
|
||||
const FIELD_DEFAULTS: Record<string, boolean> = { init: true, repr: true, compare: true }
|
||||
/** `field(...)` 里讲默认值、说明的参数,不算标记。 */
|
||||
const FIELD_VALUES = new Set(['default', 'default_factory', 'doc'])
|
||||
|
||||
/** 一种显示语言的用词。 */
|
||||
interface Words {
|
||||
kind: string
|
||||
labels: Record<'name' | 'doc' | 'access' | 'bases' | 'decorators' | 'options' | 'fields', string>
|
||||
heads: string[]
|
||||
count: (n: number) => string
|
||||
access: Record<Access, string>
|
||||
/** `@dataclass(名=值)` 的译法:值是 True / False 字面量时按「名=True」「名=False」查。 */
|
||||
options: Record<string, string>
|
||||
/** `field(名=值)` 的译法,查法同 options;KW_ONLY 记号后面的字段也用「kw_only=True」那个词。 */
|
||||
flags: Record<string, string>
|
||||
/** `default_factory=f`:「每次新建 f()」。 */
|
||||
factory: string
|
||||
/** `InitVar[...]` 的字段写在说明前面。 */
|
||||
initOnly: string
|
||||
}
|
||||
|
||||
const ZH: Words = {
|
||||
kind: '数据类',
|
||||
labels: { name: '名称', doc: '说明', access: '访问级别', bases: '继承', decorators: '装饰器', options: '选项', fields: '字段' },
|
||||
heads: ['字段', '类型', '默认值', '说明'],
|
||||
count: n => `${n} 个`,
|
||||
access: { public: '公共', private: '私有', protected: '受保护' },
|
||||
options: {
|
||||
'frozen=True': '不可修改',
|
||||
'slots=True': '省内存(固定属性)',
|
||||
'order=True': '可比较大小',
|
||||
'kw_only=True': '只能按名字传参',
|
||||
'eq=False': '不按字段比较相等',
|
||||
'unsafe_hash=True': '可哈希',
|
||||
'init=False': '不生成构造函数',
|
||||
'repr=False': '不生成文字表示',
|
||||
'match_args=False': 'match 语句里不能按位置匹配',
|
||||
'weakref_slot=True': '可弱引用',
|
||||
},
|
||||
flags: {
|
||||
'init=False': '不进构造函数',
|
||||
'repr=False': '不显示',
|
||||
'compare=False': '不参与比较',
|
||||
'hash=True': '参与哈希',
|
||||
'hash=False': '不参与哈希',
|
||||
'kw_only=True': '只能按名字传参',
|
||||
'kw_only=False': '可按位置传参',
|
||||
},
|
||||
factory: '每次新建',
|
||||
initOnly: '仅初始化时使用',
|
||||
}
|
||||
|
||||
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
|
||||
const WORDS: Record<string, Words> = { zh: ZH }
|
||||
|
||||
interface Env {
|
||||
context: RuleContext
|
||||
words: Words
|
||||
/** dataclasses 库自己和从它导入的名字:认装饰器、`field(...)` */
|
||||
names: LibraryNames
|
||||
/** 注解里的 ClassVar、InitVar、KW_ONLY 导入时起的别名 → 原名 */
|
||||
aliases: Map<string, string>
|
||||
}
|
||||
|
||||
/** 一个字段:类主体里带注解的名字。 */
|
||||
interface Field {
|
||||
block: CodeBlock
|
||||
name: Node
|
||||
annotation: Node
|
||||
value?: Node
|
||||
/** 写在 `_: KW_ONLY` 后面:没单独写 kw_only 时只能按名字传参 */
|
||||
afterKwOnly: boolean
|
||||
}
|
||||
|
||||
// ─── 读语法树 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 装饰器是 dataclass(带不带括号)时给出它的调用;不带括号时 call 没有。 */
|
||||
function dataclassDecorator(decorator: Node, env: Env): { call?: Call } | undefined {
|
||||
const [expression] = syntaxChildren(decorator)
|
||||
const callee = expression?.type === 'call' ? expression.childForFieldName('function') : expression
|
||||
if (libraryName(callee, env.names) !== 'dataclass')
|
||||
return undefined
|
||||
return { call: callOf(expression, new Map()) }
|
||||
}
|
||||
|
||||
/** 注解最外层的名字(`ClassVar[int]` → ClassVar,`dataclasses.InitVar[str]` → InitVar),导入时的别名换回原名。 */
|
||||
function annotationName(annotation: Node, env: Env): string | undefined {
|
||||
return canonicalName(genericOf(annotation)?.head ?? unwrapType(annotation), env.aliases)
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段照 dataclasses 的规则认:类主体里带注解的名字(`x: int`、`x: int = 1`),照源码顺序;
|
||||
* `ClassVar[...]` 是类变量不是字段,`_: KW_ONLY` 是记号不是字段(它后面的字段只能按名字传参)。
|
||||
*/
|
||||
function fieldsOf(children: readonly CodeBlock[], env: Env): { fields: Field[], markers: CodeBlock[] } {
|
||||
const fields: Field[] = []
|
||||
const markers: CodeBlock[] = []
|
||||
let afterKwOnly = false
|
||||
for (const block of children) {
|
||||
if (block.kind !== 'field')
|
||||
continue
|
||||
const [assignment] = syntaxChildren(block.node)
|
||||
const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
const annotation = assignment?.childForFieldName('type')
|
||||
if (!assignment || target?.type !== 'identifier' || !annotation)
|
||||
continue
|
||||
const kind = annotationName(annotation, env)
|
||||
if (kind === 'ClassVar')
|
||||
continue
|
||||
if (kind === 'KW_ONLY') {
|
||||
afterKwOnly = true
|
||||
markers.push(block)
|
||||
continue
|
||||
}
|
||||
fields.push({ block, name: target, annotation, value: assignment.childForFieldName('right') ?? undefined, afterKwOnly })
|
||||
}
|
||||
return { fields, markers }
|
||||
}
|
||||
|
||||
// ─── 写成属性表 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 一个参数(`名=值`):值是 True / False 字面量、词表里有译法的写译法;等于不写时的值的不写(undefined);
|
||||
* 别的照写:`名=值`,值交给翻译插件翻。
|
||||
*/
|
||||
function keywordText(key: string, value: Node, known: Record<string, string>, defaults: Record<string, boolean>, env: Env): Inline[] | undefined {
|
||||
const literal = value.type === 'true' ? true : value.type === 'false' ? false : undefined
|
||||
if (literal !== undefined) {
|
||||
const text = known[`${key}=${literal ? 'True' : 'False'}`]
|
||||
if (text)
|
||||
return [word(text)]
|
||||
if (defaults[key] === literal)
|
||||
return undefined
|
||||
}
|
||||
return [plain(`${key}=`), ...env.context.translate(value)]
|
||||
}
|
||||
|
||||
/** 装饰器的参数:写了的、不等于默认的,照写的顺序用 ` · ` 隔开;`**x` 照写。 */
|
||||
function optionsText(call: Call | undefined, env: Env): Inline[] {
|
||||
if (!call)
|
||||
return []
|
||||
const parts = [...call.keywords].map(([key, value]) => keywordText(key, value, env.words.options, OPTION_DEFAULTS, env))
|
||||
return joined([
|
||||
...parts.filter((part): part is Inline[] => part !== undefined),
|
||||
...call.splats.map(value => [plain('**'), ...env.context.translate(value)]),
|
||||
], hint(' · '))
|
||||
}
|
||||
|
||||
/** `default_factory=f`:「每次新建 f()」;不带参数的 lambda 写它返回的东西。 */
|
||||
function factoryText(factory: Node, env: Env): Inline[] {
|
||||
const body = factory.type === 'lambda' && !factory.childForFieldName('parameters') ? factory.childForFieldName('body') : null
|
||||
return [word(env.words.factory), plain(' '), ...(body ? env.context.translate(body) : [...env.context.translate(factory), plain('()')])]
|
||||
}
|
||||
|
||||
/** 值是 `field(...)`(`dataclasses.field(...)`)时是这个调用。 */
|
||||
function fieldCall(value: Node | undefined, env: Env): Call | undefined {
|
||||
const callee = value?.type === 'call' ? value.childForFieldName('function') : null
|
||||
return libraryName(callee, env.names) === 'field' ? callOf(value, new Map()) : undefined
|
||||
}
|
||||
|
||||
/** 默认值一格:默认值(`= x`、`field(default=x)`、`field(default_factory=f)`),后面接 field 的别的参数、KW_ONLY 带来的「只能按名字传参」,` · ` 隔开。 */
|
||||
function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
|
||||
const { context, words } = env
|
||||
const parts: Inline[][] = []
|
||||
if (!call && field.value)
|
||||
parts.push(context.translate(field.value))
|
||||
const value = call?.keywords.get('default')
|
||||
if (value)
|
||||
parts.push(context.translate(value))
|
||||
const factory = call?.keywords.get('default_factory')
|
||||
if (factory)
|
||||
parts.push(factoryText(factory, env))
|
||||
for (const [key, flag] of call?.keywords ?? []) {
|
||||
const text = FIELD_VALUES.has(key) ? undefined : keywordText(key, flag, words.flags, FIELD_DEFAULTS, env)
|
||||
if (text)
|
||||
parts.push(text)
|
||||
}
|
||||
if (field.afterKwOnly && !call?.keywords.has('kw_only'))
|
||||
parts.push([word(words.flags['kw_only=True'])])
|
||||
return joined(parts, hint(' · '))
|
||||
}
|
||||
|
||||
/** 说明一格:`field(doc="…")` 的字符串,没写时是讲它的注释、属性 docstring 的原文(用空格连起来);InitVar 的字段前面写「仅初始化时使用」。 */
|
||||
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, env: Env): Inline[] {
|
||||
const explicit = stringValue(call?.keywords.get('doc'))
|
||||
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
|
||||
const doc: Inline[] = text ? [{ text, role: 'comment' }] : []
|
||||
return initOnly ? joined([[word(env.words.initOnly)], ...(text ? [doc] : [])], hint(' · ')) : doc
|
||||
}
|
||||
|
||||
/** 字段表的一行:字段 | 类型 | 默认值 | 说明。讲的定义是字段名。 */
|
||||
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
|
||||
const call = fieldCall(field.value, env)
|
||||
const initOnly = annotationName(field.annotation, env) === 'InitVar'
|
||||
const type = initOnly ? genericOf(field.annotation)?.args[0] : undefined
|
||||
return {
|
||||
cells: [
|
||||
[name(field.name, 'variable')],
|
||||
env.context.translate(type ?? field.annotation),
|
||||
defaultCell(field, call, env),
|
||||
docCell(field, children, call, initOnly, env),
|
||||
],
|
||||
defines: range(field.name),
|
||||
}
|
||||
}
|
||||
|
||||
function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefined, env: Env): RuleCard {
|
||||
const { words, context } = env
|
||||
const { labels } = words
|
||||
const { facts, children } = block
|
||||
const { fields, markers } = fieldsOf(children, env)
|
||||
const doc = facts.doc && context.doc(facts.doc)
|
||||
const decorators = facts.decorators.filter(other => other !== decorator)
|
||||
const options = optionsText(call, env)
|
||||
const rows: PropertyRow[] = [
|
||||
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
|
||||
...(doc ? [{ label: labels.doc, value: doc }] : []),
|
||||
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
|
||||
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
|
||||
...(decorators.length > 0 ? [{ label: labels.decorators, value: joined(decorators.map(other => context.translate(other)), plain('\n')) }] : []),
|
||||
...(options.length > 0 ? [{ label: labels.options, value: options }] : []),
|
||||
{
|
||||
label: labels.fields,
|
||||
value: [hint(words.count(fields.length))],
|
||||
columns: words.heads,
|
||||
items: fields.map(field => fieldItem(field, children, env)),
|
||||
},
|
||||
]
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: { label: words.kind, color: COLOR },
|
||||
rows,
|
||||
...(facts.name && { defines: range(facts.name) }),
|
||||
absorbs: withLeadingComments(children, [...fields.map(field => field.block), ...markers]),
|
||||
}
|
||||
}
|
||||
|
||||
export const dataclassRule: LibraryRule = {
|
||||
id: LIBRARY,
|
||||
label: 'Python 数据类(dataclasses)',
|
||||
languageIds: ['python'],
|
||||
libraries: [LIBRARY],
|
||||
claim: (block, context) => {
|
||||
if (block.kind !== 'class')
|
||||
return undefined
|
||||
const env: Env = {
|
||||
context,
|
||||
words: WORDS[context.displayLanguage ?? ''] ?? ZH,
|
||||
names: libraryNames(context.imports, LIBRARY),
|
||||
aliases: aliasesOf(context.imports, TYPE_LIBRARIES),
|
||||
}
|
||||
for (const decorator of block.facts.decorators) {
|
||||
const found = dataclassDecorator(decorator, env)
|
||||
if (found)
|
||||
return dataclassCard(block, decorator, found.call, env)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ClassBlock, CodeBlock, EnumMemberFacts, ImportFacts, LibraryRule, Node, RuleFacts } from '../contract'
|
||||
import { leadingComments, withLeadingComments } from './children'
|
||||
import { stringValue, syntaxChildren } from './pythonTree'
|
||||
import type { ClassBlock, CodeBlock, EnumMemberFacts, LibraryRule, Node, RuleFacts } from '../contract'
|
||||
import type { LibraryNames } from './pythonTree'
|
||||
import { memberDocs, withLeadingComments } from './children'
|
||||
import { libraryName, libraryNames, stringValue, syntaxChildren } from './pythonTree'
|
||||
|
||||
/**
|
||||
* Python 自带 enum 库的规则:父类里有 enum 的枚举类型(Enum、IntEnum、StrEnum、Flag、IntFlag、ReprEnum;写成 `enum.X`、
|
||||
@@ -15,37 +16,9 @@ const ENUM_TYPES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag', 'Re
|
||||
/** 值是这几种内置描述符的类属性不是成员。 */
|
||||
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
|
||||
function enumCall(node: Node | null | undefined, names: LibraryNames): string | undefined {
|
||||
return node?.type === 'call' ? libraryName(node.childForFieldName('function'), names) : undefined
|
||||
}
|
||||
|
||||
/** 类主体里给一个名字赋值的子块(`A = 1`、`A: int = 1`、只写注解的 `A: int`):名字和值。 */
|
||||
@@ -82,7 +55,7 @@ function memberName(name: string): boolean {
|
||||
* (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 {
|
||||
function memberOf(block: CodeBlock, ignored: ReadonlySet<string>, names: LibraryNames): { 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))
|
||||
@@ -102,30 +75,9 @@ function memberOf(block: CodeBlock, ignored: ReadonlySet<string>, names: Names):
|
||||
}
|
||||
}
|
||||
|
||||
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */
|
||||
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 {
|
||||
function enumBlock(block: ClassBlock, names: LibraryNames): RuleFacts | undefined {
|
||||
const { facts, children } = block
|
||||
if (!facts.name || !facts.bases.some(base => ENUM_TYPES.has(enumName(base.value, names) ?? '')))
|
||||
if (!facts.name || !facts.bases.some(base => ENUM_TYPES.has(libraryName(base.value, names) ?? '')))
|
||||
return undefined
|
||||
const ignored = ignoredNames(children)
|
||||
const members: EnumMemberFacts[] = []
|
||||
@@ -148,5 +100,5 @@ export const enumRule: LibraryRule = {
|
||||
label: 'Python 枚举(enum)',
|
||||
languageIds: ['python'],
|
||||
libraries: [LIBRARY],
|
||||
claim: (block, context) => block.kind === 'class' ? enumBlock(block, namesOf(context.imports)) : undefined,
|
||||
claim: (block, context) => block.kind === 'class' ? enumBlock(block, libraryNames(context.imports, LIBRARY)) : undefined,
|
||||
}
|
||||
@@ -92,3 +92,48 @@ export function callOf(node: Node | null | undefined, aliases: ReadonlyMap<strin
|
||||
}
|
||||
return { name: canonicalName(node.childForFieldName('function'), aliases), positional, keywords, splats }
|
||||
}
|
||||
|
||||
/** 注解里 `type` 节点只包着一个类型时取里面那个。 */
|
||||
export function unwrapType(node: Node): Node {
|
||||
const inner = node.type === 'type' ? syntaxChildren(node) : []
|
||||
return inner.length === 1 ? inner[0] : node
|
||||
}
|
||||
|
||||
/** 注解是 `X[...]`(`typing.X[...]`)时:X 的节点和方括号里的各项。 */
|
||||
export function genericOf(annotation: Node | undefined): { head: Node, args: Node[] } | undefined {
|
||||
const node = annotation && unwrapType(annotation)
|
||||
if (node?.type === 'generic_type') {
|
||||
const [head, parameters] = syntaxChildren(node)
|
||||
return head && { head, args: syntaxChildren(parameters) }
|
||||
}
|
||||
const head = node?.type === 'subscript' ? node.childForFieldName('value') : null
|
||||
return head ? { head, args: node?.childrenForFieldName('subscript').filter((arg): arg is Node => arg !== null) ?? [] } : undefined
|
||||
}
|
||||
|
||||
/** 这份源码里怎么叫一个库的东西:库自己(`import enum`、`import enum as e`),和从它导入的名字(本地名 → 原名)。 */
|
||||
export interface LibraryNames {
|
||||
modules: Set<string>
|
||||
imported: Map<string, string>
|
||||
}
|
||||
|
||||
export function libraryNames(imports: readonly ImportFacts[], library: string): LibraryNames {
|
||||
const names: LibraryNames = { modules: new Set(), imported: new Map() }
|
||||
for (const facts of imports) {
|
||||
const fromLibrary = 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' && fromLibrary)
|
||||
names.imported.set(item.alias?.text ?? item.name.text, item.name.text)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 节点指的是这个库里的哪个名字:`enum.Enum`、从库导入的 `Enum`(别名换回原名);同名的别处的东西是 undefined。 */
|
||||
export function libraryName(node: Node | null | undefined, names: LibraryNames): 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
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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'
|
||||
import { aliasesOf, callOf, canonicalName, genericOf, lastName, stringValue, syntaxChildren, unwrapType } from './pythonTree'
|
||||
import { hint, joined, name, plain, range, word } from './segments'
|
||||
|
||||
/**
|
||||
@@ -164,22 +164,10 @@ function memberOf(block: CodeBlock): Member | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** 注解里 `type` 节点只包着一个类型时取里面那个。 */
|
||||
function unwrapType(node: Node): Node {
|
||||
const inner = node.type === 'type' ? syntaxChildren(node) : []
|
||||
return inner.length === 1 ? inner[0] : node
|
||||
}
|
||||
|
||||
/** `Mapped[X]`(`so.Mapped[X]`)里的 X;注解不是 Mapped[...] 时没有。 */
|
||||
function mappedType(annotation: Node | undefined): Node | undefined {
|
||||
const node = annotation && unwrapType(annotation)
|
||||
if (node?.type === 'generic_type') {
|
||||
const [head, parameters] = syntaxChildren(node)
|
||||
return lastName(head) === 'Mapped' ? syntaxChildren(parameters)[0] : undefined
|
||||
}
|
||||
if (node?.type === 'subscript' && lastName(node.childForFieldName('value')) === 'Mapped')
|
||||
return node.childForFieldName('subscript') ?? undefined
|
||||
return undefined
|
||||
const generic = genericOf(annotation)
|
||||
return generic && lastName(generic.head) === 'Mapped' ? generic.args[0] : undefined
|
||||
}
|
||||
|
||||
function isNone(node: Node): boolean {
|
||||
|
||||
Reference in new issue
Block a user