✨ feat(vscode): 代码阅读把说明里的 @param / @return / @throws 拆到参数表、输出、异常表

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent c2982537d7
commit 0958506681
43 files changed
+1293 -316

No files matched your search

+60 -26
View File
@@ -240,14 +240,14 @@
卡片外观(折叠箭头、种类图标、底色、悬停高亮、「编辑 / 预览」切换)和其他块一样,只是内容换成一张属性表:左列属性名(界面字体、`descriptionForeground` 色,宽 76px,这张表里有更长的属性名时整列按最长的那个放宽,值照样对齐在同一处,属性名不折行:shell 的「管道里任一步失败都算失败」),右列是值(等宽字体,按词性着色,长了换行),两列间距 12px,行高 20px。「输入参数」「导入项」和值是字典 / 对象字面量的「值」「初始值」下面展开子行,属性名前带折叠箭头(图标、大小、颜色同块的折叠箭头;点箭头或属性名切换,默认展开,折叠后值「N 个」「字典 N 项」照常显示,只收起子行);子行往右缩进 5px,左边一条 1px 竖线(`editorIndentGuide-background1`);输入参数例外,是一张表格(见下)。字典 / 对象的子行分键 / 值两列,键列宽 150px(编辑区窄时按 28% 收),值列占剩下的。子行下面还能再展开一层(嵌套的字典 / 对象):带下一层的子行第一列前面有折叠箭头(点箭头或第一列切换,默认展开),同一层里其余子行在同样的位置留空、第一列对齐;下一层同样往右缩进 5px、左边一条竖线,竖线对着上一层的箭头,每层自己的两列对齐。同一层里没有带下一层的子行时不留箭头的位置(和导入项的子行一样)。
输入参数的表格(原型 `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 只看有没有列名。
输入参数的表格(原型 `paramRows`):往右缩进 5px,第一行是表头「名称 | 类型 | 默认值」,下面每个参数一行。列宽按整列的内容定,webview 不认是哪种表格(库规则的字段表同样排,见 `tableTemplate`):整张表格一个网格、各行借用它的列,前几列宽裕时放到整列不换行,最后一列(默认值、说明)分剩下的、至少留 `min(20%, 8em)`;放不下时先让最后一列换行,编辑区再窄前几列平分剩下的宽度、在格子里换行(长名字从中间断开),不撑出卡片、不藏掉哪一列。格子左右留 8px、上下 2px,长类型、长默认值在格子里换行,不挤掉别的列。外框 1px、圆角 4px,格子之间、行之间 1px 分隔线,颜色都是主题的表格分隔色(`--vscode-tree-tableColumnsBorder`,主题没给时退到 `--vscode-editorWidget-border`);表头垫主题的表头底色(`--vscode-keybindingTable-headerBackground`,比编辑区深一档或浅一档),列名界面字体 12px、`descriptionForeground` 色。没有默认值的格子留空。格子可以是 Markdown(参数的「说明」、「异常」表的两列、对不上的参数的说明):按「注释的 Markdown 排版」显示(界面字体、注释色),着色的词照旧排在等宽字体的段落里,差异逐词标。属性行跟着的说明(`PropertyRow.note`,输出参数的 `@return`)接在值后面,同一行放得下就并排,剩下的地方不到 12 个字宽时换到下一行。折叠「输入参数」那一行时整张表收起;子行 id、跳到定义闪「它那个参数」、高亮名字的各处出现都和列表一样,闪的是表格里那一行(行边框和相邻行共用,改在行里面描一圈聚焦色)。哪些子行排成表格由翻译插件定:给了列名(`PropertyRow.columns`)的排成表格,没给的(导入项、字典 / 对象的各项)照旧是列表,webview 只看有没有列名。
| 种类 | 行 |
| --- | --- |
| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有说明才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,`@param` 这类标签行照抄;得到的原文按 Markdown 渲染,见「注释的 Markdown 排版」;里面的名字不能点)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,展开成表格,列名 名称 \| 类型 \| 默认值,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(译文,没有留空))/ 输出参数(返回类型的译文,没标注写「未标注」) |
| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有说明才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `<K, T extends X = Y>` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开) |
| 函数 | 名称(函数色;匿名函数写「匿名」)/ 说明(有正文才显示:JSDoc 去掉 `/**`、`*/` 和每行开头的 `*`,docstring 去掉引号;再去掉共同缩进和首尾空行,拆掉块标签(见「注释的 Markdown 排版」),剩下的正文按 Markdown 渲染)/ 对不上的参数(说明里有 `@param` 的名字在输入参数里没有时才显示:`N 个`,展开成表格「名称 \| 说明」,名称是 hint 色、悬停「没有这个参数」)/ 访问级别 / 异步(是 / 否)/ 装饰器(有才显示,源码原样照抄、按源码着色,多个换行)/ 输入参数(`N 个` 或「无」,展开成表格,列名 名称 \| 类型 \| 默认值,每个参数一行:名称 \| 类型(没标注写「未标注」)\| 默认值(译文,没有留空);任何一个参数对上了 `@param` 时多一列「说明」(Markdown,没对上的格子留空),按名字对,`*args` / `**kwargs` / `...rest` 去掉前缀对)/ 输出参数(返回类型的译文,没标注写「未标注」;写了 `@return` 时类型后面跟着这段说明,Markdown、注释色)/ 异常(写了 `@throws` 才显示:`N 个`,展开成表格「异常 \| 触发条件」,一条一行,都是 Markdown,异常名能点,见「注释的 Markdown 排版」的引用) |
| 类 | 名称(类型色;匿名类 `export default class {}` 写「匿名」;赋给变量、类字段的类表达式写变量名、字段名)/ 说明(有正文才显示,规则同函数)/ 访问级别 / 抽象(是 / 否,只有 TypeScript / TSX 有,看 `abstract class`,TypeScript 的类都有这一行;JavaScript / JSX 没有)/ 装饰器(同函数)/ 类型参数(有才显示:TypeScript `<K, T extends X = Y>` 每个参数的译文,逗号隔开,如 `K, T 限定为 X = Y`;Python 3.12 的 `class G[T]` 同样)/ 父类(有才显示:TypeScript `extends` 后面的表达式连着泛型实参的译文,如 `Base<字符串>`;Python 类名后面括号里的参数列表照写的顺序逐项翻,关键字参数、`*bases` 也在这一行,如 `Base, metaclass=ABCMeta`;逗号隔开)/ 实现的接口(有才显示:TypeScript `implements` 后面每个类型的译文,逗号隔开)/ 构造参数(说明里写了 `@param` 才显示:类卡片没有构造参数表可对,照写成「名称 \| 说明」表)/ 异常(同函数) |
| 枚举 | 名称(类型色)/ 说明(有说明才显示,规则同函数)/ 访问级别(同类)/ 装饰器(有才显示,同函数)/ 继承(有才显示:Python 类名后面括号里照写的父类,如 `str, enum.Enum`;TypeScript 没有)/ 成员(`N 个` 或「无」,展开成表格,列名 成员 \| 值 \| 说明,每个成员一行:成员名(variable 色)\| 值(写了值是值的译文;没写值时是语言算出来的值,TypeScript 数值枚举自增的 0、1、2……,算不出来和 Python 的 `auto()` 写「自动」,这两种按 hint 着色)\| 说明(成员的注释、JSDoc、属性 docstring 的原文用空格连起来,comment 色;一个成员都没有说明时没有这一列);讲的定义是成员名,跳到成员名只闪那一行)。原型「枚举」一节(`ENUM_SAMPLE`、`enumCard`) |
| 异常 | 名称(类型色;匿名类写「匿名」)/ 说明(有说明才显示,规则同函数)/ 访问级别(同类)/ 装饰器(有才显示,同函数)/ 继承(有才显示:照写的父类,同类的「父类」,如 `Exception`、`errors.HttpException<字符串>`)/ 属性(有类属性才显示:`N 个`,展开成表格,列名 属性 \| 类型 \| 值 \| 说明,每个属性一行:属性名(variable 色)\| 类型(写了标注是它的译文,一个属性都没标注时没有这一列)\| 值(写了值是值的译文,没写留空)\| 说明(同枚举成员的说明,一个属性都没有说明时没有这一列);讲的定义是属性名)/ 构造参数(写了构造函数才显示:表格同函数的「输入参数」,Python 去掉 self;讲的定义是参数名)。见「异常」 |
| 异常 | 名称(类型色;匿名类写「匿名」)/ 说明(有说明才显示,规则同函数)/ 访问级别(同类)/ 装饰器(有才显示,同函数)/ 继承(有才显示:照写的父类,同类的「父类」,如 `Exception`、`errors.HttpException<字符串>`)/ 属性(有类属性才显示:`N 个`,展开成表格,列名 属性 \| 类型 \| 值 \| 说明,每个属性一行:属性名(variable 色)\| 类型(写了标注是它的译文,一个属性都没标注时没有这一列)\| 值(写了值是值的译文,没写留空)\| 说明(同枚举成员的说明,一个属性都没有说明时没有这一列);讲的定义是属性名)/ 构造参数(写了构造函数才显示:表格同函数的「输入参数」,Python 去掉 self,类说明里的 `@param` 同函数对进「说明」列、对不上的排在说明下面;讲的定义是参数名;没写构造函数时同类,说明里的 `@param` 照写成表)/ 异常(同函数)。见「异常」 |
| 导入 | 来源(TypeScript 是字符串字面量原样,如 `'node:fs'`,字符串色;Python 是点分模块名,如 `fastapi`、`.models`,类型色)/ 仅类型(是 / 否,只有 TypeScript / TSX 有,看 `import type`;JavaScript / JSX 没有)/ 导入项(`N 个` 或「无」,每项一行:`name`、`name 作为 alias`;默认导入写「默认导出 作为 x」,`* as x` 写「全部 作为 x」,Python `from m import *` 写「全部」,Python `import a.b as c` 写「整个模块 作为 c」,一条 `import a, b` 导入多个模块时写模块名) |
| 常量 / 变量 | 名称(解构声明写整个模式,如 `{ p, q }`)/ 说明(有说明才显示:Python 的属性 docstring,规则同函数;一条语句一个,排在第一个名称后面)/ 访问级别 / 类型(写了标注才有)/ 值(常量)或初始值(变量):初始化表达式的译文,没有写「无」;是字典 / 对象字面量时见下面「字典 / 对象字面量」。一条语句声明几个变量(`let a = 1, b = 2`)就重复几组 |
@@ -462,8 +462,14 @@
### 注释的 Markdown 排版
- 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。
- 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,参数表的「说明」列、「异常」表、输出参数后面的 `@return`,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。
- 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)、引号和中文名记号(`SourceDocument.markers`,见「中文名」),再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。
- 块标签:说明(`Translator.doc` 读的:函数、类这些定义的说明,字段、成员的说明,库规则取的说明)的原文按 KDoc 拆成正文和块标签(`Doc`,解析在 `docTags.ts`,各卡片共用);单独成块的注释照原文显示,不拆。
- 认 `@param`、`@return`(`@returns` 同义)、`@throws`(`@exception` 同义)。一行(去掉开头空白)以标签开头算一条;后面接着的行(不是空行、不以 `@` 开头)续在这条后面(去掉开头空白、换行留着);空行结束这条,以 `@` 开头的行也结束上一条。围栏代码块里的行不认标签。
- `@param 名字 说明`;JSDoc 的 `@param {类型} 名字 - 说明`、`@param [名字] 说明`、`@param [名字=默认值] 说明` 也认(类型、横杠、默认值去掉)。`@return 说明`、`@returns {类型} 说明` 同样去掉类型。`@throws 异常 触发条件`:异常是一个名字、`[Foo]`、`[说法](路径#Foo)` 或 JSDoc 的 `{Foo}`,只是名字的写成不带路径的引用 `[Foo]`。几条 `@return` 用空行连成一段。
- 正文是拿掉这几种标签后剩下的行(标签前后的正文都留着),去掉首尾空行,连着的几个空行并成一个;只写了标签时没有正文。别的标签(`@see`、`@since`)留在正文里。
- 放到哪见「属性表」和各库规则:`@param` 对到参数表多出的「说明」列(按名字对,`*args` / `**kwargs` / `...rest` 去掉前缀),对不上的在说明下面列成「对不上的参数」;`@return` 跟在输出参数 / 返回那一行的值后面(`PropertyRow.note`);`@throws` 是「异常」一行。卡片共用的几行在 `rules/docRows.ts`。
- 中文名的兜底(`languages/typescriptNames.ts`、`pythonNames.ts` 从 `@param` / `Args:` 取参数的中文名)是另一回事,照旧读源码。
- 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。
- 安全:源码里的 HTML 当文字显示;链接认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、`./`、`..`、绝对路径、盘符)照原文显示。http / https / mailto 由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。http / https 图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」和路径。
- 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号。指到目录的在代码阅读侧栏的树上定位(展开并选中,和侧栏跟着当前文件走是同一个定位;侧栏没露着时亮出来),不切到 VS Code 资源管理器;树上没有(开着筛选时没摆它)时提示「侧栏的树上没有 …」,不退出筛选。
@@ -847,8 +853,9 @@ export type TokenRole =
* color:这段自己的颜色(暗色、亮色主题各一个),盖过词性的颜色:库规则按库自己的配色上色(FastAPI 的 HTTP 方法用 Swagger UI 的颜色)。
* refers:这段讲的是源码里别处的一个名字(那个名字的范围),语言扩展不认得这段自己的位置:写在字符串里的名字(FastAPI 路径里的参数名)。
* 预览把这段当那个名字:⌘ / Ctrl + 点击跳到它的定义,单击高亮它的各处出现,它的某处出现盖住 refers 时这段也高亮;offset 照旧是照抄的位置。
* tooltip:悬停这段时看的字(说明里写了、参数表里没有的参数名:「没有这个参数」)。
*/
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange, tooltip?: string }
/**
* 框的样式,决定框头图标和框的颜色:iteration 写在表达式里的遍历(渲染大纲的「对 … 的每一项」也是),图标、颜色同「循环」块;
@@ -873,6 +880,18 @@ export type Inline = Segment | SegmentGroup
*/
export interface Markdown { markdown: string }
/** 说明里讲的一个参数(`@param 名字 说明`):名字照写(`*args`、`...rest` 带着前缀),说明是 Markdown。 */
export interface DocParam { name: string, text: Markdown }
/** 说明里讲的一种异常(`@throws 异常 什么时候抛`):只写了名字的(`ValueError`、JSDoc 的 `{ValueError}`)写成 `[ValueError]`,`[Foo]`、`[说法](路径#Foo)` 照写。 */
export interface DocThrows { exception: Markdown, when: Markdown }
/**
* 一段说明(Translator.doc 读出来的):原文按 KDoc 拆成正文和块标签(规则见「注释的 Markdown 排版」的块标签)。
* 只写了标签、没有正文时没有 body。
*/
export interface Doc { body?: Markdown, params: DocParam[], returns?: Markdown, throws: DocThrows[] }
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */
export type PreviewText = Inline[] | Markdown
@@ -883,9 +902,12 @@ export type PreviewText = Inline[] | Markdown
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子;items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。 */
/**
* 属性表里展开的一个子行:几列对齐的格子,格子是着色的词或一段 Markdown(参数的说明、异常的触发条件);
* items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。
*/
export interface PropertyItem extends Defines {
cells: Inline[][]
cells: PreviewText[]
items?: PropertyItem[]
table?: PreviewTable // 排在最后一格里、文字下面的表:实参的值里跨几行写的调用的实参表,一层层往下套,总是展开
}
@@ -893,8 +915,9 @@ export interface PropertyItem extends Defines {
/**
* 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。
* columns 是子行各列的列名(输入参数的名称 / 类型 / 默认值):给了的子行排成带表头的表格(只有一层),没给的排成列表。
* note 是跟在值后面的一段说明(Markdown,注释色):输出参数那一行,返回类型后面是说明里的 `@return`。
*/
export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[], columns?: string[] }
export interface PropertyRow extends Defines { label: string, value: PreviewText, note?: Markdown, items?: PropertyItem[], columns?: string[] }
/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */
export interface PreviewTable { columns: string[], items: PropertyItem[] }
@@ -981,7 +1004,7 @@ export interface Translator {
translate: (block: TextBlock, document: SourceDocument) => TextTranslation // 只翻 header 那一段;注释块给 Markdown 原文;跨几行写的调用带实参表
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] // 函数、类、枚举、异常、导入、常量、变量块 → 属性表
expression: (node: Node, document: SourceDocument) => Inline[] // 一个表达式 / 类型节点整个翻成一行译文,译法同属性表的值;库规则借它
doc: (node: Node, document: SourceDocument) => Markdown | undefined // 说明文字(注释、docstring、当说明用的字符串)→ Markdown 原文,同属性表的「说明」
doc: (node: Node, document: SourceDocument) => Doc | undefined // 说明文字(注释、docstring、当说明用的字符串)→ 正文和块标签(Doc);正文同属性表的「说明」;连着几行的行文档注释(Rust 的 `///`)node 是第一条,翻译插件自己往下连
attributeNames?: AttributeNames // 筛选属性和值叫什么,和属性表的叫法一致;筛选栏、种类图标的悬停提示用它
}
@@ -1039,7 +1062,7 @@ export interface RuleContext {
imports: ImportFacts[] // 这份源码里各处导入,照源码顺序:认别名用
displayLanguage?: string // 当前翻译插件的 displayLanguage;没有翻译插件时没有
translate: (node: Node) => Inline[] // 借当前翻译插件的 expression;没有翻译插件时照抄源码
doc: (node: Node) => Markdown | undefined // 借当前翻译插件的 doc
doc: (node: Node) => Doc | undefined // 借当前翻译插件的 doc:正文和块标签,规则按自己的卡片把标签放到对应的行
lookup: (index: WorkspaceIndex, name: Node) => IndexEntry | undefined // name 指的定义在 index 里认出的那一项(见「规则认领时查别的文件里的定义」);还没查到是 undefined
}
@@ -1143,7 +1166,7 @@ export interface CommandRule {
- 规则自己的卡片(`RuleCard`),下面几条都说的是它。
- 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类的名字、暗 / 亮两种颜色和 lucide 图标名)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性)、`concepts`(额外认出的概念,排在种类图标后面)、`slots`(挂在卡片某一行的子行上、点子行才展开的子块,见下);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。
- 挂在卡片子行上的子块(`RuleSlot`):留下的子块(没并进来的)可以不排在卡片后面,挂在卡片第 `row` 行的第 `item` 个子行上(讲它的那一个子行);它默认收着,点那个子行的第一格才在这一行下面展开当子卡片、同一行里别的收起(Rust 实现块的方法表点方法名)。它照常转成块,照样问各规则,编辑、问 AI、差异、筛选都照常。一块只能放一处,不能同时并进来;`row` 要有这一行、`item` 要有这个子行。只有这一种放法:子块要么挂在讲它的子行上,要么照常排在卡片后面。
- 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示),别的文件里定义的名字交给 `context.lookup`(见「规则认领时查别的文件里的定义」);库自己的配色(HTTP 方法)写在段的 `color` 上。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。
- 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,给正文和块标签:正文和别的「说明」一样按 Markdown 显示,标签放到规则自己卡片对应的行,共用的几行在同目录的 `docRows.ts`),别的文件里定义的名字交给 `context.lookup`(见「规则认领时查别的文件里的定义」);库自己的配色(HTTP 方法)写在段的 `color` 上。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。
- 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。
### 启用、认领、优先
@@ -1376,7 +1399,7 @@ export interface CommandRule {
- 字段:字段名(variable 色,带下标)。类型:注解照写法翻(翻译插件翻,`str` → 字符串),`InitVar[X]` 写 X。
- 默认值:`= x` 写 x(翻译插件翻,`None` → 空);`field(default=x)` 写 x;`field(default_factory=f)` 写「每次新建 f()」(`list` → 每次新建 list();不带参数的 `lambda` 写它返回的东西:`lambda: {"a": 1}` → 每次新建 {"a": 1});没写默认值时空着。后面接 `field(...)` 的别的参数,照写的顺序:`init=False` 不进构造函数、`repr=False` 不显示、`compare=False` 不参与比较、`hash=True` 参与哈希、`hash=False` 不参与哈希、`kw_only=True` 只能按名字传参、`kw_only=False` 可按位置传参,写成默认值的不讲,别的照写 `名=值`(`metadata={"unit": "px"}`);最后是 `KW_ONLY` 带来的「只能按名字传参」。各项用 ` · ` 隔开。
- 说明:`field(doc="…")` 的字符串;没写时同枚举成员的说明取法(紧挨在上面的注释块、这一行行尾的注释、紧跟在后面的属性 docstring,原文用空格连起来),comment 色。
- 说明:`field(doc="…")` 的字符串;没写时同枚举成员的说明取法(紧挨在上面的注释块、这一行行尾的注释、紧跟在后面的属性 docstring,原文用空格连起来),comment 色;都没有时是类说明里讲它的 `@param`(字段就是构造参数,Markdown)。类说明里 `@param` 的名字不是哪个字段时,在卡片的「说明」下面列成「对不上的参数」;`@throws` 是卡片最后的「异常」表。
样例(`fixtures/dataclassModels.py` 的 `SandboxAccess`,测试里逐字核对;说明的 Markdown 从略):
@@ -1435,7 +1458,7 @@ export interface CommandRule {
- 字段:字段名(variable 色,带下标)。类型:注解照写法翻(翻译插件翻,`str` → 字符串,`str \| None` → 字符串 \| 空);类型里的名字带源码下标,能点。
- 默认值:`= x` 写 x(`None` → 空);`Field(default=x)` 或第一个位置参数写 x(`Field(...)` 的 `...` 是必填,不算默认值);`Field(default_factory=f)` 写「每次新建 f()」,和数据类同一个说法。后面接 `Field(...)` 的约束,照写的顺序用 ` · ` 隔开:`min_length=1` 至少 1 个字符、`max_length=255` 最长 255、`ge=0` ≥ 0、`le` ≤、`gt` >、`lt` <、`multiple_of=3` 3 的倍数、`pattern` 匹配、`min_items` / `max_items` 至少 / 最多 N 项、`max_digits` 最多 N 位数字、`decimal_places` 小数 N 位。`default`、`default_factory`、`description` 不在这里重复。不认得的照写 `名=值`,不丢掉。
- 说明:`Field(description="…")` 的字符串优先(不是字符串就照写法翻);没有就用字段上方的注释、这一行行尾的注释、紧跟在后面的属性 docstring,原文用空格连起来,comment 色。
- 说明:`Field(description="…")` 的字符串优先(不是字符串就照写法翻);没有就用字段上方的注释、这一行行尾的注释、紧跟在后面的属性 docstring,原文用空格连起来,comment 色;都没有时是类说明里讲它的 `@param`(同数据类)。对不上的 `@param`、`@throws` 同数据类。
样例(`fixtures/aigcMetadata.py` 的 `AIGCMetadata`,从 luffy `model/aigc.py` 摘的,测试里逐字核对):
@@ -1504,14 +1527,16 @@ export interface CommandRule {
| 行 | 内容 |
| --- | --- |
| 名称 | 函数名(function 色,带下标) |
| 说明 | 函数的 docstring,同函数块;Markdown |
| 说明 | 函数的 docstring 的正文,同函数块;Markdown |
| 对不上的参数 | 说明里 `@param` 的名字不是这个函数的参数时才有,同函数块(依赖、FastAPI 自己填的参数是函数的参数,不算对不上) |
| 路由器 | 挂在哪个路由对象上:它的名字(variable 色,带下标,⌘ + 点击跳到路由器卡片) |
| 标签 | 路由对象的 `tags=` 加装饰器的 `tags=`,字符串不带引号(string 色,带下标),「、」隔开 |
| 异步 | 是 / 否(storage 色) |
| 装饰器 | 路由装饰器以外的装饰器(`@cache(60)`),有才显示 |
| 依赖 | 路由对象、装饰器的 `dependencies=[Depends(x)]` 写 `x`,参数上的 `Depends(x)` / `Security(x)` 写 `x(参数名)`,参数名是 `_` 的只写 `x`;`Depends()` 不带参数时 x 是参数的类型;照这个顺序,「、」隔开。x 是一个名字时按 function 色、带下标,别的写法交给翻译插件翻 |
| 请求 | 值是 `方法 URL`(hint),下面一张表格「项 \| 值」,一项一行,没有的项不列:URL、方法、路径参数、查询参数、请求头、Cookie、请求体 |
| 响应 | 值是各状态码用 ` / ` 连起来(hint),下面一张表格「状态码 \| 内容 \| 说明」,一个状态码一行,按状态码从小到大,不是三位数字的(`"5XX"`)排在最后 |
| 请求 | 值是 `方法 URL`(hint),下面一张表格「项 \| 值」,一项一行,没有的项不列:URL、方法、路径参数、查询参数、请求头、Cookie、请求体。说明里有请求参数对上了 `@param` 时多一列「说明」(Markdown),这几种参数改成一个参数一行、各带它的说明,URL、方法和没写说明的格子留空 |
| 响应 | 值是各状态码用 ` / ` 连起来(hint),下面一张表格「状态码 \| 内容 \| 说明」,一个状态码一行,按状态码从小到大,不是三位数字的(`"5XX"`)排在最后;说明里写了 `@return` 时跟在值后面 |
| 异常 | 说明里写了 `@throws` 才有,同函数块的「异常」表 |
请求表:
@@ -1583,7 +1608,7 @@ TSX 和 Python 一样,从上往下一块语法一张卡片,嵌套的块排
| `react.source` | 数据源 | `download` | #9cdcfe / #001080 | 别的钩子赋给变量(`useQuery`、`usePathname`、`useRouter`、自定义 `useXxx`) | 变量 / 钩子 / 说明 |
| `react.derived` | 派生值 | `sigma` | #9cdcfe / #001080 | `useMemo`、`useCallback` | 名称 / 计算表达式 / 依赖项 |
| `react.effect` | 副作用 | `zap` | #e5c07b / #9a6a00 | `useEffect`、`useLayoutEffect`、`useInsertionEffect` | 触发时机 / 清理函数 |
| `react.handler` | 事件处理函数 | `mouse-pointer-click` | #dcdcaa / #795e26 | 组件体里定义的函数(函数声明、值是函数的常量) | 名称 / 参数 / 绑定事件 |
| `react.handler` | 事件处理函数 | `mouse-pointer-click` | #dcdcaa / #795e26 | 组件体里定义的函数(函数声明、值是函数的常量) | 名称 / 说明 / 参数 / 绑定事件(说明里的块标签另有几行,见下) |
| `react.render` | 渲染输出 | `layout-template` | #89d185 / #388a34 | 组件函数自己返回 JSX 的 return | JSX 结构大纲 |
组件色同时用在渲染大纲里的组件、「客户端组件」标签上;状态、引用、数据源、派生值都是数据,共用变量块的浅蓝,靠图标区分;副作用同「选项」块的黄,事件处理函数同「函数」块,渲染输出同「返回」块的绿。筛选栏的「种类」按这几个 id 筛,叫上表的名字。
@@ -1603,8 +1628,14 @@ TSX 和 Python 一样,从上往下一块语法一张卡片,嵌套的块排
| 行 | 内容 |
| --- | --- |
| 名称 | 组件名(组件色,带下标);后面跟小标签(`tag` 框):「具名导出」「默认导出」(同 import / export 关键字的颜色;`export default X`、`export { X }` 写在文件别处也算)、「客户端组件」(组件色)、「记忆化」「转发 ref」(同 const 关键字的颜色,memo / forwardRef 包着) |
| 说明 | 组件上面的 JSDoc,Markdown |
| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 类型 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 |
| 说明 | 组件上面的 JSDoc 的正文,Markdown(块标签拆开放,见下) |
| 对不上的参数 | 说明里 `@param` 的名字不是哪个属性时才有,同函数块 |
| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 类型 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」。说明里讲整个属性参数的 `@param props` 跟在这一行的值后面 |
| 异常 | 说明里写了 `@throws` 才有,同函数块 |
组件说明里的 `@param`:属性名对上的进「说明」一格(字段自己有注释的照旧用注释,没注释时才用它,Markdown);写成 `props.title`(属性参数名.属性名)的也算,属性参数是解构写的时点号前面写的那个名字当它的名字。
事件处理函数卡片:名称 / 说明(函数上面 JSDoc 的正文)/ 对不上的参数 / 参数(逗号隔开写在一行;说明里有参数对上了 `@param` 时排成「名称 \| 类型 \| 默认值 \| 说明」表)/ 返回(写了 `@return` 才有,值空着、后面跟说明)/ 异常 / 绑定事件。
直接返回 JSX 的箭头函数组件(`const Tag = ({ text }) => <span>{text}</span>`)没有子块可放,它渲染的大纲作为第四行「JSX 结构大纲」写在组件卡片里。
@@ -1683,11 +1714,11 @@ react.component 23
文件导入了对应 crate 才启用(来源等于 crate 名,或以 `crate::` 开头)。tokio 不单独做卡片,异步已经写在函数上。样例 `fixtures/rustLibs.rs`。
- serde(`rules/serde.ts`,`libraries: ['serde']`):`derive` 了 `Serialize` 或 `Deserialize` 的结构体。种类 `serde.struct`,标签「可序列化」,图标 `braces`,颜色同结构体(暗 #4ec9b0 / 亮 #267f99)。名称后面再标一个「可序列化」。`rename_all = "camelCase"` 收成选项「字段名改驼峰」。字段表多一列「序列化」:`rename` 写「改名 xxx」,`skip` / `skip_serializing` 写「不输出」,没标是「—」。
- serde(`rules/serde.ts`,`libraries: ['serde']`):`derive` 了 `Serialize` 或 `Deserialize` 的结构体。种类 `serde.struct`,标签「可序列化」,图标 `braces`,颜色同结构体(暗 #4ec9b0 / 亮 #267f99)。名称后面再标一个「可序列化」。`rename_all = "camelCase"` 收成选项「字段名改驼峰」。字段表多一列「序列化」:`rename` 写「改名 xxx」,`skip` / `skip_serializing` 写「不输出」,没标是「—」。说明里的 `@param` 同结构体对到字段表的「说明」列。
- thiserror(`rules/thiserror.ts`,`libraries: ['thiserror']`):`derive` 了 `Error` 的枚举。种类 `thiserror.error`,标签「错误」,图标 `triangle-alert`,颜色同异常(暗 #f14c4c / 亮 #cd3131)。变体表「变体 | 何时 | 展示」:何时是变体上面的文档注释,展示是 `#[error("…")]` 的文案。变体上的 `#[from]` 或 `#[source]` 在展示后面标「透传来源」。
- axum(`rules/axum.ts`,`libraries: ['axum']`):处理函数上的 `#[get("/path")]`、`#[axum::post(…)]` 等(get / post / put / delete / patch / head / options / trace),或顶层语句里 `Router` 的 `.route(路径, get(函数))`。函数已经标了路由属性时,这句 `route` 不再单独成卡。种类 `axum.route`,标签「接口」,图标 `globe`,颜色同 FastAPI 接口(暗 #61afef / 亮 #1a6fb5)。属性表:名称、说明、异步(是)、请求(一项「方法」、一项「路径」、路径里的 `{名字}` 对上参数写成「路径参数」,`Path(id): Path<Uuid>` 写 `id: Uuid`)、返回。
- axum(`rules/axum.ts`,`libraries: ['axum']`):处理函数上的 `#[get("/path")]`、`#[axum::post(…)]` 等(get / post / put / delete / patch / head / options / trace),或顶层语句里 `Router` 的 `.route(路径, get(函数))`。函数已经标了路由属性时,这句 `route` 不再单独成卡。种类 `axum.route`,标签「接口」,图标 `globe`,颜色同 FastAPI 接口(暗 #61afef / 亮 #1a6fb5)。属性表:名称、说明、异步(是)、请求(一项「方法」、一项「路径」、路径里的 `{名字}` 对上参数写成「路径参数」,`Path(id): Path<Uuid>` 写 `id: Uuid`)、参数(说明里写了 `@param` 才有:参数是 `Path(id)` 这类模式,不对名字,照写成「名称 \| 说明」表)、返回(`@return` 跟在后面)、异常(`@throws`)。
- sqlx(`rules/sqlx.ts`,`libraries: ['sqlx']`):`query!` / `query_as!` / `query_scalar!`。种类 `sqlx.query`,标签「查询」,图标 `database`,颜色暗 #ce9178 / 亮 #a31515。紧挨在前面的注释收成「说明」,不再单独成块。SQL 原文等宽,参数 `$1` 对上传入的表达式,`query_as!` / `query_scalar!` 的第一个实参是返回类型,`query!` 的返回类型是「—」。
- tauri(`rules/tauri.ts`,`libraries: ['tauri']`):`#[tauri::command]` 的函数。种类 `tauri.command`,标签「桌面命令」,图标 `square-function`,颜色暗 #e5c07b / 亮 #9a6a00。名称后面再标「桌面命令」。参数表「名称 | 类型 | 来源」:`State<…>` 是「窗口状态」,其余是「调用方传入」。
- tauri(`rules/tauri.ts`,`libraries: ['tauri']`):`#[tauri::command]` 的函数。种类 `tauri.command`,标签「桌面命令」,图标 `square-function`,颜色暗 #e5c07b / 亮 #9a6a00。名称后面再标「桌面命令」。参数表「名称 | 类型 | 来源」:`State<…>` 是「窗口状态」,其余是「调用方传入」;说明里的 `@param` 对上时多一列「说明」,`@return` 跟在「返回」后面,`@throws` 成「异常」表。
## 命令规则
@@ -1806,7 +1837,7 @@ shell 脚本大半是一条条命令,一条命令读成什么取决于命令
语法包是 `@vscode/tree-sitter-wasm` 里的 `tree-sitter-rust.wasm`(和 python 的 wasm 同一层:包入口就在 `wasm/`)。`languageIds: ['rust']`。样例 `fixtures/rustLang.rs`、`fixtures/rustLibs.rs`、`fixtures/rustComments.rs`。原型是 `docs/prototype/code-reader/openpencil/exports/` 的 `rust-lang.png`、`rust-lib.png`、`rust-notes.png`。中文直译 `translators/rustZh.ts`:结构体不叫「类」,可见性不叫「访问级别」,`pub` 是「公开」。
可见性:`pub` 公开,没写是本模块,`pub(super)` 父模块,`pub(crate)` 和 `pub(in …)` 本 crate。公开写在 `facts.access`,其余写在块的 `access` 属性上(筛选值 `module` / `super` / `crate`)。说明是紧挨在前面的文档注释(`///`、`/**`)的第一句,句号留着;连着几行的 `///` 算一段,中间可以隔着 `#[…]`。
可见性:`pub` 公开,没写是本模块,`pub(super)` 父模块,`pub(crate)` 和 `pub(in …)` 本 crate。公开写在 `facts.access`,其余写在块的 `access` 属性上(筛选值 `module` / `super` / `crate`)。说明是紧挨在前面的文档注释(`///`、`/**`)正文的第一句,句号留着;连着几行的 `///` 算一段(`facts.doc` 是第一条,中文直译的 `docNodes` 往下连),中间可以隔着 `#[…]`。卡片(含 Rust 库规则的)都经翻译插件的 `doc`(规则经 `context.doc`)读说明,块标签拆开放:函数的 `@param` 进参数表的「说明」列、`@return` 跟在「返回」后面、`@throws` 成「异常」表,结构体的 `@param` 对到字段表的「说明」列,对不上的列成「对不上的参数」。
注释和别的语言走同一条路(`listBlocks`,Rust 的 `CommentRules` 在 `languages/rust.ts`),文件顶层、`mod` 身体、`impl` 身体都一样:
@@ -1942,7 +1973,7 @@ interface BlockViewBase {
/**
* 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。
* 一行译文里有跨几行写的调用时 table 是摊开实参的表(TextTranslation.table),块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文(Translator.doc),
* 一行译文里有跨几行写的调用时 table 是摊开实参的表(TextTranslation.table),块带说明(TextBlock.doc)时 doc 是翻译插件读出的说明正文(Translator.doc 的 body),
* webview 依次排在译文下面。
*/
type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] })
@@ -2035,6 +2066,8 @@ vscode/src/codeReader/
rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字)
rules/segments.ts 库规则共用的拼属性表文字的办法
rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点)
docTags.ts 说明的原文按 KDoc 拆成正文和块标签(Doc:@param / @return / @throws),翻译插件的 doc 用它(纯函数)
rules/docRows.ts 讲说明的几行,中文直译和库规则共用:正文、对不上的参数、参数按名字对说明(paramDocs)、输出后面的 @return、异常表
languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数)
languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor)
languages/python.ts (iteration:推导式的结构;call:调用的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__)
@@ -2125,7 +2158,8 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。
- 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。
- `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、JSDoc 标签、HTML 都不转换),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。
- 块标签:拆法(`docTags.test.ts`:多行续写、空行结束、`@returns` / `@exception` 同义、JSDoc 的 `{类型}`、`[名字=默认值]`、横杠、标签前后的正文、没有标签时原样、别的标签和围栏代码块里的行留在正文、异常名写成引用);卡片(`translators/zhTemplate.test.ts` 的「说明里的块标签」):`fixtures/conversationBrief.py`(luffy 的 `render_conversation_brief`)说明里没有 `@param` / `@return`,输入参数表「说明」列四个参数的原文,输出参数后面跟 `@return`;`fixtures/localRuntimeGate.py`(luffy 的 `gate.py`)「异常」表两行,第一行是完整链接、第二行是不带路径的引用;对不上的 `@param` 排在说明下面、提示色、悬停「没有这个参数」;类的 `@param` 照写成表、异常类的对到构造参数(`*args` 去掉星号对)。React 组件(字段没注释时用 `@param`、`props.x`、讲整个参数的跟在构造参数后面、对不上的、异常表)和事件处理函数(`rules/react.test.ts`)、FastAPI 请求表的说明列和响应后面的 `@return`(`rules/fastapi.test.ts`)、Rust 函数和结构体(`languages/rust.test.ts`)、数据类字段(`rules/dataclass.test.ts`)。
- 注释的 Markdown:注释块、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。
- 行替换:多行块替换后缩进正确。
- 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。
- 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。
+1 -1
View File
@@ -296,7 +296,7 @@ export function toBlockViews(
if (!layerTranslator)
return { translation: [] }
const { text, table } = layerTranslator.translate(block, layerDocument)
const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)
const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)?.body
return { translation: text, ...(table && { table }), ...(doc && { doc }) }
}
/** 块执行的嵌着的代码分成的几步:只有一步时是它的子块,几步时是各步,交给那种语言转(见 TextBlock.embedded)。 */
+44 -8
View File
@@ -579,8 +579,9 @@ export type TokenRole
* refers:这段讲的是源码里别处的一个名字(那个名字的范围),语言扩展不认得这段自己的位置:写在字符串里的名字
* (FastAPI 路径 `"/{user_id}"` 里的 user_id 讲的是函数的参数 user_id)。预览把这段当那个名字:⌘ / Ctrl + 点击跳到它的定义,
* 单击高亮它的各处出现,那个名字的某处出现盖住 refers 时这段也跟着高亮。offset 照旧是这段照抄的位置。
* tooltip:悬停这段时看的字(说明里写了、参数表里没有的参数名:「没有这个参数」)。
*/
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange, tooltip?: string }
/**
* 框的样式,决定框头的图标和框的颜色:
@@ -612,10 +613,37 @@ export type Inline = Segment | SegmentGroup
/**
* 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换
* (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。
* (reStructuredText 的 `:func:` 照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示;
* KDoc 写法的不带路径的引用(`[名字]`、`[类.成员]`)由预览向宿主问它指的定义,问到了能点。
*/
export interface Markdown { markdown: string }
/** 说明里讲的一个参数(`@param 名字 说明`):名字照写(`*args`、`...rest` 带着前缀),说明是 Markdown。 */
export interface DocParam { name: string, text: Markdown }
/**
* 说明里讲的一种异常(`@throws 异常 什么时候抛`)。异常写成 Markdown:只写了名字的(`ValueError`、JSDoc 的 `{ValueError}`)
* 写成不带路径的引用 `[ValueError]`,`[Foo]`、`[说法](路径#Foo)` 照写;触发条件是 Markdown。
*/
export interface DocThrows { exception: Markdown, when: Markdown }
/**
* 一段说明(函数、类这些定义的说明,注释,当说明用的字符串):原文(同 Markdown)按 KDoc 拆成正文和块标签。
* 一行以 `@param`、`@return`(`@returns` 同义)、`@throws`(`@exception` 同义)开头算一条标签,后面接着的行(不是空行、不以 `@` 开头)
* 续在这条后面,空行结束这条;JSDoc 的 `@param {类型} 名字 - 说明`、`@param [名字] 说明` 也认(类型、横杠去掉)。
* 别的标签(`@see`、`@since`)、围栏代码块里的行留在正文里。
*/
export interface Doc {
/** 摘要和正文:拿掉这几种标签以后的原文;只写了标签时没有。 */
body?: Markdown
/** 照写的顺序。 */
params: DocParam[]
/** `@return` 的说明;写了几条时连成一段。 */
returns?: Markdown
/** 照写的顺序。 */
throws: DocThrows[]
}
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */
export type PreviewText = Inline[] | Markdown
@@ -625,9 +653,12 @@ export type PreviewText = Inline[] | Markdown
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
/**
* 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。
* 格子是着色的词,或一段 Markdown(参数的说明、异常的触发条件)。
*/
export interface PropertyItem extends Defines {
cells: Inline[][]
cells: PreviewText[]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
items?: PropertyItem[]
/**
@@ -644,6 +675,8 @@ export interface PropertyItem extends Defines {
export interface PropertyRow extends Defines {
label: string
value: PreviewText
/** 跟在值后面的一段说明(Markdown,注释色):函数输出参数那一行,返回类型后面是说明里的 `@return`。 */
note?: Markdown
items?: PropertyItem[]
columns?: string[]
}
@@ -765,8 +798,11 @@ export interface Translator {
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
expression: (node: Node, document: SourceDocument) => Inline[]
/** 一段说明文字(注释、docstring、当说明用的字符串)去掉注释符号、引号和共同缩进后的 Markdown 原文,同属性表的「说明」;没有文字时没有。 */
doc: (node: Node, document: SourceDocument) => Markdown | undefined
/**
* 一段说明文字(注释、docstring、当说明用的字符串)读成说明(见 Doc):去掉注释符号、引号和共同缩进后的 Markdown 原文,
* 拆出块标签;正文同属性表的「说明」。几条行文档注释连成一段的(Rust 的 `///`),node 是第一条,翻译插件自己往下连。没有文字时没有。
*/
doc: (node: Node, document: SourceDocument) => Doc | undefined
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
attributeNames?: AttributeNames
}
@@ -957,8 +993,8 @@ export interface RuleContext {
displayLanguage?: string
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
translate: (node: Node) => Inline[]
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/** 用当前翻译插件读一段说明文字(Translator.doc):正文和块标签;没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Doc | undefined
/**
* 源码里 name 这个名字(例如 raise 后面的异常类名)指的定义在 index 里认出的那一项,规则从定义处读出的值在 IndexEntry.value 里:
* 定义由语言扩展查(同 ⌘ + 点击),落在 index 认出的某一项的名字上才算;同一份源码里同名的名字按一处查。
+23 -16
View File
@@ -33,7 +33,7 @@ export interface ItemDiff {
/** 整行是新加的(它下面各层也都是) */
added?: true
/** 按列:这一格改成了什么样 */
cells?: Record<number, InlinePart[]>
cells?: Record<number, TextDiff>
items?: ItemsDiff
table?: ItemsDiff
}
@@ -49,6 +49,8 @@ export interface RowDiff {
/** 整行是新加的(它下面的子行也都是) */
added?: true
value?: TextDiff
/** 跟在值后面的说明(PropertyRow.note) */
note?: TextDiff
items?: ItemsDiff
}
@@ -79,7 +81,7 @@ function textWithoutSource(text: PreviewText): PreviewText {
function itemWithoutSource(item: PropertyItem): PropertyItem {
return {
cells: item.cells.map(withoutSource),
cells: item.cells.map(textWithoutSource),
...(item.items && { items: item.items.map(itemWithoutSource) }),
...(item.table && { table: tableWithoutSource(item.table) }),
}
@@ -93,6 +95,7 @@ function rowWithoutSource(row: PropertyRow): PropertyRow {
return {
label: row.label,
value: textWithoutSource(row.value),
...(row.note && { note: row.note }),
...(row.items && { items: row.items.map(itemWithoutSource) }),
...(row.columns && { columns: row.columns }),
}
@@ -175,14 +178,18 @@ export function markdownDiff(before: string, after: string): MarkdownPart[] | un
})
}
/** 一段预览文字的字:着色的词拼起来,Markdown 取原文。 */
function plainText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : inlineText(text)
}
/** 一段预览文字前后两版比:都是着色的词逐截比,都是 Markdown 逐词比;一种换成了另一种时按字逐词比。一样时是 undefined。 */
export function textDiff(before: PreviewText, after: PreviewText): TextDiff | undefined {
if (!('markdown' in before) && !('markdown' in after)) {
const inline = inlineDiff(before, after)
return inline && { inline }
}
const plain = (text: PreviewText): string => 'markdown' in text ? text.markdown : inlineText(text)
const markdown = markdownDiff(plain(before), plain(after))
const markdown = markdownDiff(plainText(before), plainText(after))
return markdown && { markdown }
}
@@ -232,13 +239,13 @@ function addedItem(item: PropertyItem): ItemDiff {
return { added: true, ...(items && { items }), ...(table && { table }) }
}
/** 配上的两个子行比:各格逐截比,下一层、套着的表逐行比。一样时是 undefined。 */
/** 配上的两个子行比:各格逐截 / 逐词比,下一层、套着的表逐行比。一样时是 undefined。 */
function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefined {
const cells: Record<number, InlinePart[]> = {}
const cells: Record<number, TextDiff> = {}
after.cells.forEach((cell, index) => {
const parts = inlineDiff(before.cells[index] ?? [], cell)
if (parts)
cells[index] = parts
const diff = textDiff(before.cells[index] ?? [], cell)
if (diff)
cells[index] = diff
})
const items = itemsDiff(before.items, after.items)
const table = itemsDiff(before.table?.items, after.table?.items)
@@ -257,7 +264,7 @@ function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefin
export function itemsDiff(before: readonly PropertyItem[] | undefined, after: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
const old = before ?? []
const now = after ?? []
const { toOld, toNew } = pairByKey(old, now, item => inlineText(item.cells[0] ?? []))
const { toOld, toNew } = pairByKey(old, now, item => plainText(item.cells[0] ?? []))
const changed: Record<number, ItemDiff> = {}
now.forEach((item, index) => {
const paired = toOld[index]
@@ -270,15 +277,15 @@ export function itemsDiff(before: readonly PropertyItem[] | undefined, after: re
}
/**
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比。子行改了时只标子行:带子行的行,值是子行的汇总(「3 个」「字典 2 项」),
* 跟着子行变,不另标。一样时是 undefined。
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比,跟在值后面的说明逐词比。子行改了时不标值:带子行的行,值是子行的汇总
* (「3 个」「字典 2 项」),跟着子行变,不另标。一样时是 undefined。
*/
function rowDiff(before: PropertyRow, after: PropertyRow): RowDiff | undefined {
const items = itemsDiff(before.items, after.items)
if (items)
return { items }
const value = textDiff(before.value, after.value)
return value && { value }
const note = textDiff(before.note ?? { markdown: '' }, after.note ?? { markdown: '' })
const value = items ? undefined : textDiff(before.value, after.value)
const diff: RowDiff = { ...(items && { items }), ...(value && { value }), ...(note && { note }) }
return Object.keys(diff).length > 0 ? diff : undefined
}
/** 属性表前后两版比:按属性名配对,配上的比值和子行,多出来的行算新加的,没了的行算删掉的、排回原来的位置。一样时是 undefined。 */
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { readDoc } from './docTags'
describe('说明拆成正文和块标签(KDoc)', () => {
it('没有标签时原样是正文', () => {
expect(readDoc('第一句。\n\n第二段,`code` 和 [Name]。')).toEqual({
body: { markdown: '第一句。\n\n第二段,`code` 和 [Name]。' },
params: [],
throws: [],
})
})
it('一行以标签开头算一条,后面接着的行续上,空行结束这条;标签前后的正文都留着', () => {
const doc = readDoc([
'摘要。',
'',
'@param messages 对话历史',
' 接着写的一行',
'@param summary 摘要',
'',
'标签后面的正文。',
'@return 转写',
].join('\n'))
expect(doc.body).toEqual({ markdown: '摘要。\n\n标签后面的正文。' })
expect(doc.params).toEqual([
{ name: 'messages', text: { markdown: '对话历史\n接着写的一行' } },
{ name: 'summary', text: { markdown: '摘要' } },
])
expect(doc.returns).toEqual({ markdown: '转写' })
})
it('`@returns` 同 `@return`,`@exception` 同 `@throws`', () => {
const doc = readDoc('@returns 结果\n@exception ValueError 参数不对')
expect(doc.body).toBeUndefined()
expect(doc.returns).toEqual({ markdown: '结果' })
expect(doc.throws).toEqual([{ exception: { markdown: '[ValueError]' }, when: { markdown: '参数不对' } }])
})
it('认 JSDoc 写法:`{类型}` 和名字后面的横杠去掉,`[名字=默认值]` 取名字', () => {
const doc = readDoc([
'@param {string} path - 文件路径',
'@param {{ a: number }} [options={}] 选项',
'@param [retries] - 重试次数',
'@returns {Promise<Config>} 配置',
'@throws {TypeError} 路径不是字符串',
].join('\n'))
expect(doc.params).toEqual([
{ name: 'path', text: { markdown: '文件路径' } },
{ name: 'options', text: { markdown: '选项' } },
{ name: 'retries', text: { markdown: '重试次数' } },
])
expect(doc.returns).toEqual({ markdown: '配置' })
expect(doc.throws).toEqual([{ exception: { markdown: '[TypeError]' }, when: { markdown: '路径不是字符串' } }])
})
it('异常写成引用:单写名字的加方括号,`[Foo]`、完整链接照写', () => {
const doc = readDoc([
'@throws [DesktopOnly](backend/src/visibility.py#DesktopOnly) 请求不是桌面端发来的',
'@throws [LocalRuntimeDisabled] 总开关关着',
'@throws errors.NotFound 没有这个人',
].join('\n'))
expect(doc.throws).toEqual([
{ exception: { markdown: '[DesktopOnly](backend/src/visibility.py#DesktopOnly)' }, when: { markdown: '请求不是桌面端发来的' } },
{ exception: { markdown: '[LocalRuntimeDisabled]' }, when: { markdown: '总开关关着' } },
{ exception: { markdown: '[errors.NotFound]' }, when: { markdown: '没有这个人' } },
])
})
it('别的标签、围栏代码块里的行留在正文;以 @ 开头的行结束上一条标签', () => {
const doc = readDoc([
'正文。',
'@param a 甲',
'@see other',
'```',
'@param inside 代码里的',
'```',
].join('\n'))
expect(doc.params).toEqual([{ name: 'a', text: { markdown: '甲' } }])
expect(doc.body).toEqual({ markdown: '正文。\n@see other\n```\n@param inside 代码里的\n```' })
})
it('`*args`、`**kwargs` 照写名字;几条 `@return` 连成一段', () => {
const doc = readDoc('@param *args 位置参数\n@param **kwargs 关键字参数\n@return 一\n@return 二')
expect(doc.params.map(param => param.name)).toEqual(['*args', '**kwargs'])
expect(doc.returns).toEqual({ markdown: '一\n\n二' })
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { Doc, DocParam, DocThrows, Markdown } from './contract'
type TagKind = 'param' | 'return' | 'throws'
/** 认的块标签:`@returns` 同 `@return`,`@exception` 同 `@throws`。 */
const TAGS: Record<string, TagKind> = { param: 'param', return: 'return', returns: 'return', throws: 'throws', exception: 'throws' }
/** 一行以块标签开头:标签名,和它后面的文字。 */
const TAG_LINE = /^\s*@(\w+)(?:\s(.*))?$/
/** 围栏代码块的开头(三个以上的反引号或波浪线,前面最多三个空格):里面的行照原样留在正文里。 */
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
/** 一个名字或点分的几个名字(`ValueError`、`errors.NotFound`):写成不带路径的引用。 */
const NAME_PATH = /^[\p{L}_$][\p{L}\p{N}_$]*(?:\.[\p{L}_$][\p{L}\p{N}_$]*)*$/u
interface Tag {
kind: TagKind
/** 标签名后面那一行的文字,和续在后面的各行 */
lines: string[]
}
function blank(line: string): boolean {
return line.trim() === ''
}
/** 围栏代码块到这一行结束:和开头同一种字符、不少于开头的个数,后面只有空白。 */
function closesFence(line: string, fence: string): boolean {
const match = /^\s{0,3}(`{3,}|~{3,})\s*$/.exec(line)
return !!match && match[1][0] === fence[0] && match[1].length >= fence.length
}
/** text 开头 open 那个括号到配对的 close 为止(含两头),不配对时没有。 */
function bracketed(text: string, open: string, close: string): string | undefined {
if (text[0] !== open)
return undefined
let depth = 0
for (let i = 0; i < text.length; i++) {
if (text[i] === open)
depth++
else if (text[i] === close && --depth === 0)
return text.slice(0, i + 1)
}
return undefined
}
/** JSDoc 写在开头的 `{类型}`:拿掉它,交出类型和剩下的。 */
function takeType(text: string): { type?: string, rest: string } {
const type = bracketed(text, '{', '}')
return type === undefined ? { rest: text } : { type: type.slice(1, -1).trim(), rest: text.slice(type.length).trimStart() }
}
/** 名字后面 JSDoc 写的 `- `:拿掉。 */
function withoutDash(text: string): string {
return text.replace(/^-(?:\s+|$)/, '')
}
/** 标签的说明:名字后面剩下的那一段接上续写的各行,空的不要。 */
function joinText(first: string, more: readonly string[]): Markdown {
return { markdown: [first, ...more].filter(line => line !== '').join('\n') }
}
/** `@param 名字 说明`、`@param {类型} 名字 - 说明`、`@param [名字=默认值] 说明`。 */
function paramOf([first, ...more]: string[]): DocParam | undefined {
const { rest } = takeType(first)
const optional = bracketed(rest, '[', ']')
const name = optional === undefined ? /^\S*/.exec(rest)![0] : optional.slice(1, -1).split('=')[0].trim()
const text = joinText(withoutDash(rest.slice(optional?.length ?? name.length).trimStart()), more)
return name === '' && text.markdown === '' ? undefined : { name, text }
}
/** `@return 说明`、`@returns {类型} 说明`。 */
function returnOf([first, ...more]: string[]): Markdown {
return joinText(withoutDash(takeType(first).rest), more)
}
/** 异常名写成 Markdown:只是名字的写成不带路径的引用 `[名字]`,别的(`[Foo]`、`[说法](路径#Foo)`、反引号)照写。 */
function exceptionMarkdown(text: string): Markdown {
return { markdown: NAME_PATH.test(text) ? `[${text}]` : text }
}
/** `@throws 异常 什么时候抛`:异常是一个名字、`[Foo]`、`[说法](路径#Foo)`,JSDoc 是 `{Foo}`。 */
function throwsOf([first, ...more]: string[]): DocThrows | undefined {
const { type, rest } = takeType(first)
let exception = type
let after = rest
if (exception === undefined) {
const label = bracketed(rest, '[', ']')
const target = label === undefined ? undefined : bracketed(rest.slice(label.length), '(', ')')
exception = label === undefined ? /^\S*/.exec(rest)![0] : label + (target ?? '')
after = rest.slice(exception.length).trimStart()
}
const when = joinText(withoutDash(after), more)
return exception === '' && when.markdown === '' ? undefined : { exception: exceptionMarkdown(exception), when }
}
/**
* 说明的 Markdown 原文按 KDoc 拆成正文和块标签(规则见 contract 的 Doc)。正文去掉首尾空行,标签拿掉后连着的几个空行并成一个。
*/
export function readDoc(markdown: string): Doc {
const body: string[] = []
const tags: Tag[] = []
let current: Tag | undefined
let fence: string | undefined
for (const line of markdown.split('\n')) {
if (fence !== undefined) {
body.push(line)
if (closesFence(line, fence))
fence = undefined
continue
}
const tag = TAG_LINE.exec(line)
const kind = tag && TAGS[tag[1]]
if (kind) {
current = { kind, lines: [(tag[2] ?? '').trim()] }
tags.push(current)
continue
}
if (current && !blank(line) && !line.trimStart().startsWith('@')) {
current.lines.push(line.trim())
continue
}
current = undefined
fence = FENCE_OPEN.exec(line)?.[1]
if (!(blank(line) && (body.length === 0 || blank(body.at(-1)!))))
body.push(line)
}
while (body.length > 0 && blank(body.at(-1)!))
body.pop()
const returns = tags.filter(tag => tag.kind === 'return').map(tag => returnOf(tag.lines)).filter(text => text.markdown !== '')
return {
...(body.length > 0 && { body: { markdown: body.join('\n') } }),
params: tags.flatMap(tag => tag.kind === 'param' ? paramOf(tag.lines) ?? [] : []),
...(returns.length > 0 && { returns: { markdown: returns.map(text => text.markdown).join('\n\n') } }),
throws: tags.flatMap(tag => tag.kind === 'throws' ? throwsOf(tag.lines) ?? [] : []),
}
}
@@ -0,0 +1,51 @@
"""把对话历史转写成一段文字, 交给不共享历史、每次都从零开始的子代理.
更早的对话已经被压缩成摘要 (`context_compaction["summary"]`, 可能为空),
`messages` 只剩近期窗口, 所以转写拼两段: 前情摘要 + 近期消息 (近期过长时只留尾部).
只转写用户与助手说过的文字: 工具往来与系统消息是执行细节, 子代理要看代码自己读.
平台注入的消息也不转写: 带 `UI_HIDDEN_KEY` 的运行通知 (任务日志 / 批准握手) 不是
谁说过的话, 握手里的「不要再讨论计划」还会和规划任务冲突; 带 `PLATFORM_ERROR_KEY`
的错误回复不是模型说的, 任何模型输入都不能带它.
"""
from __future__ import annotations
from collections.abc import Collection, Sequence
from typing import Any
from agent_compiler_deepagents.middleware import PLATFORM_ERROR_KEY, UI_HIDDEN_KEY
BRIEF_MAX_LINES = 30
USER = "用户"
ASSISTANT = "助手"
_SPEAKER = {"HumanMessage": USER, "AIMessage": ASSISTANT}
def render_conversation_brief(
messages: Sequence[Any],
summary: str | None,
*,
speakers: Collection[str] = (USER, ASSISTANT),
max_lines: int = BRIEF_MAX_LINES,
) -> str:
"""把对话转写成「前情摘要 + 近期消息」一段文字; 什么都没有时返回空串.
@param messages 对话历史 (`messages` 状态).
@param summary 更早对话的压缩摘要, 没有就传 `None`.
@param speakers 转写谁说的话: [USER] / [ASSISTANT]; 只要用户原话时只传 [USER].
@param max_lines 近期消息最多留几行, 多了从头丢弃并注明.
@return 以 `[会话进展]` 开头的转写.
"""
lines = [t for m in messages if (t := _line_of(m, speakers))]
truncated = len(lines) > max_lines
if truncated:
lines = lines[-max_lines:]
summary_text = (summary or "").strip()
if not summary_text and not lines:
return ""
parts = ["[会话进展]" + ("\n(更早对话已省略)" if truncated else "")]
if summary_text:
parts += ["[前情摘要]", summary_text]
parts.extend(lines)
return "\n".join(parts)
@@ -0,0 +1,26 @@
"""只放行从[桌面端](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#require_desktop_client)发来、且[「我的电脑」总开关](backend/src/luffy_agent/config/services.py#get_local_runtime_enabled)开着的请求,因为网页端没有本机可用,这个功能也还没对所有人开放。"""
from __future__ import annotations
from fastapi import Request
from luffy_agent.config.services import get_local_runtime_enabled
from luffy_agent.core.exceptions import AppException
from luffy_agent.sandbox.local_runtime.visibility import require_desktop_client
class LocalRuntimeDisabled(AppException):
status_code = 403
error_code = "LOCAL_RUNTIME_DISABLED"
message = "「我的电脑」运行时暂未开放"
async def require_local_runtime_open(request: Request) -> None:
"""只放行桌面端发来、且 [「我的电脑」总开关](backend/src/luffy_agent/config/services.py#get_local_runtime_enabled) 开着的请求,因为只有桌面端能在用户自己的电脑上跑应用,总开关让平台能把这个功能整体关掉。
@throws [DesktopOnly](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#DesktopOnly) 请求不是桌面端发来的
@throws [LocalRuntimeDisabled] 总开关关着
"""
require_desktop_client(request)
if not await get_local_runtime_enabled():
raise LocalRuntimeDisabled()
@@ -8,7 +8,7 @@ import { celeryRule } from '../rules/celery'
import { dataclassRule } from '../rules/dataclass'
import { enumRule } from '../rules/enum'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { flatSegments, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { createPythonAdapter } from './python'
@@ -55,7 +55,7 @@ function lines(list: readonly BlockView[], name: string): string[] {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
+40 -1
View File
@@ -4,7 +4,7 @@ import { toBlockViews } from '../blockView'
import { blockMarks } from '../diff/viewDiff'
import { rustImplRule } from '../rules/rustImpl'
import { isCrateRoot } from '../rules/rustTree'
import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { rustZhTranslator } from '../translators/rustZh'
import { createRustAdapter } from './rust'
@@ -249,6 +249,45 @@ describe('rust 注释', () => {
expect(viewLines([list[2]!])).toContain(' 说明: 读出这个账号的凭证。')
})
it('连着几行的 /// 是一段说明,块标签拆开放:@param 进参数表、@return 跟在返回后面、@throws 成异常表;结构体的 @param 对到字段', async () => {
const source = [
'/// 读出这个账号的凭证。',
'///',
'/// @param account 账号',
'/// @param ttl 写错的名字',
'/// @return 没存过时是 None',
'/// @throws [StoreError] 存储读不了',
'pub fn load(account: &str) -> Option<String> { None }',
'',
'/// 一个凭证。',
'/// @param token 访问令牌',
'pub struct Credential { pub token: String, scope: u8 }',
].join('\n')
const list = await views(source)
const fn = card(list, 'function')
const struct = card(list, 'class')
expect('properties' in fn && propertyLines(fn.properties)).toEqual([
'名称: load',
'说明: 读出这个账号的凭证。',
'对不上的参数: 1 个',
' ttl | 写错的名字',
'可见性: 公开',
'输入参数: 1 个',
' account | &str | 账号',
'返回: Option<String> — 没存过时是 None',
'异常: 1 个',
' [StoreError] | 存储读不了',
])
expect('properties' in struct && propertyLines(struct.properties)).toEqual([
'名称: Credential',
'说明: 一个凭证。',
'可见性: 公开',
'字段: 2 个',
' token | String | 公开 | 访问令牌',
' scope | u8 | 本模块 | ',
])
})
it('在 mod、impl 身体里同一套', async () => {
const source = [
'mod store {',
+1 -1
View File
@@ -116,7 +116,7 @@ function referencesInText(pieces: readonly Inline[]): Segment[] {
}
function referencesInItems(items: readonly PropertyItem[] | undefined): Segment[] {
return (items ?? []).flatMap(item => [...item.cells.flatMap(referencesInText), ...referencesInItems(item.items)])
return (items ?? []).flatMap(item => [...item.cells.flatMap(cell => 'markdown' in cell ? [] : referencesInText(cell)), ...referencesInItems(item.items)])
}
/** 属性表里(值、各层子行的格子)带引用的段。 */
+1 -1
View File
@@ -32,7 +32,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map<number, string>
}
const items = (list: readonly PropertyItem[] | undefined): void => {
for (const item of list ?? []) {
item.cells.forEach(inline)
item.cells.forEach(text)
items(item.items)
items(item.table?.items)
}
+24 -12
View File
@@ -1,5 +1,6 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { attributeName, docSentence, named, oneLine, returnText, stringContent } from './rustTree'
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import { returnNote, throwsRows, writtenParamRows } from './docRows'
import { attributeName, docSummary, named, oneLine, returnText, stringContent } from './rustTree'
/** 和 FastAPI 接口同一个蓝。 */
const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' }
@@ -16,14 +17,14 @@ export const axumRule: LibraryRule = {
label: 'axum 接口',
languageIds: ['rust'],
libraries: ['axum'],
claim: (block) => {
claim: (block, context) => {
if (block.kind === 'function') {
const route = attributeRoute(block)
return route ? card(block, route) : undefined
return route ? card(block, route, context) : undefined
}
if (block.kind === 'statement') {
const [route] = routesOn(block.node).filter(item => !handled(block.node, item.handler))
return route ? routerCard(block.node, route) : undefined
return route ? routerCard(block.node, route, context) : undefined
}
return undefined
},
@@ -48,15 +49,18 @@ function attributeRoute(block: FunctionBlock): Route | undefined {
return undefined
}
function card(block: FunctionBlock, route: Route): RuleCard {
const doc = docSentence(block.facts.doc)
function card(block: FunctionBlock, route: Route, context: RuleContext): RuleCard {
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const params = pathParams(route.path, block.node)
const rows: PropertyRow[] = [
nameRow(block.facts.name),
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
requestRow(route, params),
{ label: '返回', value: [plain(returnText(block.node))] },
...writtenParamRows(doc?.params ?? [], '参数'),
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: 'axum.route',
@@ -80,10 +84,11 @@ function handled(node: Node, handler: string | undefined): boolean {
return false
}
function routerCard(node: Node, route: Route & { handlerNode?: Node }): RuleCard {
function routerCard(node: Node, route: Route & { handlerNode?: Node }, context: RuleContext): RuleCard {
const fn = route.handler ? functionNamed(node, route.handler) : undefined
const params = fn ? pathParams(route.path, fn) : []
const doc = fn ? docSentence(leadingDoc(fn)) : undefined
const comment = fn && leadingDoc(fn)
const doc = docSummary(comment && context.doc(comment))
return {
kind: 'axum.route',
badge: BADGE,
@@ -184,11 +189,18 @@ function functionNamed(node: Node, name: string): Node | undefined {
return named(node.tree.rootNode).find(item => item.type === 'function_item' && item.childForFieldName('name')?.text === name)
}
/** 函数上面的文档注释(中间可以隔着属性):连着几行的 `///` 取第一条,读说明时从它往下连。 */
function leadingDoc(fn: Node): Node | undefined {
let prev = fn.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
return prev && (prev.type === 'line_comment' || prev.type === 'block_comment') ? prev : undefined
if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment'))
return undefined
let first = prev
for (let before = first.previousNamedSibling; first.type === 'line_comment' && before?.type === 'line_comment' && before.startPosition.row === first.startPosition.row - 1; before = before.previousNamedSibling)
first = before
return first
}
function attributeString(attributeItem: Node): string | undefined {
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Inline, PropertyRow, Segment } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { WorkspaceIndexes } from '../workspaceIndex'
import { celeryRule } from './celery'
@@ -38,7 +38,7 @@ async function beatLines(entries: string, header = 'from celery import Celery\nf
function segmentsOf(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces)
}
+3 -3
View File
@@ -454,7 +454,7 @@ function optionsCell(options: Option[], env: Env): Inline[] {
/** 注释的原文(翻译插件去掉 #),几条连成一段。 */
function commentText(comments: Node[], env: Env): Inline[] {
const text = comments.map(comment => env.context.doc(comment)?.markdown).filter(Boolean).join(' ')
const text = comments.map(comment => env.context.doc(comment)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
}
@@ -490,7 +490,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N
const entries = entriesOf(schedule)
const spreads = entries.filter(entry => entry.spread).length
const withOptions = entries.some(entry => extraOptions(entry).length > 0)
const markdown = doc && context.doc(doc)
const markdown = doc && context.doc(doc)?.body
const rows: PropertyRow[] = [
{ label: labels.app, value: context.translate(app) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
@@ -515,7 +515,7 @@ function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): Ru
call.positional.forEach((arg, i) => parameters.set(APP_PARAMETERS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
parameters.set(key, value)
const markdown = doc && context.doc(doc)
const markdown = doc && context.doc(doc)?.body
const named = new Set<string>(APP_NAMED)
const rows: PropertyRow[] = [
{ label: labels.name, value: target.type === 'identifier' ? [name(target, 'variable')] : context.translate(target) },
+32 -2
View File
@@ -5,7 +5,7 @@ import { fileAttributes } from '../attributes'
import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { celeryRule } from './celery'
import { dataclassRule } from './dataclass'
@@ -39,7 +39,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -260,4 +260,34 @@ describe('dataclasses 库规则(Python)', () => {
const list = await views('@dataclass\nclass A:\n x: int\n')
expect(list.map(view => view.kind)).toEqual(['class'])
})
it('类说明里的 @param 补上字段的说明(字段自己有注释的不换),对不上的排在说明下面', async () => {
const source = [
'from dataclasses import dataclass',
'',
'@dataclass',
'class Point:',
' """平面上的点。',
'',
' @param x 横坐标',
' @param y 不会盖过字段自己的注释',
' @param z 写错的名字',
' """',
' x: int',
' # 纵坐标',
' y: int = 0',
].join('\n')
const view = named(await views(source), 'Point')
expect(propertyLines(properties(view))).toEqual([
'名称: Point',
'说明: 平面上的点。',
'对不上的参数: 1 个',
' z | 写错的名字',
'访问级别: 公共',
'字段: 2 个',
' x | 整数 | | 横坐标',
' y | 整数 | 0 | 纵坐标',
])
expect(properties(view).find(row => row.label === '字段')?.items?.[0].cells[3]).toEqual({ markdown: '横坐标' })
})
})
+19 -8
View File
@@ -1,6 +1,8 @@
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -209,16 +211,22 @@ function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
return joined(parts, hint(' · '))
}
/** 说明一格:`field(doc="…")` 的字符串,没写时是讲它的注释、属性 docstring 的原文(用空格连起来);InitVar 的字段前面写「仅初始化时使用」。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, env: Env): Inline[] {
/**
* 说明一格:`field(doc="…")` 的字符串,没写时是讲它的注释、属性 docstring 的原文(用空格连起来),都没有时是类的说明里讲它的 `@param`
* (字段就是构造参数);InitVar 的字段前面写「仅初始化时使用」。
*/
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, docs: ParamDocs, env: Env): PreviewText {
const explicit = stringValue(call?.keywords.get('doc'))
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
const written = text ? undefined : docs.of(field.name.text)
if (written)
return initOnly ? { markdown: `${env.words.initOnly} · ${written.markdown}` } : written
const doc: Inline[] = text ? [{ text, role: 'comment' }] : []
return initOnly ? joined([[word(env.words.initOnly)], ...(text ? [doc] : [])], hint(' · ')) : doc
}
/** 字段表的一行:字段 | 类型 | 默认值 | 说明。讲的定义是字段名。 */
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
function fieldItem(field: Field, children: readonly CodeBlock[], docs: ParamDocs, env: Env): PropertyItem {
const call = fieldCall(field.value, env)
const initOnly = annotationName(field.annotation, env) === 'InitVar'
const type = initOnly ? genericOf(field.annotation)?.args[0] : undefined
@@ -227,7 +235,7 @@ function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): Prop
[name(field.name, 'variable')],
env.context.translate(type ?? field.annotation),
defaultCell(field, call, env),
docCell(field, children, call, initOnly, env),
docCell(field, children, call, initOnly, docs, env),
],
defines: range(field.name),
}
@@ -246,11 +254,13 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
const { facts, children } = block
const { fields, markers } = fieldsOf(children, env)
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const decorators = facts.decorators.filter(other => other !== decorator)
const options = optionsText(call, env)
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(decorators.length > 0 ? [{ label: labels.decorators, value: joined(decorators.map(other => context.translate(other)), plain('\n')) }] : []),
@@ -259,8 +269,9 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
label: labels.fields,
value: [hint(words.count(fields.length))],
columns: words.heads,
items: fields.map(field => fieldItem(field, children, env)),
items: fields.map(field => fieldItem(field, children, docs, env)),
},
...throwsRows(doc),
]
const concepts = frozenConcept(call, words)
return {
+95
View File
@@ -0,0 +1,95 @@
import type { Doc, DocParam, Markdown, PreviewText, PropertyRow } from '../contract'
import { hint } from './segments'
/**
* 卡片里讲说明(Doc)的几行,语言默认的卡片(中文直译)和库规则的卡片共用:说明的正文、说明里写了参数表里却没有的参数、
* 参数表里的「说明」一格、输出那一行后面的 `@return`、「异常」一行。用词是中文。
*/
/** 参数名去掉前缀、后缀再对:`*args`、`**kwargs`、`...rest` 的名字是 args、kwargs、rest,可省略参数 `x?` 是 x。 */
export function paramKey(name: string): string {
return name.replace(/^(?:\*{1,2}|\.{3})/, '').replace(/\?$/, '')
}
/** 说明里的参数按名字对到参数表:of 取一个参数的说明,described 是表里有参数对上了说明,unmatched 是表里没有的那几个。 */
export interface ParamDocs {
of: (name: string) => Markdown | undefined
described: boolean
unmatched: DocParam[]
}
/** names 是参数表里的参数名(照写,`*args` 带着星号);同一个参数写了几条时说明连起来。 */
export function paramDocs(doc: Doc | undefined, names: readonly string[]): ParamDocs {
const keys = new Set(names.map(paramKey))
const texts = new Map<string, string>()
const unmatched: DocParam[] = []
for (const param of doc?.params ?? []) {
const key = paramKey(param.name)
if (!keys.has(key))
unmatched.push(param)
else if (param.text.markdown !== '')
texts.set(key, [texts.get(key), param.text.markdown].filter(Boolean).join('\n\n'))
}
return {
of: (name) => {
const text = texts.get(paramKey(name))
return text === undefined ? undefined : { markdown: text }
},
described: texts.size > 0,
unmatched,
}
}
/** 参数表的「说明」一格:对上了的说明,没有时留空。 */
export function paramDocCell(docs: ParamDocs, name: string): PreviewText {
return docs.of(name) ?? []
}
/** 「说明」一行:说明的正文;只写了块标签、没有正文时没有。 */
export function docBodyRows(doc: Doc | undefined, label = '说明'): PropertyRow[] {
return doc?.body ? [{ label, value: doc.body }] : []
}
/** 排在「说明」下面:说明里写了、参数表里没有的参数(名字写错了),名称是提示色、悬停「没有这个参数」,后面是它的说明。 */
export function unmatchedParamRows(unmatched: readonly DocParam[]): PropertyRow[] {
if (unmatched.length === 0)
return []
return [{
label: '对不上的参数',
value: [hint(`${unmatched.length} 个`)],
columns: ['名称', '说明'],
items: unmatched.map(param => ({ cells: [[{ text: param.name, role: 'hint', tooltip: '没有这个参数' }], param.text] })),
}]
}
/**
* 卡片上没有参数表可对(类只有说明、没写构造函数)时,说明里写的参数照写成一张「名称 | 说明」表;一个都没写时没有。
*/
export function writtenParamRows(params: readonly DocParam[], label: string): PropertyRow[] {
if (params.length === 0)
return []
return [{
label,
value: [hint(`${params.length} 个`)],
columns: ['名称', '说明'],
items: params.map(param => ({ cells: [[{ text: param.name, role: 'variable' }], param.text] })),
}]
}
/** 输出那一行跟在值后面的说明(`@return`);没写时没有。 */
export function returnNote(doc: Doc | undefined): Pick<PropertyRow, 'note'> {
return doc?.returns ? { note: doc.returns } : {}
}
/** 「异常」一行:说明里的 `@throws`,下面一张「异常 | 触发条件」表,一条一行(异常名能点);没写时没有。 */
export function throwsRows(doc: Doc | undefined): PropertyRow[] {
const throws = doc?.throws ?? []
if (throws.length === 0)
return []
return [{
label: '异常',
value: [hint(`${throws.length} 个`)],
columns: ['异常', '触发条件'],
items: throws.map(item => ({ cells: [item.exception, item.when] })),
}]
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { celeryRule } from './celery'
@@ -45,7 +45,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -224,7 +224,7 @@ describe('枚举:TypeScript 和 Python 同一张卡片', () => {
attributes: view.attributes,
lines: propertyLines(rows),
columns: rows.map(row => row.columns),
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(cell).map(segment => segment.role))),
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(inlineOf(cell)).map(segment => segment.role))),
}
}
expect(shape(tsEnum)).toEqual(shape(pyEnum))
+51 -8
View File
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { DocumentLookups } from '../ruleLookups'
import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { WorkspaceIndexes } from '../workspaceIndex'
import { fastapiRule } from './fastapi'
@@ -47,7 +47,7 @@ async function route(source: string, fn: string, lookup?: RuleContext['lookup'])
function segmentsOf(row: PropertyRow | undefined): Segment[] {
const pieces: Inline[] = [
...(!row || 'markdown' in row.value ? [] : row.value),
...(row?.items ?? []).flatMap(item => item.cells.flat()),
...(row?.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
]
return flatSegments(pieces)
}
@@ -97,20 +97,20 @@ describe('fastapi 库规则:接口卡片', () => {
const rows = properties(await route(ROUTES, 'update_user', known({ Forbidden: '403' })))
const request = rows.find(row => row.label === '请求')
const [url, method] = request?.items ?? []
expect(flatSegments(url.cells[1]).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{', 'variable'], ['user_id', 'variable'], ['}', 'variable']])
for (const piece of flatSegments(url.cells[1]))
expect(flatSegments(inlineOf(url.cells[1])).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{', 'variable'], ['user_id', 'variable'], ['}', 'variable']])
for (const piece of flatSegments(inlineOf(url.cells[1])))
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
// 路径里的 user_id 讲的是 update_user 的参数 user_id:高亮参数的各处出现时跟着亮,⌘ + 点击跳到参数
const param = ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf('def update_user'))
expect(flatSegments(url.cells[1]).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: param, endIndex: param + 'user_id'.length })
expect(flatSegments(inlineOf(url.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: param, endIndex: param + 'user_id'.length })
expect(method.cells[1]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
const [ok, forbidden, validation] = rows.find(row => row.label === '响应')?.items ?? []
expect(ok.cells[0]).toEqual([{ text: '200', role: 'number', color: { dark: '#2ea043', light: '#1a7f37' } }])
const model = ROUTES.indexOf('response_model=UserDetailResponse', ROUTES.indexOf('@router.put')) + 'response_model='.length
expect(ok.cells[1]).toEqual([{ text: 'UserDetailResponse', role: 'type', offset: model }])
expect(forbidden.cells[0]).toEqual([{ text: '403', role: 'string' }])
expect(flatSegments(forbidden.cells[1])).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
expect(flatSegments([...validation.cells[1], ...validation.cells[2]]).map(piece => piece.role)).toEqual(['hint', 'hint'])
expect(flatSegments(inlineOf(forbidden.cells[1]))).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
expect(flatSegments([...inlineOf(validation.cells[1]), ...inlineOf(validation.cells[2])]).map(piece => piece.role)).toEqual(['hint', 'hint'])
// 名字都带下标、指回源码原文
for (const piece of rows.flatMap(segmentsOf).filter(piece => piece.offset !== undefined && piece.role !== 'string' && piece.role !== 'variable'))
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
@@ -316,6 +316,49 @@ async function routerCard(source: string, name: string): Promise<BlockView> {
return found
}
describe('fastapi 库规则:说明里的块标签', () => {
const SOURCE = [
'from fastapi import APIRouter, Depends, Query',
'',
'router = APIRouter(prefix="/apps")',
'',
'@router.get("/{app_id}")',
'async def get_app(app_id: int, q: str | None = Query(None), db = Depends(get_db)) -> AppOut:',
' """取一个应用。',
'',
' @param app_id 应用 id',
' @param q 按名字搜',
' @param db 数据库会话',
' @param qq 写错的名字',
' @return 应用详情',
' @throws [AppNotFound] 没有这个应用',
' """',
].join('\n')
it('@param 对到请求表(一个参数一行,多一列说明),依赖参数不算对不上;@return 跟在响应后面;@throws 成异常表', async () => {
const lines = propertyLines(properties(await route(SOURCE, 'get_app')))
expect(lines).toEqual([
'名称: get_app',
'说明: 取一个应用。',
'对不上的参数: 1 个',
' qq | 写错的名字',
'路由器: router',
'异步: 是',
'依赖: get_db(db)',
'请求: GET /apps/{app_id}',
' URL | /apps/{app_id} | ',
' 方法 | GET | ',
' 路径参数 | app_id: 整数 | 应用 id',
' 查询参数 | q: 字符串 | 空 | 按名字搜',
'响应: 200 / 422 — 应用详情',
' 200 | AppOut | 成功',
' 422 | 参数校验失败 | FastAPI 自动返回',
'异常: 1 个',
' [AppNotFound] | 没有这个应用',
])
})
})
describe('fastapi 库规则:路由器卡片', () => {
it('luffy 的 router:名称 / 前缀 / 标签,接口表列这份文件里挂在它上面的接口(方法 / 完整路径 / 函数 / docstring 第一行)', async () => {
const card = await routerCard(ROUTES, 'router')
@@ -339,7 +382,7 @@ describe('fastapi 库规则:路由器卡片', () => {
expect(getUser.cells[2]).toEqual([{ text: 'get_user', role: 'function', offset: ROUTES.indexOf('get_user(') }])
const paramOf = (fn: string): number => ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf(`def ${fn}`))
for (const [row, fn] of [[getUser, 'get_user'], [updateUser, 'update_user']] as const)
expect(flatSegments(row.cells[1]).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length })
expect(flatSegments(inlineOf(row.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length })
expect(updateUser.cells[0]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
})
+49 -19
View File
@@ -1,5 +1,7 @@
import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract'
import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { docBodyRows, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { callOf, genericOf, lastName, libraryName, stringContent, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, range, word } from './segments'
@@ -74,6 +76,8 @@ interface Words {
no: string
count: (n: number) => string
requestHeads: string[]
/** 请求表里有参数写了说明(`@param`)时多出的那一列 */
requestDoc: string
responseHeads: string[]
/** 路由器卡片的接口表:方法 | 路径 | 函数 | 说明 */
routeHeads: string[]
@@ -113,6 +117,7 @@ const ZH: Words = {
no: '否',
count: n => `${n} 个`,
requestHeads: ['项', '值'],
requestDoc: '说明',
responseHeads: ['状态码', '内容', '说明'],
routeHeads: ['方法', '路径', '函数', '说明'],
items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' },
@@ -325,11 +330,11 @@ function parametersOf(definition: Node): Map<string, Node> {
return new Map(names.flatMap(param => param?.type === 'identifier' ? [[param.text, param] as const] : []))
}
/** 函数 docstring 的第一行(翻译插件取的说明原文),没有 docstring 时没有。 */
/** 函数 docstring 正文的第一行(翻译插件读出的说明,块标签已经拿掉),没有 docstring 时没有。 */
function docFirstLine(definition: Node, env: Env): string | undefined {
const [first] = syntaxChildren(definition.childForFieldName('body'))
const [doc] = first?.type === 'expression_statement' ? syntaxChildren(first) : []
const markdown = doc?.type === 'string' ? env.context.doc(doc)?.markdown : undefined
const markdown = doc?.type === 'string' ? env.context.doc(doc)?.body?.markdown : undefined
return markdown?.split('\n').map(line => line.trim()).find(line => line !== '')
}
@@ -624,9 +629,37 @@ function methodText(method: string): Inline[] {
return [{ text: method, role: 'keyword', ...(METHOD_COLORS[method] && { color: METHOD_COLORS[method] }) }]
}
/** 请求表的一行:项 | 值。 */
function requestItem(label: string, value: Inline[]): PropertyItem {
return { cells: [[hint(label)], value] }
/** 请求表的一行:项 | 值,表里有说明一列时再加说明(note,没有说明的是空的)。 */
function requestItem(label: string, value: Inline[], note?: PreviewText): PropertyItem {
return { cells: [[hint(label)], value, ...(note ? [note] : [])] }
}
/**
* 请求表的各项:URL、方法,然后路径参数、查询参数、请求头、Cookie、请求体,有才列。说明里有请求参数对上了 `@param`(docs)时
* described:表多一列说明,这几种参数一个参数一行、各带它的说明;没有时同一种参数逗号隔开写在一行。
*/
function requestItems(url: Inline[], method: string, placed: readonly Placed[], docs: ParamDocs, env: Env): { items: PropertyItem[], described: boolean } {
const { words, context } = env
const described = placed.some(item => item.place !== 'dependency' && docs.of(item.param.text))
const item = (label: string, value: Inline[], note?: PreviewText): PropertyItem => requestItem(label, value, described ? note ?? [] : undefined)
const at = (place: Place): Placed[] => placed.filter(one => one.place === place)
const listed = (place: Exclude<Place, 'dependency'>): PropertyItem[] => {
const found = at(place)
if (described)
return found.map(one => item(words.items[place], paramsText([one], env), docs.of(one.param.text)))
return found.length > 0 ? [item(words.items[place], paramsText(found, env))] : []
}
const body = at('body')
const [only] = body
return {
items: [
item(words.items.url, url),
item(words.items.method, methodText(method)),
...(['path', 'query', 'header', 'cookie'] as const).flatMap(listed),
...(body.length === 1 && only?.type ? [item(words.items.body, context.translate(only.type), docs.of(only.param.text))] : listed('body')),
],
described,
}
}
function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
@@ -639,16 +672,12 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
const inPath = pathNames([router.prefix, route.path])
const placed = facts.parameters.flatMap(parameter => placeOf(parameter, inPath, env) ?? [])
const at = (place: Place): Placed[] => placed.filter(item => item.place === place)
const body = at('body')
const params = new Map(facts.parameters.flatMap(({ name: param }) => param.type === 'identifier' ? [[param.text, param] as const] : []))
const url = [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)]
const request: PropertyItem[] = [
requestItem(words.items.url, url),
requestItem(words.items.method, methodText(route.method)),
...(['path', 'query', 'header', 'cookie'] as const).flatMap(place => at(place).length > 0 ? [requestItem(words.items[place], paramsText(at(place), env))] : []),
...(body.length > 0 ? [requestItem(words.items.body, body.length === 1 && body[0].type ? context.translate(body[0].type) : paramsText(body, env))] : []),
]
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, facts.parameters.map(parameter => parameter.name.text))
const request = requestItems(url, route.method, placed, docs, env)
const statuses = new Statuses()
const success = call.keywords.get('status_code')
@@ -668,7 +697,6 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
if (placed.some(item => item.place !== 'dependency'))
statuses.add(numericStatus('422'), [hint(words.validation)], [hint(words.automatic)])
const doc = facts.doc && context.doc(facts.doc)
const tags = [...router.tags, ...listItems(call.keywords.get('tags'))].map(tag => tagText(tag, env))
const decorators = facts.decorators.filter(other => other !== route.decorator)
const dependencies = [
@@ -680,7 +708,8 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
]
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'function')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
{ label: labels.router, value: [name(router.target, 'variable')] },
...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []),
{ label: labels.async, value: [word(facts.async ? words.yes : words.no)] },
@@ -689,10 +718,11 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
{
label: labels.request,
value: [hint(`${route.method} ${textOf(url)}`)],
columns: words.requestHeads,
items: request,
columns: request.described ? [...words.requestHeads, words.requestDoc] : words.requestHeads,
items: request.items,
},
responsesRow(labels.responses, statuses, env),
{ ...responsesRow(labels.responses, statuses, env), ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: KIND,
@@ -712,7 +742,7 @@ function routerCard(block: VariableBlock, router: Router, routes: readonly Route
const { context, words } = env
const { labels } = words
const separator = hint(words.listSeparator)
const doc = block.facts.doc && context.doc(block.facts.doc)
const doc = block.facts.doc && context.doc(block.facts.doc)?.body
const tags = router.tags.map(tag => tagText(tag, env))
const dependencies = listedDependencies(router.dependencies, env)
const statuses = new Statuses()
+4 -3
View File
@@ -5,7 +5,7 @@ import { fileAttributes } from '../attributes'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { celeryRule } from './celery'
import { dataclassRule } from './dataclass'
@@ -46,7 +46,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -260,7 +260,8 @@ describe('pydantic 库规则(Python)', () => {
expect(Object.keys(added ?? {}).filter(index => added?.[Number(index)].added)).toEqual(['7'])
const traceFields = properties(trace).findIndex(row => row.label === '字段')
const cell = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2]
const marked = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2]
const cell = marked && 'inline' in marked ? marked.inline : undefined
expect(cell?.map(part => part.change)).toEqual(['del', 'add'])
expect(flatSegments(cell?.[0].segments ?? []).map(segment => segment.text).join('')).toBe('空')
expect(flatSegments(cell?.[1].segments ?? []).map(segment => segment.text).join('')).toBe('""')
+14 -9
View File
@@ -1,6 +1,8 @@
import type { Access, ClassBlock, CodeBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, WorkspaceIndex } from '../contract'
import type { Access, ClassBlock, CodeBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -328,26 +330,26 @@ function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
return joined(parts, hint(' · '))
}
/** 说明:`Field(description=...)` 优先;没有就用字段上方注释、行尾注释、属性 docstring。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, env: Env): Inline[] {
/** 说明:`Field(description=...)` 优先;没有就用字段上方注释、行尾注释、属性 docstring;都没有时是类的说明里讲它的 `@param`。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, docs: ParamDocs, env: Env): PreviewText {
const description = call?.keywords.get('description')
const literal = stringValue(description)
if (literal)
return [{ text: literal, role: 'comment' }]
if (description)
return env.context.translate(description)
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : docs.of(field.name.text) ?? []
}
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
function fieldItem(field: Field, children: readonly CodeBlock[], docs: ParamDocs, env: Env): PropertyItem {
const call = namedCall(field.value, 'Field', env.names)
return {
cells: [
[name(field.name, 'variable')],
env.context.translate(field.annotation),
defaultCell(field, call, env),
docCell(field, children, call, env),
docCell(field, children, call, docs, env),
],
defines: range(field.name),
}
@@ -360,10 +362,12 @@ function modelCard(block: ClassBlock, env: Env): RuleCard {
const config = configOf(children)
const fields = fieldsOf(children, env)
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const options = optionsText(config?.value, env)
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(options.length > 0 ? [{ label: labels.options, value: options }] : []),
@@ -372,9 +376,10 @@ function modelCard(block: ClassBlock, env: Env): RuleCard {
label: labels.fields,
value: [hint(words.count(fields.length))],
columns: words.heads,
items: fields.map(field => fieldItem(field, children, env)),
items: fields.map(field => fieldItem(field, children, docs, env)),
}]
: []),
...throwsRows(doc),
]
return {
kind: KIND,
+72 -7
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createTypeScriptAdapter } from '../languages/typescript'
import { propertyLines, readFixture, segmentText, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { inlineOf, previewText, propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
@@ -186,6 +186,71 @@ describe('react 库规则:构造参数', () => {
})
})
describe('react 库规则:说明里的块标签', () => {
const SOURCE = [
'interface BadgeProps {',
' /** 显示的字 */',
' label: string',
' tone?: string',
'}',
'',
'/**',
' * 小徽标。',
' *',
' * @param props 徽标的属性',
' * @param props.tone 颜色',
' * @param label 不会盖过字段自己的注释',
' * @param size 写错的名字',
' * @throws [RangeError] 颜色不认识',
' */',
'export function Badge({ label, tone = \'gray\' }: BadgeProps) {',
' /**',
' * 点了复制。',
' * @param event 点击事件',
' * @return 复制成没成',
' */',
' function onCopy(event: MouseEvent) {',
' return true',
' }',
' return <span onClick={onCopy}>{label}</span>',
'}',
].join('\n')
it('组件:字段没注释时说明格子用 @param(props.tone 也算),讲整个参数的跟在构造参数后面,对不上的列在说明下面,@throws 成异常表', async () => {
const view = await card(SOURCE, 'Badge')
const rows = properties(view)
expect(lines(view)).toEqual([
'名称: Badge ⟦具名导出|⟧',
'说明: 小徽标。',
'对不上的参数: 1 个',
' size | 写错的名字',
'构造参数: 2 个 · 类型 BadgeProps — 徽标的属性',
' label | 字符串 | — | 是 | 显示的字',
' tone | 字符串 | \'gray\' | 否 | 颜色',
'异常: 1 个',
' [RangeError] | 颜色不认识',
])
const props = rows.find(row => row.label === '构造参数')
expect(props?.note).toEqual({ markdown: '徽标的属性' })
expect(props?.items?.map(item => item.cells[4])).toEqual([[{ text: '显示的字', role: 'comment' }], { markdown: '颜色' }])
expect(rows.find(row => row.label === '对不上的参数')?.items?.[0].cells[0]).toEqual([{ text: 'size', role: 'hint', tooltip: '没有这个参数' }])
expect(rows.find(row => row.label === '异常')?.items?.[0].cells).toEqual([{ markdown: '[RangeError]' }, { markdown: '颜色不认识' }])
expect(lines(view).join('\n')).not.toMatch(/@param|@throws/)
})
it('事件处理函数:说明、@param 时参数排成带说明的表、@return 一行', async () => {
const handler = flatten(await views(SOURCE)).find(view => view.kind === 'react.handler')
expect(lines(handler)).toEqual([
'名称: onCopy',
'说明: 点了复制。',
'参数: 1 个',
' event | MouseEvent | — | 点击事件',
'返回: — 复制成没成',
'绑定事件: <span> 的 onClick',
])
})
})
describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklist,原型「对照」)', () => {
it('组件卡片的行只有名称 / 说明 / 构造参数,组件体的语句照源码顺序是它的子块', async () => {
const view = await card(PLAN, 'PlanChecklist')
@@ -499,10 +564,10 @@ describe('react 库规则:渲染大纲', () => {
' ⟦|当 platform 全等 "windows" 时⟧ WindowControls',
])
const [div] = outlineItems(view)
const [tagSegment, , chip] = div.cells[0]
const [tagSegment, , chip] = inlineOf(div.cells[0] ?? [])
expect(tagSegment).toEqual({ text: 'div', role: 'plain', color: { dark: '#808080', light: '#6f6f6f' } })
expect(chip).toMatchObject({ style: 'classes', detail: 'flex h-10 shrink-0 select-none items-center border-b border-border bg-background' })
const controls = div.items?.[2].cells[0].find(piece => 'role' in piece && piece.text === 'WindowControls')
const controls = inlineOf(div.items?.[2].cells[0] ?? []).find(piece => 'role' in piece && piece.text === 'WindowControls')
expect(controls).toMatchObject({ role: 'type', color: { dark: '#61dafb', light: '#0a7ea4' } })
expect(controls && 'role' in controls && TITLEBAR.slice(controls.offset, (controls.offset ?? 0) + 14)).toBe('WindowControls')
})
@@ -528,8 +593,8 @@ describe('react 库规则:渲染大纲', () => {
' ReactMarkdown remarkPlugins=[remarkGfm] components=对象:h2、a {markdown}',
' ⟦|否⟧ p ⟦样式 2 个类|⟧ {DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。',
])
const link = findItem(outlineItems(view), item => segmentText(item.cells[0]).startsWith('Link'))
const chip = link?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
const link = findItem(outlineItems(view), item => previewText(item.cells[0] ?? []).startsWith('Link'))
const chip = inlineOf(link?.cells[0] ?? []).find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe([
'flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300',
'doc 全等 "terms" 时:bg-surface text-foreground shadow-sm',
@@ -581,8 +646,8 @@ describe('react 库规则:渲染大纲', () => {
])
expect(text).toContain(' SettingsRow label="规格" value=deployment?.instance_type_code ?? "—" note="重新发布时可换档"')
expect(text.join('\n')).not.toContain('text-primary underline')
const anchor = findItem(outlineItems(view), item => item.cells[0].some(piece => 'role' in piece && piece.text === 'a'))
const chip = anchor?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
const anchor = findItem(outlineItems(view), item => inlineOf(item.cells[0] ?? []).some(piece => 'role' in piece && piece.text === 'a'))
const chip = inlineOf(anchor?.cells[0] ?? []).find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe('text-primary underline underline-offset-2')
})
+87 -23
View File
@@ -1,5 +1,7 @@
import type { CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { CodeBlock, Doc, Inline, KindIconName, LibraryRule, Markdown, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts, RefFacts, StateFacts } from './reactTree'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentText, componentAt, componentOf, componentOfFunction, componentsIn, declaredTypes, enclosingComponent, enclosingFunction, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
@@ -63,7 +65,9 @@ const LOOKS: Record<CardKind, { color: ThemeColor, icon: KindIconName }> = {
interface Words {
/** 各种卡片的名字,筛选栏里也叫这个 */
kinds: Record<CardKind, string>
labels: Record<'name' | 'doc' | 'props' | 'initial' | 'updates' | 'uses' | 'variable' | 'hook' | 'meaning' | 'expression' | 'deps' | 'trigger' | 'cleanup' | 'parameters' | 'events' | 'outline', string>
labels: Record<'name' | 'doc' | 'props' | 'initial' | 'updates' | 'uses' | 'variable' | 'hook' | 'meaning' | 'expression' | 'deps' | 'trigger' | 'cleanup' | 'parameters' | 'returns' | 'events' | 'outline', string>
/** 事件处理函数的说明里有参数对上了 `@param` 时,参数排成表:名称 | 类型 | 默认值 | 说明 */
parameterHeads: string[]
/** 触发时机:a、b 变化时,仅挂载时,每次渲染后;useLayoutEffect 在浏览器绘制前跑 */
changed: string
mount: string
@@ -160,9 +164,11 @@ const ZH: Words = {
trigger: '触发时机',
cleanup: '清理函数',
parameters: '参数',
returns: '返回',
events: '绑定事件',
outline: 'JSX 结构大纲',
},
parameterHeads: ['名称', '类型', '默认值', '说明'],
changed: '变化时',
mount: '仅挂载时',
afterRender: '每次渲染后',
@@ -290,10 +296,21 @@ function componentName(component: ComponentFacts, { words }: Env): PropertyRow {
}
}
/** 说明:组件上面的 JSDoc。 */
function docRows(component: ComponentFacts, { context, words }: Env): PropertyRow[] {
const doc = component.doc && context.doc(component.doc)
return doc ? [{ label: words.labels.doc, value: doc }] : []
/**
* 组件说明里的 `@param` 对到构造参数的各项:属性名对上的进说明一格,写成 `props.title`(属性参数名.属性名)的也算;
* 只写属性参数名的(`@param props`)讲的是整个参数,跟在构造参数那一行后面(whole)。属性参数是解构写的、没有名字时,
* 说明里点号前面写的那个名字(`@param props.title` 的 props)当它的名字。
*/
function propDocs(doc: Doc | undefined, props: PropsFacts, entries: readonly PropEntry[]): { docs: ParamDocs, whole?: Markdown } {
const written = doc?.params ?? []
const dotted = written.flatMap(param => param.name.includes('.') ? [param.name.slice(0, param.name.indexOf('.'))] : [])
const holder = props.parameter?.type === 'identifier' ? props.parameter.text : dotted[0]
const prefix = holder && `${holder}.`
const whole = written.find(param => param.name === holder)?.text
const params = written
.filter(param => param.name !== holder)
.map(param => prefix && param.name.startsWith(prefix) ? { ...param, name: param.name.slice(prefix.length) } : param)
return { docs: paramDocs(doc && { ...doc, params }, entries.map(entry => entry.key)), ...(whole?.markdown && { whole }) }
}
// ─── 组件:构造参数 ───────────────────────────────────────────────────────────
@@ -393,19 +410,20 @@ function propRequired(entry: PropEntry, words: Words): Inline[] {
return [hint(words.nothing)]
}
/** 说明一格:字段上面的 JSDoc / 注释。 */
function propDoc(entry: PropEntry): Inline[] {
/** 说明一格:字段上面的 JSDoc / 注释;字段没写时是组件说明里讲它的 `@param`。 */
function propDoc(entry: PropEntry, docs: ParamDocs): PreviewText {
const text = entry.field ? commentText(entry.field.docs) : entry.text?.doc
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : []
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : docs.of(entry.key) ?? []
}
/** 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写出它。 */
function propsRow(component: ComponentFacts, env: Env): PropertyRow {
/**
* 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写出它,
* 组件说明里讲整个属性参数的 `@param` 跟在这一行的值后面。
*/
function propsRow(props: PropsFacts, entries: readonly PropEntry[], written: { docs: ParamDocs, whole?: Markdown }, env: Env): PropertyRow {
const { context, words } = env
const props = propsOf(component)
const entries = propEntries(props, env)
const docs = entries.map(propDoc)
const described = docs.some(doc => doc.length > 0)
const docs = entries.map(entry => propDoc(entry, written.docs))
const described = docs.some(doc => 'markdown' in doc || doc.length > 0)
const items = entries.map((entry, index): PropertyItem => {
const defines = entry.binding?.name ?? entry.field?.name
return {
@@ -422,13 +440,28 @@ function propsRow(component: ComponentFacts, env: Env): PropertyRow {
return {
label: words.labels.props,
value: [hint(words.count(items.length)), ...(props.from ? [hint(words.typed), name(props.from, 'type')] : [])],
...(written.whole && { note: written.whole }),
...(items.length > 0 && { items, columns: described ? [...words.propHeads, words.propDoc] : words.propHeads }),
}
}
/** 组件卡片:名称 / 说明 / 构造参数。组件体的语句照常是子块;直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。 */
/**
* 组件卡片:名称 / 说明 / 对不上的参数 / 构造参数 / 异常(说明里的块标签拆开放,见 propDocs)。组件体的语句照常是子块;
* 直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。
*/
function componentCard(component: ComponentFacts, env: Env): RuleCard {
const rows = [componentName(component, env), ...docRows(component, env), propsRow(component, env)]
const { context, words } = env
const doc = component.doc && context.doc(component.doc)
const props = propsOf(component)
const entries = propEntries(props, env)
const written = propDocs(doc, props, entries)
const rows = [
componentName(component, env),
...docBodyRows(doc, words.labels.doc),
...unmatchedParamRows(written.docs.unmatched),
propsRow(props, entries, written, env),
...throwsRows(doc),
]
if (component.body.type !== 'statement_block')
rows.push(outlineRow(component.body, env))
return card('component', rows, env, { defines: range(component.name) })
@@ -725,17 +758,48 @@ function eventsText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline
return [hint(words.nothing)]
}
/** 事件处理函数:名称 / 参数 / 绑定事件。函数里的语句照常是这张卡片的子块。 */
function handlerCard(handler: HandlerFacts, analysis: Analysis, env: Env): RuleCard {
/**
* 事件处理函数:名称 / 说明 / 对不上的参数 / 参数 / 返回(说明里写了 `@return` 才有)/ 异常 / 绑定事件。参数逗号隔开写在一行,
* 说明里有参数对上了 `@param` 时排成一张表,多一列说明。函数里的语句照常是这张卡片的子块。
*/
function handlerCard(block: CodeBlock, handler: HandlerFacts, analysis: Analysis, env: Env): RuleCard {
const { context, words } = env
const parameters = parametersOf(handler.fn).map(parameter => [
const doc = block.kind === 'function' && block.facts.doc ? context.doc(block.facts.doc) : undefined
const parameters = parametersOf(handler.fn)
const docs = paramDocs(doc, parameters.map(parameter => parameter.name.text))
const described = parameters.some(parameter => docs.of(parameter.name.text))
const parameterRow: PropertyRow = described
? {
label: words.labels.parameters,
value: [hint(words.count(parameters.length))],
columns: words.parameterHeads,
items: parameters.map(parameter => ({
cells: [
binding(parameter.name, 'variable', env),
parameter.type ? context.translate(parameter.type) : [hint(words.unmarked)],
parameter.default ? context.translate(parameter.default) : [hint(words.nothing)],
paramDocCell(docs, parameter.name.text),
],
defines: range(parameter.name),
})),
}
: {
label: words.labels.parameters,
value: parameters.length > 0
? joined(parameters.map(parameter => [
...binding(parameter.name, 'variable', env),
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []),
])
]), plain(', '))
: [hint(words.none)],
}
return card('handler', [
{ label: words.labels.name, value: [name(handler.name, 'function')] },
{ label: words.labels.parameters, value: parameters.length > 0 ? joined(parameters, plain(', ')) : [hint(words.none)] },
...docBodyRows(doc, words.labels.doc),
...unmatchedParamRows(docs.unmatched),
parameterRow,
...(doc?.returns ? [{ label: words.labels.returns, value: [], ...returnNote(doc) }] : []),
...throwsRows(doc),
{ label: words.labels.events, value: eventsText(handler, analysis, env) },
], env, { defines: range(handler.name) })
}
@@ -976,7 +1040,7 @@ function bodyCard(block: CodeBlock, env: Env): RuleCard | undefined {
if (effect)
return effectCard(block, effect, env)
const handler = body.handlers.find(at)
return handler && handlerCard(handler, analysis, env)
return handler && handlerCard(block, handler, analysis, env)
}
/**
+4 -4
View File
@@ -1,4 +1,4 @@
import type { ImplBlock, KindBadge, LibraryRule, RuleCard, ThemeColor } from '../contract'
import type { ImplBlock, KindBadge, LibraryRule, RuleCard, RuleContext, ThemeColor } from '../contract'
import { implRows, plainSay } from './rustRows'
/** 和函数卡同一个黄,实现块看起来仍是函数那一挂。 */
@@ -14,15 +14,15 @@ export const rustImplRule: LibraryRule = {
label: 'Rust 实现',
languageIds: ['rust'],
libraries: [],
claim: block => block.kind === 'impl' ? implCard(block) : undefined,
claim: (block, context) => block.kind === 'impl' ? implCard(block, context) : undefined,
attributeNames: () => ({
attributes: {},
values: { kind: { 'rust.impl': '实现' } },
}),
}
function implCard(block: ImplBlock): RuleCard {
const rows = implRows(block, plainSay())
function implCard(block: ImplBlock, context: RuleContext): RuleCard {
const rows = implRows(block, plainSay(context.doc))
const row = rows.findIndex(item => item.label === '方法')
const methods = block.children.filter(child => child.kind === 'function')
return {
+2 -1
View File
@@ -108,7 +108,8 @@ describe('serde 与 thiserror', () => {
const newCard = (await views(after)).find(block => block.kind === 'axum.route')
const marks = oldCard && newCard ? blockMarks(oldCard, newCard) : undefined
const row = newCard && 'properties' in newCard ? newCard.properties.findIndex(item => item.label === '请求') : -1
const parts = marks?.rows?.changed[row]?.items?.changed[1]?.cells?.[1] ?? []
const cell = marks?.rows?.changed[row]?.items?.changed[1]?.cells?.[1]
const parts = cell && 'inline' in cell ? cell.inline : []
const text = (change: 'add' | 'del') => parts
.filter(part => part.change === change)
.flatMap(part => part.segments)
+39 -20
View File
@@ -1,11 +1,14 @@
import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, PropertyItem, PropertyRow, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, Doc, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, Node, PropertyItem, PropertyRow, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { RustVisibility } from './rustTree'
import { bodyFunctions, docSentence, isMethod, matchArms, oneLine, returnText, signatureParams, structFields, variantPayload } from './rustTree'
import { paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { bodyFunctions, docSummary, isMethod, matchArms, oneLine, returnText, signatureParams, structFields, variantPayload } from './rustTree'
/** 把类型、名字画成原文。翻译插件传入时,名字带源码下标。 */
/** 把类型、名字画成原文,读说明。翻译插件传入时,名字带源码下标。 */
export interface RustSay {
name: (node: import('../contract').Node, role: TokenRole) => Inline[]
source: (node: import('../contract').Node, role?: TokenRole) => Inline[]
name: (node: Node, role: TokenRole) => Inline[]
source: (node: Node, role?: TokenRole) => Inline[]
/** 读一项的文档注释(连着几行的 `///` 是一段),见 Translator.doc;没有时没有。 */
doc: (node: Node) => Doc | undefined
}
const VISIBILITY: Record<RustVisibility, string> = {
@@ -49,15 +52,20 @@ export function importRows(block: ImportBlock, say: RustSay): PropertyRow[] {
export function structRows(block: ClassBlock, say: RustSay): PropertyRow[] {
const fields = structFields(block.node)
const doc = block.facts.doc && say.doc(block.facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const described = fields.some(field => docs.of(field.name.text))
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(doc),
...unmatchedParamRows(docs.unmatched),
visibilityRow(block),
...tableRow('字段', ['字段', '类型', '可见性'], fields.map(field => ({
...tableRow('字段', ['字段', '类型', '可见性', ...(described ? ['说明'] : [])], fields.map(field => ({
cells: [
say.name(field.name, 'variable'),
say.source(field.type, 'type'),
[keyword(VISIBILITY[field.visibility])],
...(described ? [paramDocCell(docs, field.name.text)] : []),
],
defines: range(field.name),
}))),
@@ -67,7 +75,7 @@ export function structRows(block: ClassBlock, say: RustSay): PropertyRow[] {
export function moduleRows(block: ModuleBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'variable'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
visibilityRow(block),
]
}
@@ -166,7 +174,7 @@ function cfgText(predicate: CfgPredicate, nested: boolean): Inline[] {
export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('变体', ['变体', '携带的数据'], block.facts.members.map(member => ({
cells: [say.name(member.name, 'type'), [hint(variantPayload(member.value) ?? '—')]],
defines: range(member.name),
@@ -176,16 +184,26 @@ export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] {
export function functionRows(block: FunctionBlock, say: RustSay): PropertyRow[] {
const arms = matchArms(block.node)
const doc = block.facts.doc && say.doc(block.facts.doc)
const { parameters } = block.facts
const docs = paramDocs(doc, parameters.map(param => param.name.text))
const described = parameters.some(param => docs.of(param.name.text))
return [
nameRow(block.facts.name, say, 'function'),
...docRow(block.facts.doc),
...docRow(doc),
...unmatchedParamRows(docs.unmatched),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
visibilityRow(block),
...tableRow(isMethod(block.node) ? '参数' : '输入参数', ['名称', '类型'], block.facts.parameters.map(param => ({
cells: [say.name(param.name, 'variable'), param.type ? say.source(param.type, 'type') : [hint('—')]],
...tableRow(isMethod(block.node) ? '参数' : '输入参数', ['名称', '类型', ...(described ? ['说明'] : [])], parameters.map(param => ({
cells: [
say.name(param.name, 'variable'),
param.type ? say.source(param.type, 'type') : [hint('—')],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
defines: range(param.name),
}))),
{ label: '返回', value: [plain(returnText(block.node))] },
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
...tableRow('分情况', ['分支', '做什么'], arms.map(arm => ({
cells: [[plain(arm.pattern)], [hint(arm.does)]],
})), '支'),
@@ -196,7 +214,7 @@ export function traitRows(block: TraitBlock, say: RustSay): PropertyRow[] {
const methods = bodyFunctions(block.node)
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.childForFieldName('name')
return {
@@ -218,7 +236,7 @@ export function implRows(block: ImplBlock, say: RustSay): PropertyRow[] {
: [hint('实现')]
return [
{ label: '名称', value: title, ...(block.facts.name && { defines: range(block.facts.name) }) },
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.facts.name
return {
@@ -233,7 +251,7 @@ export function implRows(block: ImplBlock, say: RustSay): PropertyRow[] {
]
}
function nameRow(name: import('../contract').Node | undefined, say: RustSay, role: TokenRole): PropertyRow {
function nameRow(name: Node | undefined, say: RustSay, role: TokenRole): PropertyRow {
return {
label: '名称',
value: name ? say.name(name, role) : [hint('匿名')],
@@ -241,8 +259,8 @@ function nameRow(name: import('../contract').Node | undefined, say: RustSay, rol
}
}
function docRow(doc: import('../contract').Node | undefined): PropertyRow[] {
const sentence = docSentence(doc)
function docRow(doc: Doc | undefined): PropertyRow[] {
const sentence = docSummary(doc)
return sentence ? [{ label: '说明', value: { markdown: sentence } }] : []
}
@@ -277,13 +295,14 @@ function note(text: string): Inline {
return { text, role: 'comment' }
}
function range(node: import('../contract').Node): { startIndex: number, endIndex: number } {
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/** 名字、类型照抄源码,名字带上下标,点了能跳到定义。 */
export function plainSay(): RustSay {
export function plainSay(doc: RustSay['doc']): RustSay {
return {
name: (node, role) => [{ text: node.text, role, offset: node.startIndex }],
source: (node, role = 'type') => [{ text: oneLine(node.text), role, offset: node.startIndex }],
doc,
}
}
+5 -6
View File
@@ -1,4 +1,4 @@
import type { Access, BlockAttributes, Node } from '../contract'
import type { Access, BlockAttributes, Doc, Node } from '../contract'
/** 具名子节点,空的跳过。 */
export function named(node: Node | null | undefined): Node[] {
@@ -19,11 +19,10 @@ export function commentPlain(node: Node): string {
return raw.replace(/\n/g, ' ').trim()
}
/** 说明行用的第一句。没有文字时没有。 */
export function docSentence(node: Node | undefined): string | undefined {
if (!node)
return undefined
const sentence = firstSentence(commentPlain(node))
/** 说明行用的第一句:说明(翻译插件读出来的,块标签已经拿掉)正文的第一句,换行当空格。没有正文时没有。 */
export function docSummary(doc: Doc | undefined): string | undefined {
const text = doc?.body?.markdown.replace(/\s*\n\s*/g, ' ').trim()
const sentence = text && firstSentence(text)
return sentence || undefined
}
+13 -7
View File
@@ -1,7 +1,8 @@
import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor, TokenRole } from '../contract'
import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, TokenRole } from '../contract'
import type { rustVisibility } from './rustTree'
import { paramDocCell, paramDocs, unmatchedParamRows } from './docRows'
import { visibilityWord } from './rustRows'
import { attributeName, deriveNames, docSentence, itemAttributes, leadingAttributes, named, oneLine, stringContent, structFields } from './rustTree'
import { attributeName, deriveNames, docSummary, itemAttributes, leadingAttributes, named, oneLine, stringContent, structFields } from './rustTree'
/** 和结构体同一个青,序列化只是名称后的标签,不另换边框色。 */
const COLOR: ThemeColor = { dark: '#4ec9b0', light: '#267f99' }
@@ -17,7 +18,7 @@ export const serdeRule: LibraryRule = {
label: 'serde 序列化',
languageIds: ['rust'],
libraries: ['serde'],
claim: block => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block) : undefined,
claim: (block, context) => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'serde.struct': '可序列化' } } }),
}
@@ -25,30 +26,35 @@ function marked(block: ClassBlock): boolean {
return deriveNames(block.node).some(name => MARKS.has(name))
}
function card(block: ClassBlock): RuleCard {
function card(block: ClassBlock, context: RuleContext): RuleCard {
const fields = structFields(block.node)
const camel = renameAll(block.node) === 'camelCase'
const doc = docSentence(block.facts.doc)
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const described = fields.some(field => docs.of(field.name.text))
const rows: PropertyRow[] = [
{
label: '名称',
value: block.facts.name ? [...copied(block.facts.name, 'type'), plain(' '), tag('可序列化')] : [tag('可序列化')],
...(block.facts.name && { defines: range(block.facts.name) }),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...unmatchedParamRows(docs.unmatched),
{ label: '可见性', value: [{ text: visibilityWord(block), role: 'keyword' as const }] },
...(camel ? [{ label: '选项', value: [keyword('字段名改驼峰')] }] : []),
...(fields.length > 0
? [{
label: '字段',
value: [hint(`${fields.length} 个`)],
columns: ['字段', '类型', '可见性', '序列化'],
columns: ['字段', '类型', '可见性', '序列化', ...(described ? ['说明'] : [])],
items: fields.map(field => ({
cells: [
copied(field.name, 'variable'),
copied(field.type, 'type'),
[keyword(visibilityWordOf(field.visibility))],
[serdeCell(field.name)],
...(described ? [paramDocCell(docs, field.name.text)] : []),
],
defines: range(field.name),
})),
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { sqlalchemyRule } from './sqlalchemy'
@@ -38,7 +38,7 @@ async function model(source: string, name: string): Promise<BlockView> {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
+2 -2
View File
@@ -352,7 +352,7 @@ function columnItem({ member, call }: Column, env: Env): PropertyItem {
for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey'))
constraints.push(foreignKey(foreign, env))
const comment = keywords.get('comment')
const doc = member.doc && context.doc(member.doc)
const doc = member.doc && context.doc(member.doc)?.body
return {
cells: [
[name(member.name, 'variable')],
@@ -409,7 +409,7 @@ function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard {
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 doc = (comment && context.doc(comment)?.body) ?? (facts.doc && context.doc(facts.doc)?.body)
const indexes: PropertyItem[] = []
const uniques: PropertyItem[] = []
for (const item of items) {
+8 -9
View File
@@ -1,5 +1,5 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { commentPlain, firstSentence, macroInvocation, named, oneLine, stringContent } from './rustTree'
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import { docSummary, macroInvocation, named, oneLine, stringContent } from './rustTree'
/** 查询卡用字符串那种橙。 */
const COLOR: ThemeColor = { dark: '#ce9178', light: '#a31515' }
@@ -15,16 +15,16 @@ export const sqlxRule: LibraryRule = {
label: 'sqlx 查询',
languageIds: ['rust'],
libraries: ['sqlx'],
claim: (block) => {
claim: (block, context) => {
if (block.kind === 'comment')
return nextQuery(block.node) ? { omit: true } : undefined
const macro = block.kind === 'statement' ? findQuery(block.node) : undefined
return macro ? card(block, macro) : undefined
return macro ? card(block, macro, context) : undefined
},
attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }),
}
function card(block: CodeBlock, macro: Node): RuleCard {
function card(block: CodeBlock, macro: Node, context: RuleContext): RuleCard {
const call = macroInvocation(macro)
const args = call?.args ?? []
const typed = call?.name.split('::').pop() !== 'query'
@@ -32,7 +32,7 @@ function card(block: CodeBlock, macro: Node): RuleCard {
const sql = stringContent(sqlNode) ?? ''
const params = args.slice(typed ? 2 : 1)
const returned = typed ? args[0] : undefined
const note = precedingNote(block.node)
const note = precedingNote(block.node, context)
const rows: PropertyRow[] = [
...(note ? [{ label: '说明', value: { markdown: note } }] : []),
{ label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] },
@@ -65,14 +65,13 @@ function nextQuery(comment: Node): Node | undefined {
return next ? findQuery(next) : undefined
}
function precedingNote(node: Node): string | undefined {
function precedingNote(node: Node, context: RuleContext): string | undefined {
let prev = node.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment'))
return undefined
const sentence = firstSentence(commentPlain(prev))
return sentence || undefined
return docSummary(context.doc(prev))
}
function hint(text: string): Inline {
+19 -11
View File
@@ -1,5 +1,6 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, docSentence, oneLine, returnText } from './rustTree'
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor } from '../contract'
import { paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { attributeName, docSummary, oneLine, returnText } from './rustTree'
/** 桌面命令的边框用这个橙。 */
const COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
@@ -15,7 +16,7 @@ export const tauriRule: LibraryRule = {
label: 'tauri 桌面命令',
languageIds: ['rust'],
libraries: ['tauri'],
claim: block => block.kind === 'function' && commanded(block) ? card(block) : undefined,
claim: (block, context) => block.kind === 'function' && commanded(block) ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'tauri.command': '桌面命令' } } }),
}
@@ -23,8 +24,12 @@ function commanded(block: FunctionBlock): boolean {
return block.facts.decorators.some(attr => attributeName(attr) === 'tauri::command')
}
function card(block: FunctionBlock): RuleCard {
const doc = docSentence(block.facts.doc)
function card(block: FunctionBlock, context: RuleContext): RuleCard {
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const { parameters } = block.facts
const docs = paramDocs(doc, parameters.map(param => param.name.text))
const described = parameters.some(param => docs.of(param.name.text))
const rows: PropertyRow[] = [
{
label: '名称',
@@ -33,24 +38,27 @@ function card(block: FunctionBlock): RuleCard {
: [tag('桌面命令')],
...(block.facts.name && { defines: range(block.facts.name) }),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...unmatchedParamRows(docs.unmatched),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
...(block.facts.parameters.length > 0
...(parameters.length > 0
? [{
label: '参数',
value: [hint(`${block.facts.parameters.length} 个`)],
columns: ['名称', '类型', '来源'],
items: block.facts.parameters.map(param => ({
value: [hint(`${parameters.length} 个`)],
columns: ['名称', '类型', '来源', ...(described ? ['说明'] : [])],
items: parameters.map(param => ({
cells: [
[variable(param.name)],
[param.type ? typed(param.type) : hint('—')],
[hint(isWindowState(param.type) ? '窗口状态' : '调用方传入')],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
defines: range(param.name),
})),
}]
: []),
{ label: '返回', value: [plain(returnText(block.node))] },
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: 'tauri.command',
+6 -6
View File
@@ -1,5 +1,5 @@
import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, commentPlain, deriveNames, docSentence, firstSentence, named, stringContent, variantPayload } from './rustTree'
import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PreviewText, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, commentPlain, deriveNames, docSummary, firstSentence, named, stringContent, variantPayload } from './rustTree'
/** 错误卡用异常那种红。 */
const COLOR: ThemeColor = { dark: '#f14c4c', light: '#cd3131' }
@@ -14,12 +14,12 @@ export const thiserrorRule: LibraryRule = {
label: 'thiserror 错误',
languageIds: ['rust'],
libraries: ['thiserror'],
claim: block => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block) : undefined,
claim: (block, context) => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'thiserror.error': '错误' } } }),
}
function card(block: EnumBlock): RuleCard {
const doc = docSentence(block.facts.doc)
function card(block: EnumBlock, context: RuleContext): RuleCard {
const doc = docSummary(block.facts.doc && context.doc(block.facts.doc))
const rows: PropertyRow[] = [
{
label: '名称',
@@ -34,7 +34,7 @@ function card(block: EnumBlock): RuleCard {
items: block.facts.members.map((member) => {
const variant = member.name.parent?.type === 'enum_variant' ? member.name.parent : member.name
const display = errorText(variant)
const cells: Inline[][] = [
const cells: PreviewText[] = [
[{ text: member.name.text, role: 'type', offset: member.name.startIndex }],
[hint(whenText(variant) ?? '—')],
display
+9 -4
View File
@@ -66,18 +66,23 @@ export function previewText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : segmentText(text)
}
/** 预览文字里着色的词;Markdown 没有。 */
export function inlineOf(text: PreviewText): Inline[] {
return 'markdown' in text ? [] : text
}
/** 子行写成文字:各列用 ` | ` 隔开,下一层子行、排在最后一格里的表(PropertyItem.table)的各行比这一层多缩进两格。 */
function itemLines(items: PropertyItem[] | undefined, indent: string): string[] {
return (items ?? []).flatMap(item => [
`${indent}${item.cells.map(segmentText).join(' | ')}`,
`${indent}${item.cells.map(previewText).join(' | ')}`,
...itemLines(item.items, `${indent} `),
...itemLines(item.table?.items, `${indent} `),
])
}
/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格(见 itemLines)。 */
/** 属性表写成文字:每行 `名称: 值`(跟着说明的写成 `值 — 说明`),展开的子行比上一层多缩进两格(见 itemLines)。 */
export function propertyLines(rows: PropertyRow[]): string[] {
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')])
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}${row.note ? ` — ${row.note.markdown}` : ''}`, ...itemLines(row.items, ' ')])
}
/** 一行译文下面那张表写成文字,同 propertyLines 的子行,第一层不缩进。 */
@@ -93,7 +98,7 @@ export function tableLines(table: PreviewTable | undefined): string[] {
export function viewLines(blocks: readonly BlockView[], indent = ''): string[] {
const value = (text: PreviewText): string => 'markdown' in text ? text.markdown.split('\n')[0] : segmentText(text)
const items = (list: PropertyItem[] | undefined, pad: string): string[] => (list ?? []).flatMap(item => [
pad + item.cells.map(segmentText).join(' | ').replace(/\n/g, ' ⏎ '),
pad + item.cells.map(previewText).join(' | ').replace(/\n/g, ' ⏎ '),
...items(item.items, `${pad} `),
...items(item.table?.items, `${pad} `),
])
+13 -2
View File
@@ -3,8 +3,6 @@ import { attributeRows, enumRows, functionRows, implRows, importRows, moduleRows
import { isCrateRoot } from '../rules/rustTree'
import { createZhTemplateTranslator, KIND_ZH, ZH_ATTRIBUTE_NAMES } from './zhTemplate'
const say = plainSay()
const template = createZhTemplateTranslator({
id: 'rust.zh-template',
label: '中文直译',
@@ -55,6 +53,7 @@ const template = createZhTemplateTranslator({
mapLiterals: {},
stringNodes: ['string_literal'],
commentText,
docNodes,
nodeTemplates: {},
})
@@ -82,6 +81,7 @@ export const rustZhTranslator: Translator = {
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
const say = plainSay(node => template.doc(node, document))
switch (block.kind) {
case 'import':
return importRows(block, say)
@@ -130,6 +130,17 @@ function original(node: Node, span: SourceSpan, { source, language }: SourceDocu
return out
}
/**
* 一项的文档注释:`///` 一行一个节点,从第一条起往下连着写的(下一行紧跟着另一条 `///`)都算这一段;块注释本来就是一个节点。
*/
function docNodes(first: Node): Node[] {
const outer = (node: Node): boolean => node.type === 'line_comment' && node.namedChildren.some(child => child?.type === 'outer_doc_comment_marker')
const nodes = [first]
for (let next = first.nextSibling; outer(first) && next && outer(next) && next.startPosition.row === nodes.at(-1)!.startPosition.row + 1; next = next.nextSibling)
nodes.push(next)
return nodes
}
/** 行注释去掉 `//`(文档注释连 `/`、`!`)和后面一个空格,缩进留给 Markdown;块注释去掉开头结尾的注释符号。 */
function commentText(node: Node, text: string): string {
if (node.type === 'line_comment')
@@ -1,9 +1,9 @@
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract'
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract'
import type { ZhTable } from './zhTemplate'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, propertyLines, readFixture, segmentText, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from './pythonZh'
import { typescriptZhTranslator } from './typescriptZh'
import { createZhTemplateTranslator } from './zhTemplate'
@@ -58,14 +58,14 @@ function valueOf(rows: PropertyRow[] | undefined, label: string): Inline[] {
return value
}
/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`。 */
function text(segments: Inline[] | undefined): string {
return segmentText(segments ?? [])
/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`;Markdown 取原文。 */
function text(segments: PreviewText | undefined): string {
return previewText(segments ?? [])
}
/** 带下标的段(框里的也算),按出现顺序写成 [文字, 下标]。 */
function linked(segments: Inline[] | undefined): [string, number][] {
return flatSegments(segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
function linked(segments: PreviewText | undefined): [string, number][] {
return flatSegments(inlineOf(segments ?? [])).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
}
function texts(map: Map<number, Translated>): Record<number, string> {
@@ -578,7 +578,7 @@ describe('属性表', () => {
])
})
it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行和 @ 标签行;没有文字不显示', async () => {
it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行;块标签拆到参数表、输出参数后面;没有文字不显示', async () => {
const tsSource = [
'class K {',
' /**',
@@ -595,7 +595,8 @@ describe('属性表', () => {
'}',
].join('\n')
const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource))
expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')\n@param path 文件路径\n@returns 配置', '访问级别: 公共'])
expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')', '访问级别: 公共'])
expect(tsRows[2].slice(-3)).toEqual(['输入参数: 1 个', ' path | 字符串 | | 文件路径', '输出参数: 未标注 — 配置'])
expect(tsRows[11].slice(0, 2)).toEqual(['名称: empty', '访问级别: 公共'])
const pySource = [
@@ -615,7 +616,7 @@ describe('属性表', () => {
expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共'])
})
it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、JSDoc 标签、HTML 照抄', async () => {
it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、{@link}、HTML 照抄,块标签不进正文', async () => {
const pySource = [
'"""手机号规范化 / 脱敏工具.',
'',
@@ -662,7 +663,7 @@ describe('属性表', () => {
expect(tsText[3]).toBe('单行块注释')
const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource)
expect(tsRows.get(4)?.find(row => row.label === '说明')?.value).toEqual({
markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```\n@param path 文件路径',
markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```',
})
})
@@ -854,12 +855,12 @@ describe('属性表', () => {
})
describe('源码下标(跳到定义用)', () => {
function itemCells(rows: PropertyRow[] | undefined, label: string): Inline[][] {
function itemCells(rows: PropertyRow[] | undefined, label: string): PreviewText[] {
return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? []
}
function propertySegments(rows: PropertyRow[]): Segment[] {
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flat()), ...itemSegments(item.items)])
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flatMap(inlineOf)), ...itemSegments(item.items)])
return rows.flatMap(row => [...('markdown' in row.value ? [] : flatSegments(row.value)), ...itemSegments(row.items)])
}
@@ -1033,6 +1034,82 @@ describe('源码下标(跳到定义用)', () => {
})
})
describe('说明里的块标签', () => {
it('render_conversation_brief:@param 进输入参数表的说明列,@return 跟在输出参数后面,说明里只剩正文', async () => {
const rows = await describeAll(py, 'python', pythonZhTranslator, readFixture('conversationBrief.py'))
const lines = rowTexts(rows)[25]
expect(lines.find(line => line.startsWith('说明'))).toBe('说明: 把对话转写成「前情摘要 + 近期消息」一段文字; 什么都没有时返回空串.')
expect(lines.join('\n')).not.toMatch(/@param|@return/)
const params = rows.get(25)?.find(row => row.label === '输入参数')
expect(params?.columns).toEqual(['名称', '类型', '默认值', '说明'])
expect(params?.items?.map(item => [text(item.cells[0]), text(item.cells[3])])).toEqual([
['messages', '对话历史 (`messages` 状态).'],
['summary', '更早对话的压缩摘要, 没有就传 `None`.'],
['speakers', '转写谁说的话: [USER] / [ASSISTANT]; 只要用户原话时只传 [USER].'],
['max_lines', '近期消息最多留几行, 多了从头丢弃并注明.'],
])
expect(params?.items?.[3].cells[3]).toEqual({ markdown: '近期消息最多留几行, 多了从头丢弃并注明.' })
const output = rows.get(25)?.find(row => row.label === '输出参数')
expect(output?.value).toEqual([{ text: '字符串', role: 'type' }])
expect(output?.note).toEqual({ markdown: '以 `[会话进展]` 开头的转写.' })
expect(lines.some(line => line.startsWith('对不上的参数'))).toBe(false)
})
it('gate.py:@throws 成「异常」表,第一行带完整链接,第二行是不带路径的引用', async () => {
const rows = await describeAll(py, 'python', pythonZhTranslator, readFixture('localRuntimeGate.py'))
const lines = rowTexts(rows)[18]
expect(lines.join('\n')).not.toMatch(/@throws/)
expect(lines.slice(-3)).toEqual([
'异常: 2 个',
' [DesktopOnly](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#DesktopOnly) | 请求不是桌面端发来的',
' [LocalRuntimeDisabled] | 总开关关着',
])
const throws = rows.get(18)?.find(row => row.label === '异常')
expect(throws?.columns).toEqual(['异常', '触发条件'])
expect(throws?.items?.[1].cells).toEqual([{ markdown: '[LocalRuntimeDisabled]' }, { markdown: '总开关关着' }])
})
it('名字对不上的 @param 排在说明下面,提示色,悬停「没有这个参数」;没有对上的参数时参数表不加说明列', async () => {
const source = [
'/**',
' * 读文件。',
' * @param pth 路径',
' * @throws Error 读不到',
' */',
'export function read(path: string): string { return \'\' }',
].join('\n')
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
const lines = rowTexts(rows)[1]
expect(lines.slice(0, 4)).toEqual(['名称: read', '说明: 读文件。', '对不上的参数: 1 个', ' pth | 路径'])
const unmatched = rows.get(1)?.find(row => row.label === '对不上的参数')
expect(unmatched?.items?.[0].cells[0]).toEqual([{ text: 'pth', role: 'hint', tooltip: '没有这个参数' }])
expect(rows.get(1)?.find(row => row.label === '输入参数')?.columns).toEqual(['名称', '类型', '默认值'])
expect(lines.slice(-2)).toEqual(['异常: 1 个', ' [Error] | 读不到'])
})
it('类:没有构造参数表可对,@param 照写成「构造参数」表;异常类的 @param 对到构造参数表,*args 去掉星号对名字', async () => {
const source = [
'class K:',
' """类。',
'',
' @param a 甲',
' """',
'',
'class E(Exception):',
' """出错了。',
'',
' @param code 错误码',
' @param args 其余参数',
' """',
' def __init__(self, code: int, *args):',
' pass',
].join('\n')
const lines = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))
expect(lines[1]).toEqual(['名称: K', '说明: 类。', '访问级别: 公共', '构造参数: 1 个', ' a | 甲'])
expect(lines[7].slice(-3)).toEqual(['构造参数: 2 个', ' code | 整数 | | 错误码', ' *args | 未标注 | | 其余参数'])
})
})
describe('跨几行写的调用', () => {
const routes = readFixture('routes.py')
const calls = readFixture('calls.ts.txt')
@@ -1189,10 +1266,10 @@ describe('跨几行写的调用', () => {
expect(asked).toEqual(expect.arrayContaining(['PaymentCapabilityDiagnostic', 'CredentialPresence']))
const nested = line?.table?.items[6]
expect(nested?.table?.columns).toEqual(['参数', '值'])
expect(nested?.cells[1].find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) })
expect(inlineOf(nested?.cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) })
expect(nested?.table?.items[0].cells[0]).toEqual([{ text: 'mp_app_secret', role: 'variable', offset: payment.indexOf('mp_app_secret=') }])
const unresolved = (await translateAll(py, 'python', pythonZhTranslator, payment)).get(64)
expect(segmentText(unresolved?.table?.items[6].cells[1] ?? [])).toBe('调用 CredentialPresence · 4 个参数')
expect(text(unresolved?.table?.items[6].cells[1])).toBe('调用 CredentialPresence · 4 个参数')
})
it('嵌套一层层往下套;await 的调用写「等待」', async () => {
+64 -34
View File
@@ -1,5 +1,8 @@
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { ParamDocs } from '../rules/docRows'
import { readDoc } from '../docTags'
import { commonIndent, splitLines } from '../editText'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows, writtenParamRows } from '../rules/docRows'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
export interface ContextualWord {
@@ -46,6 +49,11 @@ export interface ZhTable {
stringNodes: string[]
/** 注释(或 docstring)节点去掉注释符号后的原文,可以多行;text 是节点的源码去掉代码阅读的记号(SourceDocument.markers)以后剩下的。 */
commentText: (node: Node, text: string) => string
/**
* 说明是连着几条行注释写的(Rust 的 `///`)时,从第一条(facts.doc)起这一段的各条注释节点,照源码顺序;
* 不填就是那一个节点(JSDoc、docstring 本来就是一个节点)。
*/
docNodes?: (first: Node) => Node[]
/**
* 节点模板:节点类型 → 按中文语序重排的译文。命中的节点只输出模板里的译词和槽位,节点自己的关键字、标点不再输出;
* 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。
@@ -661,9 +669,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return joinTokens(tokensIn(node, nodeSpan(node), document))
}
function doc(node: Node, document: SourceDocument): Markdown | undefined {
const markdown = docText([commentOf(node, document)])
return markdown ? { markdown } : undefined
function doc(node: Node, document: SourceDocument): Doc | undefined {
const markdown = docText((table.docNodes?.(node) ?? [node]).map(part => commentOf(part, document)))
return markdown ? readDoc(markdown) : undefined
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
@@ -817,8 +825,8 @@ interface Phrases {
module: (node: Node) => Segment[]
/** 源码里的一个名字原样照抄、按 role 着色(函数、类的名称)。 */
name: (node: Node, role: TokenRole) => Segment[]
/** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文拆成正文和块标签;没有文字时没有。 */
doc: (node: Node) => Doc | undefined
/** 一条要执行的命令:数组写法交给命令规则读,shell 写法是宿主读好的嵌着的代码(SourceDocument.embedded),可以带一张选项表。 */
command: (form: CommandForm) => { text: Inline[], table?: PreviewTable }
}
@@ -907,38 +915,50 @@ function aliased(item: Inline[], alias: Node | undefined, say: Phrases): Inline[
}
/**
* 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数 / 输出参数。
* 输入参数是一张表,列名是名称 / 类型 / 默认值,每个参数一行;没有默认值的格子留空。
* 函数:名称 / 说明 / 对不上的参数 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数 / 输出参数 / 异常。
* 输入参数是一张表,列名是名称 / 类型 / 默认值,每个参数一行;没有默认值的格子留空。说明里的块标签拆开放:
* `@param` 进输入参数表多出的「说明」列(有参数对上才有这一列),名字对不上的排在说明下面;`@return` 跟在输出参数的类型后面;
* `@throws` 是「异常」一行。
*/
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
const doc = facts.doc && say.doc(facts.doc)
const names = facts.positional ? facts.positional.flatMap(param => param.name ? [param.name.text] : []) : facts.parameters.map(param => param.name.text)
const docs = paramDocs(doc, names)
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...unmatchedParamRows(docs.unmatched),
...accessRows(facts.access),
...flagRows('async', facts.async),
...decoratorRows(facts.decorators, say),
...(facts.positional
? positionalRows(facts, facts.positional, say)
? positionalRows(facts, facts.positional, say, docs, doc)
: [
parameterRow('输入参数', facts.parameters, say),
{ label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] },
parameterRow('输入参数', facts.parameters, say, docs),
{ label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')], ...returnNote(doc) },
]),
...throwsRows(doc),
]
}
/**
* 按位置取参数的函数(shell):输入参数写「N 个,按位置」,下面一张表「位置 | 函数里叫」(没起名的写 $N);
* 按位置取参数的函数(shell):输入参数写「N 个,按位置」,下面一张表「位置 | 函数里叫」(没起名的写 $N;`@param` 按起的名字对上时多一列说明);
* 输出是它打印的那几句(调用处用 $(函数 …) 拿去用时注明);返回是写了的 return 值,没写 return、也不打印时是最后一句成不成功。
*/
function positionalRows(facts: FunctionFacts, positional: PositionalParameter[], say: Phrases): PropertyRow[] {
function positionalRows(facts: FunctionFacts, positional: PositionalParameter[], say: Phrases, docs: ParamDocs, doc: Doc | undefined): PropertyRow[] {
const described = positional.some(param => param.name && docs.of(param.name.text))
const inputs: PropertyRow = positional.length === 0
? { label: '输入参数', value: [hint('无')] }
: {
label: '输入参数',
value: [hint(`${positional.length} 个,按位置`)],
columns: ['位置', '函数里叫'],
columns: described ? ['位置', '函数里叫', '说明'] : ['位置', '函数里叫'],
items: positional.map(param => ({
cells: [[hint(`第 ${param.position} 个`)], param.name ? say.name(param.name, 'variable') : [segment(`$${param.position}`, 'variable', undefined)]],
cells: [
[hint(`第 ${param.position} 个`)],
param.name ? say.name(param.name, 'variable') : [segment(`$${param.position}`, 'variable', undefined)],
...(described ? [param.name ? paramDocCell(docs, param.name.text) : []] : []),
],
...defining(param.name),
})),
}
@@ -950,7 +970,9 @@ function positionalRows(facts: FunctionFacts, positional: PositionalParameter[],
const result: PropertyRow[] = values.length > 0
? [{ label: '返回', value: joined(values, '、') }]
: prints.length === 0 ? [{ label: '返回', value: [hint('最后一句成不成功')] }] : []
return [inputs, ...output, ...result]
// `@return` 跟在返回那一行后面;只有打印、没写 return 时跟在输出那一行后面
const [noted] = [...result, ...output]
return [inputs, ...[...output, ...result].map(row => row === noted ? { ...row, ...returnNote(doc) } : row)]
}
/** 解释器:叫什么,怎么找到它(按 PATH 找,或写死的路径)。 */
@@ -991,7 +1013,7 @@ function flagValue(flag: InstructionFlag): Inline[] {
* 接着前面某个阶段建的写「阶段 X」)/ 平台(写了 --platform 才有)。
*/
function stageRows(facts: StageFacts, say: Phrases): PropertyRow[] {
const doc = (facts.doc ?? []).map(node => say.doc(node)?.markdown).filter(Boolean).join('\n')
const doc = (facts.doc ?? []).map(node => say.doc(node)?.body?.markdown).filter(Boolean).join('\n')
const platform = facts.flags.find(flag => flag.name === 'platform')
const image: Inline[] = facts.stage
? [segment('阶段', 'keyword', undefined), segment(' ', 'plain', undefined), ...say.name(facts.image, 'variable')]
@@ -1078,17 +1100,22 @@ function scopedVariableRows(declarators: DeclaratorFacts[], scope: 'build' | 'en
}]
}
/** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */
function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases): PropertyRow {
/**
* 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。
* 说明里有参数对上了(docs)时多一列「说明」,没对上的格子留空。
*/
function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases, docs: ParamDocs): PropertyRow {
const described = parameters.some(param => docs.of(param.name.text))
return {
label,
value: count(parameters.length),
columns: ['名称', '类型', '默认值'],
columns: described ? ['名称', '类型', '默认值', '说明'] : ['名称', '类型', '默认值'],
items: parameters.map(param => ({
cells: [
param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name),
param.type ? say.translated(param.type) : [hint('未标注')],
param.default ? say.translated(param.default) : [],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
...defining(param.name),
})),
@@ -1098,19 +1125,23 @@ function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases)
/**
* 类:名称 / 说明 / 访问级别 / 抽象(只有分这个的语言才有)/ 装饰器 / 类型参数 / 父类 / 实现的接口,
* 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。
* 类没有构造参数表可对,说明里的 `@param` 照写成「构造参数」一张「名称 | 说明」表;`@throws` 是「异常」一行。
*/
function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
const listRow = (label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] =>
parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : []
const doc = facts.doc && say.doc(facts.doc)
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...accessRows(facts.access),
...flagRows('abstract', facts.abstract),
...decoratorRows(facts.decorators, say),
...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)),
...listRow('父类', facts.bases.map(base => baseText(base, say))),
...listRow('实现的接口', facts.implements.map(say.translated)),
...writtenParamRows(doc?.params ?? [], '构造参数'),
...throwsRows(doc),
]
}
@@ -1129,7 +1160,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
const described = docs.some(Boolean)
return [
{ label: '名称', value: say.name(facts.name, 'type') },
...docRows(facts.doc, say),
...docBodyRows(facts.doc && say.doc(facts.doc)),
...accessRows(facts.access),
...decoratorRows(facts.decorators, say),
...baseRows(facts.bases, say),
@@ -1153,6 +1184,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
* 异常(Python、TypeScript 同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),说明及以后有才显示;
* 类属性收成一张表:属性 | 类型 | 值 | 说明,一个属性都没写类型时没有「类型」列、都没有说明时没有「说明」列,没写的格子留空;
* 构造参数同函数的输入参数表。没有类属性、没写构造函数时没有这两行。
* 说明里的 `@param` 对到构造参数表(同函数的输入参数,对不上的排在说明下面;没写构造函数时同类,照写成一张表),`@throws` 是「异常」一行。
*/
function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] {
const docs = facts.fields.map(field => docsText(field.docs, say))
@@ -1172,14 +1204,18 @@ function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] {
...defining(field.name),
})),
}
const doc = facts.doc && say.doc(facts.doc)
const params = facts.parameters && paramDocs(doc, facts.parameters.map(param => param.name.text))
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...unmatchedParamRows(params?.unmatched ?? []),
...accessRows(facts.access),
...decoratorRows(facts.decorators, say),
...baseRows(facts.bases, say),
...(facts.fields.length > 0 ? [fields] : []),
...(facts.parameters ? [parameterRow('构造参数', facts.parameters, say)] : []),
...(facts.parameters && params ? [parameterRow('构造参数', facts.parameters, say, params)] : writtenParamRows(doc?.params ?? [], '构造参数')),
...throwsRows(doc),
]
}
@@ -1188,21 +1224,15 @@ function baseRows(bases: BaseFacts[], say: Phrases): PropertyRow[] {
return bases.length > 0 ? [{ label: '继承', value: joined(bases.map(base => baseText(base, say)), ', ') }] : []
}
/** 表格里一格说明:几段说明文字的原文用空格连起来。 */
/** 表格里一格说明:几段说明文字的正文用空格连起来。 */
function docsText(nodes: Node[], say: Phrases): string {
return nodes.map(node => say.doc(node)?.markdown).filter(Boolean).join(' ')
return nodes.map(node => say.doc(node)?.body?.markdown).filter(Boolean).join(' ')
}
function docCell(text: string): Inline[] {
return text ? [segment(text, 'comment', undefined)] : []
}
/** 函数、类、常量、变量的说明:有文字才有这一行。 */
function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] {
const markdown = doc && say.doc(doc)
return markdown ? [{ label: '说明', value: markdown }] : []
}
/** 装饰器:源码原样照抄,多个换行。 */
function decoratorRows(decorators: Node[], say: Phrases): PropertyRow[] {
return decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators.map(say.verbatim), '\n') }] : []
@@ -1238,7 +1268,7 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
const valueLabel = block.kind === 'const' ? '值' : '初始值'
return block.facts.declarators.flatMap((declarator, i) => [
{ label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) },
...(i === 0 ? docRows(block.facts.doc, say) : []),
...(i === 0 ? docBodyRows(block.facts.doc && say.doc(block.facts.doc)) : []),
...accessRows(block.facts.access),
...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []),
{ label: valueLabel, ...(declarator.value ? declaredValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },
@@ -1,7 +1,8 @@
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { BlockView, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
import type { BlockView, ItemDiff, ItemsDiff, KindBadge, PreviewText, PropertyItem, PropertyRow, Removed, RowDiff, RowsDiff, TextDiff } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import { cn } from '@/lib/utils'
import { DiffInline, DiffText } from './DiffText'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
@@ -51,23 +52,51 @@ function removedAt<T>(removed: readonly Removed<T>[] | undefined, after: number)
return (removed ?? []).flatMap((entry, index) => entry.after === after ? [{ value: entry.value, index }] : [])
}
/** 一格的字:这一格的值改了就逐截标(DiffInline),否则照常。 */
function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undefined }): ReactElement {
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
/** 一格是不是成块排的 Markdown 说明(参数的说明、异常的触发条件):它不能放进 .cr-code 的段落里。 */
function isBlock(cell: PreviewText, parts: TextDiff | undefined): boolean {
return 'markdown' in cell || (parts !== undefined && 'markdown' in parts)
}
/** 一格的字:着色的词照常,改了的逐截标(DiffInline);Markdown 说明照常排版,改了的逐词标(DiffText)。 */
function CellText({ cell, parts }: { cell: PreviewText, parts: TextDiff | undefined }): ReactElement {
if (parts)
return 'inline' in parts ? <DiffInline parts={parts.inline} /> : <DiffText diff={parts} className="min-w-0" />
return 'markdown' in cell ? <TextPreview text={cell} className="min-w-0" /> : <SegmentText segments={cell} />
}
/** 一格连外面那层:着色的词排在 .cr-code 的段落里,Markdown 说明是块,lead(差异记号、展开按钮)排在它左边。 */
function CellBox({ cell, parts, className, lead, ...attrs }: { cell: PreviewText, parts: TextDiff | undefined, className?: string, lead?: ReactNode, role?: string }): ReactElement {
if (isBlock(cell, parts)) {
return (
<div className={cn('flex min-w-0 items-start gap-1', className)} {...attrs}>
{lead}
<CellText cell={cell} parts={parts} />
</div>
)
}
return (
<p className={cn('cr-code min-w-0', className)} {...attrs}>
{lead}
<CellText cell={cell} parts={parts} />
</p>
)
}
/** 格子写成一串字(读屏念的展开 / 折叠按钮名):着色的词拼起来,Markdown 取原文。 */
function cellLabel(cell: PreviewText): string {
return 'markdown' in cell ? cell.markdown : inlineText(cell)
}
/**
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
*/
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], foldable: boolean, folding: Folding }): ReactElement {
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: PreviewText, foldable: boolean, folding: Folding }): ReactElement {
if (!foldable) {
return (
<div className="flex min-w-0 gap-1">
<span className="w-3.5 shrink-0" />
<p className="cr-code min-w-0">
<SegmentText segments={cell} />
</p>
<CellBox cell={cell} parts={undefined} />
</div>
)
}
@@ -80,15 +109,15 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[],
<button
type="button"
className="flex h-5 w-3.5 shrink-0 items-center rounded-sm"
aria-label={`${folded ? '展开' : '折叠'}${inlineText(cell)}`}
aria-label={`${folded ? '展开' : '折叠'}${cellLabel(cell)}`}
aria-expanded={!folded}
onClick={toggle}
>
<Chevron size={14} strokeWidth={1.8} className="text-[var(--vscode-descriptionForeground)] group-hover:text-[var(--vscode-foreground)]" />
</button>
<p className="cr-code min-w-0 cursor-pointer" onClick={toggle}>
<SegmentText segments={cell} />
</p>
<div className="min-w-0 cursor-pointer" onClick={toggle}>
<CellBox cell={cell} parts={undefined} />
</div>
</div>
)
}
@@ -114,11 +143,7 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
>
{item.cells.map((cell, i) => i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
: (
<p key={i} className="cr-code min-w-0">
<CellText cell={cell} parts={mark?.cells?.[i]} />
</p>
))}
: <CellBox key={i} cell={cell} parts={mark?.cells?.[i]} />)}
</div>
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
? (
@@ -171,18 +196,18 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
/** 一行:删掉的(change 是 del)不带 id,套着的表不再标;toggle 是这一行讲的子块,第一格成了展开它的按钮 */
const tableRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined, toggle?: ItemToggle): ReactElement => {
const cells = tableCells(item, columns)
const text = (cell: Inline[], i: number): ReactElement => (
<>
{signed && i === 0 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>}
{toggle && i === 0
const sign = (i: number): ReactNode => signed && i === 0 && <span className="cr-diff-sign" data-change={change} aria-hidden>{SIGNS[change ?? 'none']}</span>
/** 一格:讲子块的那一格是展开它的按钮(只放着色的词),别的照常(见 CellBox) */
const cellBox = (cell: PreviewText, i: number, className?: string): ReactElement => toggle && i === 0
? (
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${inlineText(cell)}`} onClick={toggle.onToggle}>
<p className={cn('cr-code min-w-0', className)} role="cell">
{sign(i)}
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${cellLabel(cell)}`} onClick={toggle.onToggle}>
<CellText cell={cell} parts={mark?.cells?.[i]} />
</button>
</p>
)
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
</>
)
: <CellBox cell={cell} parts={mark?.cells?.[i]} className={className} lead={sign(i)} role="cell" />
const tint = toggle?.open && toggle.badge
? { 'data-badge': '', 'style': { '--kind-dark': toggle.badge.color.dark, '--kind-light': toggle.badge.color.light } as CSSProperties }
: {}
@@ -191,11 +216,11 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
{cells.map((cell, i) => item.table && i === cells.length - 1
? (
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
<p className="cr-code">{text(cell, i)}</p>
<CellBox cell={cell} parts={mark?.cells?.[i]} lead={sign(i)} />
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} />
</div>
)
: <p key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</p>)}
: <Fragment key={i}>{cellBox(cell, i, 'cr-table-cell')}</Fragment>)}
</div>
)
}
@@ -271,7 +296,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
</button>
)
: <span className="cr-prop-label flex h-5 items-center">{row.label}</span>}
{mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />}
<RowValue row={row} mark={mark} />
</div>
{!folded && layout === 'table' && <PropertyTable parentId={id} columns={row.columns ?? []} items={row.items ?? []} marks={mark?.items} toggles={toggles} />}
{!folded && layout === 'list' && <PropertyItems parentId={id} items={row.items ?? []} folding={{ collapsed, onToggleFold }} marks={mark?.items} />}
@@ -284,6 +309,22 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl
)
}
/**
* 一行的值:着色的词或 Markdown(改了的逐截 / 逐词标);跟着说明(PropertyRow.note,输出参数的 `@return`)时说明接在值后面,
* 同一行放得下就并排,剩的地方不到 12 个字宽时换到下一行。
*/
function RowValue({ row, mark }: { row: PropertyRow, mark: RowDiff | undefined }): ReactElement {
const value = mark?.value ? <DiffText diff={mark.value} className="min-w-0" /> : <TextPreview text={row.value} className="min-w-0" />
if (!row.note)
return value
return (
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
{value}
{mark?.note ? <DiffText diff={mark.note} className="min-w-0 flex-[1_1_12em]" /> : <TextPreview text={row.note} className="min-w-0 flex-[1_1_12em]" />}
</div>
)
}
/** 属性表里删掉的一行:整行淡红底、划掉,左边 −;它下面的子行照样摊开、划掉。 */
function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement {
const layout = subRowsLayout(row)
@@ -291,7 +332,7 @@ function RemovedRow({ id, row }: { id: string, row: PropertyRow }): ReactElement
<>
<div className="cr-prop-row grid grid-cols-subgrid" data-diff="del">
<span className="cr-prop-label flex h-5 items-center">{row.label}</span>
<TextPreview text={row.value} className="min-w-0" />
<RowValue row={row} mark={undefined} />
</div>
{layout && (
<div className="cr-diff-gone">
@@ -11,6 +11,7 @@ import { HighlightContext } from './useOccurrences'
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带源码下标的名字记在 data-offset 上
* (讲别处名字的段记那个名字的下标,见 sourceOf),跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时
* 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。
* 带悬停提示(tooltip)的段悬停看它。
* 框起来的一组词画成小框,框里的词同样处理。
*/
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
@@ -31,7 +32,7 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
data-current={highlight && isCurrent(piece, highlight) ? '' : undefined}
data-name={shown?.name}
data-tooltip={shown && `原名 ${piece.text}`}
data-tooltip={shown ? `原名 ${piece.text}` : piece.tooltip}
>
{shown ? shown.name : piece.text}
{shown?.definition && <span className="cr-original">{piece.text}</span>}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { Inline, PropertyItem, PropertyRow } from './types'
import type { PreviewText, PropertyItem, PropertyRow } from './types'
/**
* 一行下面展开的子行怎么排:翻译插件给了列名的(输入参数)排成带表头和边框的表格,没给的(导入项、字典 / 对象的各项)排成列表;
@@ -21,6 +21,6 @@ export function tableTemplate(count: number): string {
}
/** 表格里一个子行的格子:按列名的列数取,子行少给的格子留空、多给的不显示。 */
export function tableCells(item: PropertyItem, columns: string[]): Inline[][] {
export function tableCells(item: PropertyItem, columns: string[]): PreviewText[] {
return columns.map((_, i) => item.cells[i] ?? [])
}
+13 -6
View File
@@ -23,8 +23,9 @@ export type TokenRole
* reference:库规则自己查定义的名字(Celery 的任务名)才有;webview 不看它,照样只报 offset,扩展端按 offset 认出来交给规则查。
* color:这段自己的颜色(库规则按库自己的配色给的,例如 HTTP 方法),盖过词性的颜色,按主题挑暗色、亮色。
* refers:这段讲的是源码里别处的一个名字(写在字符串里的名字讲的参数):跳到定义、高亮各处出现都当它是那个名字,见 sourceOf
* tooltip:悬停这段时看的字(说明里写了、参数表里没有的参数名:「没有这个参数」)
*/
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange, tooltip?: string }
/** 规则自己查得到定义的一个名字:哪个规则、规则自己认的键 */
export interface RuleReference { rule: string, key: string }
@@ -51,7 +52,10 @@ export interface SegmentGroup {
/** 译文里的一段:着色的词,或框起来的一组词 */
export type Inline = Segment | SegmentGroup
/** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */
/**
* 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块;
* 不带路径的引用(`[名字]`、`[类.成员]`)问扩展端它指的定义,问到了能点
*/
export interface Markdown { markdown: string }
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明) */
@@ -66,9 +70,9 @@ export interface SourceRange { startIndex: number, endIndex: number }
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值;格子是着色的词或 Markdown(参数的说明、异常的触发条件) */
export interface PropertyItem extends Defines {
cells: Inline[][]
cells: PreviewText[]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
items?: PropertyItem[]
/** 排在这一行最后一格里、那格文字下面的表(实参的值里跨几行写的调用的实参表),表里的行又可以带 table;总是展开 */
@@ -82,6 +86,8 @@ export interface PropertyItem extends Defines {
export interface PropertyRow extends Defines {
label: string
value: PreviewText
/** 跟在值后面的一段说明(Markdown,注释色):函数输出参数那一行,返回类型后面是说明里的 `@return` */
note?: Markdown
items?: PropertyItem[]
columns?: string[]
}
@@ -324,7 +330,7 @@ export interface ItemsDiff {
/** 一个子行的改动:整行新加的(它下面各层也都是),或按列标的格子、下一层、套着的表 */
export interface ItemDiff {
added?: true
cells?: Record<number, InlinePart[]>
cells?: Record<number, TextDiff>
items?: ItemsDiff
table?: ItemsDiff
}
@@ -335,10 +341,11 @@ export interface RowsDiff {
removed: Removed<PropertyRow>[]
}
/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、子行改了 */
/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、跟在值后面的说明、子行改了 */
export interface RowDiff {
added?: true
value?: TextDiff
note?: TextDiff
items?: ItemsDiff
}