From ecc415d763576689021a5c9fab469b52ca95e77b Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 22:08:16 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(vscode):=20=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E7=9A=84=E8=B0=83=E7=94=A8=E4=B8=8E=E6=9E=84?= =?UTF-8?q?=E9=80=A0=EF=BC=9A=E8=B7=A8=E5=A4=9A=E8=A1=8C=E5=86=99=E7=9A=84?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=8E=92=E6=88=90=E3=80=8C=E6=96=B0=E5=BB=BA?= =?UTF-8?q?=20/=20=E8=B0=83=E7=94=A8=20=E5=90=8D=E5=AD=97=20=C2=B7=20N=20?= =?UTF-8?q?=E4=B8=AA=E5=8F=82=E6=95=B0=E3=80=8D=E4=B8=80=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E5=AE=9E=E5=8F=82=E6=91=8A=E6=88=90=E5=8F=82=E6=95=B0=20/=20?= =?UTF-8?q?=E5=80=BC=E8=A1=A8=EF=BC=9BPython=20=E6=9F=A5=E8=A2=AB=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E5=90=8D=E5=AD=97=E7=9A=84=E5=AE=9A=E4=B9=89=E5=88=86?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E5=92=8C=E8=B0=83=E7=94=A8=EF=BC=8C=E6=9F=A5?= =?UTF-8?q?=E5=88=B0=E5=90=8E=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 87 +++++++- vscode/src/codeReader/blockView.test.ts | 23 +- vscode/src/codeReader/blockView.ts | 56 ++++- vscode/src/codeReader/calleeKinds.test.ts | 84 +++++++ vscode/src/codeReader/calleeKinds.ts | 46 ++++ vscode/src/codeReader/contract.ts | 49 ++++- vscode/src/codeReader/deferredAnswers.ts | 97 +++++++++ vscode/src/codeReader/editor/definitions.ts | 21 +- .../codeReader/editor/documentMessage.test.ts | 15 ++ .../src/codeReader/editor/documentMessage.ts | 8 +- vscode/src/codeReader/editor/previewEditor.ts | 27 ++- vscode/src/codeReader/fixtures/calls.ts.txt | 37 ++++ .../src/codeReader/languages/python.test.ts | 27 ++- vscode/src/codeReader/languages/python.ts | 39 +++- .../codeReader/languages/typescript.test.ts | 22 +- vscode/src/codeReader/languages/typescript.ts | 23 +- vscode/src/codeReader/nameResolution.test.ts | 7 + vscode/src/codeReader/nameResolution.ts | 3 +- vscode/src/codeReader/registry.test.ts | 2 +- vscode/src/codeReader/ruleLookups.ts | 99 +++------ vscode/src/codeReader/testSupport.ts | 22 ++ .../translators/contractOnly.test.ts | 4 +- .../codeReader/translators/zhTemplate.test.ts | 206 ++++++++++++++++-- .../src/codeReader/translators/zhTemplate.ts | 129 +++++++++-- .../webview-ui/src/codeReader/BlockTree.tsx | 13 +- .../src/codeReader/PropertyGrid.tsx | 4 +- .../webview-ui/src/codeReader/reveal.test.ts | 10 + vscode/webview-ui/src/codeReader/reveal.ts | 15 +- vscode/webview-ui/src/codeReader/types.ts | 10 +- vscode/webview-ui/src/codeReader/viewState.ts | 5 + 30 files changed, 1033 insertions(+), 157 deletions(-) create mode 100644 vscode/src/codeReader/calleeKinds.test.ts create mode 100644 vscode/src/codeReader/calleeKinds.ts create mode 100644 vscode/src/codeReader/deferredAnswers.ts create mode 100644 vscode/src/codeReader/fixtures/calls.ts.txt diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index e93c59b..c51032d 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -41,6 +41,7 @@ | 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 | | 中文名 | 代码里给定义起的中文名(`Annotated[T, Comment("当前用户")]`、`@Comment("…")`、行尾 `# @Comment 当前用户`,没有时取参数在 docstring / JSDoc 里的说明):预览里这个定义和用到它的各处都显示中文名,悬停看原名,定义处本身在中文名后面淡淡写原名。语言插件认写法,用到它的各处由语言扩展按语义认(同一文件问 document highlights,别的文件定义的先查工作区索引、再问定义提供者),webview 只按源码下标换字。见「中文名」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | +| 调用与构造 | 实参列表跨了几行写的调用不挤成一长行:写成「[等待 ]新建 / 调用 被调用的名字 · N 个参数」,实参摊成一张表「参数 \| 值」(按名字传的写名字,按位置传的写「第 N 个」,`*x` / `...x` 写「展开」,`**x` 写「展开关键字」);一行写完的调用照旧一行译文。新建还是调用:TypeScript 看有没有 `new`;Python 写法一样,查被调用的名字的定义,是类就是「新建」,是函数、查不到都是「调用」,先按调用画、查到了再刷新。表在变量 / 常量属性表的初始值 / 值那一行下面(能折叠),在返回、语句这些一行译文的块里排在那一行下面。语言插件报结构,翻译插件定用词,webview 只画表。见「调用与构造」 | ## 用户可见行为 @@ -133,7 +134,7 @@ - 悬停提示(整个插件的网页只走这一条路,预览和看板共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里,只有图标的按钮必须有 `aria-label`),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `title`;提示里的换行照样换行(看板摘修复弹框里「迁移」列出的几个文件一行一个);注释、回答里 Markdown 链接的标题(`[文字](网址 "标题")`)也写成 `data-tooltip`。网页的 TSX 里写原生 `title` 属性是 lint 错误(`vscode/eslint.config.mjs`),只有 `iframe` 这种必须带标题的元素例外,组件自己叫 `title` 的属性不算。原因:macOS 上 VS Code 带的 Electron(1.139 是 Electron 43)只在鼠标压着带 `title` 的元素自身边缘时才弹原生提示,停在它里面的图标、文字上不弹(electron/electron#49843,Electron 38 起),种类图标 20px 的框几乎被 16px 的图形占满,基本弹不出来;样子也是系统的。样子照 VS Code 的悬停框:`--vscode-editorHoverWidget-background` 底、`--vscode-editorHoverWidget-border` 边框、`--vscode-editorHoverWidget-foreground` 字、`0 2px 8px --vscode-widget-shadow` 阴影,11px 字,圆角 4px,最宽 22rem;优先在元素上方,上方放不下翻到下方,水平对着元素中间,贴边留 8px,不接鼠标。鼠标停 500ms 才弹,在同一个元素里移动不重新计时,已经弹着时移到另一个带提示的元素上立刻换;移开、移出页面、按下鼠标、按任意键(含 Esc)、滚动就收,收了之后鼠标不离开这个元素不再弹。键盘把焦点移到带提示的元素上(`:focus-visible`)立刻弹,焦点离开就收;提示不是元素自己的名字(`aria-label` 或它的字)时用 `aria-describedby` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 - 内容两种状态,每块单独切: - - 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文的块带说明(Python 字段的属性 docstring)时,说明按 Markdown 排在译文下面。 + - 预览:函数、类、枚举、异常、导入、常量、变量块是属性表(见下节),注释块是 Markdown 说明(见「注释的 Markdown 排版」),其余块是一行译文;按词着色,等宽字体(中文部分用系统中文字体回退)。一行译文里有跨几行写的调用时,摊开实参的表排在译文下面(见「调用与构造」);块带说明(Python 字段的属性 docstring)时,说明按 Markdown 再排在下面。 - 编辑:这一块的源码(按语法树着色),可以直接改;蓝色边框。按钮换成眼睛「预览」;Esc 也回到预览。 - 复合语句(函数、条件、循环……)块里只放它自己的头部那几行(主体交给子块);收尾括号、空行不成块。函数、类的说明(JSDoc / docstring)也算在函数、类块的头部里;Python 常量、变量、字段的属性 docstring 算在它那一块里(中间隔着的空行也是)。 - 库规则认领的块(数据表卡片等,见「库规则」):种类的图标、颜色和名字由规则给,内容是规则给的属性表;它的编辑范围是原块整条语句(类就是整个类),「编辑」时整段一起改。规则没并进卡片的子块(方法、别的成员)不管写在类主体哪儿(两个字段中间也算),都照源码顺序是卡片的子块,各自的「编辑」照旧只改自己那几行。 @@ -256,6 +257,30 @@ - TypeScript 没有这种写法,不涉及。几种读不成遍历的写法(语法树认、Python 3 不认的 `for x in a, b`,写到一半缺了变量 / 来源 / 条件 / 值的)照常逐词直译。 +### 调用与构造 + +原型画板「调用 · 构造」(`code.js` 的「调用与构造」一节)。实参多的调用一般跨几行写,挤成一行译文读不下去;把实参摊成一张表。 + +- 哪些调用:实参列表(括号连同里面,`CallFacts.argumentList`)跨了几行的调用,而且整个落在要翻的这一段里(块头部;TypeScript `describe('x', () => { … })` 这种主体是子块的调用不算)。一行写完的调用照旧一行译文,不问定义。链式调用跨了几行、每段的括号都写在一行里的(`items\n .filter(…)\n .map(…)`)也不算。一段里只排遇到的第一个(先外后里),实参里的调用不再排成表,照常一行译文。 +- 一行:`[等待 ]新建 / 调用 被调用的名字 · N 个参数`。「等待」就是 `await` 照常的译法(在调用前面),「新建」「调用」按声明关键字着色;被调用的名字照常翻、带源码下标(⌘ / Ctrl + 点击、单击高亮、中文名都照旧),前面几节(对象、模块)按变量着色,最后那个名字新建时按类型着色、调用时按函数着色;「· N 个参数」按说明词着色,N 数的是表里的行。 +- 新建还是调用:写法说明了的看写法(`CallFacts.construct`:TypeScript 的 `new X(…)` 是新建,不带 new 的是调用,都不问定义);Python 新建对象和调用函数写法一样,翻译插件问 `SourceDocument.calleeKind`:名字定义成类(含异常、枚举)是「新建」,函数、别的、查不到、还没查到都是「调用」。两种语言都用「新建」这个词。 +- 查定义(宿主,`calleeKinds.ts` + `editor/definitions.ts` 的 `definitionKind`):排视图数据是同步的,先按已经查到的排(第一次都按调用画),记下问了哪些调用;发出预览后问语言扩展被调用的名字(`CallFacts.name`)定义在哪(和 ⌘ + 点击同一套),打开定义所在的文件、用它那种语言的语言插件分块(`readDefinitionKind`:自己的名字盖住定义处的最里层那块),几处定义取第一处认得出的。查到的和已知的不一样就重排、重发。按被调用的表达式原文记(`user_service.update_user`),同一份文档里写法一样的按一处查;文档改了先用上一版查到的,每个版本查一次,语言扩展还没起来(没答)的照 RETRY_DELAYS 隔一会儿再查(和库规则查名字的定义同一套 `DeferredAnswers`)。被调用的不是名字(`handlers[k](…)`)的不查,按调用算。 +- 表:两列「参数 | 值」,每个实参一行、照源码顺序。按名字传的(Python `name=value`)写名字,按变量着色、带源码下标;按位置传的写「第 N 个」(说明词,只数按位置传的);`*args`、`...args` 写「展开」,`**kwargs` 写「展开关键字」(说明词)。值照常一行译文(名字带下标),里面的调用不再展开;唯一的实参是生成器时(`sum(\n x for x in xs\n)`),共用的那对括号属于调用,格子里只有框。表的样子同输入参数表(`PropertyTable`:表头、圆角外框、格子之间分隔线,第一列按内容定宽)。 +- 放在哪: + - 变量、常量块:初始值 / 值那一行写上面那一行字,表是这一行展开的子行(`PropertyRow.columns = ['参数', '值']`),带折叠箭头、默认展开。值是字典 / 对象字面量时照旧按「字典 N 项」展开,不看里面的调用。 + - 一行译文的块(返回、语句、字段……):译文里调用的位置换成那一行字(「返回 新建 UserDetailResponse · 11 个参数」),表排在译文下面(`TextTranslation.table` → 视图数据的 `table`),总是展开;块带说明时说明排在表下面。 +- 分工:语言插件报结构(`LanguageAdapter.call`:被调用的表达式、最后那个名字、写法是不是新建、实参列表、各个实参),翻译插件定用词、挑哪个调用、怎么排;webview 只画表(`PreviewTable`),不认语言;跳到定义、高亮各处出现、中文名在表里照旧(`renderedNames`、`reveal.ts` 都算上这张表)。 +- 样例(`fixtures/routes.py`,luffy 的用户路由): + +| 源码 | 预览 | +| --- | --- | +| `return UserDetailResponse(\n id=user.id,\n …(11 个)\n)`(定义是类) | 一行 `返回 新建 UserDetailResponse · 11 个参数`,表第一行 `id \| user.id` | +| `user = await user_service.update_user(\n db,\n user_id=user_id,\n …(10 个)\n)` | 初始值 `等待 调用 user_service.update_user · 10 个参数`,表第一行 `第 1 个 \| db`、第二行 `user_id \| user_id` | +| `user = await user_service.get_user(db, user_id=user_id)` | 初始值 `等待 user_service.get_user(db, user_id=user_id)`(一行写完,不展开) | +| `payload = await file.read()` | 初始值 `等待 file.read()` | +| `const url = new URL(\n \`…/comments\`,\n)` | 初始值 `新建 URL · 1 个参数`,表 `第 1 个 \| \`…/comments\`` | +| `return concatAll(\n base,\n ...extra,\n)` | 一行 `返回 调用 concatAll · 2 个参数`,表 `第 1 个 \| base`、`展开 \| extra` | + ### 注释的 Markdown 排版 - 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 @@ -499,7 +524,23 @@ export interface IterationFacts { clauses: IterationClause[] // 至少一节,第一节是 for value: Node // 每一项取的值 key?: Node // 结果是字典时每一项的键 - sharedParentheses?: { open: Node, close: Node } // 遍历的括号同时是外面调用的括号(Python f(x for x in y)):写在遍历外面 + sharedParentheses?: { open: Node, close: Node } // 遍历的括号同时是外面调用的括号(Python f(x for x in y)):写在遍历外面;只翻这个遍历(它是实参表里的值)时不写 +} + +/** 调用里的一个实参,照源码顺序;注释不算。 */ +export type CallArgument = + | { kind: 'positional', value: Node } // 按位置传的 + | { kind: 'keyword', name: Node, value: Node } // 按名字传的:Python 的 name=value + | { kind: 'spread', value: Node } // *args、...args + | { kind: 'keywordSpread', value: Node } // Python 的 **kwargs + +/** 一次调用(含新建对象)的结构:语言插件读出,翻译插件把跨几行写的排成「新建 / 调用 名字 · N 个参数」一行、实参摊成表(见「调用与构造」)。 */ +export interface CallFacts { + callee: Node // 被调用的表达式(TypeScript 的 new 不算在里面) + name?: Node // callee 里最后那个名字(点分名字的最后一节):着色、查定义用它;callee 不是名字时没有 + construct?: boolean // 写法本身说明了是不是新建对象时给(TypeScript:new 是,别的调用不是);Python 不给,翻译插件问 SourceDocument.calleeKind + argumentList: Node // 括号连同里面的实参:它跨了几行,这次调用就是跨几行写的 + arguments: CallArgument[] } export interface DeclaratorFacts { @@ -610,6 +651,12 @@ export interface PropertyItem extends Defines { cells: Inline[][], items?: Prope */ export interface PropertyRow extends Defines { label: string, value: PreviewText, items?: PropertyItem[], columns?: string[] } +/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */ +export interface PreviewTable { columns: string[], items: PropertyItem[] } + +/** 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */ +export interface TextTranslation { text: PreviewText, table?: PreviewTable } + /** 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。 */ export interface LanguageAdapter { id: string @@ -623,6 +670,8 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined + /** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */ + call?: (node: Node) => CallFacts | undefined /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader } @@ -642,7 +691,16 @@ export interface DisplayNameReader { } /** 翻译插件翻一块时拿到的这份源码:源码字符串,解析它的语言插件,和要从注释、说明里去掉的记号。 */ -export interface SourceDocument { source: string, language: LanguageAdapter, markers: SourceRange[] } +export interface SourceDocument { + source: string + language: LanguageAdapter + markers: SourceRange[] + /** + * 调用的名字(CallFacts.name)的定义是哪种块:宿主问语言扩展定义在哪,用那个文件的语言插件分块认出来。 + * 同步地只交出已经查到的;还没查到、查不到、宿主不查都是 undefined,查到了宿主重排、重发(见「调用与构造」)。 + */ + calleeKind?: (call: CallFacts) => BlockKind | undefined +} /** 筛选属性和值的显示名;没给名字的显示 id。 */ export interface AttributeNames { @@ -656,7 +714,7 @@ export interface Translator { label: string // '中文直译' languageIds: string[] displayLanguage: string // 译文用的语言('zh'):库规则按它挑自己的词表 - translate: (block: TextBlock, document: SourceDocument) => PreviewText // 只翻 header 那一段;注释块给 Markdown 原文 + 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 原文,同属性表的「说明」 @@ -750,9 +808,9 @@ export interface CodeReaderApi { - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 - 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy、Celery、enum、dataclasses、FastAPI 库规则在 `activate()` 里通过同一个 API 注册(规则按这个顺序),没有旁路。 - 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。 -- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、枚举、异常、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。 +- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、枚举、异常、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。调用由语言插件的 `call` 给结构(节点 → `CallFacts`),跨几行写的由翻译插件排成一行字加一张表(`PreviewTable`,见「调用与构造」);webview 只画表。 - 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 -- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、异常、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)、`displayNames`(这门语言里给定义起中文名的写法)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻,没有 `displayNames` 就没有中文名。 +- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、异常、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)、`call`(调用的结构)、`displayNames`(这门语言里给定义起中文名的写法)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻,没有 `call` 的调用不摊成表,没有 `displayNames` 就没有中文名。 - 中文名的分工:语言插件只认写法(`DisplayNameReader.find`:定义处的名字、中文名、注释里的记号);宿主解析一次源码时顺带问它,记号放进 `SourceDocument.markers` 交给翻译插件(翻注释、说明时去掉,中文模板直译在 `ZhTable.commentText` 之前去掉),定义交给预览找用到它的各处(`nameResolution.ts`);翻译插件不认识 `@Comment`,webview 不认识任何语言。 - 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 @@ -1209,7 +1267,7 @@ export interface CodeReaderApi { | TypeScript | `ternary_expression`(`c ? a : b`) | 如果 〈condition〉 则 〈consequence〉 否则 〈alternative〉 | | Python | `conditional_expression`(`a if c else b`,没有字段名,具名子节点依次是 a、c、b) | 如果 〈1 号〉 则 〈0 号〉 否则 〈2 号〉 | -写在表达式里的遍历(推导式)不走节点模板:它的节数不定、要换成一个框,由语言插件的 `iteration`(翻译时从 `SourceDocument.language` 拿到)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。 +写在表达式里的遍历(推导式)不走节点模板:它的节数不定、要换成一个框,由语言插件的 `iteration`(翻译时从 `SourceDocument.language` 拿到)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。跨几行写的调用也不走节点模板:由语言插件的 `call` 认出来,引擎只在翻一行译文(`translate`)和变量 / 常量的值(`declaredValue`)时按「调用与构造」一节排(`emitCall`、`argumentTable`),属性表别的值、库规则借的 `expression` 照常一行。 TypeScript 关键字表(流程 = keyword,声明 = storage): @@ -1277,9 +1335,10 @@ interface BlockViewBase { /** * 属性表块(有 facts)和库规则认领的块只带 properties,其余块只带 translation(注释块是 `{ markdown }`);没有翻译插件时是空数组。 - * 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文(Translator.doc),webview 排在译文下面。 + * 一行译文里有跨几行写的调用时 table 是摊开实参的表(TextTranslation.table),块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文(Translator.doc), + * webview 依次排在译文下面。 */ -type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) +type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] }) ``` - 扩展 → webview: @@ -1351,13 +1410,16 @@ vscode/src/codeReader/ rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认 rules/dataclass.ts 内置 dataclasses 库规则(Python):数据类卡片,字段照 dataclasses 的规则认 rules/fastapi.ts 内置 FastAPI 库规则(Python):路由器卡片(接口表)、接口卡片(请求表、响应表),参数照 FastAPI 的规则放,异常类的状态码、类型别名、枚举经索引查 - ruleLookups.ts 库规则认领时查别的文件里的定义(RuleContext.lookup):先交出已知的、记下问了什么,发出预览后查(索引 + 语言扩展查定义),变了叫宿主重发(不碰 VS Code) + ruleLookups.ts 库规则认领时查别的文件里的定义(RuleContext.lookup):索引 + 语言扩展查定义(不碰 VS Code) + calleeKinds.ts 调用的名字定义成哪种块(SourceDocument.calleeKind):按被调用的表达式原文记、问最后那个名字(不碰 VS Code) + deferredAnswers.ts 上面两样共用的:排视图数据时先交出已知的、记下问了什么,发出预览后按版本查一次、没答的隔一会儿再查,变了叫宿主重发(不碰 VS Code) + (blockView.ts 的 readDefinitionKind:定义处落在哪块自己的名字上;editor/definitions.ts 的 definitionKind:打开定义所在的文件交给它) rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字) rules/segments.ts 库规则共用的拼属性表文字的办法 rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点) languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数) - languages/typescript.ts (TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor) - languages/python.ts (iteration:推导式的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__) + languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor) + languages/python.ts (iteration:推导式的结构;call:调用的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__) languages/displayNames.ts 中文名的共用部分:第一小句、行尾记号、按优先级汇总 languages/pythonNames.ts Python 的中文名写法(Annotated、@Comment、行尾记号、docstring) languages/typescriptNames.ts TypeScript / JavaScript 的中文名写法(@Comment 装饰器、行尾记号、JSDoc) @@ -1413,6 +1475,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 属性表信息:`envLock.ts`、`sample.py`、`routes.py`(原型里的 FastAPI 路由)加内联片段,覆盖各种导入写法、形参写法、类成员访问修饰符、解构和多变量声明、Python 下划线规则;字典 / 对象字面量的各项:Python 字符串键、名字键、嵌套字典、`**` 展开、空字典、类属性,TypeScript 名字键、字符串键、计算属性名、简写、展开、方法和 getter(头部到主体之前)、嵌套对象、空对象,不是字面量的值没有 entries。 - 字典 / 对象的属性表:项数、键 | 值两列、嵌套一层层展开、「展开」、方法头部、空的一行;键和值里的名字、字符串带下标(嵌套的也是);每项标的定义是键,展开项不标;跳到定义落在对象属性名上只闪那一项、连外层各层一起展开,只是显示着的名字闪整块、展开到它那一层。 - 推导式:Python 语言插件读出的结构(调用唯一参数的生成器带 sharedParentheses、几个参数之一的不带,列表带条件,字典分出键和值,集合的解构变量,几节 for / if 照源码顺序,async for,套着的各自认,字面量和 for 语句不算);中文直译「推导式」一节的样例表逐字一致,框的样式、框头、关键字着色,顿号连写、条件位置、另起一句、异步遍历、集合、框里的框、多个参数旁边的生成器、跨行带注释的生成器,属性表的默认值和字典项的值里也换;框里的名字带下标且指回源码原文,高亮各处出现、跳到定义都认框里的名字(只显示在框里的推导式变量 → 框所在的块);TypeScript 不认。 +- 调用与构造:语言插件读出的结构(Python 按位置 / 按名字 / `*x` / `**x`、唯一的生成器实参、被调用的不是名字时没有 name、装饰器和父类列表不算;TypeScript `new` 是新建、被调用的不含 new、`...x`、带标签的模板字符串和不带括号的 `new X` 不算);中文直译按「调用与构造」一节的样例:`routes.py` 的 `return UserDetailResponse(…)` 定义是类(异常也算)时「返回 新建 UserDetailResponse · 11 个参数」、11 行表、名字带下标,没查到 / 查不到 / 是函数时「调用」、名字按函数着色;`update_user` 的初始值「等待 调用 user_service.update_user · 10 个参数」、表是这一行的子行(列名 参数 / 值)、第一行「第 1 个」按说明词着色;一行写完的 `get_user(...)`、`file.read()` 照旧一行、不问定义;「展开」「展开关键字」、「第 N 个」只数按位置传的、实参里的调用不再排成表;`fixtures/calls.ts.txt`(本仓库的代码)new 是新建、不带 new 是调用、都不问定义;链式调用跨了几行、回调主体是子块的调用不排。`calleeKinds.test.ts`:按表达式原文记、同一版本不再查、被调用的不是名字不问、查了没有不算变、没答的隔一会儿只再查没答的。视图数据带 `table`、`readDefinitionKind` 认类 / 函数 / 异常 / 变量、`renderedNames` 算上表里的名字、`documentMessage` 把 calleeKind 交给翻译插件;webview 跳到定义、键盘跳到高亮的各处出现都认一行译文下面那张表。 - 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。 - 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 7019eca..e90b219 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -1,7 +1,7 @@ import type { BlockView } from './blockView' import type { LanguageAdapter, Translator } from './contract' import { describe, expect, it } from 'vitest' -import { readBlockViews, toBlockViews } from './blockView' +import { readBlockViews, readDefinitionKind, toBlockViews } from './blockView' import { replaceLines } from './editText' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' @@ -257,4 +257,25 @@ describe('blockView', () => { ]) expect(translationText(list[0].children[0])).toBe('返回 非 x') }) + + it('跨几行写的调用:一行译文下面带摊开实参的表;calleeKind 交给翻译插件分新建还是调用', async () => { + const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n' + const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, undefined, call => call.callee.text === 'Detail' ? 'class' : undefined) + const returned = list[0].children[0] + expect(translationText(returned)).toBe('返回 新建 Detail · 1 个参数') + expect('table' in returned && returned.table).toMatchObject({ + columns: ['参数', '值'], + items: [{ cells: [[{ text: 'id', offset: source.indexOf('id=') }], [{ text: 'user' }, { text: '.' }, { text: 'id' }]] }], + }) + }) + + it('readDefinitionKind:offset 落在哪块自己的名字上就是那块的种类,里层的块优先;不在名字上没有', async () => { + const source = 'class UserService:\n def update_user(self):\n pass\n\nclass NotFound(Exception):\n pass\n\nlimit = 3\n' + const kind = (text: string): Promise => readDefinitionKind(runtime, py, 'python', source, source.indexOf(text)) + expect(await kind('UserService')).toBe('class') + expect(await kind('update_user')).toBe('function') + expect(await kind('NotFound')).toBe('exception') + expect(await kind('limit')).toBe('variable') + expect(await kind('pass')).toBeUndefined() + }) }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 05b7764..cf69eca 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockAttributes, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, BlockKind, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -33,9 +33,9 @@ interface BlockViewBase { /** * 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 - * 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。 + * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文)。 */ -export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) +export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] }) interface Line { start: number, end: number } @@ -159,10 +159,13 @@ export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, tran codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole), } } - /** 一行译文,带说明的块连着说明。 */ - const textView = (block: TextBlock): { translation: PreviewText, doc?: Markdown } => { - const doc = block.doc && translator?.doc(block.doc, document) - return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) } + /** 一行译文,连着摊开实参的表、块的说明(有才有)。 */ + const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => { + if (!translator) + return { translation: [] } + const { text, table } = translator.translate(block, document) + const doc = block.doc && translator.doc(block.doc, document) + return { translation: text, ...(table && { table }), ...(doc && { doc }) } } /** 按源码先后逐块给位置记号:先序遍历,块的记号在它的子块之前要 */ const anchors = new BlockAnchors() @@ -215,7 +218,10 @@ export interface DocumentViews { displayNames: DisplayName[] } -/** 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 */ +/** + * 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 + * calleeKind 交出调用的名字的定义眼下查到是哪种块(SourceDocument.calleeKind)。 + */ export async function readBlockViews( runtime: Pick, adapter: LanguageAdapter, @@ -223,14 +229,46 @@ export async function readBlockViews( languageId: string, source: string, rules: RuleRun = NO_RULES, + calleeKind?: SourceDocument['calleeKind'], ): Promise { const tree = await runtime.parse(adapter, languageId, source) try { const { names, markers } = adapter.displayNames?.find(tree.rootNode, source, languageId) ?? { names: [], markers: [] } - const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), { source, language: adapter, markers }, translator, rules) + const document: SourceDocument = { source, language: adapter, markers, ...(calleeKind && { calleeKind }) } + const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), document, translator, rules) return { blocks, displayNames: names } } finally { tree.delete() } } + +/** 块树里自己的名字(见 ownName)盖住 offset 的最里层那块的种类;offset 不在哪块的名字上时没有。 */ +export function blockKindAt(blocks: readonly CodeBlock[], offset: number): BlockKind | undefined { + for (const block of blocks) { + const inner = blockKindAt(block.children, offset) + if (inner) + return inner + const name = ownName(block) + if (name && name.startIndex <= offset && offset < name.endIndex) + return block.kind + } + return undefined +} + +/** 解析一份源码,offset 处的名字定义的是哪种块(见 blockKindAt);语法树用完即释放。 */ +export async function readDefinitionKind( + runtime: Pick, + adapter: LanguageAdapter, + languageId: string, + source: string, + offset: number, +): Promise { + const tree = await runtime.parse(adapter, languageId, source) + try { + return blockKindAt(adapter.toBlocks(tree.rootNode, source, languageId), offset) + } + finally { + tree.delete() + } +} diff --git a/vscode/src/codeReader/calleeKinds.test.ts b/vscode/src/codeReader/calleeKinds.test.ts new file mode 100644 index 0000000..775ae77 --- /dev/null +++ b/vscode/src/codeReader/calleeKinds.test.ts @@ -0,0 +1,84 @@ +import type { BlockKind, CallFacts, Node } from './contract' +import { describe, expect, it, vi } from 'vitest' +import { DocumentCallees } from './calleeKinds' + +/** 一次调用:只用到被调用的表达式原文,和最后那个名字的下标。 */ +function call(callee: string, nameAt?: number): CallFacts { + const facts = { callee: { text: callee } as Node, argumentList: {} as Node, arguments: [] } + return nameAt === undefined ? facts : { ...facts, name: { startIndex: nameAt } as Node } +} + +/** 各下标处名字的定义是哪种块(语言扩展答的;没有的是没答);记下问了哪些下标。 */ +function source(kinds: Record): { asked: number[], kindOf: (offset: number) => Promise } { + const asked: number[] = [] + return { + asked, + kindOf: async (offset) => { + asked.push(offset) + return kinds[offset] + }, + } +} + +describe('调用的名字定义成哪种块(DocumentCallees)', () => { + it('第一遍当没查到,按被调用的表达式原文记下问了什么(写法一样的按一处问);查到后叫宿主重排,再排时交出来', async () => { + let changed = 0 + const callees = new DocumentCallees(() => changed++, []) + const first = callees.session() + expect(first.calleeKind(call('UserDetailResponse', 40))).toBeUndefined() + expect(first.calleeKind(call('UserDetailResponse', 90))).toBeUndefined() + expect(first.calleeKind(call('user_service.update_user', 120))).toBeUndefined() + expect(first.requests()).toEqual([{ callee: 'UserDetailResponse', offset: 40 }, { callee: 'user_service.update_user', offset: 120 }]) + + const from = source({ 40: 'class', 120: 'function' }) + callees.resolve(1, first.requests(), from.kindOf) + await vi.waitFor(() => expect(changed).toBe(1)) + const second = callees.session() + expect(second.calleeKind(call('UserDetailResponse', 12))).toBe('class') + expect(second.calleeKind(call('user_service.update_user', 7))).toBe('function') + + // 同一版本再发一遍:问过的不再查 + callees.resolve(1, first.requests(), from.kindOf) + await Promise.resolve() + expect(from.asked).toEqual([40, 120]) + }) + + it('被调用的不是名字(没有 name)不问;查了没有(null)不算变了', async () => { + let changed = 0 + const callees = new DocumentCallees(() => changed++, []) + const session = callees.session() + expect(session.calleeKind(call('handlers[k]'))).toBeUndefined() + session.calleeKind(call('build', 5)) + expect(session.requests()).toEqual([{ callee: 'build', offset: 5 }]) + const from = source({ 5: null }) + callees.resolve(1, session.requests(), from.kindOf) + await vi.waitFor(() => expect(from.asked).toEqual([5])) + await Promise.resolve() + expect(changed).toBe(0) + }) + + it('语言扩展还没起来(没答):隔一会儿只再查没答的,查到了再叫重排', async () => { + let changed = 0 + const pending: (() => void)[] = [] + const scheduled: number[] = [] + const callees = new DocumentCallees(() => changed++, [5, 10], (task, ms) => { + scheduled.push(ms) + pending.push(task) + return setTimeout(() => {}, 0) + }) + const session = callees.session() + session.calleeKind(call('Detail', 40)) + session.calleeKind(call('run', 60)) + const kinds: Record = { 60: 'function' } + const from = source(kinds) + callees.resolve(1, session.requests(), from.kindOf) + await vi.waitFor(() => expect(scheduled).toEqual([5])) + expect(changed).toBe(1) + + kinds[40] = 'class' + pending.shift()?.() + await vi.waitFor(() => expect(changed).toBe(2)) + expect(from.asked).toEqual([40, 60, 40]) + expect(callees.session().calleeKind(call('Detail', 40))).toBe('class') + }) +}) diff --git a/vscode/src/codeReader/calleeKinds.ts b/vscode/src/codeReader/calleeKinds.ts new file mode 100644 index 0000000..b7709aa --- /dev/null +++ b/vscode/src/codeReader/calleeKinds.ts @@ -0,0 +1,46 @@ +import type { BlockKind, CallFacts } from './contract' +import { DeferredAnswers } from './deferredAnswers' + +/** + * 调用的名字的定义是哪种块(SourceDocument.calleeKind):翻译插件靠它分新建对象还是调用函数。 + * 按被调用的表达式原文记(`user_service.update_user`),同一份文档里写法一样的调用按一处查;问语言扩展时问它最后那个名字。这里不碰 VS Code。 + */ + +/** 问的一次调用:被调用的表达式原文,和它最后那个名字在源码里的下标。 */ +export interface CalleeRequest { callee: string, offset: number } + +/** 这份文档里 offset 处名字的定义是哪种块:查了没有是 null,语言扩展没答(还没起来)是 undefined。 */ +export type CalleeSource = (offset: number) => Promise + +/** 排一遍视图数据用的:交给翻译插件的 calleeKind,和这一遍问了哪些调用。 */ +export interface CalleeSession { + calleeKind: (call: CallFacts) => BlockKind | undefined + requests: () => CalleeRequest[] +} + +/** 一份打开着的文档里调用的名字查到定义成哪种块;什么时候查、查到了怎么叫宿主重排见 DeferredAnswers。 */ +export class DocumentCallees { + private readonly answers: DeferredAnswers + + constructor(changed: () => void, delays?: readonly number[], schedule?: (run: () => void, ms: number) => ReturnType) { + this.answers = new DeferredAnswers(({ callee }) => callee, (a, b) => (a ?? null) === (b ?? null), changed, delays, schedule) + } + + /** 被调用的不是名字(没有 CallFacts.name)的不问,按调用算。 */ + session(): CalleeSession { + const session = this.answers.session() + return { + calleeKind: call => call.name ? session.ask({ callee: call.callee.text, offset: call.name.startIndex }) : undefined, + requests: session.questions, + } + } + + /** 查 version 这一版问的调用:这个版本查过的不再查。 */ + resolve(version: number, requests: readonly CalleeRequest[], source: CalleeSource): void { + this.answers.resolve(version, requests, ({ offset }) => source(offset)) + } + + dispose(): void { + this.answers.dispose() + } +} diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 59ce795..53e519b 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -179,6 +179,32 @@ export interface IterationFacts { sharedParentheses?: { open: Node, close: Node } } +/** 调用里的一个实参,照源码顺序;注释不算。 */ +export type CallArgument + = | { kind: 'positional', value: Node } // 按位置传的 + | { kind: 'keyword', name: Node, value: Node } // 按名字传的:Python 的 name=value + | { kind: 'spread', value: Node } // 展开成按位置传的几个:*args、...args;value 是展开的那个表达式 + | { kind: 'keywordSpread', value: Node } // 展开成按名字传的几个:Python 的 **kwargs + +/** + * 一次调用(含新建对象):语言插件从语法树读出结构(LanguageAdapter.call),翻译插件把跨几行写的调用排成 + * 「新建 / 调用 被调用的名字 · N 个参数」一行,实参摊成一张表。节点都指向源码。 + */ +export interface CallFacts { + /** 被调用的表达式:`user_service.update_user`、`UserDetailResponse`;TypeScript 的 new 不算在里面。 */ + callee: Node + /** callee 里最后那个名字(点分名字的最后一节):着色、查定义用它;callee 不是名字(`f()()`、`handlers[k]()`)时没有。 */ + name?: Node + /** + * 写法本身说明了是不是新建对象时给:TypeScript 的 `new X()` 是,不带 new 的调用不是。 + * Python 新建对象和调用函数写法一样,不给,翻译插件问 name 的定义是哪种块(SourceDocument.calleeKind)。 + */ + construct?: boolean + /** 括号连同里面的实参:它跨了几行,这次调用就是跨几行写的。 */ + argumentList: Node + arguments: CallArgument[] +} + export interface DeclaratorFacts { /** 变量名;解构声明是整个模式。 */ name: Node @@ -331,6 +357,18 @@ export interface PropertyRow extends Defines { columns?: string[] } +/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行。 */ +export interface PreviewTable { + columns: string[] + items: PropertyItem[] +} + +/** 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */ +export interface TextTranslation { + text: PreviewText + table?: PreviewTable +} + /** * 语言插件:语法树 → 代码块树,外加只有它懂、翻译插件翻表达式时要用的信息(叶子的词性、写在表达式里的遍历的结构)。 * 翻译插件翻一块时从 SourceDocument.language 拿到解析这份源码的语言插件,不直接依赖某个语言插件。 @@ -347,6 +385,8 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined + /** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */ + call?: (node: Node) => CallFacts | undefined /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ displayNames?: DisplayNameReader } @@ -384,6 +424,11 @@ export interface SourceDocument { language: LanguageAdapter /** 写在注释里给代码阅读看的记号(中文名记号,见 DisplayNameFacts.markers):不是说明文字,翻注释、说明时去掉 */ markers: SourceRange[] + /** + * 调用的名字(CallFacts.name)的定义是哪种块:宿主问语言扩展定义在哪,用那个文件的语言插件分块认出来。 + * 排视图数据是同步的,这里只交出已经查到的;还没查到、查不到、宿主不查(没给)都是 undefined,查到了宿主重排、重发。 + */ + calleeKind?: (call: CallFacts) => BlockKind | undefined } /** 筛选属性和值的显示名,筛选栏、块的种类(图标的悬停提示)用它;没给名字的显示 id。 */ @@ -401,8 +446,8 @@ export interface Translator { languageIds: string[] /** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */ displayLanguage: string - /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */ - translate: (block: TextBlock, document: SourceDocument) => PreviewText + /** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文,跨几行写的调用连着摊开实参的表。 */ + translate: (block: TextBlock, document: SourceDocument) => TextTranslation /** 函数、类、枚举、导入、常量、变量块 → 属性表。 */ describe: (block: FactBlock, document: SourceDocument) => PropertyRow[] /** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */ diff --git a/vscode/src/codeReader/deferredAnswers.ts b/vscode/src/codeReader/deferredAnswers.ts new file mode 100644 index 0000000..696f989 --- /dev/null +++ b/vscode/src/codeReader/deferredAnswers.ts @@ -0,0 +1,97 @@ +import { RETRY_DELAYS } from './nameResolution' + +/** 排一遍视图数据用的:同步地问(交出已经查到的),和这一遍问了哪些问题。 */ +export interface AnswerSession { + ask: (question: Q) => A | undefined + questions: () => Q[] +} + +/** + * 查一个问题:查到了是答案,查了没有是 null;语言扩展没答(还没起来)是 undefined,已知的留着不动,隔一会儿再查。 + */ +export type Answer = (question: Q) => Promise + +/** + * 一份打开着的文档里,排视图数据时要问、却要异步才查得到的问题(库规则问的名字的定义、调用的名字定义成哪种块)的答案, + * 按问题的 id 记,id 一样的问题按一个查。这里不碰 VS Code。 + * 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查, + * 每个版本同一个问题查一次(语言扩展没答的照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed,宿主按新结果重排、重发。 + */ +export class DeferredAnswers { + /** 查到的:查了没有是 null */ + private readonly known = new Map() + private version: number | undefined + /** 眼下这个版本查过(或正在查)的问题 */ + private readonly checked = new Set() + private timer: ReturnType | undefined + + constructor( + private readonly idOf: (question: Q) => string, + /** 两个答案对问的人来说一样不一样 */ + private readonly same: (a: A | null | undefined, b: A | null | undefined) => boolean, + private readonly changed: () => void, + private readonly delays: readonly number[] = RETRY_DELAYS, + private readonly schedule: (run: () => void, ms: number) => ReturnType = setTimeout, + ) {} + + session(): AnswerSession { + const asked = new Map() + return { + ask: (question) => { + const id = this.idOf(question) + if (!asked.has(id)) + asked.set(id, question) + return this.known.get(id) ?? undefined + }, + questions: () => [...asked.values()], + } + } + + /** 查 version 这一版问的问题:这个版本查过的不再查。 */ + resolve(version: number, questions: readonly Q[], answer: Answer): void { + if (this.version !== version) { + clearTimeout(this.timer) + this.version = version + this.checked.clear() + } + const fresh = questions.filter(question => !this.checked.has(this.idOf(question))) + for (const question of fresh) + this.checked.add(this.idOf(question)) + if (fresh.length === 0) + return + const attempt = (pending: readonly Q[], tries: number): void => { + void this.check(pending, answer).then(({ unanswered, changed }) => { + if (this.version !== version) + return + if (changed) + this.changed() + if (unanswered.length > 0 && tries < this.delays.length) + this.timer = this.schedule(() => attempt(unanswered, tries + 1), this.delays[tries]) + }) + } + attempt(fresh, 0) + } + + dispose(): void { + clearTimeout(this.timer) + this.version = undefined + } + + /** 查一遍:没答的问题留着已知的不动,交回去等下一次再查。 */ + private async check(questions: readonly Q[], answer: Answer): Promise<{ unanswered: Q[], changed: boolean }> { + const unanswered: Q[] = [] + let changed = false + await Promise.all(questions.map(async (question) => { + const found = await answer(question) + if (found === undefined) { + unanswered.push(question) + return + } + const id = this.idOf(question) + if (!this.same(this.known.get(id), found)) + changed = true + this.known.set(id, found) + })) + return { unanswered, changed } + } +} diff --git a/vscode/src/codeReader/editor/definitions.ts b/vscode/src/codeReader/editor/definitions.ts index 9bbd654..c990f1a 100644 --- a/vscode/src/codeReader/editor/definitions.ts +++ b/vscode/src/codeReader/editor/definitions.ts @@ -1,8 +1,10 @@ import type { Location, LocationLink, Position } from 'vscode' -import type { RuleLocation } from '../contract' +import type { BlockKind, LanguageAdapter, RuleLocation } from '../contract' +import type { TreeSitterRuntime } from '../runtime' import type { DefinitionTarget } from './definitionAction' import { commands, Range, Uri, window, workspace } from 'vscode' import { logger } from '../../logging/logger' +import { readDefinitionKind } from '../blockView' /** 查到的一处定义:文件和定义处那个名字的范围。 */ export interface FoundLocation { uri: Uri, range: Range } @@ -36,6 +38,23 @@ export function ruleDefinitions(locations: readonly RuleLocation[]): FoundLocati return locations.map(({ uri, start, end }) => ({ uri: Uri.parse(uri), range: new Range(start.line, start.character, end.line, end.character) })) } +/** 定义处那个名字定义的是哪种块:打开目标文件,用它那种语言的语言插件分块认;目标打不开、语言不支持、不在哪块的名字上时没有。 */ +export async function definitionKind( + location: FoundLocation, + languageFor: (languageId: string) => LanguageAdapter | undefined, + runtime: Pick, +): Promise { + try { + const document = await workspace.openTextDocument(location.uri) + const adapter = languageFor(document.languageId) + return adapter && await readDefinitionKind(runtime, adapter, document.languageId, document.getText(), document.offsetAt(location.range.start)) + } + catch (err) { + logger.add({ level: 'warn', source: 'codeReader', message: `认不出 ${location.uri.toString()} 里定义的是哪种块:${err instanceof Error ? err.message : String(err)}` }) + return undefined + } +} + /** 查到的各处定义打开目标文件补上行文字、下标;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */ export async function findDefinitions(locations: readonly FoundLocation[], supports: (languageId: string) => boolean): Promise { const found = await Promise.all(locations.map(async (location): Promise => { diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index 3dc4748..b69e405 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -118,6 +118,21 @@ describe('预览的 document 消息', () => { expect(reports).toEqual(['库规则 fastapi 给筛选属性起名时出错,筛选栏照写 id:词表坏了']) }) + it('调用的名字定义成哪种块交给 calleeKind 问:跨几行新建对象的一行译文下面带实参表', async () => { + const source = 'def f(user):\n return Detail(\n id=user.id,\n name=user.name,\n )\n' + const asked: string[] = [] + const { message } = await documentMessage(doc('python', source), registryWithPython(), runtime, ignore, undefined, (call) => { + asked.push(call.callee.text) + return 'class' + }) + expect(asked).toEqual(['Detail']) + expect(message.blocks[0].children[0]).toMatchObject({ + kind: 'return', + translation: [{ text: '返回' }, { text: ' ' }, { text: '新建' }, { text: ' ' }, { text: 'Detail', role: 'type' }, { text: ' ' }, { text: '· 2 个参数', role: 'hint' }], + table: { columns: ['参数', '值'], items: [{ cells: [[{ text: 'id' }], expect.anything()] }, { cells: [[{ text: 'name' }], expect.anything()] }] }, + }) + }) + it('库规则抛错:不算解析失败,交给 report 记下,这块照常显示', async () => { const broken: LibraryRule = { id: 'broken', diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 2bd7cb4..024e6a3 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,4 +1,4 @@ -import type { DisplayName, RuleContext } from '../contract' +import type { DisplayName, RuleContext, SourceDocument } from '../contract' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { DocumentMessage } from './messages' @@ -21,7 +21,8 @@ export interface DocumentRead { /** * 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下; - * 规则查名字的定义时问 lookup(RuleContext.lookup)。筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。 + * 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。 + * 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。 */ export async function documentMessage( document: DocumentSnapshot, @@ -29,6 +30,7 @@ export async function documentMessage( runtime: Pick, report: (message: string) => void, lookup?: RuleContext['lookup'], + calleeKind?: SourceDocument['calleeKind'], ): Promise { const { languageId, version } = document const source = document.getText() @@ -48,7 +50,7 @@ export async function documentMessage( return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] } try { const rules = registry.rulesFor(languageId) - const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup }) + const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup }, calleeKind) const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)]) return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames } } diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 1513195..61a82b3 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,7 +1,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { BlockView } from '../blockView' -import type { RuleLocation, RuleReference, WorkspaceIndex } from '../contract' +import type { BlockKind, RuleLocation, RuleReference, WorkspaceIndex } from '../contract' import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' @@ -14,13 +14,14 @@ import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode' import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' +import { DocumentCallees } from '../calleeKinds' import { blockReplacement } from '../editText' import { locate } from '../folderStore' import { DocumentNames, resolveNames } from '../nameResolution' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' import { definitionAction } from './definitionAction' -import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' +import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' import { DefinitionSources } from './definitionSources' import { documentMessage } from './documentMessage' import { NavigationContext, OCCURRENCES_CONTEXT } from './occurrenceNavigation' @@ -131,6 +132,8 @@ class Preview { private readonly names: DocumentNames /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ private readonly lookups: DocumentLookups + /** 调用的名字定义成哪种块(分新建还是调用):查到的变了就重发文档 */ + private readonly callees: DocumentCallees constructor( private readonly document: TextDocument, @@ -154,6 +157,7 @@ class Preview { this.post({ type: 'displayNames', version, names }) }) this.lookups = new DocumentLookups(() => this.schedulePush()) + this.callees = new DocumentCallees(() => this.schedulePush()) this.disposables = [ panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { @@ -223,9 +227,10 @@ class Preview { const { languageId, version } = this.document const source = this.document.getText() const session = this.lookups.session() + const callees = this.callees.session() const { message, displayNames } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) - }, session.lookup) + }, session.lookup, callees.calleeKind) if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) @@ -237,9 +242,24 @@ class Preview { if (version === this.document.version) { this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report)) this.lookups.resolve(version, session.requests(), this.lookupSources()) + this.callees.resolve(version, callees.requests(), offset => this.calleeKind(offset)) } } + /** 这份文档里 offset 处名字的定义是哪种块:语言扩展查到的几处定义里第一处认得出的;语言扩展没答是 undefined。 */ + private async calleeKind(offset: number): Promise { + const { document } = this + const found = await languageDefinitions(document.uri, document.positionAt(offset)) + if (found.length === 0) + return undefined + for (const location of found) { + const kind = await definitionKind(location, languageId => this.registry.languageFor(languageId), this.runtime) + if (kind) + return kind + } + return null + } + /** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */ private async definitionStarts(offset: number): Promise { const { document } = this @@ -284,6 +304,7 @@ class Preview { clearTimeout(this.timer) this.names.dispose() this.lookups.dispose() + this.callees.dispose() for (const disposable of this.disposables) disposable.dispose() } diff --git a/vscode/src/codeReader/fixtures/calls.ts.txt b/vscode/src/codeReader/fixtures/calls.ts.txt new file mode 100644 index 0000000..f678222 --- /dev/null +++ b/vscode/src/codeReader/fixtures/calls.ts.txt @@ -0,0 +1,37 @@ +import { window } from 'vscode' +import { DefinitionSources } from './definitionSources' + +export async function removeWorktree(panel: Panel, relPath: string, issueNumber: number): Promise { + const state = await panel.readIssueState(issueNumber) + const branch = typeof state.branch === 'string' ? state.branch : '' + const choice = await window.showWarningMessage( + branch ? `确认删除 worktree ${relPath} 及其分支 ${branch}?` : `确认删除 worktree ${relPath}?`, + { modal: true }, + '删除', + ) + if (choice !== '删除') + return +} + +class Preview { + constructor(document: TextDocument, owner: Owner) { + this.definitions = new DefinitionSources({ + rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), + language: offset => languageDefinitions(document.uri, document.positionAt(offset)), + }) + } +} + +export function commentsUrl(opts: Options): URL { + const url = new URL( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, + ) + return url +} + +export function merged(base: string[], extra: string[]): string[] { + return concatAll( + base, + ...extra, + ) +} diff --git a/vscode/src/codeReader/languages/python.test.ts b/vscode/src/codeReader/languages/python.test.ts index d4af3a3..3231910 100644 --- a/vscode/src/codeReader/languages/python.test.ts +++ b/vscode/src/codeReader/languages/python.test.ts @@ -1,7 +1,7 @@ import type { IterationFacts, Node } from '../contract' import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { factsByLine, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport' +import { factsByLine, nodeFacts, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport' import { createPythonAdapter } from './python' const adapter = createPythonAdapter(WASM_DIR) @@ -488,3 +488,28 @@ describe('python 写在表达式里的遍历(推导式)', () => { expect(await iterations('x = [1, 2]\ny = {"a": 1}\nz = (a, b)\nfor x in y:\n pass')).toEqual([]) }) }) + +describe('python 调用', () => { + const calls = (source: string): Promise => nodeFacts(adapter, 'python', source, node => adapter.call?.(node)) + + it('实参照源码顺序:按位置、按名字、*x、**x;新建对象和调用写法一样,不给 construct;点分名字取最后一节', async () => { + const source = 'user_service.update_user(\n db, # 会话\n user_id=user_id,\n *rest,\n **options,\n)' + expect(await calls(source)).toEqual([{ + callee: 'user_service.update_user', + name: 'update_user', + argumentList: '(\n db, # 会话\n user_id=user_id,\n *rest,\n **options,\n)', + arguments: [ + { kind: 'positional', value: 'db' }, + { kind: 'keyword', name: 'user_id', value: 'user_id' }, + { kind: 'spread', value: 'rest' }, + { kind: 'keywordSpread', value: 'options' }, + ], + }]) + }) + + it('唯一的实参是生成器表达式时就是它;被调用的不是名字时没有 name;装饰器、父类列表不是调用', async () => { + expect(await calls('sum(x for x in xs)')).toEqual([{ callee: 'sum', name: 'sum', argumentList: '(x for x in xs)', arguments: [{ kind: 'positional', value: '(x for x in xs)' }] }]) + expect(await calls('handlers[k]()')).toEqual([{ callee: 'handlers[k]', argumentList: '()', arguments: [] }]) + expect(await calls('@cache\nclass A(Base):\n pass')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index e9a0371..67c4c75 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,4 +1,4 @@ -import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules, ExceptionRules } from './shared' import * as path from 'node:path' import { pythonNameReader } from './pythonNames' @@ -363,6 +363,42 @@ function pythonIteration(node: Node): IterationFacts | undefined { } /** elif / else 展平成紧跟 if 的兄弟块。 */ +/** 调用的实参:name=value 按名字传,*x、**x 展开,别的按位置传;唯一的实参是生成器表达式(f(x for x in y))时就是它。 */ +function callArgument(arg: Node): CallArgument | undefined { + switch (arg.type) { + case 'keyword_argument': { + const name = arg.childForFieldName('name') + const value = arg.childForFieldName('value') + return name && value ? { kind: 'keyword', name, value } : undefined + } + case 'list_splat': + case 'dictionary_splat': { + const [value] = syntaxChildren(arg) + if (!value) + return undefined + return { kind: arg.type === 'list_splat' ? 'spread' : 'keywordSpread', value } + } + default: + return { kind: 'positional', value: arg } + } +} + +/** 调用的结构:新建对象和调用函数写法一样,不给 construct。被调用的是名字或点分名字时 name 是最后那个名字。 */ +function pythonCall(node: Node): CallFacts | undefined { + const callee = node.type === 'call' ? node.childForFieldName('function') : null + const list = node.childForFieldName('arguments') + if (!callee || !list) + return undefined + const name = callee.type === 'identifier' ? callee : callee.type === 'attribute' ? callee.childForFieldName('attribute') : null + const args = list.type === 'generator_expression' ? [list] : syntaxChildren(list) + return { + callee, + ...(name && { name }), + argumentList: list, + arguments: args.map(callArgument).filter(arg => arg !== undefined), + } +} + function ifBlocks(node: Node): CodeBlock[] { const blocks = [bodyBlock('if', node, bodyOf(node), 'other')] for (const alternative of node.childrenForFieldName('alternative')) { @@ -603,6 +639,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { toBlocks: root => withExceptions(statements(named(root), 'module', -1), PYTHON_EXCEPTIONS), tokenRole: pythonTokenRole, iteration: pythonIteration, + call: pythonCall, displayNames: pythonNameReader, } } diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index 1844724..5217fef 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -1,6 +1,6 @@ import type { Outline } from '../testSupport' import { describe, expect, it } from 'vitest' -import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { factsByLine, nodeFacts, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport' import { createTypeScriptAdapter } from './typescript' const adapter = createTypeScriptAdapter(WASM_DIR) @@ -612,3 +612,23 @@ describe('typeScript 枚举', () => { expect(byLine[1]).toMatchObject({ members: [{ name: 'A', docs: [] }, { name: 'B', docs: ['/** 第二 */'] }, { name: 'C', docs: ['// 第三'] }] }) }) }) + +describe('typeScript 调用', () => { + const calls = (source: string): Promise => nodeFacts(adapter, 'typescript', source, node => adapter.call?.(node)) + + it('new X(…) 是新建,被调用的不含 new;别的调用不是新建;...x 是展开;点分名字取最后一节', async () => { + expect(await calls('new vscode.Uri(a, ...rest)')).toEqual([{ + callee: 'vscode.Uri', + name: 'Uri', + construct: true, + argumentList: '(a, ...rest)', + arguments: [{ kind: 'positional', value: 'a' }, { kind: 'spread', value: 'rest' }], + }]) + expect(await calls('run({ a: 1 })')).toEqual([{ callee: 'run', name: 'run', construct: false, argumentList: '({ a: 1 })', arguments: [{ kind: 'positional', value: '{ a: 1 }' }] }]) + }) + + it('带标签的模板字符串、不带括号的 new X 不是调用', async () => { + expect(await calls('sql`select 1`')).toEqual([]) + expect(await calls('new Date')).toEqual([]) + }) +}) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 6ad4b07..70e66aa 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,4 +1,4 @@ -import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules, ExceptionRules } from './shared' import * as path from 'node:path' import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared' @@ -542,6 +542,26 @@ const LITERAL_KEYWORDS = new Set(['true', 'false', 'null', 'undefined', 'this', const STRING_NODES = new Set(['string', 'template_string', 'regex']) const FUNCTION_NAMED = new Set(['function_declaration', 'generator_function_declaration', 'function_expression', 'function', 'generator_function', 'method_definition', 'method_signature', 'abstract_method_signature', 'function_signature']) +/** 调用的结构:`new X(…)` 是新建对象,别的调用不是;带标签的模板字符串(sql`…`)、不带括号的 `new X` 不算。 */ +function typescriptCall(node: Node): CallFacts | undefined { + const construct = node.type === 'new_expression' + const callee = construct ? node.childForFieldName('constructor') : node.type === 'call_expression' ? node.childForFieldName('function') : null + const list = node.childForFieldName('arguments') + if (!callee || list?.type !== 'arguments') + return undefined + const name = callee.type === 'identifier' ? callee : callee.type === 'member_expression' ? callee.childForFieldName('property') : null + return { + callee, + ...(name && { name }), + construct, + argumentList: list, + arguments: syntaxChildren(list).map((arg): CallArgument => { + const spread = arg.type === 'spread_element' ? syntaxChildren(arg)[0] : undefined + return spread ? { kind: 'spread', value: spread } : { kind: 'positional', value: arg } + }), + } +} + function insideString(leaf: Node): boolean { for (let node = leaf.parent; node; node = node.parent) { if (node.type === 'template_substitution') @@ -620,6 +640,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { }, toBlocks: (root, _source, languageId) => withExceptions(blockReader(TYPED_LANGUAGES.has(languageId))(root), TYPESCRIPT_EXCEPTIONS), tokenRole: typescriptTokenRole, + call: typescriptCall, displayNames: typescriptNameReader, } } diff --git a/vscode/src/codeReader/nameResolution.test.ts b/vscode/src/codeReader/nameResolution.test.ts index 2b41f58..6b7405e 100644 --- a/vscode/src/codeReader/nameResolution.test.ts +++ b/vscode/src/codeReader/nameResolution.test.ts @@ -96,6 +96,13 @@ describe('中文名落到哪些名字上', () => { expect(rendered.get(at(ROUTES, 'delete_item'))).toBe('delete_item') }) + it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)里带下标的名字也算', async () => { + const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n' + const rendered = renderedNames((await read(source)).blocks) + expect(rendered.get(at(source, 'id='))).toBe('id') + expect(rendered.get(at(source, 'user.id'))).toBe('user') + }) + it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => { const { blocks, displayNames } = await read(ROUTES) const definition = at(ROUTES, 'current_user') diff --git a/vscode/src/codeReader/nameResolution.ts b/vscode/src/codeReader/nameResolution.ts index 3fa0bfa..998bb5f 100644 --- a/vscode/src/codeReader/nameResolution.ts +++ b/vscode/src/codeReader/nameResolution.ts @@ -15,7 +15,7 @@ export interface ShownName { definition?: true } -/** 视图数据里照抄自源码、带下标的名字(一行译文、属性表的值、各层子行的格子、框里):下标 → 原文。 */ +/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面的表、属性表的值、各层子行的格子、框里):下标 → 原文。 */ export function renderedNames(blocks: readonly BlockView[]): Map { const found = new Map() const inline = (pieces: readonly Inline[]): void => { @@ -46,6 +46,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map } else { text(block.translation) + items(block.table?.items) } visit(block.children) } diff --git a/vscode/src/codeReader/registry.test.ts b/vscode/src/codeReader/registry.test.ts index a70d31f..28d6305 100644 --- a/vscode/src/codeReader/registry.test.ts +++ b/vscode/src/codeReader/registry.test.ts @@ -17,7 +17,7 @@ function adapter(id: string, languageIds: string[]): LanguageAdapter { } function translator(id: string, languageIds: string[]): Translator { - return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => [], describe: () => [], expression: () => [], doc: () => undefined } + return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => ({ text: [] }), describe: () => [], expression: () => [], doc: () => undefined } } function rule(id: string, languageIds: string[]): LibraryRule { diff --git a/vscode/src/codeReader/ruleLookups.ts b/vscode/src/codeReader/ruleLookups.ts index 6fa4887..5149b6e 100644 --- a/vscode/src/codeReader/ruleLookups.ts +++ b/vscode/src/codeReader/ruleLookups.ts @@ -1,7 +1,7 @@ import type { IndexEntry, RuleContext, WorkspaceIndex } from './contract' import type { DefinitionStart } from './nameResolution' import type { IndexedEntry } from './workspaceIndex' -import { RETRY_DELAYS } from './nameResolution' +import { DeferredAnswers } from './deferredAnswers' /** * 库规则认领时查的名字(RuleContext.lookup):名字的定义在规则的工作区索引里是哪一项。 @@ -33,91 +33,44 @@ function same(a: IndexEntry | null | undefined, b: IndexEntry | null | undefined } /** - * 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查。 - * 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查, - * 每个版本同一个名字查一次(索引里有同名的项、语言扩展却没答时照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed, - * 宿主按新结果重排、重发。 + * 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查; + * 什么时候查、查到了怎么叫宿主重排见 DeferredAnswers。 */ export class DocumentLookups { - /** 查到的:没有这个定义是 null */ - private readonly known = new Map() - private version: number | undefined - /** 眼下这个版本查过(或正在查)的名字 */ - private readonly checked = new Set() - private timer: ReturnType | undefined + private readonly answers: DeferredAnswers - constructor( - private readonly changed: () => void, - private readonly delays: readonly number[] = RETRY_DELAYS, - private readonly schedule: (run: () => void, ms: number) => ReturnType = setTimeout, - ) {} + constructor(changed: () => void, delays?: readonly number[], schedule?: (run: () => void, ms: number) => ReturnType) { + this.answers = new DeferredAnswers(({ index, key }) => idOf(index, key), same, changed, delays, schedule) + } session(): LookupSession { - const requests = new Map() + const session = this.answers.session() return { - lookup: (index, name) => { - const id = idOf(index, name.text) - if (!requests.has(id)) - requests.set(id, { index, key: name.text, offset: name.startIndex }) - return this.known.get(id) ?? undefined - }, - requests: () => [...requests.values()], + lookup: (index, name) => session.ask({ index, key: name.text, offset: name.startIndex }), + requests: session.questions, } } /** 查 version 这一版规则问的名字:这个版本查过的不再查。 */ resolve(version: number, requests: readonly LookupRequest[], sources: LookupSources): void { - if (this.version !== version) { - clearTimeout(this.timer) - this.version = version - this.checked.clear() - } - const fresh = requests.filter(request => !this.checked.has(idOf(request.index, request.key))) - for (const request of fresh) - this.checked.add(idOf(request.index, request.key)) - if (fresh.length === 0) - return - const attempt = (tries: number): void => { - void this.check(fresh, sources).then(({ settled, changed }) => { - if (this.version !== version) - return - if (changed) - this.changed() - if (!settled && tries < this.delays.length) - this.timer = this.schedule(() => attempt(tries + 1), this.delays[tries]) - }) - } - attempt(0) + this.answers.resolve(version, requests, request => find(request, sources)) } dispose(): void { - clearTimeout(this.timer) - this.version = undefined - } - - /** - * 查一遍:索引里没有同名的项就是没有这个定义;有就问语言扩展,定义处正是其中一项才算。 - * 语言扩展没答(还没起来)的名字留着已知的不动,算没查完(settled 为 false)。 - */ - private async check(requests: readonly LookupRequest[], sources: LookupSources): Promise<{ settled: boolean, changed: boolean }> { - let settled = true - let changed = false - await Promise.all(requests.map(async ({ index, key, offset }) => { - const id = idOf(index, key) - const candidates = await sources.find(index, key) - let found: IndexEntry | null = null - if (candidates.length > 0) { - const definitions = await sources.definitions(offset) - if (definitions.length === 0) { - settled = false - return - } - found = candidates.find(({ location }) => definitions.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character))?.entry ?? null - } - if (!same(this.known.get(id), found)) - changed = true - this.known.set(id, found) - })) - return { settled, changed } + this.answers.dispose() } } + +/** + * 查规则问的一个名字:索引里没有同名的项就是没有这个定义;有就问语言扩展,定义处正是其中一项才算。 + * 语言扩展没答(还没起来)是 undefined。 + */ +async function find({ index, key, offset }: LookupRequest, sources: LookupSources): Promise { + const candidates = await sources.find(index, key) + if (candidates.length === 0) + return null + const definitions = await sources.definitions(offset) + if (definitions.length === 0) + return undefined + return candidates.find(({ location }) => definitions.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character))?.entry ?? null +} diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index a59dfa7..de84b00 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -82,6 +82,28 @@ export function nodesToText(value: unknown): unknown { return value } +/** 源码里 read 认得出的每个节点(先序,外层在前)读出的结构,节点换成源码。 */ +export async function nodeFacts(adapter: LanguageAdapter, languageId: string, source: string, read: (node: Node) => unknown): Promise { + const tree = await runtime.parse(adapter, languageId, source) + try { + const found: unknown[] = [] + const visit = (node: Node): void => { + const facts = read(node) + if (facts) + found.push(nodesToText(facts)) + for (const child of node.children) { + if (child) + visit(child) + } + } + visit(tree.rootNode) + return found + } + finally { + tree.delete() + } +} + /** 每个属性表块的 facts(节点换成源码),按 header 起始行(1 起)索引。 */ export function factsByLine(blocks: CodeBlock[]): Record { const byLine: Record = {} diff --git a/vscode/src/codeReader/translators/contractOnly.test.ts b/vscode/src/codeReader/translators/contractOnly.test.ts index b6bf848..4cf755c 100644 --- a/vscode/src/codeReader/translators/contractOnly.test.ts +++ b/vscode/src/codeReader/translators/contractOnly.test.ts @@ -53,8 +53,8 @@ const translator = createZhTemplateTranslator({ function translateFirst(language: LanguageAdapter, source: string): Promise { return withBlocks(language, 'fake', source, ([block]) => { - const translation = translator.translate(block as TextBlock, { source, language, markers: [] }) - return 'markdown' in translation ? translation.markdown : segmentText(translation) + const { text } = translator.translate(block as TextBlock, { source, language, markers: [] }) + return 'markdown' in text ? text.markdown : segmentText(text) }) } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 292c1aa..a79d070 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,4 +1,4 @@ -import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, TokenRole, Translator } from '../contract' +import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' @@ -11,34 +11,37 @@ import { createZhTemplateTranslator } from './zhTemplate' const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) -/** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ -interface Translated { kind: string, segments: Inline[], markdown?: string } +/** 一块的译文:一行译文在 segments 里,下面摊开实参的表在 table 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */ +interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable } -/** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */ -function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { +/** + * 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 + * calleeKind 同 SourceDocument.calleeKind(调用的名字定义成哪种块),不给就是宿主不查。 + */ +function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 if (byLine.has(line) || 'facts' in block) return - const translation = translator.translate(block, { source, language: adapter, markers: [] }) + const { text: translation, table } = translator.translate(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) }) byLine.set(line, 'markdown' in translation ? { kind: block.kind, segments: [], markdown: translation.markdown } - : { kind: block.kind, segments: translation }) + : { kind: block.kind, segments: translation, ...(table && { table }) }) }) return byLine }) } -/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引。 */ -function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise> { +/** 属性表块(函数、导入、常量、变量)的属性表,按 header 起始行(1 起)索引;calleeKind 同 translateAll。 */ +function describeAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, calleeKind?: SourceDocument['calleeKind']): Promise> { return withBlocks(adapter, languageId, source, (blocks) => { const byLine = new Map() walk(blocks, (block) => { const line = block.header.startRow + 1 if (!byLine.has(line) && 'facts' in block) - byLine.set(line, translator.describe(block, { source, language: adapter, markers: [] })) + byLine.set(line, translator.describe(block, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })) }) return byLine }) @@ -165,12 +168,12 @@ describe('typeScript 中文直译', () => { it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => { const source = [ - 'call(', + 'list = [', ' first, // 第一个', ' [],', - ')', + ']', ].join('\n') - expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('call( first, 空数组 )') + expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = [ first, 空数组 ]') }) it('as / in / extends / default 按所在语法节点换译法', async () => { @@ -417,8 +420,10 @@ describe('python 写在表达式里的遍历(推导式)', () => { 5: '⟦集合|遍历 pairs 的每个 n, _,取 n⟧', 6: '⟦列表|遍历 grid 的每个 row,取 ⟦列表|遍历 row 的每个 y,取 y⟧⟧', 7: 'pick(a, ⟦生成器|遍历 b 的每个 y,取 y⟧)', - 8: 'sum(⟦生成器|遍历 counts 的每个 n,取 n⟧)', + 8: '调用 sum · 1 个参数', }) + // 跨几行写的调用唯一的实参是生成器:共用的那对括号属于调用,实参表里只有框 + expect(propertyLines(map.get(8) ?? []).at(-1)).toBe(' 第 1 个 | ⟦生成器|遍历 counts 的每个 n,取 n⟧') const async = valueOf(map.get(3), '初始值')[0] expect('segments' in async && async.segments.filter(piece => 'role' in piece && piece.role === 'keyword')).toEqual([ { text: '异步遍历', role: 'keyword' }, @@ -1028,6 +1033,173 @@ describe('源码下标(跳到定义用)', () => { }) }) +describe('跨几行写的调用', () => { + const routes = readFixture('routes.py') + const calls = readFixture('calls.ts.txt') + + /** 调用的名字定义成哪种块(按被调用的表达式原文查,没有的是还没查到),并记下问了哪些。 */ + function kinds(known: Record): { asked: string[], calleeKind: (call: CallFacts) => BlockKind | undefined } { + const asked: string[] = [] + return { + asked, + calleeKind: (call) => { + asked.push(call.callee.text) + return known[call.callee.text] + }, + } + } + + function tableLines(table: PreviewTable | undefined): string[] { + return (table?.items ?? []).map(item => item.cells.map(segmentText).join(' | ')) + } + + const USER_DETAIL = [ + 'id | user.id', + 'username | user.username', + 'display_name | user.display_name', + 'role | user.role.value', + 'email | user.email', + 'phone | user.phone', + 'bio | user.bio', + 'avatar_url | user.avatar_url', + 'is_active | user.is_active', + 'created_at | user.created_at', + 'updated_at | user.updated_at', + ] + + it('返回里新建对象(名字定义成类):一行「返回 新建 UserDetailResponse · 11 个参数」,实参摊成表(参数 | 值),按名字传的写名字', async () => { + const { asked, calleeKind } = kinds({ UserDetailResponse: 'class' }) + const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45) + expect(colors(line?.segments)).toEqual([ + { text: '返回', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: '新建', role: 'storage' }, + { text: ' ', role: 'plain' }, + { text: 'UserDetailResponse', role: 'type' }, + { text: ' ', role: 'plain' }, + { text: '· 11 个参数', role: 'hint' }, + ]) + expect(line?.table?.columns).toEqual(['参数', '值']) + expect(tableLines(line?.table)).toEqual(USER_DETAIL) + expect(asked).toContain('UserDetailResponse') + // 被调用的名字、按名字传的名字、值里的名字都带源码下标:能跳到定义、显示中文名 + const call = routes.indexOf('UserDetailResponse(') + expect(line?.segments.find(piece => 'role' in piece && piece.role === 'type')).toMatchObject({ offset: call }) + expect(line?.table?.items[0].cells).toEqual([ + [{ text: 'id', role: 'variable', offset: routes.indexOf('id=user.id', call) }], + [ + { text: 'user', role: 'variable', offset: routes.indexOf('user.id', call) }, + { text: '.', role: 'plain' }, + { text: 'id', role: 'variable', offset: routes.indexOf('user.id', call) + 5 }, + ], + ]) + }) + + it('名字的定义还没查到、查不到、是函数都按调用算:被调用的名字按函数着色', async () => { + for (const calleeKind of [undefined, kinds({}).calleeKind, kinds({ UserDetailResponse: 'function' }).calleeKind]) { + const line = (await translateAll(py, 'python', pythonZhTranslator, routes, calleeKind)).get(45) + expect(text(line?.segments)).toBe('返回 调用 UserDetailResponse · 11 个参数') + expect(line?.segments.find(piece => 'role' in piece && piece.text === 'UserDetailResponse')).toMatchObject({ role: 'function' }) + } + const exception = (await translateAll(py, 'python', pythonZhTranslator, routes, kinds({ UserDetailResponse: 'exception' }).calleeKind)).get(96) + expect(text(exception?.segments)).toBe('返回 新建 UserDetailResponse · 11 个参数') + }) + + it('变量的初始值里跨几行调用:初始值一行「等待 调用 user_service.update_user · 10 个参数」,实参表展开在这一行下面,按位置传的写「第 N 个」', async () => { + const rows = (await describeAll(py, 'python', pythonZhTranslator, routes, kinds({}).calleeKind)).get(83) ?? [] + expect(propertyLines(rows)).toEqual([ + '名称: user', + '初始值: 等待 调用 user_service.update_user · 10 个参数', + ' 第 1 个 | db', + ' user_id | user_id', + ' display_name | body.display_name', + ' email | body.email', + ' phone | body.phone', + ' bio | body.bio', + ' avatar_url | body.avatar_url', + ' role | body.role', + ' is_active | body.is_active', + ' password | body.password', + ]) + const value = rows.find(row => row.label === '初始值') + expect(value?.columns).toEqual(['参数', '值']) + expect(colors(value?.value)).toEqual([ + { text: '等待', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: '调用', role: 'storage' }, + { text: ' ', role: 'plain' }, + { text: 'user_service', role: 'variable' }, + { text: '.', role: 'plain' }, + { text: 'update_user', role: 'function' }, + { text: ' ', role: 'plain' }, + { text: '· 10 个参数', role: 'hint' }, + ]) + expect(value?.items?.[0].cells[0]).toEqual([{ text: '第 1 个', role: 'hint' }]) + }) + + it('一行写完的调用照旧一行译文,不问定义', async () => { + const { asked, calleeKind } = kinds({}) + const map = await describeAll(py, 'python', pythonZhTranslator, routes, calleeKind) + expect(propertyLines(map.get(43) ?? [])).toEqual(['名称: user', '初始值: 等待 user_service.get_user(db, user_id=user_id)']) + expect(propertyLines(map.get(118) ?? [])).toEqual(['名称: payload', '初始值: 等待 file.read()']) + expect(asked).not.toContain('user_service.get_user') + expect(asked).not.toContain('file.read') + }) + + it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的;实参里的调用不再排成表', async () => { + const source = [ + 'run(', + ' first,', + ' *rest,', + ' second,', + ' key=Inner(', + ' x=1,', + ' ),', + ' **options,', + ')', + ].join('\n') + const line = (await translateAll(py, 'python', pythonZhTranslator, source)).get(1) + expect(text(line?.segments)).toBe('调用 run · 5 个参数') + expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | Inner( x=1 )', '展开关键字 | options']) + }) + + it('typeScript:new 写法就是新建,不带 new 的是调用,都不问定义;...x 写「展开」', async () => { + const { asked, calleeKind } = kinds({}) + const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind) + expect(text(texts.get(18)?.segments)).toBe('this.definitions = 新建 DefinitionSources · 1 个参数') + expect(tableLines(texts.get(18)?.table)).toEqual([ + '第 1 个 | { rule: 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)) }', + ]) + expect(text(texts.get(33)?.segments)).toBe('返回 调用 concatAll · 2 个参数') + expect(tableLines(texts.get(33)?.table)).toEqual(['第 1 个 | base', '展开 | extra']) + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind) + // 实参是模板字符串:照抄 + expect(propertyLines(rows.get(7) ?? []).slice(1)).toEqual([ + '初始值: 等待 调用 window.showWarningMessage · 3 个参数', + expect.stringMatching(/^ {2}第 1 个 \| 如果 branch 则 `确认删除 worktree [^`]+` 否则 `确认删除 worktree [^`]+`$/), + ' 第 2 个 | { modal: 真 }', + ' 第 3 个 | \'删除\'', + ]) + expect(propertyLines(rows.get(26) ?? []).slice(1)).toEqual(['初始值: 新建 URL · 1 个参数', expect.stringMatching(/^ {2}第 1 个 \| `[^`]+\/comments`$/)]) + expect(asked).toEqual([]) + }) + + it('链式调用跨了几行、实参列表各自写在一行里的不排成表;回调主体不在块头部里的调用也不排', async () => { + const source = [ + 'const ids = items', + ' .filter(a => a.ok)', + ' .map(a => a.id)', + 'describe(\'x\', () => {', + ' run()', + '})', + ].join('\n') + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + expect(propertyLines(rows.get(1) ?? []).at(-1)).toBe('值: items .filter(a => a.ok) .map(a => a.id)') + const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, source) + expect(texts.get(4)).toEqual(expect.not.objectContaining({ table: expect.anything() })) + }) +}) + describe('中文模板引擎', () => { function testTranslator(table: Partial): Translator { return createZhTemplateTranslator({ @@ -1091,14 +1263,14 @@ describe('中文模板引擎', () => { const source = [ 'n += a if k in seen else b', 'print(a if c else b if d else e)', - 'print((a # 注释不占序号', - ' if k == 1 else b))', + 'return (a # 注释不占序号', + ' if k == 1 else b)', ].join('\n') const map = await translateAll(pythonColored('variable'), 'python', translator, source) expect(texts(map)).toEqual({ 1: '令 n 加上 若 k 在 seen 则 a 不然 b', 2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)', - 3: 'print((若 k 等于 1 则 a 不然 b))', + 3: 'return (若 k 等于 1 则 a 不然 b)', }) expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword').map(segment => segment.text)) .toEqual(['令', '加上', '若', '则', '不然']) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 035ade6..d049a3a 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewTable, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -68,12 +68,23 @@ interface Token { group?: SegmentGroup } -/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */ +/** 跨几行写的调用排成一行、实参摊成表(见 emitCall):只排遇到的第一个,它的表记在 table 里。 */ +interface CallSlot { + table?: PreviewTable +} + +/** + * 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 + * calls 是这一段里跨几行写的调用要不要排成一行、实参摊成表;没有就照常翻。 + */ interface Collector { document: SourceDocument + /** 要翻的那个节点 */ + root: Node tokens: Token[] cursor: number gap: boolean + calls?: CallSlot } const CONTEXT_SEPARATOR = ' > ' @@ -165,6 +176,9 @@ const KIND_ZH: Record = { } const ACCESS_ZH: Record = { public: '公共', private: '私有', protected: '受保护' } const ITERATION_ZH: Record = { generator: '生成器', list: '列表', set: '集合', dict: '字典' } +/** 被调用的名字定义成这几种块时,调用就是新建对象。 */ +const CONSTRUCTED = new Set(['class', 'exception', 'enum']) +const ARGUMENT_COLUMNS = ['参数', '值'] const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' } /** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */ @@ -302,9 +316,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return { style: 'iteration', label: ITERATION_ZH[facts.result], segments } } - /** 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 */ + /** + * 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 + * 只翻这个遍历(它是调用的实参,排在实参表里)时外面的调用不在译文里,这对括号也不写。 + */ function emitIteration(out: Collector, node: Node, facts: IterationFacts, span: SourceSpan): void { - const shared = facts.sharedParentheses + const alone = node.startIndex === out.root.startIndex && node.endIndex === out.root.endIndex + const shared = alone ? undefined : facts.sharedParentheses const bracket = (paren: Node): Omit => ({ text: paren.text, role: out.document.language.tokenRole(paren), translated: false }) if (shared) emitSource(out, shared.open, bracket(shared.open)) @@ -318,6 +336,60 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { out.cursor = node.endIndex } + /** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的调用。 */ + function expandedCall(node: Node, span: SourceSpan, calls: CallSlot, { language: { call } }: SourceDocument): CallFacts | undefined { + if (calls.table || !call || node.startIndex < span.startIndex || node.endIndex > span.endIndex) + return undefined + const facts = call(node) + return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined + } + + /** + * 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的), + * 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs);值照常翻,里面的调用不再排成表。 + */ + function argumentTable(facts: CallFacts, span: SourceSpan, document: SourceDocument): PreviewTable { + const value = (node: Node): Inline[] => joinTokens(tokensIn(node, span, document)) + let position = 0 + const label = (arg: CallArgument): Inline[] => { + switch (arg.kind) { + case 'keyword': + return [segment(arg.name.text, 'variable', offsetOf(arg.name, document.language))] + case 'positional': + position++ + return [hint(`第 ${position} 个`)] + case 'spread': + return [hint('展开')] + case 'keywordSpread': + return [hint('展开关键字')] + } + } + return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(arg => ({ cells: [label(arg), value(arg.value)] })) } + } + + /** + * 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 calls 里。被调用的名字照常翻、带源码下标, + * 最后那个名字新建时按类型、调用时按函数着色,前面几节按变量着色。新建还是调用看写法(CallFacts.construct), + * 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。 + */ + function emitCall(out: Collector, calls: CallSlot, node: Node, facts: CallFacts, span: SourceSpan): void { + calls.table = argumentTable(facts, span, out.document) + const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts)) + advance(out, node.startIndex) + emit(out, { text: construct ? '新建' : '调用', role: 'storage', translated: true, raw: '' }) + out.gap = true + out.cursor = facts.callee.startIndex + const from = out.tokens.length + collect(facts.callee, span, out) + for (const token of out.tokens.slice(from)) { + if (token.offset !== undefined && NAME_ROLES.has(token.role)) + token.role = token.offset !== facts.name?.startIndex ? 'variable' : construct ? 'type' : 'function' + } + out.gap = true + emit(out, { text: `· ${facts.arguments.length} 个参数`, role: 'hint', translated: true, raw: '' }) + out.cursor = node.endIndex + } + function collect(node: Node, span: SourceSpan, out: Collector): void { if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex) return @@ -331,6 +403,12 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { emitIteration(out, node, iteration, span) return } + const { calls } = out + const call = calls && expandedCall(node, span, calls, out.document) + if (calls && call) { + emitCall(out, calls, node, call, span) + return + } const parts = templateParts(node, span) if (parts) { advance(out, node.startIndex) @@ -362,9 +440,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } } - /** 按译文顺序收集 node 落在 span 里的词。 */ - function tokensIn(node: Node, span: SourceSpan, document: SourceDocument): Token[] { - const out: Collector = { document, tokens: [], cursor: span.startIndex, gap: false } + /** 按译文顺序收集 node 落在 span 里的词;给了 calls,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */ + function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, calls?: CallSlot): Token[] { + const out: Collector = { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(calls && { calls }) } collect(node, span, out) return out.tokens } @@ -404,15 +482,16 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return { markdown: docText(nodes.map(node => commentOf(node, document))) } } - function translate(block: TextBlock, document: SourceDocument): PreviewText { + function translate(block: TextBlock, document: SourceDocument): TextTranslation { if (block.kind === 'comment') - return translateComment(block, document) - const tokens = tokensIn(block.node, block.header, document) + return { text: translateComment(block, document) } + const calls: CallSlot = {} + const tokens = tokensIn(block.node, block.header, document, calls) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw)) tokens.pop() - return joinTokens(tokens) + return { text: joinTokens(tokens), ...(calls.table && { table: calls.table }) } } function expression(node: Node, document: SourceDocument): Inline[] { @@ -428,13 +507,16 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { const { header } = block const { language } = document // 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致 - const within = (node: Node, part: SourceSpan): Inline[] => { - const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part - return joinTokens(tokensIn(node, span, document)) - } + const clipped = (part: SourceSpan): SourceSpan => part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part + const within = (node: Node, part: SourceSpan): Inline[] => joinTokens(tokensIn(node, clipped(part), document)) const phrases: Phrases = { translated: node => within(node, nodeSpan(node)), within, + expanded: (node) => { + const calls: CallSlot = {} + const text = joinTokens(tokensIn(node, clipped(nodeSpan(node)), document, calls)) + return { text, ...(calls.table && { table: calls.table }) } + }, entryCount: (literal, size) => { const name = table.mapLiterals[literal.type] return [hint(name ? `${name} ${size} 项` : `${size} 项`)] @@ -533,6 +615,8 @@ interface Phrases { translated: (node: Node) => Inline[] /** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */ within: (node: Node, span: SourceSpan) => Inline[] + /** 同 translated,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall)。 */ + expanded: (node: Node) => { text: Inline[], table?: PreviewTable } /** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */ entryCount: (literal: Node, size: number) => Segment[] /** 源码原样照抄、按源码着色(装饰器)。 */ @@ -760,7 +844,7 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] { } /** - * 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue); + * 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,见 declaredValue); * 说明(有才显示)是整条语句的,排在第一个名称后面。 */ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { @@ -770,10 +854,21 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] { ...(i === 0 ? docRows(block.facts.doc, say) : []), ...accessRows(block.facts.access), ...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []), - { label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) }, + { label: valueLabel, ...(declarator.value ? declaredValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) }, ]) } +/** + * 声明的值:字典 / 对象字面量见 literalValue;别的值是一行译文,里面跨几行写的调用排成「新建 / 调用 …」, + * 实参摊成表(参数 | 值)展开在这一行下面。 + */ +function declaredValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): Pick { + if (entries?.length) + return literalValue(value, entries, say) + const { text, table } = say.expanded(value) + return table ? { value: text, ...table } : { value: text } +} + /** * 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行; * 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。 diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index dbf55f5..37684f4 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -8,9 +8,10 @@ import { AskThread } from './AskThread' import { BlockTools } from './BlockTools' import { CodeEditor } from './CodeEditor' import { KindIcon } from './kindIcons' -import { PropertyGrid } from './PropertyGrid' +import { PropertyGrid, PropertyTable } from './PropertyGrid' import { asksOf, historyOf } from './qa' import { TextPreview } from './TextPreview' +import { translationTableId } from './viewState' export interface BlockTreeState { version: number @@ -112,13 +113,21 @@ function BlockQa({ anchor, qa }: { anchor: string, qa: QaTree }): ReactElement { ) } -/** 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文,带说明的译文下面接着说明。 */ +/** + * 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文; + * 译文下面依次接着摊开实参的表(跨几行写的调用)、说明。 + */ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement { if ('properties' in block) return return ( <> + {block.table && ( +
+ +
+ )} {block.doc && } ) diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index eaa9ff4..052b681 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -96,9 +96,9 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items: /** * 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 - * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。 + * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它。 */ -function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { +export function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { return (
diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 9c25839..63f502e 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -95,6 +95,16 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] }) }) + it('名字在一行译文下面那张表里(跨几行写的调用的实参):表总是展开的,目标是那块,不用展开什么', () => { + const call: BlockView = { + ...textBlock('0.0', 'return', 1, [word('返回'), word('新建'), name('UserDetailResponse', 10)]), + table: { columns: ['参数', '值'], items: [{ cells: [[name('id', 40)], [name('user', 43), { text: '.', role: 'plain' }, name('id', 48)]] }] }, + } + const blocks = [textBlock('0', 'if', 0, [word('如果'), name('user', 3)], [call])] + expect(revealPlan(blocks, 9, 48)).toEqual({ id: '0.0', expand: ['0'] }) + expect(occurrencePlan(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] }) + }) + it('名字在框里(套了几层都算)也是显示着它', () => { const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] } const blocks: BlockView[] = [ diff --git a/vscode/webview-ui/src/codeReader/reveal.ts b/vscode/webview-ui/src/codeReader/reveal.ts index 5729525..7f08f6c 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -1,4 +1,4 @@ -import type { BlockView, Occurrence, PreviewText, PropertyItem, SourceRange } from './types' +import type { BlockView, Occurrence, PreviewTable, PreviewText, PropertyItem, SourceRange } from './types' import { blockPath } from './filter' import { showsOccurrence } from './occurrences' import { showsOffset } from './segments' @@ -38,19 +38,24 @@ function shows(text: PreviewText, offset: number): boolean { return !('markdown' in text) && showsOffset(text, offset) } +/** 一行译文下面那张表(总是展开的)里有格子满足 test。 */ +function tableShows(table: PreviewTable | undefined, test: (cell: PreviewText) => boolean): boolean { + return table?.items.some(item => item.cells.some(test)) ?? false +} + /** * 块里哪儿是 offset 处的定义,从具体到笼统: * ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块; * ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它, * 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开; - * ③ 块里只是显示着这个名字(一行译文里的循环变量、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 + * ③ 块里只是显示着这个名字(一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。 * 不在这块里是 undefined。 */ function locate(block: BlockView, offset: number): Spot | undefined { if (covers(block.defines, offset)) return { expand: [] } if ('translation' in block) - return shows(block.translation, offset) ? { expand: [] } : undefined + return shows(block.translation, offset) || tableShows(block.table, cell => shows(cell, offset)) ? { expand: [] } : undefined const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) })) for (const { row, id } of rows) { if (covers(row.defines, offset)) @@ -120,10 +125,10 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap boolean): string[] | undefined { if ('translation' in block) - return test(block.translation) ? [] : undefined + return test(block.translation) || tableShows(block.table, test) ? [] : undefined for (const [index, row] of block.properties.entries()) { if (test(row.value)) return [] diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index d5d6636..c1017b6 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -71,6 +71,12 @@ export interface PropertyRow extends Defines { columns?: string[] } +/** 一张带表头和边框的表:列名,每行几列对齐的格子,同 PropertyRow 给了 columns 的子行 */ +export interface PreviewTable { + columns: string[] + items: PropertyItem[] +} + /** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走 */ export interface ThemeColor { dark: string, light: string } @@ -108,9 +114,9 @@ interface BlockViewBase { /** * 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文; - * 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面 + * 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文) */ -export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] }) +export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown } | { properties: PropertyRow[] }) export interface TranslatorOption { id: string, label: string } diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index b83a17d..879d3a0 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -90,3 +90,8 @@ export function propertyRowId(blockId: string, rowIndex: number): string { export function propertyItemId(parentId: string, itemIndex: number): string { return `${parentId}/${itemIndex}` } + +/** 一行译文下面那张表(跨几行写的调用的实参)的 id:表里各行的 id 是 propertyItemId(它, 行号)。这张表总是展开的。 */ +export function translationTableId(blockId: string): string { + return `${blockId}/t` +}