✨ feat(vscode): 代码阅读的 FastAPI 接口多一个筛选属性「请求方法」(GET / POST …);库规则自己报的筛选属性由规则起名(LibraryRule.attributeNames)

This commit is contained in:
cruldra committed 2026-09-27 21:21:48 +08:00
1 parent ec34ace7cf
commit b4e4879570
9 files changed
+107 -11

No files matched your search

+5 -3
View File
@@ -148,7 +148,7 @@
- 三层分工,webview 不认识任何语言:
1. 语言插件给每块报和语言无关的属性「属性 = 值」,都是稳定的英文 id,不放显示文字。通用词表由契约定(`COMMON_ATTRIBUTES`):`kind`(块的种类)、`access`(`public` / `private` / `protected`,语言可以加自己的值)、`async`、`abstract`、`typeOnly`(`'true'` / `'false'`)。通用属性由宿主从块的 kind 和 facts 推出来(`FunctionFacts.access` / `async`、`ClassFacts.access` / `abstract`、`EnumFacts.access`、`ExceptionFacts.access`、`ImportFacts.typeOnly`、`VariableFacts.access`),只有这一条路,语言插件不用另报;facts 里没填的(语言没有这个概念,例如 JavaScript 没有抽象类、仅类型导入)块就没有这个属性,不是填「否」。语言自己的属性(Rust 的 `unsafe = 'true'`)、facts 表达不了的通用属性值(Kotlin 的 `access = 'internal'`)填在块的 `attributes` 里,和推出来的合在一起,同一属性以它为准;`kind` 总是块的种类。
2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类图标的悬停提示也取它。没起名的显示 id。
2. 翻译插件给属性和值起名(`Translator.attributeNames`:`access` → 访问级别、`public` → 公共、`kind` 的 `function` → 函数、布尔值 → 是 / 否),和属性表里的属性名、值是同一套叫法(中文直译里属性表的「访问级别」「异步」「抽象」「仅类型」行和筛选共用一份表);种类图标的悬停提示也取它。库规则的卡片自己报的属性(`RuleCard.attributes`,FastAPI 接口的 `httpMethod`)由规则起名(`LibraryRule.attributeNames`,用词归规则、按翻译插件的显示语言挑):宿主先取翻译插件给的名字,没给的再取规则给的(`attributes.ts` 的 `mergeAttributeNames`)。没起名的显示 id。
3. webview 只按属性 id / 值 id 筛,显示翻译插件给的名字。
- 下拉框:这份文件里有两种以上值的属性各一个(只有一种值的不给——筛它等于按种类筛);只有一种值、但这个值正被去掉的也给,不然藏起来的块在栏上找不到原因。排列:通用属性按契约的顺序(种类、访问级别、异步、抽象、仅类型),语言自己的属性按在文件里第一次出现的先后排在后面。下拉菜单顶上一行「全选 · 取消全选」,分隔线下面是勾选列表,列出这份文件里出现的值(按第一次出现的先后)和各有几块(所有层的块都算,例如「私有 3 / 公共 3」)。
- 全选 / 取消全选:把这份文件里这个属性出现的值一起勾上 / 去掉,和逐个勾一样记下来;别的文件才有的值记着的去掉状态不动(筛选整个工作区共用)。取消全选后有这个属性的块全藏起来,没有这个属性的照常显示;菜单不关,可以接着勾。
@@ -684,7 +684,7 @@ export interface RuleCard {
badge: KindBadge // 种类的名字、颜色、图标;筛选栏里这个种类也叫这个名字
rows: PropertyRow[]
defines?: SourceRange // 块自己的名字(类名):跳到的定义是它时闪整块
attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算
attributes?: BlockAttributes // 规则自己的筛选属性,叠在宿主从原块推出的上面;kind 填了不算;名字由 LibraryRule.attributeNames 给
absorbs: CodeBlock[] // 并进来的子块(原块 children 里的对象本身),不再单独成块;没并的照源码顺序留作子块,编辑范围是原块整条语句
}
@@ -737,6 +737,7 @@ export interface LibraryRule {
libraries: string[] // 关心的库:导入来源等于它或是它的子模块才启用
claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined
resolve?: (key: string, workspace: RuleWorkspace) => Promise<RuleLocation[]> // 查自己标的引用;没给就不能标引用
attributeNames?: (displayLanguage: string | undefined) => AttributeNames // 自己报的筛选属性和值叫什么(同 Translator.attributeNames,用词归规则);翻译插件给了名字的以翻译插件为准
}
export interface CodeReaderApi {
@@ -776,7 +777,7 @@ export interface CodeReaderApi {
- 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。
- 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。
- 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。
- 卡片在预览里:种类图标、颜色取 `badge`(`icon`;颜色暗色、亮色主题各取一个),悬停提示是 `badge.label`,内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。
- 卡片在预览里:种类图标、颜色取 `badge`(`icon`;颜色暗色、亮色主题各取一个),悬停提示是 `badge.label`,内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的(规则自己的属性叫 `LibraryRule.attributeNames` 给的名字,规则起名时抛错记日志、照写 id);筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。
### 规则自己查定义
@@ -1026,6 +1027,7 @@ export interface CodeReaderApi {
- 认哪些函数:装饰器是 `@<路由对象>.<方法>(...)`,方法是 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 不算。
- 筛选:接口卡片多一个筛选属性 `httpMethod`,值是大写的方法(`GET`、`PUT`……,照写);属性名「请求方法」在规则的词表里,经 `LibraryRule.attributeNames` 给筛选栏。
属性表(有才显示):
+12 -1
View File
@@ -1,7 +1,7 @@
import type { BlockView } from './blockView'
import type { CodeBlock, LanguageAdapter, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { blockAttributes, fileAttributes } from './attributes'
import { blockAttributes, fileAttributes, mergeAttributeNames } from './attributes'
import { toBlockViews } from './blockView'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
@@ -139,6 +139,17 @@ describe('文件里出现过的属性', () => {
expect(found[0].name).toBe('kind')
})
it('几份叫法合成一份:同一个属性、值排在前面的(翻译插件的)先用,没给的再取后面的(库规则的)', () => {
expect(mergeAttributeNames([
{ attributes: { access: '访问级别' }, values: { access: { public: '公共' } } },
undefined,
{ attributes: { access: '可见性', httpMethod: '请求方法' }, values: { access: { public: '开放', private: '内部' } } },
])).toEqual({
attributes: { access: '访问级别', httpMethod: '请求方法' },
values: { access: { public: '公共', private: '内部' } },
})
})
it('中文直译给通用属性和值起名,和属性表里的叫法一样', async () => {
const list = await views(ts, 'typescript', TS_SOURCE, typescriptZhTranslator)
const found = fileAttributes(list, typescriptZhTranslator.attributeNames)
+13
View File
@@ -80,3 +80,16 @@ export function fileAttributes(blocks: readonly Attributed[], names: AttributeNa
values: [...values].map(([value, count]) => ({ id: value, name: valueName(id, value), count })),
}))
}
/** 几份属性和值的叫法合成一份:同一个属性、同一个值谁排在前面、给了名字就用谁的(翻译插件的排在库规则的前面)。 */
export function mergeAttributeNames(list: readonly (AttributeNames | undefined)[]): AttributeNames {
const merged: AttributeNames = { attributes: {}, values: {} }
for (const names of [...list].reverse()) {
if (!names)
continue
Object.assign(merged.attributes, names.attributes)
for (const [id, values] of Object.entries(names.values))
merged.values[id] = { ...merged.values[id], ...values }
}
return merged
}
+10 -1
View File
@@ -466,7 +466,10 @@ export interface RuleCard {
rows: PropertyRow[]
/** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */
defines?: SourceRange
/** 规则自己的筛选属性,叠在宿主从原块推出的属性上(同 BlockShape.attributes);kind 总是上面的 kind,这里填了不算。 */
/**
* 规则自己的筛选属性,叠在宿主从原块推出的属性上(同 BlockShape.attributes);kind 总是上面的 kind,这里填了不算。
* 属性和值的名字由规则给(LibraryRule.attributeNames)。
*/
attributes?: BlockAttributes
/**
* 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。
@@ -580,6 +583,12 @@ export interface LibraryRule {
* 查到几处就跳、挑,和语言扩展查到的一样。没给就不能在段上标引用。
*/
resolve?: (key: string, workspace: RuleWorkspace) => Promise<RuleLocation[]>
/**
* 规则自己报的筛选属性(RuleCard.attributes,例如 FastAPI 接口的 httpMethod)和值叫什么,同 Translator.attributeNames:
* 用词归规则,按翻译插件的 displayLanguage 挑(没有翻译插件时是 undefined),没有这种语言的词表就用规则默认的那份。
* 筛选栏的名字先取翻译插件给的,没给的再取规则给的;都没给的显示 id。
*/
attributeNames?: (displayLanguage: string | undefined) => AttributeNames
}
export interface CodeReaderApi {
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { CodeReaderRegistry } from '../registry'
import { fastapiRule } from '../rules/fastapi'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { runtime, WASM_DIR } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
@@ -82,6 +83,41 @@ describe('预览的 document 消息', () => {
])
})
it('库规则自己的筛选属性:名字取规则给的(FastAPI 接口的「请求方法」),值照写;规则起名时抛错记下来、照写 id', async () => {
const routes = [
'from fastapi import APIRouter',
'',
'router = APIRouter(prefix="/users")',
'',
'@router.get("/{user_id}")',
'def get_user(user_id: int):',
' pass',
'',
'@router.put("/{user_id}")',
'def update_user(user_id: int):',
' pass',
'',
'@router.get("")',
'def list_users():',
' pass',
'',
].join('\n')
const { message } = await documentMessage(doc('python', routes), registryWithPython(fastapiRule), runtime, ignore)
expect(message.attributes.find(attribute => attribute.id === 'httpMethod')).toEqual({
id: 'httpMethod',
name: '请求方法',
values: [{ id: 'GET', name: 'GET', count: 2 }, { id: 'PUT', name: 'PUT', count: 1 }],
})
const reports: string[] = []
const broken: LibraryRule = { ...fastapiRule, attributeNames: () => {
throw new Error('词表坏了')
} }
const again = await documentMessage(doc('python', routes), registryWithPython(broken), runtime, text => reports.push(text))
expect(again.message.attributes.find(attribute => attribute.id === 'httpMethod')?.name).toBe('httpMethod')
expect(reports).toEqual(['库规则 fastapi 给筛选属性起名时出错,筛选栏照写 id:词表坏了'])
})
it('库规则抛错:不算解析失败,交给 report 记下,这块照常显示', async () => {
const broken: LibraryRule = {
id: 'broken',
@@ -2,8 +2,9 @@ import type { DisplayName, RuleContext } from '../contract'
import type { CodeReaderRegistry } from '../registry'
import type { TreeSitterRuntime } from '../runtime'
import type { DocumentMessage } from './messages'
import { fileAttributes } from '../attributes'
import { fileAttributes, mergeAttributeNames } from '../attributes'
import { readBlockViews } from '../blockView'
import { ruleAttributeNames } from '../libraryRules'
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
export interface DocumentSnapshot {
@@ -20,7 +21,7 @@ export interface DocumentRead {
/**
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
* 规则查名字的定义时问 lookup(RuleContext.lookup)。
* 规则查名字的定义时问 lookup(RuleContext.lookup)。筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
*/
export async function documentMessage(
document: DocumentSnapshot,
@@ -46,8 +47,10 @@ 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, lookup })
return { message: { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }, displayNames }
const rules = registry.rulesFor(languageId)
const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules, report, lookup })
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames }
}
catch (err) {
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [] }
+14 -1
View File
@@ -1,4 +1,4 @@
import type { CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment, SourceSpan } from './contract'
import type { AttributeNames, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment, SourceSpan } from './contract'
/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后编辑哪一段、留下哪些子块。规则本身只经 contract 拿信息。 */
@@ -84,6 +84,19 @@ export function claimBlock(block: CodeBlock, rules: readonly LibraryRule[], cont
return undefined
}
/** 各规则给自己的筛选属性起的名字(LibraryRule.attributeNames),按注册顺序;规则抛错时交给 report(记日志),当它没给。 */
export function ruleAttributeNames(rules: readonly LibraryRule[], displayLanguage: string | undefined, report: (message: string) => void): AttributeNames[] {
return rules.flatMap((rule) => {
try {
return rule.attributeNames ? [rule.attributeNames(displayLanguage)] : []
}
catch (err) {
report(`库规则 ${rule.id} 给筛选属性起名时出错,筛选栏照写 id:${err instanceof Error ? err.message : String(err)}`)
return []
}
})
}
/** 源码里一个节点占的那段。 */
function nodeSpan(node: Node): SourceSpan {
return { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }
+2 -1
View File
@@ -83,7 +83,8 @@ describe('fastapi 库规则:接口卡片', () => {
' 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(card.attributes).toMatchObject({ kind: 'fastapi.route', access: 'public', async: 'true', httpMethod: 'PUT' })
expect(fastapiRule.attributeNames?.('zh')).toEqual({ attributes: { httpMethod: '请求方法' }, values: {} })
expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('update_user')
// 函数体照常是子块,卡片的编辑范围是整个函数(连装饰器)
expect(card.children.length).toBeGreaterThan(0)
+8
View File
@@ -18,6 +18,9 @@ const ICON: KindIconName = 'globe'
/** 认路由对象、参数标记、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 的配色,看接口文档的人最熟;白底上照原色看不清,亮色主题取同色相深一些的。 */
@@ -77,6 +80,8 @@ interface Words {
messageSeparator: string
/** 依赖注进哪个参数:「(param)」 */
injectedInto: (param: string) => string
/** 筛选属性 httpMethod 叫什么(值 GET、POST……照写) */
methodAttribute: string
}
const ZH: Words = {
@@ -93,6 +98,7 @@ const ZH: Words = {
listSeparator: '、',
messageSeparator: ';',
injectedInto: param => `(${param})`,
methodAttribute: '请求方法',
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
@@ -620,6 +626,7 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
badge: { label: words.kind, color: COLOR, icon: ICON },
rows,
...(facts.name && { defines: range(facts.name) }),
attributes: { [METHOD_ATTRIBUTE]: route.method },
absorbs: [],
}
}
@@ -641,4 +648,5 @@ export const fastapiRule: LibraryRule = {
}
return undefined
},
attributeNames: displayLanguage => ({ attributes: { [METHOD_ATTRIBUTE]: (WORDS[displayLanguage ?? ''] ?? ZH).methodAttribute }, values: {} }),
}