diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index a561f0d..0ec2a81 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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 // 查自己标的引用;没给就不能标引用 + 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` 给筛选栏。 属性表(有才显示): diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index db14dc0..0f40bb6 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -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) diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index 6f6868a..d9bf73b 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -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 +} diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 6211e46..3eff5f8 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -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 + /** + * 规则自己报的筛选属性(RuleCard.attributes,例如 FastAPI 接口的 httpMethod)和值叫什么,同 Translator.attributeNames: + * 用词归规则,按翻译插件的 displayLanguage 挑(没有翻译插件时是 undefined),没有这种语言的词表就用规则默认的那份。 + * 筛选栏的名字先取翻译插件给的,没给的再取规则给的;都没给的显示 id。 + */ + attributeNames?: (displayLanguage: string | undefined) => AttributeNames } export interface CodeReaderApi { diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index fd029f4..3dc4748 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -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', diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 4c1a01d..2bd7cb4 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -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: [] } diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 0e416c9..24dfece 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -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 } diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index 856a64f..a5dcc8f 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -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) diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts index b1b9b1b..dba252b 100644 --- a/vscode/src/codeReader/rules/fastapi.ts +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -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: {} }), }