✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片
This commit is contained in:
1 parent
32be7d54b6
commit
5ece24cef9
26 files changed
+1768
-81
No files matched your search
@@ -20,7 +20,7 @@
|
|||||||
| 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 |
|
| 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 |
|
||||||
| 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 |
|
| 侧栏点文件 | 支持的语言默认打开成「预览」;不支持的语言用普通编辑器打开 |
|
||||||
| 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 |
|
| 块内修改 | 改的就是文件本身:未保存标记、⌘S 保存、⌘Z 撤销都照常,切回「预览」按新代码重新翻译 |
|
||||||
| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)和类块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 |
|
| 折叠 | 有子块的块、属性表里带子行的行、带下一层的子行(字典 / 对象里嵌套的字典 / 对象)能折叠;函数块(含方法、嵌套函数)、类块和库规则认领的块默认收起主体、属性表照常显示,其余块、属性行和子行默认展开;用户点过的按他点的记住 |
|
||||||
| 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 |
|
| 函数、类的说明 | TypeScript 紧贴在函数、类前面的 JSDoc,Python 函数、类主体开头的 docstring 是它自己的说明:进属性表「说明」一行,不再单独成一块注释;它的源码行算在函数、类块的头部里,「编辑」时和签名一起改 |
|
||||||
| 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 |
|
| 注释按 Markdown 显示 | 注释块和函数、类的「说明」按 Markdown 渲染,像 Kotlin 的 KDoc:去掉注释符号、引号和共同缩进后的原文原样交给 Markdown,不按语言转换(reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄,注释以后慢慢改成 Markdown 写法);段内单个换行是软换行,空行分段 |
|
||||||
| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 |
|
| 跳到定义 | 和普通编辑器一样 ⌘ / Ctrl + 点击名字跳到定义,定义由 VS Code 装的语言扩展(Pylance、TypeScript 语言服务……)查;目标语言有代码阅读就在预览里打开 |
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
| 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 |
|
| 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 |
|
||||||
| 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 |
|
| 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 |
|
||||||
| 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 |
|
| 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 |
|
||||||
|
| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;用词归规则自己,webview 只画结构。见「库规则」 |
|
||||||
| 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 |
|
| 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 |
|
||||||
|
|
||||||
## 用户可见行为
|
## 用户可见行为
|
||||||
@@ -74,7 +75,8 @@
|
|||||||
- 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。
|
- 预览:函数、类、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。
|
||||||
- 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。
|
- 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮文字「预览」;Esc 也回到预览。
|
||||||
- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。
|
- 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里。
|
||||||
- 折叠:函数、类块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。
|
- 库规则认领的块(数据表卡片等,见「库规则」):种类标签的字和颜色由规则给,内容是规则给的属性表;它的编辑范围是原来的头部加上并进来的成员,「编辑」时一起改。
|
||||||
|
- 折叠:函数、类块和库规则认领的块打开时默认收起子块(属性表照常显示),其余块默认展开;用户点过折叠箭头的块按他点的,记在视图状态里(见「预览数据与消息」)。
|
||||||
- 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。
|
- 顶部筛选栏:见「筛选」;翻译下拉框(多于一种翻译时才显示)也在栏里。
|
||||||
- 解析失败:显示错误原因,并提示用「编辑」查看源码。
|
- 解析失败:显示错误原因,并提示用「编辑」查看源码。
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
|
|
||||||
卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。
|
卡片外观(折叠箭头、种类标签、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`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'
|
id: string // 'typescript.zh-template'
|
||||||
label: string // '中文直译'
|
label: string // '中文直译'
|
||||||
languageIds: string[]
|
languageIds: string[]
|
||||||
|
displayLanguage: string // 译文用的语言('zh'):库规则按它挑自己的词表
|
||||||
translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文
|
translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文
|
||||||
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表
|
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、导入、常量、变量块 → 属性表
|
||||||
|
expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它
|
||||||
|
doc: (node: Node, document: SourceDocument) => Markdown | undefined // 说明文字(注释、docstring、当说明用的字符串)→ Markdown 原文,同属性表的「说明」
|
||||||
attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类标签用它
|
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 {
|
export interface CodeReaderApi {
|
||||||
registerLanguage: (adapter: LanguageAdapter) => Disposable
|
registerLanguage: (adapter: LanguageAdapter) => Disposable
|
||||||
registerTranslator: (translator: Translator) => Disposable
|
registerTranslator: (translator: Translator) => Disposable
|
||||||
|
registerLibraryRule: (rule: LibraryRule) => Disposable
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。
|
- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。
|
||||||
- 内置的 TypeScript、Python 语言插件和两个中文直译翻译插件在 `activate()` 里通过同一个 API 注册,没有旁路。
|
- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy 库规则在 `activate()` 里通过同一个 API 注册,没有旁路。
|
||||||
- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。
|
- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。
|
||||||
- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。
|
- 分工:语言插件只从语法树读出和语言无关的事实(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 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。
|
- 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ 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` 的遍历照源码结构逐词翻。
|
- 第三方语言插件要给的:`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`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。
|
- 翻译插件拿到的:代码块(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 语法包,版本配套)。
|
- 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。
|
||||||
@@ -540,17 +678,18 @@ Python 内置类型(仅注解里):str 字符串、int 整数、float 浮
|
|||||||
```ts
|
```ts
|
||||||
interface BlockViewBase {
|
interface BlockViewBase {
|
||||||
id: string // 在树里的位置,例如 '6.1.0'
|
id: string // 在树里的位置,例如 '6.1.0'
|
||||||
kind: BlockKind
|
kind: string // BlockKind,或库规则认领的块的种类 id(sqlalchemy.table)
|
||||||
startLine: number // header 的起止行,0 起
|
startLine: number // 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止
|
||||||
endLine: number
|
endLine: number
|
||||||
code: string // header 源码,去掉共同缩进
|
code: string // 编辑范围的源码,去掉共同缩进
|
||||||
codeTokens: Segment[] // 源码着色(拼起来等于 code)
|
codeTokens: Segment[] // 源码着色(拼起来等于 code)
|
||||||
defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取;跳到的定义是它时闪整块
|
defines?: SourceRange // 块自己的名字(函数名、类名、只声明一个变量时的变量名)在源码里的范围,扩展端从 facts 取(库规则的块取 RuleBlock.defines);跳到的定义是它时闪整块
|
||||||
attributes: Record<string, string> // 筛选属性(含 kind),见「筛选」
|
attributes: Record<string, string> // 筛选属性(含 kind),见「筛选」
|
||||||
|
badge?: KindBadge // 库规则认领的块才有:种类标签的字和颜色;webview 按它上色、默认折叠
|
||||||
children: BlockView[]
|
children: BlockView[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 属性表块(有 facts)只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */
|
/** 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 */
|
||||||
type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
|
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。
|
属性表里:说明按 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 初始化、语法包加载、解析
|
runtime.ts web-tree-sitter 初始化、语法包加载、解析
|
||||||
blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译)
|
blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译)
|
||||||
attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数
|
attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数
|
||||||
|
libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、并进来的范围
|
||||||
|
rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract)
|
||||||
languages/typescript.ts
|
languages/typescript.ts
|
||||||
languages/python.ts (iteration:推导式的结构)
|
languages/python.ts (iteration:推导式的结构)
|
||||||
translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup)
|
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 不认。
|
- 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。
|
||||||
- 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。
|
- 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。
|
||||||
- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取。
|
- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。
|
||||||
- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。
|
- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。
|
||||||
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。
|
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。
|
||||||
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。
|
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / 相对路径的链接和图片不渲染;跳到定义不认 Markdown 里的名字。
|
||||||
- 行替换:多行块替换后缩进正确。
|
- 行替换:多行块替换后缩进正确。
|
||||||
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
|
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
|
||||||
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。
|
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 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`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
|
- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
|
||||||
- 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。
|
- 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。
|
||||||
|
|
||||||
|
|||||||
@@ -44,31 +44,37 @@ export function blockAttributes(block: CodeBlock): BlockAttributes {
|
|||||||
|
|
||||||
interface Attributed {
|
interface Attributed {
|
||||||
attributes: BlockAttributes
|
attributes: BlockAttributes
|
||||||
|
/** 库规则认领的块的种类标签:这个种类在筛选栏里也叫它。 */
|
||||||
|
badge?: { label: string }
|
||||||
children: Attributed[]
|
children: Attributed[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面,
|
* 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面,
|
||||||
* 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id。
|
* 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id;库规则的种类用规则给的标签。
|
||||||
*/
|
*/
|
||||||
export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] {
|
export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] {
|
||||||
const counts = new Map<string, Map<string, number>>()
|
const counts = new Map<string, Map<string, number>>()
|
||||||
|
const badges = new Map<string, string>()
|
||||||
const visit = (list: readonly Attributed[]): void => {
|
const visit = (list: readonly Attributed[]): void => {
|
||||||
for (const block of list) {
|
for (const block of list) {
|
||||||
for (const [id, value] of Object.entries(block.attributes)) {
|
for (const [id, value] of Object.entries(block.attributes)) {
|
||||||
const values = counts.get(id) ?? new Map<string, number>()
|
const values = counts.get(id) ?? new Map<string, number>()
|
||||||
counts.set(id, values.set(value, (values.get(value) ?? 0) + 1))
|
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(block.children)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
visit(blocks)
|
visit(blocks)
|
||||||
const common: readonly string[] = COMMON_ATTRIBUTES
|
const common: readonly string[] = COMMON_ATTRIBUTES
|
||||||
const rank = (id: string): number => common.includes(id) ? common.indexOf(id) : common.length
|
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 是稳定的:语言自己的属性同排在最后,保持第一次出现的先后
|
// sort 是稳定的:语言自己的属性同排在最后,保持第一次出现的先后
|
||||||
return [...counts].sort(([a], [b]) => rank(a) - rank(b)).map(([id, values]) => ({
|
return [...counts].sort(([a], [b]) => rank(a) - rank(b)).map(([id, values]) => ({
|
||||||
id,
|
id,
|
||||||
name: names?.attributes[id] ?? 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 })),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -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 type { TreeSitterRuntime } from './runtime'
|
||||||
import { blockAttributes } from './attributes'
|
import { blockAttributes } from './attributes'
|
||||||
import { commonIndent, splitLines } from './editText'
|
import { commonIndent, splitLines } from './editText'
|
||||||
|
import { absorbedRange, activeRules, claimBlock, importsIn } from './libraryRules'
|
||||||
|
|
||||||
interface BlockViewBase {
|
interface BlockViewBase {
|
||||||
/** 在树里的位置,例如 '6.1.0'。 */
|
/** 在树里的位置,例如 '6.1.0'。 */
|
||||||
id: string
|
id: string
|
||||||
kind: BlockKind
|
/** BlockKind,或库规则认领的块的种类 id(RuleBlock.kind,带点,例如 sqlalchemy.table)。 */
|
||||||
/** header 的起止行,0 起。 */
|
kind: string
|
||||||
|
/** 编辑范围的起止行,0 起:一般是 header;库规则认领的块到最后一个并进来的子块为止(见 RuleBlock.absorbs)。 */
|
||||||
startLine: number
|
startLine: number
|
||||||
endLine: number
|
endLine: number
|
||||||
/** header 所在的整行源码,去掉共同缩进。 */
|
/** 编辑范围的整行源码,去掉共同缩进。 */
|
||||||
code: string
|
code: string
|
||||||
/** 源码着色(拼起来等于 code)。 */
|
/** 源码着色(拼起来等于 code)。 */
|
||||||
codeTokens: Segment[]
|
codeTokens: Segment[]
|
||||||
/** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */
|
/** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */
|
||||||
defines?: SourceRange
|
defines?: SourceRange
|
||||||
/** 筛选属性(含 kind),见 blockAttributes。 */
|
/** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */
|
||||||
attributes: BlockAttributes
|
attributes: BlockAttributes
|
||||||
|
/** 库规则认领的块才有:种类标签的文字和颜色(RuleBlock.badge)。预览同类块一样默认收起它的子块。 */
|
||||||
|
badge?: KindBadge
|
||||||
children: BlockView[]
|
children: BlockView[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */
|
/**
|
||||||
|
* 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||||
|
*/
|
||||||
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
|
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
|
||||||
|
|
||||||
interface Line { start: number, end: number }
|
interface Line { start: number, end: number }
|
||||||
@@ -102,23 +108,64 @@ function ownName(block: CodeBlock): Node | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 */
|
/** 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记。 */
|
||||||
export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined): BlockView[] {
|
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 lines = lineRanges(source)
|
||||||
const texts = splitLines(source)
|
const texts = splitLines(source)
|
||||||
const document: SourceDocument = { source, language: adapter }
|
const document: SourceDocument = { source, language: adapter }
|
||||||
const build = (block: CodeBlock, id: string): BlockView => {
|
const imports = importsIn(blocks)
|
||||||
const { startRow, endRow } = block.header
|
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<BlockView, 'startLine' | 'endLine' | 'code' | 'codeTokens'> => {
|
||||||
const rows = texts.slice(startRow, endRow + 1)
|
const rows = texts.slice(startRow, endRow + 1)
|
||||||
const indent = commonIndent(rows).length
|
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)
|
const name = ownName(block)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
kind: block.kind,
|
kind: block.kind,
|
||||||
startLine: startRow,
|
...sourceRows(root, block.header.startRow, block.header.endRow),
|
||||||
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),
|
|
||||||
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
|
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
|
||||||
attributes: blockAttributes(block),
|
attributes: blockAttributes(block),
|
||||||
children: block.children.map((child, i) => build(child, `${id}.${i}`)),
|
children: block.children.map((child, i) => build(child, `${id}.${i}`)),
|
||||||
@@ -137,10 +184,11 @@ export async function readBlockViews(
|
|||||||
translator: Translator | undefined,
|
translator: Translator | undefined,
|
||||||
languageId: string,
|
languageId: string,
|
||||||
source: string,
|
source: string,
|
||||||
|
rules: RuleRun = NO_RULES,
|
||||||
): Promise<BlockView[]> {
|
): Promise<BlockView[]> {
|
||||||
const tree = await runtime.parse(adapter, languageId, source)
|
const tree = await runtime.parse(adapter, languageId, source)
|
||||||
try {
|
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 {
|
finally {
|
||||||
tree.delete()
|
tree.delete()
|
||||||
|
|||||||
@@ -284,15 +284,82 @@ export interface Translator {
|
|||||||
id: string // 'typescript.zh-template'
|
id: string // 'typescript.zh-template'
|
||||||
label: string // '中文直译'
|
label: string // '中文直译'
|
||||||
languageIds: string[]
|
languageIds: string[]
|
||||||
|
/** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */
|
||||||
|
displayLanguage: string
|
||||||
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
|
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
|
||||||
translate: (block: TextBlock, document: SourceDocument) => PreviewText
|
translate: (block: TextBlock, document: SourceDocument) => PreviewText
|
||||||
/** 函数、类、导入、常量、变量块 → 属性表。 */
|
/** 函数、类、导入、常量、变量块 → 属性表。 */
|
||||||
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
|
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
|
||||||
|
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
|
||||||
|
expression: (node: Node, document: SourceDocument) => Inline[]
|
||||||
|
/** 一段说明文字(注释、docstring、当说明用的字符串)去掉注释符号、引号和共同缩进后的 Markdown 原文,同属性表的「说明」;没有文字时没有。 */
|
||||||
|
doc: (node: Node, document: SourceDocument) => Markdown | undefined
|
||||||
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
|
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
|
||||||
attributeNames?: AttributeNames
|
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 {
|
export interface CodeReaderApi {
|
||||||
registerLanguage: (adapter: LanguageAdapter) => Disposable
|
registerLanguage: (adapter: LanguageAdapter) => Disposable
|
||||||
registerTranslator: (translator: Translator) => Disposable
|
registerTranslator: (translator: Translator) => Disposable
|
||||||
|
registerLibraryRule: (rule: LibraryRule) => Disposable
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { Memento } from 'vscode'
|
import type { Memento } from 'vscode'
|
||||||
|
import type { LibraryRule } from '../contract'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { createPythonAdapter } from '../languages/python'
|
||||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||||
import { CodeReaderRegistry } from '../registry'
|
import { CodeReaderRegistry } from '../registry'
|
||||||
|
import { sqlalchemyRule } from '../rules/sqlalchemy'
|
||||||
import { runtime, WASM_DIR } from '../testSupport'
|
import { runtime, WASM_DIR } from '../testSupport'
|
||||||
|
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||||
import { documentMessage } from './documentMessage'
|
import { documentMessage } from './documentMessage'
|
||||||
|
|
||||||
@@ -19,13 +23,33 @@ function registryWithTypeScript(): CodeReaderRegistry {
|
|||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registryWithPython(...rules: LibraryRule[]): CodeReaderRegistry {
|
||||||
|
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
|
||||||
|
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 } {
|
function doc(languageId: string, text: string, version = 3): { languageId: string, version: number, getText: () => string } {
|
||||||
return { languageId, version, getText: () => text }
|
return { languageId, version, getText: () => text }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('预览的 document 消息', () => {
|
describe('预览的 document 消息', () => {
|
||||||
it('受支持的语言:带文档版本、代码块、可选翻译和选中的翻译', async () => {
|
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({
|
expect(message).toMatchObject({
|
||||||
type: 'document',
|
type: 'document',
|
||||||
version: 3,
|
version: 3,
|
||||||
@@ -42,16 +66,43 @@ describe('预览的 document 消息', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('没有语言插件的语言:带 error,不解析', async () => {
|
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.blocks).toEqual([])
|
||||||
expect(message.attributes).toEqual([])
|
expect(message.attributes).toEqual([])
|
||||||
expect(message.translators).toEqual([])
|
expect(message.translators).toEqual([])
|
||||||
expect(message.error).toContain('rust')
|
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 () => {
|
it('解析失败:把原因放进 error', async () => {
|
||||||
const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) }
|
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.blocks).toEqual([])
|
||||||
expect(message.error).toBe('语法包加载失败')
|
expect(message.error).toBe('语法包加载失败')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ export interface DocumentSnapshot {
|
|||||||
getText: () => string
|
getText: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。 */
|
/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */
|
||||||
export async function documentMessage(
|
export async function documentMessage(
|
||||||
document: DocumentSnapshot,
|
document: DocumentSnapshot,
|
||||||
registry: Pick<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator'>,
|
registry: Pick<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator' | 'rulesFor'>,
|
||||||
runtime: Pick<TreeSitterRuntime, 'parse'>,
|
runtime: Pick<TreeSitterRuntime, 'parse'>,
|
||||||
|
report: (message: string) => void,
|
||||||
): Promise<DocumentMessage> {
|
): Promise<DocumentMessage> {
|
||||||
const { languageId, version } = document
|
const { languageId, version } = document
|
||||||
const source = document.getText()
|
const source = document.getText()
|
||||||
@@ -33,7 +34,7 @@ export async function documentMessage(
|
|||||||
if (!adapter)
|
if (!adapter)
|
||||||
return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }
|
return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }
|
||||||
try {
|
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) }
|
return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|||||||
@@ -141,7 +141,9 @@ class Preview {
|
|||||||
if (!this.ready)
|
if (!this.ready)
|
||||||
return
|
return
|
||||||
const request = ++this.latest
|
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)
|
if (request !== this.latest || !this.ready)
|
||||||
return
|
return
|
||||||
const reveal = this.pendingReveal
|
const reveal = this.pendingReveal
|
||||||
|
|||||||
@@ -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, 末张可拆)"},
|
||||||
|
)
|
||||||
@@ -10,6 +10,7 @@ import { createTypeScriptAdapter } from './languages/typescript'
|
|||||||
import { registerNoteCommands } from './notes/commands'
|
import { registerNoteCommands } from './notes/commands'
|
||||||
import { FileNotes } from './notes/fileNotes'
|
import { FileNotes } from './notes/fileNotes'
|
||||||
import { CodeReaderRegistry } from './registry'
|
import { CodeReaderRegistry } from './registry'
|
||||||
|
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||||
import { pythonZhTranslator } from './translators/pythonZh'
|
import { pythonZhTranslator } from './translators/pythonZh'
|
||||||
import { typescriptZhTranslator } from './translators/typescriptZh'
|
import { typescriptZhTranslator } from './translators/typescriptZh'
|
||||||
@@ -41,7 +42,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
|
* 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
|
||||||
* 内置的语言插件和翻译插件与其他扩展一样通过返回的 API 注册。
|
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
|
||||||
*/
|
*/
|
||||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||||
const registry = new CodeReaderRegistry(context.globalState)
|
const registry = new CodeReaderRegistry(context.globalState)
|
||||||
@@ -74,6 +75,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
|||||||
api.registerLanguage(createPythonAdapter(wasmDir)),
|
api.registerLanguage(createPythonAdapter(wasmDir)),
|
||||||
api.registerTranslator(typescriptZhTranslator),
|
api.registerTranslator(typescriptZhTranslator),
|
||||||
api.registerTranslator(pythonZhTranslator),
|
api.registerTranslator(pythonZhTranslator),
|
||||||
|
api.registerLibraryRule(sqlalchemyRule),
|
||||||
)
|
)
|
||||||
return api
|
return api
|
||||||
}
|
}
|
||||||
@@ -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<RuleBlock> = {}): 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<BlockView[]> {
|
||||||
|
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<boolean> {
|
||||||
|
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<number, string[]>()
|
||||||
|
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 },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Memento } from 'vscode'
|
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 { describe, expect, it } from 'vitest'
|
||||||
import { CodeReaderRegistry, TRANSLATOR_CHOICE_KEY } from './registry'
|
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 {
|
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', () => {
|
describe('codeReaderRegistry', () => {
|
||||||
@@ -73,6 +77,25 @@ describe('codeReaderRegistry', () => {
|
|||||||
expect(registry.selectedTranslator('typescript')?.id).toBe('ts.zh')
|
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', () => {
|
it('变化事件可退订,也能放进 disposables', () => {
|
||||||
const registry = new CodeReaderRegistry(fakeMemento())
|
const registry = new CodeReaderRegistry(fakeMemento())
|
||||||
let changes = 0
|
let changes = 0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Disposable, Event, Memento } from 'vscode'
|
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<languageId, translatorId>。 */
|
/** globalState 里按 languageId 记选中的翻译插件 id:Record<languageId, translatorId>。 */
|
||||||
export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguage'
|
export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguage'
|
||||||
@@ -7,12 +7,13 @@ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguag
|
|||||||
interface Entry<T> { value: T }
|
interface Entry<T> { value: T }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言插件、翻译插件的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。
|
* 语言插件、翻译插件、库规则的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。
|
||||||
* 不依赖 vscode 运行时:Disposable 和事件都是结构兼容的普通对象,便于单元测试。
|
* 不依赖 vscode 运行时:Disposable 和事件都是结构兼容的普通对象,便于单元测试。
|
||||||
*/
|
*/
|
||||||
export class CodeReaderRegistry implements CodeReaderApi {
|
export class CodeReaderRegistry implements CodeReaderApi {
|
||||||
private readonly adapters: Entry<LanguageAdapter>[] = []
|
private readonly adapters: Entry<LanguageAdapter>[] = []
|
||||||
private readonly translators: Entry<Translator>[] = []
|
private readonly translators: Entry<Translator>[] = []
|
||||||
|
private readonly rules: Entry<LibraryRule>[] = []
|
||||||
private readonly listeners = new Set<() => void>()
|
private readonly listeners = new Set<() => void>()
|
||||||
|
|
||||||
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
|
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
|
||||||
@@ -36,6 +37,10 @@ export class CodeReaderRegistry implements CodeReaderApi {
|
|||||||
return this.add(this.translators, translator)
|
return this.add(this.translators, translator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerLibraryRule(rule: LibraryRule): Disposable {
|
||||||
|
return this.add(this.rules, rule)
|
||||||
|
}
|
||||||
|
|
||||||
/** 当前语言插件覆盖的全部 languageId(去重、排序),给 when 子句用。 */
|
/** 当前语言插件覆盖的全部 languageId(去重、排序),给 when 子句用。 */
|
||||||
languageIds(): string[] {
|
languageIds(): string[] {
|
||||||
return [...new Set(this.adapters.flatMap(entry => entry.value.languageIds))].sort()
|
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))
|
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 里记的那个还注册着就用它,否则用第一个。 */
|
/** 选中的翻译:globalState 里记的那个还注册着就用它,否则用第一个。 */
|
||||||
selectedTranslator(languageId: string): Translator | undefined {
|
selectedTranslator(languageId: string): Translator | undefined {
|
||||||
const candidates = this.translatorsFor(languageId)
|
const candidates = this.translatorsFor(languageId)
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<BlockView[]> {
|
||||||
|
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<BlockView> {
|
||||||
|
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<number, string[]>()
|
||||||
|
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, string>
|
||||||
|
withTimezone: string
|
||||||
|
enumType: string
|
||||||
|
unannotated: string
|
||||||
|
constraints: Record<'primaryKey' | 'unique' | 'index' | 'nullable' | 'default' | 'serverDefault' | 'foreignKey', string>
|
||||||
|
/** 外键 ondelete / onupdate 的动作(大写)→ 译名;表里没有的用 otherOn… 拼。 */
|
||||||
|
onDelete: Record<string, string>
|
||||||
|
onUpdate: Record<string, string>
|
||||||
|
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<string, Words> = { zh: ZH }
|
||||||
|
|
||||||
|
/** 认一个模型类要用的:宿主给的 context、挑好的用词、别名表。 */
|
||||||
|
interface Env {
|
||||||
|
context: RuleContext
|
||||||
|
words: Words
|
||||||
|
aliases: Map<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */
|
||||||
|
interface Call {
|
||||||
|
name: string | undefined
|
||||||
|
positional: Node[]
|
||||||
|
keywords: Map<string, Node>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类主体里一条给名字赋值(或只写注解)的语句。 */
|
||||||
|
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<string, string> {
|
||||||
|
const aliases = new Map<string, string>()
|
||||||
|
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, string>): string | undefined {
|
||||||
|
const name = lastName(node)
|
||||||
|
return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name
|
||||||
|
}
|
||||||
|
|
||||||
|
function callOf(node: Node | null | undefined, aliases: Map<string, string>): Call | undefined {
|
||||||
|
if (node?.type !== 'call')
|
||||||
|
return undefined
|
||||||
|
const args = node.childForFieldName('arguments')
|
||||||
|
const positional: Node[] = []
|
||||||
|
const keywords = new Map<string, Node>()
|
||||||
|
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<string, string>): 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<string, string>, 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<string, Node>()
|
||||||
|
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 })
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -397,6 +397,15 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
|||||||
return joinTokens(tokens)
|
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[] {
|
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
|
||||||
const { header } = block
|
const { header } = block
|
||||||
const { language } = document
|
const { language } = document
|
||||||
@@ -406,7 +415,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
|||||||
return joinTokens(tokensIn(node, span, document))
|
return joinTokens(tokensIn(node, span, document))
|
||||||
}
|
}
|
||||||
const phrases: Phrases = {
|
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,
|
within,
|
||||||
entryCount: (literal, size) => {
|
entryCount: (literal, size) => {
|
||||||
const name = table.mapLiterals[literal.type]
|
const name = table.mapLiterals[literal.type]
|
||||||
@@ -416,10 +425,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
|||||||
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
|
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
|
||||||
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'),
|
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))],
|
name: (node, role) => [segment(node.text, role, offsetOf(node, language))],
|
||||||
doc: (node) => {
|
doc,
|
||||||
const markdown = docText([table.commentText(node)])
|
|
||||||
return markdown ? { markdown } : undefined
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
switch (block.kind) {
|
switch (block.kind) {
|
||||||
case 'function':
|
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 {
|
function segment(text: string, role: TokenRole, offset: number | undefined): Segment {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement } from 'react'
|
import type { CSSProperties, ReactElement } from 'react'
|
||||||
import type { BlockKind, BlockView } from './types'
|
import type { BlockView } from './types'
|
||||||
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Eye, EyeOff, Pencil } from 'lucide-react'
|
||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -16,8 +16,8 @@ export interface BlockTreeState {
|
|||||||
hidden: ReadonlySet<string>
|
hidden: ReadonlySet<string>
|
||||||
/** 本来被筛掉、跳到定义时临时显示的块 */
|
/** 本来被筛掉、跳到定义时临时显示的块 */
|
||||||
temporary: ReadonlySet<string>
|
temporary: ReadonlySet<string>
|
||||||
/** 种类标签上写什么:翻译插件给种类起的名字,没给就是 id */
|
/** 种类标签上写什么:翻译插件给种类起的名字(库规则的种类是规则给的标签),没给就是 id */
|
||||||
kindName: (kind: BlockKind) => string
|
kindName: (kind: string) => string
|
||||||
/** 刚点了「编辑」、要把光标放进去的块 */
|
/** 刚点了「编辑」、要把光标放进去的块 */
|
||||||
focusId: string | null
|
focusId: string | null
|
||||||
onFocused: () => void
|
onFocused: () => void
|
||||||
@@ -31,12 +31,25 @@ function hasShownChildren(block: BlockView, tree: BlockTreeState): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 一层代码块:左边一条竖线标出这一层,每块的子块紧跟在它下面、再往右缩进一层。被筛掉的块不画,不留空位,竖线跟着变短。
|
* 块的种类色写在哪:内置种类按 data-kind 取 CSS 里的配色;库规则认领的块(带 badge)按规则给的颜色,
|
||||||
* kind 是这一层所属块的种类(顶层没有),悬停那个块时竖线换成它的颜色。
|
* 暗色、亮色两个都写在 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 (
|
return (
|
||||||
<div className="cr-level flex gap-2" data-kind={kind}>
|
<div className="cr-level flex gap-2" {...kindProps(owner)}>
|
||||||
<div className="flex w-3 shrink-0 justify-center">
|
<div className="flex w-3 shrink-0 justify-center">
|
||||||
<div className="cr-guide w-px" />
|
<div className="cr-guide w-px" />
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +58,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
|
|||||||
<Fragment key={block.id}>
|
<Fragment key={block.id}>
|
||||||
<BlockCard block={block} tree={tree} />
|
<BlockCard block={block} tree={tree} />
|
||||||
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
{hasShownChildren(block, tree) && !tree.collapsed.has(block.id) && (
|
||||||
<BlockLevel blocks={block.children} kind={block.kind} tree={tree} />
|
<BlockLevel blocks={block.children} owner={block} tree={tree} />
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
@@ -54,7 +67,7 @@ export function BlockLevel({ blocks, kind, tree }: { blocks: BlockView[], kind?:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 预览状态的内容:函数、类、导入、常量、变量块是属性表,其余是一行译文。 */
|
/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文。 */
|
||||||
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
|
||||||
if ('properties' in block)
|
if ('properties' in block)
|
||||||
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
|
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
|
||||||
@@ -70,7 +83,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
|
className="cr-block flex items-start gap-2.5 rounded-lg py-2 pr-2.5 pl-2"
|
||||||
data-kind={block.kind}
|
{...kindProps(block)}
|
||||||
data-id={block.id}
|
data-id={block.id}
|
||||||
data-line={block.startLine}
|
data-line={block.startLine}
|
||||||
data-editing={editing ? '' : undefined}
|
data-editing={editing ? '' : undefined}
|
||||||
@@ -88,7 +101,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
: <span className="w-3.5 shrink-0" />}
|
: <span className="w-3.5 shrink-0" />}
|
||||||
<span className="cr-badge flex h-5 w-11 shrink-0 items-center justify-center rounded text-[11px] leading-[14px] font-medium">
|
<span className="cr-badge flex h-5 min-w-11 shrink-0 items-center justify-center rounded px-1 text-[11px] leading-[14px] font-medium whitespace-nowrap">
|
||||||
{tree.kindName(block.kind)}
|
{tree.kindName(block.kind)}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { ChevronDown, ChevronRight } from 'lucide-react'
|
|||||||
import { Fragment } from 'react'
|
import { Fragment } from 'react'
|
||||||
import { inlineText } from './segments'
|
import { inlineText } from './segments'
|
||||||
import { SegmentText } from './SegmentText'
|
import { SegmentText } from './SegmentText'
|
||||||
import { subRowsLayout, tableCells } from './subRows'
|
import { subRowsLayout, tableCells, tableTemplate } from './subRows'
|
||||||
import { TextPreview } from './TextPreview'
|
import { TextPreview } from './TextPreview'
|
||||||
import { propertyItemId, propertyRowId } from './viewState'
|
import { propertyItemId, propertyRowId } from './viewState'
|
||||||
|
|
||||||
/** 子行里除最后一列以外各列的宽度(参数表格的名称、类型列,字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
|
/** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */
|
||||||
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
|
const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)']
|
||||||
|
|
||||||
function gridTemplate(count: number): string {
|
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 {
|
function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement {
|
||||||
const template = { gridTemplateColumns: gridTemplate(columns.length) }
|
|
||||||
return (
|
return (
|
||||||
<div className="pl-[5px]">
|
<div className="pl-[5px]">
|
||||||
<div className="cr-table" role="table">
|
<div className="cr-table" role="table" style={{ gridTemplateColumns: tableTemplate(columns.length) }}>
|
||||||
<div className="cr-table-head grid" role="row" style={template}>
|
<div className="cr-table-head" role="row">
|
||||||
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
|
{columns.map((name, i) => <span key={i} className="cr-table-cell" role="columnheader">{name}</span>)}
|
||||||
</div>
|
</div>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<div key={index} className="cr-table-row grid" role="row" data-id={propertyItemId(parentId, index)} style={template}>
|
<div key={index} className="cr-table-row" role="row" data-id={propertyItemId(parentId, index)}>
|
||||||
{tableCells(item, columns).map((cell, i) => (
|
{tableCells(item, columns).map((cell, i) => (
|
||||||
<p key={i} className="cr-table-cell cr-code min-w-0" role="cell">
|
<p key={i} className="cr-table-cell cr-code min-w-0" role="cell">
|
||||||
<SegmentText segments={cell} />
|
<SegmentText segments={cell} />
|
||||||
@@ -121,8 +120,8 @@ function PropertyTable({ parentId, columns, items }: { parentId: string, columns
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 函数、类、导入、常量、变量块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
* 函数、类、导入、常量、变量块和库规则认领的块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
||||||
* 翻译插件给了列名的(输入参数)排成表格,没给的排成列表。
|
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||||||
*/
|
*/
|
||||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
|
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
|
||||||
blockId: string
|
blockId: string
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ body.vscode-high-contrast-light {
|
|||||||
[data-kind="return"] { --kind: var(--cr-kind-return); }
|
[data-kind="return"] { --kind: var(--cr-kind-return); }
|
||||||
[data-kind="statement"] { --kind: var(--cr-kind-statement); }
|
[data-kind="statement"] { --kind: var(--cr-kind-statement); }
|
||||||
[data-kind="comment"] { --kind: var(--cr-kind-comment); }
|
[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 只落在鼠标下最里面那张卡片上,父卡片不跟着亮 */
|
/* 卡片和它的子层是相邻兄弟,:hover 只落在鼠标下最里面那张卡片上,父卡片不跟着亮 */
|
||||||
.cr-block {
|
.cr-block {
|
||||||
@@ -330,15 +333,23 @@ body.vscode-high-contrast-light {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 属性表里带列名的子行(输入参数)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
|
* 属性表里带列名的子行(输入参数、数据表的字段)排成的表格:外框圆角,格子之间、行之间一条主题的表格分隔线;
|
||||||
* 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色
|
* 表头垫主题的表头底色(比编辑区深一档或浅一档)、列名用说明色。
|
||||||
|
* 整张表格一个网格、每行借用它的列(subgrid),列宽按整列的内容定,见 tableTemplate
|
||||||
*/
|
*/
|
||||||
.cr-table {
|
.cr-table {
|
||||||
--cr-table-border: var(--vscode-tree-tableColumnsBorder, var(--vscode-editorWidget-border, var(--vscode-contrastBorder)));
|
--cr-table-border: var(--vscode-tree-tableColumnsBorder, var(--vscode-editorWidget-border, var(--vscode-contrastBorder)));
|
||||||
|
display: grid;
|
||||||
border: 1px solid var(--cr-table-border);
|
border: 1px solid var(--cr-table-border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.cr-table-head,
|
||||||
|
.cr-table-row {
|
||||||
|
display: grid;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
grid-template-columns: subgrid;
|
||||||
|
}
|
||||||
.cr-table-head {
|
.cr-table-head {
|
||||||
background: var(--vscode-keybindingTable-headerBackground, color-mix(in srgb, var(--vscode-foreground) 5%, transparent));
|
background: var(--vscode-keybindingTable-headerBackground, color-mix(in srgb, var(--vscode-foreground) 5%, transparent));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'
|
|||||||
import { toBlockViews } from '../../../src/codeReader/blockView'
|
import { toBlockViews } from '../../../src/codeReader/blockView'
|
||||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||||
import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript'
|
import { createTypeScriptAdapter } from '../../../src/codeReader/languages/typescript'
|
||||||
|
import { sqlalchemyRule } from '../../../src/codeReader/rules/sqlalchemy'
|
||||||
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
||||||
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
|
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
|
||||||
import { typescriptZhTranslator } from '../../../src/codeReader/translators/typescriptZh'
|
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):' })
|
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 () => {
|
it('字典 / 对象:定义落在对象的属性名上只闪那一项,嵌套的连外层各层一起展开;值里只是显示着的名字闪整块,展开到它那一层', async () => {
|
||||||
const source = [
|
const source = [
|
||||||
'export const routes = {',
|
'export const routes = {',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { toBlockViews } from '../../../src/codeReader/blockView'
|
|||||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||||
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
import { WASM_DIR, withBlocks } from '../../../src/codeReader/testSupport'
|
||||||
import { pythonZhTranslator } from '../../../src/codeReader/translators/pythonZh'
|
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 }
|
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]])
|
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 () => {
|
it('真实的 Python 函数:输入参数排成表格,导入项、字典的各项排成列表', async () => {
|
||||||
const source = [
|
const source = [
|
||||||
'from m import a',
|
'from m import a',
|
||||||
|
|||||||
@@ -10,6 +10,16 @@ export function subRowsLayout(row: PropertyRow): 'table' | 'list' | undefined {
|
|||||||
return row.columns ? 'table' : 'list'
|
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[][] {
|
export function tableCells(item: PropertyItem, columns: string[]): Inline[][] {
|
||||||
return columns.map((_, i) => item.cells[i] ?? [])
|
return columns.map((_, i) => item.cells[i] ?? [])
|
||||||
|
|||||||
@@ -63,14 +63,21 @@ export interface PropertyRow extends Defines {
|
|||||||
columns?: string[]
|
columns?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */
|
||||||
|
export interface ThemeColor { dark: string, light: string }
|
||||||
|
|
||||||
|
/** 种类标签:文字和颜色 */
|
||||||
|
export interface KindBadge { label: string, color: ThemeColor }
|
||||||
|
|
||||||
interface BlockViewBase {
|
interface BlockViewBase {
|
||||||
/** 在树里的位置,例如 '6.1.0' */
|
/** 在树里的位置,例如 '6.1.0' */
|
||||||
id: string
|
id: string
|
||||||
kind: BlockKind
|
/** BlockKind,或库规则认领的块的种类 id(带点,例如 sqlalchemy.table);webview 不认识后者,只按 badge 画 */
|
||||||
/** header 的起止行,0 起 */
|
kind: string
|
||||||
|
/** 编辑范围的起止行,0 起:一般是 header,库规则认领的块到最后一个并进来的子块为止 */
|
||||||
startLine: number
|
startLine: number
|
||||||
endLine: number
|
endLine: number
|
||||||
/** header 源码,去掉共同缩进 */
|
/** 编辑范围的源码,去掉共同缩进 */
|
||||||
code: string
|
code: string
|
||||||
/** 源码着色(拼起来等于 code) */
|
/** 源码着色(拼起来等于 code) */
|
||||||
codeTokens: Segment[]
|
codeTokens: Segment[]
|
||||||
@@ -78,10 +85,12 @@ interface BlockViewBase {
|
|||||||
defines?: SourceRange
|
defines?: SourceRange
|
||||||
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
|
/** 筛选属性:属性 id → 值 id(含 kind),都是和语言无关的英文 id,显示名在 DocumentMessage.attributes 里 */
|
||||||
attributes: Record<string, string>
|
attributes: Record<string, string>
|
||||||
|
/** 库规则认领的块才有:种类标签的文字和颜色,由规则给;和类块一样默认收起子块 */
|
||||||
|
badge?: KindBadge
|
||||||
children: BlockView[]
|
children: BlockView[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
|
/** 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
|
||||||
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
|
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
|
||||||
|
|
||||||
export interface TranslatorOption { id: string, label: string }
|
export interface TranslatorOption { id: string, label: string }
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ describe('眼下折叠着哪些', () => {
|
|||||||
expect([...collapsedIds(BLOCKS, {})].sort()).toEqual(['1', '1.0.0', '2', '2.0'])
|
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('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
|
it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
|
||||||
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
|
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'])
|
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ScrollAnchor } from './scrollPosition'
|
import type { ScrollAnchor } from './scrollPosition'
|
||||||
import type { BlockKind, BlockView } from './types'
|
import type { BlockView } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
|
* 标签页切走时 VS Code 会销毁 webview、切回来重新加载,靠它恢复:哪些块停在「编辑」、
|
||||||
@@ -54,9 +54,9 @@ function readViewState(saved: unknown): ViewState {
|
|||||||
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 块默认折叠吗:函数、类块收起主体(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
|
/** 块默认折叠吗:函数、类块和库规则认领的块收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
|
||||||
export function foldedByDefault(kind: BlockKind): boolean {
|
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boolean {
|
||||||
return kind === 'function' || kind === 'class'
|
return block.badge !== undefined || block.kind === 'function' || block.kind === 'class'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
|
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
|
||||||
@@ -64,7 +64,7 @@ export function collapsedIds(blocks: readonly BlockView[], folds: Readonly<Recor
|
|||||||
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
|
const collapsed = new Set(Object.keys(folds).filter(id => folds[id]))
|
||||||
const visit = (list: readonly BlockView[]): void => {
|
const visit = (list: readonly BlockView[]): void => {
|
||||||
for (const block of list) {
|
for (const block of list) {
|
||||||
if (folds[block.id] === undefined && foldedByDefault(block.kind))
|
if (folds[block.id] === undefined && foldedByDefault(block))
|
||||||
collapsed.add(block.id)
|
collapsed.add(block.id)
|
||||||
visit(block.children)
|
visit(block.children)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user