✨ feat(vscode): 代码阅读的注释和函数说明按 Markdown 渲染
This commit is contained in:
1 parent
aae9012f14
commit
a53c2a6aac
18 files changed
+394
-66
No files matched your search
@@ -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 []' })
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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') }] : []),
|
||||
|
||||
Reference in new issue
Block a user