diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index f9d1e61..d5e2576 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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)`;子菜单和右键菜单里文件四项、目录三项(有没有注释一样)、菜单上的字;复制命令都登记了、和代码里的命令表一致、命令面板里藏起来。 diff --git a/vscode/src/codeReader/fixtures/dataclassModels.py b/vscode/src/codeReader/fixtures/dataclassModels.py new file mode 100644 index 0000000..e5e7e8b --- /dev/null +++ b/vscode/src/codeReader/fixtures/dataclassModels.py @@ -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)) + + diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 6b8df40..acf260b 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -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 } diff --git a/vscode/src/codeReader/rules/children.ts b/vscode/src/codeReader/rules/children.ts index 66121cd..38e824c 100644 --- a/vscode/src/codeReader/rules/children.ts +++ b/vscode/src/codeReader/rules/children.ts @@ -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] : []), + ] +} diff --git a/vscode/src/codeReader/rules/contractOnly.test.ts b/vscode/src/codeReader/rules/contractOnly.test.ts index 18c1f2a..8deb996 100644 --- a/vscode/src/codeReader/rules/contractOnly.test.ts +++ b/vscode/src/codeReader/rules/contractOnly.test.ts @@ -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) => { diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts new file mode 100644 index 0000000..848dac4 --- /dev/null +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -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 { + 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']) + }) +}) diff --git a/vscode/src/codeReader/rules/dataclass.ts b/vscode/src/codeReader/rules/dataclass.ts new file mode 100644 index 0000000..ee75ef7 --- /dev/null +++ b/vscode/src/codeReader/rules/dataclass.ts @@ -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 = { + 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 = { 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 + /** `@dataclass(名=值)` 的译法:值是 True / False 字面量时按「名=True」「名=False」查。 */ + options: Record + /** `field(名=值)` 的译法,查法同 options;KW_ONLY 记号后面的字段也用「kw_only=True」那个词。 */ + flags: Record + /** `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 = { zh: ZH } + +interface Env { + context: RuleContext + words: Words + /** dataclasses 库自己和从它导入的名字:认装饰器、`field(...)` */ + names: LibraryNames + /** 注解里的 ClassVar、InitVar、KW_ONLY 导入时起的别名 → 原名 */ + aliases: Map +} + +/** 一个字段:类主体里带注解的名字。 */ +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, defaults: Record, 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 + }, +} diff --git a/vscode/src/codeReader/rules/enum.ts b/vscode/src/codeReader/rules/enum.ts index f139ea1..76a3a2e 100644 --- a/vscode/src/codeReader/rules/enum.ts +++ b/vscode/src/codeReader/rules/enum.ts @@ -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 - imported: Map -} - -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, names: Names): { name: Node, value?: Node } | undefined { +function memberOf(block: CodeBlock, ignored: ReadonlySet, 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, 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, } diff --git a/vscode/src/codeReader/rules/pythonTree.ts b/vscode/src/codeReader/rules/pythonTree.ts index f55e65e..996f81e 100644 --- a/vscode/src/codeReader/rules/pythonTree.ts +++ b/vscode/src/codeReader/rules/pythonTree.ts @@ -92,3 +92,48 @@ export function callOf(node: Node | null | undefined, aliases: ReadonlyMap arg !== null) ?? [] } : undefined +} + +/** 这份源码里怎么叫一个库的东西:库自己(`import enum`、`import enum as e`),和从它导入的名字(本地名 → 原名)。 */ +export interface LibraryNames { + modules: Set + imported: Map +} + +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 +} diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index 3e290d1..c0207ac 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -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 {