diff --git a/vscode/src/codeReader/attributes.ts b/vscode/src/codeReader/attributes.ts index 97ae3f0..98fb7c5 100644 --- a/vscode/src/codeReader/attributes.ts +++ b/vscode/src/codeReader/attributes.ts @@ -30,6 +30,7 @@ function factAttributes(block: CodeBlock): Partial { } }) - it.each(SAMPLES)('%#: 函数、类、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { + it.each(SAMPLES)('%#: 函数、类、接口、导入、常量、变量块只带属性表,其余块只带一行译文', async (adapter, languageId, translator, fixture) => { for (const view of flatten(await views(adapter, languageId, translator, readFixture(fixture)))) { expect('properties' in view, view.id).toBe(PROPERTY_KINDS.has(view.kind)) expect('translation' in view, view.id).toBe(!PROPERTY_KINDS.has(view.kind)) @@ -319,13 +319,13 @@ describe('blockView', () => { expect(signature.filter(token => token.text === 'str')).toEqual([{ text: 'str', role: 'type' }, { text: 'str', role: 'type' }, { text: 'str', role: 'function' }]) }) - it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => { + it('块自己的名字(defines):函数名、类名(赋给变量的类表达式取变量名)、接口名、只声明一个变量时的变量名(解构模式整个算);导入、一条语句几个变量、一行译文的块、匿名类没有', async () => { const own = (source: string, list: BlockView[]): Record => Object.fromEntries(list.map(view => [view.id, view.defines && source.slice(view.defines.startIndex, view.defines.endIndex)])) const pySource = ['import os', 'def f(x): pass', 'a, b = 1, 2', 'for i in x: pass', 'class K: pass'].join('\n') expect(own(pySource, await views(py, 'python', pythonZhTranslator, pySource))).toEqual({ 0: undefined, 1: 'f', 2: 'a, b', 3: undefined, 4: 'K' }) - const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1', 'class A {}', 'const B = class C {}', 'export default class {}'].join('\n') - expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h', 3: 'A', 4: 'B', 5: undefined }) + const tsSource = ['let i = 0, j', 'const { u, v } = o', 'export const h = () => 1', 'class A {}', 'const B = class C {}', 'export default class {}', 'interface I { x: number }'].join('\n') + expect(own(tsSource, await views(ts, 'typescript', typescriptZhTranslator, tsSource))).toEqual({ 0: undefined, 1: '{ u, v }', 2: 'h', 3: 'A', 4: 'B', 5: undefined, 6: 'I' }) }) it('\\r\\n 和制表符缩进:块的源码用 \\n 连接,去掉共同的制表符', async () => { diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index b3fe6c7..9432cd1 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -183,6 +183,7 @@ export function ownName(block: CodeBlock): Node | undefined { switch (block.kind) { case 'function': case 'class': + case 'interface': case 'enum': case 'exception': case 'module': diff --git a/vscode/src/codeReader/callTree/query.ts b/vscode/src/codeReader/callTree/query.ts index 9a7738f..0be7b1a 100644 --- a/vscode/src/codeReader/callTree/query.ts +++ b/vscode/src/codeReader/callTree/query.ts @@ -197,6 +197,7 @@ function kindName(kind: SymbolKind): string { case SymbolKind.Constructor: return 'class' case SymbolKind.Interface: + return 'interface' case SymbolKind.TypeParameter: return 'type' case SymbolKind.Enum: diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index c1b9db8..d793a5a 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -10,9 +10,10 @@ export type { Node } * run 构建时执行的命令,copy 往镜像里复制文件,setting 一条设置(工作目录、运行身份、端口……),label 镜像的标签, * health 健康检查,start 容器启动时执行的命令。 * comment 是讲整个文件的注释(文件开头的说明);别的注释不单独成块,挂在它讲的块上(见 BlockShape.comments)。 + * interface 是 TypeScript 的接口(属性表),type 是别的类型声明(`type` 别名这类,一行译文)。 */ export type BlockKind - = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'interface' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'trait' | 'impl' | 'module' | 'attribute' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' @@ -275,6 +276,40 @@ export interface EnumFacts { members: EnumMemberFacts[] } +/** 接口的一个属性:属性签名 `名字?: 类型`,索引签名 `[key: string]: 类型` 也算一个;节点都指向源码。 */ +export interface InterfacePropertyFacts { + /** 属性名;索引签名是方括号里的参数名(`[key: string]` 的 `key`)。 */ + name: Node + /** 索引签名方括号里参数的类型(`[key: string]` 的 `string`);普通属性没有。 */ + index?: Node + /** 属性的类型;没标注时没有。 */ + type?: Node + /** 可省略的属性 `x?`。 */ + optional: boolean + /** 只读属性 `readonly x`。 */ + readonly: boolean + /** 属性的说明文字节点,同 [EnumMemberFacts.docs]:从上一个成员之后起单独成行的注释 / JSDoc、写在它这一行行尾的注释。 */ + docs: Node[] +} + +/** + * 接口:TypeScript 的 `interface`(含 `declare interface`)由语言插件报。属性(含索引签名)连同讲它的注释并进属性表, + * 方法签名、调用签名、构造签名这些别的成员照旧是子块。 + */ +export interface InterfaceFacts { + name: Node + /** 接口自己的说明,同 [ClassFacts.doc]:紧贴在前面的 JSDoc。 */ + doc?: Node + /** 同 [ClassFacts.access]。 */ + access?: Access + /** 泛型参数,同 [ClassFacts.typeParameters]。 */ + typeParameters: Node[] + /** 继承的接口(`extends A, B`),每项一个节点,泛型实参连在里面。 */ + extends: Node[] + /** 照源码顺序。 */ + properties: InterfacePropertyFacts[] +} + /** 异常类的一个类属性:Python 类主体里给一个名字赋值的语句(带不带注解、有没有值),TypeScript 的类字段(含 static);节点都指向源码。 */ export interface FieldFacts { name: Node @@ -551,7 +586,7 @@ export interface VariableFacts { * - kind:块的种类(BlockKind); * - access:访问级别,'public' | 'private' | 'protected',语言可以加自己的值(Kotlin 的 'internal',见 BlockShape.attributes); * - async、abstract、typeOnly:异步、抽象、仅类型,值是 'true' | 'false'。 - * 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ExceptionFacts.access、ImportFacts.typeOnly、VariableFacts.access), + * 宿主从块的 kind 和 facts 推出它们([FunctionFacts.access] / [FunctionFacts.async]、[ClassFacts.access] / [ClassFacts.abstract]、[InterfaceFacts.access]、[EnumFacts.access]、[ExceptionFacts.access]、[ImportFacts.typeOnly]、[VariableFacts.access]), * 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。 */ export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const @@ -593,6 +628,8 @@ export interface ImplBlock extends BlockShape { kind: 'impl', facts: ClassFacts export interface ModuleBlock extends BlockShape { kind: 'module', facts: ClassFacts } /** Rust 的内部属性 `#![…]`:写给所在 crate 或模块的编译属性。 */ export interface AttributeBlock extends BlockShape { kind: 'attribute', facts: AttributeFacts } +/** 接口块的头部是整个接口(属性都在属性表里讲),方法签名等别的成员是子块。 */ +export interface InterfaceBlock extends BlockShape { kind: 'interface', facts: InterfaceFacts } /** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */ export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts } /** 异常块的头部是整个类(类属性都在属性表里讲),留下的方法(含构造函数)等是子块。 */ @@ -610,11 +647,11 @@ export interface StageBlock extends BlockShape { kind: 'stage', facts: StageFact export interface HealthBlock extends BlockShape { kind: 'health', facts: HealthFacts } export interface StartBlock extends BlockShape { kind: 'start', facts: StartFacts } export type FactBlock - = | FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | AttributeBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock + = | FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | AttributeBlock | InterfaceBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock | DirectiveBlock | StageBlock | HealthBlock | StartBlock /** 带说明(facts.doc)的块。 */ -export type DocumentedBlock = FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | EnumBlock | ExceptionBlock | VariableBlock +export type DocumentedBlock = FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | InterfaceBlock | EnumBlock | ExceptionBlock | VariableBlock /** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */ export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick : never : never @@ -1068,6 +1105,7 @@ export const KIND_ICONS: Record = { module: 'layers', attribute: 'sliders-horizontal', type: 'braces', + interface: 'braces', enum: 'list-ordered', exception: 'triangle-alert', field: 'tag', diff --git a/vscode/src/codeReader/features/featureLocate.test.ts b/vscode/src/codeReader/features/featureLocate.test.ts index d23c691..a6cbf95 100644 --- a/vscode/src/codeReader/features/featureLocate.test.ts +++ b/vscode/src/codeReader/features/featureLocate.test.ts @@ -91,7 +91,7 @@ describe('按符号名在源码里找(语言插件解析出的块)', () => { it('类型(interface、type)和它们的成员按声明的名字找;shell 命令不算名字', async () => { const types = 'export interface Favorite {\n uri: string\n}\ntype Slot = number\n' - expect(await find(typescript, 'typescript', types, 'Favorite')).toEqual({ text: 'Favorite', kind: 'type' }) + expect(await find(typescript, 'typescript', types, 'Favorite')).toEqual({ text: 'Favorite', kind: 'interface' }) expect(await find(typescript, 'typescript', types, 'Favorite.uri')).toEqual({ text: 'uri', kind: 'field' }) expect(await find(typescript, 'typescript', types, 'Slot')).toEqual({ text: 'Slot', kind: 'type' }) expect(await find(rust, 'rust', 'pub type Id = u32;\n', 'Id')).toEqual({ text: 'Id', kind: 'statement' }) diff --git a/vscode/src/codeReader/features/featureLocate.ts b/vscode/src/codeReader/features/featureLocate.ts index 15c77ca..c344fd4 100644 --- a/vscode/src/codeReader/features/featureLocate.ts +++ b/vscode/src/codeReader/features/featureLocate.ts @@ -16,7 +16,7 @@ export interface NamedBlock { children: NamedBlock[] } -/** 语言插件不给名字、按语法树里的声明找名字的块:类型(TypeScript 的 interface、type)、类型里的成员、别的声明语句(Rust 的 type)。 */ +/** 语言插件不给名字、按语法树里的声明找名字的块:类型(TypeScript 的 `type` 别名)、类字段、接口里没并进属性表的成员、别的声明语句(Rust 的 `type`)。 */ const DECLARATIONS = new Set(['type', 'field', 'statement']) /** @@ -32,10 +32,17 @@ function nameOf(block: CodeBlock): Node | undefined { return declaration.childForFieldName('name') ?? undefined } +/** 接口的属性并进了属性表、不是子块,照样当它下面的字段找,`Favorite.uri` 才找得到。 */ +function propertyNames(block: CodeBlock): NamedBlock[] { + if (block.kind !== 'interface') + return [] + return block.facts.properties.map(({ name }) => ({ name: name.text, kind: 'field', range: { startIndex: name.startIndex, endIndex: name.endIndex }, children: [] })) +} + /** 块树里有名字的那些;没名字的块(条件、调用语句)跳过,它里面有名字的往上提一层。 */ export function namedBlocks(blocks: readonly CodeBlock[]): NamedBlock[] { return blocks.flatMap((block): NamedBlock[] => { - const children = namedBlocks(block.children) + const children = [...namedBlocks(block.children), ...propertyNames(block)].sort((a, b) => a.range.startIndex - b.range.startIndex) const name = nameOf(block) return name ? [{ name: name.text, kind: block.kind, range: { startIndex: name.startIndex, endIndex: name.endIndex }, children }] : children }) diff --git a/vscode/src/codeReader/features/featureViews.ts b/vscode/src/codeReader/features/featureViews.ts index 69a06f2..f01d44b 100644 --- a/vscode/src/codeReader/features/featureViews.ts +++ b/vscode/src/codeReader/features/featureViews.ts @@ -51,6 +51,7 @@ const SYMBOL_ICONS: Record = { exception: 'symbol-class', impl: 'symbol-class', trait: 'symbol-interface', + interface: 'symbol-interface', enum: 'symbol-enum', module: 'symbol-module', const: 'symbol-constant', diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 6893d06..258e0dd 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -394,6 +394,7 @@ function ownDoc(block: CodeBlock): Node | undefined { case 'trait': case 'impl': case 'module': + case 'interface': case 'enum': case 'exception': case 'const': diff --git a/vscode/src/codeReader/languages/typescript.test.ts b/vscode/src/codeReader/languages/typescript.test.ts index 1c27af0..c4c4188 100644 --- a/vscode/src/codeReader/languages/typescript.test.ts +++ b/vscode/src/codeReader/languages/typescript.test.ts @@ -13,7 +13,7 @@ function facts(source: string, languageId = 'typescript'): Promise { }) }) +describe('typeScript 接口', () => { + const SESSION = [ + '/** 会话。 */', + 'export interface Session extends Base, Store {', + ' /** 编号 */', + ' readonly id: string', + ' // 标题', + ' title?: string // 可以不填', + ' [key: string]: unknown', + ' readonly [index: number]: T', + ' /** 关掉会话。 */', + ' close(reason?: string): void', + ' // 当函数调', + ' (input: string): number', + ' new (name: string): Session', + ' count', + '}', + '', + 'interface Empty {}', + 'declare interface Ambient { a: number }', + 'function local() {', + ' interface Inner { x: number }', + '}', + ].join('\n') + + it('interface 是接口块:头部是整个 interface,紧贴的 JSDoc 算进头部;属性(含索引签名)连同讲它的注释并进属性表,方法签名是函数子块,调用签名、构造签名照旧是子块', async () => { + expect(await tree(SESSION)).toEqual([ + { at: [1, 15], kind: 'interface', kids: [ + { at: [9, 10], kind: 'function' }, + { at: 12, kind: 'field', notes: [11] }, + { at: 13, kind: 'field' }, + ] }, + { at: 17, kind: 'interface' }, + { at: 18, kind: 'interface' }, + { at: 19, kind: 'function', kids: [{ at: 20, kind: 'interface' }] }, + ]) + }) + + it('属性表信息:名称、说明、访问级别、泛型参数、extends 每项一个节点;属性照源码顺序,带可省略、只读、索引签名的参数类型,说明收法同枚举成员', async () => { + const byLine = await facts(SESSION) + expect(byLine[1]).toEqual({ + name: 'Session', + doc: '/** 会话。 */', + access: 'public', + typeParameters: ['T extends object = object'], + extends: ['Base', 'Store'], + properties: [ + { name: 'id', type: 'string', optional: false, readonly: true, docs: ['/** 编号 */'] }, + { name: 'title', type: 'string', optional: true, readonly: false, docs: ['// 标题', '// 可以不填'] }, + { name: 'key', index: 'string', type: 'unknown', optional: false, readonly: false, docs: [] }, + { name: 'index', index: 'number', type: 'T', optional: false, readonly: true, docs: [] }, + { name: 'count', optional: false, readonly: false, docs: [] }, + ], + }) + expect(byLine[9]).toMatchObject({ name: 'close', doc: '/** 关掉会话。 */', parameters: [{ name: 'reason', optional: true, type: 'string' }], returnType: 'void' }) + expect(byLine[17]).toEqual({ name: 'Empty', access: 'private', typeParameters: [], extends: [], properties: [] }) + expect(byLine[18]).toMatchObject({ name: 'Ambient', access: 'private', properties: [{ name: 'a', type: 'number' }] }) + expect(byLine[20]).toEqual({ name: 'Inner', typeParameters: [], extends: [], properties: [{ name: 'x', type: 'number', optional: false, readonly: false, docs: [] }] }) + }) + + it('jSDoc 挂在同一行上一个属性后面时讲的是下一个属性;最后一个成员后面的注释挂到接口上', async () => { + const source = 'interface E { a: string; /** 第二 */ b: string; c: number // 第三\n // 收尾\n}' + expect(await facts(source)).toMatchObject({ 1: { properties: [{ name: 'a', docs: [] }, { name: 'b', docs: ['/** 第二 */'] }, { name: 'c', docs: ['// 第三'] }] } }) + expect(await tree(source)).toEqual([{ at: [1, 3], kind: 'interface', notes: [2] }]) + }) +}) + describe('typeScript 调用', () => { const calls = (source: string): Promise => nodeFacts(adapter, 'typescript', source, node => adapter.call?.(node)) diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index d9b103d..0831f8d 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -1,4 +1,4 @@ -import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, CollectionFacts, CommentBlock, DeclaratorFacts, ElementFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' +import type { Access, BaseFacts, BlockShape, CallArgument, CallFacts, ClassFacts, CodeBlock, CollectionFacts, CommentBlock, DeclaratorFacts, ElementFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, InterfacePropertyFacts, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules, ExceptionRules } from './shared' import * as path from 'node:path' import { attachComments, children, compoundShape, extendHeader, headerSpan, insideOf, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared' @@ -23,7 +23,7 @@ const COMMENTS: CommentRules = { lineStyle: comment => comment.text.startsWith('//') ? '//' : undefined, // `/**/` 是空的块注释,不是 JSDoc isDoc: comment => comment.text.startsWith('/**') && !comment.text.startsWith('/**/'), - documents: new Set(['function', 'class', 'enum', 'const', 'variable']), + documents: new Set(['function', 'class', 'interface', 'enum', 'const', 'variable']), } /** 讲整个文件的 JSDoc:写了 `@file`、`@fileoverview` 或 `@overview`。 */ @@ -104,6 +104,52 @@ function enumMembers(body: Node | null): EnumMemberFacts[] { return members } +/** 成员是属性签名、或写了参数名的索引签名(`[key: string]: T`)时它在属性表里的一行,说明是 `docs`;别的成员(方法签名这些)没有。 */ +function propertyFacts(member: Node, docs: Node[]): InterfacePropertyFacts | undefined { + const name = member.childForFieldName('name') + const index = member.type === 'index_signature' ? member.childForFieldName('index_type') : null + if (!name || (member.type !== 'property_signature' && !index)) + return undefined + const words = children(member) + return { + name, + ...(index && { index }), + type: annotatedType(member.childForFieldName('type')), + optional: words.some(word => word.type === '?'), + readonly: words.some(word => word.type === 'readonly'), + docs, + } +} + +/** + * interface 主体里的成员分两拨,照源码顺序:属性(见 [propertyFacts])进属性表,说明的收法同 [enumMembers]; + * 别的成员(方法签名、调用签名、构造签名)连同讲它们的注释、最后一个成员后面的注释留给子块(`rest`)。 + */ +function interfaceMembers(body: Node): { properties: InterfacePropertyFacts[], rest: Node[] } { + const properties: InterfacePropertyFacts[] = [] + const rest: Node[] = [] + let pending: Node[] = [] + /** 上一个成员结束在哪一行,它是属性时还有它的说明:写在这一行行尾的注释归它。 */ + let last: { row: number, docs?: Node[] } | undefined + for (const child of named(body)) { + if (child.type === 'comment') { + if (last && pending.length === 0 && child.startPosition.row === last.row && !COMMENTS.isDoc(child)) + (last.docs ?? rest).push(child) + else + pending.push(child) + continue + } + const property = propertyFacts(child, pending) + if (property) + properties.push(property) + else + rest.push(...pending, child) + last = { row: child.endPosition.row, docs: property?.docs } + pending = [] + } + return { properties, rest: [...rest, ...pending] } +} + /** 类成员:写了 public / private / protected 按写的,#name 是私有,默认公共。 */ function memberAccess(member: Node): Access { const modifier = named(member).find(child => child.type === 'accessibility_modifier') @@ -338,12 +384,14 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { case 'variable_declaration': return [variableBlock(declaration, outer, top)] case 'interface_declaration': - return [membersBlock(declaration, outer)] + return [interfaceBlock(declaration, outer, top)] case 'enum_declaration': return [enumBlock(declaration, outer, top)] case 'ambient_declaration': { - // declare enum X {}:别的 declare 写法照旧是一条语句 - const inner = named(declaration).find(child => child.type === 'enum_declaration') + // declare enum X {}、declare interface X {}:别的 declare 写法照旧是一条语句 + const inner = named(declaration).find(child => child.type === 'enum_declaration' || child.type === 'interface_declaration') + if (inner?.type === 'interface_declaration') + return [interfaceBlock(inner, outer, top)] return [inner ? enumBlock(inner, outer, top) : leafBlock('statement', outer)] } case 'type_alias_declaration': @@ -481,18 +529,35 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] { } } - /** interface:成员(属性、方法签名)→ field。 */ - function membersBlock(declaration: Node, outer: Node): CodeBlock { + /** interface(含 declare interface):整条声明是头部,属性在属性表里讲,方法签名这些别的成员是子块。 */ + function interfaceBlock(declaration: Node, outer: Node, top: boolean): CodeBlock { + const name = declaration.childForFieldName('name') const body = declaration.childForFieldName('body') - if (!body) + if (!name || !body) return leafBlock('type', outer) + const { properties, rest } = interfaceMembers(body) return { - kind: 'type', - ...compoundShape(outer, body.startIndex, named(body), (list, afterRow) => - listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)), + kind: 'interface', + node: outer, + header: nodeSpan(outer), + children: listBlocks(rest, body.startPosition.row, interfaceMember, COMMENTS), + facts: { + name, + access: declarationAccess(outer, top), + typeParameters: syntaxChildren(declaration.childForFieldName('type_parameters')), + extends: syntaxChildren(named(declaration).find(child => child.type === 'extends_type_clause')), + properties, + }, } } + /** 接口里没进属性表的成员:方法签名是函数块,别的(调用签名、构造签名)照旧是一行。 */ + function interfaceMember(member: Node): CodeBlock[] { + if (member.type === 'method_signature') + return [functionBlock(member, member, member.childForFieldName('name'), memberAccess(member))] + return [leafBlock('field', member)] + } + return root => statements(named(root), true, -1) } diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 0b9c368..d7fb07c 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -1,6 +1,7 @@ import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract' import type { ZhTable } from './zhTemplate' import { describe, expect, it } from 'vitest' +import { blockAttributes } from '../attributes' import { createPythonAdapter } from '../languages/python' import { createTypeScriptAdapter } from '../languages/typescript' import { flatSegments, inlineOf, listLines, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport' @@ -115,7 +116,6 @@ describe('typeScript 中文直译', () => { expect(text(env.get(25)?.segments)).toBe('如果 (folder 不成立)') expect(text(env.get(26)?.segments)).toBe('返回 空数组') expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)') - expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]') expect(text(issue.get(20)?.segments)).toBe('返回 对象 3 项') expect(listLines(issue.get(20)?.items)).toEqual(['locked | 真', 'prerequisiteNumber | prereq.number', 'prerequisiteColumn | prereq.column']) }) @@ -123,7 +123,6 @@ describe('typeScript 中文直译', () => { it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号、保留换行', async () => { const env = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))) const issue = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))) - expect(env[32]).toBe('公共 接口 EnvLockResult') expect(env[42]).toBe('尝试') expect(env[46]).toBe('捕获 (err)') expect(env[47]).toBe('result.failed.push({ path: f, reason: 如果 err 属于 Error 则 err.message 否则 String(err) })') @@ -137,17 +136,18 @@ describe('typeScript 中文直译', () => { 'No persistent watcher — re-scan happens on every toggle, so newly created', '`.env` files are only picked up the next time the user clicks the button.', ].join('\n')) - expect(issue[5]).toBe('prerequisiteNumber?: 数值') expect(issue[15]).toBe('如果 (issue 不成立 或者 issue.prerequisite 全等 未定义)') }) it('每个被翻的词单独成段,着色取语法树词性', async () => { const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')) const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt')) - expect(colors(flatSegments(env.get(32)?.segments ?? []).filter(segment => segment.role !== 'plain'))).toEqual([ + const alias = await translateAll(ts, 'typescript', typescriptZhTranslator, 'export type EnvLockResult = Result') + expect(colors(flatSegments(alias.get(1)?.segments ?? []).filter(segment => segment.role !== 'plain'))).toEqual([ { text: '公共', role: 'keyword' }, - { text: '接口', role: 'storage' }, + { text: '类型', role: 'storage' }, { text: 'EnvLockResult', role: 'type' }, + { text: 'Result', role: 'type' }, ]) expect(issue.get(15)?.segments).toContainEqual({ text: '全等', role: 'operator' }) expect(env.get(26)?.segments).toEqual([{ text: '返回', role: 'keyword' }, { text: ' ', role: 'plain' }, { text: '空数组', role: 'literal' }]) @@ -192,7 +192,7 @@ describe('typeScript 中文直译', () => { 'for (const key in obj) {}', 'has = \'k\' in obj', 'use(class A extends B implements C {})', - 'interface I extends J, K {}', + 'type U = T extends [infer H extends string] ? H : never', 'type F = (t: T) => void', 'type R = T extends string ? 1 : 2', 'export default g', @@ -209,7 +209,7 @@ describe('typeScript 中文直译', () => { expect(map[5]).toBe('遍历 (常量 key 取自 obj)') expect(map[6]).toBe('has = \'k\' 在 obj') expect(map[7]).toBe('use(类 A 继承 B 实现 C {})') - expect(map[8]).toBe('接口 I 继承 J, K') + expect(map[8]).toBe('类型 U = T 符合 [infer H 限定为 字符串] ? H : 永不') expect(map[9]).toBe('类型 F = (t: T) => 无') expect(map[10]).toBe('类型 R = T 符合 字符串 ? 1 : 2') expect(map[11]).toBe('公共 默认 g') @@ -549,6 +549,8 @@ describe('属性表', () => { expect(env[27]).toEqual(['名称: include', '初始值: 新建 RelativePattern(folder, ENV_PATTERN)']) expect(env[28]).toEqual(['名称: uris', '初始值: 等待 workspace.findFiles(include, EXCLUDE_PATTERN)']) expect(env[40]).toEqual(['名称: result', '类型: EnvLockResult', '初始值: 对象 3 项', ' total | files.length', ' ok | 空数组', ' failed | 空数组']) + expect(env[32]).toEqual(['名称: EnvLockResult', '访问级别: 公共', '属性: 3 个', ' total | 数值', ' ok | 字符串[]', ' failed | { path: 字符串, reason: 字符串 }[]']) + expect(issue[3]).toEqual(['名称: LockedReason', '访问级别: 公共', '属性: 3 个', ' locked | 布尔', ' prerequisiteNumber? | 数值', ' prerequisiteColumn? | 字符串']) expect(issue[1]).toEqual(['来源: \'../gitea/types\'', '仅类型: 是', '导入项: 1 个', ' Issue']) expect(issue[14]).toEqual(['名称: issue', '初始值: issues.find(i => i.number 全等 issueNumber)']) expect(routes[19]).toEqual([ @@ -911,6 +913,75 @@ describe('属性表', () => { }) }) +describe('接口的属性表', () => { + const SESSION = [ + '/** 会话。 */', + 'export interface Session extends Base, Store {', + ' /** 编号 */', + ' readonly id: string', + ' // 标题', + ' title?: string // 可以不填', + ' [key: string]: unknown', + ' readonly [index: number]: T', + ' /** 关掉会话。 */', + ' close(reason?: string): void', + ' count', + '}', + '', + 'interface Empty {}', + 'interface Point { x: number; readonly y?: number }', + ].join('\n') + + it('名称 / 说明 / 访问级别 / 类型参数 / 继承有才显示;属性表「属性 | 类型 | 说明」:可省略的带 ?,只读写在类型前,索引签名写 [参数: 类型],没写类型写未标注,说明是注释、JSDoc 的原文', async () => { + const rows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, SESSION)) + expect(rows[1]).toEqual([ + '名称: Session', + '说明: 会话。', + '访问级别: 公共', + '类型参数: T 限定为 Base = Base', + '继承: Base, Store', + '属性: 5 个', + ' id | 只读 字符串 | 编号', + ' title? | 字符串 | 标题 可以不填', + ' [key: 字符串] | 未知 | ', + ' [index: 数值] | 只读 T | ', + ' count | 未标注 | ', + ]) + expect(rows[9]).toEqual(['名称: close', '说明: 关掉会话。', '访问级别: 公共', '异步: 否', '输入参数: 1 个', ' reason? | 字符串 | ', '输出参数: 无']) + expect(rows[14]).toEqual(['名称: Empty', '访问级别: 私有']) + expect(rows[15]).toEqual(['名称: Point', '访问级别: 私有', '属性: 2 个', ' x | 数值', ' y? | 只读 数值']) + }) + + it('一个属性都没有说明时没有「说明」列;每行讲的定义是属性名(索引签名是方括号里的参数名),类型参数一行讲这几个泛型参数', async () => { + const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, SESSION) + const table = (line: number): PropertyRow | undefined => rows.get(line)?.find(row => row.label === '属性') + expect(table(1)?.columns).toEqual(['属性', '类型', '说明']) + expect(table(15)?.columns).toEqual(['属性', '类型']) + const spans = (row: { defines?: { startIndex: number, endIndex: number } } | undefined): string | undefined => row?.defines && SESSION.slice(row.defines.startIndex, row.defines.endIndex) + expect(table(1)?.items?.map(spans)).toEqual(['id', 'title', 'key', 'index', 'count']) + expect(spans(rows.get(1)?.find(row => row.label === '类型参数'))).toBe('T extends Base = Base') + expect(spans(rows.get(1)?.find(row => row.label === '属性'))).toBeUndefined() + }) + + it('着色:名称按类型,属性名按变量、带源码下标,只读按 storage,未标注按 hint,说明按 comment', async () => { + const rows = (await describeAll(ts, 'typescript', typescriptZhTranslator, SESSION)).get(1) + expect(colors(valueOf(rows, '名称'))).toEqual([{ text: 'Session', role: 'type' }]) + const items = rows?.find(row => row.label === '属性')?.items ?? [] + expect(items[0].cells[0]).toEqual([{ text: 'id', role: 'variable', offset: SESSION.indexOf('id: string') }]) + expect(colors(items[0].cells.slice(1))).toEqual([ + [{ text: '只读', role: 'storage' }, { text: ' ', role: 'plain' }, { text: '字符串', role: 'type' }], + [{ text: '编号', role: 'comment' }], + ]) + expect(colors(items[4].cells[1])).toEqual([{ text: '未标注', role: 'hint' }]) + }) + + it('筛选:种类是 interface、叫「接口」,访问级别从 facts 推出', async () => { + const attributes = await withBlocks(ts, 'typescript', SESSION, blocks => blocks.map(blockAttributes)) + expect(attributes).toEqual([{ kind: 'interface', access: 'public' }, { kind: 'interface', access: 'private' }, { kind: 'interface', access: 'private' }]) + expect(typescriptZhTranslator.attributeNames?.values.kind?.interface).toBe('接口') + }) +}) + describe('源码下标(跳到定义用)', () => { function itemCells(rows: PropertyRow[] | undefined, label: string): PreviewText[] { return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? [] diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 6c70231..bda8349 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ChainArgument, ChainFacts, ChainGroup, ChainStep, ChainStepFacts, ChainView, ClassFacts, CollectionFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, DeferredFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ChainArgument, ChainFacts, ChainGroup, ChainStep, ChainStepFacts, ChainView, ClassFacts, CollectionFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, DeferredFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterfaceFacts, InterfacePropertyFacts, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract' import type { ParamDocs } from '../rules/docRows' import { readDoc } from '../docTags' import { commonIndent, splitLines } from '../editText' @@ -220,6 +220,7 @@ export const KIND_ZH: Record = { import: '导入', function: '函数', class: '类', + interface: '接口', trait: '特质', impl: '实现', module: '模块', @@ -1024,6 +1025,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { case 'impl': case 'module': return classRows(block.facts, phrases) + case 'interface': + return interfaceRows(block.facts, phrases) case 'enum': return enumRows(block.facts, phrases) case 'exception': @@ -1480,8 +1483,6 @@ function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases, * 类没有构造参数表可对,说明里的 `@param` 照写成「构造参数」一张「名称 | 说明」表;`@throws` 是「异常」一行。 */ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { - const listRow = (label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] => - parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : [] const doc = facts.doc && say.doc(facts.doc) return [ { label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] }, @@ -1497,6 +1498,47 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] { ] } +/** 几项写成一行、逗号隔开(类型参数、父类、实现的接口这些);一项都没有时没有这一行。 */ +function listRow(label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] { + return parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : [] +} + +/** + * 接口:名称 / 说明 / 访问级别 / 类型参数 / 继承(extends 后面的各项,逗号隔开)/ 属性,说明及以后有才显示。 + * 属性收成一张表:属性 | 类型 | 说明。可省略的属性名后面带 `?`(同参数表),索引签名写成 `[参数名: 类型]`; + * 只读的类型格前面写「只读」,没写类型写「未标注」;说明同枚举成员,一个属性都没有说明时没有这一列。每行讲的定义是属性名。 + */ +function interfaceRows(facts: InterfaceFacts, say: Phrases): PropertyRow[] { + const docs = facts.properties.map(property => docsText(property.docs, say)) + const described = docs.some(Boolean) + const plain = (text: string): Segment => ({ text, role: 'plain' }) + const nameCell = ({ name, index, optional }: InterfacePropertyFacts): Inline[] => [ + ...(index ? [plain('['), ...say.name(name, 'variable'), plain(': '), ...say.translated(index), plain(']')] : say.name(name, 'variable')), + ...(optional ? [plain('?')] : []), + ] + const typeCell = ({ type, readonly }: InterfacePropertyFacts): Inline[] => [ + ...(readonly ? [{ text: '只读', role: 'storage' as const }, plain(' ')] : []), + ...(type ? say.translated(type) : [hint('未标注')]), + ] + const properties: PropertyRow = { + label: '属性', + value: count(facts.properties.length), + columns: described ? ['属性', '类型', '说明'] : ['属性', '类型'], + items: facts.properties.map((property, i) => ({ + cells: [nameCell(property), typeCell(property), ...(described ? [docCell(docs[i])] : [])], + ...defining(property.name), + })), + } + return [ + { label: '名称', value: say.name(facts.name, 'type') }, + ...docBodyRows(facts.doc && say.doc(facts.doc)), + ...accessRows(facts.access), + ...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)), + ...listRow('继承', facts.extends.map(say.translated)), + ...(facts.properties.length > 0 ? [properties] : []), + ] +} + /** 父类的一项:TypeScript 的泛型实参紧跟在后面。 */ function baseText(base: BaseFacts, say: Phrases): Inline[] { return base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value) diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 125a4b7..9b68556 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -18,7 +18,7 @@ body { 图标区分具体是哪种(kindIcons.tsx)。卡片取自己的种类,子层取所属块的种类 */ :is([data-kind="import"], [data-kind="copy"]) { --kind: var(--cr-kind-import); } [data-kind="function"] { --kind: var(--cr-kind-function); } -:is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); } +:is([data-kind="class"], [data-kind="interface"], [data-kind="type"]) { --kind: var(--cr-kind-type); } [data-kind="trait"] { --kind: var(--cr-storage); } [data-kind="attribute"] { --kind: var(--cr-constant); } [data-kind="impl"] { --kind: var(--cr-kind-function); } diff --git a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx index 13ba191..a8fad2f 100644 --- a/vscode/webview-ui/src/codeReader/kindIcons.test.tsx +++ b/vscode/webview-ui/src/codeReader/kindIcons.test.tsx @@ -26,6 +26,7 @@ describe('块的种类图标', () => { module: 'layers', attribute: 'sliders-horizontal', type: 'braces', + interface: 'braces', enum: 'list-ordered', exception: 'triangle-alert', field: 'tag', diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 45810b7..3e4f9b1 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -3,7 +3,7 @@ */ export type BlockKind - = | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' + = | 'import' | 'function' | 'class' | 'interface' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable' | 'trait' | 'impl' | 'module' | 'attribute' | 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment' | 'interpreter' | 'options' | 'case' | 'command' | 'pipeline'