diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index e3cfe6f..86ef58e 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -20,7 +20,7 @@ | 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 | | 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 | | 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 | -| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | +| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块和库规则认领的块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 | | 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 | | 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 | | 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 | @@ -31,6 +31,7 @@ | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | | 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | +| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;用词归规则自己,webview 只画结构。见「库规则」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | ## 用户可见行为 @@ -74,7 +75,8 @@ - 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。 -- 折叠:函数、类块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 +- 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原来的头部加上并进来的成员,「编辑」时一起改。 +- 折叠:函数、类块和库规则认领的块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。 - 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。 - 解析失败:显示错误原因,并提示用「编辑」查看源码。 @@ -100,7 +102,7 @@ 卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。 -输入参数的表格(原型 `paramRows`):往右缩进 5px,第一行是表头「名称 | 类型 | 默认值」,下面每个参数一行。名称、类型两列宽 150px / 220px(编辑区窄时按 28% / 38% 收),默认值列占剩下的;格子左右留 8px、上下 2px,长类型、长默认值在格子里换行,不挤掉别的列。外框 1px、圆角 4px,格子之间、行之间 1px 分隔线,颜色都是主题的表格分隔色(`--vscode-tree-tableColumnsBorder`,主题没给时退到 `--vscode-editorWidget-border`);表头垫主题的表头底色(`--vscode-keybindingTable-headerBackground`,比编辑区深一档或浅一档),列名界面字体 12px、`descriptionForeground` 色。没有默认值的格子留空。折叠「输入参数」那一行时整张表收起;子行 id、跳到定义闪「它那个参数」、高亮名字的各处出现都和列表一样,闪的是表格里那一行(行边框和相邻行共用,改在行里面描一圈聚焦色)。哪些子行排成表格由翻译插件定:给了列名(`PropertyRow.columns`)的排成表格,没给的(导入项、字典 / 对象的各项)照旧是列表,webview 只看有没有列名。 +输入参数的表格(原型 `paramRows`):往右缩进 5px,第一行是表头「名称 | 类型 | 默认值」,下面每个参数一行。列宽按整列的内容定,webview 不认是哪种表格(库规则的字段表同样排,见 `tableTemplate`):整张表格一个网格、各行借用它的列,前几列宽裕时放到整列不换行,最后一列(默认值、说明)分剩下的、至少留 `min(20%, 8em)`;放不下时先让最后一列换行,编辑区再窄前几列平分剩下的宽度、在格子里换行(长名字从中间断开),不撑出卡片、不藏掉哪一列。格子左右留 8px、上下 2px,长类型、长默认值在格子里换行,不挤掉别的列。外框 1px、圆角 4px,格子之间、行之间 1px 分隔线,颜色都是主题的表格分隔色(`--vscode-tree-tableColumnsBorder`,主题没给时退到 `--vscode-editorWidget-border`);表头垫主题的表头底色(`--vscode-keybindingTable-headerBackground`,比编辑区深一档或浅一档),列名界面字体 12px、`descriptionForeground` 色。没有默认值的格子留空。折叠「输入参数」那一行时整张表收起;子行 id、跳到定义闪「它那个参数」、高亮名字的各处出现都和列表一样,闪的是表格里那一行(行边框和相邻行共用,改在行里面描一圈聚焦色)。哪些子行排成表格由翻译插件定:给了列名(`PropertyRow.columns`)的排成表格,没给的(导入项、字典 / 对象的各项)照旧是列表,webview 只看有没有列名。 | 种类 | 行 | | --- | --- | @@ -394,25 +396,161 @@ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] + displayLanguage: string // 译文用的语言('zh'):库规则按它挑自己的词表 translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表 + expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它 + doc: (node: Node, document: SourceDocument) => Markdown | undefined // 说明文字(注释、docstring、当说明用的字符串)→ Markdown 原文,同属性表的「说明」 attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它 } +// ── 库规则(见「库规则」一节)── + +/** 一种颜色在暗色、亮色主题下各用哪个;高对比主题跟着暗 / 亮走。 */ +export interface ThemeColor { dark: string, light: string } + +/** 种类标签:文字和颜色。 */ +export interface KindBadge { label: string, color: ThemeColor } + +/** 库规则认领一块后给出的替身:一张属性表,下面挂着原块留下的子块。 */ +export interface RuleBlock { + kind: string // 种类 id,「规则 id.名字」(sqlalchemy.table):筛选的 kind 值,要稳定 + badge: KindBadge // 种类标签;筛选栏里这个种类也叫这个名字 + rows: PropertyRow[] + defines?: SourceRange // 块自己的名字(类名):跳到的定义是它时闪整块 + attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算 + absorbs: CodeBlock[] // 并进来的子块(原块 children 里的对象本身),不再单独成块 +} + +/** 规则看一块时宿主给的。 */ +export interface RuleContext { + document: SourceDocument + imports: ImportFacts[] // 这份源码里各处导入,照源码顺序:认别名用 + displayLanguage?: string // 当前翻译插件的 displayLanguage;没有翻译插件时没有 + translate: (node: Node) => Inline[] // 借当前翻译插件的 expression;没有翻译插件时照抄源码 + doc: (node: Node) => Markdown | undefined // 借当前翻译插件的 doc +} + +export interface LibraryRule { + id: string // 'sqlalchemy' + label: string // 'SQLAlchemy 数据表' + languageIds: string[] + libraries: string[] // 关心的库:导入来源等于它或是它的子模块才启用 + claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined +} + export interface CodeReaderApi { registerLanguage: (adapter: LanguageAdapter) => Disposable registerTranslator: (translator: Translator) => Disposable + registerLibraryRule: (rule: LibraryRule) => Disposable } ``` - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 -- 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。 +- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy 库规则在 `activate()` 里通过同一个 API 注册,没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 - 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。 - 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 - 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻。 - 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 +## 库规则 + +语言插件讲语法结构,翻译插件定用词,它们都不认识某个库。库规则是第四种插件:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片。原型:`docs/prototype/code-reader/figma-plugin/code.js` 的「库规则:SQLAlchemy 数据表」一节(`SQLA_MODEL`、`tableCard`),画板「库规则 · SQLAlchemy 数据表」。 + +### 规则要给什么、可以给什么 + +- 注册:`CodeReaderApi.registerLibraryRule(rule)`,返回 `Disposable`;内置规则走同一个 API。 +- 必须给:`id`、`label`、`languageIds`、`libraries`(关心的库)、`claim`(看一块,认领就返回替身 `RuleBlock`,不认返回 `undefined`)。 +- 替身必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类标签的字和暗 / 亮两种颜色)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 +- 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示)。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 +- 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。 + +### 启用、认领、优先 + +- 启用:宿主收集这份源码里各层导入块的 `ImportFacts.sources`(去掉引号),有一个等于规则的某个库、或以「库.」「库/」开头(子模块),规则才对这份源码启用。规则不用自己读导入语句。 +- 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。 +- 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。 +- 并进来的子块:不再单独成块。替身的编辑范围从原块头部开始,到最后一个并进来的子块(整条语句)结束,连成一段;夹在这段中间的子块(注释,或者写在字段中间的方法)也跟着并进来,保证每一行只在一块里能改;这段以后的子块(方法等)照常是替身的子块。 +- 替身在预览里:种类标签的字、颜色取 `badge`(暗色、亮色主题各取一个),内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。 + +### 内置:SQLAlchemy 数据表(Python) + +`vscode/src/codeReader/rules/sqlalchemy.ts`,`libraries: ['sqlalchemy', 'flask_sqlalchemy']`。类块主体里有 `__tablename__`,或有赋值是 `mapped_column(...)` / `Column(...)`(`sa.Column`、`db.Column` 这类取最后一节)时认领,种类 `sqlalchemy.table`,标签「数据表」(暗 #e8a860 / 亮 #a3590d)。导入时起的别名按 `context.imports` 换回原名(`from sqlalchemy import Enum as SQLEnum`、`UUID as PgUUID`)。 + +属性表(有才显示): + +| 行 | 内容 | +| --- | --- | +| 名称 | 类名(type 色,带下标) | +| 表名 | `__tablename__` 的字符串(string 色,不带引号) | +| 说明 | `__table_args__` 里选项字典的 `comment`,没有就用类的 docstring;Markdown | +| 继承 | 父类照写法(翻译插件翻) | +| 装饰器 | 有才显示 | +| 字段 N 个 | 表格:字段 \| 类型 \| 约束 \| 说明,每个字段一行,讲的定义是字段名 | +| 关系 N 个 | 表格:名称 \| 目标 \| 说明,每个 `relationship(...)` 一行 | +| 索引 N 个 | 表格:名称 \| 列,来自 `__table_args__` 里的 `Index(...)`;唯一索引名称后写「(唯一)」 | +| 唯一约束 N 个 | 表格:名称 \| 列,来自 `__table_args__` 里的 `UniqueConstraint(...)`(单独一张表,名称取 `name=`,没给写「未命名」) | + +字段:`mapped_column(...)` / `Column(...)` 的赋值,以及只写 `Mapped[...]` 注解的声明。第一个位置参数是字符串时是库里的列名,不当类型。 + +类型(第一个不是外键、序列这类的位置参数;没写就用 `Mapped[...]` 里去掉 None 的类型,照常翻,例如 `Mapped[bool]` → 布尔;都没有写「未标注」): + +| SQLAlchemy | 译名 | +| --- | --- | +| Integer / INTEGER / INT | 整数 | +| BigInteger / BIGINT | 长整数 | +| SmallInteger / SMALLINT | 短整数 | +| String(n) / Unicode / VARCHAR / NVARCHAR / CHAR | 字符串(n) | +| Text / UnicodeText / TEXT | 长文本 | +| Boolean / BOOLEAN | 布尔 | +| DateTime / DATETIME / TIMESTAMP | 日期时间;`timezone=True` 加「(带时区)」 | +| Date / DATE、Time / TIME、Interval | 日期、时间、时间间隔 | +| Numeric(p, s) / NUMERIC / DECIMAL | 定点数(p,s) | +| Float / FLOAT / REAL / Double / DOUBLE_PRECISION | 浮点数 | +| JSON / JSONB | JSON | +| UUID / Uuid | UUID | +| LargeBinary / BYTEA | 二进制 | +| ARRAY(T) | 数组(T) | +| Enum(X)、SQLEnum(X)、名字里带 enum 的辅助函数(`_enum(X, "…")`) | 枚举 X(X 是类名时 type 色、带下标);只列了取值的写「枚举 "a", "b"」 | +| 别的 | 照写法(翻译插件翻) | + +约束(按下面的顺序,用 ` · ` 隔开;非空是常态,不写): + +| 写法 | 显示 | +| --- | --- | +| `primary_key=True` | 主键 | +| `unique=True` | 唯一 | +| `index=True` | 索引 | +| `nullable=True`;没写 nullable 时:`Column` 和没注解的 `mapped_column` 默认可空,有注解的按 `Mapped[X \| None]` / `Optional[X]`;主键不可空 | 可空 | +| `default=表达式` | 默认 表达式(翻译插件翻,`True` → 真,`None` → 空) | +| `server_default=表达式` | 数据库默认 表达式 | +| `ForeignKey("t.c", ondelete=…, onupdate=…)` | 外键 t.c(删除时禁止);ondelete:RESTRICT 删除时禁止、CASCADE 级联删除、SET NULL 删除时置空、SET DEFAULT 删除时置默认值、NO ACTION 删除时不处理,onupdate 同理「更新时…」「级联更新」,别的写「删除时 X」;两个都有用「,」隔开 | + +说明:`comment=` 的字符串(comment 色)。关系的目标取第一个位置参数(字符串去掉引号),没写就用 `Mapped[...]` 里的类型;说明写「对方字段 X」(`back_populates` / `backref`)、「单个对象」(`uselist=False`)、「中间表 X」(`secondary`),用 ` · ` 隔开。 + +并进卡片的子块:`__tablename__`、`__table_args__`、每个字段和关系的赋值;`__mapper_args__`、普通属性、方法照常是子块。 + +样例(luffy `referral/models.py` 的 `ReferralRelation`,测试里逐字核对): + +``` +名称: ReferralRelation +表名: referral_relations +说明: 邀请关系 +继承: IntPK, TimestampMixin, Base +字段: 6 个 + inviter_user_id | 整数 | 索引 · 外键 sys_user.id(删除时禁止) | 邀请人用户ID + invitee_user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 被邀请人用户ID(唯一, 一人只绑一个邀请人) + invite_code | 字符串(16) | | 绑定时使用的邀请码 + bind_time | 日期时间(带时区) | | 绑定时间 + expire_time | 日期时间(带时区) | | 归属过期时间 + status | 枚举 RelationStatus | 默认 RelationStatus.ACTIVE | 关系状态 +索引: 1 个 + ix_referral_relations_inviter_bind_time | inviter_user_id, bind_time +``` + +配色:字段名 variable、类型译名 type、约束的词(主键、外键、默认……)storage、外键目标 string、删除规则和 ` · ` hint、说明 comment、索引名 string、索引列 variable。 + ## 解析运行时 - 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。 @@ -540,17 +678,18 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮 ```ts interface BlockViewBase { id: string // 在树里的位置,例如 '6.1.0' - kind: BlockKind - startLine: number // header 的起止行,0 起 + kind: string // BlockKind,或库规则认领的块的种类 id(sqlalchemy.table) + startLine: number // 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止 endLine: number - code: string // header 源码,去掉共同缩进 + code: string // 编辑范围的源码,去掉共同缩进 codeTokens: Segment[] // 源码着色(拼起来等于 code) - defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块 + defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块 attributes: Record // 筛选属性(含 kind),见「筛选」 + badge?: KindBadge // 库规则认领的块才有:种类标签的字和颜色;webview 按它上色、默认折叠 children: BlockView[] } -/** 属性表块(有 facts)只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */ +/** 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) ``` @@ -589,7 +728,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: P 属性表里:说明按 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;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框)。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;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。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -600,6 +739,8 @@ vscode/src/codeReader/ runtime.ts web-tree-sitter 初始化、语法包加载、解析 blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 + libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、并进来的范围 + rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract) languages/typescript.ts languages/python.ts (iteration:推导式的结构) translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup) @@ -630,14 +771,16 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 - 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。 -- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取。 +- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。 -- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中。 +- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。 +- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围到最后一个并进来的子块、夹在中间的注释并进来、之后的子块留下,每一行最多在一块里能改;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。 +- SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index b29910a..0ab3dca 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -44,31 +44,37 @@ export function blockAttributes(block: CodeBlock): BlockAttributes { interface Attributed { attributes: BlockAttributes + /** 库规则认领的块的种类标签:这个种类在筛选栏里也叫它。 */ + badge?: { label: string } children: Attributed[] } /** * 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面, - * 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id。 + * 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id;库规则的种类用规则给的标签。 */ export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] { const counts = new Map>() + const badges = new Map() const visit = (list: readonly Attributed[]): void => { for (const block of list) { for (const [id, value] of Object.entries(block.attributes)) { const values = counts.get(id) ?? new Map() counts.set(id, values.set(value, (values.get(value) ?? 0) + 1)) } + if (block.badge) + badges.set(block.attributes.kind, block.badge.label) visit(block.children) } } visit(blocks) const common: readonly string[] = COMMON_ATTRIBUTES const rank = (id: string): number => common.includes(id) ? common.indexOf(id) : common.length + const valueName = (id: string, value: string): string => (id === 'kind' ? badges.get(value) : undefined) ?? names?.values[id]?.[value] ?? value // sort 是稳定的:语言自己的属性同排在最后,保持第一次出现的先后 return [...counts].sort(([a], [b]) => rank(a) - rank(b)).map(([id, values]) => ({ id, name: names?.attributes[id] ?? id, - values: [...values].map(([value, count]) => ({ id: value, name: names?.values[id]?.[value] ?? value, count })), + values: [...values].map(([value, count]) => ({ id: value, name: valueName(id, value), count })), })) } diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 7d0255a..2d05d3d 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,27 +1,33 @@ -import type { BlockAttributes, BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract' +import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' +import { absorbedRange, activeRules, claimBlock, importsIn } from './libraryRules' interface BlockViewBase { /** 在树里的位置,例如 '6.1.0'。 */ id: string - kind: BlockKind - /** header 的起止行,0 起。 */ + /** BlockKind,或库规则认领的块的种类 id(RuleBlock.kind,带点,例如 sqlalchemy.table)。 */ + kind: string + /** 编辑范围的起止行,0 起:一般是 header;库规则认领的块到最后一个并进来的子块为止(见 RuleBlock.absorbs)。 */ startLine: number endLine: number - /** header 所在的整行源码,去掉共同缩进。 */ + /** 编辑范围的整行源码,去掉共同缩进。 */ code: string /** 源码着色(拼起来等于 code)。 */ codeTokens: Segment[] /** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */ defines?: SourceRange - /** 筛选属性(含 kind),见 blockAttributes。 */ + /** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */ attributes: BlockAttributes + /** 库规则认领的块才有:种类标签的文字和颜色(RuleBlock.badge)。预览同类块一样默认收起它的子块。 */ + badge?: KindBadge children: BlockView[] } -/** 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */ +/** + * 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 + */ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) interface Line { start: number, end: number } @@ -102,23 +108,64 @@ function ownName(block: CodeBlock): Node | undefined { } } -/** 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 */ -export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined): BlockView[] { +/** 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记。 */ +export interface RuleRun { + rules: readonly LibraryRule[] + report: (message: string) => void +} + +const NO_RULES: RuleRun = { rules: [], report: () => {} } + +/** + * 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 + * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock),并进去的子块不再单独成块。 + */ +export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] { const lines = lineRanges(source) const texts = splitLines(source) const document: SourceDocument = { source, language: adapter } - const build = (block: CodeBlock, id: string): BlockView => { - const { startRow, endRow } = block.header + const imports = importsIn(blocks) + const active = activeRules(rules.rules, imports) + const context: RuleContext = { + document, + imports, + ...(translator && { displayLanguage: translator.displayLanguage }), + translate: node => translator ? translator.expression(node, document) : [{ text: node.text, role: 'plain' }], + doc: node => translator?.doc(node, document), + } + /** startRow..endRow 这几行整行源码去共同缩进,和它的着色。 */ + const sourceRows = (root: Node, startRow: number, endRow: number): Pick => { const rows = texts.slice(startRow, endRow + 1) const indent = commonIndent(rows).length + return { + startLine: startRow, + endLine: endRow, + code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), + codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole), + } + } + const build = (block: CodeBlock, id: string): BlockView => { + const root = block.node.tree.rootNode + const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined + if (claim) { + const { kind, badge, rows, defines, attributes } = claim.block + const { endRow, kept } = absorbedRange(block, claim.block.absorbs) + return { + id, + kind, + ...sourceRows(root, block.header.startRow, endRow), + ...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }), + attributes: { ...blockAttributes(block), ...attributes, kind }, + badge, + children: kept.map((child, i) => build(child, `${id}.${i}`)), + properties: rows, + } + } const name = ownName(block) return { id, kind: block.kind, - startLine: startRow, - endLine: endRow, - code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'), - codeTokens: colorLines(block.node.tree.rootNode, source, lines, startRow, endRow, indent, adapter.tokenRole), + ...sourceRows(root, block.header.startRow, block.header.endRow), ...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }), attributes: blockAttributes(block), children: block.children.map((child, i) => build(child, `${id}.${i}`)), @@ -137,10 +184,11 @@ export async function readBlockViews( translator: Translator | undefined, languageId: string, source: string, + rules: RuleRun = NO_RULES, ): Promise { const tree = await runtime.parse(adapter, languageId, source) try { - return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator) + return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator, rules) } finally { tree.delete() diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2a45e33..7670046 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -284,15 +284,82 @@ export interface Translator { id: string // 'typescript.zh-template' label: string // '中文直译' languageIds: string[] + /** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */ + displayLanguage: string /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ translate: (block: TextBlock, document: SourceDocument) => PreviewText /** 函数、类、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] + /** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */ + expression: (node: Node, document: SourceDocument) => Inline[] + /** 一段说明文字(注释、docstring、当说明用的字符串)去掉注释符号、引号和共同缩进后的 Markdown 原文,同属性表的「说明」;没有文字时没有。 */ + doc: (node: Node, document: SourceDocument) => Markdown | undefined /** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */ attributeNames?: AttributeNames } +/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走。 */ +export interface ThemeColor { dark: string, light: string } + +/** 种类标签:文字和颜色。 */ +export interface KindBadge { label: string, color: ThemeColor } + +/** + * 库规则认领一块后给出的替身:预览里它代替原来那块,是一张属性表(种类标签、每一行都由规则给),下面挂着原块留下的子块。 + */ +export interface RuleBlock { + /** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */ + kind: string + /** 种类标签的文字和颜色:筛选栏里这个种类也叫这个名字。 */ + badge: KindBadge + rows: PropertyRow[] + /** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */ + defines?: SourceRange + /** 规则自己的筛选属性,叠在宿主从原块推出的属性上(同 BlockShape.attributes);kind 总是上面的 kind,这里填了不算。 */ + attributes?: BlockAttributes + /** + * 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。 + * 这一块的编辑范围从原块头部开始、到最后一个并进来的子块结束,连成一段;夹在这段中间的别的子块(注释等)也跟着并进来, + * 不然同一行能在两处改。这段以后的子块(方法等)照常是这一块的子块,也照常交给各规则看。 + */ + absorbs: CodeBlock[] +} + +/** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */ +export interface RuleContext { + document: SourceDocument + /** 这份源码里各处导入的 facts,照源码顺序:规则拿它认出库里名字的别名(`from sqlalchemy import Enum as SQLEnum`)。 */ + imports: ImportFacts[] + /** 当前翻译插件的 displayLanguage;没有翻译插件时没有。规则按它挑自己的词表,没有这种语言的词表就用自己默认的那份。 */ + displayLanguage?: string + /** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */ + translate: (node: Node) => Inline[] + /** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */ + doc: (node: Node) => Markdown | undefined +} + +/** + * 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;文件导入了它关心的库才启用。 + * 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;用词(属性名、种类名、类型的译名)属于规则自己。 + */ +export interface LibraryRule { + id: string // 'sqlalchemy' + label: string // 'SQLAlchemy 数据表' + languageIds: string[] + /** + * 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。 + * 宿主按语言插件给的导入判断,规则不用自己读导入语句。 + */ + libraries: string[] + /** + * 看一块:认领就返回替身,不认就返回 undefined。宿主从顶层往下每块都问(被认领的块留下的子块照样问)。 + * 几个规则都认同一块时先注册的算;规则抛错、或替身不合上面的约定时,宿主记日志、当它没认,接着问下一个规则。 + */ + claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined +} + export interface CodeReaderApi { registerLanguage: (adapter: LanguageAdapter) => Disposable registerTranslator: (translator: Translator) => Disposable + registerLibraryRule: (rule: LibraryRule) => Disposable } diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index 289e98d..e307a53 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -1,8 +1,12 @@ import type { Memento } from 'vscode' +import type { LibraryRule } from '../contract' import { describe, expect, it } from 'vitest' +import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' import { CodeReaderRegistry } from '../registry' +import { sqlalchemyRule } from '../rules/sqlalchemy' import { runtime, WASM_DIR } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' import { typescriptZhTranslator } from '../translators/typescriptZh' import { documentMessage } from './documentMessage' @@ -19,13 +23,33 @@ function registryWithTypeScript(): CodeReaderRegistry { return registry } +function registryWithPython(...rules: LibraryRule[]): CodeReaderRegistry { + const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick) + registry.registerLanguage(createPythonAdapter(WASM_DIR)) + registry.registerTranslator(pythonZhTranslator) + for (const rule of rules) + registry.registerLibraryRule(rule) + return registry +} + +function ignore(): void {} + +const MODEL = [ + 'from sqlalchemy.orm import Mapped, mapped_column', + '', + 'class User(Base):', + ' __tablename__ = "users"', + ' id: Mapped[int] = mapped_column(primary_key=True)', + '', +].join('\n') + function doc(languageId: string, text: string, version = 3): { languageId: string, version: number, getText: () => string } { return { languageId, version, getText: () => text } } describe('预览的 document 消息', () => { it('受支持的语言:带文档版本、代码块、可选翻译和选中的翻译', async () => { - const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime) + const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime, ignore) expect(message).toMatchObject({ type: 'document', version: 3, @@ -42,16 +66,43 @@ describe('预览的 document 消息', () => { }) it('没有语言插件的语言:带 error,不解析', async () => { - const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime) + const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime, ignore) expect(message.blocks).toEqual([]) expect(message.attributes).toEqual([]) expect(message.translators).toEqual([]) expect(message.error).toContain('rust') }) + it('库规则认领的块:种类是规则的种类 id,筛选栏里叫规则给的名字', async () => { + const message = await documentMessage(doc('python', MODEL), registryWithPython(sqlalchemyRule), runtime, ignore) + expect(message.blocks.map(b => b.kind)).toEqual(['import', 'sqlalchemy.table']) + expect(message.attributes.find(attribute => attribute.id === 'kind')?.values).toEqual([ + { id: 'import', name: '导入', count: 1 }, + { id: 'sqlalchemy.table', name: '数据表', count: 1 }, + ]) + }) + + it('库规则抛错:不算解析失败,交给 report 记下,这块照常显示', async () => { + const broken: LibraryRule = { + id: 'broken', + label: '坏规则', + languageIds: ['python'], + libraries: ['sqlalchemy'], + claim: () => { + throw new Error('读不懂') + }, + } + const reports: string[] = [] + const message = await documentMessage(doc('python', MODEL), registryWithPython(broken), runtime, text => reports.push(text)) + expect(message.error).toBeUndefined() + expect(message.blocks.map(b => b.kind)).toEqual(['import', 'class']) + expect(reports).toEqual([1, 3, 4, 5].map((line, i) => + `库规则 broken 看第 ${line} 行的 ${['import', 'class', 'variable', 'field'][i]} 块时出错,这块照常显示:读不懂`)) + }) + it('解析失败:把原因放进 error', async () => { const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) } - const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing) + const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore) expect(message.blocks).toEqual([]) expect(message.error).toBe('语法包加载失败') }) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 7b2ee59..986413a 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -11,11 +11,12 @@ export interface DocumentSnapshot { getText: () => string } -/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。 */ +/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */ export async function documentMessage( document: DocumentSnapshot, - registry: Pick, + registry: Pick, runtime: Pick, + report: (message: string) => void, ): Promise { const { languageId, version } = document const source = document.getText() @@ -33,7 +34,7 @@ export async function documentMessage( if (!adapter) return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` } try { - const blocks = await readBlockViews(runtime, adapter, translator, languageId, source) + const blocks = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report }) return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) } } catch (err) { diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index b99143f..303e4fa 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -141,7 +141,9 @@ class Preview { if (!this.ready) return const request = ++this.latest - const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime) + const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime, (text) => { + logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) + }) if (request !== this.latest || !this.ready) return const reveal = this.pendingReveal diff --git a/vscode/src/codeReader/fixtures/sqlalchemyModels.py b/vscode/src/codeReader/fixtures/sqlalchemyModels.py new file mode 100644 index 0000000..73749ec --- /dev/null +++ b/vscode/src/codeReader/fixtures/sqlalchemyModels.py @@ -0,0 +1,193 @@ +# luffy-agent-platform 的 backend/src/luffy_agent/referral/models.py 节选:SQLAlchemy 库规则的样例 +from __future__ import annotations + +import enum +import uuid +from datetime import datetime + +from sqlalchemy import ( + BigInteger, + DateTime, + ForeignKey, + Index, + Integer, + String, + UniqueConstraint, + text, +) +from sqlalchemy import Enum as SQLEnum +from sqlalchemy.dialects.postgresql import UUID as PgUUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from luffy_agent.core.db import Base, IntPK, TimestampMixin + + +def _enum(py_enum: type[enum.Enum], name: str) -> SQLEnum: + return SQLEnum( + py_enum, + name=name, + values_callable=lambda values: [item.value for item in values], + validate_strings=True, + ) + + +class ReferralStatus(str, enum.Enum): + NORMAL = "normal" + FROZEN = "frozen" + + +class RelationStatus(str, enum.Enum): + ACTIVE = "active" + INVALID = "invalid" + + +class ReferralRole(IntPK, TimestampMixin, Base): + __tablename__ = "referral_roles" + + role_code: Mapped[str] = mapped_column( + String(32), + unique=True, + nullable=False, + comment="角色代码: bronze/silver/gold, 见 init_referral_roles.sql", + ) + role_name: Mapped[str] = mapped_column( + String(64), nullable=False, comment="角色显示名" + ) + is_active: Mapped[bool] = mapped_column( + nullable=False, default=True, comment="是否启用" + ) + upgrade_min_invites: Mapped[int | None] = mapped_column( + Integer, + nullable=True, + comment="自动升到本级所需累计邀请人数; 空=该条件不启用", + ) + + __table_args__ = ({"comment": "推广角色配置"},) + + +class UserReferralProfile(IntPK, TimestampMixin, Base): + __tablename__ = "user_referral_profile" + + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("sys_user.id", ondelete="RESTRICT"), + unique=True, + nullable=False, + comment="平台用户ID", + ) + invite_code: Mapped[str] = mapped_column( + String(16), unique=True, index=True, nullable=False, comment="用户唯一邀请码" + ) + custom_commission_rate_bps: Mapped[int | None] = mapped_column( + Integer, default=None, comment="单独佣金费率bps, 空则用角色默认" + ) + referral_status: Mapped[ReferralStatus] = mapped_column( + _enum(ReferralStatus, "referral_status_enum"), + nullable=False, + default=ReferralStatus.NORMAL, + comment="推广状态", + ) + role: Mapped[ReferralRole] = relationship(lazy="selectin") + + __table_args__ = ({"comment": "用户推广资料"},) + + +class ReferralRelation(IntPK, TimestampMixin, Base): + __tablename__ = "referral_relations" + + inviter_user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("sys_user.id", ondelete="RESTRICT"), + index=True, + nullable=False, + comment="邀请人用户ID", + ) + invitee_user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("sys_user.id", ondelete="RESTRICT"), + unique=True, + nullable=False, + comment="被邀请人用户ID(唯一, 一人只绑一个邀请人)", + ) + invite_code: Mapped[str] = mapped_column( + String(16), nullable=False, comment="绑定时使用的邀请码" + ) + bind_time: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, comment="绑定时间" + ) + expire_time: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, comment="归属过期时间" + ) + status: Mapped[RelationStatus] = mapped_column( + _enum(RelationStatus, "referral_relation_status_enum"), + nullable=False, + default=RelationStatus.ACTIVE, + comment="关系状态", + ) + + __table_args__ = ( + Index( + "ix_referral_relations_inviter_bind_time", + "inviter_user_id", + "bind_time", + ), + {"comment": "邀请关系"}, + ) + + +class ReferralWithdrawal(IntPK, TimestampMixin, Base): + __tablename__ = "referral_withdrawals" + + order_id: Mapped[uuid.UUID] = mapped_column( + PgUUID(as_uuid=True), + ForeignKey("commerce_order.id", ondelete="RESTRICT"), + unique=True, + nullable=False, + comment="commerce 订单ID(唯一, 一单一佣金, 幂等)", + ) + tax_rate_bps: Mapped[int] = mapped_column( + Integer, + nullable=False, + default=0, + server_default=text("0"), + comment="申请时快照的代扣税点, 万分比 (后改配置不影响已有单)", + ) + auditor_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("sys_user.id", ondelete="SET NULL"), + default=None, + comment="审核人ID", + ) + + __table_args__ = ( + Index("ix_referral_withdrawals_user_status", "user_id", "status"), + {"comment": "提现申请"}, + ) + + +class ReferralWithdrawalItem(IntPK, TimestampMixin, Base): + __tablename__ = "referral_withdrawal_items" + + withdrawal_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("referral_withdrawals.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="提现申请ID", + ) + commission_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("referral_commissions.id", ondelete="RESTRICT"), + nullable=False, + index=True, + comment="占用的佣金ID", + ) + + __table_args__ = ( + UniqueConstraint( + "withdrawal_id", + "commission_id", + name="uq_referral_withdrawal_item_wd_commission", + ), + {"comment": "提现对佣金的占用明细 (FIFO, 末张可拆)"}, + ) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index d9ebc46..b96727b 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -10,6 +10,7 @@ import { createTypeScriptAdapter } from './languages/typescript' import { registerNoteCommands } from './notes/commands' import { FileNotes } from './notes/fileNotes' import { CodeReaderRegistry } from './registry' +import { sqlalchemyRule } from './rules/sqlalchemy' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { pythonZhTranslator } from './translators/pythonZh' import { typescriptZhTranslator } from './translators/typescriptZh' @@ -41,7 +42,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise { /** * 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。 - * 内置的语言插件和翻译插件与其他扩展一样通过返回的 API 注册。 + * 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。 */ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const registry = new CodeReaderRegistry(context.globalState) @@ -74,6 +75,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLanguage(createPythonAdapter(wasmDir)), api.registerTranslator(typescriptZhTranslator), api.registerTranslator(pythonZhTranslator), + api.registerLibraryRule(sqlalchemyRule), ) return api } diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts new file mode 100644 index 0000000..05c354d --- /dev/null +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -0,0 +1,166 @@ +import type { BlockView, RuleRun } from './blockView' +import type { CodeBlock, LibraryRule, RuleBlock } from './contract' +import { describe, expect, it } from 'vitest' +import { fileAttributes } from './attributes' +import { toBlockViews } from './blockView' +import { createPythonAdapter } from './languages/python' +import { activeRules, fromLibrary, importsIn } from './libraryRules' +import { WASM_DIR, withBlocks } from './testSupport' +import { pythonZhTranslator } from './translators/pythonZh' + +const py = createPythonAdapter(WASM_DIR) + +const MODEL = [ + 'from sqlalchemy.orm import mapped_column', + '', + 'class User(Base):', + ' __tablename__ = "users"', + ' # 基本信息', + ' name = mapped_column(String)', + '', + ' def greet(self) -> str:', + ' return self.name', + '', +].join('\n') + +/** 认领每个类块的假规则:并进它的前两个子块。 */ +function cardRule(id: string, overrides: Partial = {}): LibraryRule { + return { + id, + label: id, + languageIds: ['python'], + libraries: ['sqlalchemy'], + claim: block => block.kind === 'class' + ? { kind: `${id}.card`, badge: { label: `${id} 卡片`, color: { dark: '#fff', light: '#000' } }, rows: [], absorbs: block.children.slice(0, 1), ...overrides } + : undefined, + } +} + +const throwing: LibraryRule = { + id: 'throwing', + label: '抛错', + languageIds: ['python'], + libraries: ['sqlalchemy'], + claim: () => { + throw new Error('坏了') + }, +} + +async function views(source: string, rules: LibraryRule[], reports: string[] = []): Promise { + const run: RuleRun = { rules, report: message => reports.push(message) } + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, run)) +} + +function flatten(list: BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +/** 语法树用完即释放,节点要在里面读完。 */ +function activates(source: string, rule: LibraryRule): Promise { + return withBlocks(py, 'python', source, blocks => activeRules([rule], importsIn(blocks)).length > 0) +} + +describe('库规则:启用', () => { + it('来源等于库名、或是它的子模块才算导入了这个库', () => { + expect(fromLibrary('sqlalchemy', 'sqlalchemy')).toBe(true) + expect(fromLibrary('sqlalchemy.orm', 'sqlalchemy')).toBe(true) + expect(fromLibrary('drizzle-orm/pg-core', 'drizzle-orm')).toBe(true) + expect(fromLibrary('sqlalchemy_utils', 'sqlalchemy')).toBe(false) + expect(fromLibrary('.sqlalchemy', 'sqlalchemy')).toBe(false) + }) + + it.each([ + ['from sqlalchemy.orm import Mapped, mapped_column', true], + ['import sqlalchemy as sa', true], + ['import os, sqlalchemy', true], + ['def f():\n from sqlalchemy import select\n', true], + ['from sqlalchemy_utils import UUIDType', false], + ['from .sqlalchemy import Base', false], + ['import os', false], + ])('%s → %s', async (source, active) => { + expect(await activates(source, cardRule('sqlalchemy'))).toBe(active) + }) + + it('没导入库的文件不问规则', async () => { + const reports: string[] = [] + const list = await views(MODEL.replace('sqlalchemy.orm', 'other'), [throwing], reports) + expect(list.map(view => view.kind)).toEqual(['import', 'class']) + expect(reports).toEqual([]) + }) +}) + +describe('库规则:认领', () => { + it('认领的块换成替身:种类、标签、编辑范围到最后一个并进来的子块,夹在中间的注释也并进来,之后的子块留下', async () => { + const rule: LibraryRule = { + ...cardRule('orm'), + claim: (block) => { + if (block.kind !== 'class') + return undefined + const [table, , column] = block.children + return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' } }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] } + }, + } + const [, card] = await views(MODEL, [rule]) + expect(card).toMatchObject({ + kind: 'orm.card', + badge: { label: '卡片' }, + startLine: 2, + endLine: 5, + code: 'class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)', + attributes: { kind: 'orm.card', access: 'public' }, + properties: [{ label: '名称' }], + }) + expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'function', 7]]) + }) + + it('每一行最多在一块里能编辑:并进来的行只在替身里', async () => { + const list = flatten(await views(MODEL, [cardRule('orm', {})])) + const owners = new Map() + for (const view of list) { + for (let line = view.startLine; line <= view.endLine; line++) + owners.set(line, [...owners.get(line) ?? [], view.id]) + } + expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([]) + }) + + it('几个规则都认同一块时先注册的算', async () => { + expect((await views(MODEL, [cardRule('first'), cardRule('second')]))[1].kind).toBe('first.card') + expect((await views(MODEL, [cardRule('second'), cardRule('first')]))[1].kind).toBe('second.card') + }) + + it('规则抛错:记下来,接着问下一个规则;都没认就照常显示', async () => { + const reports: string[] = [] + expect((await views(MODEL, [throwing, cardRule('second')], reports))[1].kind).toBe('second.card') + expect(reports[1]).toBe('库规则 throwing 看第 3 行的 class 块时出错,这块照常显示:坏了') + + const alone = await views(MODEL, [throwing]) + expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, endLine: 2 }) + expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', 'variable', 'function']) + }) + + it('替身不合约定(种类 id 没带规则 id、并进来的不是子块):记下来,照常显示', async () => { + const reports: string[] = [] + const stranger: CodeBlock[] = [] + await withBlocks(py, 'python', 'x = 1', (blocks) => { + stranger.push(...blocks) + }) + const list = await views(MODEL, [cardRule('bad', { kind: 'card' }), cardRule('worse', { absorbs: stranger })], reports) + expect(list[1].kind).toBe('class') + expect(reports).toEqual([ + '库规则 bad 给第 3 行的 class 块的替身不合约定,这块照常显示:种类 id「card」没以「bad.」开头', + '库规则 worse 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块', + ]) + }) + + it('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', async () => { + const list = await views(MODEL, [cardRule('orm')]) + expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([ + { id: 'import', name: '导入', count: 1 }, + { id: 'orm.card', name: 'orm 卡片', count: 1 }, + { id: 'comment', name: '注释', count: 1 }, + { id: 'variable', name: '变量', count: 1 }, + { id: 'function', name: '函数', count: 1 }, + { id: 'return', name: '返回', count: 1 }, + ]) + }) +}) diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts new file mode 100644 index 0000000..3a0149a --- /dev/null +++ b/vscode/src/codeReader/libraryRules.ts @@ -0,0 +1,84 @@ +import type { CodeBlock, ImportFacts, LibraryRule, Node, RuleBlock, RuleContext } from './contract' + +/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后哪些子块并进去。规则本身只经 contract 拿信息。 */ + +/** 这份源码里各处导入的 facts,照源码顺序(先父块、再子块)。 */ +export function importsIn(blocks: readonly CodeBlock[]): ImportFacts[] { + const found: ImportFacts[] = [] + const visit = (list: readonly CodeBlock[]): void => { + for (const block of list) { + if (block.kind === 'import') + found.push(block.facts) + visit(block.children) + } + } + visit(blocks) + return found +} + +/** 来源模块的写法去掉首尾引号(TypeScript 的来源是字符串字面量)。 */ +function moduleName(source: Node): string { + return source.text.replace(/^(['"`])([\s\S]*)\1$/, '$2') +} + +/** 模块是这个库或它的子模块:等于库名,或以「库名.」(Python 的点分模块名)「库名/」(JavaScript 的子路径)开头。 */ +export function fromLibrary(module: string, library: string): boolean { + return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`) +} + +/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库。 */ +export function activeRules(rules: readonly LibraryRule[], imports: readonly ImportFacts[]): LibraryRule[] { + const modules = imports.flatMap(facts => facts.sources.map(moduleName)) + return rules.filter(rule => rule.libraries.some(library => modules.some(module => fromLibrary(module, library)))) +} + +/** 一块被哪个规则认领、替身是什么。 */ +export interface Claim { + rule: LibraryRule + block: RuleBlock +} + +/** 替身不合 RuleBlock 的约定时说哪儿不对。 */ +function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined { + if (!claimed.kind.startsWith(`${rule.id}.`)) + return `种类 id「${claimed.kind}」没以「${rule.id}.」开头` + if (claimed.absorbs.some(child => !block.children.includes(child))) + return '要并进来的块不是这块的子块' + return undefined +} + +/** + * 按注册顺序问启用的规则,第一个认领的算。规则抛错、给的替身不合约定时交给 report(记日志),当它没认、接着问下一个; + * 都没认领就是 undefined,这块照常显示。 + */ +export function claimBlock(block: CodeBlock, rules: readonly LibraryRule[], context: RuleContext, report: (message: string) => void): Claim | undefined { + const where = `第 ${block.header.startRow + 1} 行的 ${block.kind} 块` + for (const rule of rules) { + let claimed: RuleBlock | undefined + try { + claimed = rule.claim(block, context) + } + catch (err) { + report(`库规则 ${rule.id} 看${where}时出错,这块照常显示:${err instanceof Error ? err.message : String(err)}`) + continue + } + if (!claimed) + continue + const problem = problemOf(rule, block, claimed) + if (problem) { + report(`库规则 ${rule.id} 给${where}的替身不合约定,这块照常显示:${problem}`) + continue + } + return { rule, block: claimed } + } + return undefined +} + +/** + * 替身并进了哪几行、留下哪些子块:编辑范围从原块头部开始,到最后一个并进来的子块(整条语句)结束; + * 起始行落在这段里的子块都并进来(包括夹在中间、规则没点名的),之后的留下。 + */ +export function absorbedRange(block: CodeBlock, absorbs: readonly CodeBlock[]): { endRow: number, kept: CodeBlock[] } { + const endRow = Math.max(block.header.endRow, ...absorbs.map(child => Math.max(child.header.endRow, child.node.endPosition.row))) + return { endRow, kept: block.children.filter(child => child.header.startRow > endRow) } +} diff --git a/vscode/src/codeReader/registry.test.ts b/vscode/src/codeReader/registry.test.ts index a3e489f..a70d31f 100644 --- a/vscode/src/codeReader/registry.test.ts +++ b/vscode/src/codeReader/registry.test.ts @@ -1,5 +1,5 @@ import type { Memento } from 'vscode' -import type { LanguageAdapter, Translator } from './contract' +import type { LanguageAdapter, LibraryRule, Translator } from './contract' import { describe, expect, it } from 'vitest' import { CodeReaderRegistry, TRANSLATOR_CHOICE_KEY } from './registry' @@ -17,7 +17,11 @@ function adapter(id: string, languageIds: string[]): LanguageAdapter { } function translator(id: string, languageIds: string[]): Translator { - return { id, label: id, languageIds, translate: () => [], describe: () => [] } + return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => [], describe: () => [], expression: () => [], doc: () => undefined } +} + +function rule(id: string, languageIds: string[]): LibraryRule { + return { id, label: id, languageIds, libraries: [id], claim: () => undefined } } describe('codeReaderRegistry', () => { @@ -73,6 +77,25 @@ describe('codeReaderRegistry', () => { expect(registry.selectedTranslator('typescript')?.id).toBe('ts.zh') }) + it('库规则按语言过滤、保持注册顺序;注销后不再给,注册注销都触发变化事件', () => { + const registry = new CodeReaderRegistry(fakeMemento()) + let changes = 0 + registry.onDidChange(() => { + changes++ + }) + registry.registerLibraryRule(rule('sqlalchemy', ['python'])) + const django = registry.registerLibraryRule(rule('django', ['python'])) + registry.registerLibraryRule(rule('typeorm', ['typescript'])) + expect(registry.rulesFor('python').map(r => r.id)).toEqual(['sqlalchemy', 'django']) + expect(registry.rulesFor('typescript').map(r => r.id)).toEqual(['typeorm']) + expect(registry.rulesFor('rust')).toEqual([]) + + django.dispose() + django.dispose() + expect(registry.rulesFor('python').map(r => r.id)).toEqual(['sqlalchemy']) + expect(changes).toBe(4) + }) + it('变化事件可退订,也能放进 disposables', () => { const registry = new CodeReaderRegistry(fakeMemento()) let changes = 0 diff --git a/vscode/src/codeReader/registry.ts b/vscode/src/codeReader/registry.ts index 57825a5..d96e2f4 100644 --- a/vscode/src/codeReader/registry.ts +++ b/vscode/src/codeReader/registry.ts @@ -1,5 +1,5 @@ import type { Disposable, Event, Memento } from 'vscode' -import type { CodeReaderApi, LanguageAdapter, Translator } from './contract' +import type { CodeReaderApi, LanguageAdapter, LibraryRule, Translator } from './contract' /** globalState 里按 languageId 记选中的翻译插件 id:Record。 */ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguage' @@ -7,12 +7,13 @@ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguag interface Entry { value: T } /** - * 语言插件、翻译插件的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。 + * 语言插件、翻译插件、库规则的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。 * 不依赖 vscode 运行时:Disposable 和事件都是结构兼容的普通对象,便于单元测试。 */ export class CodeReaderRegistry implements CodeReaderApi { private readonly adapters: Entry[] = [] private readonly translators: Entry[] = [] + private readonly rules: Entry[] = [] private readonly listeners = new Set<() => void>() constructor(private readonly state: Pick) {} @@ -36,6 +37,10 @@ export class CodeReaderRegistry implements CodeReaderApi { return this.add(this.translators, translator) } + registerLibraryRule(rule: LibraryRule): Disposable { + return this.add(this.rules, rule) + } + /** 当前语言插件覆盖的全部 languageId(去重、排序),给 when 子句用。 */ languageIds(): string[] { return [...new Set(this.adapters.flatMap(entry => entry.value.languageIds))].sort() @@ -51,6 +56,11 @@ export class CodeReaderRegistry implements CodeReaderApi { return this.translators.map(entry => entry.value).filter(translator => translator.languageIds.includes(languageId)) } + /** 某 languageId 的库规则,按注册顺序:几个规则都认同一块时先注册的算。 */ + rulesFor(languageId: string): LibraryRule[] { + return this.rules.map(entry => entry.value).filter(rule => rule.languageIds.includes(languageId)) + } + /** 选中的翻译:globalState 里记的那个还注册着就用它,否则用第一个。 */ selectedTranslator(languageId: string): Translator | undefined { const candidates = this.translatorsFor(languageId) diff --git a/vscode/src/codeReader/rules/contractOnly.test.ts b/vscode/src/codeReader/rules/contractOnly.test.ts new file mode 100644 index 0000000..954f401 --- /dev/null +++ b/vscode/src/codeReader/rules/contractOnly.test.ts @@ -0,0 +1,21 @@ +import { readdirSync, readFileSync } from 'node:fs' +import * as path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** 库规则(这个目录下的非测试文件)import 的模块:只许 contract 和同目录的共用代码。 */ +function importsOf(file: string): string[] { + const text = readFileSync(path.join(__dirname, file), 'utf8') + return [...text.matchAll(/^\s*(?:import|export)\s(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm)].map(match => match[1]) +} + +describe('库规则只经 contract 拿信息', () => { + const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts')) + + it('目录里有规则', () => { + expect(files).toContain('sqlalchemy.ts') + }) + + it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => { + expect(importsOf(file).filter(source => source !== '../contract' && !source.startsWith('./'))).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts new file mode 100644 index 0000000..86753d2 --- /dev/null +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -0,0 +1,216 @@ +import type { BlockView } from '../blockView' +import type { Inline, PropertyRow, Segment } from '../contract' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { sqlalchemyRule } from './sqlalchemy' + +const py = createPythonAdapter(WASM_DIR) +const SNAPSHOT = readFixture('sqlalchemyModels.py') + +async function views(source: string, reports: string[] = []): Promise { + return withBlocks(py, 'python', source, blocks => + toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: message => reports.push(message) })) +} + +function flatten(list: BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +function properties(view: BlockView): PropertyRow[] { + if (!('properties' in view)) + throw new Error(`块 ${view.id} 不是属性表`) + return view.properties +} + +async function model(source: string, name: string): Promise { + const found = (await views(source)).find(view => 'properties' in view && view.properties[0]?.value && !('markdown' in view.properties[0].value) + && view.properties[0].value.some(piece => 'text' in piece && piece.text === 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) +} + +describe('sqlAlchemy 库规则', () => { + it('referralRelation:和原型一样的数据表卡片', async () => { + const table = await model(SNAPSHOT, 'ReferralRelation') + expect(table.kind).toBe('sqlalchemy.table') + expect(table.badge).toEqual({ label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } }) + expect(propertyLines(properties(table))).toEqual([ + '名称: ReferralRelation', + '表名: referral_relations', + '说明: 邀请关系', + '继承: IntPK, TimestampMixin, Base', + '字段: 6 个', + ' inviter_user_id | 整数 | 索引 · 外键 sys_user.id(删除时禁止) | 邀请人用户ID', + ' invitee_user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 被邀请人用户ID(唯一, 一人只绑一个邀请人)', + ' invite_code | 字符串(16) | | 绑定时使用的邀请码', + ' bind_time | 日期时间(带时区) | | 绑定时间', + ' expire_time | 日期时间(带时区) | | 归属过期时间', + ' status | 枚举 RelationStatus | 默认 RelationStatus.ACTIVE | 关系状态', + '索引: 1 个', + ' ix_referral_relations_inviter_bind_time | inviter_user_id, bind_time', + ]) + const rows = properties(table) + expect(rows.find(row => row.label === '字段')?.columns).toEqual(['字段', '类型', '约束', '说明']) + expect(rows.find(row => row.label === '索引')?.columns).toEqual(['名称', '列']) + expect(rows.find(row => row.label === '说明')?.value).toEqual({ markdown: '邀请关系' }) + }) + + it('其余模型:Mapped 类型、可空、默认值、数据库默认、别名、关系、唯一约束', async () => { + expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralRole'))).slice(4)).toEqual([ + '字段: 4 个', + ' role_code | 字符串(32) | 唯一 | 角色代码: bronze/silver/gold, 见 init_referral_roles.sql', + ' role_name | 字符串(64) | | 角色显示名', + ' is_active | 布尔 | 默认 真 | 是否启用', + ' upgrade_min_invites | 整数 | 可空 | 自动升到本级所需累计邀请人数; 空=该条件不启用', + ]) + expect(propertyLines(properties(await model(SNAPSHOT, 'UserReferralProfile'))).slice(2)).toEqual([ + '说明: 用户推广资料', + '继承: IntPK, TimestampMixin, Base', + '字段: 4 个', + ' user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 平台用户ID', + ' invite_code | 字符串(16) | 唯一 · 索引 | 用户唯一邀请码', + ' custom_commission_rate_bps | 整数 | 可空 · 默认 空 | 单独佣金费率bps, 空则用角色默认', + ' referral_status | 枚举 ReferralStatus | 默认 ReferralStatus.NORMAL | 推广状态', + '关系: 1 个', + ' role | ReferralRole | ', + ]) + expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawal'))).slice(4)).toEqual([ + '字段: 3 个', + ' order_id | UUID | 唯一 · 外键 commerce_order.id(删除时禁止) | commerce 订单ID(唯一, 一单一佣金, 幂等)', + ' tax_rate_bps | 整数 | 默认 0 · 数据库默认 text("0") | 申请时快照的代扣税点, 万分比 (后改配置不影响已有单)', + ' auditor_id | 整数 | 可空 · 默认 空 · 外键 sys_user.id(删除时置空) | 审核人ID', + '索引: 1 个', + ' ix_referral_withdrawals_user_status | user_id, status', + ]) + expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawalItem'))).slice(4)).toEqual([ + '字段: 2 个', + ' withdrawal_id | 整数 | 索引 · 外键 referral_withdrawals.id(级联删除) | 提现申请ID', + ' commission_id | 整数 | 索引 · 外键 referral_commissions.id(删除时禁止) | 占用的佣金ID', + '唯一约束: 1 个', + ' uq_referral_withdrawal_item_wd_commission | withdrawal_id, commission_id', + ]) + }) + + it('只认模型类:枚举类、函数照常显示;模型类的赋值都并进卡片,不再单独成块', async () => { + const list = await views(SNAPSHOT) + expect(list.map(view => view.kind)).toEqual([ + 'comment', + ...Array.from({ length: 9 }, () => 'import'), + 'function', + 'class', + 'class', + 'sqlalchemy.table', + 'sqlalchemy.table', + 'sqlalchemy.table', + 'sqlalchemy.table', + 'sqlalchemy.table', + ]) + for (const view of list.filter(item => item.kind === 'sqlalchemy.table')) + expect(view.children, view.id).toEqual([]) + }) + + it('并进来的行只在卡片里能编辑,卡片的编辑范围是整个类', async () => { + const lines = SNAPSHOT.split('\n') + const table = await model(SNAPSHOT, 'ReferralRelation') + expect(lines[table.startLine]).toBe('class ReferralRelation(IntPK, TimestampMixin, Base):') + expect(lines[table.endLine]).toBe(' )') + expect(lines[table.endLine + 1]).toBe('') + const owners = new Map() + for (const view of flatten(await views(SNAPSHOT))) { + for (let line = view.startLine; line <= view.endLine; line++) + owners.set(line, [...owners.get(line) ?? [], view.id]) + } + expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([]) + expect(owners.get(lines.indexOf(' __tablename__ = "referral_relations"'))).toEqual([table.id]) + }) + + it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => { + const table = await model(SNAPSHOT, 'ReferralRelation') + expect(SNAPSHOT.slice(table.defines?.startIndex, table.defines?.endIndex)).toBe('ReferralRelation') + const fields = properties(table).find(row => row.label === '字段')?.items ?? [] + expect(fields.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual([ + 'inviter_user_id', + 'invitee_user_id', + 'invite_code', + 'bind_time', + 'expire_time', + 'status', + ]) + const [name] = fields[0].cells[0] as Segment[] + expect(name).toEqual({ text: 'inviter_user_id', role: 'variable', offset: fields[0].defines?.startIndex }) + const linked = linkedSegments(properties(table)) + expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['IntPK', 'RelationStatus', 'ACTIVE', 'status'])) + for (const segment of linked) + expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text) + }) + + it('筛选:种类是 sqlalchemy.table,访问级别同原来的类', async () => { + const table = await model(SNAPSHOT, 'ReferralRelation') + expect(table.attributes).toEqual({ kind: 'sqlalchemy.table', access: 'public' }) + }) + + it('import sqlalchemy as sa:老写法 Column、类型全名、只写注解的字段、不认得的类型照常翻、docstring 当说明;方法照常是子块,夹在中间的注释并进来', async () => { + const source = [ + 'import sqlalchemy as sa', + 'from sqlalchemy.orm import DeclarativeBase, relationship', + '', + '', + 'class Base(DeclarativeBase):', + ' pass', + '', + '', + 'class User(Base):', + ' """用户。"""', + '', + ' __tablename__ = "users"', + ' # 基本信息', + ' id = sa.Column(sa.Integer, primary_key=True)', + ' name = sa.Column("user_name", sa.String(50), sa.ForeignKey(Org.name, onupdate="cascade"))', + ' orders = relationship("Order", back_populates="user", uselist=False)', + ' price = sa.Column(sa.Numeric(10, 2), nullable=False)', + ' nickname: Mapped[Optional[str]]', + ' extra = sa.Column(CustomJson(3), comment=NOTE)', + '', + ' def __repr__(self) -> str:', + ' return self.name', + '', + ].join('\n') + const list = await views(source) + expect(list.map(view => view.kind)).toEqual(['import', 'import', 'class', 'sqlalchemy.table']) + const user = list[3] + expect(propertyLines(properties(user))).toEqual([ + '名称: User', + '表名: users', + '说明: 用户。', + '继承: Base', + '字段: 5 个', + ' id | 整数 | 主键 | ', + ' name | 字符串(50) | 可空 · 外键 Org.name(级联更新) | ', + ' price | 定点数(10,2) | | ', + ' nickname | 字符串 | 可空 | ', + ' extra | CustomJson(3) | 可空 | NOTE', + '关系: 1 个', + ' orders | Order | 对方字段 user · 单个对象', + ]) + expect([user.startLine, user.endLine]).toEqual([8, 18]) + expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]]) + }) + + it('没导入 SQLAlchemy 的文件不认', async () => { + const list = await views('class User(Base):\n __tablename__ = "users"\n') + expect(list.map(view => view.kind)).toEqual(['class']) + }) +}) diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts new file mode 100644 index 0000000..f5fee74 --- /dev/null +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -0,0 +1,557 @@ +import type { ClassBlock, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, SourceRange, ThemeColor, TokenRole } from '../contract' + +/** + * SQLAlchemy 库规则:声明式模型类(有 `__tablename__`,或主体里用 `mapped_column(...)` / `Column(...)` 定义字段)整块换成「数据表」卡片: + * 名称 / 表名 / 说明 / 继承 / 装饰器,字段、关系、索引、唯一约束各一张表,都是有才显示。 + * `__tablename__`、`__table_args__`、字段和关系的赋值并进卡片,方法等别的成员照常是子块。 + * 规则只经 contract 拿信息,直接读的是 Python 语法树(tree-sitter-python 的节点类型);用词都在词表里,按翻译插件的显示语言挑。 + */ + +/** 文件导入了这几个库之一才启用;Flask-SQLAlchemy 的模型写成 `db.Column(...)`,文件里可能只导入了它。 */ +const LIBRARIES = ['sqlalchemy', 'flask_sqlalchemy'] +const COLUMN_CALLS = new Set(['mapped_column', 'Column']) +/** 字段定义的位置参数里不是类型的那些(外键、序列、计算列……)。 */ +const SCHEMA_ITEMS = new Set(['ForeignKey', 'Sequence', 'Identity', 'Computed', 'CheckConstraint', 'FetchedValue', 'DefaultClause']) +/** 写成关键字参数的类型大小:`String(length=16)`、`Numeric(precision=10, scale=2)`。 */ +const SIZE_KEYWORDS = ['length', 'precision', 'scale'] +const TABLE_KIND = 'sqlalchemy.table' +const TABLE_COLOR: ThemeColor = { dark: '#e8a860', light: '#a3590d' } + +/** 一种显示语言的用词。 */ +interface Words { + kind: string + labels: Record<'name' | 'table' | 'doc' | 'bases' | 'decorators' | 'columns' | 'relationships' | 'indexes' | 'uniques', string> + columnHeads: string[] + relationshipHeads: string[] + indexHeads: string[] + count: (n: number) => string + /** SQL 类型(SQLAlchemy 的类名;导入时起的别名已换回原名)→ 译名。 */ + types: Record + withTimezone: string + enumType: string + unannotated: string + constraints: Record<'primaryKey' | 'unique' | 'index' | 'nullable' | 'default' | 'serverDefault' | 'foreignKey', string> + /** 外键 ondelete / onupdate 的动作(大写)→ 译名;表里没有的用 otherOn… 拼。 */ + onDelete: Record + onUpdate: Record + otherOnDelete: (action: string) => string + otherOnUpdate: (action: string) => string + /** 外键后面括起来的删除、更新规则。 */ + actions: (texts: string[]) => string + unnamed: string + uniqueIndex: string + relationship: Record<'backPopulates' | 'single' | 'secondary', string> +} + +const ZH: Words = { + kind: '数据表', + labels: { name: '名称', table: '表名', doc: '说明', bases: '继承', decorators: '装饰器', columns: '字段', relationships: '关系', indexes: '索引', uniques: '唯一约束' }, + columnHeads: ['字段', '类型', '约束', '说明'], + relationshipHeads: ['名称', '目标', '说明'], + indexHeads: ['名称', '列'], + count: n => `${n} 个`, + types: { + Integer: '整数', + INTEGER: '整数', + INT: '整数', + BigInteger: '长整数', + BIGINT: '长整数', + SmallInteger: '短整数', + SMALLINT: '短整数', + String: '字符串', + Unicode: '字符串', + VARCHAR: '字符串', + NVARCHAR: '字符串', + CHAR: '字符串', + Text: '长文本', + UnicodeText: '长文本', + TEXT: '长文本', + Boolean: '布尔', + BOOLEAN: '布尔', + DateTime: '日期时间', + DATETIME: '日期时间', + TIMESTAMP: '日期时间', + Date: '日期', + DATE: '日期', + Time: '时间', + TIME: '时间', + Interval: '时间间隔', + Numeric: '定点数', + NUMERIC: '定点数', + DECIMAL: '定点数', + Float: '浮点数', + FLOAT: '浮点数', + REAL: '浮点数', + Double: '浮点数', + DOUBLE_PRECISION: '浮点数', + JSON: 'JSON', + JSONB: 'JSON', + UUID: 'UUID', + Uuid: 'UUID', + LargeBinary: '二进制', + BYTEA: '二进制', + ARRAY: '数组', + }, + withTimezone: '(带时区)', + enumType: '枚举', + unannotated: '未标注', + constraints: { primaryKey: '主键', unique: '唯一', index: '索引', nullable: '可空', default: '默认', serverDefault: '数据库默认', foreignKey: '外键' }, + onDelete: { 'CASCADE': '级联删除', 'RESTRICT': '删除时禁止', 'SET NULL': '删除时置空', 'SET DEFAULT': '删除时置默认值', 'NO ACTION': '删除时不处理' }, + onUpdate: { 'CASCADE': '级联更新', 'RESTRICT': '更新时禁止', 'SET NULL': '更新时置空', 'SET DEFAULT': '更新时置默认值', 'NO ACTION': '更新时不处理' }, + otherOnDelete: action => `删除时 ${action}`, + otherOnUpdate: action => `更新时 ${action}`, + actions: texts => `(${texts.join(',')})`, + unnamed: '未命名', + uniqueIndex: '(唯一)', + relationship: { backPopulates: '对方字段', single: '单个对象', secondary: '中间表' }, +} + +/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */ +const WORDS: Record = { zh: ZH } + +/** 认一个模型类要用的:宿主给的 context、挑好的用词、别名表。 */ +interface Env { + context: RuleContext + words: Words + aliases: Map +} + +/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */ +interface Call { + name: string | undefined + positional: Node[] + keywords: Map +} + +/** 类主体里一条给名字赋值(或只写注解)的语句。 */ +interface Member { + block: CodeBlock + name: Node + annotation?: Node + value?: Node +} + +/** 一个字段:只写 `Mapped[...]` 注解的没有 call。 */ +interface Column { + member: Member + call?: Call +} + +interface Model { + table?: Member + args?: Member + columns: Column[] + relationships: { member: Member, call: Call }[] +} + +// ─── 读语法树 ───────────────────────────────────────────────────────────────── + +/** 具名子节点,注释不算。 */ +function syntaxChildren(node: Node | null | undefined): Node[] { + return node ? node.namedChildren.filter((child): child is Node => child !== null && !child.isExtra) : [] +} + +/** 名字的最后一节:`sa.Column` → Column。 */ +function lastName(node: Node | null | undefined): string | undefined { + if (node?.type === 'identifier') + return node.text + if (node?.type === 'attribute') + return node.childForFieldName('attribute')?.text + return undefined +} + +function isTrue(node: Node | undefined): boolean { + return node?.type === 'true' +} + +/** 字符串字面量的内容;f-string、拼接的字符串不算。 */ +function stringValue(node: Node | null | undefined): string | undefined { + if (node?.type !== 'string') + return undefined + const parts = syntaxChildren(node) + if (parts.some(part => part.type === 'interpolation')) + return undefined + return parts.filter(part => part.type === 'string_content').map(part => part.text).join('') +} + +function fromLibrary(source: Node): boolean { + return LIBRARIES.some(library => source.text === library || source.text.startsWith(`${library}.`)) +} + +/** 从 SQLAlchemy 导入时起的别名 → 原名:`from sqlalchemy import Enum as SQLEnum`。 */ +function aliasesOf(imports: ImportFacts[]): Map { + const aliases = new Map() + for (const facts of imports) { + if (!facts.sources.some(fromLibrary)) + continue + for (const item of facts.items) { + if (item.kind === 'named' && item.alias) + aliases.set(item.alias.text, item.name.text) + } + } + return aliases +} + +/** 类型、函数的原名:直接写的名字按别名表换回,`sa.Integer` 这种取最后一节。 */ +function canonicalName(node: Node | null | undefined, aliases: Map): string | undefined { + const name = lastName(node) + return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name +} + +function callOf(node: Node | null | undefined, aliases: Map): Call | undefined { + if (node?.type !== 'call') + return undefined + const args = node.childForFieldName('arguments') + const positional: Node[] = [] + const keywords = new Map() + for (const arg of args?.type === 'argument_list' ? syntaxChildren(args) : []) { + if (arg.type === 'keyword_argument') { + const key = arg.childForFieldName('name') + const value = arg.childForFieldName('value') + if (key && value) + keywords.set(key.text, value) + } + else if (arg.type !== 'list_splat' && arg.type !== 'dictionary_splat') { + positional.push(arg) + } + } + return { name: canonicalName(node.childForFieldName('function'), aliases), positional, keywords } +} + +function memberOf(block: CodeBlock): Member | undefined { + if (block.kind !== 'field' && block.kind !== 'variable' && block.kind !== 'const') + return undefined + const [assignment] = syntaxChildren(block.node) + const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null + if (!assignment || name?.type !== 'identifier') + return undefined + return { block, name, annotation: assignment.childForFieldName('type') ?? undefined, value: assignment.childForFieldName('right') ?? 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 +} + +function isNone(node: Node): boolean { + return unwrapType(node).type === 'none' +} + +/** 类型去掉 None:`X | None`、`Optional[X]`、`Union[X, None]` → X,并说它可空;拆不出单个类型时留着整个。 */ +function withoutNone(type: Node): { type: Node, optional: boolean } { + const node = unwrapType(type) + if (node.type === 'binary_operator' && node.childForFieldName('operator')?.type === '|') { + const left = node.childForFieldName('left') + const right = node.childForFieldName('right') + if (left && right && isNone(right)) + return { type: left, optional: true } + if (left && right && isNone(left)) + return { type: right, optional: true } + return { type, optional: [left, right].some(side => side && withoutNone(side).optional) } + } + if (node.type === 'generic_type') { + const [head, parameters] = syntaxChildren(node) + const args = syntaxChildren(parameters) + if (lastName(head) === 'Optional' && args.length === 1) + return { type: args[0], optional: true } + const rest = args.filter(arg => !isNone(arg)) + if (lastName(head) === 'Union' && rest.length < args.length) + return { type: rest.length === 1 ? rest[0] : type, optional: true } + } + return { type, optional: false } +} + +/** 类主体里的表名、表参数、字段和关系;既没有表名也没有 mapped_column / Column 字段的类不是模型。 */ +function modelOf(block: ClassBlock, aliases: Map): Model | undefined { + const model: Model = { columns: [], relationships: [] } + for (const child of block.children) { + const member = memberOf(child) + if (!member) + continue + if (member.name.text === '__tablename__') { + model.table = member + continue + } + if (member.name.text === '__table_args__') { + model.args = member + continue + } + const call = callOf(member.value, aliases) + if (call?.name && COLUMN_CALLS.has(call.name)) + model.columns.push({ member, call }) + else if (call?.name === 'relationship') + model.relationships.push({ member, call }) + else if (!member.value && mappedType(member.annotation)) + model.columns.push({ member }) + } + return model.table || model.columns.some(column => column.call) ? model : undefined +} + +/** `__table_args__`:元组 / 列表里的约束、索引,最后一项可以是选项字典;也可以只写一个字典。 */ +function tableArgs(value: Node | undefined): { options?: Node, items: Node[] } { + let node = value + while (node?.type === 'parenthesized_expression') + node = syntaxChildren(node)[0] + if (node?.type === 'dictionary') + return { options: node, items: [] } + if (node?.type !== 'tuple' && node?.type !== 'list') + return { items: [] } + const items = syntaxChildren(node) + const last = items.at(-1) + return last?.type === 'dictionary' ? { options: last, items: items.slice(0, -1) } : { items } +} + +function dictValue(dictionary: Node | undefined, key: string): Node | undefined { + for (const pair of syntaxChildren(dictionary)) { + if (pair.type === 'pair' && stringValue(pair.childForFieldName('key')) === key) + return pair.childForFieldName('value') ?? undefined + } + return undefined +} + +// ─── 写成属性表 ─────────────────────────────────────────────────────────────── + +function range(node: Node): SourceRange { + return { startIndex: node.startIndex, endIndex: node.endIndex } +} + +function plain(text: string): Segment { + return { text, role: 'plain' } +} + +function hint(text: string): Segment { + return { text, role: 'hint' } +} + +function word(text: string): Segment { + return { text, role: 'storage' } +} + +/** 源码里的一个名字照抄,带源码下标(能跳到定义、高亮各处出现)。 */ +function name(node: Node, role: TokenRole): Segment { + return { text: node.text, role, offset: node.startIndex } +} + +function joined(parts: Inline[][], separator: Segment): Inline[] { + return parts.flatMap((part, i) => i > 0 ? [{ ...separator }, ...part] : part) +} + +/** 字符串字面量写内容(不带引号)、按 role 着色;别的表达式交给翻译插件翻。 */ +function textOr(node: Node, role: TokenRole, env: Env): Inline[] { + const text = stringValue(node) + return text === undefined ? env.context.translate(node) : [{ text, role }] +} + +/** 类名、枚举类这种名字:是单个名字就按类型着色、带下标,别的写法交给翻译插件。 */ +function typeName(node: Node, env: Env): Inline[] { + return node.type === 'identifier' ? [name(node, 'type')] : env.context.translate(node) +} + +/** 枚举:`Enum(X)`、`SQLEnum(X)`、包着枚举类的辅助函数(`_enum(X, "…")`)写「枚举 X」;只列了取值的写「枚举 "a", "b"」。 */ +function enumType(call: Call | undefined, env: Env): Inline[] { + const out: Inline[] = [{ text: env.words.enumType, role: 'type' }] + const [first] = call?.positional ?? [] + const values = first && first.type !== 'string' ? [first] : (call?.positional ?? []).filter(arg => arg.type === 'string') + return values.length > 0 ? [...out, plain(' '), ...joined(values.map(value => typeName(value, env)), plain(', '))] : out +} + +/** 字段的 SQL 类型:认得的换成译名,带上大小(`字符串(16)`、`定点数(10,2)`)和时区;不认得的照常翻。 */ +function sqlType(node: Node, env: Env): Inline[] { + const call = callOf(node, env.aliases) + const original = call ? call.name : canonicalName(node, env.aliases) + if (original && /enum/i.test(original)) + return enumType(call, env) + const translated = original && env.words.types[original] + if (!translated) + return env.context.translate(node) + const out: Inline[] = [{ text: translated, role: 'type' }] + if (!call) + return out + const sizes = [...call.positional, ...SIZE_KEYWORDS.flatMap(key => call.keywords.get(key) ?? [])] + if (sizes.length > 0) + out.push(plain('('), ...joined(sizes.map(size => sqlType(size, env)), plain(',')), plain(')')) + if (isTrue(call.keywords.get('timezone'))) + out.push({ text: env.words.withTimezone, role: 'type' }) + return out +} + +function referentialAction(node: Node | undefined, known: Record, other: (action: string) => string): string | undefined { + if (!node) + return undefined + const action = stringValue(node)?.trim().toUpperCase() + return action === undefined ? other(node.text) : known[action] ?? other(action) +} + +/** 外键:「外键 sys_user.id(删除时禁止)」。 */ +function foreignKey(call: Call, env: Env): Inline[] { + const { words } = env + const [target] = call.positional + const out: Inline[] = [word(words.constraints.foreignKey)] + if (target) + out.push(plain(' '), ...textOr(target, 'string', env)) + const actions = [ + referentialAction(call.keywords.get('ondelete'), words.onDelete, words.otherOnDelete), + referentialAction(call.keywords.get('onupdate'), words.onUpdate, words.otherOnUpdate), + ].filter((text): text is string => text !== undefined) + if (actions.length > 0) + out.push(hint(words.actions(actions))) + return out +} + +/** + * 可空:写了 nullable 按它;主键不可空;Column 默认可空;mapped_column 和只写注解的按 `Mapped[...]` 里有没有 None,没注解时同 Column。 + * 非空是常态,不写。 + */ +function isNullable(call: Call | undefined, optional: boolean | undefined): boolean { + const explicit = call?.keywords.get('nullable') + if (explicit) + return isTrue(explicit) + if (isTrue(call?.keywords.get('primary_key'))) + return false + return call?.name === 'Column' || optional === undefined ? true : optional +} + +/** 字段表的一行:字段 | 类型 | 约束 | 说明。讲的定义是字段名。 */ +function columnItem({ member, call }: Column, env: Env): PropertyItem { + const { words, context } = env + const mapped = mappedType(member.annotation) + const annotated = mapped && withoutNone(mapped) + // 第一个位置参数是字符串时是库里的列名,不是类型 + const args = (call?.positional ?? []).filter((arg, i) => !(i === 0 && arg.type === 'string')) + const calls = args.map(arg => callOf(arg, env.aliases)) + const typeArg = args.find((_, i) => !SCHEMA_ITEMS.has(calls[i]?.name ?? '')) + const keywords = call?.keywords ?? new Map() + const constraints: Inline[][] = [] + const flag = (key: string, text: string): void => { + if (isTrue(keywords.get(key))) + constraints.push([word(text)]) + } + flag('primary_key', words.constraints.primaryKey) + flag('unique', words.constraints.unique) + flag('index', words.constraints.index) + if (isNullable(call, annotated ? annotated.optional : undefined)) + constraints.push([word(words.constraints.nullable)]) + for (const [key, text] of [['default', words.constraints.default], ['server_default', words.constraints.serverDefault]]) { + const value = keywords.get(key) + if (value) + constraints.push([word(text), plain(' '), ...context.translate(value)]) + } + for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey')) + constraints.push(foreignKey(foreign, env)) + const comment = keywords.get('comment') + return { + cells: [ + [name(member.name, 'variable')], + typeArg ? sqlType(typeArg, env) : annotated ? context.translate(annotated.type) : [hint(words.unannotated)], + joined(constraints, hint(' · ')), + comment ? textOr(comment, 'comment', env) : [], + ], + defines: range(member.name), + } +} + +/** 关系表的一行:名称 | 目标 | 说明(对方字段、单个对象、中间表)。目标取第一个位置参数,没写时取 `Mapped[...]` 里的类型。 */ +function relationshipItem({ member, call }: { member: Member, call: Call }, env: Env): PropertyItem { + const { words, context } = env + const [target] = call.positional + const mapped = mappedType(member.annotation) + const notes: Inline[][] = [] + const back = call.keywords.get('back_populates') ?? call.keywords.get('backref') + if (back) + notes.push([word(words.relationship.backPopulates), plain(' '), ...textOr(back, 'variable', env)]) + if (call.keywords.get('uselist')?.type === 'false') + notes.push([word(words.relationship.single)]) + const secondary = call.keywords.get('secondary') + if (secondary) + notes.push([word(words.relationship.secondary), plain(' '), ...textOr(secondary, 'string', env)]) + return { + cells: [ + [name(member.name, 'variable')], + target ? textOr(target, 'type', env) : mapped ? context.translate(withoutNone(mapped).type) : [], + joined(notes, hint(' · ')), + ], + defines: range(member.name), + } +} + +/** 索引 / 唯一约束表的一行:名称 | 列。名称是字符串就写内容,没给写「未命名」;唯一索引的名称后面写「(唯一)」。 */ +function constraintItem(nameNode: Node | undefined, columns: Node[], unique: boolean, env: Env): PropertyItem { + const title = nameNode && nameNode.type !== 'none' ? textOr(nameNode, 'string', env) : [hint(env.words.unnamed)] + return { + cells: [ + unique ? [...title, hint(env.words.uniqueIndex)] : title, + joined(columns.map(column => textOr(column, 'variable', env)), plain(', ')), + ], + } +} + +function tableRow(label: string, heads: string[], items: PropertyItem[], env: Env): PropertyRow[] { + return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : [] +} + +function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock { + const { words, context } = env + const { facts } = block + const { labels } = words + const { options, items } = tableArgs(model.args?.value) + const comment = dictValue(options, 'comment') + const doc = (comment && context.doc(comment)) ?? (facts.doc && context.doc(facts.doc)) + const indexes: PropertyItem[] = [] + const uniques: PropertyItem[] = [] + for (const item of items) { + const call = callOf(item, env.aliases) + if (call?.name === 'Index') { + const [indexName, ...columns] = call.positional + indexes.push(constraintItem(indexName, columns, isTrue(call.keywords.get('unique')), env)) + } + else if (call?.name === 'UniqueConstraint') { + uniques.push(constraintItem(call.keywords.get('name'), call.positional, false, env)) + } + } + const rows: PropertyRow[] = [ + ...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []), + ...(model.table?.value ? [{ label: labels.table, value: textOr(model.table.value, 'string', env) }] : []), + ...(doc ? [{ label: labels.doc, value: doc }] : []), + ...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []), + ...(facts.decorators.length > 0 ? [{ label: labels.decorators, value: joined(facts.decorators.map(decorator => context.translate(decorator)), plain('\n')) }] : []), + ...tableRow(labels.columns, words.columnHeads, model.columns.map(column => columnItem(column, env)), env), + ...tableRow(labels.relationships, words.relationshipHeads, model.relationships.map(relationship => relationshipItem(relationship, env)), env), + ...tableRow(labels.indexes, words.indexHeads, indexes, env), + ...tableRow(labels.uniques, words.indexHeads, uniques, env), + ] + const members = [model.table, model.args, ...model.columns.map(column => column.member), ...model.relationships.map(relationship => relationship.member)] + return { + kind: TABLE_KIND, + badge: { label: words.kind, color: TABLE_COLOR }, + rows, + ...(facts.name && { defines: range(facts.name) }), + absorbs: members.filter((member): member is Member => member !== undefined).map(member => member.block), + } +} + +export const sqlalchemyRule: LibraryRule = { + id: 'sqlalchemy', + label: 'SQLAlchemy 数据表', + languageIds: ['python'], + libraries: LIBRARIES, + claim: (block, context) => { + if (block.kind !== 'class') + return undefined + const aliases = aliasesOf(context.imports) + const model = modelOf(block, aliases) + return model && tableBlock(block, model, { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases }) + }, +} diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 58a7dbd..6c6888a 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -397,6 +397,15 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return joinTokens(tokens) } + function expression(node: Node, document: SourceDocument): Inline[] { + return joinTokens(tokensIn(node, nodeSpan(node), document)) + } + + function doc(node: Node): Markdown | undefined { + const markdown = docText([table.commentText(node)]) + return markdown ? { markdown } : undefined + } + function describe(block: FactBlock, document: SourceDocument): PropertyRow[] { const { header } = block const { language } = document @@ -406,7 +415,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return joinTokens(tokensIn(node, span, document)) } const phrases: Phrases = { - translated: node => within(node, { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }), + translated: node => within(node, nodeSpan(node)), within, entryCount: (literal, size) => { const name = table.mapLiterals[literal.type] @@ -416,10 +425,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'), name: (node, role) => [segment(node.text, role, offsetOf(node, language))], - doc: (node) => { - const markdown = docText([table.commentText(node)]) - return markdown ? { markdown } : undefined - }, + doc, } switch (block.kind) { case 'function': @@ -433,7 +439,21 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe, attributeNames: ZH_ATTRIBUTE_NAMES } + return { + id: table.id, + label: table.label, + languageIds: table.languageIds, + displayLanguage: 'zh', + translate, + describe, + expression, + doc, + attributeNames: ZH_ATTRIBUTE_NAMES, + } +} + +function nodeSpan(node: Node): SourceSpan { + return { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row } } function segment(text: string, role: TokenRole, offset: number | undefined): Segment { diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 47a11ab..50879b5 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -1,5 +1,5 @@ -import type { ReactElement } from 'react' -import type { BlockKind, BlockView } from './types' +import type { CSSProperties, ReactElement } from 'react' +import type { BlockView } from './types' import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react' import { Fragment } from 'react' import { cn } from '@/lib/utils' @@ -16,8 +16,8 @@ export interface BlockTreeState { hidden: ReadonlySet /** 本来被筛掉、跳到定义时临时显示的块 */ temporary: ReadonlySet - /** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */ - kindName: (kind: BlockKind) => string + /** 种类标签上写什么:翻译插件给种类起的名字(库规则的种类是规则给的标签),没给就是 id */ + kindName: (kind: string) => string /** 刚点了「编辑」、要把光标放进去的块 */ focusId: string | null onFocused: () => void @@ -31,12 +31,25 @@ function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean { } /** - * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。 - * kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。 + * 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色, + * 暗色、亮色两个都写在 style 上,CSS 按主题挑一个(见 [data-badge])。 */ -export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: BlockKind, tree: BlockTreeState }): ReactElement { +function kindProps(block: BlockView | undefined): { 'data-kind'?: string, 'data-badge'?: '', 'style'?: CSSProperties } { + if (!block) + return {} + if (!block.badge) + return { 'data-kind': block.kind } + const { dark, light } = block.badge.color + return { 'data-kind': block.kind, 'data-badge': '', 'style': { '--kind-dark': dark, '--kind-light': light } as CSSProperties } +} + +/** + * 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。 + * owner 是这一层所属的块(顶层没有),悬停它时竖线换成它的种类色。 + */ +export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner?: BlockView, tree: BlockTreeState }): ReactElement { return ( -
+
@@ -45,7 +58,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: {hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && ( - + )} ))} @@ -54,7 +67,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?: ) } -/** 预览状态的内容:函数、类、导入、常量、变量块是属性表,其余是一行译文。 */ +/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文。 */ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { if ('properties' in block) return @@ -70,7 +83,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }): return (
) : } - + {tree.kindName(block.kind)}
diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index cdd758a..0b801e4 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -4,11 +4,11 @@ import { ChevronDown, ChevronRight } from 'lucide-react' import { Fragment } from 'react' import { inlineText } from './segments' import { SegmentText } from './SegmentText' -import { subRowsLayout, tableCells } from './subRows' +import { subRowsLayout, tableCells, tableTemplate } from './subRows' import { TextPreview } from './TextPreview' import { propertyItemId, propertyRowId } from './viewState' -/** 子行里除最后一列以外各列的宽度(参数表格的名称、类型列,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ +/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] function gridTemplate(count: number): string { @@ -95,19 +95,18 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items: } /** - * 给了列名的子行(输入参数)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 - * 列宽同列表的子行,最后一列占满剩下的,长了在格子里换行。 + * 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 + * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。 */ function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { - const template = { gridTemplateColumns: gridTemplate(columns.length) } return (
-
-
+
+
{columns.map((name, i) => {name})}
{items.map((item, index) => ( -
+
{tableCells(item, columns).map((cell, i) => (

@@ -121,8 +120,8 @@ function PropertyTable({ parentId, columns, items }: { parentId: string, columns } /** - * 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, - * 翻译插件给了列名的(输入参数)排成表格,没给的排成列表。 + * 函数、类、导入、常量、变量块和库规则认领的块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行, + * 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。 */ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: { blockId: string diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index bb067fa..d2d9ed4 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -63,6 +63,9 @@ body.vscode-high-contrast-light { [data-kind="return"] { --kind: var(--cr-kind-return); } [data-kind="statement"] { --kind: var(--cr-kind-statement); } [data-kind="comment"] { --kind: var(--cr-kind-comment); } +/* 库规则认领的块:颜色由规则给,暗色、亮色两个写在元素的 style 上(--kind-dark / --kind-light),这里按主题挑 */ +[data-badge] { --kind: var(--kind-dark); } +body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(--kind-light); } /* 卡片和它的子层是相邻兄弟,:hover 只落在鼠标下最里面那张卡片上,父卡片不跟着亮 */ .cr-block { @@ -330,15 +333,23 @@ body.vscode-high-contrast-light { } /* - * 属性表里带列名的子行(输入参数)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线; - * 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色 + * 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线; + * 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色。 + * 整张表格一个网格、每行借用它的列(subgrid),列宽按整列的内容定,见 tableTemplate */ .cr-table { --cr-table-border: var(--vscode-tree-tableColumnsBorder, var(--vscode-editorWidget-border, var(--vscode-contrastBorder))); + display: grid; border: 1px solid var(--cr-table-border); border-radius: 4px; overflow: hidden; } +.cr-table-head, +.cr-table-row { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} .cr-table-head { background: var(--vscode-keybindingTable-headerBackground, color-mix(in srgb, var(--vscode-foreground) 5%, transparent)); } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index adffa4f..8a968cd 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest' import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript' +import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh' @@ -293,6 +294,30 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await atPy('run')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'def run(self):' }) }) + it('库规则认领的块:类名闪整块,字段名只闪字段表里它那一行(先展开「字段」那一行);留下的方法照常,卡片默认折叠', async () => { + const source = [ + 'from sqlalchemy.orm import Mapped, mapped_column', + '', + 'class User(Base):', + ' __tablename__ = "users"', + ' name: Mapped[str] = mapped_column(comment="名字")', + '', + ' def greet(self):', + ' return self.name', + ].join('\n') + const views = await withBlocks(py, 'python', source, blocks => + toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) + const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { + const offset = source.indexOf(needle) + const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) + return { plan, flashed: flashed(views, plan) } + } + expect(at('User')).toEqual({ plan: { id: '1', expand: [] }, flashed: 'class User(Base):' }) + expect(at('name:')).toEqual({ plan: { id: '1', row: '1/p3/0', expand: ['1/p3'] }, flashed: 'name | 字符串 | | 名字' }) + expect(at('greet')).toEqual({ plan: { id: '1.0', expand: ['1'] }, flashed: 'def greet(self):' }) + expect([...collapsedIds(views, {})].sort()).toEqual(['1', '1.0']) + }) + it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => { const source = [ 'export const routes = {', diff --git a/vscode/webview-ui/src/codeReader/subRows.test.ts b/vscode/webview-ui/src/codeReader/subRows.test.ts index 5358625..edc9ae2 100644 --- a/vscode/webview-ui/src/codeReader/subRows.test.ts +++ b/vscode/webview-ui/src/codeReader/subRows.test.ts @@ -4,7 +4,7 @@ import { toBlockViews } from '../../../src/codeReader/blockView' import { createPythonAdapter } from '../../../src/codeReader/languages/python' import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport' import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh' -import { subRowsLayout, tableCells } from './subRows' +import { subRowsLayout, tableCells, tableTemplate } from './subRows' const x = { text: 'x', role: 'variable' as const, offset: 4 } @@ -22,6 +22,11 @@ describe('属性行下面的子行怎么排', () => { expect(tableCells({ cells: [[x], [x], [x], [x]] }, ['名称', '类型'])).toEqual([[x], [x]]) }) + it('表格的列宽按整列内容定:前几列最宽到不换行,最后一列分剩下的、至少留一段', () => { + expect(tableTemplate(2)).toBe('minmax(0, max-content) minmax(min(20%, 8em), 1fr)') + expect(tableTemplate(4)).toBe('minmax(0, max-content) minmax(0, max-content) minmax(0, max-content) minmax(min(20%, 8em), 1fr)') + }) + it('真实的 Python 函数:输入参数排成表格,导入项、字典的各项排成列表', async () => { const source = [ 'from m import a', diff --git a/vscode/webview-ui/src/codeReader/subRows.ts b/vscode/webview-ui/src/codeReader/subRows.ts index 86c12ef..209004b 100644 --- a/vscode/webview-ui/src/codeReader/subRows.ts +++ b/vscode/webview-ui/src/codeReader/subRows.ts @@ -10,6 +10,16 @@ export function subRowsLayout(row: PropertyRow): 'table' | 'list' | undefined { return row.columns ? 'table' : 'list' } +/** + * 表格的列宽(整张表格一个网格,各行借用它的列):按整列的内容定,不认是哪种表格。 + * 前几列(参数名、类型,字段、约束)宽裕时放到整列不换行;最后一列(默认值、说明)分剩下的宽度,至少留 min(20%, 8em): + * 放不下时先让最后一列换行,前几列(约束这种结构化的词)尽量一行写完。编辑区再窄,前几列平分剩下的宽度、在格子里换行(长名字从中间断开), + * 不把表格撑出卡片、不藏掉哪一列。 + */ +export function tableTemplate(count: number): string { + return [...Array.from({ length: count - 1 }, () => 'minmax(0, max-content)'), 'minmax(min(20%, 8em), 1fr)'].join(' ') +} + /** 表格里一个子行的格子:按列名的列数取,子行少给的格子留空、多给的不显示。 */ export function tableCells(item: PropertyItem, columns: string[]): Inline[][] { return columns.map((_, i) => item.cells[i] ?? []) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index df47076..8d4065f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -63,14 +63,21 @@ export interface PropertyRow extends Defines { columns?: string[] } +/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */ +export interface ThemeColor { dark: string, light: string } + +/** 种类标签:文字和颜色 */ +export interface KindBadge { label: string, color: ThemeColor } + interface BlockViewBase { /** 在树里的位置,例如 '6.1.0' */ id: string - kind: BlockKind - /** header 的起止行,0 起 */ + /** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */ + kind: string + /** 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止 */ startLine: number endLine: number - /** header 源码,去掉共同缩进 */ + /** 编辑范围的源码,去掉共同缩进 */ code: string /** 源码着色(拼起来等于 code) */ codeTokens: Segment[] @@ -78,10 +85,12 @@ interface BlockViewBase { defines?: SourceRange /** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */ attributes: Record + /** 库规则认领的块才有:种类标签的文字和颜色,由规则给;和类块一样默认收起子块 */ + badge?: KindBadge children: BlockView[] } -/** 函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */ +/** 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */ export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] }) export interface TranslatorOption { id: string, label: string } diff --git a/vscode/webview-ui/src/codeReader/viewState.test.ts b/vscode/webview-ui/src/codeReader/viewState.test.ts index d49c6cd..131b455 100644 --- a/vscode/webview-ui/src/codeReader/viewState.test.ts +++ b/vscode/webview-ui/src/codeReader/viewState.test.ts @@ -106,6 +106,11 @@ describe('眼下折叠着哪些', () => { expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2', '2.0']) }) + it('没点过:库规则认领的块(带种类标签)和类块一样折叠,不看种类 id', () => { + const table: BlockView = { ...block('0', 'if', [block('0.0', 'return')]), kind: 'sqlalchemy.table', badge: { label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } } } + expect([...collapsedIds([table, block('1', 'if')], {})]).toEqual(['0']) + }) + it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => { const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false } expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0']) diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 9ac6adb..1d871a9 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -1,5 +1,5 @@ import type { ScrollAnchor } from './scrollPosition' -import type { BlockKind, BlockView } from './types' +import type { BlockView } from './types' /** * 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、 @@ -54,9 +54,9 @@ function readViewState(saved: unknown): ViewState { return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds } } -/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ -export function foldedByDefault(kind: BlockKind): boolean { - return kind === 'function' || kind === 'class' +/** 块默认折叠吗:函数、类块和库规则认领的块收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */ +export function foldedByDefault(block: Pick): boolean { + return block.badge !== undefined || block.kind === 'function' || block.kind === 'class' } /** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */ @@ -64,7 +64,7 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly folds[id])) const visit = (list: readonly BlockView[]): void => { for (const block of list) { - if (folds[block.id] === undefined && foldedByDefault(block.kind)) + if (folds[block.id] === undefined && foldedByDefault(block)) collapsed.add(block.id) visit(block.children) }