✨ feat(vscode): 代码阅读认出代码里起的中文名(Annotated[…, Comment("…")]、@Comment 装饰器、行尾 # @Comment、docstring / JSDoc 的参数说明),预览里定义和用到它的各处都写中文名,悬停看原名,定义处后面淡淡写原名

This commit is contained in:
cruldra committed 2026-09-27 00:56:39 +08:00
1 parent 1b1299dc1f
commit 44c221ea2b
45 files changed
+1685 -128

No files matched your search

@@ -0,0 +1,74 @@
import type { DisplayName, DisplayNameFacts, Node, SourceRange } from '../contract'
/** 给定义起中文名的那个调用 / 装饰器 / 注释记号的名字:`Comment("当前用户")`、`@Comment("…")`、`# @Comment 当前用户`。 */
export const COMMENT_MARK = 'Comment'
/** 说明文字里的一句话最多几个字才当中文名 */
export const CLAUSE_LIMIT = 12
/** 说明文字的第一小句(到第一个 ,。,.;;:: 为止)去掉首尾空白;空的、超过 CLAUSE_LIMIT 个字的不当中文名。 */
export function firstClause(text: string): string | undefined {
const clause = text.split(/[,。,.;;::]/, 1)[0].trim()
return clause && [...clause].length <= CLAUSE_LIMIT ? clause : undefined
}
/** 行注释里的中文名记号 `@Comment 名字`(名字一直到注释结尾):名字,和记号从注释文字的第几个字开始。 */
export function commentTag(text: string): { text: string, start: number } | undefined {
const match = new RegExp(`@${COMMENT_MARK}[ \\t]+(\\S.*)$`).exec(text)
return match ? { text: match[1].trimEnd(), start: match.index } : undefined
}
/** 一个定义和语言插件从代码里读到的中文名。 */
export interface NameCandidate {
/** 定义处那个名字 */
name: Node
/** 写在代码里的记号给的:`Annotated[T, Comment("…")]`、`@Comment("…")` */
marked?: string
/** 说明文字里取的:docstring 的参数说明、JSDoc 的 @param */
described?: string
/** 行尾的 `@Comment` 记号能给它:参数、变量、字段、枚举成员、属性(函数、类这些用装饰器) */
trailing?: boolean
}
function rangeOf(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/** 注释前面同一行有代码:是写在行尾的注释。 */
function isTrailing(comment: Node, source: string): boolean {
const lineStart = source.lastIndexOf('\n', comment.startIndex - 1) + 1
return source.slice(lineStart, comment.startIndex).trim() !== ''
}
/**
* 汇总一份源码里的中文名。行尾注释里的记号(`x = 1 # @Comment 数量`)给那一行第一个能带行尾记号的定义(多行的参数表里就是那一行的参数),
* 记号都算 markers(翻注释、说明时去掉),不管这个定义最后取没取它。
* 每个定义取哪个:写在代码里的记号,其次行尾记号,最后才是说明文字。
*/
export function displayNameFacts(candidates: readonly NameCandidate[], lineComments: readonly Node[], source: string): DisplayNameFacts {
const byStart = new Map<number, NameCandidate & { tagged?: string }>()
for (const candidate of candidates) {
const known = byStart.get(candidate.name.startIndex)
byStart.set(candidate.name.startIndex, {
name: candidate.name,
marked: known?.marked ?? candidate.marked,
described: known?.described ?? candidate.described,
trailing: known?.trailing || candidate.trailing,
})
}
const ordered = [...byStart.values()].sort((a, b) => a.name.startIndex - b.name.startIndex)
const markers: SourceRange[] = []
for (const comment of lineComments) {
const tag = isTrailing(comment, source) ? commentTag(comment.text) : undefined
const owner = tag && ordered.find(candidate => candidate.trailing && candidate.name.startPosition.row === comment.startPosition.row)
if (!tag || !owner)
continue
owner.tagged ??= tag.text
markers.push({ startIndex: comment.startIndex + tag.start, endIndex: comment.endIndex })
}
const names = ordered.flatMap((candidate): DisplayName[] => {
const text = candidate.marked ?? candidate.tagged ?? candidate.described
return text ? [{ name: rangeOf(candidate.name), text }] : []
})
return { names, markers }
}
@@ -1,6 +1,7 @@
import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { pythonNameReader } from './pythonNames'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
/** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */
@@ -486,5 +487,6 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter {
toBlocks: root => statements(named(root), 'module', -1),
tokenRole: pythonTokenRole,
iteration: pythonIteration,
displayNames: pythonNameReader,
}
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import { runtime, WASM_DIR } from '../testSupport'
import { createPythonAdapter } from './python'
import { docstringParameters } from './pythonNames'
const py = createPythonAdapter(WASM_DIR)
/** 认出的中文名写成「原名 → 中文名」(原名前面加行号,1 起),记号写成源码原文。 */
async function names(source: string): Promise<{ names: string[], markers: string[] }> {
const tree = await runtime.parse(py, 'python', source)
try {
const facts = py.displayNames!.find(tree.rootNode, source, 'python')
const row = (index: number): number => source.slice(0, index).split('\n').length
return {
names: facts.names.map(({ name, text }) => `${row(name.startIndex)}:${source.slice(name.startIndex, name.endIndex)} → ${text}`),
markers: facts.markers.map(({ startIndex, endIndex }) => source.slice(startIndex, endIndex)),
}
}
finally {
tree.delete()
}
}
describe('python 中文名:写在代码里的记号', () => {
it('注解 Annotated[T, Comment("…")]:参数、变量、类字段;typing.Annotated、t.Comment 也算,调用的名字最后一节是 Comment 就算', async () => {
const source = [
'def get(current_user: Annotated[User, Depends(auth), Comment("当前用户")], db: typing.Annotated[Session, t.Comment("数据库")] = None):',
' count: Annotated[int, Comment("数量")] = 0',
'class Order:',
' total: Annotated[Decimal, Comment("总价")]',
' note: Annotated[str, Other("不是")]',
' tag: Annotated[str, Comment(f"{x}")]',
].join('\n')
expect((await names(source)).names).toEqual(['1:current_user → 当前用户', '1:db → 数据库', '2:count → 数量', '4:total → 总价'])
})
it('@Comment("…") 装饰器:函数、类', async () => {
const source = '@Comment("取用户")\n@router.get("/")\nasync def get_user():\n pass\n\n@Comment("订单")\nclass Order:\n pass\n\n@other("x")\ndef plain():\n pass\n'
expect((await names(source)).names).toEqual(['3:get_user → 取用户', '7:Order → 订单'])
})
it('行尾的 # @Comment 名字:多行参数表里的参数、变量、字段、枚举成员、self.x;记号从 @Comment 到行尾', async () => {
const source = [
'async def delete_item(',
' item_id: int,',
' current_user: User = Depends(get_current_user), # @Comment 当前用户',
' *args, # @Comment 其余参数',
'):',
' self.owner = current_user # 持有人 @Comment 拥有者',
'LIMIT = 10 # @Comment 上限',
'class Role(str, Enum):',
' ADMIN = "admin" # @Comment 管理员',
' name: str # @Comment 名称 ',
].join('\n')
const found = await names(source)
expect(found.names).toEqual(['3:current_user → 当前用户', '4:args → 其余参数', '6:owner → 拥有者', '7:LIMIT → 上限', '9:ADMIN → 管理员', '10:name → 名称'])
expect(found.markers).toEqual(['@Comment 当前用户', '@Comment 其余参数', '@Comment 拥有者', '@Comment 上限', '@Comment 管理员', '@Comment 名称 '])
})
it('单独成行的注释、行尾没有名字的记号、函数名那一行的记号不算(函数、类用装饰器);那一行第一个能带记号的定义拿到它', async () => {
const source = [
'# @Comment 单独一行',
'x = 1',
'print(x) # @Comment 没有定义',
'def f(a, b): # @Comment 甲',
' pass',
].join('\n')
const found = await names(source)
expect(found.names).toEqual(['4:a → 甲'])
expect(found.markers).toEqual(['@Comment 甲'])
})
})
describe('python 中文名:函数 docstring 里的参数说明(没有明写的记号时)', () => {
it('谷歌写法 Args: 一节、Sphinx 写法 :param:取第一小句,不超过 12 个字才算', async () => {
const source = [
'def create(user_id, amount, note, *items, **extra):',
' """建订单。',
'',
' Args:',
' user_id (int): 下单用户的编号,从 token 里取',
' amount: 金额。单位是分',
' note: 这是一段特别特别特别长的备注说明文字',
' 续行不算一项',
' *items: 商品; 可以有多个',
'',
' Returns:',
' extra: 不在 Args 里',
' """',
'',
'def update(order_id, status):',
' """:param order_id: 订单编号,必填',
' :param str status: 新状态',
' """',
].join('\n')
expect((await names(source)).names).toEqual([
'1:user_id → 下单用户的编号',
'1:amount → 金额',
'1:items → 商品',
'15:order_id → 订单编号',
'15:status → 新状态',
])
})
it('明写的记号优先:Annotated / 行尾记号盖过 docstring;同一个参数两种记号都写了取 Annotated,行尾那个照样算记号(显示说明时去掉)', async () => {
const source = [
'def get(',
' a: Annotated[int, Comment("甲")], # @Comment 乙',
' b: int, # @Comment 丙',
' c: int,',
'):',
' """Args:',
' a: 丁',
' b: 戊',
' c: 己',
' """',
].join('\n')
const found = await names(source)
expect(found.names).toEqual(['2:a → 甲', '3:b → 丙', '4:c → 己'])
expect(found.markers).toEqual(['@Comment 乙', '@Comment 丙'])
})
it('docstringParameters:名字去掉星号,第一个说明为准,段落结束就不再认', () => {
expect([...docstringParameters('Args:\n x: 甲\n x: 乙\nNotes:\n y: 丙\n')]).toEqual([['x', '甲']])
})
})
describe('python 中文名:预筛和工作区里的文件', () => {
it('写了 Comment、Args:、:param 的源码才解析;文件是所有 .py', () => {
const reader = py.displayNames!
expect(reader.files).toEqual({ python: '**/*.py' })
expect(reader.mentions('x = 1 # @Comment 数量')).toBe(true)
expect(reader.mentions('"""\nArgs:\n x: 甲\n"""')).toBe(true)
expect(reader.mentions(':param x: 甲')).toBe(true)
expect(reader.mentions('x = 1\n')).toBe(false)
})
})
@@ -0,0 +1,189 @@
import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract'
import type { NameCandidate } from './displayNames'
import { COMMENT_MARK, displayNameFacts, firstClause } from './displayNames'
import { children, named } from './shared'
/** 名字的最后一节:`Comment`、`typing.Annotated` 的 `Annotated`。 */
function lastName(node: Node | null | undefined): string | undefined {
if (node?.type === 'identifier')
return node.text
return node?.type === 'attribute' ? node.childForFieldName('attribute')?.text : undefined
}
/** 普通字符串字面量的内容(不带引号、前缀);f-string 这类带插值的没有。 */
function plainString(node: Node | undefined): string | undefined {
if (node?.type !== 'string' || named(node).some(part => part.type === 'interpolation'))
return undefined
const start = named(node).find(part => part.type === 'string_start')
const end = named(node).find(part => part.type === 'string_end')
const text = start && end ? node.text.slice(start.endIndex - node.startIndex, end.startIndex - node.startIndex).trim() : ''
return text || undefined
}
/** `Comment("当前用户")`(调用的名字最后一节是 Comment,不管从哪导入、是不是本地定义的)的第一个参数。 */
function commentCall(node: Node | undefined): string | undefined {
if (node?.type !== 'call' || lastName(node.childForFieldName('function')) !== COMMENT_MARK)
return undefined
return plainString(named(node.childForFieldName('arguments'))[0])
}
/** 注解 `Annotated[T, …, Comment("…")]`(`typing.Annotated` 也算)里的中文名。 */
function annotatedName(annotation: Node | null): string | undefined {
const inner = named(annotation)[0]
let head: Node | undefined
let items: Node[] = []
if (inner?.type === 'generic_type') {
head = named(inner)[0]
items = named(named(inner).find(part => part.type === 'type_parameter')).map(item => item.type === 'type' ? named(item)[0] : item)
}
else if (inner?.type === 'subscript') {
head = inner.childForFieldName('value') ?? undefined
items = inner.childrenForFieldName('subscript').filter((item): item is Node => item !== null)
}
if (lastName(head) !== 'Annotated')
return undefined
return items.slice(1).map(commentCall).find(text => text !== undefined)
}
/** 装饰器 `@Comment("…")` 给的中文名。 */
function decoratorName(definition: Node): string | undefined {
const outer = definition.parent?.type === 'decorated_definition' ? definition.parent : undefined
return named(outer).filter(child => child.type === 'decorator').map(decorator => commentCall(named(decorator)[0])).find(text => text !== undefined)
}
/** 参数节点里定义的那个名字:`x`、`x: int`、`x=1`、`*args`、`**kw`;`*`、`/` 分隔符没有。 */
function parameterName(param: Node): Node | undefined {
switch (param.type) {
case 'identifier':
return param
case 'typed_parameter':
return parameterName(named(param)[0])
case 'default_parameter':
case 'typed_default_parameter':
return param.childForFieldName('name') ?? undefined
case 'list_splat_pattern':
case 'dictionary_splat_pattern':
return named(param).find(child => child.type === 'identifier')
default:
return undefined
}
}
/**
* docstring 里各参数的说明取第一小句:Google 写法 `Args:` 一节里的 `name (type): 说明`,Sphinx 写法 `:param name: 说明`
* (`:param type name:` 也算)。名字去掉 `*` / `**`。
*/
export function docstringParameters(doc: string): Map<string, string> {
const found = new Map<string, string>()
const put = (name: string, text: string): void => {
const clause = firstClause(text)
if (clause && !found.has(name))
found.set(name, clause)
}
const lines = doc.split('\n')
const indentOf = (line: string): number => line.length - line.trimStart().length
for (let i = 0; i < lines.length; i++) {
const sphinx = /^\s*:param\s+(?:[^\s:]+\s+)?\*{0,2}(\w+)\s*:(.*)$/.exec(lines[i])
if (sphinx) {
put(sphinx[1], sphinx[2])
continue
}
if (!/^\s*(?:Args|Arguments):\s*$/.test(lines[i]))
continue
const sectionIndent = indentOf(lines[i])
let entryIndent: number | undefined
for (i++; i < lines.length; i++) {
const line = lines[i]
if (line.trim() === '')
continue
const indent = indentOf(line)
if (indent <= sectionIndent) {
i--
break
}
entryIndent ??= indent
const entry = indent === entryIndent ? /^\s*\*{0,2}(\w+)(?:\s*\([^)]*\))?\s*:(.*)$/.exec(line) : null
if (entry)
put(entry[1], entry[2])
}
}
return found
}
/** 函数主体开头的 docstring 的原文。 */
function docstringOf(fn: Node): string | undefined {
const first = named(fn.childForFieldName('body')).find(child => child.type !== 'comment')
const [expression, ...rest] = first?.type === 'expression_statement' ? named(first) : []
return rest.length === 0 ? plainString(expression) : undefined
}
function functionCandidates(fn: Node): NameCandidate[] {
const name = fn.childForFieldName('name')
const doc = docstringOf(fn)
const described = doc === undefined ? new Map<string, string>() : docstringParameters(doc)
const params = named(fn.childForFieldName('parameters')).flatMap((param): NameCandidate[] => {
const paramName = parameterName(param)
if (!paramName)
return []
return [{ name: paramName, marked: annotatedName(param.childForFieldName('type')), described: described.get(paramName.text), trailing: true }]
})
return [...(name ? [{ name, marked: decoratorName(fn) }] : []), ...params]
}
/** 赋值里定义的名字:`x = …`、`x: T = …`、只写注解的 `x: T`、`self.x = …` 的 x、解构的各个名字。 */
function targetNames(target: Node | null): Node[] {
if (!target)
return []
if (target.type === 'identifier')
return [target]
if (target.type === 'attribute') {
const attribute = target.childForFieldName('attribute')
return attribute ? [attribute] : []
}
if (['pattern_list', 'tuple_pattern', 'list_pattern', 'list_splat_pattern'].includes(target.type))
return named(target).flatMap(targetNames)
return []
}
function assignmentCandidates(assignment: Node): NameCandidate[] {
const marked = annotatedName(assignment.childForFieldName('type'))
return targetNames(assignment.childForFieldName('left')).map(name => ({ name, marked, trailing: true }))
}
/**
* Python 的中文名:`Annotated[T, Comment("…")]`(参数、变量、类字段)、`@Comment("…")`(函数、类)、
* 定义那一行行尾的 `# @Comment 名字`(多行参数表里的参数、变量、字段、枚举成员),都没有时参数取函数 docstring 里它的说明。
*/
export function pythonDisplayNames(root: Node, source: string): DisplayNameFacts {
const candidates: NameCandidate[] = []
const comments: Node[] = []
const visit = (node: Node): void => {
switch (node.type) {
case 'comment':
comments.push(node)
return
case 'function_definition':
candidates.push(...functionCandidates(node))
break
case 'class_definition': {
const name = node.childForFieldName('name')
if (name)
candidates.push({ name, marked: decoratorName(node) })
break
}
case 'assignment':
candidates.push(...assignmentCandidates(node))
break
}
for (const child of children(node))
visit(child)
}
visit(root)
return displayNameFacts(candidates, comments, source)
}
export const pythonNameReader: DisplayNameReader = {
files: { python: '**/*.py' },
mentions: source => source.includes(COMMENT_MARK) || /\b(?:Args|Arguments):|:param\s/.test(source),
find: (root, source) => pythonDisplayNames(root, source),
}
@@ -2,6 +2,7 @@ import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFa
import type { CommentRules } from './shared'
import * as path from 'node:path'
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared'
import { typescriptNameReader } from './typescriptNames'
const GRAMMARS: Record<string, string> = {
typescript: 'tree-sitter-typescript.wasm',
@@ -585,5 +586,6 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter {
},
toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root),
tokenRole: typescriptTokenRole,
displayNames: typescriptNameReader,
}
}
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import { runtime, WASM_DIR } from '../testSupport'
import { createTypeScriptAdapter } from './typescript'
import { jsdocParameters } from './typescriptNames'
const ts = createTypeScriptAdapter(WASM_DIR)
/** 认出的中文名写成「行号:原名 → 中文名」(行号 1 起),记号写成源码原文。 */
async function names(source: string, languageId = 'typescript'): Promise<{ names: string[], markers: string[] }> {
const tree = await runtime.parse(ts, languageId, source)
try {
const facts = ts.displayNames!.find(tree.rootNode, source, languageId)
const row = (index: number): number => source.slice(0, index).split('\n').length
return {
names: facts.names.map(({ name, text }) => `${row(name.startIndex)}:${source.slice(name.startIndex, name.endIndex)} → ${text}`),
markers: facts.markers.map(({ startIndex, endIndex }) => source.slice(startIndex, endIndex)),
}
}
finally {
tree.delete()
}
}
describe('typescript 中文名:@Comment 装饰器', () => {
it('类(@d export class 也算)、方法、字段、构造函数和方法的参数;调用的名字最后一节是 Comment 就算', async () => {
const source = [
'@Comment(\'服务\')',
'export class UserService {',
' @Comment(\'仓库\') repo = new Repo()',
' constructor(@Comment(\'配置\') private readonly config: Config, other: number) {}',
' @decorators.Comment(`取用户`)',
' @Get()',
' find(@Comment("编号") id: string) {}',
' @Other(\'不是\') plain() {}',
'}',
].join('\n')
expect((await names(source)).names).toEqual(['2:UserService → 服务', '3:repo → 仓库', '4:config → 配置', '7:find → 取用户', '7:id → 编号'])
})
})
describe('typescript 中文名:行尾的 // @Comment 名字', () => {
it('参数、变量、枚举成员、类字段、接口属性;块注释、单独成行的注释不算;函数那一行给那一行的参数(函数、类用装饰器)', async () => {
const source = [
'function remove(',
' itemId: number, // @Comment 编号',
' user?: User, // 当前登录的 @Comment 当前用户',
') {}',
'const total = items.map(item => item.price) // @Comment 总价',
'enum Role {',
' ADMIN = \'admin\', // @Comment 管理员',
' USER, // @Comment 普通用户',
'}',
'class Cart {',
' count = 0 // @Comment 件数',
'}',
'interface Page {',
' size: number // @Comment 每页条数',
'}',
'let a = 1 /* @Comment 块注释不算 */',
'// @Comment 单独一行',
'let b = 2',
'function f(x: number) { // @Comment 函数那一行',
'}',
].join('\n')
const found = await names(source)
expect(found.names).toEqual([
'2:itemId → 编号',
'3:user → 当前用户',
'5:total → 总价',
'7:ADMIN → 管理员',
'8:USER → 普通用户',
'11:count → 件数',
'14:size → 每页条数',
'19:x → 函数那一行',
])
expect(found.markers).toEqual(['@Comment 编号', '@Comment 当前用户', '@Comment 总价', '@Comment 管理员', '@Comment 普通用户', '@Comment 件数', '@Comment 每页条数', '@Comment 函数那一行'])
})
})
describe('typescript 中文名:JSDoc 的 @param(没有明写的记号时)', () => {
it('export 的函数、方法(隔着装饰器)、值是箭头函数的变量、类字段;{类型}、短横、[可选] 都认,取第一小句、不超过 12 个字', async () => {
const source = [
'/**',
' * 删一项。',
' * @param {number} itemId - 要删的编号,从路径取',
' * @param [force=false] 强制删除',
' * @param opts.x 属性不算',
' * @param note 一段特别特别特别长的备注说明文字',
' */',
'export function remove(itemId: number, force = false, note = \'\') {}',
'class Api {',
' /** @param id 编号 */',
' @Get()',
' load(id: string) {}',
' /** @param e 事件 */',
' onClick = (e: Event) => {}',
'}',
'/** @param a 甲 */',
'const add = (a: number) => a',
'/** @param a 丁 */',
'const other = 1',
'function g(a: number) {}',
].join('\n')
expect((await names(source)).names).toEqual([
'8:itemId → 要删的编号',
'8:force → 强制删除',
'12:id → 编号',
'14:e → 事件',
'17:a → 甲',
])
})
it('明写的记号优先:装饰器、行尾记号盖过 JSDoc;JavaScript 的默认值参数也认', async () => {
const source = [
'/**',
' * @param a 甲',
' * @param b 乙',
' */',
'function f(',
' a, // @Comment 丙',
' b = 1,',
') {}',
].join('\n')
expect((await names(source, 'javascript')).names).toEqual(['6:a → 丙', '7:b → 乙'])
})
it('jsdocParameters:同名的取第一个', () => {
expect([...jsdocParameters('@param x 甲\n@param x 乙')]).toEqual([['x', '甲']])
})
})
describe('typescript 中文名:预筛和工作区里的文件', () => {
it('写了 Comment、@param 的源码才解析;四种方言各自的文件', () => {
const reader = ts.displayNames!
expect(reader.files).toEqual({
typescript: '**/*.{ts,mts,cts}',
typescriptreact: '**/*.tsx',
javascript: '**/*.{js,mjs,cjs}',
javascriptreact: '**/*.jsx',
})
expect(reader.mentions('const a = 1 // @Comment 甲')).toBe(true)
expect(reader.mentions('/** @param a 甲 */')).toBe(true)
expect(reader.mentions('const a = 1')).toBe(false)
})
})
@@ -0,0 +1,172 @@
import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract'
import type { NameCandidate } from './displayNames'
import { COMMENT_MARK, displayNameFacts, firstClause } from './displayNames'
import { children, named } from './shared'
const DECLARED_FUNCTIONS = new Set(['function_declaration', 'generator_function_declaration', 'function_signature'])
const METHODS = new Set(['method_definition', 'method_signature', 'abstract_method_signature'])
const FUNCTION_VALUES = new Set(['function_expression', 'function', 'generator_function', 'arrow_function'])
const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class'])
const DECLARATIONS = new Set(['interface_declaration', 'type_alias_declaration', 'enum_declaration'])
const PROPERTIES = new Set(['public_field_definition', 'property_signature'])
const NAMES = new Set(['identifier', 'type_identifier', 'property_identifier', 'private_property_identifier'])
/** 字符串字面量的内容(不带引号);带 `${}` 的模板字符串没有。 */
function plainString(node: Node | undefined): string | undefined {
if (!node || (node.type !== 'string' && node.type !== 'template_string') || named(node).some(part => part.type === 'template_substitution'))
return undefined
return node.text.slice(1, -1).trim() || undefined
}
/** `@Comment('…')`(调用的名字最后一节是 Comment)给的中文名。 */
function decoratorName(decorators: readonly Node[]): string | undefined {
for (const decorator of decorators) {
const call = named(decorator)[0]
const callee = call?.type === 'call_expression' ? call.childForFieldName('function') : null
const last = callee?.type === 'member_expression' ? callee.childForFieldName('property') : callee
if (last?.text === COMMENT_MARK) {
const text = plainString(named(call?.childForFieldName('arguments'))[0])
if (text)
return text
}
}
return undefined
}
/** 节点自己带的装饰器(参数、字段、类声明)。 */
function ownDecorators(node: Node | null | undefined): Node[] {
return named(node).filter(child => child.type === 'decorator')
}
/** 类主体里排在成员前面的装饰器(方法的装饰器是它前面的兄弟节点)。 */
function precedingDecorators(member: Node): Node[] {
const found: Node[] = []
for (let prev = member.previousNamedSibling; prev?.type === 'decorator'; prev = prev.previousNamedSibling)
found.push(prev)
return found
}
/** 声明外面包着的 export 语句(没有就是它自己):JSDoc、`@d export class` 的装饰器挂在它上面。 */
function outerStatement(node: Node): Node {
return node.parent?.type === 'export_statement' ? node.parent : node
}
/** 紧贴在前面的 JSDoc(中间隔着装饰器也算)的原文,去掉开头结尾的注释符号和每行开头的星号。 */
function jsdocBefore(node: Node): string | undefined {
let prev = node.previousNamedSibling
while (prev?.type === 'decorator')
prev = prev.previousNamedSibling
if (prev?.type !== 'comment' || !prev.text.startsWith('/**') || prev.text.startsWith('/**/'))
return undefined
return prev.text.replace(/^\/\*+/, '').replace(/\*+\/$/, '').split('\n').map(line => line.replace(/^\s*\*(?!\/)\s?/, '')).join('\n')
}
/** JSDoc 里各参数 `@param [{类型}] 名字 [-] 说明` 的说明取第一小句;`[名字]`、`[名字=默认]` 也算,`opts.x` 这种属性不算。 */
export function jsdocParameters(doc: string): Map<string, string> {
const found = new Map<string, string>()
for (const line of doc.split('\n')) {
const at = line.indexOf('@param')
let rest = at < 0 ? '' : line.slice(at + '@param'.length).trimStart()
if (rest.startsWith('{'))
rest = rest.includes('}') ? rest.slice(rest.indexOf('}') + 1).trimStart() : ''
const token = rest.split(/\s/, 1)[0]
const name = token.replace(/^\[/, '').replace(/\]$/, '').split('=')[0]
const clause = /^[A-Z_$][\w$]*$/i.test(name) ? firstClause(rest.slice(token.length).trim().replace(/^-\s*/, '')) : undefined
if (clause && !found.has(name))
found.set(name, clause)
}
return found
}
/** 函数的 JSDoc 挂在哪:声明(连 export)、方法本身、值是这个函数的变量声明(连 export)或类字段。 */
function functionDoc(fn: Node): string | undefined {
if (METHODS.has(fn.type))
return jsdocBefore(fn)
if (DECLARED_FUNCTIONS.has(fn.type))
return jsdocBefore(outerStatement(fn))
const holder = fn.parent
if (holder?.type === 'public_field_definition')
return jsdocBefore(holder)
const declaration = holder?.type === 'variable_declarator' ? holder.parent : null
return declaration ? jsdocBefore(outerStatement(declaration)) : undefined
}
/** 参数节点里定义的那个名字:`x`、`x?: T`、`x = 1`(JavaScript)、不带括号的箭头函数参数;解构的不算。 */
function parameterName(param: Node): Node | undefined {
switch (param.type) {
case 'identifier':
return param
case 'required_parameter':
case 'optional_parameter': {
const pattern = param.childForFieldName('pattern')
return pattern?.type === 'identifier' ? pattern : undefined
}
case 'assignment_pattern': {
const left = param.childForFieldName('left')
return left?.type === 'identifier' ? left : undefined
}
default:
return undefined
}
}
function functionCandidates(fn: Node): NameCandidate[] {
const doc = functionDoc(fn)
const described = doc === undefined ? new Map<string, string>() : jsdocParameters(doc)
const single = fn.childForFieldName('parameter')
const params = (single ? [single] : named(fn.childForFieldName('parameters'))).flatMap((param): NameCandidate[] => {
const name = parameterName(param)
return name ? [{ name, marked: decoratorName(ownDecorators(param)), described: described.get(name.text), trailing: true }] : []
})
const name = FUNCTION_VALUES.has(fn.type) ? null : fn.childForFieldName('name')
const own = name ? [{ name, marked: METHODS.has(fn.type) ? decoratorName(precedingDecorators(fn)) : undefined }] : []
return [...own, ...params]
}
/**
* TypeScript / JavaScript 的中文名:`@Comment('…')` 装饰器(类、方法、字段、方法和构造函数的参数)、
* 定义那一行行尾的 `// @Comment 名字`(参数、变量、枚举成员、属性),都没有时参数取 JSDoc 里 `@param` 的说明。
*/
export function typescriptDisplayNames(root: Node, source: string): DisplayNameFacts {
const candidates: NameCandidate[] = []
const comments: Node[] = []
const nameOf = (node: Node): Node | undefined => {
const name = node.childForFieldName('name')
return name && NAMES.has(name.type) ? name : undefined
}
const visit = (node: Node): void => {
const name = nameOf(node)
if (node.type === 'comment') {
if (node.text.startsWith('//'))
comments.push(node)
return
}
if (DECLARED_FUNCTIONS.has(node.type) || METHODS.has(node.type) || FUNCTION_VALUES.has(node.type))
candidates.push(...functionCandidates(node))
else if (CLASSES.has(node.type) && name)
candidates.push({ name, marked: decoratorName([...ownDecorators(node), ...ownDecorators(outerStatement(node) === node ? undefined : node.parent)]) })
else if (PROPERTIES.has(node.type) && name)
candidates.push({ name, marked: decoratorName(ownDecorators(node)), trailing: true })
else if ((node.type === 'variable_declarator' || node.type === 'enum_assignment') && name)
candidates.push({ name, trailing: true })
else if (DECLARATIONS.has(node.type) && name)
candidates.push({ name })
else if (node.type === 'property_identifier' && node.parent?.type === 'enum_body')
candidates.push({ name: node, trailing: true })
for (const child of children(node))
visit(child)
}
visit(root)
return displayNameFacts(candidates, comments, source)
}
export const typescriptNameReader: DisplayNameReader = {
files: {
typescript: '**/*.{ts,mts,cts}',
typescriptreact: '**/*.tsx',
javascript: '**/*.{js,mjs,cjs}',
javascriptreact: '**/*.jsx',
},
mentions: source => source.includes(COMMENT_MARK) || source.includes('@param'),
find: (root, source) => typescriptDisplayNames(root, source),
}