🐛 fix(vscode): 代码阅读把 Python 赋值后面的字符串当变量说明,其余单独的字符串显示成注释

This commit is contained in:
cruldra committed 2026-09-26 18:57:07 +08:00
1 parent a51f90e34d
commit 1f86201373
14 files changed
+358 -59

No files matched your search

+47
View File
@@ -2,6 +2,7 @@ import type { BlockView } from './blockView'
import type { LanguageAdapter, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { readBlockViews, toBlockViews } from './blockView'
import { replaceLines } from './editText'
import { createPythonAdapter } from './languages/python'
import { createTypeScriptAdapter } from './languages/typescript'
import { previewText, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from './testSupport'
@@ -33,6 +34,7 @@ const SAMPLES: [LanguageAdapter, string, Translator, string][] = [
[py, 'python', pythonZhTranslator, 'sample.py'],
[py, 'python', pythonZhTranslator, 'routes.py'],
[py, 'python', pythonZhTranslator, 'classes.py'],
[py, 'python', pythonZhTranslator, 'attributeDocs.py'],
]
const PROPERTY_KINDS = new Set(['function', 'class', 'import', 'const', 'variable'])
@@ -122,6 +124,51 @@ describe('blockView', () => {
expect(notFound.children[0]).toMatchObject({ kind: 'variable', startLine: 20, endLine: 20 })
})
it('属性 docstring 算在常量、变量、字段块里:常量、变量的「说明」紧跟名称,字段的说明排在译文下面;编辑范围连着它', async () => {
const source = [
'CASE_DEPLOYMENT_UNIQUE_CONSTRAINT = "uq_case_library_deployment"',
'"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""',
'',
'',
'class CaseStatus:',
' ANALYZING = "analyzing"',
'',
' """正在分析源 deployment."""',
' title: str = ""',
' """案例标题,',
' 用户可编辑."""',
].join('\n')
const list = await views(py, 'python', pythonZhTranslator, source)
const snapshot = flatten(list).map(view => ({
id: view.id,
kind: view.kind,
lines: [view.startLine, view.endLine],
...('properties' in view ? { rows: propertyLines(view.properties) } : { translation: previewText(view.translation), doc: view.doc }),
}))
expect(snapshot).toEqual([
{
id: '0',
kind: 'const',
lines: [0, 1],
rows: [
'名称: CASE_DEPLOYMENT_UNIQUE_CONSTRAINT',
'说明: 一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。',
'访问级别: 公共',
'值: "uq_case_library_deployment"',
],
},
{ id: '1', kind: 'class', lines: [4, 4], rows: ['名称: CaseStatus', '访问级别: 公共'] },
{ id: '1.0', kind: 'variable', lines: [5, 7], rows: ['名称: ANALYZING', '说明: 正在分析源 deployment.', '访问级别: 公共', '初始值: "analyzing"'] },
{ id: '1.1', kind: 'field', lines: [8, 10], translation: 'title: 字符串 = ""', doc: { markdown: '案例标题,\n用户可编辑.' } },
])
expect(byId(list, '0').code).toBe(source.split('\n').slice(0, 2).join('\n'))
expect(byId(list, '1.0').code).toBe('ANALYZING = "analyzing"\n\n"""正在分析源 deployment."""')
expect(byId(list, '1.1').code).toBe('title: str = ""\n"""案例标题,\n用户可编辑."""')
for (const view of flatten(list))
expect(replaceLines(source, view.startLine, view.endLine, view.code), view.id).toBe(source)
expect(replaceLines(source, 8, 10, 'title: str = "x"\n"""新说明"""').split('\n').slice(8)).toEqual([' title: str = "x"', ' """新说明"""'])
})
it('注释块的译文、函数的「说明」是 Markdown 原文,webview 按它渲染', async () => {
const env = await views(ts, 'typescript', typescriptZhTranslator, readFixture('envLock.ts.txt'))
expect(byId(env, '0')).toMatchObject({ kind: 'comment', translation: { markdown: expect.stringMatching(/^\.env file lock \/ unlock helpers\.\n\nRecursively/) } })
+9 -5
View File
@@ -1,4 +1,4 @@
import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract'
import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { commonIndent, splitLines } from './editText'
@@ -27,8 +27,9 @@ interface BlockViewBase {
/**
* 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
* 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
@@ -144,6 +145,11 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu
codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole),
}
}
/** 一行译文,带说明的块连着说明。 */
const textView = (block: TextBlock): { translation: PreviewText, doc?: Markdown } => {
const doc = block.doc && translator?.doc(block.doc, document)
return { translation: translator ? translator.translate(block, document) : [], ...(doc && { doc }) }
}
const build = (block: CodeBlock, id: string): BlockView => {
const root = block.node.tree.rootNode
const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined
@@ -169,9 +175,7 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu
...(name && { defines: { startIndex: name.startIndex, endIndex: name.endIndex } }),
attributes: blockAttributes(block),
children: block.children.map((child, i) => build(child, `${id}.${i}`)),
...('facts' in block
? { properties: translator ? translator.describe(block, document) : [] }
: { translation: translator ? translator.translate(block, document) : [] }),
...('facts' in block ? { properties: translator ? translator.describe(block, document) : [] } : textView(block)),
}
}
return blocks.map((block, i) => build(block, String(i)))
+15 -3
View File
@@ -131,6 +131,11 @@ export interface DeclaratorFacts {
export interface VariableFacts {
/** 只有模块顶层(Python 还有类主体)的声明有。 */
access?: Access
/**
* 变量自己的说明,同 FunctionFacts.doc:Python 是模块、类主体里紧跟在这条赋值后面的字符串(属性 docstring,字符串节点;
* 中间可以隔空行和赋值行尾的注释)。它算在块的头部里,不再单独成一块。
*/
doc?: Node
/** 一条语句声明几个变量(`let a = 1, b = 2`)就有几项。 */
declarators: DeclaratorFacts[]
}
@@ -155,7 +160,7 @@ export interface BlockShape {
node: Node
/**
* 块自己显示 / 编辑的那一段:复合语句只到主体开始之前,其余是整条语句。
* 函数、类的说明(facts.doc)也算在里面:JSDoc 往上延到注释开头,docstring 往下延到字符串结尾。
* 块自己的说明(facts.doc、TextBlock.doc)也算在里面:JSDoc 往上延到注释开头,docstring、属性 docstring 往下延到字符串结尾。
*/
header: SourceSpan
children: CodeBlock[]
@@ -174,12 +179,19 @@ export interface VariableBlock extends BlockShape { kind: 'const' | 'variable',
export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock
/** 带说明(facts.doc)的块。 */
export type DocumentedBlock = FunctionBlock | ClassBlock
export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock
export type TextKind = Exclude<BlockKind, FactBlock['kind']>
/** 预览成一行译文的块。 */
export interface TextBlock extends BlockShape { kind: TextKind }
export interface TextBlock extends BlockShape {
kind: TextKind
/**
* 块自己的说明,同 VariableFacts.doc:Python 字段(类主体里带注解的赋值)后面紧跟的属性 docstring(字符串节点)。
* 算在块的头部里;预览在一行译文下面按 Markdown 显示(Translator.doc)。
*/
doc?: Node
}
export type CodeBlock = TextBlock | FactBlock
@@ -0,0 +1,33 @@
"""属性 docstring 的样例:前两条常量节选自 luffy-agent-platform 的 case_library/models.py。"""
CASE_DEPLOYMENT_UNIQUE_CONSTRAINT = "uq_case_library_deployment"
"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""
_TIMEOUT = 30 # 秒
"""隔着空行、赋值行尾有注释也算。"""
class CaseStatus:
"""案例状态."""
ANALYZING = "analyzing"
"""正在分析源 deployment."""
title: str = ""
"""案例标题,
用户可编辑.""" # 行尾
# 隔着注释就不算
"""不是 title 的说明。"""
def run(items):
total = 0
"""先把总数清零。
- 再逐个加
"""
f"{total} 不是注释"
b"bytes"
return total
+77 -3
View File
@@ -1,7 +1,7 @@
import type { IterationFacts, Node } from '../contract'
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { factsByLine, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { factsByLine, nodesToText, outline, readFixture, runtime, walk, WASM_DIR, withBlocks } from '../testSupport'
import { createPythonAdapter } from './python'
const adapter = createPythonAdapter(WASM_DIR)
@@ -79,15 +79,89 @@ describe('python 代码块树', () => {
])
})
it('docstring 只认主体开头;不是开头的字符串表达式是语句', async () => {
it('docstring 只认主体开头;不是开头、也没挂到赋值上的字符串是注释块,f-string、字节串照旧是语句', async () => {
const source = [
'def f():',
' x = 1',
' """not a docstring"""',
' "a" \'b\'',
' f"{x}"',
' b"x"',
' "a" f"b"',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'function', kids: [{ at: 2, kind: 'variable' }, { at: 3, kind: 'statement' }] },
{ at: 1, kind: 'function', kids: [
{ at: 2, kind: 'variable' },
{ at: 3, kind: 'comment' },
{ at: 4, kind: 'comment' },
{ at: 5, kind: 'statement' },
{ at: 6, kind: 'statement' },
{ at: 7, kind: 'statement' },
] },
])
expect(await tree('def f(): f"not a docstring"')).toEqual([{ at: 1, kind: 'function', kids: [{ at: 1, kind: 'statement' }] }])
})
})
describe('python 属性 docstring', () => {
/** 字段(一行译文的块)的说明,按 header 起始行(1 起)索引。 */
function fieldDocs(source: string): Promise<Record<number, unknown>> {
return withBlocks(adapter, 'python', source, (blocks) => {
const byLine: Record<number, unknown> = {}
walk(blocks, (block) => {
if (block.kind === 'field' && block.doc)
byLine[block.header.startRow + 1] = nodesToText(block.doc)
})
return byLine
})
}
it('attributeDocs.py:模块、类主体里紧跟在赋值后面的字符串并进那块,头部延到字符串结尾;隔着注释、不跟在赋值后面的是注释块', async () => {
expect(await tree(readFixture('attributeDocs.py'))).toEqual([
{ at: 1, kind: 'comment' },
{ at: [3, 4], kind: 'const' },
{ at: [6, 8], kind: 'variable' },
{ at: [11, 12], kind: 'class', kids: [
{ at: [14, 15], kind: 'variable' },
{ at: [17, 19], kind: 'field' },
{ at: 21, kind: 'comment' },
{ at: 22, kind: 'comment' },
] },
{ at: 25, kind: 'function', kids: [
{ at: 26, kind: 'variable' },
{ at: [27, 30], kind: 'comment' },
{ at: 31, kind: 'statement' },
{ at: 32, kind: 'statement' },
{ at: 33, kind: 'return' },
] },
])
})
it('进常量、变量的 facts.doc 和字段的 doc,是那个字符串节点;函数体里的赋值没有', async () => {
const source = readFixture('attributeDocs.py')
const found = await facts(source)
expect(found[3]).toMatchObject({ doc: '"""一 deployment 一案例的唯一约束名; 入库侧按它认"重复入库", 故与建表同源。"""' })
expect(found[6]).toMatchObject({ doc: '"""隔着空行、赋值行尾有注释也算。"""' })
expect(found[14]).toMatchObject({ doc: '"""正在分析源 deployment."""' })
expect(found[26]).not.toHaveProperty('doc')
expect(await fieldDocs(source)).toEqual({ 17: '"""案例标题,\n 用户可编辑."""' })
})
it('只挂到紧跟着的下一条语句:两个字符串只有第一个是说明;没有值的注解、拼接的字符串也算;条件分支里的赋值不算', async () => {
const source = [
'class K:',
' a: int',
' "A" "B"',
' "又一个"',
'if DEBUG:',
' X = 1',
' """不算"""',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'class', kids: [{ at: [2, 3], kind: 'field' }, { at: 4, kind: 'comment' }] },
{ at: 5, kind: 'if', kids: [{ at: 6, kind: 'variable' }, { at: 7, kind: 'comment' }] },
])
expect(await fieldDocs(source)).toEqual({ 2: '"A" "B"' })
})
})
+72 -17
View File
@@ -9,11 +9,41 @@ type Scope = 'module' | 'class' | 'other'
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
const PARAMETER_SEPARATORS = new Set(['keyword_separator', 'positional_separator'])
const PATTERNS = new Set(['pattern_list', 'tuple_pattern', 'list_pattern'])
/** 字符串开头(string_start):普通字符串的前缀只有 r / u 或没有;f、t、b 开头的不是。 */
const STRING_PREFIX = /^[ru]?["']/i
// 只有 # 行注释;函数、类的说明是主体开头的 docstring,不是写在前面的注释
const COMMENTS: CommentRules = { isLine: () => true, isDoc: () => false }
function statements(items: Node[], scope: Scope, afterRow: number, docstring: boolean): CodeBlock[] {
return listBlocks(items, afterRow, (item, first) => statementBlocks(item, scope, docstring && first), COMMENTS)
function statements(items: Node[], scope: Scope, afterRow: number): CodeBlock[] {
const docs = attributeDocs(items, scope)
const attached = new Set([...docs.values()].map(statement => statement.startIndex))
return listBlocks(items, afterRow, (item) => {
if (attached.has(item.startIndex))
return []
const docStatement = docs.get(item.startIndex)
return statementBlocks(item, scope, docStatement && docstringIn(docStatement))
}, COMMENTS)
}
/**
* 属性 docstring(PEP 257):模块、类主体里紧跟在一条赋值后面的字符串是这个变量的说明。返回赋值语句的起始下标 → 那条字符串语句。
* 同 JSDoc 挂到下一条语句:中间可以隔空行和赋值的行尾注释,隔着别的注释、语句就不算。
*/
function attributeDocs(items: Node[], scope: Scope): Map<number, Node> {
const docs = new Map<number, Node>()
if (scope === 'other')
return docs
items.forEach((item, i) => {
if (!assignmentIn(item))
return
let next = i + 1
while (items[next]?.type === 'comment' && items[next].startPosition.row === item.endPosition.row)
next++
const statement = items[next]
if (statement && docstringIn(statement))
docs.set(item.startIndex, statement)
})
return docs
}
/** 冒号后面的主体:body / consequence 字段,except / finally 子句里是不带字段名的 block。 */
@@ -25,17 +55,18 @@ function bodyOf(node: Node): Node | null {
return blocks.at(-1) ?? null
}
function bodyShape(outer: Node, body: Node | null, scope: Scope, docstring = false): BlockShape {
function bodyShape(outer: Node, body: Node | null, scope: Scope): BlockShape {
if (!body)
return leafShape(outer)
return compoundShape(outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow, docstring))
return compoundShape(outer, body.startIndex, named(body), (list, afterRow) => statements(list, scope, afterRow))
}
function bodyBlock(kind: TextKind, outer: Node, body: Node | null, scope: Scope): CodeBlock {
return { kind, ...bodyShape(outer, body, scope) }
}
function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBlock[] {
/** doc 是紧跟在这条赋值后面的属性 docstring(见 attributeDocs)。 */
function statementBlocks(node: Node, scope: Scope, doc: Node | undefined): CodeBlock[] {
switch (node.type) {
case 'import_statement':
case 'import_from_statement':
@@ -64,7 +95,7 @@ function statementBlocks(node: Node, scope: Scope, docstring: boolean): CodeBloc
case 'return_statement':
return [leafBlock('return', node)]
case 'expression_statement':
return [expressionBlock(node, scope, docstring)]
return [expressionBlock(node, scope, doc)]
default:
return [leafBlock('statement', node)]
}
@@ -87,18 +118,30 @@ function docstringOf(body: Node | null): Node | undefined {
return first && docstringIn(first)
}
/** 语句是单独一个字符串表达式(docstring 的写法)时返回那个字符串节点。 */
/**
* 语句是单独一个字符串字面量(docstring 的写法,隐式拼接的几段也算)时返回那个字符串节点。
* f-string、t-string、字节串不算:Python 不把它们当 docstring。
*/
function docstringIn(statement: Node): Node | undefined {
if (statement.type !== 'expression_statement')
return undefined
const [expression, ...rest] = named(statement)
return rest.length === 0 && (expression?.type === 'string' || expression?.type === 'concatenated_string') ? expression : undefined
if (!expression || rest.length > 0)
return undefined
const parts = expression.type === 'concatenated_string' ? named(expression) : [expression]
return parts.every(isTextString) ? expression : undefined
}
/** 普通字符串字面量:前缀只有 r / u(不分大小写)或没有。 */
function isTextString(node: Node): boolean {
const start = node.type === 'string' ? node.firstChild : null
return start?.type === 'string_start' && STRING_PREFIX.test(start.text)
}
/** 函数、类的 docstring 是它自己的说明:算进头部(连同它前面的注释),子块从它后面开始。 */
function documentedShape(outer: Node, body: Node, doc: Node, scope: Scope): BlockShape {
const rest = named(body).filter(item => item.startIndex >= doc.endIndex)
return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, scope, afterRow, false))
return compoundShape(outer, doc.endIndex, rest, (list, afterRow) => statements(list, scope, afterRow))
}
/** 单下划线开头的名字是私有(`_x`、`__x`),前后双下划线的特殊方法(`__init__`)是公共。 */
@@ -219,28 +262,40 @@ function isConstantTarget(target: Node | null): boolean {
return false
}
/** 赋值 → 字段 / 常量 / 变量;主体开头的字符串是 docstring;其余表达式是语句。 */
function expressionBlock(node: Node, scope: Scope, docstring: boolean): CodeBlock {
if (docstring && docstringIn(node))
/**
* 赋值 → 字段 / 常量 / 变量,doc 是它的属性 docstring:进字段的 doc、常量和变量的 facts.doc,头部往下延到字符串结尾;
* 单独一个字符串(模块开头的 docstring、写在语句中间当注释用的字符串)是注释;其余表达式是语句。
*/
function expressionBlock(node: Node, scope: Scope, doc: Node | undefined): CodeBlock {
if (docstringIn(node))
return leafBlock('comment', node)
const expression = named(node)[0]
const left = expression?.type === 'assignment' ? expression.childForFieldName('left') : null
const expression = assignmentIn(node)
const left = expression?.childForFieldName('left')
if (!expression || !left)
return leafBlock('statement', node)
const leaf = leafShape(node)
const shape = doc ? { ...leaf, header: { ...leaf.header, endIndex: doc.endIndex, endRow: doc.endPosition.row } } : leaf
const type = expression.childForFieldName('type')
if (scope === 'class' && type)
return leafBlock('field', node)
return { kind: 'field', ...shape, doc }
const value = expression.childForFieldName('right')
return {
kind: scope === 'module' && isConstantTarget(left) ? 'const' : 'variable',
...leafShape(node),
...shape,
facts: {
access: scopeAccess(left, scope),
doc,
declarators: [{ name: left, type: type ?? undefined, value: value ?? undefined, entries: dictionaryEntries(value) }],
},
}
}
/** 语句是一条赋值(带不带注解、有没有值)时返回那个 assignment 节点。 */
function assignmentIn(statement: Node): Node | undefined {
const expression = statement.type === 'expression_statement' ? named(statement)[0] : undefined
return expression?.type === 'assignment' ? expression : undefined
}
/** 字典字面量的各项:`键: 值`(值又是字典时连它的各项)、`**other`;值不是字典字面量时没有。 */
function dictionaryEntries(value: Node | null): EntryFacts[] | undefined {
if (value?.type !== 'dictionary')
@@ -428,7 +483,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter {
id: 'python',
languageIds: ['python'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-python.wasm'),
toBlocks: root => statements(named(root), 'module', -1, true),
toBlocks: root => statements(named(root), 'module', -1),
tokenRole: pythonTokenRole,
iteration: pythonIteration,
}
+3 -5
View File
@@ -104,19 +104,18 @@ function documented<B extends DocumentedBlock>(block: B, doc: Node): B {
* 文档注释和下一条语句之间只有空白、下一条语句是函数或类块时,它是这个函数、类的说明,不单独成块;
* 中间隔着的装饰器(不出块的语句)不算隔开。
* afterRow 是这串语句之前的最后一行(头部结尾行),模块顶层传 -1;
* toBlocks 的 first 表示它是这串里第一条非注释语句(Python 认 docstring 用)。
* toBlocks 可以返回空数组:这条语句不出块(装饰器、并进上一块的属性 docstring),和它同一行的注释照样算行尾注释。
*/
export function listBlocks(
items: Node[],
afterRow: number,
toBlocks: (item: Node, first: boolean) => CodeBlock[],
toBlocks: (item: Node) => CodeBlock[],
comments: CommentRules,
): CodeBlock[] {
const out: CodeBlock[] = []
let lastRow = afterRow
let lineRun: CodeBlock | undefined
let doc: Node | undefined
let first = true
const flushDoc = (): void => {
if (doc)
out.push(leafBlock('comment', doc))
@@ -143,7 +142,7 @@ export function listBlocks(
}
}
else {
const blocks = toBlocks(item, first)
const blocks = toBlocks(item)
const [head] = blocks
if ((head?.kind === 'function' || head?.kind === 'class') && doc) {
blocks[0] = documented(head, doc)
@@ -153,7 +152,6 @@ export function listBlocks(
flushDoc()
}
out.push(...blocks)
first = false
lineRun = undefined
}
lastRow = item.endPosition.row
@@ -224,6 +224,23 @@ describe('typeScript 代码块树', () => {
{ at: 6, kind: 'statement' },
])
})
it('单独一个字符串的语句(\'use strict\' 这类指令,赋值后面的字符串)照旧是语句,不挂到赋值上', async () => {
const source = [
'\'use strict\'',
'const x = 1',
'\'not a doc\'',
'function f() {',
' \'use client\'',
'}',
].join('\n')
expect(await tree(source, 'javascript')).toEqual([
{ at: 1, kind: 'statement' },
{ at: 2, kind: 'const' },
{ at: 3, kind: 'statement' },
{ at: 4, kind: 'function', kids: [{ at: 5, kind: 'statement' }] },
])
})
})
describe('typeScript 函数的 JSDoc', () => {
@@ -209,6 +209,35 @@ describe('sqlAlchemy 库规则', () => {
expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]])
})
it('字段的属性 docstring:没写 comment= 时当说明,写了以 comment= 为准;它并进卡片,编辑范围到最后一个字段的说明为止', async () => {
const source = [
'from sqlalchemy.orm import Mapped, mapped_column',
'',
'',
'class Case(Base):',
' __tablename__ = "case_library"',
'',
' title: Mapped[str] = mapped_column(String(120))',
' """案例标题, 用户可编辑"""',
' summary: Mapped[str] = mapped_column(Text, comment="案例描述")',
' """不显示"""',
' status = mapped_column(String(16))',
' """案例状态"""',
'',
' def label(self) -> str:',
' return self.title',
].join('\n')
const [, table] = await views(source)
expect(propertyLines(properties(table)).slice(-4)).toEqual([
'字段: 3 个',
' title | 字符串(120) | | 案例标题, 用户可编辑',
' summary | 长文本 | | 案例描述',
' status | 字符串(16) | 可空 | 案例状态',
])
expect([table.startLine, table.endLine]).toEqual([3, 11])
expect(table.children.map(child => [child.kind, child.startLine])).toEqual([['function', 13]])
})
it('没导入 SQLAlchemy 的文件不认', async () => {
const list = await views('class User(Base):\n __tablename__ = "users"\n')
expect(list.map(view => view.kind)).toEqual(['class'])
+12 -4
View File
@@ -123,12 +123,13 @@ interface Call {
keywords: Map<string, Node>
}
/** 类主体里一条给名字赋值(或只写注解)的语句。 */
/** 类主体里一条给名字赋值(或只写注解)的语句;doc 是它的属性 docstring(字段的 doc、变量的 facts.doc)。 */
interface Member {
block: CodeBlock
name: Node
annotation?: Node
value?: Node
doc?: Node
}
/** 一个字段:只写 `Mapped[...]` 注解的没有 call。 */
@@ -225,7 +226,13 @@ function memberOf(block: CodeBlock): Member | undefined {
const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
if (!assignment || name?.type !== 'identifier')
return undefined
return { block, name, annotation: assignment.childForFieldName('type') ?? undefined, value: assignment.childForFieldName('right') ?? undefined }
return {
block,
name,
annotation: assignment.childForFieldName('type') ?? undefined,
value: assignment.childForFieldName('right') ?? undefined,
doc: 'facts' in block ? block.facts.doc : block.doc,
}
}
/** 注解里 `type` 节点只包着一个类型时取里面那个。 */
@@ -424,7 +431,7 @@ function isNullable(call: Call | undefined, optional: boolean | undefined): bool
return call?.name === 'Column' || optional === undefined ? true : optional
}
/** 字段表的一行:字段 | 类型 | 约束 | 说明。讲的定义是字段名。 */
/** 字段表的一行:字段 | 类型 | 约束 | 说明(`comment=`,没写时用字段的属性 docstring 原文)。讲的定义是字段名。 */
function columnItem({ member, call }: Column, env: Env): PropertyItem {
const { words, context } = env
const mapped = mappedType(member.annotation)
@@ -452,12 +459,13 @@ function columnItem({ member, call }: Column, env: Env): PropertyItem {
for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey'))
constraints.push(foreignKey(foreign, env))
const comment = keywords.get('comment')
const doc = member.doc && context.doc(member.doc)
return {
cells: [
[name(member.name, 'variable')],
typeArg ? sqlType(typeArg, env) : annotated ? context.translate(annotated.type) : [hint(words.unannotated)],
joined(constraints, hint(' · ')),
comment ? textOr(comment, 'comment', env) : [],
comment ? textOr(comment, 'comment', env) : doc ? [{ text: doc.markdown, role: 'comment' }] : [],
],
defines: range(member.name),
}
@@ -616,7 +616,7 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
]
}
/** 函数、类的说明:有文字才有这一行。 */
/** 函数、类、常量、变量的说明:有文字才有这一行。 */
function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] {
const markdown = doc && say.doc(doc)
return markdown ? [{ label: '说明', value: markdown }] : []
@@ -649,11 +649,15 @@ function importRows(facts: ImportFacts, say: Phrases): PropertyRow[] {
]
}
/** 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue)。 */
/**
* 常量 / 变量:每个声明的变量依次是名称 / 访问级别 / 类型(有标注才有)/ 值(变量叫初始值,字典 / 对象字面量见 literalValue);
* 说明(有才显示)是整条语句的,排在第一个名称后面。
*/
function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
const valueLabel = block.kind === 'const' ? '值' : '初始值'
return block.facts.declarators.flatMap(declarator => [
return block.facts.declarators.flatMap((declarator, i) => [
{ label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) },
...(i === 0 ? docRows(block.facts.doc, say) : []),
...accessRows(block.facts.access),
...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []),
{ label: valueLabel, ...(declarator.value ? literalValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },
@@ -67,11 +67,16 @@ export function BlockLevel({ blocks, owner, tree }: { blocks: BlockView[], owner
)
}
/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文。 */
/** 预览状态的内容:函数、类、导入、常量、变量块和库规则认领的块是属性表,其余是一行译文,带说明的译文下面接着说明。 */
function BlockPreview({ block, tree }: { block: BlockView, tree: BlockTreeState }): ReactElement {
if ('properties' in block)
return <PropertyGrid blockId={block.id} rows={block.properties} collapsed={tree.collapsed} onToggleFold={tree.onToggleFold} />
return <TextPreview text={block.translation} />
return (
<>
<TextPreview text={block.translation} />
{block.doc && <TextPreview text={block.doc} className="mt-0.5" />}
</>
)
}
/** 一块:折叠箭头、种类标签、预览内容或源码、右边的「编辑 / 预览」切换。 */
+5 -2
View File
@@ -90,8 +90,11 @@ interface BlockViewBase {
children: BlockView[]
}
/** 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文 */
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
/**
* 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
* 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
export interface TranslatorOption { id: string, label: string }