diff --git a/vscode/src/codeReader/fixtures/rustLibs.rs b/vscode/src/codeReader/fixtures/rustLibs.rs new file mode 100644 index 0000000..297857f --- /dev/null +++ b/vscode/src/codeReader/fixtures/rustLibs.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +/// 桌面端列表里的一行代理。 +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentRow { + #[serde(rename = "agent_id")] + pub id: Uuid, + pub name: String, + #[serde(skip)] + secret: String, +} + +use thiserror::Error; + +/// 读写代理时出的错。 +#[derive(Error, Debug)] +pub enum AgentError { + /// 这个 id 没有代理 + #[error("agent {id} 不存在")] + NotFound { id: Uuid }, + /// 调用上游失败 + #[error("上游错误")] + Upstream(#[from] Other), +} diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 4e1bdfe..5f119a3 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -35,7 +35,9 @@ import { fastapiRule } from './rules/fastapi' import { pydanticRule } from './rules/pydantic' import { reactRule } from './rules/react' import { rustImplRule } from './rules/rustImpl' +import { serdeRule } from './rules/serde' import { sqlalchemyRule } from './rules/sqlalchemy' +import { thiserrorRule } from './rules/thiserror' import { bundledWasmDir, TreeSitterRuntime } from './runtime' import { bashZhTranslator } from './translators/bashZh' import { dockerfileZhTranslator } from './translators/dockerfileZh' @@ -165,6 +167,8 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { api.registerLibraryRule(fastapiRule), api.registerLibraryRule(reactRule), api.registerLibraryRule(rustImplRule), + api.registerLibraryRule(serdeRule), + api.registerLibraryRule(thiserrorRule), ) return api } diff --git a/vscode/src/codeReader/languages/rust.ts b/vscode/src/codeReader/languages/rust.ts index 8d84367..dc5dc52 100644 --- a/vscode/src/codeReader/languages/rust.ts +++ b/vscode/src/codeReader/languages/rust.ts @@ -134,6 +134,8 @@ export function deriveNames(node: Node): string[] { for (const name of named(tree)) { if (name.type === 'identifier') names.push(name.text) + else if (name.type === 'scoped_identifier') + names.push(name.childForFieldName('name')?.text ?? name.text) } } return names diff --git a/vscode/src/codeReader/rules/rustLibs.test.ts b/vscode/src/codeReader/rules/rustLibs.test.ts new file mode 100644 index 0000000..e8f0398 --- /dev/null +++ b/vscode/src/codeReader/rules/rustLibs.test.ts @@ -0,0 +1,65 @@ +import type { BlockView } from '../blockView' +import { describe, expect, it } from 'vitest' +import { toBlockViews } from '../blockView' +import { createRustAdapter } from '../languages/rust' +import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport' +import { rustZhTranslator } from '../translators/rustZh' +import { rustImplRule } from './rustImpl' +import { serdeRule } from './serde' +import { thiserrorRule } from './thiserror' + +const adapter = createRustAdapter(WASM_DIR) +const rules = [rustImplRule, serdeRule, thiserrorRule] + +function views(source: string): Promise { + return withBlocks(adapter, 'rust', source, blocks => + toBlockViews(blocks, { source, language: adapter, markers: [] }, rustZhTranslator, { + rules, + report: (message) => { + throw new Error(message) + }, + })) +} + +describe('serde 与 thiserror', () => { + it('可序列化的结构体、错误枚举', async () => { + expect(viewLines(await views(readFixture('rustLibs.rs')))).toEqual([ + 'import 1', + ' 来源: serde', + ' 导入项: 2 个', + ' Deserialize | —', + ' Serialize | —', + 'serde.struct 3', + ' 名称: AgentRow ⟦可序列化|⟧', + ' 说明: 桌面端列表里的一行代理。', + ' 可见性: 公开', + ' 选项: 字段名改驼峰', + ' 字段: 3 个', + ' id | Uuid | 公开 | 改名 agent_id', + ' name | String | 公开 | —', + ' secret | String | 本模块 | 不输出', + 'import 14', + ' 来源: thiserror', + ' 导入项: 1 个', + ' Error | —', + 'thiserror.error 16', + ' 名称: AgentError', + ' 说明: 读写代理时出的错。', + ' 变体: 2 个', + ' NotFound | 这个 id 没有代理 | agent {id} 不存在', + ' Upstream | 调用上游失败 | 上游错误 ⟦透传来源|⟧', + ]) + }) + + it('没导入 serde 时,带 derive 的结构体仍是结构体', async () => { + const source = '#[derive(Serialize)]\npub struct AgentRow {\n name: String,\n}\n' + const [card] = await views(source) + expect(card?.kind).toBe('class') + }) + + 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)) + expect(lines).toContain(' token | String | 本模块 | 不输出') + }) +}) diff --git a/vscode/src/codeReader/rules/serde.ts b/vscode/src/codeReader/rules/serde.ts new file mode 100644 index 0000000..17460bf --- /dev/null +++ b/vscode/src/codeReader/rules/serde.ts @@ -0,0 +1,145 @@ +import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor, TokenRole } from '../contract' +import type { rustVisibility } from '../languages/rust' +import { attributeName, deriveNames, docSentence, itemAttributes, leadingAttributes, oneLine, stringContent, structFields } from '../languages/rust' +import { named } from '../languages/shared' +import { visibilityWord } from '../rustRows' + +/** 和结构体同一个青,序列化只是名称后的标签,不另换边框色。 */ +const COLOR: ThemeColor = { dark: '#4ec9b0', light: '#267f99' } +const BADGE: KindBadge = { label: '可序列化', color: COLOR, icon: 'braces' } +const MARKS = new Set(['Serialize', 'Deserialize']) + +/** + * 带 Serialize / Deserialize 的结构体。字段多一列「序列化」。 + * 文件里导入了 serde 才启用。 + */ +export const serdeRule: LibraryRule = { + id: 'serde', + label: 'serde 序列化', + languageIds: ['rust'], + libraries: ['serde'], + claim: block => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block) : undefined, + attributeNames: () => ({ attributes: {}, values: { kind: { 'serde.struct': '可序列化' } } }), +} + +function marked(block: ClassBlock): boolean { + return deriveNames(block.node).some(name => MARKS.has(name)) +} + +function card(block: ClassBlock): RuleCard { + const fields = structFields(block.node) + const camel = renameAll(block.node) === 'camelCase' + const doc = docSentence(block.facts.doc) + const rows: PropertyRow[] = [ + { + label: '名称', + value: block.facts.name ? [...copied(block.facts.name, 'type'), plain(' '), tag('可序列化')] : [tag('可序列化')], + ...(block.facts.name && { defines: range(block.facts.name) }), + }, + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + { label: '可见性', value: [{ text: visibilityWord(block), role: 'keyword' as const }] }, + ...(camel ? [{ label: '选项', value: [keyword('字段名改驼峰')] }] : []), + ...(fields.length > 0 + ? [{ + label: '字段', + value: [hint(`${fields.length} 个`)], + columns: ['字段', '类型', '可见性', '序列化'], + items: fields.map(field => ({ + cells: [ + copied(field.name, 'variable'), + copied(field.type, 'type'), + [keyword(visibilityWordOf(field.visibility))], + [serdeCell(field.name)], + ], + defines: range(field.name), + })), + }] + : []), + ] + return { + kind: 'serde.struct', + badge: BADGE, + rows, + ...(block.facts.name && { defines: range(block.facts.name) }), + absorbs: [], + } +} + +function visibilityWordOf(visibility: ReturnType): string { + switch (visibility) { + case 'public': return '公开' + case 'super': return '父模块' + case 'crate': return '本 crate' + default: return '本模块' + } +} + +function serdeCell(fieldName: Node): Inline { + const field = fieldName.parent + if (!field) + return hint('—') + let rename: string | undefined + let skip = false + for (const attr of leadingAttributes(field)) { + if (attributeName(attr) !== 'serde') + continue + for (const piece of named(tokenTree(attr))) { + if (piece.type === 'identifier' && (piece.text === 'skip' || piece.text === 'skip_serializing')) + skip = true + if (piece.type === 'identifier' && piece.text === 'rename') + rename = stringContent(firstString(tokenTree(attr))) ?? rename + } + } + if (skip) + return hint('不输出') + if (rename) + return { text: `改名 ${rename}`, role: 'string' } + return hint('—') +} + +function renameAll(node: Node): string | undefined { + for (const attr of itemAttributes(node)) { + if (attributeName(attr) !== 'serde') + continue + const names = named(tokenTree(attr)) + if (!names.some(piece => piece.type === 'identifier' && piece.text === 'rename_all')) + continue + const value = stringContent(firstString(tokenTree(attr))) + if (value) + return value + } + return undefined +} + +function tokenTree(attributeItem: Node): Node | undefined { + const attribute = named(attributeItem).find(child => child.type === 'attribute') + return attribute?.children.find(child => child?.type === 'token_tree') ?? undefined +} + +function firstString(tree: Node | undefined): Node | undefined { + return tree?.children.find(child => child?.type === 'string_literal') ?? undefined +} + +function copied(node: Node, role: TokenRole): Inline[] { + return [{ text: oneLine(node.text), role, 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 } +} diff --git a/vscode/src/codeReader/rules/thiserror.ts b/vscode/src/codeReader/rules/thiserror.ts new file mode 100644 index 0000000..7d41961 --- /dev/null +++ b/vscode/src/codeReader/rules/thiserror.ts @@ -0,0 +1,113 @@ +import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract' +import { attributeName, commentPlain, deriveNames, docSentence, firstSentence, stringContent, variantPayload } from '../languages/rust' +import { named } from '../languages/shared' + +/** 错误卡用异常那种红。 */ +const COLOR: ThemeColor = { dark: '#f14c4c', light: '#cd3131' } +const BADGE: KindBadge = { label: '错误', color: COLOR, icon: 'triangle-alert' } + +/** + * derive Error 的枚举。变体表是:变体、何时、展示。 + * 文件里导入了 thiserror 才启用。 + */ +export const thiserrorRule: LibraryRule = { + id: 'thiserror', + label: 'thiserror 错误', + languageIds: ['rust'], + libraries: ['thiserror'], + claim: block => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block) : undefined, + attributeNames: () => ({ attributes: {}, values: { kind: { 'thiserror.error': '错误' } } }), +} + +function card(block: EnumBlock): RuleCard { + const doc = docSentence(block.facts.doc) + const rows: PropertyRow[] = [ + { + label: '名称', + value: [{ text: block.facts.name.text, role: 'type', offset: block.facts.name.startIndex }], + defines: range(block.facts.name), + }, + ...(doc ? [{ label: '说明', value: { markdown: doc } }] : []), + { + label: '变体', + value: [hint(`${block.facts.members.length} 个`)], + columns: ['变体', '何时', '展示'], + items: block.facts.members.map((member) => { + const variant = member.name.parent?.type === 'enum_variant' ? member.name.parent : member.name + const display = errorText(variant) + const cells: Inline[][] = [ + [{ text: member.name.text, role: 'type', offset: member.name.startIndex }], + [hint(whenText(variant) ?? '—')], + display + ? [plain(display), ...(passesSource(variant) ? [plain(' '), tag('透传来源')] : [])] + : [hint(variantPayload(member.value) ?? '—')], + ] + return { cells, defines: range(member.name) } + }), + }, + ] + return { + kind: 'thiserror.error', + badge: BADGE, + rows, + defines: range(block.facts.name), + absorbs: [], + } +} + +/** 变体上面的文档注释,属性可以隔在中间。第一句是「何时」。 */ +function whenText(variant: Node): string | undefined { + const docs: Node[] = [] + let prev = variant.previousNamedSibling + while (prev?.type === 'attribute_item') + prev = prev.previousNamedSibling + while (prev && (prev.type === 'line_comment' || prev.type === 'block_comment')) { + docs.unshift(prev) + prev = prev.previousNamedSibling + } + if (docs.length === 0) + return undefined + const sentence = firstSentence(docs.map(commentPlain).join(' ')) + return sentence || undefined +} + +function errorText(variant: Node): string | undefined { + let prev = variant.previousNamedSibling + while (prev?.type === 'attribute_item') { + if (attributeName(prev) === 'error') { + const tree = tokenTree(prev) + const literal = tree?.children.find(child => child?.type === 'string_literal') + const text = stringContent(literal ?? undefined) + if (text) + return text + } + prev = prev.previousNamedSibling + } + return undefined +} + +function passesSource(variant: Node): boolean { + const body = variant.childForFieldName('body') + return named(body).some(child => child.type === 'attribute_item' && (attributeName(child) === 'from' || attributeName(child) === 'source')) +} + +function tokenTree(attributeItem: Node): Node | undefined { + const attribute = named(attributeItem).find(child => child.type === 'attribute') + return attribute?.children.find(child => child?.type === 'token_tree') ?? undefined +} + +function tag(label: string): SegmentGroup { + return { style: 'tag', label, segments: [], color: COLOR } +} + +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 } +}