✨ feat(vscode): 代码阅读认出 Python 数据类(@dataclass),整块换成「数据类」卡片,选项和字段的默认值、field 参数写成大白话

This commit is contained in:
cruldra committed 2026-09-26 23:31:06 +08:00
1 parent 6f2c220908
commit c5c5d2b517
10 files changed
+709 -78

No files matched your search

+48 -3
View File
@@ -792,6 +792,49 @@ export interface CodeReaderApi {
INVALID | "invalid" | 已失效
```
### 内置:dataclasses 数据类(Python)
`vscode/src/codeReader/rules/dataclass.ts`,`libraries: ['dataclasses']`(Python 自带的库)。样例是 luffy 的 `user/repositories.py`、`commerce/payments/_driver.py`、`core/sms/services.py`、`sandbox/runtime.py` 里的数据类(节选在 `fixtures/dataclassModels.py`)。种类 `dataclasses.dataclass`,标签「数据类」(暗 #ef7fb0 / 亮 #b3306c),和类块一样默认收起子块。
- 认哪些类:装饰器是 `@dataclass`、`@dataclass(...)`、`@dataclasses.dataclass(...)`(`import dataclasses as dc` 的 `dc.dataclass`、`from dataclasses import dataclass as data` 的 `data` 也算,别名按 `context.imports` 换回)。同名的别处的东西不算(`from pydantic.dataclasses import dataclass`、`@other.dataclass`)。
- 字段照 dataclasses 的规则认:类主体里带注解的名字(`x: int`、`x: int = 1`),照源码顺序。`ClassVar[...]`(`typing.ClassVar`、只写 `ClassVar` 也算)是类变量,不是字段,照常是子块;`InitVar[...]` 是字段,类型写方括号里的,说明前面写「仅初始化时使用」;`_: KW_ONLY` 是记号,不是字段,它后面的字段(没单独写 `kw_only=` 的)在默认值一格写「只能按名字传参」。
- 并进卡片的子块:各个字段连同紧挨在它前面的注释块、`KW_ONLY` 记号;方法(写在字段中间也算)、`ClassVar`、没注解的类属性、讲它们的注释照常是子块。
属性表(有才显示;字段总有,一个都没有时写「0 个」、不画表格):
| 行 | 内容 |
| --- | --- |
| 名称 | 类名(type 色,带下标) |
| 说明 | 类的 docstring;Markdown |
| 访问级别 | 公共 / 私有 / 受保护(keyword 色) |
| 继承 | 父类照写法(翻译插件翻) |
| 装饰器 | dataclass 以外的装饰器(`@final`),有才显示 |
| 选项 | dataclass 装饰器的参数,只讲写了、又不是默认值的,照写的顺序用 ` · ` 隔开(storage 色):`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` 可弱引用;写成默认值的(`frozen=False`)不讲;别的参数、值不是 True / False 的照写 `名=值`(值翻译插件翻),`**x` 照写 |
| 字段 N 个 | 表格:字段 \| 类型 \| 默认值 \| 说明,每个字段一行,讲的定义是字段名 |
字段表的格子:
- 字段:字段名(variable 色,带下标)。类型:注解照写法翻(翻译插件翻,`str` → 字符串),`InitVar[X]` 写 X。
- 默认值:`= x` 写 x(翻译插件翻,`None` → 空);`field(default=x)` 写 x;`field(default_factory=f)` 写「每次新建 f()」(`list` → 每次新建 list();不带参数的 `lambda` 写它返回的东西:`lambda: {"a": 1}` → 每次新建 {"a": 1});没写默认值时空着。后面接 `field(...)` 的别的参数,照写的顺序:`init=False` 不进构造函数、`repr=False` 不显示、`compare=False` 不参与比较、`hash=True` 参与哈希、`hash=False` 不参与哈希、`kw_only=True` 只能按名字传参、`kw_only=False` 可按位置传参,写成默认值的不讲,别的照写 `名=值`(`metadata={"unit": "px"}`);最后是 `KW_ONLY` 带来的「只能按名字传参」。各项用 ` · ` 隔开。
- 说明:`field(doc="…")` 的字符串;没写时同枚举成员的说明取法(紧挨在上面的注释块、这一行行尾的注释、紧跟在后面的属性 docstring,原文用空格连起来),comment 色。
样例(`fixtures/dataclassModels.py` 的 `SandboxAccess`,测试里逐字核对;说明的 Markdown 从略):
```
名称: SandboxAccess
说明: sandbox 类工具的最小资源集 (任何沙箱工具都需要). …
访问级别: 公共
选项: 不可修改 · 省内存(固定属性)
字段: 5 个
sandbox | CodeSandbox | |
sandbox_id | 字符串 | |
app_id | 字符串 | 空 | 空 |
session_id | 字符串 | 空 | 空 |
binding | SandboxBinding | 不进构造函数 · 不参与比较 · 不显示 |
```
`__post_init__` 照常是卡片的子块。
## 解析运行时
- 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。
@@ -984,7 +1027,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } |
属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。
种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
## 代码位置
@@ -1002,9 +1045,10 @@ vscode/src/codeReader/
rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract 和同目录的共用代码)
rules/celery.ts 内置 Celery 库规则:两张卡片、频率换算、任务名索引
rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认
rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名)
rules/dataclass.ts 内置 dataclasses 库规则(Python):数据类卡片,字段照 dataclasses 的规则认
rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字)
rules/segments.ts 库规则共用的拼属性表文字的办法
rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块)
rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点)
languages/typescript.ts
languages/python.ts (iteration:推导式的结构)
translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup)
@@ -1065,6 +1109,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。还要跳过的文件(`workspaceScope.test.ts`,内存里的文件夹):子目录里 `.git` 是文件的 git worktree、`.git` 是目录的子仓库整个跳过,工作区自己的 `.git` 不算;`.gitignore` 忽略的目录跳过,每层规则相对那一层、取反照 gitignore 写法,`.ignore` 也读;`search.useIgnoreFiles` 关着时不读忽略文件、嵌套的 checkout 照样跳过;同一个目录只查一次。
- Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。
- 枚举(`rules/enum.test.ts`、`languages/typescript.test.ts`):`fixtures/enumModels.py` 的 `CommissionStatus` 逐字同上面的样例,`SecretScope` 的 docstring 当说明、没有说明列;种类 enum、叫「枚举」、访问级别同原来的类、没有标签(badge),编辑范围是整个类;Python 成员规则(dunder、sunder、私有名、`_ignore_`、lambda、`property`、`nonmember`、只写注解的不是成员,`auto()` 写「自动」,`member(x)` 取 x,说明取上面的注释块、行尾注释、属性 docstring,装饰器);方法、嵌套类、不是成员的属性照源码顺序是子块,成员前面的注释并进来、最后面的留下,「编辑」写回整个类;类名、成员名的定义范围和带下标的词指回源码原文;没导入 enum、SQLAlchemy 的 `Enum`、不是枚举类型的父类不认,`import enum as e` 的 `e.IntEnum` 认。TypeScript:`enums.ts` 里 export / const / declare / 函数里的 enum 都是枚举块、JSDoc 进头部,写的值、自增算出的值(接不上数字就没有)、说明(上面的注释和 JSDoc、行尾注释,同一行的 JSDoc 讲下一个),访问级别;自增的值和「自动」按 hint。TypeScript 和 Python 同样的成员得到一样的种类、属性表行、列名和格子着色(除了 Python 多的「继承」)。宿主:规则认成现成属性表块时照语言插件报的这种块显示、并进来的不是子块时照常显示;webview 枚举块默认折叠。
- 数据类(`rules/dataclass.test.ts`):`fixtures/dataclassModels.py` 的 `SandboxAccess` 逐字同上面的样例,`AdminUserRow`、`RedirectPayResult`(`field(init=False, default=…)`)、`SmsSent`(`default_factory=dict`)的行;种类 `dataclasses.dataclass`、叫「数据类」、颜色、访问级别同原来的类;编辑范围是整个类、写回原样,`__post_init__` 是子块;类名、字段名的定义范围和带下标的词指回源码原文;装饰器的写法(别名、`dataclasses.dataclass`、`dc.dataclass`)认,pydantic 的、`other.dataclass` 不认,没导入 dataclasses 不认;每个选项的译法、默认值不讲、别的照写、`**x`、别的装饰器一行;字段规则(`ClassVar`、`typing.ClassVar` 留作子块,`InitVar` 的类型和「仅初始化时使用」,`KW_ONLY` 记号并进来、后面的字段「只能按名字传参」、单独写 `kw_only=False` 的「可按位置传参」),`field(...)` 的默认值、`default_factory`(名字、lambda)、各参数、`metadata` 照写、`doc=`,说明取上面的注释块、行尾注释、属性 docstring;没注解的类属性、字段中间的方法照源码顺序是子块,「编辑」写回整个类。
- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
- 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。
- 侧栏行上的菜单(`explorer/rowMenus.test.ts`,读 `package.json`):行尾按钮先「复制」子菜单再注释,有没有注释换「添加 / 编辑注释」,工作区根都没有;「复制」子菜单带 `$(copy)`,注释图标 `$(note)` / `$(edit)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。
@@ -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))
+2
View File
@@ -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
}
+26 -1
View File
@@ -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'])
})
})
+292
View File
@@ -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
},
}
+10 -58
View File
@@ -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,
}
+45
View File
@@ -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
}
+3 -15
View File
@@ -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 {