✨ feat(vscode): 代码阅读加枚举卡片,Python enum 库规则和 TypeScript enum 画成同一张表
This commit is contained in:
1 parent
2251e67ef8
commit
de355661f0
24 files changed
+858
-83
No files matched your search
@@ -26,11 +26,12 @@ function factAttributes(block: CodeBlock): Partial<Record<CommonAttribute, strin
|
||||
return { access: block.facts.access, async: flag(block.facts.async) }
|
||||
case 'class':
|
||||
return { access: block.facts.access, abstract: flag(block.facts.abstract) }
|
||||
case 'import':
|
||||
return { typeOnly: flag(block.facts.typeOnly) }
|
||||
case 'enum':
|
||||
case 'const':
|
||||
case 'variable':
|
||||
return { access: block.facts.access }
|
||||
case 'import':
|
||||
return { typeOnly: flag(block.facts.typeOnly) }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { activeRules, claimBlock, claimedShape, importsIn } from './libraryRules
|
||||
interface BlockViewBase {
|
||||
/** 在树里的位置,例如 '6.1.0'。 */
|
||||
id: string
|
||||
/** BlockKind,或库规则认领的块的种类 id(RuleBlock.kind,带点,例如 sqlalchemy.table)。 */
|
||||
/** BlockKind,或库规则卡片的种类 id(RuleCard.kind,带点,例如 sqlalchemy.table)。 */
|
||||
kind: string
|
||||
/**
|
||||
* 编辑范围的起止行,0 起:一般是 header;库规则认领的块是原块整条语句(见 claimedShape),盖住它留下的子块的行。
|
||||
@@ -23,13 +23,13 @@ interface BlockViewBase {
|
||||
defines?: SourceRange
|
||||
/** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */
|
||||
attributes: BlockAttributes
|
||||
/** 库规则认领的块才有:种类标签的文字和颜色(RuleBlock.badge)。预览同类块一样默认收起它的子块。 */
|
||||
/** 库规则的卡片才有:种类标签的文字和颜色(RuleCard.badge)。预览同类块一样默认收起它的子块。 */
|
||||
badge?: KindBadge
|
||||
children: BlockView[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||
* 发给 webview 的一块(可序列化):函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||
* 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。
|
||||
*/
|
||||
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
|
||||
@@ -103,6 +103,7 @@ function ownName(block: CodeBlock): Node | undefined {
|
||||
switch (block.kind) {
|
||||
case 'function':
|
||||
case 'class':
|
||||
case 'enum':
|
||||
return block.facts.name
|
||||
case 'const':
|
||||
case 'variable':
|
||||
@@ -122,7 +123,8 @@ const NO_RULES: RuleRun = { rules: [], report: () => {} }
|
||||
|
||||
/**
|
||||
* 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。
|
||||
* 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock),并进去的子块不再单独成块。
|
||||
* 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块),
|
||||
* 并进去的子块不再单独成块。
|
||||
*/
|
||||
export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] {
|
||||
const lines = lineRanges(source)
|
||||
@@ -153,34 +155,40 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu
|
||||
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
|
||||
if (claim) {
|
||||
const { kind, badge, rows, defines, attributes } = claim.block
|
||||
const { span, kept } = claimedShape(block, claim.block.absorbs)
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
...sourceRows(root, span.startRow, span.endRow),
|
||||
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
|
||||
attributes: { ...blockAttributes(block), ...attributes, kind },
|
||||
badge,
|
||||
children: kept.map((child, i) => build(child, `${id}.${i}`)),
|
||||
properties: rows,
|
||||
}
|
||||
}
|
||||
/** 块照它自己的样子(语言插件报的,或库规则认成的属性表块):头部是编辑范围,子块照常交给各规则看。 */
|
||||
function plain(block: CodeBlock, id: string): BlockView {
|
||||
const name = ownName(block)
|
||||
return {
|
||||
id,
|
||||
kind: block.kind,
|
||||
...sourceRows(root, block.header.startRow, block.header.endRow),
|
||||
...sourceRows(block.node.tree.rootNode, block.header.startRow, block.header.endRow),
|
||||
...(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) : [] } : textView(block)),
|
||||
}
|
||||
}
|
||||
function build(block: CodeBlock, id: string): BlockView {
|
||||
const claim = active.length > 0 ? claimBlock(block, active, context, rules.report) : undefined
|
||||
if (!claim)
|
||||
return plain(block, id)
|
||||
const { span, kept } = claimedShape(block, claim.block.absorbs)
|
||||
if ('as' in claim.block) {
|
||||
const { attributes } = block
|
||||
return plain({ ...claim.block.as, node: block.node, header: span, children: kept, ...(attributes && { attributes }) }, id)
|
||||
}
|
||||
const { kind, badge, rows, defines, attributes } = claim.block
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
...sourceRows(block.node.tree.rootNode, span.startRow, span.endRow),
|
||||
...(defines && { defines: { startIndex: defines.startIndex, endIndex: defines.endIndex } }),
|
||||
attributes: { ...blockAttributes(block), ...attributes, kind },
|
||||
badge,
|
||||
children: kept.map((child, i) => build(child, `${id}.${i}`)),
|
||||
properties: rows,
|
||||
}
|
||||
}
|
||||
return blocks.map((block, i) => build(block, String(i)))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Disposable } from 'vscode'
|
||||
export type { Node }
|
||||
|
||||
export type BlockKind
|
||||
= | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable'
|
||||
= | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable'
|
||||
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
|
||||
|
||||
/** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */
|
||||
@@ -70,6 +70,37 @@ export interface ClassFacts {
|
||||
implements: Node[]
|
||||
}
|
||||
|
||||
/** 枚举的一个成员;节点都指向源码。 */
|
||||
export interface EnumMemberFacts {
|
||||
name: Node
|
||||
/** 源码里写的值;没写(TypeScript 的自增成员)、或值交给库自动取(Python 的 `auto()`)时没有。 */
|
||||
value?: Node
|
||||
/** 没写值、语言算得出来时的值(TypeScript 数值枚举自增:0、1、2……);算不出来时没有。 */
|
||||
computed?: number
|
||||
/**
|
||||
* 成员的说明文字节点(注释、JSDoc、属性 docstring),照源码顺序:紧挨在它上面(从上一个成员之后起)单独成行的、
|
||||
* 写在它这一行行尾的、Python 紧跟在它后面的属性 docstring。翻译插件逐个取原文连起来。
|
||||
*/
|
||||
docs: Node[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 枚举:TypeScript 的 `enum` 由语言插件报;Python 没有枚举语法,自带 enum 库的写法(`class X(enum.Enum)`)由库规则认成这种块。
|
||||
* 两边同一种块、同一张属性表。
|
||||
*/
|
||||
export interface EnumFacts {
|
||||
name: Node
|
||||
/** 枚举自己的说明,同 ClassFacts.doc。 */
|
||||
doc?: Node
|
||||
/** 同 ClassFacts.access。 */
|
||||
access?: Access
|
||||
decorators: Node[]
|
||||
/** 照源码写的父类(Python 的 `str, enum.Enum`);TypeScript 没有。 */
|
||||
bases: BaseFacts[]
|
||||
/** 照源码顺序。 */
|
||||
members: EnumMemberFacts[]
|
||||
}
|
||||
|
||||
/** 导入的一项。 */
|
||||
export type ImportItem
|
||||
= | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b
|
||||
@@ -145,7 +176,7 @@ export interface VariableFacts {
|
||||
* - kind:块的种类(BlockKind);
|
||||
* - access:访问级别,'public' | 'private' | 'protected',语言可以加自己的值(Kotlin 的 'internal',见 BlockShape.attributes);
|
||||
* - async、abstract、typeOnly:异步、抽象、仅类型,值是 'true' | 'false'。
|
||||
* 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、ImportFacts.typeOnly、VariableFacts.access),
|
||||
* 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ImportFacts.typeOnly、VariableFacts.access),
|
||||
* 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。
|
||||
*/
|
||||
export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const
|
||||
@@ -174,12 +205,17 @@ export interface BlockShape {
|
||||
/** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */
|
||||
export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts }
|
||||
export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts }
|
||||
/** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */
|
||||
export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts }
|
||||
export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts }
|
||||
export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts }
|
||||
export type FactBlock = FunctionBlock | ClassBlock | ImportBlock | VariableBlock
|
||||
export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock
|
||||
|
||||
/** 带说明(facts.doc)的块。 */
|
||||
export type DocumentedBlock = FunctionBlock | ClassBlock | VariableBlock
|
||||
export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock
|
||||
|
||||
/** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */
|
||||
export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick<B, 'kind' | 'facts'> : never : never
|
||||
|
||||
export type TextKind = Exclude<BlockKind, FactBlock['kind']>
|
||||
|
||||
@@ -301,7 +337,7 @@ export interface Translator {
|
||||
displayLanguage: string
|
||||
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
|
||||
translate: (block: TextBlock, document: SourceDocument) => PreviewText
|
||||
/** 函数、类、导入、常量、变量块 → 属性表。 */
|
||||
/** 函数、类、枚举、导入、常量、变量块 → 属性表。 */
|
||||
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
|
||||
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
|
||||
expression: (node: Node, document: SourceDocument) => Inline[]
|
||||
@@ -318,9 +354,13 @@ export interface ThemeColor { dark: string, light: string }
|
||||
export interface KindBadge { label: string, color: ThemeColor }
|
||||
|
||||
/**
|
||||
* 库规则认领一块后给出的替身:预览里它代替原来那块,是一张属性表(种类标签、每一行都由规则给),下面挂着原块留下的子块。
|
||||
* 库规则认领一块后给出的替身,预览里它代替原来那块,下面挂着原块留下的子块:
|
||||
* 规则自己的卡片(RuleCard),或认成契约里现成的一种属性表块(RuleFacts)。
|
||||
*/
|
||||
export interface RuleBlock {
|
||||
export type RuleBlock = RuleCard | RuleFacts
|
||||
|
||||
/** 规则自己的卡片:一张属性表,种类标签、每一行都由规则给(用词归规则)。 */
|
||||
export interface RuleCard {
|
||||
/** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */
|
||||
kind: string
|
||||
/** 种类标签的文字和颜色:筛选栏里这个种类也叫这个名字。 */
|
||||
@@ -338,6 +378,16 @@ export interface RuleBlock {
|
||||
absorbs: CodeBlock[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这块认成契约里现成的一种属性表块(Python 自带 enum 库的类 → 枚举块):宿主当语言插件报的这种块处理,
|
||||
* 种类、种类名、颜色、属性表、筛选属性都和语言自带的这种块一样,由翻译插件排;规则只给 facts(库的知识),不给用词。
|
||||
* 块的头部是原块整条语句(facts 里的节点都在里面),absorbs 同 RuleCard.absorbs。
|
||||
*/
|
||||
export interface RuleFacts {
|
||||
as: BlockFacts
|
||||
absorbs: CodeBlock[]
|
||||
}
|
||||
|
||||
/** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */
|
||||
export interface RuleContext {
|
||||
document: SourceDocument
|
||||
@@ -396,8 +446,9 @@ export interface RuleWorkspace {
|
||||
}
|
||||
|
||||
/**
|
||||
* 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;文件导入了它关心的库才启用。
|
||||
* 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;用词(属性名、种类名、类型的译名)属于规则自己。
|
||||
* 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片,或认成现成的一种属性表块
|
||||
* (enum 库的类 → 枚举块);文件导入了它关心的库才启用。
|
||||
* 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;卡片的用词(属性名、种类名、类型的译名)属于规则自己。
|
||||
*/
|
||||
export interface LibraryRule {
|
||||
id: string // 'sqlalchemy'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# luffy-agent-platform 的 backend/src/luffy_agent/referral/models.py、vault/models.py 节选:enum 库规则的样例
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
|
||||
|
||||
class ReferralStatus(str, enum.Enum):
|
||||
NORMAL = "normal"
|
||||
FROZEN = "frozen"
|
||||
|
||||
|
||||
class RelationStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INVALID = "invalid"
|
||||
|
||||
|
||||
class CommissionStatus(str, enum.Enum):
|
||||
PENDING = "pending" # 待结算
|
||||
AVAILABLE = "available" # 可提现
|
||||
WITHDRAWING = "withdrawing" # 提现中
|
||||
WITHDRAWN = "withdrawn" # 已提现
|
||||
INVALID = "invalid" # 已失效
|
||||
|
||||
|
||||
class SecretScope(enum.StrEnum):
|
||||
"""条目作用域: user 全局 / app 级 (同名覆盖 user)."""
|
||||
|
||||
USER = "user"
|
||||
APP = "app"
|
||||
|
||||
|
||||
class SecretKind(enum.StrEnum):
|
||||
"""条目类型: secret 只写不读; variable 可读回明文."""
|
||||
|
||||
SECRET = "secret"
|
||||
VARIABLE = "variable"
|
||||
@@ -0,0 +1,20 @@
|
||||
/** 订单状态。 */
|
||||
export enum OrderStatus {
|
||||
/** 待支付 */
|
||||
Pending = 'pending',
|
||||
Paid = 'paid', // 已支付
|
||||
// 已取消
|
||||
Cancelled = 'cancelled',
|
||||
}
|
||||
|
||||
enum Direction { Up, Down = 5, Left, Right = -1, Next, Mask = 1 << 2, After }
|
||||
|
||||
export const enum Flag { A = 0x10, B }
|
||||
|
||||
declare enum Ambient { X }
|
||||
|
||||
export declare const enum Both { Y = 1 }
|
||||
|
||||
function local(): void {
|
||||
enum Inner { Z }
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { registerNoteCommands } from './notes/commands'
|
||||
import { FileNotes } from './notes/fileNotes'
|
||||
import { CodeReaderRegistry } from './registry'
|
||||
import { celeryRule } from './rules/celery'
|
||||
import { enumRule } from './rules/enum'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -91,6 +92,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerTranslator(pythonZhTranslator),
|
||||
api.registerLibraryRule(sqlalchemyRule),
|
||||
api.registerLibraryRule(celeryRule),
|
||||
api.registerLibraryRule(enumRule),
|
||||
)
|
||||
return api
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function listBlocks(
|
||||
else {
|
||||
const blocks = toBlocks(item)
|
||||
const [head] = blocks
|
||||
if ((head?.kind === 'function' || head?.kind === 'class') && doc) {
|
||||
if ((head?.kind === 'function' || head?.kind === 'class' || head?.kind === 'enum') && doc) {
|
||||
blocks[0] = documented(head, doc)
|
||||
doc = undefined
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('typeScript 代码块树', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('enum 成员 → field;type 别名 → type;多个循环 → loop', async () => {
|
||||
it('enum → 枚举块(整条声明是头部,成员不成子块);type 别名 → type;多个循环 → loop', async () => {
|
||||
const source = [
|
||||
'enum Color {',
|
||||
' Red = 1,',
|
||||
@@ -170,7 +170,7 @@ describe('typeScript 代码块树', () => {
|
||||
'}',
|
||||
].join('\n')
|
||||
expect(await tree(source)).toEqual([
|
||||
{ at: 1, kind: 'type', kids: [{ at: 2, kind: 'field' }, { at: 3, kind: 'field' }] },
|
||||
{ at: [1, 4], kind: 'enum' },
|
||||
{ at: 5, kind: 'type' },
|
||||
{ at: 6, kind: 'loop', kids: [
|
||||
{ at: 7, kind: 'loop', kids: [{ at: 7, kind: 'statement' }] },
|
||||
@@ -561,3 +561,54 @@ describe('typeScript 类', () => {
|
||||
expect(tsx[2]).toMatchObject({ abstract: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('typeScript 枚举', () => {
|
||||
it('enums.ts:export / const / declare / 函数里的 enum 都是枚举块,紧贴的 JSDoc 算进头部', async () => {
|
||||
expect(await tree(readFixture('enums.ts.txt'))).toEqual([
|
||||
{ at: [1, 8], kind: 'enum' },
|
||||
{ at: 10, kind: 'enum' },
|
||||
{ at: 12, kind: 'enum' },
|
||||
{ at: 14, kind: 'enum' },
|
||||
{ at: 16, kind: 'enum' },
|
||||
{ at: 18, kind: 'function', kids: [{ at: 19, kind: 'enum' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('成员:写的值、自增算出来的值(接不上数字就算不出来)、说明(前面单独成行的注释和 JSDoc、行尾注释);export 的公共、顶层其余私有、嵌套的没有', async () => {
|
||||
const byLine = await facts(readFixture('enums.ts.txt'))
|
||||
expect(byLine[1]).toEqual({
|
||||
name: 'OrderStatus',
|
||||
doc: '/** 订单状态。 */',
|
||||
access: 'public',
|
||||
decorators: [],
|
||||
bases: [],
|
||||
members: [
|
||||
{ name: 'Pending', value: '\'pending\'', docs: ['/** 待支付 */'] },
|
||||
{ name: 'Paid', value: '\'paid\'', docs: ['// 已支付'] },
|
||||
{ name: 'Cancelled', value: '\'cancelled\'', docs: ['// 已取消'] },
|
||||
],
|
||||
})
|
||||
expect(byLine[10]).toMatchObject({
|
||||
access: 'private',
|
||||
members: [
|
||||
{ name: 'Up', computed: 0, docs: [] },
|
||||
{ name: 'Down', value: '5', docs: [] },
|
||||
{ name: 'Left', computed: 6, docs: [] },
|
||||
{ name: 'Right', value: '-1', docs: [] },
|
||||
{ name: 'Next', computed: 0, docs: [] },
|
||||
{ name: 'Mask', value: '1 << 2', docs: [] },
|
||||
{ name: 'After', docs: [] },
|
||||
],
|
||||
})
|
||||
expect((byLine[10] as { members: object[] }).members[6]).toEqual({ name: 'After', docs: [] })
|
||||
expect(byLine[12]).toMatchObject({ name: 'Flag', access: 'public', members: [{ name: 'A', value: '0x10' }, { name: 'B', computed: 17 }] })
|
||||
expect(byLine[14]).toMatchObject({ name: 'Ambient', access: 'private', members: [{ name: 'X', computed: 0 }] })
|
||||
expect(byLine[16]).toMatchObject({ name: 'Both', access: 'public', members: [{ name: 'Y', value: '1' }] })
|
||||
expect(byLine[19]).toEqual({ name: 'Inner', decorators: [], bases: [], members: [{ name: 'Z', computed: 0, docs: [] }] })
|
||||
})
|
||||
|
||||
it('jSDoc 挂在同一行上一个成员后面时讲的是下一个成员;最后一个成员后面的注释不算', async () => {
|
||||
const byLine = await facts('enum E { A, /** 第二 */ B, C // 第三\n // 收尾\n}')
|
||||
expect(byLine[1]).toMatchObject({ members: [{ name: 'A', docs: [] }, { name: 'B', docs: ['/** 第二 */'] }, { name: 'C', docs: ['// 第三'] }] })
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
|
||||
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
|
||||
import type { CommentRules } from './shared'
|
||||
import * as path from 'node:path'
|
||||
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared'
|
||||
@@ -51,6 +51,49 @@ function declarationAccess(outer: Node, top: boolean): Access | undefined {
|
||||
return top ? 'private' : undefined
|
||||
}
|
||||
|
||||
/** 数字字面量(可带正负号)的值;别的写法算不出来。 */
|
||||
function numberValue(node: Node): number | undefined {
|
||||
const sign = node.type === 'unary_expression' ? node.child(0)?.type : undefined
|
||||
const literal = sign ? node.childForFieldName('argument') : node
|
||||
if (literal?.type !== 'number' || (sign && sign !== '-' && sign !== '+'))
|
||||
return undefined
|
||||
const value = Number(literal.text.replace(/_/g, ''))
|
||||
return Number.isFinite(value) ? (sign === '-' ? -value : value) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* enum 的成员,照源码顺序:没写值的成员接着上一个数字往上加(第一个是 0),上一个的值不是数字字面量、也不是算出来的数就算不出来。
|
||||
* 说明:从上一个成员之后起单独成行的注释、写在它这一行行尾的注释(JSDoc 总是讲下一个成员)。
|
||||
*/
|
||||
function enumMembers(body: Node | null): EnumMemberFacts[] {
|
||||
const members: EnumMemberFacts[] = []
|
||||
let pending: Node[] = []
|
||||
let lastRow = -1
|
||||
let next: number | undefined = 0
|
||||
for (const child of named(body)) {
|
||||
if (child.type === 'comment') {
|
||||
const last = members.at(-1)
|
||||
if (last && pending.length === 0 && child.startPosition.row === lastRow && !COMMENTS.isDoc(child))
|
||||
last.docs.push(child)
|
||||
else
|
||||
pending.push(child)
|
||||
continue
|
||||
}
|
||||
const assigned = child.type === 'enum_assignment'
|
||||
const name = assigned ? child.childForFieldName('name') ?? child.firstNamedChild : child
|
||||
const value = assigned ? child.childForFieldName('value') ?? undefined : undefined
|
||||
if (!name)
|
||||
continue
|
||||
const computed: number | undefined = value ? undefined : next
|
||||
const base: number | undefined = value ? numberValue(value) : computed
|
||||
next = base === undefined ? undefined : base + 1
|
||||
members.push({ name, ...(value && { value }), ...(computed !== undefined && { computed }), docs: pending })
|
||||
pending = []
|
||||
lastRow = child.endPosition.row
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/** 类成员:写了 public / private / protected 按写的,#name 是私有,默认公共。 */
|
||||
function memberAccess(member: Node): Access {
|
||||
const modifier = named(member).find(child => child.type === 'accessibility_modifier')
|
||||
@@ -274,8 +317,14 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] {
|
||||
case 'variable_declaration':
|
||||
return [variableBlock(declaration, outer, top)]
|
||||
case 'interface_declaration':
|
||||
case 'enum_declaration':
|
||||
return [membersBlock(declaration, outer)]
|
||||
case 'enum_declaration':
|
||||
return [enumBlock(declaration, outer, top)]
|
||||
case 'ambient_declaration': {
|
||||
// declare enum X {}:别的 declare 写法照旧是一条语句
|
||||
const inner = named(declaration).find(child => child.type === 'enum_declaration')
|
||||
return [inner ? enumBlock(inner, outer, top) : leafBlock('statement', outer)]
|
||||
}
|
||||
case 'type_alias_declaration':
|
||||
return [leafBlock('type', outer)]
|
||||
default: {
|
||||
@@ -399,7 +448,19 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] {
|
||||
}
|
||||
}
|
||||
|
||||
/** interface / enum:成员(属性、方法签名、枚举项)→ field。 */
|
||||
/** enum(含 const enum、declare enum):整条声明是头部,成员都在属性表里讲,没有子块。 */
|
||||
function enumBlock(declaration: Node, outer: Node, top: boolean): CodeBlock {
|
||||
const name = declaration.childForFieldName('name')
|
||||
if (!name)
|
||||
return leafBlock('type', outer)
|
||||
return {
|
||||
kind: 'enum',
|
||||
...leafShape(outer),
|
||||
facts: { name, access: declarationAccess(outer, top), decorators: [], bases: [], members: enumMembers(declaration.childForFieldName('body')) },
|
||||
}
|
||||
}
|
||||
|
||||
/** interface:成员(属性、方法签名)→ field。 */
|
||||
function membersBlock(declaration: Node, outer: Node): CodeBlock {
|
||||
const body = declaration.childForFieldName('body')
|
||||
if (!body)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { toBlockViews } from './blockView'
|
||||
import { replaceLines } from './editText'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { activeRules, fromLibrary, importsIn, resolveReference } from './libraryRules'
|
||||
import { WASM_DIR, withBlocks } from './testSupport'
|
||||
import { propertyLines, WASM_DIR, withBlocks } from './testSupport'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
@@ -177,6 +177,27 @@ describe('库规则:认领', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('认成契约里现成的属性表块:种类、属性表、筛选属性照语言插件报的这种块,编辑范围是整个类;并进来的不是子块时照常显示', async () => {
|
||||
const asEnum = (absorbs: (block: CodeBlock) => CodeBlock[]): LibraryRule => ({
|
||||
...cardRule('lib'),
|
||||
claim: block => block.kind === 'class' && block.facts.name
|
||||
? { as: { kind: 'enum', facts: { name: block.facts.name, access: block.facts.access, decorators: [], bases: [], members: [] } }, absorbs: absorbs(block) }
|
||||
: undefined,
|
||||
})
|
||||
const [, recognized] = await views(MODEL, [asEnum(block => block.children.slice(0, 1))])
|
||||
expect(recognized).toMatchObject({ kind: 'enum', startLine: 2, endLine: 8, attributes: { kind: 'enum', access: 'public' } })
|
||||
expect(recognized.badge).toBeUndefined()
|
||||
expect(propertyLines('properties' in recognized ? recognized.properties : [])).toEqual(['名称: User', '访问级别: 公共', '成员: 无'])
|
||||
expect(recognized.children.map(child => child.kind)).toEqual(['comment', 'variable', 'function'])
|
||||
|
||||
const reports: string[] = []
|
||||
const stranger: CodeBlock[] = []
|
||||
await withBlocks(py, 'python', 'x = 1', blocks => stranger.push(...blocks))
|
||||
const [, shown] = await views(MODEL, [asEnum(() => stranger)], reports)
|
||||
expect(shown.kind).toBe('class')
|
||||
expect(reports).toEqual(['库规则 lib 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块'])
|
||||
})
|
||||
|
||||
it('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', async () => {
|
||||
const list = await views(MODEL, [cardRule('orm')])
|
||||
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([
|
||||
|
||||
@@ -40,10 +40,12 @@ export interface Claim {
|
||||
|
||||
/** 替身不合 RuleBlock 的约定时说哪儿不对。 */
|
||||
function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): string | undefined {
|
||||
if (!claimed.kind.startsWith(`${rule.id}.`))
|
||||
return `种类 id「${claimed.kind}」没以「${rule.id}.」开头`
|
||||
if (claimed.absorbs.some(child => !block.children.includes(child)))
|
||||
return '要并进来的块不是这块的子块'
|
||||
if ('as' in claimed)
|
||||
return undefined
|
||||
if (!claimed.kind.startsWith(`${rule.id}.`))
|
||||
return `种类 id「${claimed.kind}」没以「${rule.id}.」开头`
|
||||
for (const { reference, offset } of referencedSegments(claimed.rows)) {
|
||||
if (reference?.rule !== rule.id)
|
||||
return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己`
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
|
||||
import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
|
||||
import type { Call } from './pythonTree'
|
||||
import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree'
|
||||
import { hint, joined, name, plain, range, word } from './segments'
|
||||
@@ -482,7 +482,7 @@ function appOfConf(node: Node | null | undefined, attribute: string): Node | und
|
||||
}
|
||||
|
||||
/** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */
|
||||
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleBlock {
|
||||
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleCard {
|
||||
const { words, context } = env
|
||||
const { labels, heads } = words
|
||||
const entries = entriesOf(schedule)
|
||||
@@ -506,7 +506,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N
|
||||
// ─── Celery 应用 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */
|
||||
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleBlock {
|
||||
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleCard {
|
||||
const { words, context } = env
|
||||
const { labels } = words
|
||||
const parameters = new Map<string, Node>()
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => {
|
||||
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
|
||||
|
||||
it('目录里有规则', () => {
|
||||
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts']))
|
||||
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts']))
|
||||
})
|
||||
|
||||
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PropertyRow, Segment } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileAttributes } from '../attributes'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { replaceLines } from '../editText'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { createTypeScriptAdapter } from '../languages/typescript'
|
||||
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||
import { celeryRule } from './celery'
|
||||
import { enumRule } from './enum'
|
||||
import { sqlalchemyRule } from './sqlalchemy'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||||
const SNAPSHOT = readFixture('enumModels.py')
|
||||
/** 和扩展里注册的顺序一样。 */
|
||||
const RULES = [sqlalchemyRule, celeryRule, enumRule]
|
||||
|
||||
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
|
||||
return withBlocks(py, 'python', source, blocks =>
|
||||
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) }))
|
||||
}
|
||||
|
||||
function tsViews(source: string): Promise<BlockView[]> {
|
||||
return withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator, { rules: RULES, report: () => {} }))
|
||||
}
|
||||
|
||||
function properties(view: BlockView | undefined): PropertyRow[] {
|
||||
if (!view || !('properties' in view))
|
||||
throw new Error(`块 ${view?.id} 不是属性表`)
|
||||
return view.properties
|
||||
}
|
||||
|
||||
function named(list: BlockView[], name: string): BlockView {
|
||||
const found = list.find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`)
|
||||
if (!found)
|
||||
throw new Error(`没有 ${name}`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 属性表里所有带下标的词(连表格里的格子)。 */
|
||||
function linkedSegments(rows: PropertyRow[]): Segment[] {
|
||||
const pieces: Inline[] = rows.flatMap(row => [
|
||||
...('markdown' in row.value ? [] : row.value),
|
||||
...(row.items ?? []).flatMap(item => item.cells.flat()),
|
||||
])
|
||||
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
|
||||
}
|
||||
|
||||
const RULE_CASES = [
|
||||
'from enum import Enum as E, IntFlag, auto, member, nonmember',
|
||||
'import enum',
|
||||
'',
|
||||
'',
|
||||
'class Color(E):',
|
||||
' """颜色。"""',
|
||||
'',
|
||||
' _ignore_ = ["TEMP"]',
|
||||
' __slots__ = ()',
|
||||
' _order_ = "RED GREEN"',
|
||||
' # 红色',
|
||||
' # 最常用',
|
||||
' RED = auto()',
|
||||
' GREEN = 2 # 绿',
|
||||
' """绿色。"""',
|
||||
' TEMP = 9',
|
||||
' __secret = 1',
|
||||
' helper = lambda self: 1',
|
||||
' label = property(lambda self: self.name)',
|
||||
' table = nonmember({})',
|
||||
'',
|
||||
' def describe(self) -> str:',
|
||||
' return self.name',
|
||||
'',
|
||||
' BLUE = member(3)',
|
||||
' _private = 5',
|
||||
' ALIAS: int = 4',
|
||||
' _value_: int',
|
||||
'',
|
||||
' class Meta:',
|
||||
' pass',
|
||||
'',
|
||||
' # 收尾',
|
||||
'',
|
||||
'',
|
||||
'@enum.unique',
|
||||
'class Perm(IntFlag):',
|
||||
' R = 4',
|
||||
' W = 2',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
describe('enum 库规则(Python)', () => {
|
||||
it('luffy 的枚举:和原型一样的枚举卡片,说明取行尾注释,类的 docstring 当说明', async () => {
|
||||
const list = await views(SNAPSHOT)
|
||||
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', 'import', 'enum', 'enum', 'enum', 'enum', 'enum'])
|
||||
const commission = named(list, 'CommissionStatus')
|
||||
expect(commission.badge).toBeUndefined()
|
||||
expect(propertyLines(properties(commission))).toEqual([
|
||||
'名称: CommissionStatus',
|
||||
'访问级别: 公共',
|
||||
'继承: str, enum.Enum',
|
||||
'成员: 5 个',
|
||||
' PENDING | "pending" | 待结算',
|
||||
' AVAILABLE | "available" | 可提现',
|
||||
' WITHDRAWING | "withdrawing" | 提现中',
|
||||
' WITHDRAWN | "withdrawn" | 已提现',
|
||||
' INVALID | "invalid" | 已失效',
|
||||
])
|
||||
expect(properties(commission).at(-1)?.columns).toEqual(['成员', '值', '说明'])
|
||||
expect(propertyLines(properties(named(list, 'SecretScope')))).toEqual([
|
||||
'名称: SecretScope',
|
||||
'说明: 条目作用域: user 全局 / app 级 (同名覆盖 user).',
|
||||
'访问级别: 公共',
|
||||
'继承: enum.StrEnum',
|
||||
'成员: 2 个',
|
||||
' USER | "user"',
|
||||
' APP | "app"',
|
||||
])
|
||||
expect(properties(named(list, 'SecretScope')).at(-1)?.columns).toEqual(['成员', '值'])
|
||||
})
|
||||
|
||||
it('筛选:种类是 enum、叫「枚举」,访问级别同原来的类;卡片默认的编辑范围是整个类', async () => {
|
||||
const list = await views(SNAPSHOT)
|
||||
const commission = named(list, 'CommissionStatus')
|
||||
expect(commission.attributes).toEqual({ kind: 'enum', access: 'public' })
|
||||
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
|
||||
.toContainEqual({ id: 'enum', name: '枚举', count: 5 })
|
||||
const lines = SNAPSHOT.split('\n')
|
||||
expect(lines[commission.startLine]).toBe('class CommissionStatus(str, enum.Enum):')
|
||||
expect(lines[commission.endLine]).toBe(' INVALID = "invalid" # 已失效')
|
||||
})
|
||||
|
||||
it('成员照 Python 的规则认:dunder、sunder、私有名、_ignore_ 里的、lambda、property、nonmember、只写注解的不是成员;auto() 写「自动」,member(x) 的值是 x', async () => {
|
||||
const list = await views(RULE_CASES)
|
||||
const color = named(list, 'Color')
|
||||
expect(propertyLines(properties(color))).toEqual([
|
||||
'名称: Color',
|
||||
'说明: 颜色。',
|
||||
'访问级别: 公共',
|
||||
'继承: E',
|
||||
'成员: 5 个',
|
||||
' RED | 自动 | 红色 最常用',
|
||||
' GREEN | 2 | 绿 绿色。',
|
||||
' BLUE | 3 | ',
|
||||
' _private | 5 | ',
|
||||
' ALIAS | 4 | ',
|
||||
])
|
||||
const auto = properties(color).at(-1)?.items?.[0].cells[1]
|
||||
expect(auto).toEqual([{ text: '自动', role: 'hint' }])
|
||||
expect(propertyLines(properties(named(list, 'Perm')))).toEqual([
|
||||
'名称: Perm',
|
||||
'访问级别: 公共',
|
||||
'装饰器: @enum.unique',
|
||||
'继承: IntFlag',
|
||||
'成员: 2 个',
|
||||
' R | 4',
|
||||
' W | 2',
|
||||
])
|
||||
})
|
||||
|
||||
it('方法、嵌套类、不是成员的属性不管写在哪都照常是子块;成员前面的注释并进卡片,最后面的注释留下;卡片「编辑」写回整个类', async () => {
|
||||
const list = await views(RULE_CASES)
|
||||
const color = named(list, 'Color')
|
||||
const lines = RULE_CASES.split('\n')
|
||||
expect(color.children.map(child => lines[child.startLine].trim())).toEqual([
|
||||
'_ignore_ = ["TEMP"]',
|
||||
'__slots__ = ()',
|
||||
'_order_ = "RED GREEN"',
|
||||
'TEMP = 9',
|
||||
'__secret = 1',
|
||||
'helper = lambda self: 1',
|
||||
'label = property(lambda self: self.name)',
|
||||
'table = nonmember({})',
|
||||
'def describe(self) -> str:',
|
||||
'_value_: int',
|
||||
'class Meta:',
|
||||
'# 收尾',
|
||||
])
|
||||
expect([lines[color.startLine], lines[color.endLine]]).toEqual(['class Color(E):', ' # 收尾'])
|
||||
expect(replaceLines(RULE_CASES, color.startLine, color.endLine, color.code)).toBe(RULE_CASES)
|
||||
const edited = replaceLines(RULE_CASES, color.startLine, color.endLine, color.code.replace('GREEN = 2', 'GREEN = 20'))
|
||||
expect(edited).toBe(RULE_CASES.replace('GREEN = 2', 'GREEN = 20'))
|
||||
})
|
||||
|
||||
it('跳到定义、高亮出现处:卡片是类名的定义,成员行是成员名的定义,带下标的词都指着源码里的这个名字', async () => {
|
||||
const list = await views(SNAPSHOT)
|
||||
const commission = named(list, 'CommissionStatus')
|
||||
expect(SNAPSHOT.slice(commission.defines?.startIndex, commission.defines?.endIndex)).toBe('CommissionStatus')
|
||||
const members = properties(commission).at(-1)?.items ?? []
|
||||
expect(members.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual(['PENDING', 'AVAILABLE', 'WITHDRAWING', 'WITHDRAWN', 'INVALID'])
|
||||
const linked = linkedSegments(properties(commission))
|
||||
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['CommissionStatus', 'str', 'enum', 'Enum', 'PENDING', '"pending"']))
|
||||
for (const segment of linked)
|
||||
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
|
||||
})
|
||||
|
||||
it('不是 enum 的枚举类型不认:没导入 enum、父类是 SQLAlchemy 的 Enum、父类不是枚举类型', async () => {
|
||||
const plain = await views('class Color(Enum):\n RED = 1\n')
|
||||
expect(plain.map(view => view.kind)).toEqual(['class'])
|
||||
const other = await views('import enum\nfrom sqlalchemy import Enum\n\n\nclass A(Enum):\n X = 1\n\n\nclass B(enum.auto):\n Y = 1\n\n\nclass C(str):\n Z = 1\n')
|
||||
expect(other.map(view => view.kind)).toEqual(['import', 'import', 'class', 'class', 'class'])
|
||||
const aliased = await views('import enum as e\n\n\nclass A(e.IntEnum):\n X = 1\n')
|
||||
expect(aliased.map(view => view.kind)).toEqual(['import', 'enum'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('枚举:TypeScript 和 Python 同一张卡片', () => {
|
||||
it('同样的成员:种类、种类名、属性表的行、列名、格子都一样(TypeScript 没有「继承」)', async () => {
|
||||
const tsSource = 'export enum Status {\n PENDING = "pending", // 待结算\n /** 可提现 */\n AVAILABLE = "available",\n}\n'
|
||||
const [tsEnum] = await tsViews(tsSource)
|
||||
const [pending] = properties(tsEnum).at(-1)?.items ?? []
|
||||
expect(pending.cells[0]).toEqual([{ text: 'PENDING', role: 'variable', offset: tsSource.indexOf('PENDING') }])
|
||||
expect(pending.defines).toEqual({ startIndex: tsSource.indexOf('PENDING'), endIndex: tsSource.indexOf('PENDING') + 'PENDING'.length })
|
||||
const [, pyEnum] = await views('import enum\n\n\nclass Status(enum.Enum):\n PENDING = "pending" # 待结算\n # 可提现\n AVAILABLE = "available"\n')
|
||||
const shape = (view: BlockView): unknown => {
|
||||
const rows = properties(view).filter(row => row.label !== '继承')
|
||||
return {
|
||||
kind: view.kind,
|
||||
badge: view.badge,
|
||||
attributes: view.attributes,
|
||||
lines: propertyLines(rows),
|
||||
columns: rows.map(row => row.columns),
|
||||
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(cell).map(segment => segment.role))),
|
||||
}
|
||||
}
|
||||
expect(shape(tsEnum)).toEqual(shape(pyEnum))
|
||||
expect(propertyLines(properties(tsEnum))).toEqual([
|
||||
'名称: Status',
|
||||
'访问级别: 公共',
|
||||
'成员: 2 个',
|
||||
' PENDING | "pending" | 待结算',
|
||||
' AVAILABLE | "available" | 可提现',
|
||||
])
|
||||
})
|
||||
|
||||
it('typeScript 没写值的成员写算出来的数,算不出来写「自动」,都是说明词', async () => {
|
||||
const [direction] = await tsViews('enum Direction { Up, Down = 5, Left, Mask = 1 << 2, After }')
|
||||
expect(propertyLines(properties(direction))).toEqual([
|
||||
'名称: Direction',
|
||||
'访问级别: 私有',
|
||||
'成员: 5 个',
|
||||
' Up | 0',
|
||||
' Down | 5',
|
||||
' Left | 6',
|
||||
' Mask | 1 << 2',
|
||||
' After | 自动',
|
||||
])
|
||||
expect(properties(direction).at(-1)?.items?.map(item => item.cells[1])).toEqual([
|
||||
[{ text: '0', role: 'hint' }],
|
||||
[{ text: '5', role: 'number' }],
|
||||
[{ text: '6', role: 'hint' }],
|
||||
expect.any(Array),
|
||||
[{ text: '自动', role: 'hint' }],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { ClassBlock, CodeBlock, EnumMemberFacts, ImportFacts, LibraryRule, Node, RuleFacts } from '../contract'
|
||||
import { leadingComments, withLeadingComments } from './children'
|
||||
import { stringValue, syntaxChildren } from './pythonTree'
|
||||
|
||||
/**
|
||||
* Python 自带 enum 库的规则:父类里有 enum 的枚举类型(Enum、IntEnum、StrEnum、Flag、IntFlag、ReprEnum;写成 `enum.X`、
|
||||
* 从 enum 导入的名字或它的别名都算,可以和别的父类混着写:`class Status(str, enum.Enum)`)的类,认成契约里的枚举块(EnumFacts):
|
||||
* 和 TypeScript 的 enum 是同一种块、同一张属性表,用词归翻译插件,规则只给 enum 库的知识——哪些类是枚举、哪些赋值是成员。
|
||||
* 成员照 Python 的规则认(见 memberOf);成员连同讲它的注释并进来,方法、嵌套类、别的属性照常是子块。
|
||||
* 父类是同一文件里别的枚举类(`class Sub(Base)`)的认不出来:规则只看这一块。
|
||||
*/
|
||||
|
||||
const LIBRARY = 'enum'
|
||||
const ENUM_TYPES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag', 'ReprEnum'])
|
||||
/** 值是这几种内置描述符的类属性不是成员。 */
|
||||
const DESCRIPTORS = new Set(['property', 'classmethod', 'staticmethod'])
|
||||
|
||||
/** 这份源码里怎么叫 enum 库的东西:库自己(`import enum`、`import enum as e`),和从它导入的名字(本地名 → 原名)。 */
|
||||
interface Names {
|
||||
modules: Set<string>
|
||||
imported: Map<string, string>
|
||||
}
|
||||
|
||||
function namesOf(imports: readonly ImportFacts[]): Names {
|
||||
const names: Names = { modules: new Set(), imported: new Map() }
|
||||
for (const facts of imports) {
|
||||
const fromEnum = facts.sources.some(source => source.text === LIBRARY)
|
||||
for (const item of facts.items) {
|
||||
if (item.kind === 'module' && item.module.text === LIBRARY)
|
||||
names.modules.add(item.alias?.text ?? LIBRARY)
|
||||
else if (item.kind === 'named' && fromEnum)
|
||||
names.imported.set(item.alias?.text ?? item.name.text, item.name.text)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 节点指的是 enum 库里的哪个名字:`enum.Enum`、从 enum 导入的 `Enum`(别名换回原名);别的是 undefined。 */
|
||||
function enumName(node: Node | null | undefined, names: Names): string | undefined {
|
||||
if (node?.type === 'identifier')
|
||||
return names.imported.get(node.text)
|
||||
const object = node?.type === 'attribute' ? node.childForFieldName('object') : null
|
||||
return object?.type === 'identifier' && names.modules.has(object.text) ? node?.childForFieldName('attribute')?.text : undefined
|
||||
}
|
||||
|
||||
/** 调用的是 enum 库里的哪个函数(`auto()`、`enum.nonmember(...)`)。 */
|
||||
function enumCall(node: Node | null | undefined, names: Names): string | undefined {
|
||||
return node?.type === 'call' ? enumName(node.childForFieldName('function'), names) : undefined
|
||||
}
|
||||
|
||||
/** 类主体里给一个名字赋值的子块(`A = 1`、`A: int = 1`、只写注解的 `A: int`):名字和值。 */
|
||||
function assignmentOf(block: CodeBlock): { name: Node, value?: Node } | undefined {
|
||||
if (block.kind !== 'variable' && block.kind !== 'field')
|
||||
return undefined
|
||||
const assignment = block.node.firstNamedChild
|
||||
const name = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
if (name?.type !== 'identifier')
|
||||
return undefined
|
||||
return { name, value: assignment?.childForFieldName('right') ?? undefined }
|
||||
}
|
||||
|
||||
/** `_ignore_` 里列的名字:空格隔开的字符串,或字符串的列表、元组。 */
|
||||
function ignoredNames(children: readonly CodeBlock[]): Set<string> {
|
||||
const value = children.map(assignmentOf).find(assignment => assignment?.name.text === '_ignore_')?.value
|
||||
const text = stringValue(value)
|
||||
if (text !== undefined)
|
||||
return new Set(text.split(/\s+/).filter(Boolean))
|
||||
const items = value?.type === 'list' || value?.type === 'tuple' ? syntaxChildren(value).map(stringValue) : []
|
||||
return new Set(items.filter((item): item is string => item !== undefined))
|
||||
}
|
||||
|
||||
/** 名字能当成员:不是 `__x__`(dunder)、`_x_`(enum 保留的 sunder)、`__x`(私有,会被改名)。 */
|
||||
function memberName(name: string): boolean {
|
||||
const dunder = name.length > 4 && name.startsWith('__') && name.endsWith('__')
|
||||
const sunder = name.length > 2 && name.startsWith('_') && name.endsWith('_') && name[1] !== '_' && name.at(-2) !== '_'
|
||||
const privateName = name.startsWith('__') && !name.endsWith('__')
|
||||
return !dunder && !sunder && !privateName
|
||||
}
|
||||
|
||||
/**
|
||||
* 一个子块是不是成员,是的话它的值:名字能当成员、不在 `_ignore_` 里,赋了值,值不是函数(lambda)、内置描述符
|
||||
* (property、classmethod、staticmethod)、`nonmember(...)`、连着赋值(`A = B = 1`)。
|
||||
* `auto()` 的值交给库自动取(没有 value),`member(x)` 的值是 x。
|
||||
*/
|
||||
function memberOf(block: CodeBlock, ignored: ReadonlySet<string>, names: Names): { name: Node, value?: Node } | undefined {
|
||||
const assignment = assignmentOf(block)
|
||||
const value = assignment?.value
|
||||
if (!assignment || !value || !memberName(assignment.name.text) || ignored.has(assignment.name.text))
|
||||
return undefined
|
||||
const callee = value.type === 'call' ? value.childForFieldName('function') : null
|
||||
if (value.type === 'lambda' || value.type === 'assignment' || (callee?.type === 'identifier' && DESCRIPTORS.has(callee.text)))
|
||||
return undefined
|
||||
switch (enumCall(value, names)) {
|
||||
case 'nonmember':
|
||||
return undefined
|
||||
case 'auto':
|
||||
return { name: assignment.name }
|
||||
case 'member':
|
||||
return { name: assignment.name, value: syntaxChildren(value.childForFieldName('arguments'))[0] }
|
||||
default:
|
||||
return { name: assignment.name, value }
|
||||
}
|
||||
}
|
||||
|
||||
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);当注释用的字符串是那个字符串。 */
|
||||
function commentNodes(block: CodeBlock): Node[] {
|
||||
if (block.node.type !== 'comment')
|
||||
return syntaxChildren(block.node).slice(0, 1)
|
||||
const nodes: Node[] = []
|
||||
for (let node: Node | null = block.node; node?.type === 'comment' && node.endIndex <= block.header.endIndex; node = node.nextSibling)
|
||||
nodes.push(node)
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** 成员的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、紧跟在它后面的属性 docstring。 */
|
||||
function memberDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] {
|
||||
const trailing = block.node.nextSibling
|
||||
const doc = block.kind === 'variable' ? block.facts.doc : block.kind === 'field' ? block.doc : undefined
|
||||
return [
|
||||
...leadingComments(children, block).flatMap(commentNodes),
|
||||
...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []),
|
||||
...(doc ? [doc] : []),
|
||||
]
|
||||
}
|
||||
|
||||
function enumBlock(block: ClassBlock, names: Names): RuleFacts | undefined {
|
||||
const { facts, children } = block
|
||||
if (!facts.name || !facts.bases.some(base => ENUM_TYPES.has(enumName(base.value, names) ?? '')))
|
||||
return undefined
|
||||
const ignored = ignoredNames(children)
|
||||
const members: EnumMemberFacts[] = []
|
||||
const blocks: CodeBlock[] = []
|
||||
for (const child of children) {
|
||||
const member = memberOf(child, ignored, names)
|
||||
if (!member)
|
||||
continue
|
||||
members.push({ ...member, docs: memberDocs(children, child) })
|
||||
blocks.push(child)
|
||||
}
|
||||
return {
|
||||
as: { kind: 'enum', facts: { name: facts.name, doc: facts.doc, access: facts.access, decorators: facts.decorators, bases: facts.bases, members } },
|
||||
absorbs: withLeadingComments(children, blocks),
|
||||
}
|
||||
}
|
||||
|
||||
export const enumRule: LibraryRule = {
|
||||
id: 'enum',
|
||||
label: 'Python 枚举(enum)',
|
||||
languageIds: ['python'],
|
||||
libraries: [LIBRARY],
|
||||
claim: (block, context) => block.kind === 'class' ? enumBlock(block, namesOf(context.imports)) : undefined,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, ThemeColor, TokenRole } from '../contract'
|
||||
import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, TokenRole } from '../contract'
|
||||
import type { Call } from './pythonTree'
|
||||
import { withLeadingComments } from './children'
|
||||
import { aliasesOf, callOf, canonicalName, lastName, stringValue, syntaxChildren } from './pythonTree'
|
||||
@@ -414,7 +414,7 @@ function tableRow(label: string, heads: string[], items: PropertyItem[], env: En
|
||||
return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : []
|
||||
}
|
||||
|
||||
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock {
|
||||
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard {
|
||||
const { words, context } = env
|
||||
const { facts } = block
|
||||
const { labels } = words
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Access, AttributeNames, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import { commonIndent, splitLines } from '../editText'
|
||||
|
||||
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
|
||||
@@ -136,6 +136,7 @@ const KIND_ZH: Record<BlockKind, string> = {
|
||||
import: '导入',
|
||||
function: '函数',
|
||||
class: '类',
|
||||
enum: '枚举',
|
||||
type: '类型',
|
||||
field: '字段',
|
||||
const: '常量',
|
||||
@@ -432,6 +433,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return functionRows(block.facts, phrases)
|
||||
case 'class':
|
||||
return classRows(block.facts, phrases)
|
||||
case 'enum':
|
||||
return enumRows(block.facts, phrases)
|
||||
case 'import':
|
||||
return importRows(block.facts, phrases)
|
||||
default:
|
||||
@@ -611,11 +614,46 @@ function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
|
||||
...flagRows('abstract', facts.abstract),
|
||||
...decoratorRows(facts.decorators, say),
|
||||
...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)),
|
||||
...listRow('父类', facts.bases.map(base => base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value))),
|
||||
...listRow('父类', facts.bases.map(base => baseText(base, say))),
|
||||
...listRow('实现的接口', facts.implements.map(say.translated)),
|
||||
]
|
||||
}
|
||||
|
||||
/** 父类的一项:TypeScript 的泛型实参紧跟在后面。 */
|
||||
function baseText(base: BaseFacts, say: Phrases): Inline[] {
|
||||
return base.typeArguments ? [...say.translated(base.value), ...say.translated(base.typeArguments)] : say.translated(base.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 枚举(TypeScript 的 enum、Python enum 库的类,同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),有才显示;
|
||||
* 成员收成一张表:成员 | 值 | 说明。没写值的成员写语言算出的值(自增的 0、1、2……),算不出来写「自动」,都按说明词着色;
|
||||
* 说明是成员的注释、JSDoc、属性 docstring 的原文用空格连起来,一个成员都没有说明时不给这一列。
|
||||
*/
|
||||
function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
|
||||
const docs = facts.members.map(member => member.docs.map(node => say.doc(node)?.markdown).filter(Boolean).join(' '))
|
||||
const described = docs.some(Boolean)
|
||||
return [
|
||||
{ label: '名称', value: say.name(facts.name, 'type') },
|
||||
...docRows(facts.doc, say),
|
||||
...accessRows(facts.access),
|
||||
...decoratorRows(facts.decorators, say),
|
||||
...(facts.bases.length > 0 ? [{ label: '继承', value: joined(facts.bases.map(base => baseText(base, say)), ', ') }] : []),
|
||||
{
|
||||
label: '成员',
|
||||
value: count(facts.members.length),
|
||||
columns: described ? ['成员', '值', '说明'] : ['成员', '值'],
|
||||
items: facts.members.map((member, i) => ({
|
||||
cells: [
|
||||
say.name(member.name, 'variable'),
|
||||
member.value ? say.translated(member.value) : [hint(member.computed === undefined ? '自动' : String(member.computed))],
|
||||
...(described ? [docs[i] ? [segment(docs[i], 'comment', undefined)] : []] : []),
|
||||
],
|
||||
...defining(member.name),
|
||||
})),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/** 函数、类、常量、变量的说明:有文字才有这一行。 */
|
||||
function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] {
|
||||
const markdown = doc && say.doc(doc)
|
||||
|
||||
@@ -74,7 +74,7 @@ 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} />
|
||||
|
||||
@@ -120,7 +120,7 @@ function PropertyTable({ parentId, columns, items }: { parentId: string, columns
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数、类、导入、常量、变量块和库规则认领的块的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
||||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片的预览:属性名一列对齐,值按词性着色;输入参数、导入项、字典 / 对象的值下面展开子行,
|
||||
* 给了列名的(输入参数、数据表的字段)排成表格,没给的排成列表。
|
||||
*/
|
||||
export function PropertyGrid({ blockId, rows, collapsed, onToggleFold }: {
|
||||
|
||||
@@ -14,6 +14,7 @@ body {
|
||||
--cr-kind-import: #c586c0;
|
||||
--cr-kind-function: #dcdcaa;
|
||||
--cr-kind-type: #4ec9b0;
|
||||
--cr-kind-enum: #b180d7;
|
||||
--cr-kind-data: #9cdcfe;
|
||||
--cr-kind-branch: #ce9178;
|
||||
--cr-kind-loop: #8f9cff;
|
||||
@@ -43,6 +44,7 @@ body.vscode-high-contrast-light {
|
||||
--cr-kind-import: #af00db;
|
||||
--cr-kind-function: #795e26;
|
||||
--cr-kind-type: #267f99;
|
||||
--cr-kind-enum: #652d90;
|
||||
--cr-kind-data: #001080;
|
||||
--cr-kind-branch: #a31515;
|
||||
--cr-kind-loop: #4f56d6;
|
||||
@@ -56,6 +58,7 @@ body.vscode-high-contrast-light {
|
||||
[data-kind="import"] { --kind: var(--cr-kind-import); }
|
||||
[data-kind="function"] { --kind: var(--cr-kind-function); }
|
||||
:is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); }
|
||||
[data-kind="enum"] { --kind: var(--cr-kind-enum); }
|
||||
:is([data-kind="field"], [data-kind="const"], [data-kind="variable"]) { --kind: var(--cr-kind-data); }
|
||||
:is([data-kind="if"], [data-kind="else"]) { --kind: var(--cr-kind-branch); }
|
||||
[data-kind="loop"] { --kind: var(--cr-kind-loop); }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
export type BlockKind
|
||||
= | 'import' | 'function' | 'class' | 'type' | 'field' | 'const' | 'variable'
|
||||
= | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable'
|
||||
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
|
||||
|
||||
/** 着色用的词性,源码着色和译文着色共用。 */
|
||||
@@ -97,7 +97,7 @@ interface BlockViewBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
|
||||
* 函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文;
|
||||
* 一行译文的块带说明时 doc 是说明的 Markdown 原文,排在译文下面
|
||||
*/
|
||||
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
|
||||
|
||||
@@ -111,6 +111,10 @@ describe('眼下折叠着哪些', () => {
|
||||
expect([...collapsedIds([table, block('1', 'if')], {})]).toEqual(['0'])
|
||||
})
|
||||
|
||||
it('没点过:枚举块(Python 枚举下面留着方法)和类块一样折叠', () => {
|
||||
expect([...collapsedIds([block('0', 'enum', [block('0.0', 'function')]), block('1', 'if')], {})]).toEqual(['0', '0.0'])
|
||||
})
|
||||
|
||||
it('点过的按用户的:展开的函数、类保持展开,折叠的其他块、属性行保持折叠', () => {
|
||||
const folds = { '1': false, '2': false, '1.0': true, '1.0.0': false, [propertyRowId('0', 2)]: true, [propertyRowId('2.0', 1)]: false }
|
||||
expect([...collapsedIds(BLOCKS, folds)].sort()).toEqual(['0/p2', '1.0', '2.0'])
|
||||
|
||||
@@ -54,9 +54,9 @@ function readViewState(saved: unknown): ViewState {
|
||||
return isScrollAnchor(state.scroll) ? { editing, folds, scroll: state.scroll } : { editing, folds }
|
||||
}
|
||||
|
||||
/** 块默认折叠吗:函数、类块和库规则认领的块收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
|
||||
/** 块默认折叠吗:函数、类、枚举块和库规则的卡片收起子块(属性表照常显示),其余块展开。属性行和带下一层的子行默认都展开。 */
|
||||
export function foldedByDefault(block: Pick<BlockView, 'kind' | 'badge'>): boolean {
|
||||
return block.badge !== undefined || block.kind === 'function' || block.kind === 'class'
|
||||
return block.badge !== undefined || block.kind === 'function' || block.kind === 'class' || block.kind === 'enum'
|
||||
}
|
||||
|
||||
/** 眼下折叠着的块、属性行和子行:用户点过的按他点的,没点过的按 foldedByDefault。 */
|
||||
|
||||
Reference in new issue
Block a user