✨ feat(vscode): 函数体里 let 的初始值、返回值也出 sqlx 查询和 axum 路由卡片

Rust 库规则共用 valueCard:语句看整句、let 看初始值、返回块看返回的值;认领 let 时第一行「变量」是变量名,也是卡片的定义;sqlx 认 query / query_as / query_scalar 函数连着 .bind 的写法,query_scalar! 不再把 SQL 当返回类型
This commit is contained in:
cruldra committed 2026-10-15 20:08:51 +11:00
1 parent 324df3d5bd
commit 95559b160b
4 files changed
+217 -29

No files matched your search

+6 -7
View File
@@ -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': '接口' } } }),
}
@@ -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<Vec<Record>, 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<Vec<Record>, 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',
' 返回类型: —',
])
})
})
+27 -1
View File
@@ -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 }
}
+66 -21
View File
@@ -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' }
}