diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index c4519f9..8598b30 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -35,7 +35,7 @@ | 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 | | 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 | | 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 | -| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | +| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片;内置 dataclasses 规则:数据类 → 「数据类」卡片;内置 FastAPI 规则:挂在路由对象上的函数 → 「接口」卡片,请求、响应各一张表),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | | 枚举 | 枚举是一种块(种类「枚举」,紫色 #b180d7 / 亮色 #652d90),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承(有才显示)/ 成员 N 个,成员排成表格「成员 \| 值 \| 说明」。TypeScript 的 `enum` 是语言自己的写法,由语言插件报;Python 没有枚举语法,自带 enum 库的写法由库规则认成同一种块。两边同一种块、同一张表、同一个筛选种类,webview 只有一种画法 | | 异常 | 异常类是一种块(种类「异常」,红色 #f14c4c / 亮色 #cd3131,和「捕获」同一色系),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承 / 属性 N 个(表格「属性 \| 类型 \| 值 \| 说明」)/ 构造参数 N 个(表格同函数的输入参数),有才显示。异常是语言自己的概念,Python、TypeScript 都由语言插件认:父类是语言自带的异常类、同一文件里认出来的异常类,或名字按命名习惯以 Error / Exception(Python 还有 Warning)结尾。方法(含构造函数)照常是子块、默认收起。见「异常」 | | 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 | @@ -126,7 +126,7 @@ | 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`。webview 按 lucide 图标名画(`lucide-react` 的全套图标),认不出的名字画虚线方块(`square-dashed`)。 + 库规则的种类用规则给的图标(`KindBadge.icon`):SQLAlchemy 数据表 `database`、数据类 `package`、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`。webview 按 lucide 图标名画(`lucide-react` 的全套图标),认不出的名字画虚线方块(`square-dashed`)。 - 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示(`title`)和 `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 能走到,回车 / 空格切换。 - 种类图标和底色按种类区分(颜色见「颜色」);注释块虚线框。 - 内容两种状态,每块单独切: @@ -566,8 +566,9 @@ export type TokenRole = * 预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。由从语法树叶子生成这段的一方填(模板直译引擎的译文和属性表); * 第三方翻译插件可以不填,不填就不能跳。 * reference:只有库规则给的段有,这段讲的东西由规则自己查定义(LibraryRule.resolve),不问语言扩展;这样的段必须带 offset。 + * color:这段自己的颜色(暗色、亮色主题各一个),盖过词性的颜色:库规则按库自己的配色上色(FastAPI 的 HTTP 方法用 Swagger UI 的颜色)。 */ -export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference } +export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor } /** 框的样式,决定框头图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */ export type GroupStyle = 'iteration' @@ -690,6 +691,7 @@ export interface RuleContext { displayLanguage?: string // 当前翻译插件的 displayLanguage;没有翻译插件时没有 translate: (node: Node) => Inline[] // 借当前翻译插件的 expression;没有翻译插件时照抄源码 doc: (node: Node) => Markdown | undefined // 借当前翻译插件的 doc + lookup: (index: WorkspaceIndex, name: Node) => IndexEntry | undefined // name 指的定义在 index 里认出的那一项(见「规则认领时查别的文件里的定义」);还没查到是 undefined } // ── 规则自己查定义(见「规则自己查定义」)── @@ -703,7 +705,7 @@ export interface IndexedFile { source: string exists: (path: string) => boolean // 同一个工作区文件夹里有没有这个文件(只认 include 找到的) } -export interface IndexEntry { key: string, name: SourceRange } +export interface IndexEntry { key: string, name: SourceRange, value?: string } // value:规则从定义处读出、认领别的文件时要用的值(FastAPI:异常类的 status_code) /** 规则在整个工作区里建的索引:规则只说看哪些文件、认出什么;找文件、预筛、解析、缓存、跟着改动更新归宿主。 */ export interface WorkspaceIndex { @@ -735,7 +737,7 @@ export interface CodeReaderApi { ``` - `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。 -- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy、Celery、enum 库规则在 `activate()` 里通过同一个 API 注册(规则按这个顺序),没有旁路。 +- 内置的 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,不认语言。 - 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 @@ -755,7 +757,7 @@ export interface CodeReaderApi { - 认成现成的属性表块(`RuleFacts`):`as` 是种类和 facts(`BlockFacts`,例如 `{ kind: 'enum', facts: EnumFacts }`),`absorbs` 同卡片。宿主把原块换成这种块(节点不变,头部是原块整条语句,子块是留下的),此后和语言插件报的这种块完全一样:翻译插件的 `describe` 排属性表、种类名取翻译插件的、颜色取这个种类的、筛选属性从 facts 推、块自己的名字从 facts 取。规则不给用词,也不给标签和颜色。只要检查并进来的是不是这块的子块。 - 规则自己的卡片(`RuleCard`),下面几条都说的是它。 - 卡片必须给:`kind`(写成「规则 id.名字」,宿主核对前缀;筛选记住的就是它,要稳定)、`badge`(种类的名字、暗 / 亮两种颜色和 lucide 图标名)、`rows`(用现成的 `PropertyRow`:属性名 + 着色的值或 Markdown,表格就是带 `columns` 的子行)、`absorbs`(并进来的子块)。可以给:`defines`(块自己的名字,跳到定义是它时闪整块)、`attributes`(自己的筛选属性);表格子行填 `PropertyItem.defines`(字段名),跳到这个定义时只闪那一行。 -- 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示)。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 +- 只经契约:规则拿到的是块(kind、node、header、children、facts)和 `RuleContext`;源码里的名字照抄时自己带下标(`Segment.offset`),表达式、类型交给 `context.translate`(当前翻译插件的 `expression`,名字带下标,⌘ + 点击、高亮各处出现照常),说明文字交给 `context.doc`(当前翻译插件的 `doc`,和别的「说明」一样按 Markdown 显示),别的文件里定义的名字交给 `context.lookup`(见「规则认领时查别的文件里的定义」);库自己的配色(HTTP 方法)写在段的 `color` 上。规则可以读自己那门语言的语法树(规则声明了 `languageIds`),但不 import 语言插件、翻译插件和宿主模块(`rules/contractOnly.test.ts` 检查 `rules/` 下非测试文件只 import `../contract` 和同目录的文件)。 - 用词归规则:属性名、表头、种类名、类型译名都在规则自己的词表里,按 `context.displayLanguage`(翻译插件的 `displayLanguage`)挑,没有这种语言的词表就用规则默认的那份;加一种显示语言就是给规则加一份词表。webview 只画结构,不认识「数据表」这些字。 ### 启用、认领、优先 @@ -778,6 +780,14 @@ export interface CodeReaderApi { - 盯着文件改动:改了只重看那一份;新建的加进来;删掉的(目录连里面的)去掉;规则认的时候问过在不在(`IndexedFile.exists`)的文件新建、删掉时,问过它的那几份跟着重看(Python 包路径看 `__init__.py`);建索引期间的改动建好后补上; - 读不了、规则认的时候出错的文件记日志、跳过,别的照常。 +### 规则认领时查别的文件里的定义 + +有的卡片要用到别的文件里的东西:FastAPI 接口里 `raise Forbidden(...)` 的状态码写在 `core/exceptions/schemas.py` 的 `class Forbidden` 上。规则照「规则自己查定义」描述一个 `WorkspaceIndex`,`entries` 认出的项带着规则要的值(`IndexEntry.value`,FastAPI:`"403"`);认领时对源码里的名字(节点)调 `context.lookup(索引, 名字)`,拿到那个名字的定义在索引里的那一项。和「中文名」查别的文件里的定义是同一个办法: + +- 怎么算:索引里有同名(键等于名字原文)的项,才问语言扩展这个名字的定义(`vscode.executeDefinitionProvider`,同 ⌘ + 点击);定义处(文件、起始行列)正是索引里某一项的名字才算,同名的别处的类不算。索引里没有同名的项就是没有,不问语言扩展。同一份源码里同名的名字(几处 `raise Forbidden`)按第一处查、共用一个结果。 +- 什么时候查(宿主,`ruleLookups.ts` 的 `DocumentLookups`,不碰 VS Code):认领是同步的,排视图数据时 `lookup` 只交出眼下知道的(没查到是 undefined),记下这一遍问了哪些名字;发出预览后再查,同一个版本同一个名字只查一次;查到的(键、值)和已知的不一样就按新结果重排一遍、重发文档。文档改了先用上一版查到的(不闪),新版本再查一遍,没变不重发。索引里有同名的项、语言扩展却没答(还没起来)时已知的不动,照「中文名」的间隔(1、2、4、8、16 秒)再查。 +- 没有工作区时(单元测试、没传 `lookup`)总是 undefined;规则要能照常出卡片,只是少了查不到的那部分。 + ### 内置:SQLAlchemy 数据表(Python) `vscode/src/codeReader/rules/sqlalchemy.ts`,`libraries: ['sqlalchemy', 'flask_sqlalchemy']`。类块主体里有 `__tablename__`,或有赋值是 `mapped_column(...)` / `Column(...)`(`sa.Column`、`db.Column` 这类取最后一节)时认领,种类 `sqlalchemy.table`,标签「数据表」(暗 #e8a860 / 亮 #a3590d)。导入时起的别名按 `context.imports` 换回原名(`from sqlalchemy import Enum as SQLEnum`、`UUID as PgUUID`)。 @@ -999,6 +1009,80 @@ export interface CodeReaderApi { `__post_init__` 照常是卡片的子块。 +### 内置: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,和函数块一样默认收起子块。 + +- 认哪些函数:装饰器是 `@<路由对象>.<方法>(...)`,方法是 get / post / put / patch / delete / head / options / trace,路由对象是同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 赋值出来的(`APIRouter`、`FastAPI` 从 fastapi 导入,别名、`fastapi.APIRouter` 也算)。路由对象从别的文件导入的、`@router.get` 不带括号的、`@router.websocket` 不认。几个路由装饰器时取第一个。 +- 整个函数块换成卡片,不并子块:函数体照常是子块;编辑范围同别的卡片,是整条语句(连装饰器的整个函数)。 +- 挂到应用上时再叠的前缀(别的文件里的 `include_router(prefix=…)`、`settings.API_V1_PREFIX`)URL 不算。 + +属性表(有才显示): + +| 行 | 内容 | +| --- | --- | +| 名称 | 函数名(function 色,带下标) | +| 说明 | 函数的 docstring,同函数块;Markdown | +| 标签 | 路由对象的 `tags=` 加装饰器的 `tags=`,字符串不带引号(string 色,带下标),「、」隔开 | +| 异步 | 是 / 否(storage 色) | +| 装饰器 | 路由装饰器以外的装饰器(`@cache(60)`),有才显示 | +| 依赖 | 路由对象、装饰器的 `dependencies=[Depends(x)]` 写 `x`,参数上的 `Depends(x)` / `Security(x)` 写 `x(参数名)`,参数名是 `_` 的只写 `x`;`Depends()` 不带参数时 x 是参数的类型;照这个顺序,「、」隔开。x 是一个名字时按 function 色、带下标,别的写法交给翻译插件翻 | +| 请求 | 值是 `方法 URL`(hint),下面一张表格「项 \| 值」,一项一行,没有的项不列:URL、方法、路径参数、查询参数、请求头、Cookie、请求体 | +| 响应 | 值是各状态码用 ` / ` 连起来(hint),下面一张表格「状态码 \| 内容 \| 说明」,一个状态码一行,按状态码从小到大,不是三位数字的(`"5XX"`)排在最后 | + +请求表: + +- URL:路由对象的 `prefix` 加装饰器的路径;`{参数}` 按 variable 色,其余按 string 色,都带源码下标;写的不是字符串(`prefix=settings.X`)交给翻译插件翻。 +- 方法:大写,颜色用 Swagger UI 的配色(段的 `color`):GET #61affe、POST #49cc90、PUT #fca130、PATCH #50e3c2、DELETE #f93e3e(暗色);白底上照原色看不清,亮色主题取同色相深一些的 #1b6ac9、#18834f、#b35f00、#0f8a70、#cf2020;HEAD #b36bff / #9012fe、OPTIONS #4b92e0 / #0d5aa7(Swagger UI 的 #9012fe、#0d5aa7 在暗底上看不清,暗色调亮了);TRACE 没有配色,按 keyword 色。 +- 路径参数、查询参数、请求头、Cookie:`名称: 类型`,逗号隔开,名称 variable 色带下标,类型交给翻译插件翻(`Annotated[T, …]` 写 T);没标注的只写名称。 +- 请求体:只有一个时写它的类型(模型名),几个时(表单的几个字段)写 `名称: 类型` 一串。 + +参数放在哪儿,照 FastAPI 的规则,按下面的先后,第一条对上的算: + +1. 写了 `Depends(...)` / `Security(...)`(默认值 `= Depends(x)`,或 `Annotated[T, Depends(x)]`):依赖,不是请求带的。 +2. 名字在路径里(`{user_id}`、`{file_path:path}`,前缀和路径都算):路径参数。 +3. 写了 `Path` / `Query` / `Header` / `Cookie` / `Body` / `Form` / `File`(同上两种写法):照写的,`Header` 进「请求头」、`Cookie` 进「Cookie」,`Body` / `Form` / `File` 进「请求体」。 +4. 类型是别处写好的类型别名(`Int4Query = Annotated[int \| None, Query(...)]`、`FilterQuery = Annotated[FilterList, Depends(...)]`):经工作区索引查到它的标记(见下),照 1、3 放。 +5. `Request`、`Response`、`WebSocket`、`HTTPConnection`、`BackgroundTasks`、`SecurityScopes`:FastAPI 自己填的,不列。 +6. 上传文件(`UploadFile`、`list[UploadFile]`):请求体。 +7. 简单类型:查询参数。简单类型是 int、float、str、bool、bytes、complex、UUID、datetime、date、time、timedelta、Decimal、Any、EmailStr、HttpUrl、AnyUrl、SecretStr、`None`、`Literal[...]`、枚举类(别处定义的经索引查),和只包着它们的 `X | None`、`Optional` / `Union` / `list` / `set` / `tuple` / `Sequence` 这些;没标注类型的也是。 +8. 别的(Pydantic 模型、字典……):请求体。 + +`*args`、`**kwargs` 不算。 + +响应表(同一个状态码的几处写在一行里:内容「、」隔开、去重,说明「;」隔开、去重): + +- 成功那一行:装饰器的 `status_code`(整数,或 `status.HTTP_201_CREATED` 这种常量),没写是 200;内容是 `response_model`,没写就用返回类型标注(`response_model=None`、`-> None` 没有内容);说明是 `response_description`,没写是「成功」(comment 色)。 +- 路由对象和装饰器的 `responses={...}`:每一项一行,键是状态码(整数、`"404"`、常量;`"5XX"`、`"default"` 照写);值是字典时 `"model"` 是内容、`"description"` 是说明,值不是字典时整个是内容;`**x` 不列。 +- 函数体里直接抛的异常(嵌套的函数、类、lambda 里的不算,只写 `raise` 的不算,`raise X from e` 算,调别的函数间接抛的不算)按状态码归:`HTTPException`(从 fastapi 或 starlette 导入)的状态码是 `status_code=` 或第一个位置参数,消息是 `detail=` 或第二个位置参数;别的类的状态码是类上的 `status_code = N`,经工作区索引查(下面),消息是第一个位置参数(或 `message=`、`detail=`)。内容是异常类名(type 色,带下标,⌘ + 点击到类的定义),说明是消息:字符串照写(comment 色),别的写法交给翻译插件翻。查不到状态码的异常(没查完、不是 HTTP 异常)不列。 +- 有参数或请求体(路径 / 查询 / 请求头 / Cookie / 请求体里有一项)时,422 那一行加上「参数校验失败 \| FastAPI 自动返回」(hint 色,淡)。 +- 状态码的颜色:小于 400 的绿色(暗 #2ea043 / 亮 #1a7f37,段的 `color`),别的 string 色;不是三位数字的照写、string 色。 + +别的文件里的定义(索引 `fastapi.names`,见「规则认领时查别的文件里的定义」):看工作区里所有 `**/*.py`,预筛有 `status_code`、`Annotated` 或 `Enum` 才解析;从模块顶层认出:类上写了 `status_code = N`(带不带注解、`status.HTTP_403_FORBIDDEN` 这种常量也算)的类 → 值是状态码;父类是 enum 的枚举类型(Enum、IntEnum、StrEnum、Flag、IntFlag)的类 → `Enum`;`X = Annotated[T, Query(...)]` 这种类型别名 → 标记名(`Query`、`Path`、`Depends`……)。父类继承下来的 `status_code`(`class AccountFrozen(Forbidden)` 没自己写)不认。 + +样例(`fixtures/routes.py` 的 `update_user`,`Forbidden` 定义在 luffy 的 `core/exceptions/schemas.py`(`fixtures/appExceptions.py`),`status_code = 403`;测试里逐字核对): + +``` +名称: update_user +说明: 更新用户信息。… +标签: 用户管理 +异步: 是 +依赖: get_db(db)、get_current_user(current_user) +请求: PUT /users/{user_id} + URL | /users/{user_id} + 方法 | PUT + 路径参数 | user_id: Int4Path + 请求体 | UserUpdateRequest +响应: 200 / 403 / 422 + 200 | UserDetailResponse | 成功 + 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码 + 422 | 参数校验失败 | FastAPI 自动返回 +``` + +`skill_hub/routes.py` 的 `list_skills`:`查询参数 | query: 字符串 | 空, tag: 字符串 | 空, skip: 整数, limit: 整数`,依赖 `get_db(db)、get_current_user`(参数名是 `_`);`user/console/routes.py` 的 `create_user`:成功那一行 `201 | UserDetailResponse | 成功`,请求体 `UserCreateRequest`。 + +配色:名称 function、标签 string、依赖名 function、「(参数名)」hint、表格第一列(项)hint、URL 同上、参数名 variable、类型按翻译插件、异常类名 type、消息和说明 comment、自动带的 422 hint。 + ## 解析运行时 - 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。 @@ -1197,7 +1281,9 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | 属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。 -种类(种类图标的颜色 + 块底 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。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。 +带自己颜色的段(`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`。 ## 代码位置 @@ -1217,6 +1303,8 @@ 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 的规则放,异常类的状态码、类型别名、枚举经索引查 + ruleLookups.ts 库规则认领时查别的文件里的定义(RuleContext.lookup):先交出已知的、记下问了什么,发出预览后查(索引 + 语言扩展查定义),变了叫宿主重发(不碰 VS Code) rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名、注解里的 X[...]、库里的名字) rules/segments.ts 库规则共用的拼属性表文字的办法 rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点) @@ -1302,6 +1390,9 @@ 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 的 `{参数}` / 路径、方法的 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` 类型别名,普通类不认。 +- 规则查别的文件里的定义(`ruleLookups.test.ts`):第一遍当没查到、记下问了什么(同一索引同名的只记一处);查到后叫重排,交出定义处正是的那一项;同一版本不再查;索引里没有同名的不问语言扩展,定义处不是索引里的项算没有,没变不叫重排;新版本先用上一版的、再查、变了才叫重排;语言扩展没答时已知的不动、隔一会儿再查。 +- 带自己颜色的词(webview `SegmentText.test.tsx`):`cr-colored`,暗色、亮色写在 style 上。 - 异常(`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`)。 - 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;全选 / 取消全选只动这份文件里的值、别的文件的去掉状态和别的属性不动、取消全选藏掉有这个属性的块(整棵子树)、再全选全回来;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。 - 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。 diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 2f16dd0..05b7764 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -117,10 +117,14 @@ function ownName(block: CodeBlock): Node | undefined { } } -/** 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记。 */ +/** + * 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记, + * 规则查名字的定义时交出眼下知道的(RuleContext.lookup,见 ruleLookups.ts);没给时总是没查到。 + */ export interface RuleRun { rules: readonly LibraryRule[] report: (message: string) => void + lookup?: RuleContext['lookup'] } const NO_RULES: RuleRun = { rules: [], report: () => {} } @@ -142,6 +146,7 @@ export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, tran ...(translator && { displayLanguage: translator.displayLanguage }), translate: node => translator ? translator.expression(node, document) : [{ text: node.text, role: 'plain' }], doc: node => translator?.doc(node, document), + lookup: rules.lookup ?? (() => undefined), } /** startRow..endRow 这几行整行源码去共同缩进,和它的着色。 */ const sourceRows = (root: Node, startRow: number, endRow: number): Pick => { diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 8c2744e..5486a5f 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -274,8 +274,9 @@ export type TokenRole * 一段着色的文字。offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时, * 它在源码里的起始下标(同 SourceSpan 的下标),预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。 * reference:只有库规则给的段有,这段讲的东西由规则自己查定义(LibraryRule.resolve),不问语言扩展;这样的段必须带 offset。 + * color:这段自己的颜色(暗色、亮色主题各一个),盖过词性的颜色:库规则按库自己的配色上色(FastAPI 的 HTTP 方法用 Swagger UI 的颜色)。 */ -export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference } +export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor } /** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */ export type GroupStyle = 'iteration' @@ -453,7 +454,7 @@ export interface RuleFacts { absorbs: CodeBlock[] } -/** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */ +/** 库规则看一块时宿主给的:这份源码、它的导入,借当前翻译插件翻表达式、取说明的办法,和查名字的定义在工作区索引里是哪一项的办法。 */ export interface RuleContext { document: SourceDocument /** 这份源码里各处导入的 facts,照源码顺序:规则拿它认出库里名字的别名(`from sqlalchemy import Enum as SQLEnum`)。 */ @@ -464,6 +465,13 @@ export interface RuleContext { translate: (node: Node) => Inline[] /** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */ doc: (node: Node) => Markdown | undefined + /** + * 源码里 name 这个名字(例如 raise 后面的异常类名)指的定义在 index 里认出的那一项,规则从定义处读出的值在 IndexEntry.value 里: + * 定义由语言扩展查(同 ⌘ + 点击),落在 index 认出的某一项的名字上才算;同一份源码里同名的名字按一处查。 + * 认领是同步的,查要等工作区索引建好、语言扩展答话:宿主先当没查到(undefined)交给规则排,发出预览后再查, + * 查到的和上次不一样就按新结果重排一遍、重发;文档改了先用上次查到的,再重查。没有工作区时(单元测试)总是 undefined。 + */ + lookup: (index: WorkspaceIndex, name: Node) => IndexEntry | undefined } /** 规则自己查得到定义的一个名字:哪个规则(LibraryRule.id),和规则自己认的键(Celery 的任务名)。 */ @@ -484,8 +492,11 @@ export interface IndexedFile { exists: (path: string) => boolean } -/** 规则从一份文件里认出的一项:键,和定义处那个名字在源码里的范围。 */ -export interface IndexEntry { key: string, name: SourceRange } +/** + * 规则从一份文件里认出的一项:键,定义处那个名字在源码里的范围,和规则从定义处读出、认领别的文件时要用的值 + * (FastAPI 规则:异常类的 status_code;见 RuleContext.lookup)。 + */ +export interface IndexEntry { key: string, name: SourceRange, value?: string } /** * 规则在整个工作区里建的索引(键 → 定义处):规则只说看哪些文件、从一份源码里认出什么; diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 279def0..4c1a01d 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,4 +1,4 @@ -import type { DisplayName } from '../contract' +import type { DisplayName, RuleContext } from '../contract' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { DocumentMessage } from './messages' @@ -18,12 +18,16 @@ export interface DocumentRead { displayNames: DisplayName[] } -/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */ +/** + * 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下; + * 规则查名字的定义时问 lookup(RuleContext.lookup)。 + */ export async function documentMessage( document: DocumentSnapshot, registry: Pick, runtime: Pick, report: (message: string) => void, + lookup?: RuleContext['lookup'], ): Promise { const { languageId, version } = document const source = document.getText() @@ -42,7 +46,7 @@ export async function documentMessage( if (!adapter) return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] } try { - const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report }) + const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report, lookup }) return { message: { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }, displayNames } } catch (err) { diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 26c6fdb..1513195 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,11 +1,13 @@ 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 } from '../contract' -import type { IndexedName, NameSources } from '../nameResolution' +import type { RuleLocation, RuleReference, WorkspaceIndex } from '../contract' +import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution' import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' +import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' +import type { IndexedEntry } from '../workspaceIndex' import type { BlockFilter } from './blockFilter' import type { Definition, FoundLocation } from './definitions' import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages' @@ -16,6 +18,7 @@ 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 { DefinitionSources } from './definitionSources' @@ -50,6 +53,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly qa: CodeQa, /** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */ readonly indexedNames: (languageId: string, name: string) => Promise, + /** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */ + readonly indexed: (index: WorkspaceIndex, key: string) => Promise, ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -124,6 +129,8 @@ class Preview { private sent: { blocks: BlockView[], source: string } | undefined /** 这份文档各版本显示成中文名的名字 */ private readonly names: DocumentNames + /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ + private readonly lookups: DocumentLookups constructor( private readonly document: TextDocument, @@ -146,6 +153,7 @@ class Preview { if (this.ready && version === document.version) this.post({ type: 'displayNames', version, names }) }) + this.lookups = new DocumentLookups(() => this.schedulePush()) this.disposables = [ panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { @@ -214,9 +222,10 @@ class Preview { const request = ++this.latest const { languageId, version } = this.document const source = this.document.getText() + const session = this.lookups.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) if (request !== this.latest || !this.ready) return this.definitions.remember(message.version, message.blocks) @@ -225,8 +234,17 @@ class Preview { this.pendingReveal = undefined this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) // 文档在解析期间又改了的,等下一次发文档再查 - if (version === this.document.version) + 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()) + } + } + + /** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */ + private async definitionStarts(offset: number): Promise { + const { document } = this + return (await languageDefinitions(document.uri, document.positionAt(offset))) + .map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character })) } /** 找中文名落在哪些名字上时问的:这份文档的语言扩展、工作区里的中文名索引。 */ @@ -235,12 +253,19 @@ class Preview { return { uri: document.uri.toString(), occurrences: async offset => (await occurrencesOf(document, offset)).map(({ start, end }) => ({ start, end })), - definitions: async offset => (await languageDefinitions(document.uri, document.positionAt(offset))) - .map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character })), + definitions: offset => this.definitionStarts(offset), indexed: name => this.owner.indexedNames(document.languageId, name), } } + /** 查库规则问的名字时问的:规则的工作区索引、这份文档的语言扩展。 */ + private lookupSources(): LookupSources { + return { + find: (index, key) => this.owner.indexed(index, key), + definitions: offset => this.definitionStarts(offset), + } + } + /** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */ moveOccurrence(step: 1 | -1): void { if (this.ready) @@ -258,6 +283,7 @@ class Preview { dispose(): void { clearTimeout(this.timer) this.names.dispose() + this.lookups.dispose() for (const disposable of this.disposables) disposable.dispose() } diff --git a/vscode/src/codeReader/fixtures/consoleRoutes.py b/vscode/src/codeReader/fixtures/consoleRoutes.py new file mode 100644 index 0000000..51c5052 --- /dev/null +++ b/vscode/src/codeReader/fixtures/consoleRoutes.py @@ -0,0 +1,194 @@ +"""用户管理 console (admin) 路由。 + +提供给管理后台用的用户 CRUD、批量开号、批量删除与启用状态切换接口, +路径前缀 ``/admin/users``, 全部需要管理员权限。 +""" + +from __future__ import annotations + +import uuid +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, Query +from langgraph.checkpoint.base import BaseCheckpointSaver +from luffy_billing_sdk.client import BillingClient +from sqlalchemy.ext.asyncio import AsyncSession + +from luffy_agent.acquisition.service import acquisition_service +from luffy_agent.agent.runtime.deps import ( + get_agent_pool, + get_checkpointer, + get_session_runner, +) +from luffy_agent.agent.runtime.pool import AgentPool +from luffy_agent.agent.runtime.runner import SessionRunner +from luffy_agent.campaign.triggers import emit_user_registered +from luffy_agent.core.db import get_db +from luffy_agent.core.db.schemas import BulkDeleteFailedItem +from luffy_agent.core.exceptions import Forbidden +from luffy_agent.core.filter.deps import FilterQuery +from luffy_agent.core.params import Int4Path +from luffy_agent.deployment.deps import get_billing_client +from luffy_agent.user.auth.deps import ( + ConsolePrincipal, + declares_permission, + require_console_user, + require_permission, +) +from luffy_agent.user.console.rbac.service import ( + bind_console_audit_request, + console_rbac_service, +) +from luffy_agent.user.console.schemas import ( + ImpersonateRequest, + ImpersonateResponse, + UserBatchCreateItem, + UserBatchCreateRequest, + UserBatchCreateResponse, + UserBulkDeleteRequest, + UserBulkDeleteResponse, + UserCreateRequest, + UserListItem, + UserListResponse, + UserPhoneBackfillConflictItem, + UserPhoneBackfillFilledItem, + UserPhoneBackfillResponse, + UserToggleActiveResponse, +) +from luffy_agent.user.models import User, UserRole +from luffy_agent.user.schemas import UserDetailResponse, UserUpdateRequest +from luffy_agent.user.services import BatchUserResult, user_service + +router = APIRouter(tags=["用户管理-运维"], prefix="/admin/users") + + +def _assert_permission(principal: ConsolePrincipal, key: str) -> None: + if not principal.is_root and key not in principal.permissions: + raise Forbidden("无操作权限") + + +def _reject_admin_role(role: UserRole) -> None: + if role == UserRole.ADMIN: + raise Forbidden("不能设置管理员角色") + + +def _to_detail(user: User) -> UserDetailResponse: + return UserDetailResponse( + 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, + ) + + +@declares_permission("users:activate") +async def require_toggle_active_permission( + user_id: int, + db: AsyncSession = Depends(get_db), + principal: ConsolePrincipal = Depends(require_console_user), +) -> ConsolePrincipal: + user = await user_service.get_user(db, user_id=user_id) + key = "users:deactivate" if user.is_active else "users:activate" + _assert_permission(principal, key) + return principal + + +require_users_view = require_permission("users:view") +require_users_create = require_permission("users:create") +require_users_batch_create = require_permission("users:batch_create") +require_users_update = require_permission("users:update") +require_users_delete = require_permission("users:delete") +require_users_bulk_delete = require_permission("users:bulk_delete") +require_users_impersonate = require_permission("users:impersonate") + + +@router.get("", response_model=UserListResponse) +async def list_users( + filters: FilterQuery, + page: Annotated[int, Query(ge=1, description="页码")] = 1, + page_size: Annotated[int, Query(ge=1, le=100, description="每页数量")] = 20, + acquisition_channel_id: Annotated[ + int | None, Query(description="按来源渠道 ID 精确筛选") + ] = None, + referred: Annotated[ + Literal["yes", "no"] | None, + Query(description="按是否曾被邀请筛选: yes=曾受邀 / no=从未受邀"), + ] = None, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_users_view), +) -> UserListResponse: + """获取用户列表(支持筛选)。 + + 需要管理员权限。 + """ + skip = (page - 1) * page_size + rows, total = await user_service.get_admin_users( + db, + skip=skip, + limit=page_size, + filters=filters.filters, + acquisition_channel_id=acquisition_channel_id, + referred=None if referred is None else referred == "yes", + ) + + return UserListResponse( + items=[ + UserListItem( + id=row.user.id, + username=row.user.username, + display_name=row.user.display_name, + role=row.user.role.value, + phone=row.user.phone, + is_active=row.user.is_active, + acquisition_channel_id=row.channel.id, + acquisition_channel_code=row.channel.code, + acquisition_channel_name=row.channel.name, + referred=row.referred, + created_at=row.user.created_at, + updated_at=row.user.updated_at, + ) + for row in rows + ], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("", response_model=UserDetailResponse, status_code=201) +async def create_user( + body: UserCreateRequest, + db: AsyncSession = Depends(get_db), + _: object = Depends(require_users_create), +) -> UserDetailResponse: + """创建新用户。 + + 需要管理员权限。密码会自动哈希。 + """ + _reject_admin_role(body.role) + admin_channel_id = await acquisition_service.resolve_registration_channel_id( + db, + created_via="password", + channel_code=None, + admin_created=True, + ) + user = await user_service.create_user( + db, + username=body.username, + password=body.password, + display_name=body.display_name, + acquisition_channel_id=admin_channel_id, + role=body.role, + is_active=body.is_active, + ) + emit_user_registered(user.id) + + return _to_detail(user) + diff --git a/vscode/src/codeReader/fixtures/coreParams.py b/vscode/src/codeReader/fixtures/coreParams.py new file mode 100644 index 0000000..8e53e13 --- /dev/null +++ b/vscode/src/codeReader/fixtures/coreParams.py @@ -0,0 +1,20 @@ +"""路由入参的共用类型: 数据库整型主键必须落在 int4 范围内, 越界在入口就 422. + +Python 的 int 不会溢出, 11 位手机号误当 user_id 传进来会一路走到 asyncpg, 绑到 +INTEGER 列时才抛 DataError, 请求变成 500 且污染当前事务. 主键类路径 / 查询参数 +一律用这里的类型, 上界与 :data:`luffy_agent.core.db.PG_INT4_MAX` 同源. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Path, Query + +from luffy_agent.core.db import PG_INT4_MAX + +Int4Path = Annotated[int, Path(ge=1, le=PG_INT4_MAX)] +"""路径里的整型主键 (``/users/{user_id}`` 一类).""" + +Int4Query = Annotated[int | None, Query(ge=1, le=PG_INT4_MAX)] +"""查询串里可选的整型主键过滤 (``?user_id=``).""" diff --git a/vscode/src/codeReader/fixtures/skillHubRoutes.py b/vscode/src/codeReader/fixtures/skillHubRoutes.py new file mode 100644 index 0000000..01ee883 --- /dev/null +++ b/vscode/src/codeReader/fixtures/skillHubRoutes.py @@ -0,0 +1,111 @@ +"""技能商店用户端路由. + +注: prefix 不带 /api/v1, create_app include 时叠 settings.API_V1_PREFIX. +""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from luffy_agent.core.db import get_db, get_redis +from luffy_agent.sandbox.deps import get_sandbox_registry +from luffy_agent.skill_hub import service +from luffy_agent.skill_hub.models import HubSkillStatus +from luffy_agent.skill_hub.rate_limit import check_mutation_rate +from luffy_agent.skill_hub.repositories import hub_skill_repository +from luffy_agent.skill_hub.schemas import ( + AppSkillItem, + HubSkillDetail, + HubSkillListItem, +) +from luffy_agent.user.auth.deps import get_current_user +from luffy_agent.user.models import User + +skill_hub_router = APIRouter(prefix="/skill-hub", tags=["skill-hub"]) + + +@skill_hub_router.get("/skills", response_model=list[HubSkillListItem]) +async def list_skills( + db: Annotated[AsyncSession, Depends(get_db)], + _: Annotated[User, Depends(get_current_user)], + query: Annotated[str | None, Query(max_length=80)] = None, + tag: Annotated[str | None, Query(max_length=40)] = None, + skip: Annotated[int, Query(ge=0)] = 0, + limit: Annotated[int, Query(ge=1, le=100)] = 50, +) -> list[HubSkillListItem]: + rows = await hub_skill_repository.list_published( + db, q=query, tag=tag, skip=skip, limit=limit + ) + return [HubSkillListItem.model_validate(row) for row in rows] + + +@skill_hub_router.get("/skills/{slug}", response_model=HubSkillDetail) +async def get_skill( + slug: str, + db: Annotated[AsyncSession, Depends(get_db)], + _: Annotated[User, Depends(get_current_user)], +) -> HubSkillDetail: + skill = await hub_skill_repository.get_by_slug(db, slug) + if skill is None or skill.status != HubSkillStatus.PUBLISHED: + raise HTTPException(status_code=404, detail=f"技能不存在: {slug}") + return HubSkillDetail.model_validate(skill) + + +@skill_hub_router.get("/apps/{app_id}/skills", response_model=list[AppSkillItem]) +async def list_installed( + app_id: str, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> list[AppSkillItem]: + rows = await service.list_app_skills(db, user_id=current_user.id, app_id=app_id) + return [ + AppSkillItem( + slug=skill.slug, + name=skill.name, + description=skill.description, + version=record.version, + installed_at=record.installed_at, + ) + for record, skill in rows + ] + + +@skill_hub_router.post("/apps/{app_id}/skills/{slug}", status_code=204) +async def install( + app_id: str, + slug: str, + request: Request, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> None: + await check_mutation_rate(get_redis(), user_id=current_user.id) + registry = get_sandbox_registry(request) + await service.install_skill( + db, + user_id=current_user.id, + app_id=app_id, + slug=slug, + sandbox_registry=registry, + ) + + +@skill_hub_router.delete("/apps/{app_id}/skills/{slug}", status_code=204) +async def uninstall( + app_id: str, + slug: str, + request: Request, + db: Annotated[AsyncSession, Depends(get_db)], + current_user: Annotated[User, Depends(get_current_user)], +) -> None: + await check_mutation_rate(get_redis(), user_id=current_user.id) + registry = get_sandbox_registry(request) + await service.uninstall_skill( + db, + user_id=current_user.id, + app_id=app_id, + slug=slug, + sandbox_registry=registry, + ) diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 052f58a..57b3018 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -21,6 +21,7 @@ import { ReviewMode } from './review/reviewMode' import { celeryRule } from './rules/celery' import { dataclassRule } from './rules/dataclass' import { enumRule } from './rules/enum' +import { fastapiRule } from './rules/fastapi' import { sqlalchemyRule } from './rules/sqlalchemy' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { pythonZhTranslator } from './translators/pythonZh' @@ -73,7 +74,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { const adapter = registry.languageFor(languageId) return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : [] } - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn) + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key)) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true }) @@ -116,6 +117,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLibraryRule(celeryRule), api.registerLibraryRule(enumRule), api.registerLibraryRule(dataclassRule), + api.registerLibraryRule(fastapiRule), ) return api } diff --git a/vscode/src/codeReader/ruleLookups.test.ts b/vscode/src/codeReader/ruleLookups.test.ts new file mode 100644 index 0000000..94b6115 --- /dev/null +++ b/vscode/src/codeReader/ruleLookups.test.ts @@ -0,0 +1,121 @@ +import type { IndexEntry, Node, WorkspaceIndex } from './contract' +import type { DefinitionStart } from './nameResolution' +import type { LookupSources } from './ruleLookups' +import type { IndexedEntry } from './workspaceIndex' +import { describe, expect, it, vi } from 'vitest' +import { DocumentLookups } from './ruleLookups' + +const INDEX: WorkspaceIndex = { id: 'orm.names', languageId: 'python', include: '**/*.py', mentions: () => true, entries: () => [] } +const OTHER: WorkspaceIndex = { ...INDEX, id: 'orm.others' } + +/** 源码里一个名字:只用到原文和下标。 */ +function nameAt(text: string, startIndex: number): Node { + return { text, startIndex } as Node +} + +function entry(key: string, value: string, uri: string, line: number): IndexedEntry { + const found: IndexEntry = { key, name: { startIndex: 0, endIndex: key.length }, value } + return { entry: found, location: { uri, start: { line, character: 6 }, end: { line, character: 6 + key.length } } } +} + +/** 工作区索引里的项(按键)和各下标处名字的定义(语言扩展答的);记下问了几次定义。 */ +function sources(indexed: Record, definitions: Record): LookupSources & { asked: number[] } { + const asked: number[] = [] + return { + asked, + find: async (_index, key) => indexed[key] ?? [], + definitions: async (offset) => { + asked.push(offset) + return definitions[offset] ?? [] + }, + } +} + +describe('库规则查名字的定义(DocumentLookups)', () => { + const FORBIDDEN = entry('Forbidden', '403', 'file:///w/errors.py', 10) + const ELSEWHERE = entry('Forbidden', '418', 'file:///w/other.py', 3) + const AT_ERRORS = [{ uri: 'file:///w/errors.py', line: 10, character: 6 }] + + it('第一遍当没查到,记下问了什么;查到后叫宿主重排,再排时交出定义处正是的那一项', async () => { + let changed = 0 + const lookups = new DocumentLookups(() => changed++, []) + const first = lookups.session() + expect(first.lookup(INDEX, nameAt('Forbidden', 40))).toBeUndefined() + expect(first.lookup(INDEX, nameAt('Forbidden', 90))).toBeUndefined() + expect(first.lookup(OTHER, nameAt('Forbidden', 40))).toBeUndefined() + expect(first.requests().map(({ index, key, offset }) => `${index.id}:${key}@${offset}`)).toEqual(['orm.names:Forbidden@40', 'orm.others:Forbidden@40']) + + const from = sources({ Forbidden: [ELSEWHERE, FORBIDDEN] }, { 40: AT_ERRORS }) + lookups.resolve(1, first.requests(), from) + await vi.waitFor(() => expect(changed).toBe(1)) + expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 12))).toEqual(FORBIDDEN.entry) + + // 同一版本再发一遍:问过的不再查 + lookups.resolve(1, first.requests(), from) + await Promise.resolve() + expect(from.asked).toEqual([40, 40]) + }) + + it('索引里没有同名的项不问语言扩展,是没有这个定义;定义处不是索引里的项也算没有;结果没变不叫重排', async () => { + let changed = 0 + const lookups = new DocumentLookups(() => changed++, []) + const session = lookups.session() + session.lookup(INDEX, nameAt('ValueError', 5)) + session.lookup(INDEX, nameAt('Forbidden', 40)) + const from = sources({ Forbidden: [ELSEWHERE] }, { 40: AT_ERRORS }) + lookups.resolve(1, session.requests(), from) + await vi.waitFor(() => expect(from.asked).toEqual([40])) + await Promise.resolve() + expect(changed).toBe(0) + expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 40))).toBeUndefined() + }) + + it('文档改了先用上一版查到的,新版本再查一遍;查到的变了才叫重排', async () => { + let changed = 0 + const lookups = new DocumentLookups(() => changed++, []) + const first = lookups.session() + first.lookup(INDEX, nameAt('Forbidden', 40)) + const indexed: Record = { Forbidden: [FORBIDDEN] } + const from = sources(indexed, { 40: AT_ERRORS, 41: AT_ERRORS }) + lookups.resolve(1, first.requests(), from) + await vi.waitFor(() => expect(changed).toBe(1)) + + const second = lookups.session() + expect(second.lookup(INDEX, nameAt('Forbidden', 41))?.value).toBe('403') + lookups.resolve(2, second.requests(), from) + await vi.waitFor(() => expect(from.asked).toEqual([40, 41])) + await Promise.resolve() + expect(changed).toBe(1) + + indexed.Forbidden = [entry('Forbidden', '401', 'file:///w/errors.py', 10)] + const third = lookups.session() + third.lookup(INDEX, nameAt('Forbidden', 41)) + lookups.resolve(3, third.requests(), from) + await vi.waitFor(() => expect(changed).toBe(2)) + expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 41))?.value).toBe('401') + }) + + it('语言扩展还没起来(问了没答):已知的不动,隔一会儿再查,查到了再叫重排', async () => { + let changed = 0 + const pending: (() => void)[] = [] + const scheduled: number[] = [] + const lookups = new DocumentLookups(() => changed++, [5, 10], (task, ms) => { + scheduled.push(ms) + pending.push(task) + return setTimeout(() => {}, 0) + }) + const session = lookups.session() + session.lookup(INDEX, nameAt('Forbidden', 40)) + const definitions: Record = {} + const from = sources({ Forbidden: [FORBIDDEN] }, definitions) + lookups.resolve(1, session.requests(), from) + await vi.waitFor(() => expect(scheduled).toEqual([5])) + expect(changed).toBe(0) + + definitions[40] = AT_ERRORS + pending.shift()?.() + await vi.waitFor(() => expect(changed).toBe(1)) + expect(scheduled).toEqual([5]) + expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 40))?.value).toBe('403') + }) +}) diff --git a/vscode/src/codeReader/ruleLookups.ts b/vscode/src/codeReader/ruleLookups.ts new file mode 100644 index 0000000..6fa4887 --- /dev/null +++ b/vscode/src/codeReader/ruleLookups.ts @@ -0,0 +1,123 @@ +import type { IndexEntry, RuleContext, WorkspaceIndex } from './contract' +import type { DefinitionStart } from './nameResolution' +import type { IndexedEntry } from './workspaceIndex' +import { RETRY_DELAYS } from './nameResolution' + +/** + * 库规则认领时查的名字(RuleContext.lookup):名字的定义在规则的工作区索引里是哪一项。 + * 同「中文名」查别的文件里的定义:索引里有同名的项才问语言扩展定义在哪,定义处正是索引里那一项才算。这里不碰 VS Code。 + */ + +/** 规则问的一个名字:哪个索引、名字原文,和名字在源码里的下标(问语言扩展定义用)。 */ +export interface LookupRequest { index: WorkspaceIndex, key: string, offset: number } + +/** 查规则问的名字要问的:工作区索引里认出的各项,这份文档里 offset 处名字的定义(语言扩展)。 */ +export interface LookupSources { + find: (index: WorkspaceIndex, key: string) => Promise + definitions: (offset: number) => Promise +} + +/** 排一遍视图数据用的:交给规则的 lookup,和这一遍规则问了哪些名字。 */ +export interface LookupSession { + lookup: RuleContext['lookup'] + requests: () => LookupRequest[] +} + +function idOf(index: WorkspaceIndex, key: string): string { + return `${index.id}\n${key}` +} + +/** 查到的项对规则来说一样不一样:规则只看键和值。 */ +function same(a: IndexEntry | null | undefined, b: IndexEntry | null | undefined): boolean { + return a?.key === b?.key && a?.value === b?.value +} + +/** + * 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查。 + * 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查, + * 每个版本同一个名字查一次(索引里有同名的项、语言扩展却没答时照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed, + * 宿主按新结果重排、重发。 + */ +export class DocumentLookups { + /** 查到的:没有这个定义是 null */ + private readonly known = new Map() + private version: number | undefined + /** 眼下这个版本查过(或正在查)的名字 */ + private readonly checked = new Set() + private timer: ReturnType | undefined + + constructor( + private readonly changed: () => void, + private readonly delays: readonly number[] = RETRY_DELAYS, + private readonly schedule: (run: () => void, ms: number) => ReturnType = setTimeout, + ) {} + + session(): LookupSession { + const requests = new Map() + 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()], + } + } + + /** 查 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) + } + + 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 } + } +} diff --git a/vscode/src/codeReader/rules/contractOnly.test.ts b/vscode/src/codeReader/rules/contractOnly.test.ts index 8deb996..40e5657 100644 --- a/vscode/src/codeReader/rules/contractOnly.test.ts +++ b/vscode/src/codeReader/rules/contractOnly.test.ts @@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => { const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts')) it('目录里有规则', () => { - expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts', 'dataclass.ts'])) + expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts', 'dataclass.ts', 'fastapi.ts'])) }) it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => { diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts new file mode 100644 index 0000000..f8446a5 --- /dev/null +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -0,0 +1,357 @@ +import type { BlockView } from '../blockView' +import type { Inline, PropertyRow, RuleContext, Segment } from '../contract' +import type { DefinitionStart } from '../nameResolution' +import { describe, expect, it, vi } from 'vitest' +import { toBlockViews } from '../blockView' +import { createPythonAdapter } from '../languages/python' +import { DocumentLookups } from '../ruleLookups' +import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport' +import { pythonZhTranslator } from '../translators/pythonZh' +import { WorkspaceIndexes } from '../workspaceIndex' +import { fastapiRule } from './fastapi' + +const py = createPythonAdapter(WASM_DIR) +const ROUTES = readFixture('routes.py') +const SKILL_HUB = readFixture('skillHubRoutes.py') +const CONSOLE = readFixture('consoleRoutes.py') + +/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */ +function known(values: Record): RuleContext['lookup'] { + return (_index, node) => values[node.text] === undefined ? undefined : { key: node.text, name: { startIndex: 0, endIndex: 0 }, value: values[node.text] } +} + +async function views(source: string, lookup?: RuleContext['lookup'], reports: string[] = []): Promise { + return withBlocks(py, 'python', source, blocks => + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [fastapiRule], report: message => reports.push(message), lookup })) +} + +function flatten(list: readonly BlockView[]): BlockView[] { + return list.flatMap(view => [view, ...flatten(view.children)]) +} + +function properties(view: BlockView | undefined): PropertyRow[] { + if (!view || !('properties' in view)) + throw new Error(`块 ${view?.id} 不是属性表`) + return view.properties +} + +/** 函数名是 fn 的接口卡片。 */ +async function route(source: string, fn: string, lookup?: RuleContext['lookup']): Promise { + const found = flatten(await views(source, lookup)).find(view => view.kind === 'fastapi.route' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === fn) + if (!found) + throw new Error(`没有 ${fn} 的接口卡片`) + return found +} + +/** 属性表某一行(连子行格子)里所有的词。 */ +function segmentsOf(row: PropertyRow | undefined): Segment[] { + const pieces: Inline[] = [ + ...(!row || 'markdown' in row.value ? [] : row.value), + ...(row?.items ?? []).flatMap(item => item.cells.flat()), + ] + return flatSegments(pieces) +} + +const HEADER = [ + 'from typing import Annotated', + '', + 'from fastapi import APIRouter, Body, Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Path, Query, Request, Security, UploadFile, status', + '', + 'router = APIRouter(prefix="/items", tags=["物品"], dependencies=[Depends(verify_token)], responses={401: {"description": "没登录"}})', + 'app = FastAPI()', + '', +].join('\n') + +describe('fastapi 库规则:接口卡片', () => { + it('luffy 的 update_user:名称 / 说明 / 标签 / 异步 / 依赖,请求表、响应表;Forbidden 的状态码查到是 403,四条消息写在一起', async () => { + const card = await route(ROUTES, 'update_user', known({ Forbidden: '403' })) + expect(card.badge).toEqual({ label: '接口', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' }) + expect(propertyLines(properties(card))).toEqual([ + '名称: update_user', + '说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。', + '标签: 用户管理', + '异步: 是', + '依赖: get_db(db)、get_current_user(current_user)', + '请求: PUT /users/{user_id}', + ' URL | /users/{user_id}', + ' 方法 | PUT', + ' 路径参数 | user_id: Int4Path', + ' 请求体 | UserUpdateRequest', + '响应: 200 / 403 / 422', + ' 200 | UserDetailResponse | 成功', + ' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ]) + expect(properties(card).map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, undefined, ['项', '值'], ['状态码', '内容', '说明']]) + expect(card.attributes).toMatchObject({ kind: 'fastapi.route', access: 'public', async: 'true' }) + expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('update_user') + // 函数体照常是子块,卡片的编辑范围是整个函数(连装饰器) + expect(card.children.length).toBeGreaterThan(0) + expect(card.code.split('\n')[0]).toBe('@router.put("/{user_id}", response_model=UserDetailResponse)') + expect(card.code.split('\n').at(-1)).toBe(' )') + }) + + it('着色:{参数} 按变量、别的路径按字符串,带源码下标;方法用 Swagger UI 的颜色;成功的状态码绿色,FastAPI 自动带的 422 淡色', async () => { + const rows = properties(await route(ROUTES, 'update_user', known({ Forbidden: '403' }))) + const request = rows.find(row => row.label === '请求') + const [url, method] = request?.items ?? [] + expect(flatSegments(url.cells[1]).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{user_id}', 'variable']]) + for (const piece of flatSegments(url.cells[1])) + expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text) + expect(method.cells[1]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }]) + const [ok, forbidden, validation] = rows.find(row => row.label === '响应')?.items ?? [] + expect(ok.cells[0]).toEqual([{ text: '200', role: 'number', color: { dark: '#2ea043', light: '#1a7f37' } }]) + const model = ROUTES.indexOf('response_model=UserDetailResponse', ROUTES.indexOf('@router.put')) + 'response_model='.length + expect(ok.cells[1]).toEqual([{ text: 'UserDetailResponse', role: 'type', offset: model }]) + expect(forbidden.cells[0]).toEqual([{ text: '403', role: 'string' }]) + expect(flatSegments(forbidden.cells[1])).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }]) + expect(flatSegments([...validation.cells[1], ...validation.cells[2]]).map(piece => piece.role)).toEqual(['hint', 'hint']) + // 名字都带下标、指回源码原文 + for (const piece of rows.flatMap(segmentsOf).filter(piece => piece.offset !== undefined && piece.role !== 'string' && piece.role !== 'variable')) + expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text) + }) + + it('没查到异常类的状态码(索引还没建好、语言扩展没答)时不列它', async () => { + const lines = propertyLines(properties(await route(ROUTES, 'update_user'))) + expect(lines.slice(-3)).toEqual(['响应: 200 / 422', ' 200 | UserDetailResponse | 成功', ' 422 | 参数校验失败 | FastAPI 自动返回']) + }) + + it('luffy 的 list_skills:Annotated 里的 Query 是查询参数(类型取 Annotated 的第一项),Depends 是依赖,参数名是 _ 的只写依赖', async () => { + expect(propertyLines(properties(await route(SKILL_HUB, 'list_skills')))).toEqual([ + '名称: list_skills', + '标签: skill-hub', + '异步: 是', + '依赖: get_db(db)、get_current_user', + '请求: GET /skill-hub/skills', + ' URL | /skill-hub/skills', + ' 方法 | GET', + ' 查询参数 | query: 字符串 | 空, tag: 字符串 | 空, skip: 整数, limit: 整数', + '响应: 200 / 422', + ' 200 | list[HubSkillListItem] | 成功', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ]) + }) + + it('luffy 的 skill_hub:HTTPException 按 status_code 归,消息是 detail;Request 不算参数;-> None 没有内容', async () => { + expect(propertyLines(properties(await route(SKILL_HUB, 'get_skill'))).slice(-3)).toEqual([ + ' 200 | HubSkillDetail | 成功', + ' 404 | HTTPException | f"技能不存在: {slug}"', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ]) + expect(propertyLines(properties(await route(SKILL_HUB, 'install'))).slice(4)).toEqual([ + '请求: POST /skill-hub/apps/{app_id}/skills/{slug}', + ' URL | /skill-hub/apps/{app_id}/skills/{slug}', + ' 方法 | POST', + ' 路径参数 | app_id: 字符串, slug: 字符串', + '响应: 204 / 422', + ' 204 | | 成功', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ]) + }) + + it('luffy 的 create_user:status_code=201 是成功那一行;Pydantic 模型是请求体;函数里调别的函数抛的异常不算', async () => { + expect(propertyLines(properties(await route(CONSOLE, 'create_user', known({ Forbidden: '403' }))))).toEqual([ + '名称: create_user', + '说明: 创建新用户。\n\n需要管理员权限。密码会自动哈希。', + '标签: 用户管理-运维', + '异步: 是', + '依赖: get_db(db)、require_users_create', + '请求: POST /admin/users', + ' URL | /admin/users', + ' 方法 | POST', + ' 请求体 | UserCreateRequest', + '响应: 201 / 422', + ' 201 | UserDetailResponse | 成功', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ]) + }) + + it('luffy 的 list_users:别处写好的类型别名按索引查到的标记放(FilterQuery 是 Depends → 依赖),Literal 是简单类型', async () => { + const lines = propertyLines(properties(await route(CONSOLE, 'list_users', known({ FilterQuery: 'Depends' })))) + expect(lines).toContain('依赖: FilterQuery(filters)、get_db(db)、require_users_view') + expect(lines).toContain(' 查询参数 | page: 整数, page_size: 整数, acquisition_channel_id: 整数 | 空, referred: Literal["yes", "no"] | 空') + expect(lines.some(line => line.includes('请求体'))).toBe(false) + }) + + it('参数放在哪儿照 FastAPI 的规则:路径里的名字、Query / Header / Cookie / Body / Form / File、上传文件、简单类型、枚举和类型别名(查到的)、Pydantic 模型;Request 不算', async () => { + const source = `${HEADER} +@router.post("/{item_id}/files/{path:path}") +async def upload( + item_id: int, + path: str, + request: Request, + extra: Extra, + q: str | None = None, + ids: list[int] = Query(default=[]), + color: Color = Color.RED, + owner: Int4Query = None, + token: Annotated[str, Header()] = "", + session: str = Cookie(None), + note: str = Form(...), + upload: UploadFile = File(...), + item: Item = Body(...), + user: User = Security(get_user, scopes=["me"]), + helper: Helper = Depends(), +): + pass +` + const lines = propertyLines(properties(await route(source, 'upload', known({ Color: 'Enum', Int4Query: 'Query' })))) + expect(lines).toEqual(expect.arrayContaining([ + '依赖: verify_token、get_user(user)、Helper(helper)', + ' 路径参数 | item_id: 整数, path: 字符串', + ' 查询参数 | q: 字符串 | 空, ids: 列表[整数], color: Color, owner: Int4Query', + ' 请求头 | token: 字符串', + ' Cookie | session: 字符串', + ' 请求体 | extra: Extra, note: 字符串, upload: UploadFile, item: Item', + ])) + expect(lines.indexOf(' 请求头 | token: 字符串')).toBeLessThan(lines.indexOf(' Cookie | session: 字符串')) + }) + + it('标签、依赖、响应叠上路由对象的;responses= 各占一行;status 常量、响应描述;嵌套函数里、光写 raise 的不算,raise ... from 算', async () => { + const source = `${HEADER} +@router.delete( + "/{item_id}", + status_code=status.HTTP_202_ACCEPTED, + response_model=None, + response_description="已受理", + tags=["管理"], + dependencies=[Depends(audit)], + responses={404: {"model": ErrorOut, "description": "没找到"}, "5XX": {"description": "服务出错"}}, +) +def remove(item_id: int): + """删掉一个物品。""" + try: + drop(item_id) + except KeyError as exc: + raise HTTPException(status.HTTP_404_NOT_FOUND, "物品不存在") from exc + except RuntimeError: + raise + if item_id < 0: + raise HTTPException(status_code=409, detail="冲突") + + def inner(): + raise HTTPException(status_code=500, detail="不算") +` + expect(propertyLines(properties(await route(source, 'remove')))).toEqual([ + '名称: remove', + '说明: 删掉一个物品。', + '标签: 物品、管理', + '异步: 否', + '依赖: verify_token、audit', + '请求: DELETE /items/{item_id}', + ' URL | /items/{item_id}', + ' 方法 | DELETE', + ' 路径参数 | item_id: 整数', + '响应: 202 / 401 / 404 / 409 / 422 / 5XX', + ' 202 | | 已受理', + ' 401 | | 没登录', + ' 404 | ErrorOut、HTTPException | 没找到;物品不存在', + ' 409 | HTTPException | 冲突', + ' 422 | 参数校验失败 | FastAPI 自动返回', + ' 5XX | | 服务出错', + ]) + }) + + it('直接挂在 FastAPI 应用上的接口:没有前缀;没有参数时没有 422;别的装饰器照写', async () => { + const source = `${HEADER} +@app.get("/health") +@cache(60) +async def health() -> dict: + return {"ok": True} +` + expect(propertyLines(properties(await route(source, 'health')))).toEqual([ + '名称: health', + '异步: 是', + '装饰器: @cache(60)', + '请求: GET /health', + ' URL | /health', + ' 方法 | GET', + '响应: 200', + ' 200 | 字典 | 成功', + ]) + }) + + it('不认的:没导入 fastapi、路由对象不在这份文件里、不是路由对象的方法、不是挂接口的方法', async () => { + const plain = 'router = APIRouter()\n\n@router.get("/")\ndef a():\n pass\n' + expect(flatten(await views(plain)).some(view => view.kind === 'fastapi.route')).toBe(false) + const source = `${HEADER} +from .routes import other_router + +@other_router.get("/") +def b(): + pass + +@limiter.get("/") +def c(): + pass + +@router.websocket("/ws") +async def d(ws): + pass + +@router.get +def e(): + pass +` + const reports: string[] = [] + expect(flatten(await views(source, undefined, reports)).filter(view => view.kind === 'fastapi.route')).toEqual([]) + expect(reports).toEqual([]) + }) +}) + +describe('fastapi 库规则:别的文件里的定义(工作区索引 + 语言扩展查定义)', () => { + const SCHEMAS = 'backend/src/luffy_agent/core/exceptions/schemas.py' + const PARAMS = 'backend/src/luffy_agent/core/params.py' + const files: Record = { + [SCHEMAS]: readFixture('appExceptions.py'), + [PARAMS]: readFixture('coreParams.py'), + 'backend/src/luffy_agent/user/routes.py': ROUTES, + 'backend/src/luffy_agent/other/errors.py': 'class Forbidden(Exception):\n status_code = 418\n', + 'backend/src/luffy_agent/other/models.py': 'import enum\n\nclass Color(str, enum.Enum):\n RED = "red"\n\nclass Plain:\n pass\n', + } + + /** file 里 text 的起点(行、列 0 起),语言扩展答的定义处。 */ + function at(file: string, text: string): DefinitionStart { + const before = files[file].slice(0, files[file].indexOf(text)).split('\n') + return { uri: `${MemoryIndexHost.FOLDER}/${file}`, line: before.length - 1, character: before.at(-1)?.length ?? 0 } + } + + it('update_user 抛的 Forbidden:语言扩展查到定义在 core/exceptions/schemas.py,那个类上 status_code = 403;同名的别处的类不算', async () => { + const indexes = new WorkspaceIndexes(new MemoryIndexHost(files, py)) + const definitions = async (offset: number): Promise => ROUTES.startsWith('Forbidden', offset) ? [at(SCHEMAS, 'Forbidden(AppException)')] : [] + let changed = 0 + const lookups = new DocumentLookups(() => changed++, []) + const first = lookups.session() + expect(propertyLines(properties(await route(ROUTES, 'update_user', first.lookup)))).not.toContain(' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码') + + lookups.resolve(1, first.requests(), { find: (index, key) => indexes.find(index, key), definitions }) + await vi.waitFor(() => expect(changed).toBe(1)) + expect(propertyLines(properties(await route(ROUTES, 'update_user', lookups.session().lookup)))).toContain(' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码') + indexes.dispose() + }) + + it('索引认的:类上的 status_code(带不带注解、status 常量)、枚举类、Annotated 类型别名的标记;普通类不认', async () => { + const host = new MemoryIndexHost({ + ...files, + 'backend/src/luffy_agent/other/more.py': 'from fastapi import status\n\nclass Gone(Exception):\n status_code: int = status.HTTP_410_GONE\n', + }, py) + const indexes = new WorkspaceIndexes(host) + const lookups = new DocumentLookups(() => {}, []) + const session = lookups.session() + await route(`${HEADER} +@router.get("/{item_id}") +def show(item_id: Int4Path, color: Color, owner: Int4Query, plain: Plain): + raise Gone("没了") +`, 'show', session.lookup) + const index = session.requests()[0].index + const values = async (key: string): Promise => (await indexes.find(index, key)).map(({ entry }) => `${entry.value}`) + expect(await values('Forbidden')).toEqual(['403', '418']) + expect(await values('Gone')).toEqual(['410']) + expect(await values('Color')).toEqual(['Enum']) + expect(await values('Int4Path')).toEqual(['Path']) + expect(await values('Int4Query')).toEqual(['Query']) + expect(await values('Plain')).toEqual([]) + expect(host.reports).toEqual([]) + indexes.dispose() + }) +}) diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts new file mode 100644 index 0000000..fb88674 --- /dev/null +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -0,0 +1,625 @@ +import type { FunctionBlock, ImportFacts, IndexEntry, Inline, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, 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' + +/** + * FastAPI 库规则:挂在路由对象上的函数(`@router.get(...)` 这类装饰器,router 是同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` + * 赋值出来的)整块换成「接口」卡片,函数体照常是子块。卡片:名称 / 说明 / 标签 / 异步 / 依赖,然后请求表(一项一行)、响应表(一个状态码一行)。 + * 参数放在请求的哪儿照 FastAPI 的规则认(见 placeOf);函数体里直接抛的异常按状态码归进响应表:HTTPException 看 status_code, + * 自定义的异常类看类上的 `status_code = N`,类写在别处时经工作区索引查(NAMES_INDEX,RuleContext.lookup)。 + * 挂到应用上时再叠的前缀(include_router、settings.API_V1_PREFIX)在别的文件里,URL 不算。用词都在词表里,按翻译插件的显示语言挑。 + */ + +const RULE_ID = 'fastapi' +const KIND = 'fastapi.route' +const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' } +const ICON = 'globe' +/** 认路由对象、参数标记、HTTPException 时看从这几个库导入的名字(HTTPException、status 也可以从 starlette 来)。 */ +const NAME_LIBRARIES = ['fastapi', 'starlette'] + +/** 路由对象上挂接口的方法:`router.get`、`app.post` …… */ +const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace']) +/** HTTP 方法的颜色:Swagger UI 的配色,看接口文档的人最熟;白底上照原色看不清,亮色主题取同色相深一些的。 */ +const METHOD_COLORS: Record = { + GET: { dark: '#61affe', light: '#1b6ac9' }, + POST: { dark: '#49cc90', light: '#18834f' }, + PUT: { dark: '#fca130', light: '#b35f00' }, + PATCH: { dark: '#50e3c2', light: '#0f8a70' }, + DELETE: { dark: '#f93e3e', light: '#cf2020' }, + HEAD: { dark: '#b36bff', light: '#9012fe' }, + OPTIONS: { dark: '#4b92e0', light: '#0d5aa7' }, +} +/** 成功(小于 400)的状态码的颜色。 */ +const SUCCESS_COLOR: ThemeColor = { dark: '#2ea043', light: '#1a7f37' } + +/** 参数上写的 FastAPI 标记(默认值 `= Query(...)`,或 `Annotated[T, Query(...)]` 里的)→ 放在请求的哪儿。 */ +const MARKER_PLACES: Record = { + Path: 'path', + Query: 'query', + Header: 'header', + Cookie: 'cookie', + Body: 'body', + Form: 'body', + File: 'body', + Depends: 'dependency', + Security: 'dependency', +} +/** FastAPI 自己往里填、不是请求带来的参数类型。 */ +const INJECTED = new Set(['Request', 'Response', 'WebSocket', 'HTTPConnection', 'BackgroundTasks', 'SecurityScopes']) +/** 简单类型:没写标记时是查询参数;别的类(Pydantic 模型)是请求体。 */ +const SIMPLE = new Set(['int', 'float', 'str', 'bool', 'bytes', 'complex', 'UUID', 'datetime', 'date', 'time', 'timedelta', 'Decimal', 'Any', 'EmailStr', 'HttpUrl', 'AnyUrl', 'SecretStr']) +/** 包着别的类型的写法:里面都是简单类型时它也是(`list[str]`、`Optional[int]`)。 */ +const WRAPPERS = new Set(['Optional', 'Union', 'list', 'List', 'set', 'Set', 'frozenset', 'FrozenSet', 'tuple', 'Tuple', 'Sequence']) +/** enum 库的枚举类型:父类是它们的类是简单类型(查询参数)。 */ +const ENUM_BASES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag']) +/** 类属性、状态码常量里的状态码:`status_code = 403`、`status.HTTP_403_FORBIDDEN`。 */ +const STATUS_NAME = /^HTTP_(\d{3})_/ + +type Place = 'path' | 'query' | 'header' | 'cookie' | 'body' | 'dependency' + +/** 一种显示语言的用词。 */ +interface Words { + kind: string + labels: Record<'name' | 'doc' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses', string> + yes: string + no: string + requestHeads: string[] + responseHeads: string[] + items: Record<'url' | 'method' | 'path' | 'query' | 'header' | 'cookie' | 'body', string> + /** 成功那一行没写 response_description 时的说明 */ + success: string + /** FastAPI 自动带的 422:内容、说明 */ + validation: string + automatic: string + /** 几项之间(标签、依赖、内容)、几条消息之间 */ + listSeparator: string + messageSeparator: string + /** 依赖注进哪个参数:「(param)」 */ + injectedInto: (param: string) => string +} + +const ZH: Words = { + kind: '接口', + labels: { name: '名称', doc: '说明', tags: '标签', async: '异步', decorators: '装饰器', dependencies: '依赖', request: '请求', responses: '响应' }, + yes: '是', + no: '否', + requestHeads: ['项', '值'], + responseHeads: ['状态码', '内容', '说明'], + items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' }, + success: '成功', + validation: '参数校验失败', + automatic: 'FastAPI 自动返回', + listSeparator: '、', + messageSeparator: ';', + injectedInto: param => `(${param})`, +} + +/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */ +const WORDS: Record = { zh: ZH } + +interface Env { + context: RuleContext + words: Words + /** fastapi、starlette 和从它们导入的名字 */ + names: LibraryNames +} + +// ─── 别的文件里的定义 ───────────────────────────────────────────────────────── + +/** 状态码写法 → 三位数字:整数字面量、`HTTP_403_FORBIDDEN` 这种常量(`status.` 开头也算)。 */ +function statusNumber(node: Node | null | undefined): string | undefined { + if (node?.type === 'integer') + return node.text + const match = STATUS_NAME.exec(lastName(node) ?? '') + return match?.[1] +} + +/** 类主体里 `status_code = N`(带不带注解)写的状态码。 */ +function classStatus(definition: Node): string | undefined { + for (const statement of syntaxChildren(definition.childForFieldName('body'))) { + const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : [] + if (assignment?.type === 'assignment' && assignment.childForFieldName('left')?.text === 'status_code') { + const status = statusNumber(assignment.childForFieldName('right')) + if (status) + return status + } + } + return undefined +} + +/** + * `Annotated[T, ..., Query(...)]`:T,和里面的 FastAPI 标记(调用)。markerName 认调用的是哪个标记: + * 本文件按导入认(fastapi 里的名字),别的文件(建索引时)只看名字的最后一节。 + */ +function annotatedMarker(node: Node | null | undefined, markerName: (call: Node) => string | undefined): { type?: Node, marker?: Node } | undefined { + const generic = node ? genericOf(node) : undefined + if (!generic || lastName(generic.head) !== 'Annotated') + return undefined + const [type, ...metadata] = generic.args.map(unwrapType) + return { type, marker: metadata.find(item => MARKER_PLACES[markerName(item) ?? ''] !== undefined) } +} + +/** 调用的函数名的最后一节(建索引时认别的文件里的标记)。 */ +function calleeName(node: Node): string | undefined { + return node.type === 'call' ? lastName(node.childForFieldName('function')) : undefined +} + +/** + * 从一份文件的模块顶层认出别的文件要查的名字,值是: + * - 类上写了 `status_code = N` 的(异常类):状态码 N; + * - 父类是 enum 枚举类型的类:`Enum`(简单类型,查询参数); + * - `X = Annotated[T, Query(...)]` 这种类型别名:标记名(`Query`、`Path`、`Depends` ……)。 + */ +function indexEntries(root: Node): IndexEntry[] { + const entries: IndexEntry[] = [] + for (const statement of syntaxChildren(root)) { + const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : statement + if (definition?.type === 'class_definition') { + const className = definition.childForFieldName('name') + const status = classStatus(definition) + const enumType = syntaxChildren(definition.childForFieldName('superclasses')).some(base => ENUM_BASES.has(lastName(base) ?? '')) + const value = status ?? (enumType ? 'Enum' : undefined) + if (className && value) + entries.push({ key: className.text, name: range(className), value }) + continue + } + const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : [] + const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null + const marker = annotatedMarker(assignment?.childForFieldName('right'), calleeName)?.marker + if (target?.type === 'identifier' && marker) + entries.push({ key: target.text, name: range(target), value: calleeName(marker) }) + } + return entries +} + +const NAMES_INDEX: WorkspaceIndex = { + id: `${RULE_ID}.names`, + languageId: 'python', + include: '**/*.py', + mentions: source => /\bstatus_code\b|\bAnnotated\b|Enum\b/.test(source), + entries: root => indexEntries(root), +} + +/** 名字(`Forbidden`、`exceptions.Forbidden` 的最后一节)的定义在索引里认出的值;没查到是 undefined。 */ +function definedValue(node: Node | null | undefined, env: Env): string | undefined { + const target = node?.type === 'attribute' ? node.childForFieldName('attribute') : node + return target?.type === 'identifier' ? env.context.lookup(NAMES_INDEX, target)?.value : undefined +} + +// ─── 读语法树 ───────────────────────────────────────────────────────────────── + +/** fastapi、starlette(连子模块)自己和从它们导入的名字:本地名 → 原名。 */ +function namesOf(imports: readonly ImportFacts[]): LibraryNames { + const names: LibraryNames = { modules: new Set(), imported: new Map() } + const ours = (module: string): boolean => NAME_LIBRARIES.some(library => module === library || module.startsWith(`${library}.`)) + for (const facts of imports) { + const from = facts.sources.some(source => ours(source.text)) + for (const item of facts.items) { + if (item.kind === 'module' && ours(item.module.text)) + names.modules.add(item.alias?.text ?? item.module.text) + else if (item.kind === 'named' && from) + names.imported.set(item.alias?.text ?? item.name.text, item.name.text) + } + } + return names +} + +/** 调用的是 fastapi / starlette 里的哪个名字(`Query(...)`、`fastapi.APIRouter(...)`)。 */ +function calledName(node: Node | null | undefined, env: Env): string | undefined { + return node?.type === 'call' ? libraryName(node.childForFieldName('function'), env.names) : undefined +} + +/** 列表、元组的各项;别的写法当一项。 */ +function listItems(node: Node | undefined): Node[] { + if (!node) + return [] + return node.type === 'list' || node.type === 'tuple' ? syntaxChildren(node) : [node] +} + +/** 路由对象:同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 赋值出来的,和它给挂在上面的接口叠的东西。 */ +interface Router { + prefix?: Node + tags: Node[] + dependencies: Node[] + responses?: Node +} + +function routersOf(root: Node, env: Env): Map { + const routers = new Map() + for (const statement of syntaxChildren(root)) { + const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : [] + const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null + const value = assignment?.childForFieldName('right') + const kind = calledName(value, env) + const call = callOf(value, new Map()) + if (target?.type !== 'identifier' || !call || (kind !== 'APIRouter' && kind !== 'FastAPI')) + continue + routers.set(target.text, { + prefix: kind === 'APIRouter' ? call.keywords.get('prefix') : undefined, + tags: listItems(call.keywords.get('tags')), + dependencies: listItems(call.keywords.get('dependencies')), + responses: call.keywords.get('responses'), + }) + } + return routers +} + +/** 挂在路由对象上的一个接口:`@router.put("/{user_id}", ...)`。 */ +interface Route { + decorator: Node + router: Router + method: string + call: Call + path?: Node +} + +function routeOf(decorator: Node, routers: ReadonlyMap): Route | undefined { + const [expression] = syntaxChildren(decorator) + const callee = expression?.type === 'call' ? expression.childForFieldName('function') : null + const object = callee?.type === 'attribute' ? callee.childForFieldName('object') : null + const method = callee?.childForFieldName('attribute')?.text + const router = object?.type === 'identifier' ? routers.get(object.text) : undefined + const call = callOf(expression, new Map()) + if (!router || !call || !method || !METHODS.has(method)) + return undefined + return { decorator, router, method: method.toUpperCase(), call, path: call.positional[0] ?? call.keywords.get('path') } +} + +/** 路径里的一段:`{参数}` 按变量着色,其余按字符串,带源码下标;写的不是字符串时交给翻译插件翻。 */ +function pathSegments(node: Node | undefined, env: Env): Inline[] { + if (!node) + return [] + const content = stringContent(node) + if (!content) + return env.context.translate(node) + const segments: Segment[] = [] + let at = content.node?.startIndex ?? node.startIndex + for (const part of content.text.split(/(\{[^}]*\})/).filter(Boolean)) { + segments.push({ text: part, role: part.startsWith('{') ? 'variable' : 'string', offset: at }) + at += part.length + } + return segments +} + +/** 路径里的参数名:`{user_id}`、`{file_path:path}` 里冒号前面的。 */ +function pathNames(parts: readonly (Node | undefined)[]): Set { + const text = parts.map(part => stringContent(part)?.text ?? '').join('') + return new Set([...text.matchAll(/\{([^}:]+)(?::[^}]*)?\}/g)].map(match => match[1].trim())) +} + +/** 一个参数放在请求的哪儿;FastAPI 自己往里填的(Request……)、`*args` 这种没有。 */ +interface Placed { + place: Place + param: Node + /** 参数的类型(`Annotated[T, ...]` 取 T);没标注的没有 */ + type?: Node + /** 依赖:Depends(x) 的 x */ + dependency?: Node +} + +/** 类型最外层的名字:`int`、`list[str]` 的 list、`sa.UUID` 的 UUID。 */ +function headName(type: Node): string | undefined { + return lastName(genericOf(type)?.head ?? unwrapType(type)) +} + +/** 简单类型(FastAPI 当查询参数的):内置的标量、UUID、日期时间、Literal,枚举类(别处定义的经索引查),和只包着它们的 Optional / list / `X | None`。 */ +function isSimple(node: Node, env: Env): boolean { + const type = unwrapType(node) + if (type.type === 'none') + return true + if (type.type === 'binary_operator') + return syntaxChildren(type).every(side => isSimple(side, env)) + const generic = genericOf(type) + if (generic) { + const head = lastName(generic.head) + if (head === 'Literal') + return true + if (head === 'Annotated') + return generic.args.length > 0 && isSimple(generic.args[0], env) + return head !== undefined && WRAPPERS.has(head) && generic.args.every(arg => arg.type === 'ellipsis' || isSimple(arg, env)) + } + const head = lastName(type) + return head !== undefined && (SIMPLE.has(head) || definedValue(type, env) === 'Enum') +} + +/** 上传文件(`UploadFile`、`list[UploadFile]`):FastAPI 当表单里的文件,是请求体。 */ +function isUpload(type: Node): boolean { + const generic = genericOf(type) + return headName(type) === 'UploadFile' || (generic !== undefined && WRAPPERS.has(lastName(generic.head) ?? '') && generic.args.some(isUpload)) +} + +/** + * 参数放在请求的哪儿,照 FastAPI 的规则:Depends / Security 是依赖;名字在路径里是路径参数;写了 Query / Header / Cookie / Body / Form / File + * 照写的(默认值 `= Query(...)` 或 `Annotated[T, Query(...)]`,别处写好的 `X = Annotated[T, Query(...)]` 经索引查); + * Request 这些 FastAPI 自己填的不算;上传文件、别的类(Pydantic 模型)是请求体;其余简单类型、没标注的是查询参数。 + */ +function placeOf(parameter: ParameterFacts, inPath: ReadonlySet, env: Env): Placed | undefined { + const param = parameter.name + if (param.type !== 'identifier') + return undefined + const annotated = annotatedMarker(parameter.type, call => calledName(call, env)) + const type = annotated?.type ?? (parameter.type && unwrapType(parameter.type)) + const markerCall = MARKER_PLACES[calledName(parameter.default, env) ?? ''] ? parameter.default : annotated?.marker + const marker = markerCall && calledName(markerCall, env) + if (markerCall && marker && MARKER_PLACES[marker] === 'dependency') { + const call = callOf(markerCall, new Map()) + return { place: 'dependency', param, dependency: call?.positional[0] ?? call?.keywords.get('dependency') ?? type } + } + if (inPath.has(param.text)) + return { place: 'path', param, type } + if (marker && MARKER_PLACES[marker]) + return { place: MARKER_PLACES[marker], param, type } + const alias = type && (type.type === 'identifier' || type.type === 'attribute') ? definedValue(type, env) : undefined + if (alias && MARKER_PLACES[alias]) + return MARKER_PLACES[alias] === 'dependency' ? { place: 'dependency', param, dependency: type } : { place: MARKER_PLACES[alias], param, type } + if (type && INJECTED.has(headName(type) ?? '')) + return undefined + if (type && isUpload(type)) + return { place: 'body', param, type } + return !type || isSimple(type, env) ? { place: 'query', param, type } : { place: 'body', param, type } +} + +/** 函数体里直接抛的异常(嵌套的函数、类、lambda 里的不算)。 */ +function raisesIn(node: Node | null | undefined): Node[] { + const found: Node[] = [] + const visit = (current: Node): void => { + for (const child of syntaxChildren(current)) { + if (child.type === 'raise_statement') + found.push(child) + else if (!['function_definition', 'class_definition', 'decorated_definition', 'lambda'].includes(child.type)) + visit(child) + } + } + if (node) + visit(node) + return found +} + +// ─── 写成属性表 ─────────────────────────────────────────────────────────────── + +/** 一串词拼成的文字(框写框头)。 */ +function textOf(pieces: readonly Inline[]): string { + return pieces.map(piece => 'role' in piece ? piece.text : piece.label).join('') +} + +/** 依赖的名字:写成一个名字时按函数着色,别的交给翻译插件翻。 */ +function dependencyText(node: Node, env: Env): Inline[] { + return node.type === 'identifier' ? [name(node, 'function')] : env.context.translate(node) +} + +/** `dependencies=[Depends(x), ...]` 里的各个 x。 */ +function listedDependencies(items: readonly Node[], env: Env): Inline[][] { + return items.flatMap((item) => { + const call = MARKER_PLACES[calledName(item, env) ?? ''] === 'dependency' ? callOf(item, new Map()) : undefined + const dependency = call?.positional[0] ?? call?.keywords.get('dependency') + return dependency ? [dependencyText(dependency, env)] : [] + }) +} + +/** 标签:字符串照写(不带引号,带下标),别的交给翻译插件翻。 */ +function tagText(node: Node, env: Env): Inline[] { + const content = stringContent(node) + return content?.node ? [{ text: content.text, role: 'string', offset: content.node.startIndex }] : env.context.translate(node) +} + +/** 「名称: 类型」一串,逗号隔开;没标注的只写名称。 */ +function paramsText(placed: readonly Placed[], env: Env): Inline[] { + return joined(placed.map(({ param, type }) => [name(param, 'variable'), ...(type ? [{ text: ': ', role: 'plain' as const }, ...env.context.translate(type)] : [])]), { text: ', ', role: 'plain' }) +} + +/** 响应表里一个状态码的一行:状态码、内容、说明,同一个状态码的几处写在一起。 */ +interface Status { + code: Inline[] + /** 三位数字的状态码按大小排,别的(`"4XX"`、`default`)排在后面 */ + order: number + contents: Inline[][] + notes: Inline[][] +} + +/** 响应表里的状态码:三位数字的(按大小排、上色),或照写的(`"5XX"`、交给翻译插件翻的表达式)。 */ +type StatusCode = { key: string, number: number } | { key: string, cell: Inline[] } + +function numericStatus(code: string): StatusCode { + return { key: code, number: Number(code) } +} + +/** 状态码写法 → 状态码:`403`、`status.HTTP_403_FORBIDDEN`、`"403"` 是数字,别的字符串(`"5XX"`)照写,别的写法交给翻译插件翻。 */ +function statusCodeOf(node: Node, env: Env): StatusCode { + const text = stringContent(node)?.text + const number = statusNumber(node) ?? (text !== undefined && /^\d{3}$/.test(text) ? text : undefined) + if (number) + return numericStatus(number) + return text !== undefined ? { key: text, cell: [{ text, role: 'string' }] } : { key: node.text, cell: env.context.translate(node) } +} + +/** 响应表的各行:一个状态码一行,同一个状态码的几处写进同一行(内容、说明各自去重)。 */ +class Statuses { + private readonly byKey = new Map() + + add(status: StatusCode, content?: Inline[], note?: Inline[]): void { + let row = this.byKey.get(status.key) + if (!row) { + const code: Inline[] = 'cell' in status + ? status.cell + : [status.number < 400 ? { text: status.key, role: 'number', color: SUCCESS_COLOR } : { text: status.key, role: 'string' }] + row = { code, order: 'number' in status ? status.number : Number.POSITIVE_INFINITY, contents: [], notes: [] } + this.byKey.set(status.key, row) + } + if (content && content.length > 0 && !row.contents.some(known => textOf(known) === textOf(content))) + row.contents.push(content) + if (note && note.length > 0 && !row.notes.some(known => textOf(known) === textOf(note))) + row.notes.push(note) + } + + rows(): Status[] { + return [...this.byKey.values()].sort((a, b) => a.order - b.order) + } +} + +/** `responses={404: {"model": X, "description": "…"}, ...}` 的各项:模型是内容,description 是说明;值不是字典时整个是内容。 */ +function addResponses(node: Node | undefined, statuses: Statuses, env: Env): void { + if (node?.type !== 'dictionary') + return + for (const pair of syntaxChildren(node)) { + const key = pair.type === 'pair' ? pair.childForFieldName('key') : null + const value = pair.childForFieldName('value') + if (!key || !value) + continue + if (value.type !== 'dictionary') { + statuses.add(statusCodeOf(key, env), env.context.translate(value)) + continue + } + const fields = new Map(syntaxChildren(value).flatMap((field) => { + const fieldKey = field.type === 'pair' ? stringContent(field.childForFieldName('key'))?.text : undefined + const fieldValue = field.childForFieldName('value') + return fieldKey !== undefined && fieldValue ? [[fieldKey, fieldValue] as const] : [] + })) + const model = fields.get('model') + const description = stringContent(fields.get('description'))?.text + statuses.add(statusCodeOf(key, env), model && modelText(model, env), description ? [{ text: description, role: 'comment' }] : undefined) + } +} + +/** + * 写在参数值里的响应模型(response_model=、responses 里的 "model"):写成一个名字时按类型着色(翻译插件翻表达式时把它当变量), + * 别的交给翻译插件翻。返回类型是注解,翻译插件本来就按类型翻。 + */ +function modelText(node: Node, env: Env): Inline[] { + return node.type === 'identifier' ? [name(node, 'type')] : env.context.translate(node) +} + +/** 抛出时带的消息:字符串照写(说明色),别的交给翻译插件翻。 */ +function messageText(node: Node | undefined, env: Env): Inline[] | undefined { + if (!node) + return undefined + const content = stringContent(node) + return content ? [{ text: content.text, role: 'comment' }] : env.context.translate(node) +} + +/** 函数体里直接抛的异常按状态码归进去:HTTPException 看 status_code、detail;别的类看索引里查到的类上的 status_code,消息是第一个参数(或 message=、detail=)。 */ +function addRaises(body: Node | null | undefined, statuses: Statuses, env: Env): void { + for (const raise of raisesIn(body)) { + const [thrown] = syntaxChildren(raise) + if (!thrown) + continue + const callee = thrown.type === 'call' ? thrown.childForFieldName('function') : thrown + const call = callOf(thrown, new Map()) + const className = callee?.type === 'attribute' ? callee.childForFieldName('attribute') : callee + if (className?.type !== 'identifier') + continue + if (libraryName(callee, env.names) === 'HTTPException') { + const status = call?.keywords.get('status_code') ?? call?.positional[0] + if (status) + statuses.add(statusCodeOf(status, env), [name(className, 'type')], messageText(call?.keywords.get('detail') ?? call?.positional[1], env)) + continue + } + const status = definedValue(className, env) + if (status && /^\d{3}$/.test(status)) + statuses.add(numericStatus(status), [name(className, 'type')], messageText(call?.positional[0] ?? call?.keywords.get('message') ?? call?.keywords.get('detail'), env)) + } +} + +/** 请求表的一行:项 | 值。 */ +function requestItem(label: string, value: Inline[]): PropertyItem { + return { cells: [[hint(label)], value] } +} + +function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard { + const { context, words } = env + const { labels } = words + const { facts } = block + const { router, call } = route + const separator = hint(words.listSeparator) + + const inPath = pathNames([router.prefix, route.path]) + const placed = facts.parameters.flatMap(parameter => placeOf(parameter, inPath, env) ?? []) + const at = (place: Place): Placed[] => placed.filter(item => item.place === place) + const body = at('body') + + const url = [...pathSegments(router.prefix, env), ...pathSegments(route.path, 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] }) }]), + ...(['path', 'query', 'header', 'cookie'] as const).flatMap(place => at(place).length > 0 ? [requestItem(words.items[place], paramsText(at(place), env))] : []), + ...(body.length > 0 ? [requestItem(words.items.body, body.length === 1 && body[0].type ? context.translate(body[0].type) : paramsText(body, env))] : []), + ] + + const statuses = new Statuses() + const success = call.keywords.get('status_code') + const model = call.keywords.get('response_model') + const description = stringContent(call.keywords.get('response_description'))?.text + // 没写 response_model 时内容是返回类型;`-> None` 没有内容 + const returned = facts.returnType && unwrapType(facts.returnType).type !== 'none' ? facts.returnType : undefined + statuses.add( + success ? statusCodeOf(success, env) : numericStatus('200'), + model ? (model.type === 'none' ? undefined : modelText(model, env)) : returned && context.translate(returned), + [{ text: description ?? words.success, role: 'comment' }], + ) + addResponses(router.responses, statuses, env) + addResponses(call.keywords.get('responses'), statuses, env) + const definition = block.node.type === 'decorated_definition' ? block.node.childForFieldName('definition') : block.node + 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)) + const decorators = facts.decorators.filter(other => other !== route.decorator) + const dependencies = [ + ...listedDependencies(router.dependencies, env), + ...listedDependencies(listItems(call.keywords.get('dependencies')), env), + ...at('dependency').flatMap(({ param, dependency }) => dependency + ? [[...dependencyText(dependency, env), ...(param.text === '_' ? [] : [hint(words.injectedInto(param.text))])]] + : []), + ] + const rows: PropertyRow[] = [ + ...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'function')] }] : []), + ...(doc ? [{ label: labels.doc, value: doc }] : []), + ...(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' }) }] : []), + ...(dependencies.length > 0 ? [{ label: labels.dependencies, value: joined(dependencies, separator) }] : []), + { + label: labels.request, + value: [hint(`${route.method} ${textOf(url)}`)], + 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' })], + })), + }, + ] + return { + kind: KIND, + badge: { label: words.kind, color: COLOR, icon: ICON }, + rows, + ...(facts.name && { defines: range(facts.name) }), + 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) + } + return undefined + }, +} diff --git a/vscode/webview-ui/src/codeReader/SegmentText.test.tsx b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx new file mode 100644 index 0000000..f3efbdd --- /dev/null +++ b/vscode/webview-ui/src/codeReader/SegmentText.test.tsx @@ -0,0 +1,22 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { SegmentText } from './SegmentText' + +// 高亮的上下文所在的模块连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 +vi.mock('./useOccurrences', async () => { + const { createContext } = await import('react') + return { HighlightContext: createContext(undefined) } +}) + +describe('着色的词', () => { + it('按词性上色;带自己颜色的词(库规则给的)把暗色、亮色写在 style 上,CSS 按主题挑', () => { + const html = renderToStaticMarkup( + , + ) + expect(html).toBe('PUT /users') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 437f41c..fea928f 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -1,5 +1,5 @@ import type { LucideIcon } from 'lucide-react' -import type { ReactElement } from 'react' +import type { CSSProperties, ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' import { useContext } from 'react' import { DisplayNameContext, shownName } from './displayNames' @@ -8,7 +8,7 @@ import { isCurrent, segmentOccurrence } from './occurrences' import { HighlightContext } from './useOccurrences' /** - * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上, + * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换,带自己颜色(color)的段用它(.cr-colored);带源码下标的名字记在 data-offset 上, * 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 * 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。 * 框起来的一组词画成小框,框里的词同样处理。 @@ -25,7 +25,8 @@ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement return (