✨ feat(vscode): 代码阅读的 TypeScript interface 换成一张属性表卡片

契约加 InterfaceBlock / InterfaceFacts(种类「接口」,图标、颜色同类型块);属性签名和索引签名连同讲它的注释并进「属性 | 类型 | 说明」表,可省略带 ?、只读写在类型前、没标注写未标注,每行讲的定义是属性名;方法签名是函数子块,调用签名、构造签名照旧是子块;declare interface 也认;「了解一块代码」照旧按 接口名.属性名 找得到属性
This commit is contained in:
cruldra committed 2026-10-16 14:27:28 +11:00
1 parent f487b841ce
commit 6b1e6f3487
16 files changed
+334 -46

No files matched your search

@@ -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> = T extends [infer H extends string] ? H : never',
'type F<T extends X> = (t: T) => void',
'type R<T> = 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> = T 符合 [infer H 限定为 字符串] ? H : 永不')
expect(map[9]).toBe('类型 F<T 限定为 X> = (t: T) => 无')
expect(map[10]).toBe('类型 R<T> = 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<T extends Base = Base> extends Base, Store<T> {',
' /** 编号 */',
' 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<T>',
'属性: 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) ?? []
@@ -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<BlockKind, string> = {
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)