diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index cb6c391..6b5ede3 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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 限定为 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 限定为 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 }) => {text}`)没有子块可放,它渲染的大纲作为第四行「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` 写 `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` 写 `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` 或又按键、点鼠标不守。 diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index e0795f0..e5cc94c 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -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)。 */ diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index c92056e..86bdc7c 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -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 认出的某一项的名字上才算;同一份源码里同名的名字按一处查。 diff --git a/vscode/src/codeReader/diff/viewDiff.ts b/vscode/src/codeReader/diff/viewDiff.ts index 77ba18a..b75bec9 100644 --- a/vscode/src/codeReader/diff/viewDiff.ts +++ b/vscode/src/codeReader/diff/viewDiff.ts @@ -33,7 +33,7 @@ export interface ItemDiff { /** 整行是新加的(它下面各层也都是) */ added?: true /** 按列:这一格改成了什么样 */ - cells?: Record + cells?: Record 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 = {} + const cells: Record = {} 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 = {} 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。 */ diff --git a/vscode/src/codeReader/docTags.test.ts b/vscode/src/codeReader/docTags.test.ts new file mode 100644 index 0000000..8fb2f8f --- /dev/null +++ b/vscode/src/codeReader/docTags.test.ts @@ -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} 配置', + '@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二' }) + }) +}) diff --git a/vscode/src/codeReader/docTags.ts b/vscode/src/codeReader/docTags.ts new file mode 100644 index 0000000..fb0755b --- /dev/null +++ b/vscode/src/codeReader/docTags.ts @@ -0,0 +1,137 @@ +import type { Doc, DocParam, DocThrows, Markdown } from './contract' + +type TagKind = 'param' | 'return' | 'throws' + +/** 认的块标签:`@returns` 同 `@return`,`@exception` 同 `@throws`。 */ +const TAGS: Record = { 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) ?? [] : []), + } +} diff --git a/vscode/src/codeReader/fixtures/conversationBrief.py b/vscode/src/codeReader/fixtures/conversationBrief.py new file mode 100644 index 0000000..2dc8f8a --- /dev/null +++ b/vscode/src/codeReader/fixtures/conversationBrief.py @@ -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) diff --git a/vscode/src/codeReader/fixtures/localRuntimeGate.py b/vscode/src/codeReader/fixtures/localRuntimeGate.py new file mode 100644 index 0000000..1ec0eea --- /dev/null +++ b/vscode/src/codeReader/fixtures/localRuntimeGate.py @@ -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() diff --git a/vscode/src/codeReader/languages/exceptions.test.ts b/vscode/src/codeReader/languages/exceptions.test.ts index 58c1f2a..e2a4945 100644 --- a/vscode/src/codeReader/languages/exceptions.test.ts +++ b/vscode/src/codeReader/languages/exceptions.test.ts @@ -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) } diff --git a/vscode/src/codeReader/languages/rust.test.ts b/vscode/src/codeReader/languages/rust.test.ts index a4b58f6..020e4c6 100644 --- a/vscode/src/codeReader/languages/rust.test.ts +++ b/vscode/src/codeReader/languages/rust.test.ts @@ -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 { 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 — 没存过时是 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 {', diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 5d843ef..27047d2 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -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)]) } /** 属性表里(值、各层子行的格子)带引用的段。 */ diff --git a/vscode/src/codeReader/nameResolution.ts b/vscode/src/codeReader/nameResolution.ts index 3df7155..9be4c21 100644 --- a/vscode/src/codeReader/nameResolution.ts +++ b/vscode/src/codeReader/nameResolution.ts @@ -32,7 +32,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map } 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) } diff --git a/vscode/src/codeReader/rules/axum.ts b/vscode/src/codeReader/rules/axum.ts index ecb230d..d6abc73 100644 --- a/vscode/src/codeReader/rules/axum.ts +++ b/vscode/src/codeReader/rules/axum.ts @@ -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 { diff --git a/vscode/src/codeReader/rules/celery.test.ts b/vscode/src/codeReader/rules/celery.test.ts index f847ae7..7dd5243 100644 --- a/vscode/src/codeReader/rules/celery.test.ts +++ b/vscode/src/codeReader/rules/celery.test.ts @@ -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) } diff --git a/vscode/src/codeReader/rules/celery.ts b/vscode/src/codeReader/rules/celery.ts index dccf376..1173ef3 100644 --- a/vscode/src/codeReader/rules/celery.ts +++ b/vscode/src/codeReader/rules/celery.ts @@ -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(APP_NAMED) const rows: PropertyRow[] = [ { label: labels.name, value: target.type === 'identifier' ? [name(target, 'variable')] : context.translate(target) }, diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts index 3bc63d3..d860536 100644 --- a/vscode/src/codeReader/rules/dataclass.test.ts +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -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: '横坐标' }) + }) }) diff --git a/vscode/src/codeReader/rules/dataclass.ts b/vscode/src/codeReader/rules/dataclass.ts index 4ec0557..c415e12 100644 --- a/vscode/src/codeReader/rules/dataclass.ts +++ b/vscode/src/codeReader/rules/dataclass.ts @@ -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 { diff --git a/vscode/src/codeReader/rules/docRows.ts b/vscode/src/codeReader/rules/docRows.ts new file mode 100644 index 0000000..a8223ee --- /dev/null +++ b/vscode/src/codeReader/rules/docRows.ts @@ -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() + 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 { + 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] })), + }] +} diff --git a/vscode/src/codeReader/rules/enum.test.ts b/vscode/src/codeReader/rules/enum.test.ts index 7c88628..94f35a4 100644 --- a/vscode/src/codeReader/rules/enum.test.ts +++ b/vscode/src/codeReader/rules/enum.test.ts @@ -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)) diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index 2d11423..f187862 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -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 { 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' } }]) }) diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts index b9b77d9..9679d51 100644 --- a/vscode/src/codeReader/rules/fastapi.ts +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -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 { 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): 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() diff --git a/vscode/src/codeReader/rules/pydantic.test.ts b/vscode/src/codeReader/rules/pydantic.test.ts index e81ead5..29cd97d 100644 --- a/vscode/src/codeReader/rules/pydantic.test.ts +++ b/vscode/src/codeReader/rules/pydantic.test.ts @@ -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('""') diff --git a/vscode/src/codeReader/rules/pydantic.ts b/vscode/src/codeReader/rules/pydantic.ts index 81656c8..ecdccb8 100644 --- a/vscode/src/codeReader/rules/pydantic.ts +++ b/vscode/src/codeReader/rules/pydantic.ts @@ -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, diff --git a/vscode/src/codeReader/rules/react.test.ts b/vscode/src/codeReader/rules/react.test.ts index b0912bd..1895131 100644 --- a/vscode/src/codeReader/rules/react.test.ts +++ b/vscode/src/codeReader/rules/react.test.ts @@ -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 {label}', + '}', + ].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 | — | 点击事件', + '返回: — 复制成没成', + '绑定事件: 的 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') }) diff --git a/vscode/src/codeReader/rules/react.ts b/vscode/src/codeReader/rules/react.ts index ff0977c..3deea41 100644 --- a/vscode/src/codeReader/rules/react.ts +++ b/vscode/src/codeReader/rules/react.ts @@ -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 = { interface Words { /** 各种卡片的名字,筛选栏里也叫这个 */ kinds: Record - 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 => [ - ...binding(parameter.name, 'variable', env), - ...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []), - ...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []), - ]) + 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) } /** diff --git a/vscode/src/codeReader/rules/rustImpl.ts b/vscode/src/codeReader/rules/rustImpl.ts index 40d13b9..185462d 100644 --- a/vscode/src/codeReader/rules/rustImpl.ts +++ b/vscode/src/codeReader/rules/rustImpl.ts @@ -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 { diff --git a/vscode/src/codeReader/rules/rustLibs.test.ts b/vscode/src/codeReader/rules/rustLibs.test.ts index 199b627..f000c64 100644 --- a/vscode/src/codeReader/rules/rustLibs.test.ts +++ b/vscode/src/codeReader/rules/rustLibs.test.ts @@ -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) diff --git a/vscode/src/codeReader/rules/rustRows.ts b/vscode/src/codeReader/rules/rustRows.ts index 796a55e..a15a9f5 100644 --- a/vscode/src/codeReader/rules/rustRows.ts +++ b/vscode/src/codeReader/rules/rustRows.ts @@ -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 = { @@ -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, } } diff --git a/vscode/src/codeReader/rules/rustTree.ts b/vscode/src/codeReader/rules/rustTree.ts index 93e47eb..2c79928 100644 --- a/vscode/src/codeReader/rules/rustTree.ts +++ b/vscode/src/codeReader/rules/rustTree.ts @@ -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 } diff --git a/vscode/src/codeReader/rules/serde.ts b/vscode/src/codeReader/rules/serde.ts index 9140e0d..b499723 100644 --- a/vscode/src/codeReader/rules/serde.ts +++ b/vscode/src/codeReader/rules/serde.ts @@ -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), })), diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index f9e86ad..6048608 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -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 { 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) } diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index ea81cdf..30c227a 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -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) { diff --git a/vscode/src/codeReader/rules/sqlx.ts b/vscode/src/codeReader/rules/sqlx.ts index b53e1f5..d01c5e6 100644 --- a/vscode/src/codeReader/rules/sqlx.ts +++ b/vscode/src/codeReader/rules/sqlx.ts @@ -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 { diff --git a/vscode/src/codeReader/rules/tauri.ts b/vscode/src/codeReader/rules/tauri.ts index ec188f7..9c54fee 100644 --- a/vscode/src/codeReader/rules/tauri.ts +++ b/vscode/src/codeReader/rules/tauri.ts @@ -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', diff --git a/vscode/src/codeReader/rules/thiserror.ts b/vscode/src/codeReader/rules/thiserror.ts index 44d90f6..c430150 100644 --- a/vscode/src/codeReader/rules/thiserror.ts +++ b/vscode/src/codeReader/rules/thiserror.ts @@ -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 diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 4be21a2..bb09f5b 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -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} `), ]) diff --git a/vscode/src/codeReader/translators/rustZh.ts b/vscode/src/codeReader/translators/rustZh.ts index 4e0facd..8c36bec 100644 --- a/vscode/src/codeReader/translators/rustZh.ts +++ b/vscode/src/codeReader/translators/rustZh.ts @@ -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') diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 1b3016a..a6f056c 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -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): Record { @@ -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 () => { diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index c366d05..b76c298 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -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('无')] }) }, diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 73868f9..70b0d6b 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -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(removed: readonly Removed[] | 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 ? : +/** 一格是不是成块排的 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 ? : + return 'markdown' in 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 ( +
+ {lead} + +
+ ) + } + return ( +

+ {lead} + +

+ ) +} + +/** 格子写成一串字(读屏念的展开 / 折叠按钮名):着色的词拼起来,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 (
-

- -

+
) } @@ -80,15 +109,15 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], -

- -

+
+ +
) } @@ -114,11 +143,7 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string, > {item.cells.map((cell, i) => i === 0 && nested ? 0 && change !== 'del'} folding={folds} /> - : ( -

- -

- ))} + : )} {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 && {SIGNS[change ?? 'none']}} - {toggle && i === 0 - ? ( - - ) - : } - - ) + const sign = (i: number): ReactNode => signed && i === 0 && {SIGNS[change ?? 'none']} + /** 一格:讲子块的那一格是展开它的按钮(只放着色的词),别的照常(见 CellBox) */ + const cellBox = (cell: PreviewText, i: number, className?: string): ReactElement => toggle && i === 0 + ? ( +

+ {sign(i)} + +

+ ) + : 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 ? (
-

{text(cell, i)}

+
) - :

{text(cell, i)}

)} + : {cellBox(cell, i, 'cr-table-cell')})} ) } @@ -271,7 +296,7 @@ export function PropertyGrid({ blockId, rows, collapsed, onToggleFold, marks, sl ) : {row.label}} - {mark?.value ? : } + {!folded && layout === 'table' && } {!folded && layout === 'list' && } @@ -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 ? : + if (!row.note) + return value + return ( +
+ {value} + {mark?.note ? : } +
+ ) +} + /** 属性表里删掉的一行:整行淡红底、划掉,左边 −;它下面的子行照样摊开、划掉。 */ 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 <>
{row.label} - +
{layout && (
diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 4a1c973..dfa10fb 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -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 && {piece.text}} diff --git a/vscode/webview-ui/src/codeReader/subRows.ts b/vscode/webview-ui/src/codeReader/subRows.ts index 209004b..57e9478 100644 --- a/vscode/webview-ui/src/codeReader/subRows.ts +++ b/vscode/webview-ui/src/codeReader/subRows.ts @@ -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] ?? []) } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 3d8a98f..760a00f 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 + cells?: Record items?: ItemsDiff table?: ItemsDiff } @@ -335,10 +341,11 @@ export interface RowsDiff { removed: Removed[] } -/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、子行改了 */ +/** 属性表一行的改动:整行新加的(它下面的子行也都是),或值、跟在值后面的说明、子行改了 */ export interface RowDiff { added?: true value?: TextDiff + note?: TextDiff items?: ItemsDiff }