diff --git a/vscode/src/codeReader/rules/axum.ts b/vscode/src/codeReader/rules/axum.ts index 8165e70..a84029f 100644 --- a/vscode/src/codeReader/rules/axum.ts +++ b/vscode/src/codeReader/rules/axum.ts @@ -1,6 +1,6 @@ import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract' import { returnNote, throwsRows, writtenParamRows } from './docRows' -import { returnValue } from './rustRows' +import { returnValue, valueCard } from './rustRows' import { attributeName, docSummary, named, stringContent } from './rustTree' /** 和 FastAPI 接口同一个蓝。 */ @@ -9,7 +9,7 @@ const BADGE: KindBadge = { label: '接口', color: COLOR, icon: 'globe' } const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']) /** - * 处理函数上的 get / post 等路由属性,或 Router 上能看出来的 route。 + * 处理函数上的 get / post 等路由属性,或语句、变量的初始值、返回值里 Router 上能看出来的 route。 * 请求表和 FastAPI 接口一样:方法、路径、路径参数,再加返回。 * 文件里导入了 axum 才启用。 */ @@ -23,11 +23,10 @@ export const axumRule: LibraryRule = { const route = attributeRoute(block) return route ? card(block, route, context) : undefined } - if (block.kind === 'statement') { - const [route] = routesOn(block.node).filter(item => !handled(block.node, item.handler)) - return route ? routerCard(block.node, route, context) : undefined - } - return undefined + return valueCard(block, context, (value) => { + const [route] = routesOn(value).filter(item => !handled(value, item.handler)) + return route ? routerCard(value, route, context) : undefined + }) }, attributeNames: () => ({ attributes: {}, values: { kind: { 'axum.route': '接口' } } }), } diff --git a/vscode/src/codeReader/rules/rustLibs.test.ts b/vscode/src/codeReader/rules/rustLibs.test.ts index b941e98..99238ee 100644 --- a/vscode/src/codeReader/rules/rustLibs.test.ts +++ b/vscode/src/codeReader/rules/rustLibs.test.ts @@ -161,3 +161,121 @@ describe('serde 与 thiserror', () => { expect(lines).toContain(' token | String | 本模块 | 不输出') }) }) + +describe('函数体里的 sqlx 查询、axum 路由', () => { + const source = [ + 'use axum::Router;', + 'use axum::routing::get;', + 'use sqlx::PgPool;', + '', + 'async fn load(pool: PgPool, id: Uuid, team: String) -> Result<(), sqlx::Error> {', + ' // 按 id 取一行。', + ' let row = sqlx::query!("SELECT name FROM agents WHERE id = $1", id).fetch_one(&pool).await?;', + ' let rows = sqlx::query_as::<_, AgentRow>("SELECT id, name FROM agents WHERE team = $1").bind(team).fetch_all(&pool).await?;', + ' let Some(total) = sqlx::query_scalar!("SELECT count(*) FROM agents").fetch_one(&pool).await? else {', + ' return Ok(());', + ' };', + ' Ok(())', + '}', + '', + 'async fn names(pool: PgPool) -> Result, sqlx::Error> {', + ' sqlx::query!("SELECT name FROM agents").fetch_all(&pool).await', + '}', + '', + 'fn app(state: AppState) -> Router {', + ' let probe = Router::new().route("/health", get(health));', + ' Router::new().route("/agents", get(list)).route("/agents/{id}", get(show)).with_state(state)', + '}', + '', + ].join('\n') + + it('let 的初始值、末尾不带分号的返回值是查询或路由时出库卡片,变量名是第一行「变量」,let … else 的 else 照常是子块', async () => { + const list = (await views(source)).filter(block => block.kind !== 'import') + expect(viewLines(list)).toEqual([ + 'function 5', + ' 名称: load', + ' 异步: 是', + ' 可见性: 本模块', + ' 输入参数: 3 个', + ' pool | PgPool', + ' id | Uuid', + ' team | String', + ' 返回: Result<(), sqlx::Error>', + ' sqlx.query 6', + ' 变量: row', + ' 注释: 按 id 取一行。', + ' SQL: SELECT name FROM agents WHERE id = $1', + ' 参数: $1 = id', + ' 返回类型: —', + ' sqlx.query 8', + ' 变量: rows', + ' SQL: SELECT id, name FROM agents WHERE team = $1', + ' 参数: $1 = team', + ' 返回类型: AgentRow', + ' sqlx.query 9', + ' 变量: Some(total)', + ' SQL: SELECT count(*) FROM agents', + ' 返回类型: —', + ' else 9: 否则', + ' return 10: 返回 Ok(());', + ' return 12: 返回 Ok(())', + 'function 15', + ' 名称: names', + ' 异步: 是', + ' 可见性: 本模块', + ' 输入参数: 1 个', + ' pool | PgPool', + ' 返回: Result, sqlx::Error>', + ' sqlx.query 16', + ' SQL: SELECT name FROM agents', + ' 返回类型: —', + 'function 19', + ' 名称: app', + ' 可见性: 本模块', + ' 输入参数: 1 个', + ' state | AppState', + ' 返回: Router', + ' axum.route 20', + ' 变量: probe', + ' 名称: health', + ' 请求: GET /health', + ' 方法 | GET', + ' 路径 | /health', + ' 返回: —', + ' axum.route 21', + ' 名称: show', + ' 请求: GET /agents/{id}', + ' 方法 | GET', + ' 路径 | /agents/{id}', + ' 返回: —', + ]) + }) + + it('认领的块留着变量、返回的概念;变量名是卡片和「变量」一行的定义', async () => { + const list = await views(source) + const [load, names, app] = list.filter(block => block.kind === 'function') + const [row] = load?.children ?? [] + const at = source.indexOf('row =') + expect(row?.concepts.map(concept => concept.id)).toEqual(['variable', 'sqlx.query']) + expect(row?.defines).toEqual({ startIndex: at, endIndex: at + 'row'.length }) + expect(propertyRow(row, '变量').defines).toEqual({ startIndex: at, endIndex: at + 'row'.length }) + expect(names?.children[0]?.concepts.map(concept => concept.id)).toEqual(['return', 'sqlx.query']) + expect(app?.children[1]?.concepts.map(concept => concept.id)).toEqual(['return', 'axum.route']) + }) + + it('函数写法的 SQL 是拼出来的变量时照翻成一行,每个 .bind 依次是 $1、$2', async () => { + const lines = viewLines(await views([ + 'use sqlx::PgPool;', + 'async fn rename(pool: PgPool, sql: String, id: Uuid, name: String) {', + ' sqlx::query(&sql).bind(id).bind(&name).execute(&pool).await?;', + '}', + '', + ].join('\n'))) + expect(lines.slice(lines.indexOf(' sqlx.query 3'))).toEqual([ + ' sqlx.query 3', + ' SQL: &sql', + ' 参数: $1 = id、$2 = &name', + ' 返回类型: —', + ]) + }) +}) diff --git a/vscode/src/codeReader/rules/rustRows.ts b/vscode/src/codeReader/rules/rustRows.ts index 2da7153..eb3b947 100644 --- a/vscode/src/codeReader/rules/rustRows.ts +++ b/vscode/src/codeReader/rules/rustRows.ts @@ -1,4 +1,4 @@ -import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, Doc, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, Node, PropertyItem, PropertyRow, RuleContext, SourceSpan, TokenRole, TraitBlock } from '../contract' +import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, Doc, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SourceSpan, TokenRole, TraitBlock } from '../contract' import type { RustVisibility } from './rustTree' import { paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows' import { docSummary, isFnItem, isMethod, signatureParams, structFields, variantPayload } from './rustTree' @@ -291,3 +291,29 @@ export function payloadValue(body: Node | undefined, say: RustSay): Inline[] { const parts = variantPayload(body) return parts.length > 0 ? joined(parts.map(part => say.verbatim(part)), plain(', ')) : [hint('—')] } + +/** + * Rust 库规则认函数体里的一块时看它算出的值:语句看整句,变量看初始值,返回块看返回的值,交给 `card` 认成卡片。 + * + * 认领变量时变量名排在卡片第一行「变量」,卡片和这一行的定义范围都是它,同没认领时的变量块; + * `let … else` 的 else、实参里的闭包这些子块不并进卡片,照常是它的子块。 + * + * @param block 要认的那一块 + * @param context 把变量名翻成一行用 + * @param card 从值里认出卡片,认不出是 `undefined` + * @return 认出的卡片;别的种类的块、没写初始值的变量是 `undefined` + */ +export function valueCard(block: CodeBlock, context: RuleContext, card: (value: Node) => RuleCard | undefined): RuleCard | undefined { + if (block.kind === 'statement' || block.kind === 'return') + return card(block.node) + if (block.kind !== 'variable') + return undefined + const [declarator, ...rest] = block.facts.declarators + if (!declarator?.value || rest.length > 0) + return undefined + const found = card(declarator.value) + if (!found) + return undefined + const defines = range(declarator.name) + return { ...found, rows: [{ label: '变量', value: context.translate(declarator.name), defines }, ...found.rows], defines } +} diff --git a/vscode/src/codeReader/rules/sqlx.ts b/vscode/src/codeReader/rules/sqlx.ts index faef4ea..80b8249 100644 --- a/vscode/src/codeReader/rules/sqlx.ts +++ b/vscode/src/codeReader/rules/sqlx.ts @@ -1,13 +1,23 @@ import type { Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract' +import { valueCard } from './rustRows' import { macroInvocation, named, oneLine, stringContent } from './rustTree' /** 查询卡用字符串那种橙。 */ const COLOR: ThemeColor = { dark: '#ce9178', light: '#a31515' } const BADGE: KindBadge = { label: '查询', color: COLOR, icon: 'database' } -const MACROS = new Set(['query', 'query_as', 'query_scalar']) +/** sqlx 发查询的宏和同名的函数。 */ +const QUERIES = new Set(['query', 'query_as', 'query_scalar']) + +/** 一次查询写明的:SQL、依次传给 `$1`、`$2`… 的参数,写明了才有的返回类型。 */ +interface Query { + sql?: Node + params: Node[] + returned?: Node +} /** - * sqlx::query! / query_as! 这类宏。等宽 SQL、参数、返回类型;前面的注释同别的块,是卡片的「注释」一行。 + * sqlx 的查询:`query!` / `query_as!` / `query_scalar!` 这类宏,或 `query` / `query_as` / `query_scalar` 函数连着 `.bind(参数)`。 + * 等宽 SQL、参数、返回类型;前面的注释同别的块,是卡片的「注释」一行。语句、变量的初始值、返回值里有查询都认。 * 文件里导入了 sqlx 才启用。 */ export const sqlxRule: LibraryRule = { @@ -15,23 +25,16 @@ export const sqlxRule: LibraryRule = { label: 'sqlx 查询', languageIds: ['rust'], libraries: ['sqlx'], - claim: (block, context) => { - const macro = block.kind === 'statement' ? findQuery(block.node) : undefined - return macro ? card(macro, context) : undefined - }, + claim: (block, context) => valueCard(block, context, (value) => { + const query = findQuery(value) + return query && card(query, context) + }), attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }), } -function card(macro: Node, context: RuleContext): RuleCard { - const call = macroInvocation(macro) - const args = call?.args ?? [] - const typed = call?.name.split('::').pop() !== 'query' - const sqlNode = args[typed ? 1 : 0] - const sql = stringContent(sqlNode) ?? '' - const params = args.slice(typed ? 2 : 1) - const returned = typed ? args[0] : undefined +function card({ sql, params, returned }: Query, context: RuleContext): RuleCard { const rows: PropertyRow[] = [ - { label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] }, + { label: 'SQL', value: sqlText(sql, context) }, ...(params.length > 0 ? [{ label: '参数', value: [{ text: params.map((param, index) => `$${index + 1} = ${oneLine(param.text)}`).join('、'), role: 'plain' as const }] }] : []), @@ -40,12 +43,19 @@ function card(macro: Node, context: RuleContext): RuleCard { return { kind: 'sqlx.query', badge: BADGE, rows, absorbs: [] } } -function findQuery(node: Node): Node | undefined { - if (node.type === 'macro_invocation') { - const last = node.childForFieldName('macro')?.text.split('::').pop() - if (last && MACROS.has(last)) - return node - } +/** 写成字符串的 SQL 是去掉引号的原文;拼出来再传进来的(`&sql`)照翻成一行。 */ +function sqlText(sql: Node | undefined, context: RuleContext): Inline[] { + const text = stringContent(sql) + if (sql && text === undefined) + return context.translate(sql) + return [{ text: text ?? '', role: 'string', ...(sql && { offset: sql.startIndex }) }] +} + +/** 外层先认,找到的第一个查询。 */ +function findQuery(node: Node): Query | undefined { + const query = macroQuery(node) ?? callQuery(node) + if (query) + return query for (const child of named(node)) { const found = findQuery(child) if (found) @@ -54,6 +64,41 @@ function findQuery(node: Node): Node | undefined { return undefined } +/** `query!(SQL, 参数…)`、`query_scalar!(SQL, 参数…)`,`query_as!` 在 SQL 前面多一个返回类型。 */ +function macroQuery(node: Node): Query | undefined { + const call = macroInvocation(node) + const name = call?.name.split('::').pop() + if (!call || !name || !QUERIES.has(name)) + return undefined + const typed = name === 'query_as' + const returned = typed ? call.args[0] : undefined + return { sql: call.args[typed ? 1 : 0], params: call.args.slice(typed ? 2 : 1), ...(returned && { returned }) } +} + +/** + * `query(SQL)`、`query_as::<_, 返回类型>(SQL)` 这类函数,后面连着的每个 `.bind(参数)` 依次是一个参数。 + * 尖括号里第一个是数据库、第二个是返回类型;`query` 和没写尖括号的没有返回类型。 + */ +function callQuery(node: Node): Query | undefined { + if (node.type !== 'call_expression') + return undefined + const callee = node.childForFieldName('function') + const [first] = named(node.childForFieldName('arguments')) + if (callee?.type === 'field_expression' && callee.childForFieldName('field')?.text === 'bind') { + const inner = callee.childForFieldName('value') + const query = inner ? callQuery(inner) : undefined + return query && { ...query, params: first ? [...query.params, first] : query.params } + } + const generic = callee?.type === 'generic_function' ? callee : undefined + const path = generic ? generic.childForFieldName('function') : callee + const name = path?.type === 'scoped_identifier' ? path.childForFieldName('name')?.text : path?.type === 'identifier' ? path.text : undefined + if (!name || !QUERIES.has(name)) + return undefined + const types = named(generic?.childForFieldName('type_arguments')).filter(type => type.type !== 'lifetime') + const returned = name === 'query' ? undefined : types[1] + return { sql: first, params: [], ...(returned && { returned }) } +} + function hint(text: string): Inline { return { text, role: 'hint' } }