✨ feat(vscode): 代码阅读预览里实参值、return 这类一行译文里跨几行写的字典 / 列表按项拆开,各项带自己的实参表

This commit is contained in:
cruldra committed 2026-10-13 13:57:22 +11:00
1 parent b2c5309d1f
commit 907c4092c8
18 files changed
+515 -102

No files matched your search

+12
View File
@@ -351,6 +351,18 @@ describe('blockView', () => {
})
})
it('跨几行写的列表:一行译文下面带拆开的各项,项里的调用带着自己的实参表', async () => {
const source = 'def f(user):\n return [\n Detail(\n id=user.id,\n ),\n user,\n ]\n'
const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { calleeKind: call => call.callee.text === 'Detail' ? 'class' : undefined })
const returned = list[0].children[0]
expect(translationText(returned)).toBe('返回 列表 2 项')
expect(returned).not.toHaveProperty('table')
expect('items' in returned && returned.items).toMatchObject([
{ cells: [[{ text: '第 1 项' }], [{ text: '新建' }, { text: ' ' }, { text: 'Detail', offset: source.indexOf('Detail') }, { text: ' ' }, { text: '· 1 个参数' }]], table: { columns: ['参数', '值'] } },
{ cells: [[{ text: '第 2 项' }], [{ text: 'user', offset: source.indexOf('user,') }]] },
])
})
it('概念列表:语言的种类在前,语言另认的接着,规则的种类和另认的在后;同一个 id 只留第一个', async () => {
const language: LanguageAdapter = {
...py,
+8 -8
View File
@@ -1,5 +1,5 @@
import type { Tree } from '@vscode/tree-sitter-wasm'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { BlockAttributes, BlockConcept, BlockKind, CodeBlock, DisplayName, EmbeddedCode, EmbeddedReading, HttpEndpoint, Inline, KindBadge, LanguageAdapter, LibraryRule, LocalSymbol, Markdown, Node, PreviewTable, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, SourceDocument, SourceRange, SourceSpan, StepBlocks, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import type { ImportedName } from './symbolLinks'
import { blockAttributes } from './attributes'
@@ -72,10 +72,10 @@ export type CommentTarget
/**
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文。
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(TextTranslation.table),块的说明(TextBlock.doc,翻译插件给的 Markdown 原文),
* 几行属性(rows:「注释」一行)。
* 一行译文下面依次是:跨几行写的字面量拆开的各项(TextTranslation.items),跨几行写的调用摊开各个实参的表(TextTranslation.table),
* 块的说明(TextBlock.doc,翻译插件给的 Markdown 原文),几行属性(rows:「注释」一行)。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
@@ -342,13 +342,13 @@ export function toBlockViews(
doc: node => layerTranslator?.doc(node, layerDocument),
lookup: layerRules.lookup ?? (() => undefined),
}
/** 一行译文,连着摊开实参的表、块的说明(有才有)。 */
const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => {
/** 一行译文,连着拆开的各项、摊开实参的表、块的说明(有才有)。 */
const textView = (block: TextBlock): { translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown } => {
if (!layerTranslator)
return { translation: [] }
const { text, table } = layerTranslator.translate(block, layerDocument)
const { text, items, table } = layerTranslator.translate(block, layerDocument)
const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)?.body
return { translation: text, ...(table && { table }), ...(doc && { doc }) }
return { translation: text, ...(items && { items }), ...(table && { table }), ...(doc && { doc }) }
}
/** 块执行的嵌着的代码分成的几步:只有一步时是它的子块,几步时是各步,交给那种语言转(见 TextBlock.embedded)。 */
const embeddedChildren = (block: CodeBlock, id: string, scope: string, from: number): BlockView[] => {
+5 -1
View File
@@ -715,9 +715,13 @@ export interface PreviewTable {
items: PropertyItem[]
}
/** 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);跨几行写的调用还有摊开各个实参的表,预览排在这一行下面。 */
/**
* 一行译文的块翻出来的:一行译文(注释块是 Markdown 说明);预览排在这一行下面的,跨几行写的字面量是拆开的各项(items,同 PropertyItem.items),
* 跨几行写的调用是摊开各个实参的表(table)。
*/
export interface TextTranslation {
text: PreviewText
items?: PropertyItem[]
table?: PreviewTable
}
@@ -160,6 +160,14 @@ describe('blockMarks', () => {
expect(marks?.doc).toEqual({ markdown: [{ text: '旧说明', change: 'del' }] })
})
it('一行译文下面拆开的各项逐行比:按第一列配,多出来的算加行,没了的算删行', () => {
const before = textView([seg('返回 列表 2 项')], { items: [item('第 1 项'), item('第 2 项')] } as Partial<BlockView>)
const after = textView([seg('返回 列表 1 项')], { items: [item('第 1 项')] } as Partial<BlockView>)
const marks = blockMarks(before, after)
expect(marks?.items?.removed).toEqual([{ after: 0, value: item('第 2 项') }])
expect(blockMarks(after, before)?.items?.changed).toEqual({ 1: { added: true } })
})
it('一块是属性表、一块是一行译文时没法逐行比', () => {
expect(blockMarks(textView([seg('a')]), propertyView([row('名称', [seg('a')])]))).toBeUndefined()
})
@@ -178,6 +186,14 @@ describe('removedView / withoutSource', () => {
expect(JSON.stringify(removed)).not.toMatch(/"offset"|"refers"/)
})
it('一行译文下面拆开的各项、摊开实参的表照样带着,词不带源码下标', () => {
const named = { cells: [[seg('第 1 项')], [seg('x', 'variable', 9)]] }
const removed = removedView(textView([seg('返回 列表 1 项')], { items: [named], table: { columns: ['参数', '值'], items: [named] } } as Partial<BlockView>))
expect('items' in removed && removed.items).toEqual([{ cells: [[seg('第 1 项')], [seg('x', 'variable')]] }])
expect('table' in removed && removed.table?.items).toHaveLength(1)
expect(JSON.stringify(removed)).not.toMatch(/"offset"/)
})
it('框里的词也去掉下标,颜色留着', () => {
const color = { dark: '#fff', light: '#000' }
expect(withoutSource([{ style: 'iteration', label: '列表', segments: [{ ...seg('x', 'variable', 3), color }] }])).toEqual([
+9 -3
View File
@@ -54,10 +54,14 @@ export interface RowDiff {
items?: ItemsDiff
}
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows,「注释」一行)逐行,一行译文逐截,摊开实参的表逐行,说明逐词。 */
/**
* 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows,「注释」一行)逐行,一行译文逐截,
* 译文下面拆开的各项、摊开实参的表逐行,说明逐词。
*/
export interface BlockMarks {
rows?: RowsDiff
translation?: TextDiff
items?: ItemsDiff
table?: ItemsDiff
doc?: TextDiff
}
@@ -112,6 +116,7 @@ export function removedView(view: BlockView): BlockView {
return {
...base,
translation: textWithoutSource(view.translation),
...(view.items && { items: view.items.map(itemWithoutSource) }),
...(view.table && { table: tableWithoutSource(view.table) }),
...(view.doc && { doc: view.doc }),
...(view.rows && { rows: view.rows.map(rowWithoutSource) }),
@@ -305,7 +310,7 @@ export function rowsDiff(before: readonly PropertyRow[], after: readonly Propert
}
/**
* 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,摊开实参的表逐行,说明逐词。
* 配上的两块自己的内容(不算子块)比:属性表逐行,一行译文逐截,译文下面拆开的各项、摊开实参的表逐行,说明逐词。
* 一样(或一块是属性表、一块是一行译文,没法逐行比)时是 undefined。
*/
export function blockMarks(before: BlockView, after: BlockView): BlockMarks | undefined {
@@ -314,9 +319,10 @@ export function blockMarks(before: BlockView, after: BlockView): BlockMarks | un
return rows && { rows }
}
const translation = textDiff(before.translation, after.translation)
const items = itemsDiff(before.items, after.items)
const table = itemsDiff(before.table?.items, after.table?.items)
const doc = textDiff(before.doc ?? { markdown: '' }, after.doc ?? { markdown: '' })
const rows = rowsDiff(before.rows ?? [], after.rows ?? [])
const marks: BlockMarks = { ...(translation && { translation }), ...(table && { table }), ...(doc && { doc }), ...(rows && { rows }) }
const marks: BlockMarks = { ...(translation && { translation }), ...(items && { items }), ...(table && { table }), ...(doc && { doc }), ...(rows && { rows }) }
return Object.keys(marks).length > 0 ? marks : undefined
}
@@ -106,6 +106,15 @@ describe('中文名落到哪些名字上', () => {
expect(rendered.get(at(source, 'user.name'))).toBe('user')
})
it('renderedNames:一行译文下面拆开的各项(跨几行写的列表、字典)、项下面的实参表里带下标的名字也算', async () => {
const source = 'def f(user):\n return [\n Detail(\n id=user.id,\n ),\n {"owner": owner},\n ]\n'
const rendered = renderedNames((await read(source)).blocks)
expect(rendered.get(at(source, 'Detail'))).toBe('Detail')
expect(rendered.get(at(source, 'id='))).toBe('id')
expect(rendered.get(at(source, 'user.id'))).toBe('user')
expect(rendered.get(at(source, 'owner}'))).toBe('owner')
})
it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => {
const { blocks, displayNames } = await read(ROUTES)
const definition = at(ROUTES, 'current_user')
+2 -1
View File
@@ -15,7 +15,7 @@ export interface ShownName {
definition?: true
}
/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面的表、属性表的值、各层子行的格子、框里):下标 → 原文。 */
/** 视图数据里照抄自源码、带下标的名字(一行译文和它下面拆开的各项、表,属性表的值、各层子行的格子、框里):下标 → 原文。 */
export function renderedNames(blocks: readonly BlockView[]): Map<number, string> {
const found = new Map<number, string>()
const inline = (pieces: readonly Inline[]): void => {
@@ -47,6 +47,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map<number, string>
}
else {
text(block.translation)
items(block.items)
items(block.table?.items)
}
visit(block.children)
+7 -1
View File
@@ -109,9 +109,14 @@ export function tableLines(table: PreviewTable | undefined): string[] {
return itemLines(table?.items, '')
}
/** 一行译文下面拆开的各项写成文字,同 tableLines。 */
export function listLines(items: PropertyItem[] | undefined): string[] {
return itemLines(items, '')
}
/**
* 视图数据写成文字(整份源码读出来的样子):一行译文的块是「种类 行号: 译文」(下面的「注释」一行写成「注释: 第一行」),属性表块是「种类 行号」下面每行「属性: 值」,
* 子块比上一层多缩进两格;Markdown 只取第一行。表(属性行展开的、译文下面的、格子里套的)每行比上一层多缩进,
* 子块比上一层多缩进两格;Markdown 只取第一行。子行和表(属性行展开的、译文下面拆开的各项和表、格子里套的)每行比上一层多缩进,
* 格子用 | 隔开,格子里的换行写成 ⏎。
*/
export function viewLines(blocks: readonly BlockView[], indent = ''): string[] {
@@ -126,6 +131,7 @@ export function viewLines(blocks: readonly BlockView[], indent = ''): string[] {
? [`${indent}${block.kind} ${block.startLine + 1}`, ...block.properties.flatMap(row => [`${indent} ${row.label}: ${value(row.value)}`, ...items(row.items, `${indent} `)])]
: [
`${indent}${block.kind} ${block.startLine + 1}: ${value(block.translation)}`,
...items(block.items, `${indent} `),
...items(block.table?.items, `${indent} `),
...(block.rows ?? []).map(row => `${indent} ${row.label}: ${value(row.value)}`),
]
@@ -3,7 +3,7 @@ import type { ZhTable } from './zhTemplate'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, inlineOf, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, listLines, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from './pythonZh'
import { typescriptZhTranslator } from './typescriptZh'
import { createZhTemplateTranslator } from './zhTemplate'
@@ -11,8 +11,11 @@ import { createZhTemplateTranslator } from './zhTemplate'
const ts = createTypeScriptAdapter(WASM_DIR)
const py = createPythonAdapter(WASM_DIR)
/** 一块的译文:一行译文在 segments 里,下面摊开实参的表在 table 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
interface Translated { kind: string, segments: Inline[], markdown?: string, table?: PreviewTable }
/**
* 一块的译文:一行译文在 segments 里,下面拆开的各项在 items 里、摊开实参的表在 table 里;
* 注释块是 Markdown,原文在 markdown 里,segments 为空。
*/
interface Translated { kind: string, segments: Inline[], markdown?: string, items?: PropertyItem[], table?: PreviewTable }
/**
* 一行译文块、注释块(单独成块的、挂在块上的)的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。
@@ -26,10 +29,10 @@ function translateAll(adapter: LanguageAdapter, languageId: string, translator:
const line = item.header.startRow + 1
if (byLine.has(line) || 'facts' in item)
return
const { text: translation, table } = translator.translate(item, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
const { text: translation, items, table } = translator.translate(item, { source, language: adapter, markers: [], ...(calleeKind && { calleeKind }) })
byLine.set(line, 'markdown' in translation
? { kind: item.kind, segments: [], markdown: translation.markdown }
: { kind: item.kind, segments: translation, ...(table && { table }) })
: { kind: item.kind, segments: translation, ...(items && { items }), ...(table && { table }) })
}))
return byLine
})
@@ -82,7 +85,10 @@ const CJK = /\p{Script=Han}/u
const CJK_ONLY = /^\p{Script=Han}+$/u
const WORD_CHAR = /[\p{Script=Han}\w]/u
/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 */
/**
* 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。
* 说明词(「对象 3 项」「· 2 个参数」)本来就带数字,只看它不和前后粘在一起。
*/
function expectWordsApart(map: Map<number, Translated>): void {
for (const [line, { kind, segments }] of map) {
if (kind === 'comment')
@@ -92,7 +98,8 @@ function expectWordsApart(map: Map<number, Translated>): void {
let at = 0
for (const segment of flat) {
if (segment.role !== 'string' && CJK.test(segment.text)) {
expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY)
if (segment.role !== 'hint')
expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY)
for (const neighbor of [full[at - 1], full[at + segment.text.length]])
expect(neighbor ?? ' ', `L${line} "${full}" 里的「${segment.text}」`).not.toMatch(WORD_CHAR)
}
@@ -109,7 +116,8 @@ describe('typeScript 中文直译', () => {
expect(text(env.get(26)?.segments)).toBe('返回 空数组')
expect(text(env.get(41)?.segments)).toBe('遍历 (常量 f 取自 files)')
expect(text(env.get(35)?.segments)).toBe('failed: { path: 字符串, reason: 字符串 }[]')
expect(text(issue.get(20)?.segments)).toBe('返回 { locked: 真, prerequisiteNumber: prereq.number, prerequisiteColumn: prereq.column }')
expect(text(issue.get(20)?.segments)).toBe('返回 对象 3 项')
expect(listLines(issue.get(20)?.items)).toEqual(['locked | 真', 'prerequisiteNumber | prereq.number', 'prerequisiteColumn | prereq.column'])
})
it('其余块:结构照抄,复合语句去掉结尾的 {,注释去掉注释符号、保留换行', async () => {
@@ -169,12 +177,10 @@ describe('typeScript 中文直译', () => {
it('多行合成一行、去掉右括号前的尾逗号、行中注释不进译文', async () => {
const source = [
'list = [',
' first, // 第一个',
' [],',
']',
'list = first // 第一个',
' || [first, [],]',
].join('\n')
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = [ first, 空数组 ]')
expect(texts(await translateAll(ts, 'typescript', typescriptZhTranslator, source))[1]).toBe('list = first 或者 [first, 空数组]')
})
it('as / in / extends / default 按所在语法节点换译法', async () => {
@@ -1346,8 +1352,11 @@ describe('跨几行写的调用', () => {
const { asked, calleeKind } = kinds({})
const texts = await translateAll(ts, 'typescript', typescriptZhTranslator, calls, calleeKind)
expect(text(texts.get(18)?.segments)).toBe('this.definitions = 新建 DefinitionSources · 1 个参数')
// 实参是跨几行写的对象:按项拆在那一格里
expect(tableLines(texts.get(18)?.table)).toEqual([
'第 1 个 | { rule: 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)) }',
'第 1 个 | 对象 2 项',
' rule | 异步 reference => ruleDefinitions(等待 owner.resolveReference(reference))',
' language | offset => languageDefinitions(document.uri, document.positionAt(offset))',
])
expect(text(texts.get(33)?.segments)).toBe('返回 调用 concatAll · 2 个参数')
expect(tableLines(texts.get(33)?.table)).toEqual(['第 1 个 | base', '展开 | extra'])
@@ -1507,7 +1516,7 @@ describe('跨几行写的列表', () => {
])
})
it('实参的值是跨几行写的列表时也按项拆,嵌在那一格下面;写在一行里的列表、实参里的字典照旧一行', async () => {
it('实参的值是跨几行写的列表时也按项拆,嵌在那一格下面;写在一行里的列表、字典照旧一行', async () => {
const source = [
'session.query(',
' filters=[',
@@ -1574,6 +1583,182 @@ describe('跨几行写的列表', () => {
expect(text(line?.segments)).toBe('调用 register · 2 个参数')
expect(tableLines(line?.table)).toEqual(['第 1 个 | \'x\'', '第 2 个 | 数组 2 项', ' 第 1 项 | a', ' 第 2 项 | b'])
})
it('实参的值是跨几行写的字典时也按项拆,嵌在那一格下面,和跨几行的列表一样;项的值照字典拆,键标出它讲的定义', async () => {
const source = [
'session.query(',
' filters=[',
' Filter(field="a"),',
' ],',
' meta={',
' "a": 1,',
' "b": {"c": 2},',
' **extra,',
' },',
' ids={"x": 1},',
')',
].join('\n')
const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(1)
expect(text(line?.segments)).toBe('调用 session.query · 3 个参数')
expect(tableLines(line?.table)).toEqual([
'filters | 列表 1 项',
' 第 1 项 | 新建 Filter · 1 个参数',
' field | "a"',
'meta | 字典 3 项',
' "a" | 1',
' "b" | 字典 1 项',
' "c" | 2',
' 展开 | extra',
'ids | {"x": 1}',
])
const meta = line?.table?.items[1]
expect(meta?.table).toBeUndefined()
const key = source.indexOf('"a": 1')
expect(meta?.items?.[0].defines).toEqual({ startIndex: key, endIndex: key + 3 })
expect(linked(meta?.items?.[2].cells[1])).toEqual([['extra', source.indexOf('extra')]])
})
it('返回跨几行写的列表:这一行写「返回 列表 N 项」,各项排在这一行下面、各带自己的实参表,不再有挂在整行下面的表', async () => {
const source = [
'def list_filters(archived: bool, current_user: User):',
...FILTERS.split('\n').slice(1).map(line => line.replace('filters = ', 'return ')),
].join('\n')
const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(2)
expect(text(line?.segments)).toBe('返回 列表 2 项')
expect(colors(line?.segments.at(-1))).toEqual({ text: '列表 2 项', role: 'hint' })
expect(line?.table).toBeUndefined()
expect(listLines(line?.items)).toEqual([
'第 1 项 | 新建 Filter · 3 个参数',
' field | "user_id"',
' operator | FilterOperator.EQ',
' value | current_user.id',
'第 2 项 | 新建 Filter · 3 个参数',
' field | "archived_at"',
' operator | 如果 archived 则 FilterOperator.NE 否则 FilterOperator.EQ',
' value | 空',
])
const [first, second] = line?.items ?? []
expect(first?.table?.columns).toEqual(['参数', '值'])
// 名字照样带源码下标:被调用的名字、按名字传的名字、值里的名字
const multiline = source.indexOf('Filter(\n')
expect(inlineOf(second?.cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'Filter')).toEqual({ text: 'Filter', role: 'type', offset: multiline })
expect(second?.table?.items[0].cells[0]).toEqual([{ text: 'field', role: 'variable', offset: source.indexOf('field="archived_at"') }])
const user = source.indexOf('current_user.id')
expect(linked(first?.table?.items[2].cells[1])).toEqual([['current_user', user], ['id', user + 13]])
})
it('别的语句里跨几行写的字典、列表同样拆;一段只排先遇到的那一个(先外后里),埋在式子里的整个括起来,常量 / 变量的值也一样', async () => {
const source = [
'def save(self, rows):',
' yield {',
' "rows": [',
' 1,',
' 2,',
' ],',
' "count": len(rows),',
' }',
' publish({',
' "a": 1,',
' })',
' rows += [',
' make(',
' a,',
' ),',
' ] + [',
' b,',
' ]',
'def total(a, b):',
' return f([',
' a,',
' b,',
' ]) + 1',
'LIMITS = [',
' 1,',
'] + DEFAULTS',
].join('\n')
const lines = await translateAll(py, 'python', pythonZhTranslator, source)
expect(text(lines.get(2)?.segments)).toBe('产出 字典 2 项')
expect(listLines(lines.get(2)?.items)).toEqual(['"rows" | 列表 2 项', ' 第 1 项 | 1', ' 第 2 项 | 2', '"count" | len(rows)'])
// 调用在外面:排调用,字典是它的实参,拆在实参表那一格里
expect(text(lines.get(9)?.segments)).toBe('调用 publish · 1 个参数')
expect(lines.get(9)?.items).toBeUndefined()
expect(tableLines(lines.get(9)?.table)).toEqual(['第 1 个 | 字典 1 项', ' "a" | 1'])
// 列表埋在式子里:整个括起来;同一段里后面再有的照旧一行
expect(text(lines.get(12)?.segments)).toBe('rows += (列表 1 项) + [ b ]')
expect(listLines(lines.get(12)?.items)).toEqual(['第 1 项 | 调用 make · 1 个参数', ' 第 1 个 | a'])
expect(text(lines.get(20)?.segments)).toBe('返回 (调用 f · 1 个参数) + 1')
expect(lines.get(20)?.items).toBeUndefined()
expect(tableLines(lines.get(20)?.table)).toEqual(['第 1 个 | 列表 2 项', ' 第 1 项 | a', ' 第 2 项 | b'])
const rows = await describeAll(py, 'python', pythonZhTranslator, source)
expect(propertyLines(rows.get(24) ?? []).slice(2)).toEqual(['值: (列表 1 项) + DEFAULTS', ' 第 1 项 | 1'])
})
it('typeScript:实参的值是跨几行写的对象时也按项拆,嵌在那一格下面;写在一行里的对象照旧一行', async () => {
const source = [
'register(',
' \'x\',',
' {',
' a: 1,',
' b: { c: 2 },',
' ...rest,',
' },',
' { d: 1 },',
')',
].join('\n')
const line = (await translateAll(ts, 'typescript', typescriptZhTranslator, source)).get(1)
expect(text(line?.segments)).toBe('调用 register · 3 个参数')
expect(tableLines(line?.table)).toEqual([
'第 1 个 | \'x\'',
'第 2 个 | 对象 3 项',
' a | 1',
' b | 对象 1 项',
' c | 2',
' 展开 | rest',
'第 3 个 | { d: 1 }',
])
const key = source.indexOf('a: 1')
expect(line?.table?.items[1].items?.[0].defines).toEqual({ startIndex: key, endIndex: key + 1 })
})
it('typeScript:返回、赋值里跨几行写的数组、对象按项拆,各项排在这一行下面;as const 包着的对象埋在式子里,整个括起来', async () => {
const source = [
'function filters(archived: boolean) {',
' return [',
' new Filter({ field: \'user_id\' }),',
' makeFilter(',
' \'archived_at\',',
' archived,',
' ),',
' ]',
'}',
'module.exports = {',
' a: 1,',
' b: [',
' 1,',
' 2,',
' ],',
'}',
'export const ICONS = {',
' a: \'x\',',
'} as const',
].join('\n')
const lines = await translateAll(ts, 'typescript', typescriptZhTranslator, source)
expect(text(lines.get(2)?.segments)).toBe('返回 数组 2 项')
expect(lines.get(2)?.table).toBeUndefined()
expect(listLines(lines.get(2)?.items)).toEqual([
'第 1 项 | 新建 Filter · 1 个参数',
' 第 1 个 | { field: \'user_id\' }',
'第 2 项 | 调用 makeFilter · 2 个参数',
' 第 1 个 | \'archived_at\'',
' 第 2 个 | archived',
])
const make = source.indexOf('makeFilter')
expect(inlineOf(lines.get(2)?.items?.[1].cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'makeFilter')).toEqual({ text: 'makeFilter', role: 'function', offset: make })
expect(text(lines.get(10)?.segments)).toBe('module.exports = 对象 2 项')
expect(listLines(lines.get(10)?.items)).toEqual(['a | 1', 'b | 数组 2 项', ' 第 1 项 | 1', ' 第 2 项 | 2'])
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
expect(propertyLines(rows.get(17) ?? []).slice(-2)).toEqual(['值: (对象 1 项) 断言为 常量', ' a | \'x\''])
})
})
describe('中文模板引擎', () => {
+75 -35
View File
@@ -1,4 +1,4 @@
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CollectionFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, ElementFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { ParamDocs } from '../rules/docRows'
import { readDoc } from '../docTags'
import { commonIndent, splitLines } from '../editText'
@@ -107,16 +107,17 @@ interface Token {
}
/**
* 排在这一段译文下面的那张表:跨几行写的调用的实参表(见 emitCall),或读法给的表(分情况的「情况 / 做什么」)。
* 一段只排一张,排了的记在 table 里。
* 排在这一段译文下面的那一样:跨几行写的调用的实参表(见 emitCall)、读法给的表(分情况的「情况 / 做什么」),
* 或跨几行写的字面量拆开的各项(见 emitCollection)。一段只排一样,排了的记在这里。
*/
interface TableSlot {
interface Slot {
items?: PropertyItem[]
table?: PreviewTable
}
/**
* 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。
* slot 是这一段下面能不能排一张表(跨几行写的调用、读法给的表);没有就照常翻。
* slot 是这一段下面能不能排一样东西(跨几行写的调用、字面量,读法给的表);没有就照常翻。
*/
interface Collector {
document: SourceDocument
@@ -125,7 +126,7 @@ interface Collector {
tokens: Token[]
cursor: number
gap: boolean
slot?: TableSlot
slot?: Slot
}
const CONTEXT_SEPARATOR = ' > '
@@ -171,6 +172,11 @@ function continued(node: Node, root: Node): boolean {
return false
}
/** 这一段下面已经排了一样东西(见 Slot)。 */
function taken(slot: Slot): boolean {
return slot.items !== undefined || slot.table !== undefined
}
function isComment(token: Token): boolean {
return token.text === '' && !token.group
}
@@ -412,47 +418,58 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
out.cursor = node.endIndex
}
/** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的调用。 */
function expandedCall(node: Node, span: SourceSpan, slot: TableSlot, { language: { call } }: SourceDocument): CallFacts | undefined {
if (slot.table || !call || !inside(node, span))
/** 要排成一行、实参摊成表的调用:实参列表跨了几行,整个落在要翻的这一段里,这一段还没排过别的。 */
function expandedCall(node: Node, span: SourceSpan, slot: Slot, { language: { call } }: SourceDocument): CallFacts | undefined {
if (taken(slot) || !call || !inside(node, span))
return undefined
const facts = call(node)
return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined
}
/** 要按项拆开的字面量:语言插件给了各项(LanguageAdapter.collection)、不是空的,跨了几行,整个落在要翻的这一段里,这一段还没排过别的。 */
function expandedCollection(node: Node, span: SourceSpan, slot: Slot, { language: { collection } }: SourceDocument): CollectionFacts | undefined {
if (taken(slot) || !collection || !inside(node, span))
return undefined
const facts = collection(node)
if (!facts || (facts.kind === 'map' ? facts.entries : facts.elements).length === 0)
return undefined
return node.startPosition.row !== node.endPosition.row ? facts : undefined
}
/**
* node 落在 span 里的那一段翻成一行译文,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall);
* 给了 call(node 整个就是这次调用)时不管写成几行都这样排。一行译文的块、属性表里每一格的值各翻各的,各自排自己里面的那个调用。
* node 落在 span 里的那一段翻成一行译文,里面先遇到的(先外后里)跨几行写的调用排成一行「新建 / 调用 …」、实参摊成表(见 emitCall),
* 或跨几行写的字面量排成一行「列表 2 项」、各项拆成子行(见 emitCollection);同一段里后面再有的照常一行。
* 给了 call(node 整个就是这次调用)时不管写成几行都这样排。一行译文的块、属性表里每一格的值各是一段,各自排自己里面的那一个。
*/
function expandedText(node: Node, span: SourceSpan, document: SourceDocument, call?: CallFacts): CellValue {
const slot: TableSlot = {}
const slot: Slot = {}
const out = collector(node, span, document, slot)
if (call)
emitCall(out, slot, node, call, span)
else
collect(node, span, out)
return { text: joinTokens(out.tokens), ...(slot.table && { table: slot.table }) }
return { text: joinTokens(out.tokens), ...slot }
}
/**
* 属性表里一格的值(常量 / 变量的值、字典一项的值、列表的一项、实参的值),只翻落在 span 里的部分:
* 语言插件给了各项的字面量(LanguageAdapter.collection)写叫法和项数,各项拆成下一层子行(见 entryItem、elementItems)——
* 字典 / 对象一直拆(maps 为假时照旧一行:实参的值),列表 / 元组 / 集合 / 数组跨几行写才拆;
* 空字面量和别的值是一行译文,里面跨几行写的调用排成「新建 / 调用 …」,实参表跟着这一格(见 expandedText)。
* 属性表里一格的值(常量 / 变量的值、字典一项的值、列表的一项、实参的值),只翻落在 span 里的部分,见 expandedText;
* maps 为真时(实参的值以外)值整个是字典 / 对象字面量的写在一行里也拆,空字面量照旧一行(「空字典」)。
*/
function cellValue(node: Node, span: SourceSpan, document: SourceDocument, maps = true): CellValue {
const collection = document.language.collection?.(node)
if (collection?.kind === 'map' && maps && collection.entries.length > 0)
return { text: itemCount(node, collection.entries.length), items: collection.entries.map(entry => entryItem(entry, span, document)) }
if (collection?.kind === 'sequence' && collection.elements.length > 0 && node.startPosition.row !== node.endPosition.row)
return { text: itemCount(node, collection.elements.length), items: elementItems(collection.elements, span, document) }
const collection = maps ? document.language.collection?.(node) : undefined
if (collection?.kind === 'map' && collection.entries.length > 0)
return collectionValue(node, collection, span, document)
return expandedText(node, span, document)
}
/** 拆开的字面量写叫法和项数(展开的也算一项):「字典 3 项」,叫法按字面量的节点类型查表(ZhTable.collections)。 */
function itemCount(literal: Node, size: number): Segment[] {
/**
* 拆开的字面量:写叫法和项数(展开的也算一项),叫法按字面量的节点类型查表(ZhTable.collections):「字典 3 项」;
* 各项是下一层子行,字典 / 对象见 entryItem,列表 / 元组 / 集合 / 数组见 elementItems。
*/
function collectionValue(literal: Node, facts: CollectionFacts, span: SourceSpan, document: SourceDocument): { text: Segment[], items: PropertyItem[] } {
const items = facts.kind === 'map' ? facts.entries.map(entry => entryItem(entry, span, document)) : elementItems(facts.elements, span, document)
const name = table.collections[literal.type]
return [hint(name ? `${name} ${size} 项` : `${size} 项`)]
return { text: [hint(name ? `${name} ${items.length} 项` : `${items.length} 项`)], items }
}
/**
@@ -492,8 +509,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
/**
* 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的),
* 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值见 cellValue(字典 / 对象照旧一行):值里有跨几行写的调用时
* 同样排成一行,它的实参表排在值那一格里(PropertyItem.table);值是跨几行写的列表时各项拆在那一格里(PropertyItem.items)。一层层往下套。
* 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值见 cellValue(写在一行里的字典 / 对象照旧一行):值里有跨几行写的调用时
* 同样排成一行,它的实参表排在值那一格里(PropertyItem.table);值里有跨几行写的字典 / 列表时各项拆在那一格里(PropertyItem.items)。一层层往下套。
*/
function argumentTable(facts: CallFacts, span: SourceSpan, document: SourceDocument): PreviewTable {
let position = 0
@@ -523,7 +540,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
* 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。
* 调用后面还紧跟着同一个式子的别的部分(见 continued)时整行括起来:「(新建 X · 3 个参数).model_dump()」。
*/
function emitCall(out: Collector, slot: TableSlot, node: Node, facts: CallFacts, span: SourceSpan): void {
function emitCall(out: Collector, slot: Slot, node: Node, facts: CallFacts, span: SourceSpan): void {
slot.table = argumentTable(facts, span, out.document)
const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts))
const wrapped = continued(node, out.root)
@@ -546,6 +563,24 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
out.cursor = node.endIndex
}
/**
* 跨几行写的字面量排成一行「列表 2 项」「字典 3 项」,各项拆成子行记在 slot 里(见 collectionValue)。
* 后面还紧跟着同一个式子的别的部分(见 continued)时整个括起来:「(列表 2 项) + rest」。
*/
function emitCollection(out: Collector, slot: Slot, node: Node, facts: CollectionFacts, span: SourceSpan): void {
const { text, items } = collectionValue(node, facts, span, out.document)
slot.items = items
const wrapped = continued(node, out.root)
advance(out, node.startIndex)
if (wrapped)
emit(out, { text: '(', role: 'plain', translated: true, raw: '' })
for (const piece of text)
emit(out, { text: piece.text, role: piece.role, translated: true, raw: '' })
if (wrapped)
emit(out, { text: ')', role: 'plain', translated: true, raw: '' })
out.cursor = node.endIndex
}
/** 读法用的:照常翻、看节点在不在这一段里、照抄名字、读命令。 */
function readerContext(out: Collector, span: SourceSpan): ReaderContext {
const { document } = out
@@ -561,10 +596,10 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
}
}
/** 读出来的几段接着前面的词输出;读法给了表、这一段还没排表时表排在这一段下面。 */
/** 读出来的几段接着前面的词输出;读法给了表、这一段下面还空着时表排在这一段下面。 */
function emitReading(out: Collector, node: Node, reading: Inline[] | { text: Inline[], table?: PreviewTable }): void {
const { text, table: below } = Array.isArray(reading) ? { text: reading, table: undefined } : reading
if (below && out.slot && !out.slot.table)
if (below && out.slot && !taken(out.slot))
out.slot.table = below
emitPieces(out, node, text)
}
@@ -624,6 +659,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
emitCall(out, slot, node, call, span)
return
}
const collection = slot && expandedCollection(node, span, slot, out.document)
if (slot && collection) {
emitCollection(out, slot, node, collection, span)
return
}
const reading = table.readers?.[node.type]?.(node, readerContext(out, span))
if (reading) {
emitReading(out, node, reading)
@@ -665,13 +705,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
}
}
/** 翻 node 落在 span 里的那一段用的收集器;给了 slot,跨几行写的调用排成一行、实参摊成表(见 emitCall)。 */
function collector(node: Node, span: SourceSpan, document: SourceDocument, slot?: TableSlot): Collector {
/** 翻 node 落在 span 里的那一段用的收集器;给了 slot,先遇到的跨几行写的调用、字面量排开(见 expandedText)。 */
function collector(node: Node, span: SourceSpan, document: SourceDocument, slot?: Slot): Collector {
return { document, root: node, tokens: [], cursor: span.startIndex, gap: false, ...(slot && { slot }) }
}
/** 按译文顺序收集 node 落在 span 里的词;slot 见 collector。 */
function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, slot?: TableSlot): Token[] {
function tokensIn(node: Node, span: SourceSpan, document: SourceDocument, slot?: Slot): Token[] {
const out = collector(node, span, document, slot)
collect(node, span, out)
return out.tokens
@@ -719,13 +759,13 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
const group = block.group && table.groups?.[block.node.type]
if (block.group && group)
return group(block.group, readerContext(collector(block.node, block.header, document), block.header))
const slot: TableSlot = {}
const slot: Slot = {}
const tokens = tokensIn(block.node, block.header, document, slot)
const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex
const last = tokens.at(-1)
if (compound && last && !last.translated && HEADER_OPENERS.has(last.raw))
tokens.pop()
return { text: joinTokens(tokens), ...(slot.table && { table: slot.table }) }
return { text: joinTokens(tokens), ...slot }
}
function expression(node: Node, document: SourceDocument): Inline[] {
@@ -1,3 +1,5 @@
// @vitest-environment happy-dom
import type { BlockTreeState, RewriteTree } from './BlockTree'
import type { Ask, ChatState } from './qa'
import type { BlockView, KindBadge } from './types'
@@ -240,3 +242,85 @@ describe('「注释」一行和「重写注释」按钮', () => {
expect(html(view('4', 'statement', 'save()'))).not.toContain('按注释规范')
})
})
describe('一行译文下面拆开的各项', () => {
/** 一段 Python 里函数体的第一块(return)经过宿主转成的视图数据;Filter 的定义是类。 */
async function returned(source: string): Promise<BlockView> {
const language = createPythonAdapter(WASM_DIR)
const views = await withBlocks(language, 'python', source, blocks => toBlockViews(blocks, { source, language, markers: [], calleeKind: call => call.callee.text === 'Filter' ? 'class' : undefined }, pythonZhTranslator))
return views[0].children[0]
}
const SOURCE = [
'def list_filters(archived, current_user):',
' return [',
' Filter(field="user_id", value=current_user.id),',
' Filter(',
' field="archived_at",',
' value=None,',
' ),',
' {"k": v},',
' ]',
].join('\n')
/** 画成页面(只画这一块),collapsed 是折叠着的 id */
function page(block: BlockView, collapsed: ReadonlySet<string> = new Set(), diff?: BlockTreeState['diff']): HTMLElement {
const root = document.createElement('div')
root.innerHTML = renderToStaticMarkup(<BlockLevel blocks={[block]} tree={{ ...state(collapsed), ...(diff && { diff }) }} />)
return root
}
function byId(root: HTMLElement, id: string): HTMLElement {
const found = root.querySelector<HTMLElement>(`[data-id="${id}"]`)
if (!found)
throw new Error(`页面上没有 ${id}`)
return found
}
/** a 在页面上排在 b 前面 */
function before(a: Element, b: Element): boolean {
return (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
}
it('各项排在译文下面、往右缩一层、左边一条竖线,和变量初始值的各项一样;项下面挂着它的实参表,排在下一项上面', async () => {
const block = await returned(SOURCE)
const root = page(block)
const line = root.querySelector('.cr-code')
expect(line?.textContent).toBe('返回 列表 3 项')
const first = byId(root, `${block.id}/i/0`)
const second = byId(root, `${block.id}/i/1`)
expect(first.textContent).toBe('第 1 项新建 Filter · 2 个参数')
expect(second.textContent).toBe('第 2 项新建 Filter · 2 个参数')
const cell = byId(root, `${block.id}/i/1/t/0`)
expect(cell.textContent).toBe('field"archived_at"')
expect(before(line!, first) && before(second, cell) && before(cell, byId(root, `${block.id}/i/2`))).toBe(true)
expect(first.closest('.cr-table')).toBeNull()
let level = first.parentElement
while (level && !level.firstElementChild?.classList.contains('cr-guide'))
level = level.parentElement
expect(level?.contains(second)).toBe(true)
expect(level?.contains(line)).toBe(false)
})
it('项下面的下一层(字典)能折叠,折着时只收起那一层;实参表总是展开的', async () => {
const block = await returned(SOURCE)
const map = `${block.id}/i/2`
const open = page(block)
expect(byId(open, map).querySelector('button')?.getAttribute('aria-expanded')).toBe('true')
expect(byId(open, `${map}/0`).textContent).toBe('"k"v')
const folded = page(block, new Set([map]))
expect(folded.querySelector(`[data-id="${map}/0"]`)).toBeNull()
expect(folded.querySelector(`[data-id="${block.id}/i/0/t/0"]`)).not.toBeNull()
})
it('标差异:新加的项淡绿底,删掉的项放回原来的位置、划掉', async () => {
const block = await returned(SOURCE)
const gone = { cells: [[{ text: '第 3 项', role: 'hint' as const }], [{ text: 'old', role: 'variable' as const }]] }
const changes = new Map([[block.id, { change: 'mod' as const, own: true as const, marks: { items: { changed: { 2: { added: true as const } }, removed: [{ after: 1, value: gone }] } } }]])
const root = page(block, new Set(), { changes, removed: new Map(), current: true })
expect(byId(root, `${block.id}/i/2`).dataset.diff).toBe('add')
const removed = root.querySelector<HTMLElement>('[data-diff="del"]')
expect(removed?.textContent).toBe('第 3 项old')
expect(before(byId(root, `${block.id}/i/1`), removed!) && before(removed!, byId(root, `${block.id}/i/2`))).toBe(true)
})
})
+11 -4
View File
@@ -13,10 +13,10 @@ import { CodeEditor } from './CodeEditor'
import { DiffText } from './DiffText'
import { removedTitle } from './diffView'
import { KindIcon } from './kindIcons'
import { PropertyGrid, PropertyTable } from './PropertyGrid'
import { PropertyGrid, PropertyItems, PropertyTable } from './PropertyGrid'
import { asksOf, historyOf, unfinished } from './qa'
import { TextPreview } from './TextPreview'
import { tableId } from './viewState'
import { listId, tableId } from './viewState'
/**
* 块树里改代码的那部分:哪些块在编辑、点「编辑」。页面上的块树才有;定义卡片浮窗里的块树没有,
@@ -234,17 +234,24 @@ function BlockRewrite({ anchor, rewrite }: { anchor: string, rewrite: RewriteTre
/**
* 预览状态的内容:函数、类、枚举、导入、常量、变量块和库规则的卡片是属性表,其余是一行译文;
* 译文下面依次接着摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
* 译文下面依次接着拆开的各项(跨几行写的字面量,排法同变量初始值下面的)、摊开实参的表(跨几行写的调用)、说明、几行属性(「注释」一行)。
* 标差异时 marks 是这块自己的内容改了哪些(页面上正是差异算的那一版才给)。
*/
function BlockPreview({ block, tree, marks }: { block: BlockView, tree: BlockTreeState, marks?: BlockMarks }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} marks={marks?.rows} slots={cardSlots(block, tree)} />
const folding = { collapsed: tree.collapsed, onToggleFold: tree.onToggleFold }
return (
<>
{marks?.translation ? <DiffText diff={marks.translation} /> : <TextPreview text={block.translation} />}
{block.items && (
<div className="mt-[3px]">
<PropertyItems parentId={listId(block.id)} items={block.items} folding={folding} marks={marks?.items} />
</div>
)}
{block.table && (
<div className="mt-[3px]">
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={{ collapsed: tree.collapsed, onToggleFold: tree.onToggleFold }} />
<PropertyTable parentId={tableId(block.id)} columns={block.table.columns} items={block.table.items} marks={marks?.table} folding={folding} />
</div>
)}
{marks?.doc ? <DiffText diff={marks.doc} className="mt-0.5" /> : block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
@@ -133,12 +133,12 @@ function Nested({ children }: { children: ReactNode }): ReactElement {
}
/**
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。
* 展开在一行或一个子行(parentId)下面的一层子行:左边一条竖线,每个子行几列对齐。一行译文下面拆开的各项也用它。
* 带下一层的子行(字典 / 对象、跨几行写的列表的各项),下一层展开在它下面、再往右缩进一层,默认展开,能折叠;
* 带实参表的子行(列表里整项是调用),表排在它下面、同样往右缩进一层,总是展开。
* 标差异时(marks)新加的子行淡绿底、左边 +,删掉的放回原来的位置、淡红底划掉、左边 −,改了的格子逐截标。
*/
function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
export function PropertyItems({ parentId, items, folding, marks }: { parentId: string, items: PropertyItem[], folding: Folding, marks?: ItemsDiff }): ReactElement {
const nested = items.some(item => (item.items?.length ?? 0) > 0)
/** 一个子行:删掉的(change 是 del)不带 id、下面的子行总是展开 */
const itemRow = (item: PropertyItem, id: string, change: 'add' | 'del' | undefined, mark: ItemDiff | undefined): ReactElement => {
@@ -134,6 +134,23 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
expect(occurrencePlan(blocks, { start: 140, end: 147, kind: 'read' }, () => false)).toEqual({ id: '1', expand: [] })
})
it('名字在一行译文下面拆开的各项里:各项总是展开的,讲这个定义的项(字典的键)只闪它;项下面的下一层要展开那一项,实参表总是展开的', () => {
const blocks: BlockView[] = [
{ ...textBlock('0', 'return', 0, [word('返回'), word('列表 2 项')]), items: [
{ cells: [[word('第 1 项')], [word('新建'), name('Filter', 10)]], table: { columns: ['参数', '值'], items: [{ cells: [[name('field', 20)], [name('user_id', 30)]] }] } },
{ cells: [[word('第 2 项')], [word('字典 1 项')]], items: [{ cells: [[name('key', 40)], [name('value', 45)]], defines: { startIndex: 40, endIndex: 43 } }] },
] },
{ ...textBlock('1', 'return', 5, [word('返回'), word('对象 1 项')]), items: [{ cells: [[name('locked', 60)], [name('flag', 70)]], defines: { startIndex: 60, endIndex: 66 } }] },
]
expect(revealPlan(blocks, 0, 10)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 0, 30)).toEqual({ id: '0', expand: [] })
expect(revealPlan(blocks, 0, 40)).toEqual({ id: '0', row: '0/i/1/0', expand: ['0/i/1'] })
expect(revealPlan(blocks, 0, 45)).toEqual({ id: '0', expand: ['0/i/1'] })
expect(revealPlan(blocks, 5, 60)).toEqual({ id: '1', row: '1/i/0', expand: [] })
expect(occurrencePlan(blocks, { start: 45, end: 50, kind: 'read' }, () => false)).toEqual({ id: '0', expand: ['0/i/1'] })
expect(occurrencePlan(blocks, { start: 30, end: 37, kind: 'read' }, () => false)).toEqual({ id: '0', expand: [] })
})
it('名字在框里(套了几层都算)也是显示着它', () => {
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
const blocks: BlockView[] = [
+25 -13
View File
@@ -1,7 +1,7 @@
import type { BlockView, Inline, Occurrence, PreviewTable, PreviewText, PropertyItem, PropertyRow, SourceRange } from './types'
import { blockPath } from './filter'
import { showsOccurrence, sourceOf } from './occurrences'
import { propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState'
import { listId, propertyItemId, propertyRowId, tableId, unfoldToShow } from './viewState'
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
export interface RevealPlan {
@@ -87,34 +87,46 @@ function rowItems(row: PropertyRow, id: string): Generator<PlacedItem> {
return nestedItems(row.items, id, [id], !row.columns)
}
/** 一行译文下面那张实参表(总是展开的)里一层层的子行。 */
function tableItems(blockId: string, table: PreviewTable | undefined): Generator<PlacedItem> {
return nestedItems(table?.items, tableId(blockId), [], false)
/** 一行译文下面一层层的子行:拆开的各项(这一层总是展开的,项下面的下一层折叠在项上),摊开实参的表(总是展开的)。 */
function* textItems(blockId: string, items: PropertyItem[] | undefined, table: PreviewTable | undefined): Generator<PlacedItem> {
yield* nestedItems(items, listId(blockId), [], true)
yield* nestedItems(table?.items, tableId(blockId), [], false)
}
/** 一层层的子行里,讲 offset 处这个定义的那一个(defines 盖住 offset):只闪它,展开它上面的各层。 */
function definedItem(items: Iterable<PlacedItem>, offset: number): Spot | undefined {
for (const { item, id, expand } of items) {
if (item.defines && covers(item.defines, offset))
return { row: id, expand, span: width(item.defines) }
}
return undefined
}
/**
* 块里哪儿是 offset 处的定义,从具体到笼统:
* ① 块自己的名字(函数名、类名、只声明一个变量时的变量名)→ 整块;
* ② 属性表里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* ② 属性表、一行译文下面的子行里讲这个定义的一行 / 子行(defines 盖住 offset:参数、导入项、多变量声明里的一个、对象一项的键)→ 只闪它,
* 子行连它上面的各层(所在的行、嵌套时外层的子行)一起展开;
* ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面的表里的名字、没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* ③ 块里只是显示着这个名字(一段源码范围盖住 offset:一行译文和它下面拆开的各项、表里的名字,没填 defines 的翻译插件)→ 整块,名字在子行里时展开它上面的各层。
* span 是盖住 offset 的那段有多长。不在这块里是 undefined。
*/
function locate(block: BlockView, offset: number): Spot | undefined {
if (block.defines && covers(block.defines, offset))
return { expand: [], span: width(block.defines) }
if ('translation' in block) {
const defined = definedItem(textItems(block.id, block.items, block.table), offset)
if (defined)
return defined
const span = textSpan(block.translation, offset)
return span === undefined ? shownItem(tableItems(block.id, block.table), offset) : { expand: [], span }
return span === undefined ? shownItem(textItems(block.id, block.items, block.table), offset) : { expand: [], span }
}
const rows = block.properties.map((row, index) => ({ row, id: propertyRowId(block.id, index) }))
for (const { row, id } of rows) {
if (row.defines && covers(row.defines, offset))
return { row: id, expand: [], span: width(row.defines) }
for (const { item, id: itemId, expand } of rowItems(row, id)) {
if (item.defines && covers(item.defines, offset))
return { row: itemId, expand, span: width(item.defines) }
}
const defined = definedItem(rowItems(row, id), offset)
if (defined)
return defined
}
for (const { row, id } of rows) {
const value = textSpan(row.value, offset)
@@ -186,10 +198,10 @@ export function uncoveredPlan(plan: RevealPlan, covered: ReadonlyMap<string, str
return owner === undefined ? plan : { id: owner, expand: blockPath(owner).slice(0, -1) }
}
/** 块里哪儿显示着满足 test 的文字(译文和它下面的表、属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
/** 块里哪儿显示着满足 test 的文字(译文和它下面拆开的各项、表,属性行的值、各层子行的格子):要展开的属性行和子行;不在这块里是 undefined。 */
function showingSpot(block: BlockView, test: (text: PreviewText) => boolean): string[] | undefined {
if ('translation' in block)
return test(block.translation) ? [] : showingItem(tableItems(block.id, block.table), test)
return test(block.translation) ? [] : showingItem(textItems(block.id, block.items, block.table), test)
for (const [index, row] of block.properties.entries()) {
if (test(row.value))
return []
+5 -3
View File
@@ -181,9 +181,10 @@ export type CommentTarget
/**
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文;
* 一行译文下面依次是:跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),几行属性(rows:「注释」一行)
* 一行译文下面依次是:跨几行写的字面量拆开的各项(items),跨几行写的调用摊开各个实参的表(table),块的说明(doc,Markdown 原文),
* 几行属性(rows:「注释」一行)
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyItem[], table?: PreviewTable, doc?: Markdown, rows?: PropertyRow[] } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }
@@ -407,10 +408,11 @@ export interface RowDiff {
items?: ItemsDiff
}
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,摊开实参的表逐行,说明逐词 */
/** 一块自己的内容(不算子块)改了哪些:属性表(一行译文的卡片是下面那几行 rows)逐行,一行译文逐截,译文下面拆开的各项、摊开实参的表逐行,说明逐词 */
export interface BlockMarks {
rows?: RowsDiff
translation?: TextDiff
items?: ItemsDiff
table?: ItemsDiff
doc?: TextDiff
}
@@ -124,6 +124,14 @@ export function tableId(ownerId: string): string {
return `${ownerId}/t`
}
/**
* 一行译文下面拆开的各项(跨几行写的列表、字典)的上一层 id:`<块 id>/i`,各项的 id 是 propertyItemId(它, 项号)。
* 这一层总是展开的,项下面的下一层照常折叠在项的 id 上。
*/
export function listId(blockId: string): string {
return `${blockId}/i`
}
/**
* 父块看得见时,要让它的一个子块也看得见得展开的(块 id、属性行 id):一般的子块是父块自己(收起子块时不画);
* 挂在父卡片子行上的子块(BlockSlot)是它所在的那一行和它自己。