✨ feat(vscode): 代码阅读认出代码里起的中文名(Annotated[…, Comment("…")]、@Comment 装饰器、行尾 # @Comment、docstring / JSDoc 的参数说明),预览里定义和用到它的各处都写中文名,悬停看原名,定义处后面淡淡写原名
This commit is contained in:
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),
|
||||
}
|
||||
Reference in new issue
Block a user