From 285d711740d5592e7b06ce44c50f6b1343653494 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 22:31:35 +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=E5=B5=8C=E5=A5=97?= =?UTF-8?q?=EF=BC=9A=E5=AE=9E=E5=8F=82=E7=9A=84=E5=80=BC=E5=8F=88=E6=98=AF?= =?UTF-8?q?=E8=B7=A8=E5=A4=9A=E8=A1=8C=E7=9A=84=E8=B0=83=E7=94=A8=E6=97=B6?= =?UTF-8?q?=E5=9C=A8=E5=80=BC=E9=82=A3=E4=B8=80=E6=A0=BC=E9=87=8C=E5=86=99?= =?UTF-8?q?=E5=AE=83=E7=9A=84=E4=B8=80=E8=A1=8C=E3=80=81=E5=A5=97=E5=AE=83?= =?UTF-8?q?=E7=9A=84=E5=AE=9E=E5=8F=82=E8=A1=A8=EF=BC=8C=E4=B8=80=E5=B1=82?= =?UTF-8?q?=E5=B1=82=E5=BE=80=E4=B8=8B=E5=A5=97=EF=BC=9B=E5=9F=8B=E5=9C=A8?= =?UTF-8?q?=E5=BC=8F=E5=AD=90=E9=87=8C=E5=90=8E=E9=9D=A2=E8=BF=98=E8=B7=9F?= =?UTF-8?q?=E7=9D=80=E4=B8=9C=E8=A5=BF=E7=9A=84=E8=B0=83=E7=94=A8=E6=8B=AC?= =?UTF-8?q?=E8=B5=B7=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 29 ++-- vscode/src/codeReader/contract.ts | 5 + .../src/codeReader/fixtures/chainedCalls.py | 87 +++++++++++ .../codeReader/fixtures/nestedCalls.ts.txt | 18 +++ .../fixtures/paymentConsoleRoutes.py | 86 +++++++++++ vscode/src/codeReader/nameResolution.test.ts | 7 +- vscode/src/codeReader/nameResolution.ts | 1 + vscode/src/codeReader/testSupport.ts | 18 ++- .../codeReader/translators/zhTemplate.test.ts | 140 ++++++++++++++++-- .../src/codeReader/translators/zhTemplate.ts | 62 ++++++-- .../webview-ui/src/codeReader/BlockTree.tsx | 4 +- .../src/codeReader/PropertyGrid.tsx | 36 +++-- .../webview-ui/src/codeReader/reveal.test.ts | 11 ++ vscode/webview-ui/src/codeReader/reveal.ts | 11 +- vscode/webview-ui/src/codeReader/types.ts | 2 + vscode/webview-ui/src/codeReader/viewState.ts | 9 +- 16 files changed, 471 insertions(+), 55 deletions(-) create mode 100644 vscode/src/codeReader/fixtures/chainedCalls.py create mode 100644 vscode/src/codeReader/fixtures/nestedCalls.ts.txt create mode 100644 vscode/src/codeReader/fixtures/paymentConsoleRoutes.py diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index c51032d..40427fc 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -41,7 +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 只画表。见「调用与构造」 | +| 调用与构造 | 实参列表跨了几行写的调用不挤成一长行:写成「[等待 ]新建 / 调用 被调用的名字 · N 个参数」,实参摊成一张表「参数 \| 值」(按名字传的写名字,按位置传的写「第 N 个」,`*x` / `...x` 写「展开」,`**x` 写「展开关键字」);一行写完的调用照旧一行译文。实参的值又是跨几行写的调用时,值那一格写它的一行、它的表套在格子里,一层层往下套;埋在式子里、后面还跟着东西的(`X(\n…\n).model_dump()`)括起来「(新建 X · 3 个参数).model_dump()」。新建还是调用:TypeScript 看有没有 `new`;Python 写法一样,查被调用的名字的定义,是类就是「新建」,是函数、查不到都是「调用」,先按调用画、查到了再刷新。表在变量 / 常量属性表的初始值 / 值那一行下面(能折叠),在返回、语句这些一行译文的块里排在那一行下面。语言插件报结构,翻译插件定用词,webview 只画表。见「调用与构造」 | ## 用户可见行为 @@ -259,18 +259,21 @@ ### 调用与构造 -原型画板「调用 · 构造」(`code.js` 的「调用与构造」一节)。实参多的调用一般跨几行写,挤成一行译文读不下去;把实参摊成一张表。 +原型画板「调用 · 构造」(`code.js` 的「调用与构造」一节,嵌套见 `nestedArgRow`、`NESTED_CALL_SAMPLE`)。实参多的调用一般跨几行写,挤成一行译文读不下去;把实参摊成一张表,实参的值又是这样的调用时在格子里接着摊。 -- 哪些调用:实参列表(括号连同里面,`CallFacts.argumentList`)跨了几行的调用,而且整个落在要翻的这一段里(块头部;TypeScript `describe('x', () => { … })` 这种主体是子块的调用不算)。一行写完的调用照旧一行译文,不问定义。链式调用跨了几行、每段的括号都写在一行里的(`items\n .filter(…)\n .map(…)`)也不算。一段里只排遇到的第一个(先外后里),实参里的调用不再排成表,照常一行译文。 +- 哪些调用:实参列表(括号连同里面,`CallFacts.argumentList`)跨了几行的调用,而且整个落在要翻的这一段里(块头部;TypeScript `describe('x', () => { … })` 这种主体是子块的调用不算)。一行写完的调用照旧一行译文,不问定义。链式调用跨了几行、每段的括号都写在一行里的(`items\n .filter(…)\n .map(…)`)也不算;式子跨了几行、里面的调用各自写在一行里的(`bool(x)\n and Path(x).is_file()`)是式子,也不算。 +- 一段排一个:一行译文的块、常量 / 变量的值、实参表里每个值各是一段,各自排自己里面遇到的第一个(先外后里)这样的调用;同一段里后面再有的照常一行译文(挤成一行时圆括号贴着里面的内容:`f(\n x\n)` 写成 `f(x)`,方括号、花括号照旧空一格)。被调用的表达式里的调用(`get_driver(\n m\n).query(\n …\n)` 排的是外面的 query)照常一行。 - 一行:`[等待 ]新建 / 调用 被调用的名字 · N 个参数`。「等待」就是 `await` 照常的译法(在调用前面),「新建」「调用」按声明关键字着色;被调用的名字照常翻、带源码下标(⌘ / Ctrl + 点击、单击高亮、中文名都照旧),前面几节(对象、模块)按变量着色,最后那个名字新建时按类型着色、调用时按函数着色;「· N 个参数」按说明词着色,N 数的是表里的行。 +- 埋在式子里:调用后面还紧跟着同一个式子的别的部分(`.model_dump()`、`[0]`、`or x`;后面是右括号、逗号、分号、冒号或什么都没有的不算),这一行整个括起来:「(新建 X · 3 个参数).model_dump()」「(调用 make · 1 个参数) 或者 fallback」,表照样排在这一段下面。前面有东西的(`fallback or make(\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`:表头、圆角外框、格子之间分隔线,第一列按内容定宽)。 +- 表:两列「参数 | 值」,每个实参一行、照源码顺序。按名字传的(Python `name=value`)写名字,按变量着色、带源码下标;按位置传的写「第 N 个」(说明词,只数按位置传的);`*args`、`...args` 写「展开」,`**kwargs` 写「展开关键字」(说明词)。值照常一行译文(名字带下标);唯一的实参是生成器时(`sum(\n x for x in xs\n)`),共用的那对括号属于调用,格子里只有框。表的样子同输入参数表(`PropertyTable`:表头、圆角外框、格子之间分隔线,第一列按内容定宽)。 +- 嵌套:值(一段)里有跨几行写的调用时,值那一格写它的一行,它的实参表排在同一格里、文字下面(`PropertyItem.table`),一层层往下套,多深都一样;套着的表不能折叠,总是展开。每一层都和最外层一样:先按「调用」画、查到定义再刷新成「新建」,名字带源码下标,跳到定义、高亮各处出现、中文名照旧。排版:每套一层往右缩 5px(`PropertyTable` 外面那层的左边距),第一列按内容定宽、窄了能收,长的值在格子里换行,不出横向滚动条。 - 放在哪: - 变量、常量块:初始值 / 值那一行写上面那一行字,表是这一行展开的子行(`PropertyRow.columns = ['参数', '值']`),带折叠箭头、默认展开。值是字典 / 对象字面量时照旧按「字典 N 项」展开,不看里面的调用。 - 一行译文的块(返回、语句、字段……):译文里调用的位置换成那一行字(「返回 新建 UserDetailResponse · 11 个参数」),表排在译文下面(`TextTranslation.table` → 视图数据的 `table`),总是展开;块带说明时说明排在表下面。 -- 分工:语言插件报结构(`LanguageAdapter.call`:被调用的表达式、最后那个名字、写法是不是新建、实参列表、各个实参),翻译插件定用词、挑哪个调用、怎么排;webview 只画表(`PreviewTable`),不认语言;跳到定义、高亮各处出现、中文名在表里照旧(`renderedNames`、`reveal.ts` 都算上这张表)。 -- 样例(`fixtures/routes.py`,luffy 的用户路由): +- 分工:语言插件报结构(`LanguageAdapter.call`:被调用的表达式、最后那个名字、写法是不是新建、实参列表、各个实参),翻译插件定用词、挑哪个调用、怎么排;webview 只画表(`PreviewTable`),不认语言;跳到定义、高亮各处出现、中文名在表里照旧(`renderedNames`、`reveal.ts` 都算上这张表和一层层套着的表)。 +- 样例(`fixtures/routes.py`、`paymentConsoleRoutes.py`、`chainedCalls.py` 是 luffy 的代码,`calls.ts.txt`、`nestedCalls.ts.txt` 是本仓库的代码): | 源码 | 预览 | | --- | --- | @@ -280,6 +283,10 @@ | `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` | +| `return PaymentCapabilityDiagnostic(\n …\n credentials_present=CredentialPresence(\n mp_app_secret=…,\n …(4 个)\n ),\n …(15 个)\n)`(都定义成类) | 一行 `返回 新建 PaymentCapabilityDiagnostic · 15 个参数`;`credentials_present` 那一格 `新建 CredentialPresence · 4 个参数`,下面套它的 4 行表;其中 `wechat_pay_private_key=bool(private_key_path)\n and Path(private_key_path).is_file()` 是式子,照旧一行 `bool(private_key_path) 并且 Path(private_key_path).is_file()` | +| `self._admin_basic = base64.b64encode(\n f"…".encode()\n).decode()` | 初始值 `(调用 base64.b64encode · 1 个参数).decode()`,表 `第 1 个 \| f"…".encode()` | +| `result = await config.get_payment_driver(\n order.payment_method\n).query_refund_status(\n out_trade_no=…,\n out_request_no=…,\n)` | 初始值 `等待 调用 config.get_payment_driver(order.payment_method).query_refund_status · 2 个参数` | +| `context.subscriptions.push(\n treeView,\n commands.registerCommand('…', () => {\n …\n }),\n …\n)` | 一行 `调用 context.subscriptions.push · 4 个参数`;第 2 ~ 4 个那一格 `调用 commands.registerCommand · 2 个参数`,下面套它的 2 行表 | ### 注释的 Markdown 排版 @@ -643,7 +650,11 @@ export type PreviewText = Inline[] | Markdown export interface Defines { defines?: SourceRange } /** 属性表里展开的一个子行:几列对齐的格子;items 是展开在它下面的下一层子行(嵌套的字典 / 对象),一层层可以一直往下套。 */ -export interface PropertyItem extends Defines { cells: Inline[][], items?: PropertyItem[] } +export interface PropertyItem extends Defines { + cells: Inline[][] + items?: PropertyItem[] + table?: PreviewTable // 排在最后一格里、文字下面的表:实参的值里跨几行写的调用的实参表,一层层往下套,总是展开 +} /** * 属性表的一行:属性名、着色的值或 Markdown 说明(函数、类的「说明」)、展开在下面的子行(输入参数、导入项、字典 / 对象的各项)。 @@ -1267,7 +1278,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` 拿到)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。跨几行写的调用也不走节点模板:由语言插件的 `call` 认出来,引擎只在翻一行译文(`translate`)和变量 / 常量的值(`declaredValue`)时按「调用与构造」一节排(`emitCall`、`argumentTable`),属性表别的值、库规则借的 `expression` 照常一行。 +写在表达式里的遍历(推导式)不走节点模板:它的节数不定、要换成一个框,由语言插件的 `iteration`(翻译时从 `SourceDocument.language` 拿到)认出来,引擎按「推导式」一节的规则排进框里;和节点模板一样,没整个落在要翻的这一段里就不重排、照常翻。跨几行写的调用也不走节点模板:由语言插件的 `call` 认出来,引擎只在翻一行译文(`translate`)、变量 / 常量的值(`declaredValue`)和实参表里的值时按「调用与构造」一节排(每段一个 `CallSlot`,见 `expandedText`、`emitCall`、`argumentTable`),属性表别的值、库规则借的 `expression` 照常一行。 TypeScript 关键字表(流程 = keyword,声明 = storage): @@ -1475,7 +1486,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 跳到定义、键盘跳到高亮的各处出现都认一行译文下面那张表。 +- 调用与构造:语言插件读出的结构(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 是调用、都不问定义;链式调用跨了几行、回调主体是子块的调用不排。嵌套:`paymentConsoleRoutes.py` 的 15 行表逐字一致,`credentials_present` 那一格套 4 行表、`CredentialPresence` 同样问定义、按类型着色、带下标,`bool(…)\n and Path(…).is_file()` 照旧一行;自造的三层(Outer → Middle → Leaf、带 await 的 fetch)一层层套、每层都问定义;`chainedCalls.py` 的 `(调用 base64.b64encode · 1 个参数).decode()`、被调用的表达式里的调用照常一行(圆括号贴着内容),`[0]`、`or` 后面跟着时括起来、前面有东西或后面是逗号时不括,实参值里的 `X(\n…\n).model_dump()` 也括起来、表套在格子里;`nestedCalls.ts.txt` 三个 `commands.registerCommand(…)` 各套一张表,`new Server(new Options(…))` 套 new。`calleeKinds.test.ts`:按表达式原文记、同一版本不再查、被调用的不是名字不问、查了没有不算变、没答的隔一会儿只再查没答的。视图数据带 `table`、`readDefinitionKind` 认类 / 函数 / 异常 / 变量、`renderedNames` 算上表里(连套着的表)的名字、`documentMessage` 把 calleeKind 交给翻译插件;webview 跳到定义、键盘跳到高亮的各处出现都认一行译文下面那张表和值那一格里套着的表(套在变量初始值那一行下面的展开那一行)。 - 只经契约:只照 contract 写的假语言插件(`iteration` 把一个普通调用认成遍历)配中文模板,那个调用换成遍历框;同一个翻译插件配上不给 `iteration` 的语言插件照常翻。测试文件不 import 任何语言插件模块。 - 输入参数表格:函数的「输入参数」行带列名 名称 / 类型 / 默认值(TS、Python 一样),默认值格子只有译文、没有就是空格子,别的行没有列名;webview 按有没有列名选表格还是列表,表格的格子按列数取,列宽模板(`tableTemplate`)只看列数。 - 翻译:上面两张样例表逐字一致;属性名不被翻;每个被翻的词单独成段;节点模板嵌套时正确、槽位里照样翻、没命中模板的节点照抄;属性表里的值和一行译文用同一套表和模板。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 53e519b..2899eb4 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -344,6 +344,11 @@ export interface PropertyItem extends Defines { cells: Inline[][] /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */ items?: PropertyItem[] + /** + * 排在这一行最后一格里、那格文字下面的表:实参表里一个实参的值里有跨几行写的调用时,是那个调用的实参表, + * 表里的行又可以带 table,一层层往下套。总是展开的。 + */ + table?: PreviewTable } /** diff --git a/vscode/src/codeReader/fixtures/chainedCalls.py b/vscode/src/codeReader/fixtures/chainedCalls.py new file mode 100644 index 0000000..1f77078 --- /dev/null +++ b/vscode/src/codeReader/fixtures/chainedCalls.py @@ -0,0 +1,87 @@ +# luffy 的两段节选:workspace_repo/service.py 的 WorkspaceRepoService.__init__、commerce/tasks.py 的 _async_check_pending_refunds +import base64 + + +class WorkspaceRepoService: + def __init__( + self, + client: GiteaAdminClient, + *, + public_url: str, + internal_url: str, + admin_user: str, + admin_password: str, + ) -> None: + self._client = client + self._public_url = public_url.rstrip("/") + self._internal_url = internal_url.rstrip("/") + self._admin_basic = base64.b64encode( + f"{admin_user}:{admin_password}".encode() + ).decode() + + + +async def _async_check_pending_refunds() -> None: + """超时未回调的退款 cron 兜底: 主动查 → 调 reversal_hook → 推进 REFUNDED. + + 与异步回调 ``handle_alipay_refund_notify`` 共用 ``_finalize_refund`` + helper, 保证账本侧 reversal 与订单状态机推进始终一对一. 此前仅写 + ``refund_trade_no`` 不调 reversal_hook, 会让"用户钱已退 + 账本 grant + 仍在"成为双花漏洞. + + 金额来源说明: 当前 ``RefundQueryResult`` 不带 alipay 实际退款金额, cron + 无法判断 unused 模式下的部分退款. 折中策略: 按订单金额 (snapshot) 走 + ``_finalize_refund``, full 模式严格相等会通过, unused 模式 + ``0 < refunded <= rmb_amount_minor`` 也通过. 等 driver 后续暴露 + ``refund_amount_minor`` 字段再切换. + """ + from luffy_agent.commerce.config import get_commerce_config + from luffy_agent.commerce.exceptions import UnsupportedPaymentMethodError + from luffy_agent.commerce.orders.repositories import order_repository + from luffy_agent.commerce.payments.service import _finalize_refund + + config = get_commerce_config() + factory = get_pg_session_factory() + checked = 0 + confirmed = 0 + + async with factory() as db: + orders = await order_repository.list_pending_refunds( + db, age_hours=24, limit=100 + ) + for order in orders: + checked += 1 + try: + result = await config.get_payment_driver( + order.payment_method + ).query_refund_status( + out_trade_no=str(order.id), + out_request_no=f"{order.id}-refund", + ) + except UnsupportedPaymentMethodError: + logger.warning( + "check_pending_refunds: skip order %s, no driver for %s", + order.id, + order.payment_method, + ) + continue + except Exception as exc: # noqa: BLE001 + logger.exception( + "query_refund_status failed for order %s: %s", order.id, exc + ) + continue + + if not result.refunded: + continue + + # 已加 with_for_update(skip_locked) 行锁, 与 refund-notify 互斥. + logger.warning("refund confirmed via active query, order=%s", order.id) + confirmed += 1 + await _finalize_refund( + db, + order=order, + refund_trade_no=result.refund_trade_no, + refunded_amount_minor=order.rmb_amount_minor, + source="check-refund-cron", + ) + logger.info( diff --git a/vscode/src/codeReader/fixtures/nestedCalls.ts.txt b/vscode/src/codeReader/fixtures/nestedCalls.ts.txt new file mode 100644 index 0000000..ea76b17 --- /dev/null +++ b/vscode/src/codeReader/fixtures/nestedCalls.ts.txt @@ -0,0 +1,18 @@ +// 本仓库 vscode/src/extension.ts 的 activate 节选 +export function activate(context: ExtensionContext): SuperpowersApi { + context.subscriptions.push( + treeView, + commands.registerCommand('superpowers.openKanban', () => { + KanbanWebviewPanel.createOrShow(context) + }), + commands.registerCommand('superpowers.refresh', () => { + KanbanWebviewPanel.refresh() + }), + commands.registerCommand('superpowers.setGiteaToken', () => { + KanbanWebviewPanel.createOrShow(context) + KanbanWebviewPanel.requestEditAuth() + }), + ) + + return { codeReader: registerCodeReader(context) } +} diff --git a/vscode/src/codeReader/fixtures/paymentConsoleRoutes.py b/vscode/src/codeReader/fixtures/paymentConsoleRoutes.py new file mode 100644 index 0000000..8f94ca8 --- /dev/null +++ b/vscode/src/codeReader/fixtures/paymentConsoleRoutes.py @@ -0,0 +1,86 @@ +"""支付子域管理后台路由. + +仅含管理后台用的支付流水列表/详情. URL prefix 与 tags 与拆分前完全一致 +(``/admin/commerce/payments`` + ``commerce-admin-payments``), 保证对外契约不变. +共享 schema ``OrderResponse`` 与 repository 从父子域绝对 import. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from pathlib import Path +from typing import Annotated, Literal +from urllib.parse import urlparse + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, ConfigDict +from sqlalchemy.ext.asyncio import AsyncSession + +from luffy_agent.commerce.bootstrap import _secret_text, jsapi_ready +from luffy_agent.commerce.orders.console.schemas import ( + AdminOrderRow, + to_admin_order_row, + to_admin_order_rows, +) +from luffy_agent.commerce.orders.repositories import order_repository +from luffy_agent.commerce.payments.wechat_jsapi import repositories as jsapi_repo +from luffy_agent.commerce.payments.wechat_jsapi import service as jsapi_service +from luffy_agent.core.db import get_db +from luffy_agent.core.exceptions import AppException +from luffy_agent.core.params import Int4Query +from luffy_agent.core.security.fernet import FernetSecretService, MasterKeyMissingError +from luffy_agent.core.setting import get_settings +from luffy_agent.core.setting.schemas import Settings +from luffy_agent.user.auth.deps import ConsolePrincipal, require_permission +from luffy_agent.user.console.rbac.service import ( + bind_console_audit_request, + console_rbac_service, +) + +router = APIRouter(prefix="/admin/commerce/payments", tags=["commerce-admin-payments"]) + + +class CredentialPresence(BaseModel): + mp_app_secret: bool + wechat_pay_api_v3_key: bool + wechat_pay_private_key: bool + jsapi_intent_master_key: bool +@router.get("/capability", response_model=PaymentCapabilityDiagnostic) +async def get_payment_capability( + request: Request, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_permission("commerce.payments:view")), +) -> PaymentCapabilityDiagnostic: + """admin: JSAPI/Native/H5 只读诊断. 不含密钥、openid、签名或密文.""" + settings = get_settings() + oauth_host = urlparse(settings.WECHAT_MP_OAUTH_REDIRECT_URI).hostname or "" + allowed = list(settings.PAYMENT_RETURN_ALLOWED_ORIGINS) + request_origin = request.headers.get("origin") + pay_origin = request_origin if request_origin in allowed else None + hint_origins = [pay_origin] if pay_origin else allowed + private_key_path = settings.WECHAT_PAY_PRIVATE_KEY_PATH + count, earliest = await jsapi_repo.recovery_required_summary(db) + return PaymentCapabilityDiagnostic( + native_enabled=bool(settings.WECHAT_PAY_MCH_ID), + h5_enabled=settings.WECHAT_H5_ENABLED, + jsapi_enabled=settings.WECHAT_JSAPI_ENABLED, + jsapi_ready=jsapi_ready(settings), + pay_app_id_masked=_mask_app_id(settings.WECHAT_PAY_APP_ID), + mp_app_id_masked=_mask_app_id(settings.WECHAT_MP_APP_ID), + credentials_present=CredentialPresence( + mp_app_secret=bool(_secret_text(settings.WECHAT_MP_APP_SECRET)), + wechat_pay_api_v3_key=bool(settings.WECHAT_PAY_API_V3_KEY), + wechat_pay_private_key=bool(private_key_path) + and Path(private_key_path).is_file(), + jsapi_intent_master_key=_intent_key_present(settings), + ), + oauth_callback_host=oauth_host, + pay_origin=pay_origin, + allowed_pay_origins=allowed, + jsapi_auth_dir_hint=[f"{origin}/" for origin in hint_origins if origin], + domain_whitelist="unknown_manual_check", + merchant_binding="unknown_manual_check", + recovery_required_count=count, + recovery_required_earliest=earliest, + ) diff --git a/vscode/src/codeReader/nameResolution.test.ts b/vscode/src/codeReader/nameResolution.test.ts index 6b7405e..b085dbd 100644 --- a/vscode/src/codeReader/nameResolution.test.ts +++ b/vscode/src/codeReader/nameResolution.test.ts @@ -96,11 +96,14 @@ 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' + it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)、值那一格里套着的表里带下标的名字也算', async () => { + const source = 'def f(user):\n return Detail(\n id=user.id,\n owner=Owner(\n name=user.name,\n ),\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') + expect(rendered.get(at(source, 'Owner('))).toBe('Owner') + expect(rendered.get(at(source, 'name='))).toBe('name') + expect(rendered.get(at(source, 'user.name'))).toBe('user') }) it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => { diff --git a/vscode/src/codeReader/nameResolution.ts b/vscode/src/codeReader/nameResolution.ts index 998bb5f..3df7155 100644 --- a/vscode/src/codeReader/nameResolution.ts +++ b/vscode/src/codeReader/nameResolution.ts @@ -34,6 +34,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map for (const item of list ?? []) { item.cells.forEach(inline) items(item.items) + items(item.table?.items) } } const visit = (list: readonly BlockView[]): void => { diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index de84b00..831bf76 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -1,5 +1,5 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' +import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment } from './contract' import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex' import { readFileSync } from 'node:fs' import * as path from 'node:path' @@ -62,15 +62,25 @@ export function previewText(text: PreviewText): string { return 'markdown' in text ? text.markdown : segmentText(text) } -/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格、各列用 ` | ` 隔开。 */ -export function propertyLines(rows: PropertyRow[]): string[] { - const itemLines = (items: PropertyItem[] | undefined, indent: string): string[] => (items ?? []).flatMap(item => [ +/** 子行写成文字:各列用 ` | ` 隔开,下一层子行、排在最后一格里的表(PropertyItem.table)的各行比这一层多缩进两格。 */ +function itemLines(items: PropertyItem[] | undefined, indent: string): string[] { + return (items ?? []).flatMap(item => [ `${indent}${item.cells.map(segmentText).join(' | ')}`, ...itemLines(item.items, `${indent} `), + ...itemLines(item.table?.items, `${indent} `), ]) +} + +/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格(见 itemLines)。 */ +export function propertyLines(rows: PropertyRow[]): string[] { return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')]) } +/** 一行译文下面那张表写成文字,同 propertyLines 的子行,第一层不缩进。 */ +export function tableLines(table: PreviewTable | undefined): string[] { + return itemLines(table?.items, '') +} + /** 语法树节点换成它的源码,没填的可选字段去掉。 */ export function nodesToText(value: unknown): unknown { if (value instanceof Node) diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index a79d070..1b3016a 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -3,7 +3,7 @@ import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' -import { flatSegments, propertyLines, readFixture, segmentText, walk, WASM_DIR, withBlocks } from '../testSupport' +import { flatSegments, propertyLines, readFixture, segmentText, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport' import { pythonZhTranslator } from './pythonZh' import { typescriptZhTranslator } from './typescriptZh' import { createZhTemplateTranslator } from './zhTemplate' @@ -1049,10 +1049,6 @@ describe('跨几行写的调用', () => { } } - 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', @@ -1146,21 +1142,125 @@ describe('跨几行写的调用', () => { expect(asked).not.toContain('file.read') }) - it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的;实参里的调用不再排成表', async () => { + it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的', async () => { const source = [ 'run(', ' first,', ' *rest,', ' second,', - ' key=Inner(', - ' x=1,', - ' ),', + ' key=value,', ' **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']) + expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | value', '展开关键字 | options']) + }) + + it('嵌套:实参的值是跨几行写的调用(可以带 await)时,值那一格写它的一行,它的实参表排在格子里;一行写完的调用、只是式子里有调用的照旧一行', async () => { + const payment = readFixture('paymentConsoleRoutes.py') + const { asked, calleeKind } = kinds({ PaymentCapabilityDiagnostic: 'class', CredentialPresence: 'class' }) + const line = (await translateAll(py, 'python', pythonZhTranslator, payment, calleeKind)).get(64) + expect(text(line?.segments)).toBe('返回 新建 PaymentCapabilityDiagnostic · 15 个参数') + expect(tableLines(line?.table)).toEqual([ + 'native_enabled | bool(settings.WECHAT_PAY_MCH_ID)', + 'h5_enabled | settings.WECHAT_H5_ENABLED', + 'jsapi_enabled | settings.WECHAT_JSAPI_ENABLED', + 'jsapi_ready | jsapi_ready(settings)', + 'pay_app_id_masked | _mask_app_id(settings.WECHAT_PAY_APP_ID)', + 'mp_app_id_masked | _mask_app_id(settings.WECHAT_MP_APP_ID)', + 'credentials_present | 新建 CredentialPresence · 4 个参数', + ' mp_app_secret | bool(_secret_text(settings.WECHAT_MP_APP_SECRET))', + ' wechat_pay_api_v3_key | bool(settings.WECHAT_PAY_API_V3_KEY)', + // 跨了两行、却是式子不是一次调用:照旧一行 + ' wechat_pay_private_key | bool(private_key_path) 并且 Path(private_key_path).is_file()', + ' jsapi_intent_master_key | _intent_key_present(settings)', + 'oauth_callback_host | oauth_host', + 'pay_origin | pay_origin', + 'allowed_pay_origins | allowed', + 'jsapi_auth_dir_hint | ⟦列表|遍历 hint_origins 的每个 origin,仅当 origin,取 f"{origin}/"⟧', + 'domain_whitelist | "unknown_manual_check"', + 'merchant_binding | "unknown_manual_check"', + 'recovery_required_count | count', + 'recovery_required_earliest | earliest', + ]) + expect(line?.table?.columns).toEqual(['参数', '值']) + // 里层和外层一样:问定义分新建和调用,名字带源码下标 + expect(asked).toEqual(expect.arrayContaining(['PaymentCapabilityDiagnostic', 'CredentialPresence'])) + const nested = line?.table?.items[6] + expect(nested?.table?.columns).toEqual(['参数', '值']) + expect(nested?.cells[1].find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) }) + expect(nested?.table?.items[0].cells[0]).toEqual([{ text: 'mp_app_secret', role: 'variable', offset: payment.indexOf('mp_app_secret=') }]) + const unresolved = (await translateAll(py, 'python', pythonZhTranslator, payment)).get(64) + expect(segmentText(unresolved?.table?.items[6].cells[1] ?? [])).toBe('调用 CredentialPresence · 4 个参数') + }) + + it('嵌套一层层往下套;await 的调用写「等待」', async () => { + const source = [ + 'return Outer(', + ' inner=Middle(', + ' leaf=Leaf(', + ' value=1,', + ' ),', + ' flag=await fetch(', + ' key,', + ' ),', + ' ),', + ')', + ].join('\n') + const { asked, calleeKind } = kinds({ Outer: 'class', Middle: 'class', Leaf: 'class' }) + const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(1) + expect(text(line?.segments)).toBe('返回 新建 Outer · 1 个参数') + expect(tableLines(line?.table)).toEqual([ + 'inner | 新建 Middle · 2 个参数', + ' leaf | 新建 Leaf · 1 个参数', + ' value | 1', + ' flag | 等待 调用 fetch · 1 个参数', + ' 第 1 个 | key', + ]) + expect([...asked].sort()).toEqual(['Leaf', 'Middle', 'Outer', 'fetch']) + }) + + it('埋在式子里的跨几行调用也排:后面紧跟着同一个式子的别的部分(.x()、[0]、or)时整行括起来,后面是分隔符或什么都没有时不括', async () => { + const chained = readFixture('chainedCalls.py') + const rows = await describeAll(py, 'python', pythonZhTranslator, chained) + expect(propertyLines(rows.get(18) ?? []).slice(1)).toEqual([ + '初始值: (调用 base64.b64encode · 1 个参数).decode()', + ' 第 1 个 | f"{admin_user}:{admin_password}".encode()', + ]) + // 调用跨几行的是外面那次:被调用的里面那次调用照旧一行,挤成一行时圆括号贴着里面的内容 + expect(propertyLines(rows.get(55) ?? []).slice(1, 2)).toEqual(['初始值: 等待 调用 config.get_payment_driver(order.payment_method).query_refund_status · 2 个参数']) + + const source = [ + 'first = make(', + ' a,', + ')[0]', + 'either = make(', + ' a,', + ') or fallback', + 'other = fallback or make(', + ' a,', + ')', + 'items = [', + ' make(', + ' a,', + ' ),', + ' b,', + ']', + 'run(', + ' key=Model(', + ' a=1,', + ' ).model_dump(),', + ')', + ].join('\n') + const values = await describeAll(py, 'python', pythonZhTranslator, source) + const value = (line: number): string | undefined => propertyLines(values.get(line) ?? []).find(row => row.startsWith('初始值')) + expect(value(1)).toBe('初始值: (调用 make · 1 个参数)[0]') + expect(value(4)).toBe('初始值: (调用 make · 1 个参数) 或者 fallback') + expect(value(7)).toBe('初始值: fallback 或者 调用 make · 1 个参数') + expect(value(10)).toBe('初始值: [ 调用 make · 1 个参数, b ]') + const cell = (await translateAll(py, 'python', pythonZhTranslator, source)).get(16) + expect(tableLines(cell?.table)).toEqual(['key | (调用 Model · 1 个参数).model_dump()', ' a | 1']) }) it('typeScript:new 写法就是新建,不带 new 的是调用,都不问定义;...x 写「展开」', async () => { @@ -1184,6 +1284,26 @@ describe('跨几行写的调用', () => { expect(asked).toEqual([]) }) + it('typeScript 嵌套:实参是跨几行写的调用、new 时同样排在值那一格里', async () => { + const line = (await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('nestedCalls.ts.txt'))).get(3) + expect(text(line?.segments)).toBe('调用 context.subscriptions.push · 4 个参数') + expect(tableLines(line?.table)).toEqual([ + '第 1 个 | treeView', + '第 2 个 | 调用 commands.registerCommand · 2 个参数', + ' 第 1 个 | \'superpowers.openKanban\'', + ' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) }', + '第 3 个 | 调用 commands.registerCommand · 2 个参数', + ' 第 1 个 | \'superpowers.refresh\'', + ' 第 2 个 | () => { KanbanWebviewPanel.refresh() }', + '第 4 个 | 调用 commands.registerCommand · 2 个参数', + ' 第 1 个 | \'superpowers.setGiteaToken\'', + ' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) KanbanWebviewPanel.requestEditAuth() }', + ]) + const source = 'const server = new Server(\n new Options(\n port,\n ),\n handler,\n)' + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source) + expect(propertyLines(rows.get(1) ?? []).slice(-4)).toEqual(['值: 新建 Server · 2 个参数', ' 第 1 个 | 新建 Options · 1 个参数', ' 第 1 个 | port', ' 第 2 个 | handler']) + }) + it('链式调用跨了几行、实参列表各自写在一行里的不排成表;回调主体不在块头部里的调用也不排', async () => { const source = [ 'const ids = items', diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index d049a3a..4c68b7b 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -89,6 +89,8 @@ interface Collector { const CONTEXT_SEPARATOR = ' > ' const CLOSING = new Set([')', ']', '}']) +/** 跟在一个式子后面、不属于这个式子的标点:右括号、逗号、分号、冒号。 */ +const SEPARATORS = new Set([...CLOSING, ',', ';', ':']) const HEADER_OPENERS = new Set(['{', ':']) /** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */ const NAME_ROLES = new Set(['variable', 'function', 'type']) @@ -117,6 +119,25 @@ function withoutMarkers(node: Node, markers: readonly SourceRange[]): string { return text + node.text.slice(cursor - node.startIndex) } +function sameSpan(a: Node, b: Node): boolean { + return a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type +} + +/** + * 在要翻的 root 里,node 后面还紧跟着包着它的式子的别的部分(`.model_dump()`、`[0]`、`or x`):从 node 往上找第一个后面有东西的节点, + * 那个东西不是右括号、逗号、分号、冒号(语法之外的注释不算)。 + */ +function continued(node: Node, root: Node): boolean { + for (let current: Node | null = node; current && !sameSpan(current, root); current = current.parent) { + let next = current.nextSibling + while (next?.isExtra) + next = next.nextSibling + if (next) + return !SEPARATORS.has(next.type) + } + return false +} + function isComment(token: Token): boolean { return token.text === '' && !token.group } @@ -245,9 +266,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return parts.every((part): part is string | Node => part != null) ? parts : undefined } - /** 源码里上一个词到 index 之间有空白,下一个词前面就空一格。 */ + /** + * 源码里上一个词到 index 之间有空白,下一个词前面就空一格;跨行写的一对圆括号挤成一行时贴着里面的内容 + * (`f(\n x\n)` 写成 `f(x)`),方括号、花括号照旧空一格。 + */ function advance(out: Collector, index: number): void { - out.gap ||= /\s/.test(out.document.source.slice(out.cursor, index)) + const between = out.document.source.slice(out.cursor, index) + const hugging = between.includes('\n') && (out.tokens.at(-1)?.raw === '(' || out.document.source[index] === ')') + out.gap ||= !hugging && /\s/.test(between) out.cursor = index } @@ -344,12 +370,22 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined } + /** + * node 落在 span 里的那一段翻成一行译文,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall)。 + * 一行译文的块、常量 / 变量的值、实参表里每个值各翻各的,各自排自己里面的那个调用。 + */ + function expandedText(node: Node, span: SourceSpan, document: SourceDocument): { text: Inline[], table?: PreviewTable } { + const calls: CallSlot = {} + const text = joinTokens(tokensIn(node, span, document, calls)) + return { text, ...(calls.table && { table: calls.table }) } + } + /** * 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的), - * 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs);值照常翻,里面的调用不再排成表。 + * 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值照常翻;值里有跨几行写的调用时同样排成一行, + * 它的实参表排在值那一格里(PropertyItem.table),一层层往下套。 */ 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) { @@ -364,18 +400,26 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return [hint('展开关键字')] } } - return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(arg => ({ cells: [label(arg), value(arg.value)] })) } + const row = (arg: CallArgument): PropertyItem => { + const { text, table } = expandedText(arg.value, span, document) + return { cells: [label(arg), text], ...(table && { table }) } + } + return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(row) } } /** * 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 calls 里。被调用的名字照常翻、带源码下标, * 最后那个名字新建时按类型、调用时按函数着色,前面几节按变量着色。新建还是调用看写法(CallFacts.construct), * 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。 + * 调用后面还紧跟着同一个式子的别的部分(见 continued)时整行括起来:「(新建 X · 3 个参数).model_dump()」。 */ 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)) + const wrapped = continued(node, out.root) advance(out, node.startIndex) + if (wrapped) + emit(out, { text: '(', role: 'plain', translated: true, raw: '' }) emit(out, { text: construct ? '新建' : '调用', role: 'storage', translated: true, raw: '' }) out.gap = true out.cursor = facts.callee.startIndex @@ -387,6 +431,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { } out.gap = true emit(out, { text: `· ${facts.arguments.length} 个参数`, role: 'hint', translated: true, raw: '' }) + if (wrapped) + emit(out, { text: ')', role: 'plain', translated: true, raw: '' }) out.cursor = node.endIndex } @@ -512,11 +558,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { 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 }) } - }, + expanded: node => expandedText(node, clipped(nodeSpan(node)), document), entryCount: (literal, size) => { const name = table.mapLiterals[literal.type] return [hint(name ? `${name} ${size} 项` : `${size} 项`)] diff --git a/vscode/webview-ui/src/codeReader/BlockTree.tsx b/vscode/webview-ui/src/codeReader/BlockTree.tsx index 37684f4..f54537e 100644 --- a/vscode/webview-ui/src/codeReader/BlockTree.tsx +++ b/vscode/webview-ui/src/codeReader/BlockTree.tsx @@ -11,7 +11,7 @@ import { KindIcon } from './kindIcons' import { PropertyGrid, PropertyTable } from './PropertyGrid' import { asksOf, historyOf } from './qa' import { TextPreview } from './TextPreview' -import { translationTableId } from './viewState' +import { tableId } from './viewState' export interface BlockTreeState { version: number @@ -125,7 +125,7 @@ function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState {block.table && (
- +
)} {block.doc && } diff --git a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx index 052b681..915c044 100644 --- a/vscode/webview-ui/src/codeReader/PropertyGrid.tsx +++ b/vscode/webview-ui/src/codeReader/PropertyGrid.tsx @@ -6,7 +6,7 @@ import { inlineText } from './segments' import { SegmentText } from './SegmentText' import { subRowsLayout, tableCells, tableTemplate } from './subRows' import { TextPreview } from './TextPreview' -import { propertyItemId, propertyRowId } from './viewState' +import { propertyItemId, propertyRowId, tableId } from './viewState' /** 列表子行里除最后一列以外各列的宽度(字典的键),最后一列占满剩下的;编辑区窄时按比例收,不把最后一列挤没 */ const CELL_WIDTHS = ['min(150px, 28%)', 'min(220px, 38%)'] @@ -96,7 +96,8 @@ function PropertyItems({ parentId, items, folding }: { parentId: string, items: /** * 给了列名的子行(输入参数、数据表的字段)排成表格:第一行是表头(列名),下面每个子行一行;外框圆角,格子之间、行之间一条分隔线。 - * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它。 + * 列宽按整列的内容定(见 tableTemplate),最后一列分剩下的,长了在格子里换行。一行译文下面摊开实参的表也用它; + * 行带 table(实参的值里跨几行写的调用)时,那张表排在最后一格里、文字下面,往右缩一点,一层层往下套。 */ export function PropertyTable({ parentId, columns, items }: { parentId: string, columns: string[], items: PropertyItem[] }): ReactElement { return ( @@ -105,15 +106,28 @@ export function PropertyTable({ parentId, columns, items }: { parentId: string,
{columns.map((name, i) => {name})}
- {items.map((item, index) => ( -
- {tableCells(item, columns).map((cell, i) => ( -

- -

- ))} -
- ))} + {items.map((item, index) => { + const id = propertyItemId(parentId, index) + const cells = tableCells(item, columns) + return ( +
+ {cells.map((cell, i) => item.table && i === cells.length - 1 + ? ( +
+

+ +

+ +
+ ) + : ( +

+ +

+ ))} +
+ ) + })} ) diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index 63f502e..b5174b7 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -105,6 +105,17 @@ describe('跳到定义:滚到哪块、展开哪些', () => { expect(occurrencePlan(blocks, { start: 40, end: 42, kind: 'read' }, () => false)).toEqual({ id: '0.0', expand: ['0'] }) }) + it('名字在值那一格里套着的实参表里(套了几层都算):一行译文下面的不用展开,变量初始值那一行下面的展开那一行', () => { + const leaf = { columns: ['参数', '值'], items: [{ cells: [[name('mp_app_secret', 70)], [name('secret', 90)]] }] } + const nested = { columns: ['参数', '值'], items: [{ cells: [[name('credentials', 50)], [word('新建'), name('CredentialPresence', 62)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('inner', 66)], [word('新建')]], table: leaf }] } }] } + const blocks: BlockView[] = [ + { ...textBlock('0', 'return', 0, [word('返回'), word('新建'), name('Diagnostic', 10)]), table: nested }, + factBlock('1', 'variable', 5, [{ label: '初始值', value: [word('新建'), name('Diagnostic', 110)], columns: ['参数', '值'], items: nested.items }]), + ] + expect(revealPlan(blocks, 9, 90)).toEqual({ id: '0', expand: [] }) + expect(occurrencePlan(blocks, { start: 70, end: 83, kind: 'read' }, id => id === '0')).toEqual({ id: '1', expand: ['1/p0'] }) + }) + 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 7f08f6c..e282156 100644 --- a/vscode/webview-ui/src/codeReader/reveal.ts +++ b/vscode/webview-ui/src/codeReader/reveal.ts @@ -38,9 +38,9 @@ function shows(text: PreviewText, offset: number): boolean { return !('markdown' in text) && showsOffset(text, offset) } -/** 一行译文下面那张表(总是展开的)里有格子满足 test。 */ +/** 实参表(一行译文下面那张、值那一格里套着的,都总是展开的)里有格子满足 test,套着的表一层层都算。 */ function tableShows(table: PreviewTable | undefined, test: (cell: PreviewText) => boolean): boolean { - return table?.items.some(item => item.cells.some(test)) ?? false + return table?.items.some(item => item.cells.some(test) || tableShows(item.table, test)) ?? false } /** @@ -87,10 +87,13 @@ function definingItem(items: PropertyItem[] | undefined, parentId: string, offse return undefined } -/** parentId 下面一层层的子行里,哪个的格子满足 test(显示着要找的名字):要展开的各层(parentId 到那个子行所在的那一层)。 */ +/** + * parentId 下面一层层的子行里,哪个的格子(连它值那一格里套着的实参表)满足 test(显示着要找的名字): + * 要展开的各层(parentId 到那个子行所在的那一层)。 + */ function showingItem(items: PropertyItem[] | undefined, parentId: string, test: (cell: PreviewText) => boolean): string[] | undefined { for (const [index, item] of (items ?? []).entries()) { - if (item.cells.some(test)) + if (item.cells.some(test) || tableShows(item.table, test)) return [parentId] const inner = showingItem(item.items, propertyItemId(parentId, index), test) if (inner) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index c1017b6..a6ec6da 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -58,6 +58,8 @@ export interface PropertyItem extends Defines { cells: Inline[][] /** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */ items?: PropertyItem[] + /** 排在这一行最后一格里、那格文字下面的表(实参的值里跨几行写的调用的实参表),表里的行又可以带 table;总是展开 */ + table?: PreviewTable } /** diff --git a/vscode/webview-ui/src/codeReader/viewState.ts b/vscode/webview-ui/src/codeReader/viewState.ts index 879d3a0..7bf7edf 100644 --- a/vscode/webview-ui/src/codeReader/viewState.ts +++ b/vscode/webview-ui/src/codeReader/viewState.ts @@ -91,7 +91,10 @@ export function propertyItemId(parentId: string, itemIndex: number): string { return `${parentId}/${itemIndex}` } -/** 一行译文下面那张表(跨几行写的调用的实参)的 id:表里各行的 id 是 propertyItemId(它, 行号)。这张表总是展开的。 */ -export function translationTableId(blockId: string): string { - return `${blockId}/t` +/** + * 实参表的 id:一行译文下面那张(owner 是块 id),或实参表里一行的值那一格里套着的那张(owner 是那一行的 id); + * 表里各行的 id 是 propertyItemId(它, 行号)。这两种表总是展开的。 + */ +export function tableId(ownerId: string): string { + return `${ownerId}/t` }