✨ feat(vscode): 代码阅读把推导式换成按读的顺序重排的遍历小框
This commit is contained in:
1 parent
677fb7da1f
commit
6416cdf714
16 files changed
+641
-91
No files matched your search
@@ -92,6 +92,33 @@ export type EntryFacts
|
||||
| { kind: 'spread', value: Node } // **other、...other:value 是展开的那个表达式
|
||||
| { kind: 'method', name: Node, node: Node, header: SourceSpan } // 对象字面量里的方法、getter、setter:node 是整个方法,header 同 BlockShape.header,只到主体开始之前
|
||||
|
||||
/** 写在表达式里的遍历得到什么:逐个产出的生成器,或一次建好的列表、集合、字典。 */
|
||||
export type IterationResult = 'generator' | 'list' | 'set' | 'dict'
|
||||
|
||||
/** 遍历的一节,照源码顺序:从来源里逐个取出变量(async:异步地取),或只留下满足条件的。 */
|
||||
export type IterationClause
|
||||
= | { kind: 'for', variables: Node, source: Node, async: boolean }
|
||||
| { kind: 'if', condition: Node }
|
||||
|
||||
/**
|
||||
* 写在表达式里的遍历(Python 的列表 / 集合 / 字典推导式、生成器表达式):语言插件从语法树读出结构,翻译插件按自己的语序重排。
|
||||
* 节点都指向源码;各节、每一项取的值里还能再套遍历。
|
||||
*/
|
||||
export interface IterationFacts {
|
||||
result: IterationResult
|
||||
/** 至少一节,第一节是 for。 */
|
||||
clauses: IterationClause[]
|
||||
/** 每一项取的值。 */
|
||||
value: Node
|
||||
/** 结果是字典时每一项的键。 */
|
||||
key?: Node
|
||||
/**
|
||||
* 遍历自己的一对括号同时是外面调用的括号时(Python `f(x for x in y)` 只写一对括号)是这对括号:
|
||||
* 它们属于外面的调用,照常写在遍历外面;别的写法里遍历自己的括号、方括号、花括号不写出来。
|
||||
*/
|
||||
sharedParentheses?: { open: Node, close: Node }
|
||||
}
|
||||
|
||||
export interface DeclaratorFacts {
|
||||
/** 变量名;解构声明是整个模式。 */
|
||||
name: Node
|
||||
@@ -170,6 +197,23 @@ export type TokenRole
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
|
||||
export type GroupStyle = 'iteration'
|
||||
|
||||
/**
|
||||
* 框起来的一组词:译文里自成一体的一段(写在表达式里的遍历),预览把它画成嵌在前后文字里的一个小框,框头是图标和 label。
|
||||
* 框里的词照样带源码下标,还能再套框。
|
||||
*/
|
||||
export interface SegmentGroup {
|
||||
style: GroupStyle
|
||||
/** 框头的文字(遍历的结果:生成器 / 列表 …),翻译插件给。 */
|
||||
label: string
|
||||
segments: Inline[]
|
||||
}
|
||||
|
||||
/** 译文里的一段:着色的词,或框起来的一组词。 */
|
||||
export type Inline = Segment | SegmentGroup
|
||||
|
||||
/**
|
||||
* 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换
|
||||
* (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。
|
||||
@@ -177,7 +221,7 @@ export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
export interface Markdown { markdown: string }
|
||||
|
||||
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */
|
||||
export type PreviewText = Segment[] | Markdown
|
||||
export type PreviewText = Inline[] | Markdown
|
||||
|
||||
/**
|
||||
* 属性表里的一行或一个子行讲的是哪个定义:定义出来的那个名字的节点(取自 facts,例如参数名、导入项的名称和别名、
|
||||
@@ -187,7 +231,7 @@ export interface Defines { defines?: SourceRange }
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
|
||||
export interface PropertyItem extends Defines {
|
||||
cells: Segment[][]
|
||||
cells: Inline[][]
|
||||
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
|
||||
items?: PropertyItem[]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { IterationFacts, Node } from '../contract'
|
||||
import type { Outline } from '../testSupport'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { factsByLine, outline, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { createPythonAdapter } from './python'
|
||||
import { factsByLine, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { createPythonAdapter, pythonIteration } from './python'
|
||||
|
||||
const adapter = createPythonAdapter(WASM_DIR)
|
||||
|
||||
@@ -326,3 +327,90 @@ describe('python 类', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('python 写在表达式里的遍历(推导式)', () => {
|
||||
/** 源码里每个认得出的遍历(外层在前),节点换成源码。 */
|
||||
async function iterations(source: string): Promise<unknown[]> {
|
||||
const tree = await runtime.parse(adapter, 'python', source)
|
||||
try {
|
||||
const found: IterationFacts[] = []
|
||||
const visit = (node: Node): void => {
|
||||
const facts = pythonIteration(node)
|
||||
if (facts)
|
||||
found.push(facts)
|
||||
for (const child of node.children) {
|
||||
if (child)
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree.rootNode)
|
||||
return found.map(nodesToText)
|
||||
}
|
||||
finally {
|
||||
tree.delete()
|
||||
}
|
||||
}
|
||||
|
||||
it('调用唯一的参数是生成器:那对括号是调用的,记在 sharedParentheses;参数不止一个时生成器自己带括号,没有', async () => {
|
||||
expect(await iterations('"".join(secrets.choice(ALPHABET) for _ in range(length))')).toEqual([{
|
||||
result: 'generator',
|
||||
clauses: [{ kind: 'for', variables: '_', source: 'range(length)', async: false }],
|
||||
value: 'secrets.choice(ALPHABET)',
|
||||
sharedParentheses: { open: '(', close: ')' },
|
||||
}])
|
||||
const source = 'f(a, (x for x in y))'
|
||||
expect(await iterations(source)).toEqual([{
|
||||
result: 'generator',
|
||||
clauses: [{ kind: 'for', variables: 'x', source: 'y', async: false }],
|
||||
value: 'x',
|
||||
}])
|
||||
})
|
||||
|
||||
it('列表带条件、字典分出键和值、集合的变量是解构', async () => {
|
||||
expect(await iterations('[u.id for u in users if u.is_active]')).toEqual([{
|
||||
result: 'list',
|
||||
clauses: [{ kind: 'for', variables: 'u', source: 'users', async: false }, { kind: 'if', condition: 'u.is_active' }],
|
||||
value: 'u.id',
|
||||
}])
|
||||
expect(await iterations('{u.id: u for u in users}')).toEqual([{
|
||||
result: 'dict',
|
||||
clauses: [{ kind: 'for', variables: 'u', source: 'users', async: false }],
|
||||
key: 'u.id',
|
||||
value: 'u',
|
||||
}])
|
||||
expect(await iterations('{n for n, _ in pairs}')).toEqual([{
|
||||
result: 'set',
|
||||
clauses: [{ kind: 'for', variables: 'n, _', source: 'pairs', async: false }],
|
||||
value: 'n',
|
||||
}])
|
||||
})
|
||||
|
||||
it('几节 for、if 照源码顺序;async for 标出异步', async () => {
|
||||
expect(await iterations('[c for row in grid if row for c in row if c]')).toEqual([{
|
||||
result: 'list',
|
||||
clauses: [
|
||||
{ kind: 'for', variables: 'row', source: 'grid', async: false },
|
||||
{ kind: 'if', condition: 'row' },
|
||||
{ kind: 'for', variables: 'c', source: 'row', async: false },
|
||||
{ kind: 'if', condition: 'c' },
|
||||
],
|
||||
value: 'c',
|
||||
}])
|
||||
expect(await iterations('[v async for v in stream() if v]')).toEqual([{
|
||||
result: 'list',
|
||||
clauses: [{ kind: 'for', variables: 'v', source: 'stream()', async: true }, { kind: 'if', condition: 'v' }],
|
||||
value: 'v',
|
||||
}])
|
||||
})
|
||||
|
||||
it('套着的遍历各自认,外层取的值就是里层整个遍历', async () => {
|
||||
expect(await iterations('[[y for y in row] for row in grid]')).toEqual([
|
||||
{ result: 'list', clauses: [{ kind: 'for', variables: 'row', source: 'grid', async: false }], value: '[y for y in row]' },
|
||||
{ result: 'list', clauses: [{ kind: 'for', variables: 'y', source: 'row', async: false }], value: 'y' },
|
||||
])
|
||||
})
|
||||
|
||||
it('列表、字典、元组字面量和 for 语句不是', async () => {
|
||||
expect(await iterations('x = [1, 2]\ny = {"a": 1}\nz = (a, b)\nfor x in y:\n pass')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
|
||||
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 { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
|
||||
@@ -258,6 +258,54 @@ function dictionaryEntries(value: Node | null): EntryFacts[] | undefined {
|
||||
})
|
||||
}
|
||||
|
||||
const ITERATION_RESULTS: Record<string, IterationResult> = {
|
||||
generator_expression: 'generator',
|
||||
list_comprehension: 'list',
|
||||
set_comprehension: 'set',
|
||||
dictionary_comprehension: 'dict',
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导式、生成器表达式的结构,别的节点没有。几种读不成遍历的写法也没有,照常翻:
|
||||
* `for x in a, b`(语法树认、Python 3 不认的多个来源),以及缺了变量、来源、条件或每一项的值(写到一半的代码)。
|
||||
*/
|
||||
export function pythonIteration(node: Node): IterationFacts | undefined {
|
||||
const result = ITERATION_RESULTS[node.type]
|
||||
const body = node.childForFieldName('body')
|
||||
if (!result || !body)
|
||||
return undefined
|
||||
const clauses: IterationClause[] = []
|
||||
for (const child of syntaxChildren(node)) {
|
||||
if (child.type === 'for_in_clause') {
|
||||
const variables = child.childForFieldName('left')
|
||||
const sources = child.childrenForFieldName('right').filter(source => source !== null)
|
||||
if (!variables || sources.length !== 1)
|
||||
return undefined
|
||||
clauses.push({ kind: 'for', variables, source: sources[0], async: child.children.some(token => token?.type === 'async') })
|
||||
}
|
||||
else if (child.type === 'if_clause') {
|
||||
const [condition] = syntaxChildren(child)
|
||||
if (!condition)
|
||||
return undefined
|
||||
clauses.push({ kind: 'if', condition })
|
||||
}
|
||||
}
|
||||
if (clauses[0]?.kind !== 'for')
|
||||
return undefined
|
||||
const key = result === 'dict' ? body.childForFieldName('key') : undefined
|
||||
const value = result === 'dict' ? body.childForFieldName('value') : body
|
||||
if (!value || key === null)
|
||||
return undefined
|
||||
const facts: IterationFacts = { result, clauses, value, ...(key && { key }) }
|
||||
// 调用唯一的参数是生成器表达式时只写一对括号(f(x for x in y)),语法树把这对括号算给生成器
|
||||
const call = node.parent
|
||||
const open = node.child(0)
|
||||
const close = node.child(node.childCount - 1)
|
||||
if (result === 'generator' && call?.type === 'call' && sameNode(call.childForFieldName('arguments'), node) && open && close)
|
||||
facts.sharedParentheses = { open, close }
|
||||
return facts
|
||||
}
|
||||
|
||||
/** elif / else 展平成紧跟 if 的兄弟块。 */
|
||||
function ifBlocks(node: Node): CodeBlock[] {
|
||||
const blocks = [bodyBlock('if', node, bodyOf(node), 'other')]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BlockKind, CodeBlock, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
|
||||
import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { Node } from '@vscode/tree-sitter-wasm'
|
||||
@@ -45,8 +45,14 @@ export function walk(blocks: CodeBlock[], visit: (block: CodeBlock) => void): vo
|
||||
}
|
||||
}
|
||||
|
||||
export function segmentText(segments: Segment[]): string {
|
||||
return segments.map(segment => segment.text).join('')
|
||||
/** 着色的词拼成文字;框写成 `⟦框头|框里的文字⟧`。 */
|
||||
export function segmentText(segments: Inline[]): string {
|
||||
return segments.map(piece => 'role' in piece ? piece.text : `⟦${piece.label}|${segmentText(piece.segments)}⟧`).join('')
|
||||
}
|
||||
|
||||
/** 框拆开,框里的词按顺序接在前后的词中间(框头不算)。 */
|
||||
export function flatSegments(pieces: Inline[]): Segment[] {
|
||||
return pieces.flatMap(piece => 'role' in piece ? [piece] : flatSegments(piece.segments))
|
||||
}
|
||||
|
||||
/** 预览文字写成文字:着色的词拼起来,Markdown 取原文。 */
|
||||
@@ -64,7 +70,7 @@ export function propertyLines(rows: PropertyRow[]): string[] {
|
||||
}
|
||||
|
||||
/** 语法树节点换成它的源码,没填的可选字段去掉。 */
|
||||
function nodesToText(value: unknown): unknown {
|
||||
export function nodesToText(value: unknown): unknown {
|
||||
if (value instanceof Node)
|
||||
return value.text
|
||||
if (Array.isArray(value))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Node, Translator } from '../contract'
|
||||
import { insideAnnotation, pythonTokenRole } from '../languages/python'
|
||||
import { insideAnnotation, pythonIteration, pythonTokenRole } from '../languages/python'
|
||||
import { createZhTemplateTranslator } from './zhTemplate'
|
||||
|
||||
function stringContent(node: Node): string {
|
||||
@@ -39,7 +39,7 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({
|
||||
},
|
||||
},
|
||||
'for': '遍历',
|
||||
'in': { zh: '在', byContext: { for_statement: '取自', for_in_clause: '取自' } },
|
||||
'in': { zh: '在', byContext: { for_statement: '取自' } },
|
||||
'while': '当',
|
||||
'try': '尝试',
|
||||
'except': '捕获',
|
||||
@@ -87,4 +87,5 @@ export const pythonZhTranslator: Translator = createZhTemplateTranslator({
|
||||
nodeTemplates: {
|
||||
conditional_expression: ['如果', { namedChild: 1 }, '则', { namedChild: 0 }, '否则', { namedChild: 2 }],
|
||||
},
|
||||
iteration: pythonIteration,
|
||||
})
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract'
|
||||
import type { Inline, LanguageAdapter, PropertyItem, PropertyRow, Segment, Translator } from '../contract'
|
||||
import type { ZhTable } from './zhTemplate'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||
import { propertyLines, readFixture, walk, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { flatSegments, propertyLines, readFixture, segmentText, walk, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from './pythonZh'
|
||||
import { typescriptZhTranslator } from './typescriptZh'
|
||||
import { createZhTemplateTranslator } from './zhTemplate'
|
||||
@@ -12,7 +12,7 @@ const ts = createTypeScriptAdapter(WASM_DIR)
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
/** 一块的译文:一行译文在 segments 里;注释块是 Markdown,原文在 markdown 里,segments 为空。 */
|
||||
interface Translated { kind: string, segments: Segment[], markdown?: string }
|
||||
interface Translated { kind: string, segments: Inline[], markdown?: string }
|
||||
|
||||
/** 一行译文块、注释块的译文,按 header 起始行(1 起)索引;同一行有多块时取外层那块。属性表块见 describeAll。 */
|
||||
function translateAll(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<Map<number, Translated>> {
|
||||
@@ -48,15 +48,21 @@ function rowTexts(map: Map<number, PropertyRow[]>): Record<number, string[]> {
|
||||
return Object.fromEntries([...map].map(([line, rows]) => [line, propertyLines(rows)]))
|
||||
}
|
||||
|
||||
function valueOf(rows: PropertyRow[] | undefined, label: string): Segment[] {
|
||||
function valueOf(rows: PropertyRow[] | undefined, label: string): Inline[] {
|
||||
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 {
|
||||
return (segments ?? []).map(segment => segment.text).join('')
|
||||
/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`。 */
|
||||
function text(segments: Inline[] | undefined): string {
|
||||
return segmentText(segments ?? [])
|
||||
}
|
||||
|
||||
/** 带下标的段(框里的也算),按出现顺序写成 [文字, 下标]。 */
|
||||
function linked(segments: Inline[] | undefined): [string, number][] {
|
||||
return flatSegments(segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
|
||||
}
|
||||
|
||||
function texts(map: Map<number, Translated>): Record<number, string> {
|
||||
@@ -72,14 +78,15 @@ const CJK = /\p{Script=Han}/u
|
||||
const CJK_ONLY = /^\p{Script=Han}+$/u
|
||||
const WORD_CHAR = /[\p{Script=Han}\w]/u
|
||||
|
||||
/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起。 */
|
||||
/** 被翻的词单独成段,而且不和前后的字母、数字、汉字粘在一起;框里的词和框前后的词也一样。 */
|
||||
function expectWordsApart(map: Map<number, Translated>): void {
|
||||
for (const [line, { kind, segments }] of map) {
|
||||
if (kind === 'comment')
|
||||
continue
|
||||
const full = text(segments)
|
||||
const flat = flatSegments(segments)
|
||||
const full = flat.map(segment => segment.text).join('')
|
||||
let at = 0
|
||||
for (const segment of segments) {
|
||||
for (const segment of flat) {
|
||||
if (segment.role !== 'string' && CJK.test(segment.text)) {
|
||||
expect(segment.text, `L${line} ${full}`).toMatch(CJK_ONLY)
|
||||
for (const neighbor of [full[at - 1], full[at + segment.text.length]])
|
||||
@@ -125,7 +132,7 @@ describe('typeScript 中文直译', () => {
|
||||
it('每个被翻的词单独成段,着色取语法树词性', async () => {
|
||||
const env = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
|
||||
const issue = await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('issueLock.ts.txt'))
|
||||
expect(colors(env.get(32)?.segments.filter(segment => segment.role !== 'plain'))).toEqual([
|
||||
expect(colors(flatSegments(env.get(32)?.segments ?? []).filter(segment => segment.role !== 'plain'))).toEqual([
|
||||
{ text: '公共', role: 'keyword' },
|
||||
{ text: '接口', role: 'storage' },
|
||||
{ text: 'EnvLockResult', role: 'type' },
|
||||
@@ -213,7 +220,7 @@ describe('typeScript 中文直译', () => {
|
||||
3: 'w = 如果 a 则 b 否则 如果 c 则 d 否则 e',
|
||||
4: 'v = 如果 a 不全等 b 并且 非 c 则 类型为 d 否则 等待 e',
|
||||
})
|
||||
expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword')).toEqual([
|
||||
expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword')).toEqual([
|
||||
{ text: '如果', role: 'keyword' },
|
||||
{ text: '则', role: 'keyword' },
|
||||
{ text: '否则', role: 'keyword' },
|
||||
@@ -252,7 +259,7 @@ describe('python 中文直译', () => {
|
||||
expectWordsApart(map)
|
||||
})
|
||||
|
||||
it('内置类型只在注解里翻,in 按位置区分取自 / 在', async () => {
|
||||
it('内置类型只在注解里翻;比较里的 not in 是「不在」,推导式里也是', async () => {
|
||||
const source = [
|
||||
'def pick(items: dict[str, int], list: set) -> tuple:',
|
||||
' return [str(k) for k in items if k not in list]',
|
||||
@@ -266,7 +273,7 @@ describe('python 中文直译', () => {
|
||||
' list | 集合 | ',
|
||||
'输出参数: 元组',
|
||||
])
|
||||
expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 [str(k) 遍历 k 取自 items 如果 k 不在 list]')
|
||||
expect(texts(await translateAll(py, 'python', pythonZhTranslator, source))[2]).toBe('返回 ⟦列表|遍历 items 的每个 k,仅当 k 不在 list,取 str(k)⟧')
|
||||
})
|
||||
|
||||
it('逻辑与、或译成「并且」「或者」', async () => {
|
||||
@@ -324,9 +331,9 @@ describe('python 中文直译', () => {
|
||||
1: '如果 _role 等于 "beat" 则 5680 否则 5679',
|
||||
2: '如果 x 则 a 否则 如果 y 则 b 否则 c',
|
||||
3: '如果 y 则 (如果 x 则 a 否则 b) 否则 c',
|
||||
4: '如果 非 done 并且 ok 则 [k 遍历 k 取自 items 如果 k 不在 seen] 否则 空',
|
||||
4: '如果 非 done 并且 ok 则 ⟦列表|遍历 items 的每个 k,仅当 k 不在 seen,取 k⟧ 否则 空',
|
||||
})
|
||||
expect(valueOf(map.get(1), '初始值').filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([
|
||||
expect(flatSegments(valueOf(map.get(1), '初始值')).filter(segment => segment.role === 'keyword' || segment.role === 'operator')).toEqual([
|
||||
{ text: '如果', role: 'keyword' },
|
||||
{ text: '等于', role: 'operator' },
|
||||
{ text: '则', role: 'keyword' },
|
||||
@@ -335,6 +342,123 @@ describe('python 中文直译', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('python 写在表达式里的遍历(推导式)', () => {
|
||||
it('spec 样例逐字一致:原地换成一个框,框里按读的顺序重排;调用和生成器共用的括号写在框外', async () => {
|
||||
const source = [
|
||||
'def invite_code(length):',
|
||||
' return "".join(secrets.choice(ALPHABET) for _ in range(length))',
|
||||
'def active_ids(users):',
|
||||
' return [u.id for u in users if u.is_active]',
|
||||
'def by_id(users):',
|
||||
' return {u.id: u for u in users}',
|
||||
].join('\n')
|
||||
const map = await translateAll(py, 'python', pythonZhTranslator, source)
|
||||
expect(texts(map)).toEqual({
|
||||
2: '返回 "".join(⟦生成器|遍历 range(length) 的每个 _,取 secrets.choice(ALPHABET)⟧)',
|
||||
4: '返回 ⟦列表|遍历 users 的每个 u,仅当 u.is_active,取 u.id⟧',
|
||||
6: '返回 ⟦字典|遍历 users 的每个 u,取 u.id: u⟧',
|
||||
})
|
||||
expectWordsApart(map)
|
||||
})
|
||||
|
||||
it('框是 iteration 样式、框头是结果类型;遍历 / 的每个 / 仅当 / 取 按关键字着色,框里的名字照常着色', async () => {
|
||||
const map = await translateAll(py, 'python', pythonZhTranslator, 'return [u.id for u in users if u.is_active]')
|
||||
expect(colors(map.get(1)?.segments)).toEqual([
|
||||
{ text: '返回', role: 'keyword' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{
|
||||
style: 'iteration',
|
||||
label: '列表',
|
||||
segments: [
|
||||
{ text: '遍历', role: 'keyword' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: 'users', role: 'variable' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: '的每个', role: 'keyword' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: 'u', role: 'variable' },
|
||||
{ text: ',', role: 'plain' },
|
||||
{ text: '仅当', role: 'keyword' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: 'u', role: 'variable' },
|
||||
{ text: '.', role: 'plain' },
|
||||
{ text: 'is_active', role: 'variable' },
|
||||
{ text: ',', role: 'plain' },
|
||||
{ text: '取', role: 'keyword' },
|
||||
{ text: ' ', role: 'plain' },
|
||||
{ text: 'u', role: 'variable' },
|
||||
{ text: '.', role: 'plain' },
|
||||
{ text: 'id', role: 'variable' },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('紧挨着的几节 for 用顿号接着写,条件在它的位置,异步的写「异步遍历」;集合、套着的框、别的参数旁边的生成器', async () => {
|
||||
const source = [
|
||||
'a = [c for xs in grid for c in xs]',
|
||||
'b = [c for row in grid if row for c in row if c]',
|
||||
'c = [v async for v in stream()]',
|
||||
'd = [(x, y) for x in xs async for y in ys]',
|
||||
'e = {n for n, _ in pairs}',
|
||||
'f = [[y for y in row] for row in grid]',
|
||||
'g = pick(a, (y for y in b))',
|
||||
'h = sum(',
|
||||
' n # 每一项',
|
||||
' for n in counts',
|
||||
')',
|
||||
].join('\n')
|
||||
const map = await describeAll(py, 'python', pythonZhTranslator, source)
|
||||
expect(Object.fromEntries([...map].map(([line, rows]) => [line, text(valueOf(rows, '初始值'))]))).toEqual({
|
||||
1: '⟦列表|遍历 grid 的每个 xs、xs 的每个 c,取 c⟧',
|
||||
2: '⟦列表|遍历 grid 的每个 row,仅当 row,遍历 row 的每个 c,仅当 c,取 c⟧',
|
||||
3: '⟦列表|异步遍历 stream() 的每个 v,取 v⟧',
|
||||
4: '⟦列表|遍历 xs 的每个 x,异步遍历 ys 的每个 y,取 (x, y)⟧',
|
||||
5: '⟦集合|遍历 pairs 的每个 n, _,取 n⟧',
|
||||
6: '⟦列表|遍历 grid 的每个 row,取 ⟦列表|遍历 row 的每个 y,取 y⟧⟧',
|
||||
7: 'pick(a, ⟦生成器|遍历 b 的每个 y,取 y⟧)',
|
||||
8: 'sum(⟦生成器|遍历 counts 的每个 n,取 n⟧)',
|
||||
})
|
||||
const async = valueOf(map.get(3), '初始值')[0]
|
||||
expect('segments' in async && async.segments.filter(piece => 'role' in piece && piece.role === 'keyword')).toEqual([
|
||||
{ text: '异步遍历', role: 'keyword' },
|
||||
{ text: '的每个', role: 'keyword' },
|
||||
{ text: '取', role: 'keyword' },
|
||||
])
|
||||
})
|
||||
|
||||
it('属性表的值里也换:参数默认值、字典一项的值', async () => {
|
||||
const source = [
|
||||
'def f(ids=[u.id for u in users]): pass',
|
||||
'CONFIG = {"ids": {k: v for k, v in pairs}}',
|
||||
].join('\n')
|
||||
const rows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))
|
||||
expect(rows[1]).toContain(' ids | 未标注 | 默认 ⟦列表|遍历 users 的每个 u,取 u.id⟧')
|
||||
expect(rows[2]).toEqual(['名称: CONFIG', '访问级别: 公共', '值: 字典 1 项', ' "ids" | ⟦字典|遍历 pairs 的每个 k, v,取 k: v⟧'])
|
||||
})
|
||||
|
||||
it('框里的名字带源码下标,和框外的一样指回源码原文', async () => {
|
||||
const source = 'return "".join(secrets.choice(ALPHABET) for _ in range(length))'
|
||||
const at = (word: string): number => source.indexOf(word)
|
||||
const map = await translateAll(py, 'python', pythonZhTranslator, source)
|
||||
expect(linked(map.get(1)?.segments)).toEqual([
|
||||
['""', at('""')],
|
||||
['join', at('join')],
|
||||
['range', at('range')],
|
||||
['length', at('length')],
|
||||
['_', at('_')],
|
||||
['secrets', at('secrets')],
|
||||
['choice', at('choice')],
|
||||
['ALPHABET', at('ALPHABET')],
|
||||
])
|
||||
})
|
||||
|
||||
it('typeScript 没有这种写法,不认', async () => {
|
||||
const map = await translateAll(ts, 'typescript', typescriptZhTranslator, 'return items.map(x => x.id)')
|
||||
expect(text(map.get(1)?.segments)).toBe('返回 items.map(x => x.id)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('属性表', () => {
|
||||
it('spec 样例逐字一致', async () => {
|
||||
const env = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt')))
|
||||
@@ -703,18 +827,13 @@ describe('属性表', () => {
|
||||
})
|
||||
|
||||
describe('源码下标(跳到定义用)', () => {
|
||||
/** 带下标的段,按出现顺序写成 [文字, 下标]。 */
|
||||
function linked(segments: Segment[] | undefined): [string, number][] {
|
||||
return (segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
|
||||
}
|
||||
|
||||
function itemCells(rows: PropertyRow[] | undefined, label: string): Segment[][] {
|
||||
function itemCells(rows: PropertyRow[] | undefined, label: string): Inline[][] {
|
||||
return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? []
|
||||
}
|
||||
|
||||
function propertySegments(rows: PropertyRow[]): Segment[] {
|
||||
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)])
|
||||
return rows.flatMap(row => [...('markdown' in row.value ? [] : row.value), ...itemSegments(row.items)])
|
||||
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flat()), ...itemSegments(item.items)])
|
||||
return rows.flatMap(row => [...('markdown' in row.value ? [] : flatSegments(row.value)), ...itemSegments(row.items)])
|
||||
}
|
||||
|
||||
it('python:点分模块名每一节、导入项、别名各自带下标,点号不带;前面有中文注释也对得上', async () => {
|
||||
@@ -867,7 +986,7 @@ describe('源码下标(跳到定义用)', () => {
|
||||
]
|
||||
for (const [adapter, languageId, translator, name] of samples) {
|
||||
const source = readFixture(name)
|
||||
const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => block.segments)
|
||||
const translated = [...(await translateAll(adapter, languageId, translator, source)).values()].flatMap(block => flatSegments(block.segments))
|
||||
const described = [...(await describeAll(adapter, languageId, translator, source)).values()].flatMap(propertySegments)
|
||||
const segments = [...translated, ...described]
|
||||
expect(linked(segments).length, name).toBeGreaterThan(10)
|
||||
@@ -956,7 +1075,7 @@ describe('中文模板引擎', () => {
|
||||
2: 'print(若 c 则 a 不然 若 d 则 b 不然 e)',
|
||||
3: 'print((若 k 等于 1 则 a 不然 b))',
|
||||
})
|
||||
expect(map.get(1)?.segments.filter(segment => segment.role === 'keyword').map(segment => segment.text))
|
||||
expect(flatSegments(map.get(1)?.segments ?? []).filter(segment => segment.role === 'keyword').map(segment => segment.text))
|
||||
.toEqual(['令', '加上', '若', '则', '不然'])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import { commonIndent, splitLines } from '../editText'
|
||||
|
||||
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
|
||||
@@ -49,18 +49,26 @@ export interface ZhTable {
|
||||
* 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。
|
||||
*/
|
||||
nodeTemplates: Record<string, TemplatePart[]>
|
||||
/**
|
||||
* 写在表达式里的遍历(推导式、生成器表达式)的结构,语言插件给;没有这种写法的语言不填。
|
||||
* 认出来的节点整个换成一个框,框里按读的顺序重排,见 iterationGroup。
|
||||
*/
|
||||
iteration?: (node: Node) => IterationFacts | undefined
|
||||
}
|
||||
|
||||
interface Token {
|
||||
/** 注释是空文字:不进译文,只在前后留一个空格。 */
|
||||
text: string
|
||||
role: TokenRole
|
||||
translated: boolean
|
||||
/** 源码里的原文,用来认出逗号、括号;模板插入的译词没有原文。 */
|
||||
/** 源码里的原文,用来认出逗号、括号;模板插入的译词、框没有原文。 */
|
||||
raw: string
|
||||
/** 前面要空一格:源码里它前面有空白,或者模板在它前面插了别的项。 */
|
||||
gap: boolean
|
||||
/** 照抄的名字、字符串在源码里的起始下标,见 Segment.offset。 */
|
||||
offset?: number
|
||||
/** 整个换成一个框的节点(写在表达式里的遍历):输出这个框,text 为空。 */
|
||||
group?: SegmentGroup
|
||||
}
|
||||
|
||||
/** 按译文顺序收集词:cursor 是上一个词在源码里的结尾,gap 是下一个词前面要不要空一格。 */
|
||||
@@ -90,10 +98,14 @@ function commentsWithin(node: Node, span: SourceSpan): Node[] {
|
||||
return node.children.flatMap(child => child ? commentsWithin(child, span) : [])
|
||||
}
|
||||
|
||||
function isComment(token: Token): boolean {
|
||||
return token.text === '' && !token.group
|
||||
}
|
||||
|
||||
/** 下一个要输出的词的原文(跳过注释)。 */
|
||||
function nextRaw(tokens: Token[], i: number): string {
|
||||
for (let j = i + 1; j < tokens.length; j++) {
|
||||
if (tokens[j].text !== '')
|
||||
if (!isComment(tokens[j]))
|
||||
return tokens[j].raw
|
||||
}
|
||||
return ''
|
||||
@@ -142,6 +154,7 @@ const KIND_ZH: Record<BlockKind, string> = {
|
||||
comment: '注释',
|
||||
}
|
||||
const ACCESS_ZH: Record<Access, string> = { public: '公共', private: '私有', protected: '受保护' }
|
||||
const ITERATION_ZH: Record<IterationResult, string> = { generator: '生成器', list: '列表', set: '集合', dict: '字典' }
|
||||
const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' }
|
||||
|
||||
/** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */
|
||||
@@ -225,6 +238,76 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
out.cursor = node.endIndex
|
||||
}
|
||||
|
||||
/** 语言插件认出的写在表达式里的遍历;没整个落在要翻的这一段里就不重排、照常翻。 */
|
||||
function iterationIn(node: Node, span: SourceSpan): IterationFacts | undefined {
|
||||
if (!table.iteration || node.startIndex < span.startIndex || node.endIndex > span.endIndex)
|
||||
return undefined
|
||||
return table.iteration(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* 遍历按读的顺序重排进一个框:「遍历 <来源> 的每个 <变量>」,紧挨着的几节 for 用顿号接着写(异步的写「异步遍历」,
|
||||
* 同步、异步换了就另起一句),条件在它所在的位置写「,仅当 <条件>」,最后「,取 <值>」,字典是「<键>: <值>」。
|
||||
* 来源、变量、条件、值照常翻,里面的名字带源码下标,套着的遍历是框里的框。
|
||||
*/
|
||||
function iterationGroup(facts: IterationFacts, span: SourceSpan, source: string): SegmentGroup {
|
||||
const segments: Inline[] = []
|
||||
const plain = (text: string): void => pushSegment(segments, text, 'plain')
|
||||
const word = (text: string): void => pushSegment(segments, text, 'keyword')
|
||||
const say = (node: Node): void => appendInline(segments, joinTokens(tokensIn(node, span, source)))
|
||||
let previous: IterationClause | undefined
|
||||
for (const clause of facts.clauses) {
|
||||
if (clause.kind === 'if') {
|
||||
plain(',')
|
||||
word('仅当')
|
||||
plain(' ')
|
||||
say(clause.condition)
|
||||
}
|
||||
else {
|
||||
if (previous?.kind === 'for' && previous.async === clause.async) {
|
||||
plain('、')
|
||||
}
|
||||
else {
|
||||
if (previous)
|
||||
plain(',')
|
||||
word(clause.async ? '异步遍历' : '遍历')
|
||||
plain(' ')
|
||||
}
|
||||
say(clause.source)
|
||||
plain(' ')
|
||||
word('的每个')
|
||||
plain(' ')
|
||||
say(clause.variables)
|
||||
}
|
||||
previous = clause
|
||||
}
|
||||
plain(',')
|
||||
word('取')
|
||||
plain(' ')
|
||||
if (facts.key) {
|
||||
say(facts.key)
|
||||
plain(': ')
|
||||
}
|
||||
say(facts.value)
|
||||
return { style: 'iteration', label: ITERATION_ZH[facts.result], segments }
|
||||
}
|
||||
|
||||
/** 遍历整个换成一个框;和外面的调用共用的那对括号照常写在框外,紧贴着框。 */
|
||||
function emitIteration(out: Collector, node: Node, facts: IterationFacts, span: SourceSpan): void {
|
||||
const shared = facts.sharedParentheses
|
||||
const bracket = (paren: Node): Omit<Token, 'gap' | 'raw'> => ({ text: paren.text, role: table.tokenRole(paren), translated: false })
|
||||
if (shared)
|
||||
emitSource(out, shared.open, bracket(shared.open))
|
||||
else
|
||||
advance(out, node.startIndex)
|
||||
emit(out, { text: '', role: 'plain', translated: true, raw: '', group: iterationGroup(facts, span, out.source) })
|
||||
if (shared) {
|
||||
out.cursor = shared.close.startIndex
|
||||
emitSource(out, shared.close, bracket(shared.close))
|
||||
}
|
||||
out.cursor = node.endIndex
|
||||
}
|
||||
|
||||
function collect(node: Node, span: SourceSpan, out: Collector): void {
|
||||
if (node.endIndex <= span.startIndex || node.startIndex >= span.endIndex || node.startIndex === node.endIndex)
|
||||
return
|
||||
@@ -232,6 +315,11 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
emitSource(out, node, { text: '', role: 'comment', translated: false })
|
||||
return
|
||||
}
|
||||
const iteration = iterationIn(node, span)
|
||||
if (iteration) {
|
||||
emitIteration(out, node, iteration, span)
|
||||
return
|
||||
}
|
||||
const parts = templateParts(node, span)
|
||||
if (parts) {
|
||||
advance(out, node.startIndex)
|
||||
@@ -314,7 +402,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
function describe(block: FactBlock, source: string): PropertyRow[] {
|
||||
const { header } = block
|
||||
// 值里带语句块主体的回调(useMemo(() => { … }))只翻到主体开始之前,和块头部一致
|
||||
const within = (node: Node, part: SourceSpan): Segment[] => {
|
||||
const within = (node: Node, part: SourceSpan): Inline[] => {
|
||||
const span = part.endIndex > header.endIndex ? { ...part, endIndex: header.endIndex, endRow: header.endRow } : part
|
||||
return joinTokens(tokensIn(node, span, source))
|
||||
}
|
||||
@@ -353,30 +441,43 @@ function segment(text: string, role: TokenRole, offset: number | undefined): Seg
|
||||
return offset === undefined ? { text, role } : { text, role, offset }
|
||||
}
|
||||
|
||||
function pushSegment(segments: Segment[], text: string, role: TokenRole, offset?: number): void {
|
||||
/** 相邻的两段不带下标的普通文字并成一段;框不和别的并。 */
|
||||
function pushSegment(segments: Inline[], text: string, role: TokenRole, offset?: number): void {
|
||||
if (!text)
|
||||
return
|
||||
const prev = segments.at(-1)
|
||||
if (role === 'plain' && prev?.role === 'plain')
|
||||
if (role === 'plain' && prev && 'role' in prev && prev.role === 'plain')
|
||||
prev.text += text
|
||||
else
|
||||
segments.push(segment(text, role, offset))
|
||||
}
|
||||
|
||||
function appendInline(segments: Inline[], pieces: Inline[]): void {
|
||||
for (const piece of pieces) {
|
||||
if ('role' in piece)
|
||||
pushSegment(segments, piece.text, piece.role, piece.offset)
|
||||
else
|
||||
segments.push(piece)
|
||||
}
|
||||
}
|
||||
|
||||
/** 词连成译文:源码里隔着空白的词之间空一格,注释不进译文,右括号前的尾逗号去掉。 */
|
||||
function joinTokens(tokens: Token[]): Segment[] {
|
||||
const segments: Segment[] = []
|
||||
function joinTokens(tokens: Token[]): Inline[] {
|
||||
const segments: Inline[] = []
|
||||
let gap = false
|
||||
tokens.forEach((token, i) => {
|
||||
gap ||= token.gap
|
||||
const trailingComma = token.raw === ',' && !token.translated && CLOSING.has(nextRaw(tokens, i))
|
||||
if (token.text === '' || trailingComma) {
|
||||
if (isComment(token) || trailingComma) {
|
||||
gap ||= token.role === 'comment'
|
||||
return
|
||||
}
|
||||
if (gap && segments.length > 0)
|
||||
pushSegment(segments, ' ', 'plain')
|
||||
pushSegment(segments, token.text, token.role, token.offset)
|
||||
if (token.group)
|
||||
segments.push(token.group)
|
||||
else
|
||||
pushSegment(segments, token.text, token.role, token.offset)
|
||||
// 「非」是个词,后面补一个空格,不和操作数粘在一起
|
||||
gap = token.translated && token.raw === '!'
|
||||
})
|
||||
@@ -388,9 +489,9 @@ function joinTokens(tokens: Token[]): Segment[] {
|
||||
/** 属性表里的值怎么写。 */
|
||||
interface Phrases {
|
||||
/** 按表翻译,只翻落在块头部里的那一段。 */
|
||||
translated: (node: Node) => Segment[]
|
||||
translated: (node: Node) => Inline[]
|
||||
/** 同 translated,只翻 node 落在 span 里的那一段(对象字面量里方法的头部)。 */
|
||||
within: (node: Node, span: SourceSpan) => Segment[]
|
||||
within: (node: Node, span: SourceSpan) => Inline[]
|
||||
/** 字典 / 对象字面量的项数:「字典 3 项」,叫法按字面量的节点类型查表。 */
|
||||
entryCount: (literal: Node, size: number) => Segment[]
|
||||
/** 源码原样照抄、按源码着色(装饰器)。 */
|
||||
@@ -416,7 +517,7 @@ function count(n: number): Segment[] {
|
||||
return [hint(n > 0 ? `${n} 个` : '无')]
|
||||
}
|
||||
|
||||
function joined(parts: Segment[][], separator: string): Segment[] {
|
||||
function joined(parts: Inline[][], separator: string): Inline[] {
|
||||
return parts.flatMap((part, i) => i > 0 ? [{ text: separator, role: 'plain' as const }, ...part] : part)
|
||||
}
|
||||
|
||||
@@ -443,7 +544,7 @@ function importNodes(item: ImportItem): (Node | undefined)[] {
|
||||
}
|
||||
}
|
||||
|
||||
function aliased(item: Segment[], alias: Node | undefined, say: Phrases): Segment[] {
|
||||
function aliased(item: Inline[], alias: Node | undefined, say: Phrases): Inline[] {
|
||||
if (!alias)
|
||||
return item
|
||||
return [...item, { text: ' ', role: 'plain' }, { text: '作为', role: 'keyword' }, { text: ' ', role: 'plain' }, ...say.translated(alias)]
|
||||
@@ -478,7 +579,7 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
|
||||
* 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。
|
||||
*/
|
||||
function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
|
||||
const listRow = (label: string, parts: Segment[][], defines: Defines = {}): PropertyRow[] =>
|
||||
const listRow = (label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] =>
|
||||
parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : []
|
||||
return [
|
||||
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
|
||||
@@ -505,7 +606,7 @@ function decoratorRows(decorators: Node[], say: Phrases): PropertyRow[] {
|
||||
|
||||
/** 导入:来源 / 仅类型(只有分这个的语言才有)/ 导入项(每项一行,有别名写成「名称 作为 别名」)。 */
|
||||
function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] {
|
||||
const itemText = (item: ImportItem): Segment[] => {
|
||||
const itemText = (item: ImportItem): Inline[] => {
|
||||
switch (item.kind) {
|
||||
case 'named':
|
||||
return aliased(say.translated(item.name), item.alias, say)
|
||||
@@ -540,7 +641,7 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
|
||||
* 值:字典 / 对象字面量(语言插件给了 entries)写项数,各项展开成下一层子行;
|
||||
* 空字面量和别的值是一行译文(空的译成「空字典」「空对象」)。
|
||||
*/
|
||||
function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Segment[], items?: PropertyItem[] } {
|
||||
function literalValue(value: Node, entries: EntryFacts[] | undefined, say: Phrases): { value: Inline[], items?: PropertyItem[] } {
|
||||
if (!entries?.length)
|
||||
return { value: say.translated(value) }
|
||||
return { value: say.entryCount(value, entries.length), items: entries.map(entry => entryItem(entry, say)) }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { PropertyItem, PropertyRow, Segment } from './types'
|
||||
import type { Inline, PropertyItem, PropertyRow } from './types'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { Fragment } from 'react'
|
||||
import { inlineText } from './segments'
|
||||
import { SegmentText } from './SegmentText'
|
||||
import { TextPreview } from './TextPreview'
|
||||
import { propertyItemId, propertyRowId } from './viewState'
|
||||
@@ -20,15 +21,11 @@ interface Folding {
|
||||
onToggleFold: (id: string) => void
|
||||
}
|
||||
|
||||
function segmentsText(segments: Segment[]): string {
|
||||
return segments.map(segment => segment.text).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
|
||||
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
|
||||
*/
|
||||
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[], foldable: boolean, folding: Folding }): ReactElement {
|
||||
function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Inline[], foldable: boolean, folding: Folding }): ReactElement {
|
||||
if (!foldable) {
|
||||
return (
|
||||
<div className="flex min-w-0 gap-1">
|
||||
@@ -45,7 +42,7 @@ function ItemHead({ id, cell, foldable, folding }: { id: string, cell: Segment[]
|
||||
<button
|
||||
type="button"
|
||||
className="group flex min-w-0 items-start gap-1 rounded-sm text-left"
|
||||
aria-label={`${folded ? '展开' : '折叠'}${segmentsText(cell)}`}
|
||||
aria-label={`${folded ? '展开' : '折叠'}${inlineText(cell)}`}
|
||||
aria-expanded={!folded}
|
||||
onClick={() => folding.onToggleFold(id)}
|
||||
>
|
||||
|
||||
@@ -1,27 +1,48 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { Segment } from './types'
|
||||
import type { GroupStyle, Inline, SegmentGroup } from './types'
|
||||
import { Repeat } from 'lucide-react'
|
||||
import { useContext } from 'react'
|
||||
import { segmentOccurrence } from './occurrences'
|
||||
import { HighlightContext } from './useOccurrences'
|
||||
|
||||
/**
|
||||
* 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上,
|
||||
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。
|
||||
* 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色。框起来的一组词画成小框,框里的词同样处理。
|
||||
*/
|
||||
export function SegmentText({ segments }: { segments: Segment[] }): ReactElement {
|
||||
export function SegmentText({ segments }: { segments: Inline[] }): ReactElement {
|
||||
const highlight = useContext(HighlightContext)
|
||||
return (
|
||||
<>
|
||||
{segments.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`cr-role-${segment.role}`}
|
||||
data-offset={segment.offset}
|
||||
data-occurrence={highlight && segmentOccurrence(segment, highlight.occurrences)}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
{segments.map((piece, index) => 'role' in piece
|
||||
? (
|
||||
<span
|
||||
key={index}
|
||||
className={`cr-role-${piece.role}`}
|
||||
data-offset={piece.offset}
|
||||
data-occurrence={highlight && segmentOccurrence(piece, highlight.occurrences)}
|
||||
>
|
||||
{piece.text}
|
||||
</span>
|
||||
)
|
||||
: <GroupBox key={index} group={piece} />)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 框的样式 → 框头图标;颜色在 codeReader.css 的 .cr-group[data-style] 里。 */
|
||||
const GROUP_ICONS: Record<GroupStyle, LucideIcon> = { iteration: Repeat }
|
||||
|
||||
/** 框起来的一组词:嵌在前后文字里的小框,放不下时整个挪到下一行,比一行还长时在框里换行。 */
|
||||
function GroupBox({ group }: { group: SegmentGroup }): ReactElement {
|
||||
const Icon = GROUP_ICONS[group.style]
|
||||
return (
|
||||
<span className="cr-group" data-style={group.style}>
|
||||
<span className="cr-group-head">
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
{group.label}
|
||||
</span>
|
||||
<SegmentText segments={group.segments} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -278,6 +278,38 @@ body.vscode-high-contrast-light {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*
|
||||
* 框起来的一组词(写在表达式里的遍历):嵌在译文里的小框,框头是图标和 label,边框、底色、框头取框的样式色(遍历同「循环」块)。
|
||||
* 整个框放不下时挪到下一行,比一行还长时占满一行、在框里换行。上下边框算在行高里:框和前后文字顶部对齐、基线对齐,不把这一行撑高;
|
||||
* 框里再套的框行高再减 2px,同样不撑高外面那个框的行
|
||||
*/
|
||||
.cr-group[data-style="iteration"] { --group: var(--cr-kind-loop); }
|
||||
.cr-group {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
padding: 0 5px;
|
||||
line-height: 18px;
|
||||
vertical-align: top;
|
||||
border: 1px solid color-mix(in srgb, var(--group) 45%, transparent);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--group) 14%, transparent);
|
||||
}
|
||||
.cr-group .cr-group {
|
||||
line-height: 16px;
|
||||
}
|
||||
.cr-group-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-right: 6px;
|
||||
vertical-align: top;
|
||||
font-family: var(--vscode-font-family, system-ui, sans-serif), "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--group);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 编辑:透明输入框垫在底下接收输入和选区,着色层盖在上面只负责显示,高度由着色层撑开 */
|
||||
.cr-editor {
|
||||
position: relative;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Click, Highlight } from './occurrences'
|
||||
import type { BlockView, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
|
||||
import type { BlockView, Inline, Occurrence, PreviewText, PropertyItem, Segment, WebviewToExtension } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../../../src/codeReader/blockView'
|
||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||
@@ -205,12 +205,17 @@ describe('occurrences:高亮的状态', () => {
|
||||
})
|
||||
})
|
||||
|
||||
/** 框拆开,框里的词接在前后的词中间:SegmentText 同样一段段画框里的词。 */
|
||||
function inlineSegments(pieces: Inline[]): Segment[] {
|
||||
return pieces.flatMap(piece => 'role' in piece ? [piece] : inlineSegments(piece.segments))
|
||||
}
|
||||
|
||||
function textSegments(text: PreviewText): Segment[] {
|
||||
return 'markdown' in text ? [] : text
|
||||
return 'markdown' in text ? [] : inlineSegments(text)
|
||||
}
|
||||
|
||||
function itemSegments(items: PropertyItem[] | undefined): Segment[] {
|
||||
return (items ?? []).flatMap(item => [...item.cells.flat(), ...itemSegments(item.items)])
|
||||
return (items ?? []).flatMap(item => [...inlineSegments(item.cells.flat()), ...itemSegments(item.items)])
|
||||
}
|
||||
|
||||
/** 预览里画出来的所有段,按页面上从上到下的顺序:每块的属性表或译文,再是它的子块。 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageAdapter, Translator } from '../../../src/codeReader/contract'
|
||||
import type { RevealPlan } from './reveal'
|
||||
import type { BlockKind, BlockView, PropertyItem, PropertyRow, Segment } from './types'
|
||||
import type { BlockKind, BlockView, Inline, PropertyItem, PropertyRow, Segment } from './types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../../../src/codeReader/blockView'
|
||||
import { createPythonAdapter } from '../../../src/codeReader/languages/python'
|
||||
@@ -19,7 +19,7 @@ function word(text: string): Segment {
|
||||
return { text, role: 'keyword' }
|
||||
}
|
||||
|
||||
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Segment[], children: BlockView[] = []): BlockView {
|
||||
function textBlock(id: string, kind: BlockKind, startLine: number, translation: Inline[], children: BlockView[] = []): BlockView {
|
||||
return { id, kind, startLine, endLine: startLine, code: '', codeTokens: [], attributes: { kind }, children, translation }
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ describe('跳到定义:滚到哪块、展开哪些', () => {
|
||||
expect(revealPlan(BLOCKS, 4, 102)).toEqual({ id: '1.0.0', expand: ['1', '1.0'] })
|
||||
})
|
||||
|
||||
it('名字在框里(套了几层都算)也是显示着它', () => {
|
||||
const inner = { style: 'iteration' as const, label: '列表', segments: [word('遍历'), name('row', 30), word('取'), name('y', 40)] }
|
||||
const blocks: BlockView[] = [
|
||||
textBlock('0', 'return', 0, [word('返回'), { style: 'iteration', label: '列表', segments: [word('遍历'), name('grid', 20), word('取'), inner] }]),
|
||||
]
|
||||
expect(revealPlan(blocks, 5, 40)).toEqual({ id: '0', expand: [] })
|
||||
expect(revealPlan(blocks, 5, 20)).toEqual({ id: '0', expand: [] })
|
||||
})
|
||||
|
||||
it('注释块、函数「说明」的 Markdown 里的名字不带下标,不算显示着定义', () => {
|
||||
const blocks: BlockView[] = [
|
||||
{ id: '0', kind: 'comment', startLine: 0, endLine: 0, code: '', codeTokens: [], attributes: { kind: 'comment' }, children: [], translation: { markdown: '见 `get_db`' } },
|
||||
@@ -235,6 +244,11 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => {
|
||||
expect(await at('i in range')).toEqual({ plan: { id: '1.1', expand: ['1'] }, flashed: 'for i in range(3):' })
|
||||
})
|
||||
|
||||
it('python:推导式的变量只在框里显示:闪框所在的那块', async () => {
|
||||
const source = ['def ids(users):', ' return [u.id for u in users]'].join('\n')
|
||||
expect(await jump(py, 'python', pythonZhTranslator, source, 'u in users')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'return [u.id for u in users]' })
|
||||
})
|
||||
|
||||
it('python:翻译插件没标 defines 时闪整块,名字在子行里照样展开那一行', async () => {
|
||||
const at = (needle: string): ReturnType<typeof jump> => jump(py, 'python', withoutDefines(pythonZhTranslator), PY, needle)
|
||||
expect(await at('phone: str')).toEqual({ plan: { id: '1', expand: ['1/p3'] }, flashed: 'def mask_phone(phone: str) -> str:' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BlockView, PreviewText, PropertyItem, SourceRange } from './types'
|
||||
import { showsOffset } from './segments'
|
||||
import { propertyItemId, propertyRowId } from './viewState'
|
||||
|
||||
/** 跳到定义时要滚到的块,就是这处定义的属性行 / 子行(只闪它),以及要展开的块、属性行和子行:它们折叠着,目标就不在页面上。 */
|
||||
@@ -30,9 +31,9 @@ function covers(range: SourceRange | undefined, offset: number): boolean {
|
||||
return range !== undefined && range.startIndex <= offset && offset < range.endIndex
|
||||
}
|
||||
|
||||
/** 文字里显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
|
||||
/** 文字里(连框里)显示着 offset 处的名字;Markdown 说明里的名字不带下标。 */
|
||||
function shows(text: PreviewText, offset: number): boolean {
|
||||
return !('markdown' in text) && text.some(segment => segment.offset === offset)
|
||||
return !('markdown' in text) && showsOffset(text, offset)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Segment } from './types'
|
||||
import type { Inline, Segment } from './types'
|
||||
|
||||
/** 取 segments 拼起来的文字里 [from, to) 这一段,每个字保留原来的词性。 */
|
||||
export function sliceSegments(segments: Segment[], from: number, to: number): Segment[] {
|
||||
@@ -37,3 +37,13 @@ export function recolor(tokens: Segment[], code: string, text: string): Segment[
|
||||
...sliceSegments(tokens, code.length - tail, code.length),
|
||||
]
|
||||
}
|
||||
|
||||
/** 着色的词拼成的文字(给折叠按钮的说明用);框写成框头加框里的文字。 */
|
||||
export function inlineText(pieces: Inline[]): string {
|
||||
return pieces.map(piece => 'role' in piece ? piece.text : `${piece.label} ${inlineText(piece.segments)}`).join('')
|
||||
}
|
||||
|
||||
/** 文字里有没有 offset 处的那个名字,框里的也算。 */
|
||||
export function showsOffset(pieces: Inline[], offset: number): boolean {
|
||||
return pieces.some(piece => 'role' in piece ? piece.offset === offset : showsOffset(piece.segments, offset))
|
||||
}
|
||||
@@ -17,11 +17,24 @@ export type TokenRole
|
||||
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */
|
||||
export type GroupStyle = 'iteration'
|
||||
|
||||
/** 框起来的一组词:画成嵌在前后文字里的一个小框,框头是图标和 label;框里的词照样带源码下标,还能再套框 */
|
||||
export interface SegmentGroup {
|
||||
style: GroupStyle
|
||||
label: string
|
||||
segments: Inline[]
|
||||
}
|
||||
|
||||
/** 译文里的一段:着色的词,或框起来的一组词 */
|
||||
export type Inline = Segment | SegmentGroup
|
||||
|
||||
/** 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,按 Markdown 渲染成注释色的段落、列表、代码块 */
|
||||
export interface Markdown { markdown: string }
|
||||
|
||||
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明) */
|
||||
export type PreviewText = Segment[] | Markdown
|
||||
export type PreviewText = Inline[] | Markdown
|
||||
|
||||
/** 源码里一段的下标,end 为开区间 */
|
||||
export interface SourceRange { startIndex: number, endIndex: number }
|
||||
@@ -34,7 +47,7 @@ export interface Defines { defines?: SourceRange }
|
||||
|
||||
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
|
||||
export interface PropertyItem extends Defines {
|
||||
cells: Segment[][]
|
||||
cells: Inline[][]
|
||||
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
|
||||
items?: PropertyItem[]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user