✨ feat(vscode): 代码阅读的注释和函数说明按 Markdown 渲染

This commit is contained in:
cruldra committed 2026-09-25 14:28:23 +08:00
1 parent aae9012f14
commit a53c2a6aac
18 files changed
+394 -66

No files matched your search

+10 -3
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { readBlockViews, toBlockViews } from './blockView'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { propertyLines, readFixture, runtime, segmentText, WASM_DIR, withBlocks } from './testSupport'
import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
@@ -38,7 +38,7 @@ const PROPERTY_KINDS = new Set(['function', 'import', 'const', 'variable'])
function translationText(view: BlockView): string {
if (!('translation' in view))
throw new Error(`块 ${view.id} 是属性表`)
return segmentText(view.translation)
return previewText(view.translation)
}
describe('blockView', () => {
@@ -53,7 +53,7 @@ describe('blockView', () => {
if ('properties' in view)
expect(view.properties.length, view.id).toBeGreaterThan(0)
else
expect(view.translation.length, view.id).toBeGreaterThan(0)
expect(previewText(view.translation), view.id).not.toBe('')
}
})
@@ -100,6 +100,13 @@ describe('blockView', () => {
expect(getUser?.children[0]).toMatchObject({ kind: 'comment', startLine: 38, endLine: 38 })
})
it('注释块的译文、函数的「说明」是 Markdown 原文,webview 按它渲染', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
expect(byId(env, '0')).toMatchObject({ kind: 'comment', translation: { markdown: expect.stringMatching(/^\.env file lock \/ unlock helpers\.\n\nRecursively/) } })
const findEnvFiles = byId(env, '5')
expect('properties' in findEnvFiles && findEnvFiles.properties[1]).toEqual({ label: '说明', value: { markdown: expect.stringMatching(/^Scan the workspace folder/) } })
})
it('id 是块在树里的位置,行号 0 起,多行块去掉共同缩进', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
expect(byId(env, '5.1.0')).toMatchObject({ kind: 'return', startLine: 25, endLine: 25, code: 'return []' })
+3 -3
View File
@@ -1,4 +1,4 @@
import type { BlockKind, CodeBlock, LanguageAdapter, Node, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract'
import type { BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceRange, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { commonIndent, splitLines } from './editText'
@@ -18,8 +18,8 @@ interface BlockViewBase {
children: BlockView[]
}
/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,其余是一行译文。 */
export type BlockView = BlockViewBase & ({ translation: Segment[] } | { properties: PropertyRow[] })
/** 发给 webview 的一块(可序列化):函数、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
+13 -4
View File
@@ -110,6 +110,15 @@ export type TokenRole
*/
export interface Segment { text: string, role: TokenRole, offset?: number }
/**
* 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换
* (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。
*/
export interface Markdown { markdown: string }
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数的说明)。 */
export type PreviewText = Segment[] | Markdown
/**
* 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、
* 声明的变量名)在源码里的范围。跳到定义时,定义处落在这个范围里就只闪这一行;不填(第三方翻译插件可以不填)就闪整块。
@@ -119,10 +128,10 @@ export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值。 */
export interface PropertyItem extends Defines { cells: Segment[][] }
/** 属性表的一行:左列属性名,右列着色的值;items 是展开在这一行下面的子行(输入参数、导入项)。 */
/** 属性表的一行:左列属性名,右列着色的值或 Markdown 说明;items 是展开在这一行下面的子行(输入参数、导入项)。 */
export interface PropertyRow extends Defines {
label: string
value: Segment[]
value: PreviewText
items?: PropertyItem[]
}
@@ -143,8 +152,8 @@ export interface Translator {
id: string // 'typescript.zh-template'
label: string // '中文直译'
languageIds: string[]
/** 只翻 header 那一段。 */
translate: (block: TextBlock, source: string) => Segment[]
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
translate: (block: TextBlock, source: string) => PreviewText
/** 函数、导入、常量、变量块 → 属性表。 */
describe: (block: FactBlock, source: string) => PropertyRow[]
}
+7 -2
View File
@@ -1,4 +1,4 @@
import type { BlockKind, CodeBlock, LanguageAdapter, PropertyRow, Segment } from './contract'
import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyRow, Segment } from './contract'
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { Node } from '@vscode/tree-sitter-wasm'
@@ -49,10 +49,15 @@ export function segmentText(segments: Segment[]): string {
return segments.map(segment => segment.text).join('')
}
/** 预览文字写成文字:着色的词拼起来,Markdown 取原文。 */
export function previewText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : segmentText(text)
}
/** 属性表写成文字:每行 `名称: 值`,展开的子行缩进两格、各列用 ` | ` 隔开。 */
export function propertyLines(rows: PropertyRow[]): string[] {
return rows.flatMap(row => [
`${row.label}: ${segmentText(row.value)}`,
`${row.label}: ${previewText(row.value)}`,
...(row.items ?? []).map(item => ` ${item.cells.map(segmentText).join(' | ')}`),
])
}
@@ -11,16 +11,21 @@ import { createZhTemplateTranslator } from './zhTemplate'
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
interface Translated { kind: string, segments: Segment[] }
/** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
interface Translated { kind: string, segments: Segment[], markdown?: string }
/** 一行译文块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */
/** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<Map<number, Translated>> {
return withBlocks(adapter, languageId, source, (blocks) => {
const byLine = new Map<number, Translated>()
walk(blocks, (block) => {
const line = block.header.startRow + 1
if (!byLine.has(line) && !('facts' in block))
byLine.set(line, { kind: block.kind, segments: translator.translate(block, source) })
if (byLine.has(line) || 'facts' in block)
return
const translation = translator.translate(block, source)
byLine.set(line, 'markdown' in translation
? { kind: block.kind, segments: [], markdown: translation.markdown }
: { kind: block.kind, segments: translation })
})
return byLine
})
@@ -44,7 +49,10 @@ function rowTexts(map: Map<number, PropertyRow[]>): Record<number, string[]> {
}
function valueOf(rows: PropertyRow[] | undefined, label: string): Segment[] {
return rows?.find(row => row.label === label)?.value ?? []
const value = rows?.find(row => row.label === label)?.value ?? []
if ('markdown' in value)
throw new Error(`「${label}」是 Markdown`)
return value
}
function text(segments: Segment[] | undefined): string {
@@ -52,7 +60,7 @@ function text(segments: Segment[] | undefined): string {
}
function texts(map: Map<number, Translated>): Record<number, string> {
return Object.fromEntries([...map].map(([line, { segments }]) => [line, text(segments)]))
return Object.fromEntries([...map].map(([line, { segments, markdown }]) => [line, markdown ?? text(segments)]))
}
/** 只看文字和着色:去掉各段的源码下标、属性行讲的定义(这两样另有用例)。 */
@@ -93,14 +101,23 @@ describe('typeScript 中文直译', () => {
expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }')
})
it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号', async () => {
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) })')
expect(env[1]).toBe('.env file lock / unlock helpers. Recursively scans the main workspace folder for `.env*` files (skipping `node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or 0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`. 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.')
expect(env[1]).toBe([
'.env file lock / unlock helpers.',
'',
'Recursively scans the main workspace folder for `.env*` files (skipping',
'`node_modules`, `.git`, `.claude`) and chmods them to 0o444 (locked) or',
'0o644 (unlocked) in bulk. Backs the toolbar Lock toggle in `PanelHeader`.',
'',
'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 全等 未定义)')
})
@@ -382,7 +399,7 @@ describe('属性表', () => {
const rows = colors(env.get(18) ?? [])
expect(rows.map(row => row.label)).toEqual(['名称', '说明', '访问级别', '异步', '输入参数', '输出参数'])
expect(rows[0].value).toEqual([{ text: 'findEnvFiles', role: 'function' }])
expect(rows[1].value).toEqual([{ text: valueOf(env.get(18), '说明')[0].text, role: 'comment' }])
expect(rows[1].value).toEqual({ markdown: expect.stringMatching(/^Scan the workspace folder/) })
expect(rows[2].value).toEqual([{ text: '公共', role: 'keyword' }])
expect(rows[3].value).toEqual([{ text: '是', role: 'storage' }])
expect(rows[4].value).toEqual([{ text: '1 个', role: 'hint' }])
@@ -447,6 +464,57 @@ describe('属性表', () => {
expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共'])
})
it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、JSDoc 标签、HTML 照抄', async () => {
const pySource = [
'"""手机号规范化 / 脱敏工具.',
'',
'凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 <b>mask</b>。',
'"""',
'# 用法:',
'#',
'# mask_phone("13800138000")',
'def mask_phone(phone: str) -> str:',
' """把中间四位换成 ``*``.',
'',
' - 不足 7 位原样返回',
' - 见 https://example.com/phone',
' """',
].join('\n')
const pyText = await translateAll(py, 'python', pythonZhTranslator, pySource)
expect(pyText.get(1)).toEqual({
kind: 'comment',
segments: [],
markdown: '手机号规范化 / 脱敏工具.\n\n凡是入库、比对的手机号都先过 :func:`normalize_phone`,日志里打印 ``user`` 时用 <b>mask</b>。',
})
expect(pyText.get(5)?.markdown).toBe('用法:\n\n mask_phone("13800138000")')
const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource)
expect(pyRows.get(8)?.find(row => row.label === '说明')?.value).toEqual({
markdown: '把中间四位换成 ``*``.\n\n- 不足 7 位原样返回\n- 见 https://example.com/phone',
})
const tsSource = [
'// 第一行',
'// - 缩进的列表',
'/* 单行块注释 */',
'/**',
' * 读配置,见 {@link load}。',
' *',
' * ```ts',
' * load(\'a\')',
' * ```',
' * @param path 文件路径',
' */',
'function load(path: string) {}',
].join('\n')
const tsText = texts(await translateAll(ts, 'typescript', typescriptZhTranslator, tsSource))
expect(tsText[1]).toBe('第一行\n - 缩进的列表')
expect(tsText[3]).toBe('单行块注释')
const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource)
expect(tsRows.get(4)?.find(row => row.label === '说明')?.value).toEqual({
markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```\n@param path 文件路径',
})
})
it('导入:默认导出、全部、整个模块、副作用导入;Python 没有「仅类型」', async () => {
const tsSource = [
'import d, { b as c } from \'y\'',
@@ -517,7 +585,7 @@ describe('源码下标(跳到定义用)', () => {
}
function propertySegments(rows: PropertyRow[]): Segment[] {
return rows.flatMap(row => [...row.value, ...(row.items ?? []).flatMap(item => item.cells.flat())])
return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...(row.items ?? []).flatMap(item => item.cells.flat())])
}
it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => {
+18 -17
View File
@@ -1,4 +1,4 @@
import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Node, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, Defines, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
import { commonIndent, splitLines } from '../editText'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
@@ -102,13 +102,15 @@ function oneLine(text: string): string {
}
/**
* 说明文字排版:去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。
* 第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法)。
* 注释的 Markdown 原文:各个注释节点去掉注释符号后(texts)拼起来,去掉行尾空白、首尾空行和共同缩进,保留换行(段落之间的空行也留着)。
* 只有一个节点(块注释、docstring)时第一行紧跟在引号 / 注释符号后面,本来就没有缩进,不参与算共同缩进(同 PEP 257 的做法);
* 几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。
*/
function docText(raw: string): string {
const [first = '', ...rest] = splitLines(raw).map(line => line.trimEnd())
const indent = commonIndent(rest).length
return [first.trim(), ...rest.map(line => line.slice(indent))].join('\n').replace(/^\n+|\n+$/g, '')
function docText(texts: string[]): string {
const lines = splitLines(texts.join('\n')).map(line => line.trimEnd())
const quoted = texts.length === 1
const indent = commonIndent(quoted ? lines.slice(1) : lines).length
return lines.map((line, i) => quoted && i === 0 ? line.trim() : line.slice(indent)).join('\n').replace(/^\n+|\n+$/g, '')
}
/** 模板槽位引用的子节点;注释是语法之外的节点,不占具名子节点序号。 */
@@ -261,15 +263,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return segments
}
function translateComment(block: TextBlock): Segment[] {
function translateComment(block: TextBlock): Markdown {
const { startIndex, endIndex } = block.header
const root = block.node.tree.rootNode
const nodes = block.node.type === 'comment' ? commentsWithin(root.descendantForIndex(startIndex, endIndex) ?? root, block.header) : [block.node]
const text = nodes.map(table.commentText).join('\n').split('\n').map(line => line.trim()).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
return { markdown: docText(nodes.map(table.commentText)) }
}
function translate(block: TextBlock, source: string): Segment[] {
function translate(block: TextBlock, source: string): PreviewText {
if (block.kind === 'comment')
return translateComment(block)
const tokens = tokensIn(block.node, block.header, source)
@@ -299,8 +300,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node))] : verbatim(node, source, 'type'),
name: (node, role) => [segment(node.text, role, offsetOf(node))],
doc: (node) => {
const text = docText(table.commentText(node))
return text ? [{ text, role: 'comment' }] : []
const markdown = docText([table.commentText(node)])
return markdown ? { markdown } : undefined
},
}
switch (block.kind) {
@@ -362,8 +363,8 @@ interface Phrases {
module: (node: Node) => Segment[]
/** 源码里的一个名字原样照抄、按 role 着色(函数名称)。 */
name: (node: Node, role: TokenRole) => Segment[]
/** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进,保留换行,按注释着色;没有文字时是空的。 */
doc: (node: Node) => Segment[]
/** 函数的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
}
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
@@ -416,10 +417,10 @@ function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segmen
/** 函数:名称 / 访问级别 / 异步 / 装饰器 / 输入参数(每个参数一行:名称、类型、默认值)/ 输出参数。 */
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
const decorators = facts.decorators.map(say.verbatim)
const doc = facts.doc ? say.doc(facts.doc) : []
const doc = facts.doc && say.doc(facts.doc)
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...(doc.length > 0 ? [{ label: '说明', value: doc }] : []),
...(doc ? [{ label: '说明', value: doc }] : []),
...accessRows(facts.access),
{ label: '异步', value: yesNo(facts.async) },
...(decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators, '\n') }] : []),