✨ feat(vscode): 代码阅读函数块默认折叠,函数说明进属性表「说明」行

This commit is contained in:
cruldra committed 2026-09-25 13:09:53 +08:00
1 parent 62f48fe67c
commit 9b32d4b5a8
17 files changed
+489 -100

No files matched your search

+39 -2
View File
@@ -20,8 +20,7 @@ const SAMPLE: Outline[] = [
{ at: 4, kind: 'import' },
{ at: 6, kind: 'const' },
{ at: 7, kind: 'variable' },
{ at: 10, kind: 'function', kids: [
{ at: 11, kind: 'comment' },
{ at: [10, 11], kind: 'function', kids: [
{ at: 12, kind: 'comment' },
{ at: 13, kind: 'variable' },
{ at: 14, kind: 'if', kids: [{ at: 15, kind: 'return' }] },
@@ -91,6 +90,42 @@ describe('python 代码块树', () => {
})
})
describe('python 函数的 docstring', () => {
const source = [
'class K:',
' """类说明"""',
' def run(self): # 行尾',
' # 先说一句',
' """跑一遍。',
'',
' 细节。',
' """ # 行尾',
' # 真正的注释',
' return 1',
'def g(): "单行"',
].join('\n')
it('算进函数头部、不单独成块,它前面的注释也在头部里;后面的注释照旧成块;类的 docstring 照旧是注释块', async () => {
expect(await tree(source)).toEqual([
{ at: 1, kind: 'class', kids: [
{ at: 2, kind: 'comment' },
{ at: [3, 8], kind: 'function', kids: [
{ at: 9, kind: 'comment' },
{ at: 10, kind: 'return' },
] },
] },
{ at: 11, kind: 'function' },
])
})
it('进 facts.doc:是那个字符串节点;没有 docstring 的函数没有', async () => {
const found = await facts(source)
expect(found[3]).toMatchObject({ name: 'run', doc: '"""跑一遍。\n\n 细节。\n """' })
expect(found[11]).toMatchObject({ name: 'g', doc: '"单行"' })
expect(await facts('def f():\n x = 1\n """not a docstring"""')).not.toHaveProperty('1.doc')
})
})
describe('python 属性表信息(facts)', () => {
it('sample.py:import / from import、模块和类里的赋值、函数和方法', async () => {
const sample = await facts(readFixture('sample.py'))
@@ -99,6 +134,7 @@ describe('python 属性表信息(facts)', () => {
expect(sample[6]).toEqual({ access: 'public', declarators: [{ name: 'MAX_DEPTH', value: '8' }] })
expect(sample[10]).toEqual({
name: 'find_env_files',
doc: '"""Return every .env file under root."""',
access: 'public',
async: false,
decorators: [],
@@ -128,6 +164,7 @@ describe('python 属性表信息(facts)', () => {
})
expect(routes[29]).toEqual({
name: 'get_user',
doc: '"""获取用户详情。\n\n 管理员可查看任意用户,普通用户只能查看自己。\n """',
access: 'public',
async: true,
decorators: ['@router.get("/{user_id}", response_model=UserDetailResponse)'],
+33 -7
View File
@@ -1,4 +1,5 @@
import type { Access, BlockShape, CodeBlock, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
@@ -8,9 +9,11 @@ type Scope = 'module' | 'class' | 'other'
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
const PARAMETER_SEPARATORS = new Set(['keyword_separator', 'positional_separator'])
const PATTERNS = new Set(['pattern_list', 'tuple_pattern', 'list_pattern'])
// 只有 # 行注释;函数的说明是主体开头的 docstring,不是写在前面的注释
const COMMENTS: CommentRules = { isLine: () => true, isDoc: () => false }
function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] {
return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), () => true)
return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), COMMENTS)
}
/** 冒号后面的主体:body / consequence 字段,except / finally 子句里是不带字段名的 block。 */
@@ -69,15 +72,37 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc
/** function_definition / class_definition;outer 是带装饰器的整体时头部从装饰器开始。 */
function definitionBlock(definition: Node, outer: Node, scope: Scope): CodeBlock {
const body = definition.childForFieldName('body')
if (definition.type === 'class_definition')
return { kind: 'class', ...bodyShape(outer, definition.childForFieldName('body'), 'class', true) }
return { kind: 'class', ...bodyShape(outer, body, 'class', true) }
const doc = docstringOf(body)
return {
kind: 'function',
...bodyShape(outer, definition.childForFieldName('body'), 'other', true),
facts: functionFacts(definition, outer, scope),
...(body && doc ? documentedShape(outer, body, doc) : bodyShape(outer, body, 'other')),
facts: functionFacts(definition, outer, scope, doc),
}
}
/** 主体开头的 docstring:第一条语句(注释不算)是单独一个字符串表达式时,返回那个字符串节点。 */
function docstringOf(body: Node | null): Node | undefined {
const first = named(body).find(child => child.type !== 'comment')
return first && docstringIn(first)
}
/** 语句是单独一个字符串表达式(docstring 的写法)时返回那个字符串节点。 */
function docstringIn(statement: Node): Node | undefined {
if (statement.type !== 'expression_statement')
return undefined
const [expression, ...rest] = named(statement)
return rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string') ? expression : undefined
}
/** 函数的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */
function documentedShape(outer: Node, body: Node, doc: Node): BlockShape {
const rest = named(body).filter(item => item.startIndex >= doc.endIndex)
return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, 'other', afterRow, false))
}
/** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */
function nameAccess(name: string): Access {
const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__')
@@ -108,10 +133,11 @@ function scopeAccess(target: Node, scope: Scope): Access | undefined {
return names.every(name => nameAccess(name.text) === 'private') ? 'private' : 'public'
}
function functionFacts(definition: Node, outer: Node, scope: Scope): FunctionFacts {
function functionFacts(definition: Node, outer: Node, scope: Scope, doc: Node | undefined): FunctionFacts {
const name = definition.childForFieldName('name')
return {
name: name ?? undefined,
doc,
access: name ? scopeAccess(name, scope) : undefined,
async: children(definition).some(child => child.type === 'async'),
decorators: named(outer).filter(child => child.type === 'decorator'),
@@ -177,9 +203,9 @@ function isConstantTarget(target: Node | null): boolean {
/** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 docstring;其余表达式是语句。 */
function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBlock {
const [expression, ...rest] = named(node)
if (docstring && rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string'))
if (docstring && docstringIn(node))
return leafBlock('comment', node)
const expression = named(node)[0]
const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null
if (!expression || !left)
return leafBlock('statement', node)
+44 -7
View File
@@ -1,4 +1,4 @@
import type { BlockShape, CodeBlock, Node, SourceSpan, TextKind } from '../contract'
import type { BlockShape, CodeBlock, FunctionBlock, Node, SourceSpan, TextKind } from '../contract'
/** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */
@@ -85,9 +85,24 @@ export function compoundShape(outer: Node, bodyStart: number, items: Node[], lis
return { node: outer, header, children: list([...between, ...items], header.endRow) }
}
/** 语言怎么认注释。 */
export interface CommentRules {
/** 行注释:相邻几行合成一块。 */
isLine: (comment: Node) => boolean
/** 文档注释(JSDoc):紧贴在函数块前面时是这个函数的说明,见 FunctionFacts.doc。 */
isDoc: (comment: Node) => boolean
}
/** 函数块带上说明:说明进 facts.doc,头部往上延到它开头。 */
function documented(block: FunctionBlock, doc: Node): FunctionBlock {
return { ...extendHeader(block, doc), facts: { ...block.facts, doc } }
}
/**
* 一串语句 → 块。注释单独成块,相邻行的行注释合成一块;
* 和前一条语句(或头部)同一行的注释是行尾注释,跟着那一行显示,不单独成块。
* 文档注释和下一条语句之间只有空白、下一条语句是函数块时,它是这个函数的说明,不单独成块;
* 中间隔着的装饰器(不出块的语句)不算隔开。
* afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1;
* toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。
*/
@@ -95,20 +110,32 @@ export function listBlocks(
items: Node[],
afterRow: number,
toBlocks: (item: Node, first: boolean) => CodeBlock[],
isLineComment: (comment: Node) => boolean,
comments: CommentRules,
): CodeBlock[] {
const out: CodeBlock[] = []
let lastRow = afterRow
let lineRun: CodeBlock | undefined
let doc: Node | undefined
let first = true
for (const item of items) {
const flushDoc = (): void => {
if (doc)
out.push(leafBlock('comment', doc))
doc = undefined
}
items.forEach((item, i) => {
if (item.type === 'comment') {
if (item.startPosition.row === lastRow)
continue
const line = isLineComment(item)
return
flushDoc()
const line = comments.isLine(item)
const next = items[i + 1]
if (line && lineRun && item.startPosition.row === lineRun.header.endRow + 1) {
lineRun.header = { ...lineRun.header, endIndex: item.endIndex, endRow: item.endPosition.row }
}
else if (comments.isDoc(item) && next && next.type !== 'comment' && sameNode(item.nextSibling, next)) {
doc = item
lineRun = undefined
}
else {
const block = leafBlock('comment', item)
out.push(block)
@@ -116,12 +143,22 @@ export function listBlocks(
}
}
else {
out.push(...toBlocks(item, first))
const blocks = toBlocks(item, first)
const [head] = blocks
if (head?.kind === 'function' && doc) {
blocks[0] = documented(head, doc)
doc = undefined
}
else if (head) {
flushDoc()
}
out.push(...blocks)
first = false
lineRun = undefined
}
lastRow = item.endPosition.row
}
})
flushDoc()
return out
}
@@ -13,15 +13,14 @@ 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 一致
// 与原型 docs/prototype/code-reader/figma-plugin/code.js 的 CODE_TREE 一致,只是紧贴函数的 JSDoc 算进了函数头部
const ENV_LOCK: Outline[] = [
{ at: [1, 10], kind: 'comment' },
{ at: 12, kind: 'import' },
{ at: 13, kind: 'import' },
{ at: 15, kind: 'const' },
{ at: 16, kind: 'const' },
{ at: [18, 22], kind: 'comment' },
{ at: 23, kind: 'function', kids: [
{ at: [18, 23], kind: 'function', kids: [
{ at: 24, kind: 'variable' },
{ at: 25, kind: 'if', kids: [
{ at: 26, kind: 'return' },
@@ -49,12 +48,10 @@ const ENV_LOCK: Outline[] = [
] },
{ at: 50, kind: 'return' },
] },
{ at: 53, kind: 'comment' },
{ at: 54, kind: 'function', kids: [
{ at: [53, 54], kind: 'function', kids: [
{ at: 55, kind: 'return' },
] },
{ at: 58, kind: 'comment' },
{ at: 59, kind: 'function', kids: [
{ at: [58, 59], kind: 'function', kids: [
{ at: 60, kind: 'return' },
] },
]
@@ -66,8 +63,7 @@ const ISSUE_LOCK: Outline[] = [
{ at: 5, kind: 'field' },
{ at: 6, kind: 'field' },
] },
{ at: [9, 12], kind: 'comment' },
{ at: 13, kind: 'function', kids: [
{ at: [9, 13], kind: 'function', kids: [
{ at: 14, kind: 'variable' },
{ at: 15, kind: 'if', kids: [
{ at: 16, kind: 'return' },
@@ -230,14 +226,97 @@ describe('typeScript 代码块树', () => {
})
})
describe('typeScript 函数的 JSDoc', () => {
const source = [
'/** 普通 */',
'function a() {}',
'/** 隔一个空行也算紧贴 */',
'',
'export function b() {}',
'/** 默认导出 */',
'export default function () {}',
'/** 函数值的变量 */',
'const c = () => {',
' /** 嵌套 */',
' function d() {}',
'}',
'class K {',
' /** 方法,隔着装饰器 */',
' @memo()',
' m() {}',
' /** 字段 */',
' x = 1',
'}',
'/** 常量 */',
'const y = 1',
'/* 普通块注释 */',
'function e() {}',
'// 行注释',
'function f() {}',
'/** 中间隔着行注释 */',
'// 行注释',
'function g() {}',
'foo() /** 行尾 */',
'function h() {}',
'/**/',
'function i() {}',
].join('\n')
it('紧贴在函数块前面的算进函数头部、不单独成块;前面不是函数块、没紧贴、不是 JSDoc 的照旧是注释块', async () => {
expect(await tree(source)).toEqual([
{ at: [1, 2], kind: 'function' },
{ at: [3, 5], kind: 'function' },
{ at: [6, 7], kind: 'function' },
{ at: [8, 9], kind: 'function', kids: [{ at: [10, 11], kind: 'function' }] },
{ at: 13, kind: 'class', kids: [
{ at: [14, 16], kind: 'function' },
{ at: 17, kind: 'comment' },
{ at: 18, kind: 'field' },
] },
{ at: 20, kind: 'comment' },
{ at: 21, kind: 'const' },
{ at: 22, kind: 'comment' },
{ at: 23, kind: 'function' },
{ at: 24, kind: 'comment' },
{ at: 25, kind: 'function' },
{ at: 26, kind: 'comment' },
{ at: 27, kind: 'comment' },
{ at: 28, kind: 'function' },
{ at: 29, kind: 'statement' },
{ at: 30, kind: 'function' },
{ at: 31, kind: 'comment' },
{ at: 32, kind: 'function' },
])
})
it('进 facts.doc:是那个注释节点;没挂上说明的函数没有', async () => {
const found = await facts(source)
expect(found[1]).toMatchObject({ name: 'a', doc: '/** 普通 */' })
expect(found[3]).toMatchObject({ name: 'b', doc: '/** 隔一个空行也算紧贴 */' })
expect(found[6]).toMatchObject({ doc: '/** 默认导出 */' })
expect(found[8]).toMatchObject({ name: 'c', doc: '/** 函数值的变量 */' })
expect(found[10]).toMatchObject({ name: 'd', doc: '/** 嵌套 */' })
expect(found[14]).toMatchObject({ name: 'm', doc: '/** 方法,隔着装饰器 */', decorators: ['@memo()'] })
for (const line of [23, 25, 28, 30, 32])
expect(found[line], `L${line}`).not.toHaveProperty('doc')
})
})
describe('typeScript 属性表信息(facts)', () => {
it('envLock.ts:导入、常量、函数、局部变量', async () => {
const env = await facts(readFixture('envLock.ts.txt'))
expect(env[12]).toEqual({ sources: ['\'node:fs\''], typeOnly: false, items: [{ kind: 'named', name: 'promises', alias: 'fsp' }] })
expect(env[13]).toEqual({ sources: ['\'vscode\''], typeOnly: false, items: [{ kind: 'named', name: 'RelativePattern' }, { kind: 'named', name: 'workspace' }] })
expect(env[15]).toEqual({ access: 'private', declarators: [{ name: 'ENV_PATTERN', value: '\'**/.env*\'' }] })
expect(env[23]).toEqual({
expect(env[18]).toEqual({
name: 'findEnvFiles',
doc: [
'/**',
' * Scan the workspace folder rooted at `workspaceRoot` for all `.env*`',
' * files, skipping `node_modules`, `.git`, and `.claude`. Returns absolute',
' * fs paths. Empty array if the workspace folder isn\'t currently open.',
' */',
].join('\n'),
access: 'public',
async: true,
decorators: [],
@@ -1,4 +1,5 @@
import type { Access, BlockShape, CodeBlock, DeclaratorFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, extendHeader, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
@@ -13,8 +14,10 @@ const FUNCTION_VALUES = new Set(['arrow_function', 'function_expression', 'funct
const LOOPS = new Set(['for_statement', 'for_in_statement', 'while_statement', 'do_statement'])
const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class'])
function isLineComment(comment: Node): boolean {
return comment.text.startsWith('//')
const COMMENTS: CommentRules = {
isLine: comment => comment.text.startsWith('//'),
// `/**/` 是空的块注释,不是 JSDoc
isDoc: comment => comment.text.startsWith('/**') && !comment.text.startsWith('/**/'),
}
/** 带语句块主体的函数值(箭头函数 / 函数表达式)的主体。 */
@@ -39,7 +42,7 @@ function callbackBody(expression: Node | null | undefined): Node | null {
}
function statements(items: Node[], top: boolean, afterRow: number): CodeBlock[] {
return listBlocks(items, afterRow, item => statementBlocks(item, top), isLineComment)
return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS)
}
/** 主体是语句块时子块来自它的语句,否则主体本身就是唯一的子语句(`if (x) return`)。 */
@@ -291,7 +294,7 @@ function classBlock(declaration: Node, outer: Node): CodeBlock {
const [from] = decorators
decorators = []
return from ? blocks.map((block, i) => i === 0 ? extendHeader(block, from) : block) : blocks
}, isLineComment)
}, COMMENTS)
}),
}
}
@@ -321,7 +324,7 @@ function membersBlock(declaration: Node, outer: Node): CodeBlock {
return {
kind: 'type',
...compoundShape(outer, body.startIndex, named(body), (list, afterRow) =>
listBlocks(list, afterRow, member => [leafBlock('field', member)], isLineComment)),
listBlocks(list, afterRow, member => [leafBlock('field', member)], COMMENTS)),
}
}