diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 0f3bf2e..91d35cb 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -987,7 +987,7 @@ export interface LibraryRule { label: string // 'SQLAlchemy 数据表' languageIds: string[] /** - * 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。 + * 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」「它::」开头(它的子模块;Rust 的路径用 ::)才启用。 * 宿主按语言插件给的导入判断,规则不用自己读导入语句。空数组是不看导入、languageIds 里这几种语言的源码都启用 * (React 组件:新的 JSX 写法不用导入 react,TSX / JSX 文件里就是 React 组件)。 */ diff --git a/vscode/src/codeReader/fixtures/rustLibs.rs b/vscode/src/codeReader/fixtures/rustLibs.rs index 297857f..d99188d 100644 --- a/vscode/src/codeReader/fixtures/rustLibs.rs +++ b/vscode/src/codeReader/fixtures/rustLibs.rs @@ -23,3 +23,26 @@ pub enum AgentError { #[error("上游错误")] Upstream(#[from] Other), } + +use axum::routing::get; + +/// 按 id 取一个代理。 +#[get("/agents/{id}")] +async fn get_agent(Path(id): Path) -> Result, StatusCode> { + todo!() +} + +let app = Router::new().route("/health", get(health)); + +use sqlx::query_as; + +// 按 id 取一行。 +let row = sqlx::query_as!(AgentRow, "SELECT id, name FROM agents WHERE id = $1", id); + +use tauri::State; + +/// 让窗口把这一行改名。 +#[tauri::command] +async fn rename_agent(state: State, name: String) -> Result { + todo!() +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 5f119a3..e52203e 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -28,6 +28,7 @@ import { FileNotes } from './notes/fileNotes' import { CodeQa } from './qa/codeQa' import { CodeReaderRegistry } from './registry' import { ReviewMode } from './review/reviewMode' +import { axumRule } from './rules/axum' import { celeryRule } from './rules/celery' import { dataclassRule } from './rules/dataclass' import { enumRule } from './rules/enum' @@ -37,6 +38,8 @@ import { reactRule } from './rules/react' import { rustImplRule } from './rules/rustImpl' import { serdeRule } from './rules/serde' import { sqlalchemyRule } from './rules/sqlalchemy' +import { sqlxRule } from './rules/sqlx' +import { tauriRule } from './rules/tauri' import { thiserrorRule } from './rules/thiserror' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { bashZhTranslator } from './translators/bashZh' @@ -169,6 +172,9 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLibraryRule(rustImplRule), api.registerLibraryRule(serdeRule), api.registerLibraryRule(thiserrorRule), + api.registerLibraryRule(axumRule), + api.registerLibraryRule(sqlxRule), + api.registerLibraryRule(tauriRule), ) return api } diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 72d01b7..e940a07 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -66,6 +66,7 @@ describe('库规则:启用', () => { expect(fromLibrary('sqlalchemy', 'sqlalchemy')).toBe(true) expect(fromLibrary('sqlalchemy.orm', 'sqlalchemy')).toBe(true) expect(fromLibrary('drizzle-orm/pg-core', 'drizzle-orm')).toBe(true) + expect(fromLibrary('axum::routing', 'axum')).toBe(true) expect(fromLibrary('sqlalchemy_utils', 'sqlalchemy')).toBe(false) expect(fromLibrary('.sqlalchemy', 'sqlalchemy')).toBe(false) }) diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 5ce1716..65552b0 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -23,7 +23,7 @@ function moduleName(source: Node): string { /** 模块是这个库或它的子模块:等于库名,或以「库名.」(Python 的点分模块名)「库名/」(JavaScript 的子路径)开头。 */ export function fromLibrary(module: string, library: string): boolean { - return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`) + return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`) || module.startsWith(`${library}::`) } /** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库,或规则不看导入(libraries 是空的)。 */ diff --git a/vscode/src/codeReader/rules/axum.ts b/vscode/src/codeReader/rules/axum.ts new file mode 100644 index 0000000..b3cd558 --- /dev/null +++ b/vscode/src/codeReader/rules/axum.ts @@ -0,0 +1,227 @@ +import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, ThemeColor } from '../contract' +import { attributeName, docSentence, oneLine, returnText, stringContent } from '../languages/rust' +import { named } from '../languages/shared' + +/** 和 FastAPI 接口同一个蓝。 */ +const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' } +const BADGE: KindBadge = { label: '接口', color: COLOR, icon: 'globe' } +const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']) + +/** + * 处理函数上的 get / post 等路由属性,或 Router 上能看出来的 route。 + * 请求表和 FastAPI 接口一样:方法、路径、路径参数,再加返回。 + * 文件里导入了 axum 才启用。 + */ +export const axumRule: LibraryRule = { + id: 'axum', + label: 'axum 接口', + languageIds: ['rust'], + libraries: ['axum'], + claim: (block) => { + if (block.kind === 'function') { + const route = attributeRoute(block) + return route ? card(block, route) : undefined + } + if (block.kind === 'statement') { + const [route] = routesOn(block.node).filter(item => !handled(block.node, item.handler)) + return route ? routerCard(block.node, route) : undefined + } + return undefined + }, + attributeNames: () => ({ attributes: {}, values: { kind: { 'axum.route': '接口' } } }), +} + +interface Route { + method: string + path: string + handler?: string +} + +function attributeRoute(block: FunctionBlock): Route | undefined { + for (const attr of block.facts.decorators) { + const method = attributeName(attr)?.split('::').pop() + if (!method || !METHODS.has(method)) + continue + const path = attributeString(attr) + if (path) + return { method: method.toUpperCase(), path } + } + return undefined +} + +function card(block: FunctionBlock, route: Route): RuleCard { + const doc = docSentence(block.facts.doc) + const params = pathParams(route.path, block.node) + const rows: PropertyRow[] = [ + nameRow(block.facts.name), + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + ...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []), + requestRow(route, params), + { label: '返回', value: [plain(returnText(block.node))] }, + ] + return { + kind: 'axum.route', + badge: BADGE, + rows, + ...(block.facts.name && { defines: range(block.facts.name) }), + absorbs: [], + } +} + +/** 同一文件里的处理函数已经标了路由属性时,Router 上这句不再单独成卡。 */ +function handled(node: Node, handler: string | undefined): boolean { + if (!handler) + return false + const root = node.tree.rootNode + for (const item of named(root)) { + if (item.type !== 'function_item' || item.childForFieldName('name')?.text !== handler) + continue + return itemAttributesHasRoute(item) + } + return false +} + +function routerCard(node: Node, route: Route & { handlerNode?: Node }): RuleCard { + const fn = route.handler ? functionNamed(node, route.handler) : undefined + const params = fn ? pathParams(route.path, fn) : [] + const doc = fn ? docSentence(leadingDoc(fn)) : undefined + return { + kind: 'axum.route', + badge: BADGE, + rows: [ + { label: '名称', value: route.handler ? [plain(route.handler)] : [hint('路由')] }, + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + requestRow(route, params), + { label: '返回', value: [plain(fn ? returnText(fn) : '—')] }, + ], + absorbs: [], + } +} + +function requestRow(route: Route, params: string[]): PropertyRow { + const items: PropertyItem[] = [ + { cells: [[plain('方法')], [keyword(route.method)]] }, + { cells: [[plain('路径')], [{ text: route.path, role: 'string' }]] }, + ...(params.length > 0 ? [{ cells: [[plain('路径参数')], params.map(text => plain(text)).flatMap((part, index) => index === 0 ? [part] : [plain(', '), part])] }] : []), + ] + return { + label: '请求', + value: [keyword(route.method), plain(` ${route.path}`)], + columns: ['项', '值'], + items, + } +} + +function routesOn(node: Node, found: (Route & { handlerNode?: Node })[] = []): (Route & { handlerNode?: Node })[] { + if (node.type === 'call_expression') { + const callee = node.childForFieldName('function') + const field = callee?.type === 'field_expression' ? callee.childForFieldName('field')?.text : undefined + if (field === 'route') { + const args = named(node.childForFieldName('arguments')) + const pathNode = args.find(arg => arg.type === 'string_literal') + const call = args.find(arg => arg.type === 'call_expression') + const method = call?.childForFieldName('function')?.text.split('::').pop() + const handlerNode = named(call?.childForFieldName('arguments'))[0] + const path = stringContent(pathNode) + if (path && method && METHODS.has(method)) { + found.push({ + method: method.toUpperCase(), + path, + ...(handlerNode?.type === 'identifier' && { handler: handlerNode.text, handlerNode }), + }) + } + } + } + for (const child of named(node)) + routesOn(child, found) + return found +} + +/** 路径里的 {名字} 对上参数。Path(id): Path 写成 id: Uuid。 */ +function pathParams(path: string, fn: Node): string[] { + const names = [...path.matchAll(/\{(\w+)\}/g)].map(match => match[1] ?? '') + return names.flatMap((name) => { + const param = parameterNamed(fn, name) + if (!param) + return [`${name}`] + const type = innerType(param.childForFieldName('type')) + return [type ? `${name}: ${type}` : name] + }) +} + +function parameterNamed(fn: Node, name: string): Node | undefined { + const list = fn.childForFieldName('parameters') + return named(list).find((param) => { + if (param.type !== 'parameter' && param.type !== 'self_parameter') + return false + const pattern = param.childForFieldName('pattern') ?? param + return namedDeep(pattern).some(node => (node.type === 'identifier' || node.type === 'self') && node.text === name) + }) +} + +function innerType(type: Node | null): string | undefined { + if (!type) + return undefined + if (type.type === 'generic_type' && type.childForFieldName('type')?.text === 'Path') { + const arg = named(type.childForFieldName('type_arguments'))[0] + if (arg) + return oneLine(arg.text) + } + return oneLine(type.text) +} + +function itemAttributesHasRoute(fn: Node): boolean { + let prev = fn.previousNamedSibling + while (prev?.type === 'attribute_item') { + const method = attributeName(prev)?.split('::').pop() + if (method && METHODS.has(method)) + return true + prev = prev.previousNamedSibling + } + return false +} + +function functionNamed(node: Node, name: string): Node | undefined { + return named(node.tree.rootNode).find(item => item.type === 'function_item' && item.childForFieldName('name')?.text === name) +} + +function leadingDoc(fn: Node): Node | undefined { + let prev = fn.previousNamedSibling + while (prev?.type === 'attribute_item') + prev = prev.previousNamedSibling + return prev && (prev.type === 'line_comment' || prev.type === 'block_comment') ? prev : undefined +} + +function attributeString(attributeItem: Node): string | undefined { + const attribute = named(attributeItem).find(child => child.type === 'attribute') + const tree = attribute?.children.find(child => child?.type === 'token_tree') + return stringContent(tree?.children.find(child => child?.type === 'string_literal') ?? undefined) +} + +function namedDeep(node: Node): Node[] { + return [node, ...named(node).flatMap(namedDeep)] +} + +function nameRow(name: Node | undefined): PropertyRow { + return { + label: '名称', + value: name ? [{ text: name.text, role: 'function', offset: name.startIndex }] : [hint('匿名')], + ...(name && { defines: range(name) }), + } +} + +function keyword(text: string): Inline { + return { text, role: 'keyword' } +} + +function plain(text: string): Inline { + return { text, role: 'plain' } +} + +function hint(text: string): Inline { + return { text, role: 'hint' } +} + +function range(node: Node): { startIndex: number, endIndex: number } { + return { startIndex: node.startIndex, endIndex: node.endIndex } +} diff --git a/vscode/src/codeReader/rules/rustLibs.test.ts b/vscode/src/codeReader/rules/rustLibs.test.ts index e8f0398..199b627 100644 --- a/vscode/src/codeReader/rules/rustLibs.test.ts +++ b/vscode/src/codeReader/rules/rustLibs.test.ts @@ -1,15 +1,19 @@ import type { BlockView } from '../blockView' import { describe, expect, it } from 'vitest' import { toBlockViews } from '../blockView' +import { blockMarks } from '../diff/viewDiff' import { createRustAdapter } from '../languages/rust' import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport' import { rustZhTranslator } from '../translators/rustZh' +import { axumRule } from './axum' import { rustImplRule } from './rustImpl' import { serdeRule } from './serde' +import { sqlxRule } from './sqlx' +import { tauriRule } from './tauri' import { thiserrorRule } from './thiserror' const adapter = createRustAdapter(WASM_DIR) -const rules = [rustImplRule, serdeRule, thiserrorRule] +const rules = [rustImplRule, serdeRule, thiserrorRule, axumRule, sqlxRule, tauriRule] function views(source: string): Promise { return withBlocks(adapter, 'rust', source, blocks => @@ -48,6 +52,46 @@ describe('serde 与 thiserror', () => { ' 变体: 2 个', ' NotFound | 这个 id 没有代理 | agent {id} 不存在', ' Upstream | 调用上游失败 | 上游错误 ⟦透传来源|⟧', + 'import 27', + ' 来源: axum::routing', + ' 导入项: 1 个', + ' get | —', + 'axum.route 29', + ' 名称: get_agent', + ' 说明: 按 id 取一个代理。', + ' 异步: 是', + ' 请求: GET /agents/{id}', + ' 方法 | GET', + ' 路径 | /agents/{id}', + ' 路径参数 | id: Uuid', + ' 返回: Result, StatusCode>', + 'axum.route 35', + ' 名称: health', + ' 请求: GET /health', + ' 方法 | GET', + ' 路径 | /health', + ' 返回: —', + 'import 37', + ' 来源: sqlx', + ' 导入项: 1 个', + ' query_as | —', + 'sqlx.query 40', + ' 说明: 按 id 取一行。', + ' SQL: SELECT id, name FROM agents WHERE id = $1', + ' 参数: $1 = id', + ' 返回类型: AgentRow', + 'import 42', + ' 来源: tauri', + ' 导入项: 1 个', + ' State | —', + 'tauri.command 44', + ' 名称: rename_agent ⟦桌面命令|⟧', + ' 说明: 让窗口把这一行改名。', + ' 异步: 是', + ' 参数: 2 个', + ' state | State | 窗口状态', + ' name | String | 调用方传入', + ' 返回: Result', ]) }) @@ -57,6 +101,23 @@ describe('serde 与 thiserror', () => { expect(card?.kind).toBe('class') }) + it('改 axum 的路径,路径那一格标成改过', async () => { + const before = 'use axum::routing::get;\n#[get("/agents/{id}")]\nasync fn get_agent(Path(id): Path) -> Result, StatusCode> {}\n' + const after = before.replace('/agents/{id}', '/people/{id}') + const oldCard = (await views(before)).find(block => block.kind === 'axum.route') + const newCard = (await views(after)).find(block => block.kind === 'axum.route') + const marks = oldCard && newCard ? blockMarks(oldCard, newCard) : undefined + const row = newCard && 'properties' in newCard ? newCard.properties.findIndex(item => item.label === '请求') : -1 + const parts = marks?.rows?.changed[row]?.items?.changed[1]?.cells?.[1] ?? [] + const text = (change: 'add' | 'del') => parts + .filter(part => part.change === change) + .flatMap(part => part.segments) + .map(segment => 'text' in segment ? segment.text : '') + .join('') + expect(text('del')).toContain('/agents/{id}') + expect(text('add')).toContain('/people/{id}') + }) + it('skip_serializing 也是不输出', async () => { const source = 'use serde::Serialize;\n#[derive(Serialize)]\nstruct Row {\n #[serde(skip_serializing)]\n token: String,\n}\n' const lines = viewLines(await views(source)) diff --git a/vscode/src/codeReader/rules/sqlx.ts b/vscode/src/codeReader/rules/sqlx.ts new file mode 100644 index 0000000..9d93287 --- /dev/null +++ b/vscode/src/codeReader/rules/sqlx.ts @@ -0,0 +1,81 @@ +import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, ThemeColor } from '../contract' +import { commentPlain, firstSentence, macroInvocation, oneLine, stringContent } from '../languages/rust' +import { named } from '../languages/shared' + +/** 查询卡用字符串那种橙。 */ +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::query! / query_as! 这类宏。一句说明、等宽 SQL、参数、返回类型。 + * 文件里导入了 sqlx 才启用。 + */ +export const sqlxRule: LibraryRule = { + id: 'sqlx', + label: 'sqlx 查询', + languageIds: ['rust'], + libraries: ['sqlx'], + claim: (block) => { + if (block.kind === 'comment') + return nextQuery(block.node) ? { omit: true } : undefined + const macro = block.kind === 'statement' ? findQuery(block.node) : undefined + return macro ? card(block, macro) : undefined + }, + attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }), +} + +function card(block: CodeBlock, macro: Node): 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 + const note = precedingNote(block.node) + const rows: PropertyRow[] = [ + ...(note ? [{ label: '说明', value: { markdown: note } }] : []), + { label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] }, + ...(params.length > 0 + ? [{ label: '参数', value: [{ text: params.map((param, index) => `$${index + 1} = ${oneLine(param.text)}`).join('、'), role: 'plain' as const }] }] + : []), + { label: '返回类型', value: returned ? [{ text: oneLine(returned.text), role: 'type', offset: returned.startIndex }] : [hint('—')] }, + ] + 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 + } + for (const child of named(node)) { + const found = findQuery(child) + if (found) + return found + } + return undefined +} + +function nextQuery(comment: Node): Node | undefined { + let next = comment.nextNamedSibling + while (next?.type === 'attribute_item') + next = next.nextNamedSibling + return next ? findQuery(next) : undefined +} + +function precedingNote(node: Node): string | undefined { + let prev = node.previousNamedSibling + while (prev?.type === 'attribute_item') + prev = prev.previousNamedSibling + if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment')) + return undefined + const sentence = firstSentence(commentPlain(prev)) + return sentence || undefined +} + +function hint(text: string): Inline { + return { text, role: 'hint' } +} diff --git a/vscode/src/codeReader/rules/tauri.ts b/vscode/src/codeReader/rules/tauri.ts new file mode 100644 index 0000000..7b2363f --- /dev/null +++ b/vscode/src/codeReader/rules/tauri.ts @@ -0,0 +1,98 @@ +import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract' +import { attributeName, docSentence, oneLine, returnText } from '../languages/rust' + +/** 桌面命令的边框用这个橙。 */ +const COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' } +const BADGE: KindBadge = { label: '桌面命令', color: COLOR, icon: 'square-function' } + +/** + * #[tauri::command] 的函数。名称后标「桌面命令」。 + * 参数多一列来源:State<…> 是窗口状态,其余是调用方传入。 + * 文件里导入了 tauri 才启用。 + */ +export const tauriRule: LibraryRule = { + id: 'tauri', + label: 'tauri 桌面命令', + languageIds: ['rust'], + libraries: ['tauri'], + claim: block => block.kind === 'function' && commanded(block) ? card(block) : undefined, + attributeNames: () => ({ attributes: {}, values: { kind: { 'tauri.command': '桌面命令' } } }), +} + +function commanded(block: FunctionBlock): boolean { + return block.facts.decorators.some(attr => attributeName(attr) === 'tauri::command') +} + +function card(block: FunctionBlock): RuleCard { + const doc = docSentence(block.facts.doc) + const rows: PropertyRow[] = [ + { + label: '名称', + value: block.facts.name + ? [{ text: block.facts.name.text, role: 'function', offset: block.facts.name.startIndex }, plain(' '), tag('桌面命令')] + : [tag('桌面命令')], + ...(block.facts.name && { defines: range(block.facts.name) }), + }, + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + ...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []), + ...(block.facts.parameters.length > 0 + ? [{ + label: '参数', + value: [hint(`${block.facts.parameters.length} 个`)], + columns: ['名称', '类型', '来源'], + items: block.facts.parameters.map(param => ({ + cells: [ + [variable(param.name)], + [param.type ? typed(param.type) : hint('—')], + [hint(isWindowState(param.type) ? '窗口状态' : '调用方传入')], + ], + defines: range(param.name), + })), + }] + : []), + { label: '返回', value: [plain(returnText(block.node))] }, + ] + return { + kind: 'tauri.command', + badge: BADGE, + rows, + ...(block.facts.name && { defines: range(block.facts.name) }), + absorbs: [], + } +} + +function isWindowState(type: Node | undefined): boolean { + if (!type) + return false + if (type.type === 'generic_type') + return type.childForFieldName('type')?.text === 'State' + return type.text.startsWith('State<') +} + +function variable(node: Node): Inline { + return { text: node.text, role: 'variable', offset: node.startIndex } +} + +function typed(node: Node): Inline { + return { text: oneLine(node.text), role: 'type', offset: node.startIndex } +} + +function tag(label: string): SegmentGroup { + return { style: 'tag', label, segments: [], color: COLOR } +} + +function plain(text: string): Inline { + return { text, role: 'plain' } +} + +function keyword(text: string): Inline { + return { text, role: 'keyword' } +} + +function hint(text: string): Inline { + return { text, role: 'hint' } +} + +function range(node: Node): { startIndex: number, endIndex: number } { + return { startIndex: node.startIndex, endIndex: node.endIndex } +}