import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract' import type { ParamDocs } from './docRows' import type { Call, LibraryNames } from './pythonTree' import { docBodyRows, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows' import { callOf, genericOf, lastName, libraryName, stringContent, syntaxChildren, unwrapType } from './pythonTree' import { hint, joined, name, range, word } from './segments' /** * 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: KindIconName = 'globe' const ROUTER_KIND = 'fastapi.router' const ROUTER_COLOR: ThemeColor = { dark: '#7aa2f7', light: '#34548a' } const ROUTER_ICON: KindIconName = 'network' /** 认路由对象、参数标记、HTTPException 时看从这几个库导入的名字(HTTPException、status 也可以从 starlette 来)。 */ const NAME_LIBRARIES = ['fastapi', 'starlette'] /** 接口的筛选属性:HTTP 方法,值是大写的 GET、POST……(名字由 attributeNames 给,值照写)。 */ const METHOD_ATTRIBUTE = 'httpMethod' /** 路由对象上挂接口的方法:`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 routerKind: string labels: Record<'name' | 'doc' | 'router' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses' | 'prefix' | 'defaultResponses' | 'routes', string> yes: string no: string count: (n: number) => string requestHeads: string[] /** 请求表里有参数写了说明(`@param`)时多出的那一列 */ requestDoc: string responseHeads: string[] /** 路由器卡片的接口表:方法 | 路径 | 函数 | 说明 */ routeHeads: 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 /** 筛选属性 httpMethod 叫什么(值 GET、POST……照写) */ methodAttribute: string } const ZH: Words = { kind: '路由', routerKind: '路由器', labels: { name: '名称', doc: '说明', router: '路由器', tags: '标签', async: '异步', decorators: '装饰器', dependencies: '依赖', request: '请求', responses: '响应', prefix: '前缀', defaultResponses: '默认响应', routes: '接口', }, yes: '是', no: '否', count: n => `${n} 个`, requestHeads: ['项', '值'], requestDoc: '说明', responseHeads: ['状态码', '内容', '说明'], routeHeads: ['方法', '路径', '函数', '说明'], items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' }, success: '成功', validation: '参数校验失败', automatic: 'FastAPI 自动返回', listSeparator: '、', messageSeparator: ';', injectedInto: param => `(${param})`, methodAttribute: '请求方法', } /** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */ 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, names: LibraryNames): string | undefined { return node?.type === 'call' ? libraryName(node.childForFieldName('function'), 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 { /** 赋值目标:路由对象的名字 */ target: Node prefix?: Node tags: Node[] dependencies: Node[] responses?: Node } function routersOf(root: Node, names: LibraryNames): 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, names) const call = callOf(value, new Map()) if (target?.type !== 'identifier' || !call || (kind !== 'APIRouter' && kind !== 'FastAPI')) continue routers.set(target.text, { target, prefix: kind === 'APIRouter' ? call.keywords.get('prefix') : undefined, tags: listItems(call.keywords.get('tags')), dependencies: listItems(call.keywords.get('dependencies')), 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') } } /** 路由器卡片接口表里的一个接口:挂在这个路由对象上的函数(模块顶层的)。 */ interface RouteOn { route: Route definition: Node name: Node } /** 这份文件模块顶层挂在各路由对象上的接口,照源码顺序;一个函数几个路由装饰器时同接口卡片,取第一个。 */ function routesOn(root: Node, routers: ReadonlyMap): Map { const routes = new Map() for (const statement of syntaxChildren(root)) { const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : null const name = definition?.type === 'function_definition' ? definition.childForFieldName('name') : null if (!definition || !name) continue const route = syntaxChildren(statement).filter(child => child.type === 'decorator').map(decorator => routeOf(decorator, routers)).find(found => found !== undefined) if (route) routes.set(route.router, [...routes.get(route.router) ?? [], { route, definition, name }]) } return routes } /** 认接口要整份扫一遍才知道的:fastapi / starlette 的名字、模块顶层的路由对象、各路由对象上挂的接口。 */ interface FileScan { names: LibraryNames routers: Map routes: Map } /** 宿主每读一份文档给各块同一个 context:整份文件按它扫一遍,这份文档的各块共用。 */ const scans = new WeakMap() function scanOf(root: Node, context: RuleContext): FileScan { let scan = scans.get(context) if (!scan) { const names = namesOf(context.imports) const routers = routersOf(root, names) scan = { names, routers, routes: routesOn(root, routers) } scans.set(context, scan) } return scan } /** 函数的参数名 → 定义处那个名字(`*args` 这种不算),路径里 `{参数}` 讲的是它。 */ function parametersOf(definition: Node): Map { const names = syntaxChildren(definition.childForFieldName('parameters')).map(param => param.type === 'identifier' ? param : param.type === 'typed_parameter' ? syntaxChildren(param)[0] : param.childForFieldName('name')) return new Map(names.flatMap(param => param?.type === 'identifier' ? [[param.text, param] as const] : [])) } /** 函数 docstring 正文的第一行(翻译插件读出的说明,块标签已经拿掉),没有 docstring 时没有。 */ function docFirstLine(definition: Node, env: Env): string | undefined { const [first] = syntaxChildren(definition.childForFieldName('body')) const [doc] = first?.type === 'expression_statement' ? syntaxChildren(first) : [] const markdown = doc?.type === 'string' ? env.context.doc(doc)?.body?.markdown : undefined return markdown?.split('\n').map(line => line.trim()).find(line => line !== '') } /** * 路径里的一段:`{参数}` 按变量着色,其余按字符串,都带源码下标;花括号里的名字讲的是函数的同名参数(params), * 标上 refers,高亮参数的各处出现时它也跟着亮、⌘ + 点击跳到参数。写的不是字符串时交给翻译插件翻。 */ function pathSegments(node: Node | undefined, params: ReadonlyMap, 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)) { if (!part.startsWith('{')) { segments.push({ text: part, role: 'string', offset: at }) } else { // `{file_path:path}`:冒号前面是参数名,后面是转换器 const colon = part.indexOf(':') const key = part.slice(1, colon < 0 ? -1 : colon) const rest = part.slice(1 + key.length, -1) const param = params.get(key.trim()) const pieces: Segment[] = [ { text: '{', role: 'variable', offset: at }, { text: key, role: 'variable', offset: at + 1, ...(param && { refers: range(param) }) }, { text: `${rest}}`, role: 'variable', offset: at + 1 + key.length }, ] segments.push(...pieces.filter(piece => piece.text !== '')) } 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.names)) const type = annotated?.type ?? (parameter.type && unwrapType(parameter.type)) const markerCall = MARKER_PLACES[calledName(parameter.default, env.names) ?? ''] ? parameter.default : annotated?.marker const marker = markerCall && calledName(markerCall, env.names) 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.names) ?? ''] === '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 responsesRow(label: string, statuses: Statuses, env: Env): PropertyRow { const { words } = env const rows = statuses.rows() return { label, value: [hint(rows.map(row => textOf(row.code)).join(' / '))], columns: words.responseHeads, items: rows.map(row => ({ cells: [row.code, joined(row.contents, hint(words.listSeparator)), joined(row.notes, { text: words.messageSeparator, role: 'comment' })], })), } } /** HTTP 方法:大写,按 Swagger UI 的配色(没有配色的按关键字着色)。 */ function methodText(method: string): Inline[] { return [{ text: method, role: 'keyword', ...(METHOD_COLORS[method] && { color: METHOD_COLORS[method] }) }] } /** 请求表的一行:项 | 值,表里有说明一列时再加说明(note,没有说明的是空的)。 */ function requestItem(label: string, value: Inline[], note?: PreviewText): PropertyItem { return { cells: [[hint(label)], value, ...(note ? [note] : [])] } } /** * 请求表的各项:URL、方法,然后路径参数、查询参数、请求头、Cookie、请求体,有才列。说明里有请求参数对上了 `@param`(docs)时 * described:表多一列说明,这几种参数一个参数一行、各带它的说明;没有时同一种参数逗号隔开写在一行。 */ function requestItems(url: Inline[], method: string, placed: readonly Placed[], docs: ParamDocs, env: Env): { items: PropertyItem[], described: boolean } { const { words, context } = env const described = placed.some(item => item.place !== 'dependency' && docs.of(item.param.text)) const item = (label: string, value: Inline[], note?: PreviewText): PropertyItem => requestItem(label, value, described ? note ?? [] : undefined) const at = (place: Place): Placed[] => placed.filter(one => one.place === place) const listed = (place: Exclude): PropertyItem[] => { const found = at(place) if (described) return found.map(one => item(words.items[place], paramsText([one], env), docs.of(one.param.text))) return found.length > 0 ? [item(words.items[place], paramsText(found, env))] : [] } const body = at('body') const [only] = body return { items: [ item(words.items.url, url), item(words.items.method, methodText(method)), ...(['path', 'query', 'header', 'cookie'] as const).flatMap(listed), ...(body.length === 1 && only?.type ? [item(words.items.body, context.translate(only.type), docs.of(only.param.text))] : listed('body')), ], described, } } /** 接口卡片;siblings 是同一个路由对象上的全部接口,随卡片讲的 HTTP 接口一起交给宿主定挂载前缀。 */ function routeCard(block: FunctionBlock, route: Route, env: Env, siblings: readonly RouteOn[]): 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 params = new Map(facts.parameters.flatMap(({ name: param }) => param.type === 'identifier' ? [[param.text, param] as const] : [])) const url = [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)] const doc = facts.doc && context.doc(facts.doc) const docs = paramDocs(doc, facts.parameters.map(parameter => parameter.name.text)) const request = requestItems(url, route.method, placed, docs, env) const statuses = new Statuses() const success = call.keywords.get('status_code') 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 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')] }] : []), ...docBodyRows(doc, labels.doc), ...unmatchedParamRows(docs.unmatched), { label: labels.router, value: [name(router.target, 'variable')] }, ...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []), { label: labels.async, value: [word(facts.async ? words.yes : words.no)] }, ...(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: request.described ? [...words.requestHeads, words.requestDoc] : words.requestHeads, items: request.items, }, { ...responsesRow(labels.responses, statuses, env), ...returnNote(doc) }, ...throwsRows(doc), ] return { kind: KIND, badge: { label: words.kind, color: COLOR, icon: ICON }, rows, ...(facts.name && { defines: range(facts.name) }), attributes: { [METHOD_ATTRIBUTE]: route.method }, absorbs: [], ...(facts.name && endpointOf(route, facts.name.text, siblings)), } } /** * 卡片讲的 HTTP 接口,连同同一个路由对象上的全部接口(算不出路径的不算):算不出这一个的路径时没有。 * FastAPI 默认的 operationId 是函数名接完整路径和方法。 */ function endpointOf(route: Route, fn: string, siblings: readonly RouteOn[]): Pick { const path = localPath(route) if (path === undefined) return {} const sameRouter = siblings.flatMap(({ route: other }) => { const otherPath = localPath(other) return otherPath === undefined ? [] : [{ method: other.method, path: otherPath }] }) return { endpoint: { method: route.method, path, operationIdPrefix: `${fn}_`, sameRouter } } } /** 接口在这份文件里写出来的路径(路由对象的前缀接装饰器里的路径):两段都是字符串字面量(或不写)才算得出。 */ function localPath(route: Route): string | undefined { const parts = [route.router.prefix, route.path].map(part => part ? stringContent(part)?.text : '') return parts.includes(undefined) ? undefined : parts.join('') } /** * 路由器卡片:名称 / 说明 / 前缀 / 标签 / 依赖 / 默认响应(有才显示),接口表列这份文件里挂在它上面的全部接口: * 方法 | 路径(前缀 + 装饰器里的路径,`{参数}` 讲那个函数的参数)| 函数(⌘ + 点击跳到那张接口卡片)| 说明(docstring 第一行)。 */ function routerCard(block: VariableBlock, router: Router, routes: readonly RouteOn[], env: Env): RuleCard { const { context, words } = env const { labels } = words const separator = hint(words.listSeparator) const doc = block.facts.doc && context.doc(block.facts.doc)?.body const tags = router.tags.map(tag => tagText(tag, env)) const dependencies = listedDependencies(router.dependencies, env) const statuses = new Statuses() addResponses(router.responses, statuses, env) const rows: PropertyRow[] = [ { label: labels.name, value: [name(router.target, 'variable')] }, ...(doc ? [{ label: labels.doc, value: doc }] : []), ...(router.prefix ? [{ label: labels.prefix, value: pathSegments(router.prefix, new Map(), env) }] : []), ...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []), ...(dependencies.length > 0 ? [{ label: labels.dependencies, value: joined(dependencies, separator) }] : []), ...(statuses.rows().length > 0 ? [responsesRow(labels.defaultResponses, statuses, env)] : []), { label: labels.routes, value: [hint(words.count(routes.length))], ...(routes.length > 0 && { columns: words.routeHeads, items: routes.map(({ route, definition, name: fn }) => { const params = parametersOf(definition) const doc = docFirstLine(definition, env) return { cells: [ methodText(route.method), [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)], [name(fn, 'function')], doc ? [{ text: doc, role: 'comment' }] : [], ], } }), }), }, ] return { kind: ROUTER_KIND, badge: { label: words.routerKind, color: ROUTER_COLOR, icon: ROUTER_ICON }, rows, defines: range(router.target), absorbs: [], } } export const fastapiRule: LibraryRule = { id: RULE_ID, label: 'FastAPI 接口', languageIds: ['python'], libraries: ['fastapi'], claim: (block, context) => { const { names, routers, routes } = scanOf(block.node.tree.rootNode, context) const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, names } if (block.kind === 'function' && block.facts.decorators.length > 0) { for (const decorator of block.facts.decorators) { const route = routeOf(decorator, routers) if (route) return routeCard(block, route, env, routes.get(route.router) ?? []) } } // 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块 if ((block.kind === 'const' || block.kind === 'variable') && block.facts.declarators.length === 1) { const target = block.facts.declarators[0].name const router = routers.get(target.text) if (router?.target.startIndex === target.startIndex) return routerCard(block, router, routes.get(router) ?? [], env) } return undefined }, attributeNames: displayLanguage => ({ attributes: { [METHOD_ATTRIBUTE]: (WORDS[displayLanguage ?? ''] ?? ZH).methodAttribute }, values: {} }), }