✨ 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

@@ -394,6 +394,7 @@ function ownDoc(block: CodeBlock): Node | undefined {
case 'trait':
case 'impl':
case 'module':
case 'interface':
case 'enum':
case 'exception':
case 'const':
@@ -13,7 +13,7 @@ function facts(source: string, languageId = 'typescript'): Promise<Record<number
return withBlocks(adapter, languageId, source, factsByLine)
}
// 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致,只是紧贴函数的 JSDoc 算进了函数头部
// 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致,只是紧贴函数的 JSDoc 算进了函数头部,interface 是一整块(属性在属性表里讲)
const ENV_LOCK: Outline[] = [
{ at: [1, 10], kind: 'comment' },
{ at: 12, kind: 'import' },
@@ -29,11 +29,7 @@ const ENV_LOCK: Outline[] = [
{ at: 28, kind: 'variable' },
{ at: 29, kind: 'return' },
] },
{ at: 32, kind: 'type', kids: [
{ at: 33, kind: 'field' },
{ at: 34, kind: 'field' },
{ at: 35, kind: 'field' },
] },
{ at: [32, 36], kind: 'interface' },
{ at: 38, kind: 'function', kids: [
{ at: 39, kind: 'variable' },
{ at: 40, kind: 'variable' },
@@ -58,11 +54,7 @@ const ENV_LOCK: Outline[] = [
const ISSUE_LOCK: Outline[] = [
{ at: 1, kind: 'import' },
{ at: 3, kind: 'type', kids: [
{ at: 4, kind: 'field' },
{ at: 5, kind: 'field' },
{ at: 6, kind: 'field' },
] },
{ at: [3, 7], kind: 'interface' },
{ at: [9, 13], kind: 'function', kids: [
{ at: 14, kind: 'variable' },
{ at: 15, kind: 'if', kids: [
@@ -629,6 +621,73 @@ describe('typeScript 枚举', () => {
})
})
describe('typeScript 接口', () => {
const SESSION = [
'/** 会话。 */',
'export interface Session<T extends object = object> extends Base, Store<T> {',
' /** 编号 */',
' readonly id: string',
' // 标题',
' title?: string // 可以不填',
' [key: string]: unknown',
' readonly [index: number]: T',
' /** 关掉会话。 */',
' close(reason?: string): void',
' // 当函数调',
' (input: string): number',
' new (name: string): Session<T>',
' 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<T>'],
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<unknown[]> => nodeFacts(adapter, 'typescript', source, node => adapter.call?.(node))
+76 -11
View File
@@ -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)
}