From 71641bdc4119e86cc20c8fa5305617a8f3cbf675 Mon Sep 17 00:00:00 2001 From: cruldra Date: Sun, 27 Sep 2026 21:26:36 +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=20FastAPI=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=99=A8=E5=8D=95=E7=8B=AC=E4=B8=80=E5=BC=A0=E3=80=8C=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=99=A8=E3=80=8D=E5=8D=A1=E7=89=87=EF=BC=88=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E3=80=81=E6=A0=87=E7=AD=BE=E3=80=81=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E3=80=81=E9=BB=98=E8=AE=A4=E5=93=8D=E5=BA=94=EF=BC=8C=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=A1=A8=E5=88=97=E6=8C=82=E5=9C=A8=E5=AE=83=E4=B8=8A?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=90=8D=20=E2=8C=98=20+=20=E7=82=B9=E5=87=BB=E8=B7=B3?= =?UTF-8?q?=E5=88=B0=E6=8E=A5=E5=8F=A3=E5=8D=A1=E7=89=87=EF=BC=89=EF=BC=9B?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=8D=A1=E7=89=87=E5=86=99=E6=89=80=E5=B1=9E?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 49 ++++- vscode/src/codeReader/contract.ts | 1 + vscode/src/codeReader/rules/fastapi.test.ts | 87 ++++++++- vscode/src/codeReader/rules/fastapi.ts | 171 ++++++++++++++++-- .../webview-ui/src/codeReader/kindIcons.tsx | 2 + vscode/webview-ui/src/codeReader/types.ts | 2 +- 6 files changed, 282 insertions(+), 30 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 0ec2a81..576b136 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -126,9 +126,9 @@ | enum 枚举 | `list-ordered` | else 否则 | `corner-down-right` | statement 语句 | `code` | | exception 异常 | `triangle-alert` | loop 循环 | `repeat` | comment 注释 | `message-square-text` | - 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`。 + 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`。 - 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 18 个加库规则用的 5 个,lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 + 这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 18 个加库规则用的 6 个,lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,`KindBadge.icon` 只能写其中一个,写别的是类型错误。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。 - 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。 - 悬停提示(整个预览只走这一条路,看板 webview 共用同一个组件):带提示的元素只写 `data-tooltip="提示"`(名字照旧写在 `aria-label` 里),页面根上挂一个 `TooltipHost`(`webview-ui/src/components/ui/tooltip.tsx`)统一弹,不用原生 `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` 挂到元素上,读屏当描述念。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 @@ -669,7 +669,7 @@ export interface Translator { export interface ThemeColor { dark: string, light: string } /** 种类图标:插件自己定义的一组 lucide 图标(kebab-case 名字);前 18 个给内置种类,后面的给库规则。规则要新图标就加进这组,没有按任意名字找图标的路。 */ -export const KIND_ICON_NAMES = ['import', 'square-function', /* …18 个内置种类的… */ 'database', 'package', 'server-cog', 'calendar-clock', 'globe'] as const +export const KIND_ICON_NAMES = ['import', 'square-function', /* …18 个内置种类的… */ 'database', 'package', 'server-cog', 'calendar-clock', 'globe', 'network'] as const export type KindIconName = (typeof KIND_ICON_NAMES)[number] /** 库规则的种类:块左边只画图标(种类色),label 是悬停提示和读屏念的名字,筛选栏里也叫它;icon 只能从 KindIconName 里挑。 */ @@ -1022,7 +1022,40 @@ export interface CodeReaderApi { ### 内置:FastAPI 接口(Python) -`vscode/src/codeReader/rules/fastapi.ts`,`libraries: ['fastapi']`。原型:`code.js` 的「库规则:FastAPI 接口」一节(`ROUTE_SAMPLE`、`routeCard`),画板「库规则 · FastAPI 接口」;样例是 luffy 的 `user/routes.py`(`fixtures/routes.py`)、`skill_hub/routes.py`(`fixtures/skillHubRoutes.py`)、`user/console/routes.py`(节选在 `fixtures/consoleRoutes.py`)。种类 `fastapi.route`,名字「接口」,图标 `globe`,颜色暗 #61afef / 亮 #1a6fb5,和函数块一样默认收起子块。 +`vscode/src/codeReader/rules/fastapi.ts`,`libraries: ['fastapi']`。原型:`code.js` 的「库规则:FastAPI 接口」一节(`ROUTER_SAMPLE`、`routerCard`、`ROUTE_SAMPLE`、`routeCard`),画板「库规则 · FastAPI 接口」;样例是 luffy 的 `user/routes.py`(`fixtures/routes.py`)、`skill_hub/routes.py`(`fixtures/skillHubRoutes.py`)、`user/console/routes.py`(节选在 `fixtures/consoleRoutes.py`)。路由器和挂在它上面的接口是两回事,各一张卡片: + +- 接口:种类 `fastapi.route`,名字「接口」,图标 `globe`,颜色暗 #61afef / 亮 #1a6fb5,和函数块一样默认收起子块。见下面「接口卡片」。 +- 路由器:种类 `fastapi.router`,名字「路由器」,图标 `network`,颜色暗 #7aa2f7 / 亮 #34548a(Tokyo Night 的蓝,亮色取它 Day 主题的)。见下面「路由器卡片」。 + +#### 路由器卡片 + +模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 的赋值(常量、变量块,只声明它一个)整块换成「路由器」卡片,没有子块可并。属性表(有才显示): + +| 行 | 内容 | +| --- | --- | +| 名称 | 赋值目标(variable 色,带下标;跳到路由器的定义闪整张卡片) | +| 说明 | 赋值后面的属性 docstring;Markdown | +| 前缀 | `prefix=`,着色同接口卡片的 URL;`FastAPI(...)` 没有 | +| 标签 | `tags=`,同接口卡片 | +| 依赖 | `dependencies=[Depends(x)]` 写 `x`,写了才有 | +| 默认响应 | `responses={...}`,写了才有;值和表格同接口卡片的「响应」 | +| 接口 | `N 个`(hint),下面一张表格「方法 \| 路径 \| 函数 \| 说明」,列这份文件模块顶层挂在它上面的全部接口,照源码顺序;一个都没有时写「0 个」、不画表格 | + +接口表的格子:方法同接口卡片(Swagger UI 的颜色);路径是完整的(前缀 + 装饰器里的路径),着色同接口卡片的 URL,`{参数}` 里的名字讲的是那个函数的同名参数(`refers`);函数是函数名(function 色,带下标:⌘ + 点击跳到它的定义,就是那张接口卡片,闪整张卡片);说明是函数 docstring 的第一行(comment 色),没有 docstring 留空。 + +样例(`fixtures/routes.py` 的 `router`,测试里逐字核对): + +``` +名称: router +前缀: /users +标签: 用户管理 +接口: 3 个 + GET | /users/{user_id} | get_user | 获取用户详情。 + PUT | /users/{user_id} | update_user | 更新用户信息。 + POST | /users/me/avatar | upload_current_user_avatar | 上传当前登录用户头像。 +``` + +#### 接口卡片 - 认哪些函数:装饰器是 `@<路由对象>.<方法>(...)`,方法是 get / post / put / patch / delete / head / options / trace,路由对象是同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 赋值出来的(`APIRouter`、`FastAPI` 从 fastapi 导入,别名、`fastapi.APIRouter` 也算)。路由对象从别的文件导入的、`@router.get` 不带括号的、`@router.websocket` 不认。几个路由装饰器时取第一个。 - 整个函数块换成卡片,不并子块:函数体照常是子块;编辑范围同别的卡片,是整条语句(连装饰器的整个函数)。 @@ -1035,6 +1068,7 @@ export interface CodeReaderApi { | --- | --- | | 名称 | 函数名(function 色,带下标) | | 说明 | 函数的 docstring,同函数块;Markdown | +| 路由器 | 挂在哪个路由对象上:它的名字(variable 色,带下标,⌘ + 点击跳到路由器卡片) | | 标签 | 路由对象的 `tags=` 加装饰器的 `tags=`,字符串不带引号(string 色,带下标),「、」隔开 | | 异步 | 是 / 否(storage 色) | | 装饰器 | 路由装饰器以外的装饰器(`@cache(60)`),有才显示 | @@ -1077,6 +1111,7 @@ export interface CodeReaderApi { ``` 名称: update_user 说明: 更新用户信息。… +路由器: router 标签: 用户管理 异步: 是 依赖: get_db(db)、get_current_user(current_user) @@ -1295,7 +1330,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | 带自己颜色的段(`Segment.color`,库规则按库的配色给,例如 FastAPI 的 HTTP 方法、成功的状态码):暗色、亮色两个写在元素的 style 上(`--color-dark` / `--color-light`),`.cr-colored` 按主题挑一个,盖过词性的颜色;词性照旧报(`role`)。 -种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 ## 代码位置 @@ -1315,7 +1350,7 @@ vscode/src/codeReader/ rules/celery.ts 内置 Celery 库规则:两张卡片、频率换算、任务名索引 rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认 rules/dataclass.ts 内置 dataclasses 库规则(Python):数据类卡片,字段照 dataclasses 的规则认 - rules/fastapi.ts 内置 FastAPI 库规则(Python):接口卡片(请求表、响应表),参数照 FastAPI 的规则放,异常类的状态码、类型别名、枚举经索引查 + rules/fastapi.ts 内置 FastAPI 库规则(Python):路由器卡片(接口表)、接口卡片(请求表、响应表),参数照 FastAPI 的规则放,异常类的状态码、类型别名、枚举经索引查 ruleLookups.ts 库规则认领时查别的文件里的定义(RuleContext.lookup):先交出已知的、记下问了什么,发出预览后查(索引 + 语言扩展查定义),变了叫宿主重发(不碰 VS Code) rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字) rules/segments.ts 库规则共用的拼属性表文字的办法 @@ -1402,7 +1437,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。 - 枚举(`rules/enum.test.ts`、`languages/typescript.test.ts`):`fixtures/enumModels.py` 的 `CommissionStatus` 逐字同上面的样例,`SecretScope` 的 docstring 当说明、没有说明列;种类 enum、叫「枚举」、访问级别同原来的类、没有标签(badge),编辑范围是整个类;Python 成员规则(dunder、sunder、私有名、`_ignore_`、lambda、`property`、`nonmember`、只写注解的不是成员,`auto()` 写「自动」,`member(x)` 取 x,说明取上面的注释块、行尾注释、属性 docstring,装饰器);方法、嵌套类、不是成员的属性照源码顺序是子块,成员前面的注释并进来、最后面的留下,「编辑」写回整个类;类名、成员名的定义范围和带下标的词指回源码原文;没导入 enum、SQLAlchemy 的 `Enum`、不是枚举类型的父类不认,`import enum as e` 的 `e.IntEnum` 认。TypeScript:`enums.ts` 里 export / const / declare / 函数里的 enum 都是枚举块、JSDoc 进头部,写的值、自增算出的值(接不上数字就没有)、说明(上面的注释和 JSDoc、行尾注释,同一行的 JSDoc 讲下一个),访问级别;自增的值和「自动」按 hint。TypeScript 和 Python 同样的成员得到一样的种类、属性表行、列名和格子着色(除了 Python 多的「继承」)。宿主:规则认成现成属性表块时照语言插件报的这种块显示、并进来的不是子块时照常显示;webview 枚举块默认折叠。 - 数据类(`rules/dataclass.test.ts`):`fixtures/dataclassModels.py` 的 `SandboxAccess` 逐字同上面的样例,`AdminUserRow`、`RedirectPayResult`(`field(init=False, default=…)`)、`SmsSent`(`default_factory=dict`)的行;种类 `dataclasses.dataclass`、叫「数据类」、颜色、访问级别同原来的类;编辑范围是整个类、写回原样,`__post_init__` 是子块;类名、字段名的定义范围和带下标的词指回源码原文;装饰器的写法(别名、`dataclasses.dataclass`、`dc.dataclass`)认,pydantic 的、`other.dataclass` 不认,没导入 dataclasses 不认;每个选项的译法、默认值不讲、别的照写、`**x`、别的装饰器一行;字段规则(`ClassVar`、`typing.ClassVar` 留作子块,`InitVar` 的类型和「仅初始化时使用」,`KW_ONLY` 记号并进来、后面的字段「只能按名字传参」、单独写 `kw_only=False` 的「可按位置传参」),`field(...)` 的默认值、`default_factory`(名字、lambda)、各参数、`metadata` 照写、`doc=`,说明取上面的注释块、行尾注释、属性 docstring;没注解的类属性、字段中间的方法照源码顺序是子块,「编辑」写回整个类。 -- FastAPI 接口(`rules/fastapi.test.ts`):`fixtures/routes.py` 的 `update_user` 逐字同上面的样例(查到 `Forbidden` 是 403 时四条消息写在一行,没查到时不列),名字、颜色、图标、列名、筛选属性、定义范围、编辑范围是整个函数、函数体是子块;着色(URL 的 `{参数}` / 路径,花括号里的参数名 `refers` 到函数的参数,方法的 Swagger 颜色、成功的状态码绿色、自动带的 422 淡色,带下标的词指回源码原文);`fixtures/skillHubRoutes.py` 的 `list_skills`(Annotated 里的 Query、`_` 的依赖)、`get_skill`(HTTPException 的 status_code / detail)、`install`(Request 不算、`-> None` 没有内容);`fixtures/consoleRoutes.py` 的 `create_user`(`status_code=201`、Pydantic 模型、调别的函数间接抛的不算)、`list_users`(查到的类型别名 `FilterQuery` 是依赖、Literal 是简单类型);参数放在哪儿的各条(路径里的名字含 `{x:path}`、各种标记两种写法、上传文件、枚举和类型别名查到的、Pydantic 模型、`Security`、`Depends()`);标签、依赖、`responses=` 叠上路由对象的,`status.HTTP_…` 常量、`response_model=None`、`response_description`、`raise … from`、只写 `raise`、嵌套函数里的;`FastAPI()` 上的接口、别的装饰器一行;不认的(没导入、路由对象在别的文件、不是路由对象、`websocket`、不带括号)。跨文件(工作区索引 + 假的语言扩展):`update_user` 的 `Forbidden` 按定义处查到 `core/exceptions/schemas.py` 的 403,同名的别处的类不算;索引认的类上的 `status_code`(带注解、常量)、枚举类、`Annotated` 类型别名,普通类不认。 +- FastAPI 接口(`rules/fastapi.test.ts`):`fixtures/routes.py` 的 `update_user` 逐字同上面的样例(查到 `Forbidden` 是 403 时四条消息写在一行,没查到时不列),名字、颜色、图标、列名、筛选属性、定义范围、编辑范围是整个函数、函数体是子块;着色(URL 的 `{参数}` / 路径,花括号里的参数名 `refers` 到函数的参数,方法的 Swagger 颜色、成功的状态码绿色、自动带的 422 淡色,带下标的词指回源码原文);`fixtures/skillHubRoutes.py` 的 `list_skills`(Annotated 里的 Query、`_` 的依赖)、`get_skill`(HTTPException 的 status_code / detail)、`install`(Request 不算、`-> None` 没有内容);`fixtures/consoleRoutes.py` 的 `create_user`(`status_code=201`、Pydantic 模型、调别的函数间接抛的不算)、`list_users`(查到的类型别名 `FilterQuery` 是依赖、Literal 是简单类型);参数放在哪儿的各条(路径里的名字含 `{x:path}`、各种标记两种写法、上传文件、枚举和类型别名查到的、Pydantic 模型、`Security`、`Depends()`);标签、依赖、`responses=` 叠上路由对象的,`status.HTTP_…` 常量、`response_model=None`、`response_description`、`raise … from`、只写 `raise`、嵌套函数里的;`FastAPI()` 上的接口、别的装饰器一行;不认的(没导入、路由对象在别的文件、不是路由对象、`websocket`、不带括号)。跨文件(工作区索引 + 假的语言扩展):`update_user` 的 `Forbidden` 按定义处查到 `core/exceptions/schemas.py` 的 403,同名的别处的类不算;索引认的类上的 `status_code`(带注解、常量)、枚举类、`Annotated` 类型别名,普通类不认。路由器卡片:`fixtures/routes.py` 的 `router` 逐字同上面的样例,名字、颜色、图标、列名、编辑范围、定义范围;接口表的函数名带下标、路径里的 `{user_id}` 各 `refers` 到各自函数的参数、方法的颜色;接口卡片的「路由器」行排在说明后面、名字带下标指回路由器;写了依赖、默认响应才有这两行,`FastAPI()` 没有前缀,属性 docstring 是说明,没挂接口时「0 个」、不画表格。 - 规则查别的文件里的定义(`ruleLookups.test.ts`):第一遍当没查到、记下问了什么(同一索引同名的只记一处);查到后叫重排,交出定义处正是的那一项;同一版本不再查;索引里没有同名的不问语言扩展,定义处不是索引里的项算没有,没变不叫重排;新版本先用上一版的、再查、变了才叫重排;语言扩展没答时已知的不动、隔一会儿再查。 - 带自己颜色的词(webview `SegmentText.test.tsx`):`cr-colored`,暗色、亮色写在 style 上;讲别处名字的段 `data-offset` 记那个名字的下标。 - 异常(`languages/exceptions.test.ts`,真实解析 + 中文直译 + 视图数据):`fixtures/appExceptions.py` 的 `AppException`、`NotFound` 逐字同「异常」的样例(列名随有没有类型、说明变),`GatewayTimeout` 的多段说明,方法是子块;`fixtures/commerceExceptions.py` 16 个类都是异常、只有说明的四行、写了 `__init__` 的构造参数;认哪些类(自带的异常类、`Warning`、不合后缀的自带类 `StopIteration` / `KeyboardInterrupt`、`pkg.errors.AppException`、同一文件里写在后面的父类一层层往下传、泛型和别的父类混写,关键字参数、`Mapping[...]`、`*bases` 不算,函数里的类也认);类属性(带不带注解、只写注解、说明取上面的注释块 / 行尾注释 / 属性 docstring,连着赋值、解构赋值、方法、讲方法的注释、最后面的注释留作子块),构造参数去掉 self、`*args` 开头的不去;`@dataclass` 的异常类照样是异常卡片;编辑范围是整个类、改卡片写回整个类,类名、属性名、参数名的定义范围和带下标的词指回源码原文;TypeScript `fixtures/exceptions.ts.txt`(`extends Error`、类字段含 static、参数属性、JSDoc、写在后面的父类、类表达式、`ns.X`,父类名不合习惯的照常是类)、JavaScript 的类字段;TypeScript 和 Python 同样的异常得到一样的行和列名;筛选的种类叫「异常」、没有 badge;属性的中文名和去掉行尾记号。webview:异常块默认折叠(`viewState.test.ts`),跳到类名闪整块、属性名 / 构造参数名只闪表里那一行(`reveal.test.ts`)。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 3eff5f8..59ce795 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -444,6 +444,7 @@ export const KIND_ICON_NAMES = [ 'server-cog', 'calendar-clock', 'globe', + 'network', ] as const export type KindIconName = (typeof KIND_ICON_NAMES)[number] diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index a5dcc8f..2d11423 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -69,6 +69,7 @@ describe('fastapi 库规则:接口卡片', () => { expect(propertyLines(properties(card))).toEqual([ '名称: update_user', '说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。', + '路由器: router', '标签: 用户管理', '异步: 是', '依赖: get_db(db)、get_current_user(current_user)', @@ -82,7 +83,7 @@ describe('fastapi 库规则:接口卡片', () => { ' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码', ' 422 | 参数校验失败 | FastAPI 自动返回', ]) - expect(properties(card).map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, undefined, ['项', '值'], ['状态码', '内容', '说明']]) + expect(properties(card).map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, undefined, undefined, ['项', '值'], ['状态码', '内容', '说明']]) expect(card.attributes).toMatchObject({ kind: 'fastapi.route', access: 'public', async: 'true', httpMethod: 'PUT' }) expect(fastapiRule.attributeNames?.('zh')).toEqual({ attributes: { httpMethod: '请求方法' }, values: {} }) expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('update_user') @@ -123,6 +124,7 @@ describe('fastapi 库规则:接口卡片', () => { it('luffy 的 list_skills:Annotated 里的 Query 是查询参数(类型取 Annotated 的第一项),Depends 是依赖,参数名是 _ 的只写依赖', async () => { expect(propertyLines(properties(await route(SKILL_HUB, 'list_skills')))).toEqual([ '名称: list_skills', + '路由器: skill_hub_router', '标签: skill-hub', '异步: 是', '依赖: get_db(db)、get_current_user', @@ -142,7 +144,7 @@ describe('fastapi 库规则:接口卡片', () => { ' 404 | HTTPException | f"技能不存在: {slug}"', ' 422 | 参数校验失败 | FastAPI 自动返回', ]) - expect(propertyLines(properties(await route(SKILL_HUB, 'install'))).slice(4)).toEqual([ + expect(propertyLines(properties(await route(SKILL_HUB, 'install'))).slice(5)).toEqual([ '请求: POST /skill-hub/apps/{app_id}/skills/{slug}', ' URL | /skill-hub/apps/{app_id}/skills/{slug}', ' 方法 | POST', @@ -157,6 +159,7 @@ describe('fastapi 库规则:接口卡片', () => { expect(propertyLines(properties(await route(CONSOLE, 'create_user', known({ Forbidden: '403' }))))).toEqual([ '名称: create_user', '说明: 创建新用户。\n\n需要管理员权限。密码会自动哈希。', + '路由器: router', '标签: 用户管理-运维', '异步: 是', '依赖: get_db(db)、require_users_create', @@ -239,6 +242,7 @@ def remove(item_id: int): expect(propertyLines(properties(await route(source, 'remove')))).toEqual([ '名称: remove', '说明: 删掉一个物品。', + '路由器: router', '标签: 物品、管理', '异步: 否', '依赖: verify_token、audit', @@ -265,6 +269,7 @@ async def health() -> dict: ` expect(propertyLines(properties(await route(source, 'health')))).toEqual([ '名称: health', + '路由器: app', '异步: 是', '装饰器: @cache(60)', '请求: GET /health', @@ -303,6 +308,84 @@ def e(): }) }) +/** 名字是 name 的路由器卡片。 */ +async function routerCard(source: string, name: string): Promise { + const found = flatten(await views(source)).find(view => view.kind === 'fastapi.router' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name) + if (!found) + throw new Error(`没有 ${name} 的路由器卡片`) + return found +} + +describe('fastapi 库规则:路由器卡片', () => { + it('luffy 的 router:名称 / 前缀 / 标签,接口表列这份文件里挂在它上面的接口(方法 / 完整路径 / 函数 / docstring 第一行)', async () => { + const card = await routerCard(ROUTES, 'router') + expect(card.badge).toEqual({ label: '路由器', color: { dark: '#7aa2f7', light: '#34548a' }, icon: 'network' }) + expect(propertyLines(properties(card))).toEqual([ + '名称: router', + '前缀: /users', + '标签: 用户管理', + '接口: 3 个', + ' GET | /users/{user_id} | get_user | 获取用户详情。', + ' PUT | /users/{user_id} | update_user | 更新用户信息。', + ' POST | /users/me/avatar | upload_current_user_avatar | 上传当前登录用户头像。', + ]) + expect(properties(card).at(-1)?.columns).toEqual(['方法', '路径', '函数', '说明']) + expect(card.code).toBe('router = APIRouter(tags=["用户管理"], prefix="/users")') + expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('router') + }) + + it('函数名带下标(⌘ + 点击跳到接口卡片);路径里的 {user_id} 各讲各的函数的参数;方法用 Swagger UI 的颜色', async () => { + const [getUser, updateUser] = properties(await routerCard(ROUTES, 'router')).at(-1)?.items ?? [] + expect(getUser.cells[2]).toEqual([{ text: 'get_user', role: 'function', offset: ROUTES.indexOf('get_user(') }]) + const paramOf = (fn: string): number => ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf(`def ${fn}`)) + for (const [row, fn] of [[getUser, 'get_user'], [updateUser, 'update_user']] as const) + expect(flatSegments(row.cells[1]).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length }) + expect(updateUser.cells[0]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }]) + }) + + it('接口卡片写挂在哪个路由器上(名字带下标,⌘ + 点击跳到路由器卡片),排在说明后面', async () => { + const rows = properties(await route(ROUTES, 'update_user')) + expect(rows.map(row => row.label).slice(0, 4)).toEqual(['名称', '说明', '路由器', '标签']) + expect(rows[2].value).toEqual([{ text: 'router', role: 'variable', offset: ROUTES.indexOf('router = APIRouter') }]) + }) + + it('写了依赖、默认响应才有这两行;FastAPI 应用没有前缀;变量的说明是属性 docstring;没挂接口时「0 个」、不画表格', async () => { + const source = `${HEADER}"""整个应用。""" +empty = APIRouter() + +@router.get("/{item_id}") +def show(item_id: int): + """看一个物品。 + + 带库存。 + """ + +@app.post("/items") +def create(): + pass +` + expect(propertyLines(properties(await routerCard(source, 'router')))).toEqual([ + '名称: router', + '前缀: /items', + '标签: 物品', + '依赖: verify_token', + '默认响应: 401', + ' 401 | | 没登录', + '接口: 1 个', + ' GET | /items/{item_id} | show | 看一个物品。', + ]) + expect(propertyLines(properties(await routerCard(source, 'app')))).toEqual([ + '名称: app', + '说明: 整个应用。', + '接口: 1 个', + ' POST | /items | create | ', + ]) + const empty = properties(await routerCard(source, 'empty')) + expect(propertyLines(empty)).toEqual(['名称: empty', '接口: 0 个']) + expect(empty.at(-1)?.columns).toBeUndefined() + }) +}) + describe('fastapi 库规则:别的文件里的定义(工作区索引 + 语言扩展查定义)', () => { const SCHEMAS = 'backend/src/luffy_agent/core/exceptions/schemas.py' const PARAMS = 'backend/src/luffy_agent/core/params.py' diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts index dba252b..b9b77d9 100644 --- a/vscode/src/codeReader/rules/fastapi.ts +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -1,4 +1,4 @@ -import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract' +import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract' import type { Call, LibraryNames } from './pythonTree' import { callOf, genericOf, lastName, libraryName, stringContent, syntaxChildren, unwrapType } from './pythonTree' import { hint, joined, name, range, word } from './segments' @@ -15,6 +15,9 @@ const RULE_ID = 'fastapi' const KIND = 'fastapi.route' const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' } const ICON: KindIconName = 'globe' +const ROUTER_KIND = 'fastapi.router' +const ROUTER_COLOR: ThemeColor = { dark: '#7aa2f7', light: '#34548a' } +const ROUTER_ICON: KindIconName = 'network' /** 认路由对象、参数标记、HTTPException 时看从这几个库导入的名字(HTTPException、status 也可以从 starlette 来)。 */ const NAME_LIBRARIES = ['fastapi', 'starlette'] @@ -63,12 +66,17 @@ type Place = 'path' | 'query' | 'header' | 'cookie' | 'body' | 'dependency' /** 一种显示语言的用词。 */ interface Words { + /** 接口卡片、路由器卡片的种类名 */ kind: string - labels: Record<'name' | 'doc' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses', string> + routerKind: string + labels: Record<'name' | 'doc' | 'router' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses' | 'prefix' | 'defaultResponses' | 'routes', string> yes: string no: string + count: (n: number) => string requestHeads: string[] responseHeads: string[] + /** 路由器卡片的接口表:方法 | 路径 | 函数 | 说明 */ + routeHeads: string[] items: Record<'url' | 'method' | 'path' | 'query' | 'header' | 'cookie' | 'body', string> /** 成功那一行没写 response_description 时的说明 */ success: string @@ -86,11 +94,27 @@ interface Words { const ZH: Words = { kind: '接口', - labels: { name: '名称', doc: '说明', tags: '标签', async: '异步', decorators: '装饰器', dependencies: '依赖', request: '请求', responses: '响应' }, + routerKind: '路由器', + labels: { + name: '名称', + doc: '说明', + router: '路由器', + tags: '标签', + async: '异步', + decorators: '装饰器', + dependencies: '依赖', + request: '请求', + responses: '响应', + prefix: '前缀', + defaultResponses: '默认响应', + routes: '接口', + }, yes: '是', no: '否', + count: n => `${n} 个`, requestHeads: ['项', '值'], responseHeads: ['状态码', '内容', '说明'], + routeHeads: ['方法', '路径', '函数', '说明'], items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' }, success: '成功', validation: '参数校验失败', @@ -225,6 +249,8 @@ function listItems(node: Node | undefined): Node[] { /** 路由对象:同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 赋值出来的,和它给挂在上面的接口叠的东西。 */ interface Router { + /** 赋值目标:路由对象的名字 */ + target: Node prefix?: Node tags: Node[] dependencies: Node[] @@ -242,6 +268,7 @@ function routersOf(root: Node, env: Env): Map { if (target?.type !== 'identifier' || !call || (kind !== 'APIRouter' && kind !== 'FastAPI')) continue routers.set(target.text, { + target, prefix: kind === 'APIRouter' ? call.keywords.get('prefix') : undefined, tags: listItems(call.keywords.get('tags')), dependencies: listItems(call.keywords.get('dependencies')), @@ -272,6 +299,40 @@ function routeOf(decorator: Node, routers: ReadonlyMap): Route | return { decorator, router, method: method.toUpperCase(), call, path: call.positional[0] ?? call.keywords.get('path') } } +/** 路由器卡片接口表里的一个接口:挂在这个路由对象上的函数(模块顶层的)。 */ +interface RouteOn { + route: Route + definition: Node + name: Node +} + +/** 这份文件模块顶层挂在 router 上的接口,照源码顺序;一个函数几个路由装饰器时同接口卡片,取第一个。 */ +function routesOn(root: Node, routers: ReadonlyMap, router: Router): RouteOn[] { + return syntaxChildren(root).flatMap((statement) => { + const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : null + const name = definition?.type === 'function_definition' ? definition.childForFieldName('name') : null + if (!definition || !name) + return [] + const route = syntaxChildren(statement).filter(child => child.type === 'decorator').map(decorator => routeOf(decorator, routers)).find(found => found !== undefined) + return route?.router === router ? [{ route, definition, name }] : [] + }) +} + +/** 函数的参数名 → 定义处那个名字(`*args` 这种不算),路径里 `{参数}` 讲的是它。 */ +function parametersOf(definition: Node): Map { + const names = syntaxChildren(definition.childForFieldName('parameters')).map(param => + param.type === 'identifier' ? param : param.type === 'typed_parameter' ? syntaxChildren(param)[0] : param.childForFieldName('name')) + return new Map(names.flatMap(param => param?.type === 'identifier' ? [[param.text, param] as const] : [])) +} + +/** 函数 docstring 的第一行(翻译插件取的说明原文),没有 docstring 时没有。 */ +function docFirstLine(definition: Node, env: Env): string | undefined { + const [first] = syntaxChildren(definition.childForFieldName('body')) + const [doc] = first?.type === 'expression_statement' ? syntaxChildren(first) : [] + const markdown = doc?.type === 'string' ? env.context.doc(doc)?.markdown : undefined + return markdown?.split('\n').map(line => line.trim()).find(line => line !== '') +} + /** * 路径里的一段:`{参数}` 按变量着色,其余按字符串,都带源码下标;花括号里的名字讲的是函数的同名参数(params), * 标上 refers,高亮参数的各处出现时它也跟着亮、⌘ + 点击跳到参数。写的不是字符串时交给翻译插件翻。 @@ -544,6 +605,25 @@ function addRaises(body: Node | null | undefined, statuses: Statuses, env: Env): } } +/** 响应那一行:值是各状态码用 ` / ` 连起来,下面一张表格「状态码 | 内容 | 说明」(同一行里的内容「、」隔开,消息「;」隔开)。 */ +function responsesRow(label: string, statuses: Statuses, env: Env): PropertyRow { + const { words } = env + const rows = statuses.rows() + return { + label, + value: [hint(rows.map(row => textOf(row.code)).join(' / '))], + columns: words.responseHeads, + items: rows.map(row => ({ + cells: [row.code, joined(row.contents, hint(words.listSeparator)), joined(row.notes, { text: words.messageSeparator, role: 'comment' })], + })), + } +} + +/** HTTP 方法:大写,按 Swagger UI 的配色(没有配色的按关键字着色)。 */ +function methodText(method: string): Inline[] { + return [{ text: method, role: 'keyword', ...(METHOD_COLORS[method] && { color: METHOD_COLORS[method] }) }] +} + /** 请求表的一行:项 | 值。 */ function requestItem(label: string, value: Inline[]): PropertyItem { return { cells: [[hint(label)], value] } @@ -565,7 +645,7 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { const url = [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)] const request: PropertyItem[] = [ requestItem(words.items.url, url), - requestItem(words.items.method, [{ text: route.method, role: 'keyword', ...(METHOD_COLORS[route.method] && { color: METHOD_COLORS[route.method] }) }]), + requestItem(words.items.method, methodText(route.method)), ...(['path', 'query', 'header', 'cookie'] as const).flatMap(place => at(place).length > 0 ? [requestItem(words.items[place], paramsText(at(place), env))] : []), ...(body.length > 0 ? [requestItem(words.items.body, body.length === 1 && body[0].type ? context.translate(body[0].type) : paramsText(body, env))] : []), ] @@ -587,7 +667,6 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { addRaises(definition?.childForFieldName('body'), statuses, env) if (placed.some(item => item.place !== 'dependency')) statuses.add(numericStatus('422'), [hint(words.validation)], [hint(words.automatic)]) - const responses = statuses.rows() const doc = facts.doc && context.doc(facts.doc) const tags = [...router.tags, ...listItems(call.keywords.get('tags'))].map(tag => tagText(tag, env)) @@ -602,6 +681,7 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { const rows: PropertyRow[] = [ ...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'function')] }] : []), ...(doc ? [{ label: labels.doc, value: doc }] : []), + { label: labels.router, value: [name(router.target, 'variable')] }, ...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []), { label: labels.async, value: [word(facts.async ? words.yes : words.no)] }, ...(decorators.length > 0 ? [{ label: labels.decorators, value: joined(decorators.map(other => context.translate(other)), { text: '\n', role: 'plain' }) }] : []), @@ -612,14 +692,7 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { columns: words.requestHeads, items: request, }, - { - label: labels.responses, - value: [hint(responses.map(row => textOf(row.code)).join(' / '))], - columns: words.responseHeads, - items: responses.map(row => ({ - cells: [row.code, joined(row.contents, separator), joined(row.notes, { text: words.messageSeparator, role: 'comment' })], - })), - }, + responsesRow(labels.responses, statuses, env), ] return { kind: KIND, @@ -631,20 +704,78 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { } } +/** + * 路由器卡片:名称 / 说明 / 前缀 / 标签 / 依赖 / 默认响应(有才显示),接口表列这份文件里挂在它上面的全部接口: + * 方法 | 路径(前缀 + 装饰器里的路径,`{参数}` 讲那个函数的参数)| 函数(⌘ + 点击跳到那张接口卡片)| 说明(docstring 第一行)。 + */ +function routerCard(block: VariableBlock, router: Router, routes: readonly RouteOn[], env: Env): RuleCard { + const { context, words } = env + const { labels } = words + const separator = hint(words.listSeparator) + const doc = block.facts.doc && context.doc(block.facts.doc) + const tags = router.tags.map(tag => tagText(tag, env)) + const dependencies = listedDependencies(router.dependencies, env) + const statuses = new Statuses() + addResponses(router.responses, statuses, env) + const rows: PropertyRow[] = [ + { label: labels.name, value: [name(router.target, 'variable')] }, + ...(doc ? [{ label: labels.doc, value: doc }] : []), + ...(router.prefix ? [{ label: labels.prefix, value: pathSegments(router.prefix, new Map(), env) }] : []), + ...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []), + ...(dependencies.length > 0 ? [{ label: labels.dependencies, value: joined(dependencies, separator) }] : []), + ...(statuses.rows().length > 0 ? [responsesRow(labels.defaultResponses, statuses, env)] : []), + { + label: labels.routes, + value: [hint(words.count(routes.length))], + ...(routes.length > 0 && { + columns: words.routeHeads, + items: routes.map(({ route, definition, name: fn }) => { + const params = parametersOf(definition) + const doc = docFirstLine(definition, env) + return { + cells: [ + methodText(route.method), + [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)], + [name(fn, 'function')], + doc ? [{ text: doc, role: 'comment' }] : [], + ], + } + }), + }), + }, + ] + return { + kind: ROUTER_KIND, + badge: { label: words.routerKind, color: ROUTER_COLOR, icon: ROUTER_ICON }, + rows, + defines: range(router.target), + absorbs: [], + } +} + export const fastapiRule: LibraryRule = { id: RULE_ID, label: 'FastAPI 接口', languageIds: ['python'], libraries: ['fastapi'], claim: (block, context) => { - if (block.kind !== 'function' || block.facts.decorators.length === 0) - return undefined const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, names: namesOf(context.imports) } - const routers = routersOf(block.node.tree.rootNode, env) - for (const decorator of block.facts.decorators) { - const route = routeOf(decorator, routers) - if (route) - return routeCard(block, route, env) + const root = block.node.tree.rootNode + if (block.kind === 'function' && block.facts.decorators.length > 0) { + const routers = routersOf(root, env) + for (const decorator of block.facts.decorators) { + const route = routeOf(decorator, routers) + if (route) + return routeCard(block, route, env) + } + } + // 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块 + if ((block.kind === 'const' || block.kind === 'variable') && block.facts.declarators.length === 1) { + const routers = routersOf(root, env) + const target = block.facts.declarators[0].name + const router = routers.get(target.text) + if (router?.target.startIndex === target.startIndex) + return routerCard(block, router, routesOn(root, routers, router), env) } return undefined }, diff --git a/vscode/webview-ui/src/codeReader/kindIcons.tsx b/vscode/webview-ui/src/codeReader/kindIcons.tsx index c644b13..d154b9f 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.tsx @@ -14,6 +14,7 @@ import { ListOrdered, Lock, MessageSquareText, + Network, Package, Repeat, ServerCog, @@ -52,6 +53,7 @@ export const KIND_ICON_COMPONENTS: Record = { 'server-cog': ServerCog, 'calendar-clock': CalendarClock, 'globe': Globe, + 'network': Network, } /** diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 6f28096..d5d6636 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -78,7 +78,7 @@ export interface ThemeColor { dark: string, light: string } export type KindIconName = | 'import' | 'square-function' | 'box' | 'braces' | 'list-ordered' | 'triangle-alert' | 'tag' | 'lock' | 'variable' | 'split' | 'corner-down-right' | 'repeat' | 'shield' | 'shield-alert' | 'shield-check' | 'corner-down-left' | 'code' - | 'message-square-text' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' + | 'message-square-text' | 'database' | 'package' | 'server-cog' | 'calendar-clock' | 'globe' | 'network' /** 库规则的种类:名字(悬停提示、读屏念的文字)、颜色和图标(从 KindIconName 里挑);块左边只画图标 */ export interface KindBadge { label: string, color: ThemeColor, icon: KindIconName }