✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片

This commit is contained in:
cruldra committed 2026-09-26 18:29:40 +08:00
1 parent 32be7d54b6
commit 5ece24cef9
26 files changed
+1768 -81

No files matched your search

+8 -2
View File
@@ -44,31 +44,37 @@ export function blockAttributes(block: CodeBlock): BlockAttributes {
interface Attributed {
attributes: BlockAttributes
/** 库规则认领的块的种类标签:这个种类在筛选栏里也叫它。 */
badge?: { label: string }
children: Attributed[]
}
/**
* 整份文件(所有层的块)里出现过的属性,每个值有几块。通用属性按 COMMON_ATTRIBUTES 的顺序排在前面,
* 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id。
* 语言自己的属性按第一次出现的先后排在后面。显示名取翻译插件的 names,没给的用 id;库规则的种类用规则给的标签。
*/
export function fileAttributes(blocks: readonly Attributed[], names: AttributeNames | undefined): FileAttribute[] {
const counts = new Map<string, Map<string, number>>()
const badges = new Map<string, string>()
const visit = (list: readonly Attributed[]): void => {
for (const block of list) {
for (const [id, value] of Object.entries(block.attributes)) {
const values = counts.get(id) ?? new Map<string, number>()
counts.set(id, values.set(value, (values.get(value) ?? 0) + 1))
}
if (block.badge)
badges.set(block.attributes.kind, block.badge.label)
visit(block.children)
}
}
visit(blocks)
const common: readonly string[] = COMMON_ATTRIBUTES
const rank = (id: string): number => common.includes(id) ? common.indexOf(id) : common.length
const valueName = (id: string, value: string): string => (id === 'kind' ? badges.get(value) : undefined) ?? names?.values[id]?.[value] ?? value
// sort 是稳定的:语言自己的属性同排在最后,保持第一次出现的先后
return [...counts].sort(([a], [b]) => rank(a) - rank(b)).map(([id, values]) => ({
id,
name: names?.attributes[id] ?? id,
values: [...values].map(([value, count]) => ({ id: value, name: names?.values[id]?.[value] ?? value, count })),
values: [...values].map(([value, count]) => ({ id: value, name: valueName(id, value), count })),
}))
}
+63 -15
View File
@@ -1,27 +1,33 @@
import type { BlockAttributes, BlockKind, CodeBlock, LanguageAdapter, Node, PreviewText, PropertyRow, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract'
import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TokenRole, Translator } from './contract'
import type { TreeSitterRuntime } from './runtime'
import { blockAttributes } from './attributes'
import { commonIndent, splitLines } from './editText'
import { absorbedRange, activeRules, claimBlock, importsIn } from './libraryRules'
interface BlockViewBase {
/** 在树里的位置,例如 '6.1.0'。 */
id: string
kind: BlockKind
/** header 的起止行,0 起。 */
/** BlockKind,或库规则认领的块的种类 id(RuleBlock.kind,带点,例如 sqlalchemy.table)。 */
kind: string
/** 编辑范围的起止行,0 起:一般是 header;库规则认领的块到最后一个并进来的子块为止(见 RuleBlock.absorbs)。 */
startLine: number
endLine: number
/** header 所在的整行源码,去掉共同缩进。 */
/** 编辑范围的整行源码,去掉共同缩进。 */
code: string
/** 源码着色(拼起来等于 code)。 */
codeTokens: Segment[]
/** 块自己的名字(见 ownName)在源码里的范围:跳到的定义就是它时闪整块,不闪属性表里的某一行。 */
defines?: SourceRange
/** 筛选属性(含 kind),见 blockAttributes。 */
/** 筛选属性(含 kind),见 blockAttributes;库规则认领的块叠上规则给的,kind 是规则的种类 id。 */
attributes: BlockAttributes
/** 库规则认领的块才有:种类标签的文字和颜色(RuleBlock.badge)。预览同类块一样默认收起它的子块。 */
badge?: KindBadge
children: BlockView[]
}
/** 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。 */
/**
* 发给 webview 的一块(可序列化):函数、类、导入、常量、变量块和库规则认领的块预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
*/
export type BlockView = BlockViewBase & ({ translation: PreviewText } | { properties: PropertyRow[] })
interface Line { start: number, end: number }
@@ -102,23 +108,64 @@ function ownName(block: CodeBlock): Node | undefined {
}
}
/** 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。 */
export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined): BlockView[] {
/** 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记。 */
export interface RuleRun {
rules: readonly LibraryRule[]
report: (message: string) => void
}
const NO_RULES: RuleRun = { rules: [], report: () => {} }
/**
* 代码块树 → 视图数据:header 整行源码去共同缩进、按语法树着色、调用翻译插件。
* 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock),并进去的子块不再单独成块。
*/
export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] {
const lines = lineRanges(source)
const texts = splitLines(source)
const document: SourceDocument = { source, language: adapter }
const build = (block: CodeBlock, id: string): BlockView => {
const { startRow, endRow } = block.header
const imports = importsIn(blocks)
const active = activeRules(rules.rules, imports)
const context: RuleContext = {
document,
imports,
...(translator && { displayLanguage: translator.displayLanguage }),
translate: node => translator ? translator.expression(node, document) : [{ text: node.text, role: 'plain' }],
doc: node => translator?.doc(node, document),
}
/** startRow..endRow 这几行整行源码去共同缩进,和它的着色。 */
const sourceRows = (root: Node, startRow: number, endRow: number): Pick<BlockView, 'startLine' | 'endLine' | 'code' | 'codeTokens'> => {
const rows = texts.slice(startRow, endRow + 1)
const indent = commonIndent(rows).length
return {
startLine: startRow,
endLine: endRow,
code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'),
codeTokens: colorLines(root, source, lines, startRow, endRow, indent, adapter.tokenRole),
}
}
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 { endRow, kept } = absorbedRange(block, claim.block.absorbs)
return {
id,
kind,
...sourceRows(root, block.header.startRow, 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,
}
}
const name = ownName(block)
return {
id,
kind: block.kind,
startLine: startRow,
endLine: endRow,
code: rows.map(row => row.slice(Math.min(indent, row.length))).join('\n'),
codeTokens: colorLines(block.node.tree.rootNode, source, lines, startRow, endRow, indent, adapter.tokenRole),
...sourceRows(root, 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}`)),
@@ -137,10 +184,11 @@ export async function readBlockViews(
translator: Translator | undefined,
languageId: string,
source: string,
rules: RuleRun = NO_RULES,
): Promise<BlockView[]> {
const tree = await runtime.parse(adapter, languageId, source)
try {
return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator)
return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator, rules)
}
finally {
tree.delete()
+67
View File
@@ -284,15 +284,82 @@ export interface Translator {
id: string // 'typescript.zh-template'
label: string // '中文直译'
languageIds: string[]
/** 译文用的语言(BCP 47 的语言部分,例如 'zh'):库规则按它挑自己的词表。 */
displayLanguage: string
/** 只翻 header 那一段;注释块给去掉注释符号的 Markdown 原文。 */
translate: (block: TextBlock, document: SourceDocument) => PreviewText
/** 函数、类、导入、常量、变量块 → 属性表。 */
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
expression: (node: Node, document: SourceDocument) => Inline[]
/** 一段说明文字(注释、docstring、当说明用的字符串)去掉注释符号、引号和共同缩进后的 Markdown 原文,同属性表的「说明」;没有文字时没有。 */
doc: (node: Node, document: SourceDocument) => Markdown | undefined
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
attributeNames?: AttributeNames
}
/** 一种颜色在暗色、亮色主题下各用哪个(CSS 颜色值);高对比主题跟着暗 / 亮走。 */
export interface ThemeColor { dark: string, light: string }
/** 种类标签:文字和颜色。 */
export interface KindBadge { label: string, color: ThemeColor }
/**
* 库规则认领一块后给出的替身:预览里它代替原来那块,是一张属性表(种类标签、每一行都由规则给),下面挂着原块留下的子块。
*/
export interface RuleBlock {
/** 种类 id,写成「规则 id.名字」(sqlalchemy.table):筛选的 kind 属性值就是它,筛选记住的也是它,要稳定。 */
kind: string
/** 种类标签的文字和颜色:筛选栏里这个种类也叫这个名字。 */
badge: KindBadge
rows: PropertyRow[]
/** 块自己的名字(类名)在源码里的范围:跳到的定义就是它时闪整块。 */
defines?: SourceRange
/** 规则自己的筛选属性,叠在宿主从原块推出的属性上(同 BlockShape.attributes);kind 总是上面的 kind,这里填了不算。 */
attributes?: BlockAttributes
/**
* 并进这一块的子块(原块 children 里的对象本身):不再单独成块,内容由 rows 讲。
* 这一块的编辑范围从原块头部开始、到最后一个并进来的子块结束,连成一段;夹在这段中间的别的子块(注释等)也跟着并进来,
* 不然同一行能在两处改。这段以后的子块(方法等)照常是这一块的子块,也照常交给各规则看。
*/
absorbs: CodeBlock[]
}
/** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */
export interface RuleContext {
document: SourceDocument
/** 这份源码里各处导入的 facts,照源码顺序:规则拿它认出库里名字的别名(`from sqlalchemy import Enum as SQLEnum`)。 */
imports: ImportFacts[]
/** 当前翻译插件的 displayLanguage;没有翻译插件时没有。规则按它挑自己的词表,没有这种语言的词表就用自己默认的那份。 */
displayLanguage?: string
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
translate: (node: Node) => Inline[]
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
}
/**
* 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;文件导入了它关心的库才启用。
* 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;用词(属性名、种类名、类型的译名)属于规则自己。
*/
export interface LibraryRule {
id: string // 'sqlalchemy'
label: string // 'SQLAlchemy 数据表'
languageIds: string[]
/**
* 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。
*/
libraries: string[]
/**
* 看一块:认领就返回替身,不认就返回 undefined。宿主从顶层往下每块都问(被认领的块留下的子块照样问)。
* 几个规则都认同一块时先注册的算;规则抛错、或替身不合上面的约定时,宿主记日志、当它没认,接着问下一个规则。
*/
claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined
}
export interface CodeReaderApi {
registerLanguage: (adapter: LanguageAdapter) => Disposable
registerTranslator: (translator: Translator) => Disposable
registerLibraryRule: (rule: LibraryRule) => Disposable
}
@@ -1,8 +1,12 @@
import type { Memento } from 'vscode'
import type { LibraryRule } from '../contract'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { CodeReaderRegistry } from '../registry'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { runtime, WASM_DIR } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { documentMessage } from './documentMessage'
@@ -19,13 +23,33 @@ function registryWithTypeScript(): CodeReaderRegistry {
return registry
}
function registryWithPython(...rules: LibraryRule[]): CodeReaderRegistry {
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createPythonAdapter(WASM_DIR))
registry.registerTranslator(pythonZhTranslator)
for (const rule of rules)
registry.registerLibraryRule(rule)
return registry
}
function ignore(): void {}
const MODEL = [
'from sqlalchemy.orm import Mapped, mapped_column',
'',
'class User(Base):',
' __tablename__ = "users"',
' id: Mapped[int] = mapped_column(primary_key=True)',
'',
].join('\n')
function doc(languageId: string, text: string, version = 3): { languageId: string, version: number, getText: () => string } {
return { languageId, version, getText: () => text }
}
describe('预览的 document 消息', () => {
it('受支持的语言:带文档版本、代码块、可选翻译和选中的翻译', async () => {
const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime)
const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime, ignore)
expect(message).toMatchObject({
type: 'document',
version: 3,
@@ -42,16 +66,43 @@ describe('预览的 document 消息', () => {
})
it('没有语言插件的语言:带 error,不解析', async () => {
const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime)
const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime, ignore)
expect(message.blocks).toEqual([])
expect(message.attributes).toEqual([])
expect(message.translators).toEqual([])
expect(message.error).toContain('rust')
})
it('库规则认领的块:种类是规则的种类 id,筛选栏里叫规则给的名字', async () => {
const message = await documentMessage(doc('python', MODEL), registryWithPython(sqlalchemyRule), runtime, ignore)
expect(message.blocks.map(b => b.kind)).toEqual(['import', 'sqlalchemy.table'])
expect(message.attributes.find(attribute => attribute.id === 'kind')?.values).toEqual([
{ id: 'import', name: '导入', count: 1 },
{ id: 'sqlalchemy.table', name: '数据表', count: 1 },
])
})
it('库规则抛错:不算解析失败,交给 report 记下,这块照常显示', async () => {
const broken: LibraryRule = {
id: 'broken',
label: '坏规则',
languageIds: ['python'],
libraries: ['sqlalchemy'],
claim: () => {
throw new Error('读不懂')
},
}
const reports: string[] = []
const message = await documentMessage(doc('python', MODEL), registryWithPython(broken), runtime, text => reports.push(text))
expect(message.error).toBeUndefined()
expect(message.blocks.map(b => b.kind)).toEqual(['import', 'class'])
expect(reports).toEqual([1, 3, 4, 5].map((line, i) =>
`库规则 broken 看第 ${line} 行的 ${['import', 'class', 'variable', 'field'][i]} 块时出错,这块照常显示:读不懂`))
})
it('解析失败:把原因放进 error', async () => {
const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) }
const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing)
const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore)
expect(message.blocks).toEqual([])
expect(message.error).toBe('语法包加载失败')
})
@@ -11,11 +11,12 @@ export interface DocumentSnapshot {
getText: () => string
}
/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。 */
/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */
export async function documentMessage(
document: DocumentSnapshot,
registry: Pick<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator'>,
registry: Pick<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator' | 'rulesFor'>,
runtime: Pick<TreeSitterRuntime, 'parse'>,
report: (message: string) => void,
): Promise<DocumentMessage> {
const { languageId, version } = document
const source = document.getText()
@@ -33,7 +34,7 @@ export async function documentMessage(
if (!adapter)
return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }
try {
const blocks = await readBlockViews(runtime, adapter, translator, languageId, source)
const blocks = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report })
return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }
}
catch (err) {
@@ -141,7 +141,9 @@ class Preview {
if (!this.ready)
return
const request = ++this.latest
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime)
const message: ExtensionToWebview = await documentMessage(this.document, this.registry, this.runtime, (text) => {
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
})
if (request !== this.latest || !this.ready)
return
const reveal = this.pendingReveal
@@ -0,0 +1,193 @@
# luffy-agent-platform 的 backend/src/luffy_agent/referral/models.py 节选:SQLAlchemy 库规则的样例
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from sqlalchemy import (
BigInteger,
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy import Enum as SQLEnum
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from luffy_agent.core.db import Base, IntPK, TimestampMixin
def _enum(py_enum: type[enum.Enum], name: str) -> SQLEnum:
return SQLEnum(
py_enum,
name=name,
values_callable=lambda values: [item.value for item in values],
validate_strings=True,
)
class ReferralStatus(str, enum.Enum):
NORMAL = "normal"
FROZEN = "frozen"
class RelationStatus(str, enum.Enum):
ACTIVE = "active"
INVALID = "invalid"
class ReferralRole(IntPK, TimestampMixin, Base):
__tablename__ = "referral_roles"
role_code: Mapped[str] = mapped_column(
String(32),
unique=True,
nullable=False,
comment="角色代码: bronze/silver/gold, 见 init_referral_roles.sql",
)
role_name: Mapped[str] = mapped_column(
String(64), nullable=False, comment="角色显示名"
)
is_active: Mapped[bool] = mapped_column(
nullable=False, default=True, comment="是否启用"
)
upgrade_min_invites: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
comment="自动升到本级所需累计邀请人数; 空=该条件不启用",
)
__table_args__ = ({"comment": "推广角色配置"},)
class UserReferralProfile(IntPK, TimestampMixin, Base):
__tablename__ = "user_referral_profile"
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_user.id", ondelete="RESTRICT"),
unique=True,
nullable=False,
comment="平台用户ID",
)
invite_code: Mapped[str] = mapped_column(
String(16), unique=True, index=True, nullable=False, comment="用户唯一邀请码"
)
custom_commission_rate_bps: Mapped[int | None] = mapped_column(
Integer, default=None, comment="单独佣金费率bps, 空则用角色默认"
)
referral_status: Mapped[ReferralStatus] = mapped_column(
_enum(ReferralStatus, "referral_status_enum"),
nullable=False,
default=ReferralStatus.NORMAL,
comment="推广状态",
)
role: Mapped[ReferralRole] = relationship(lazy="selectin")
__table_args__ = ({"comment": "用户推广资料"},)
class ReferralRelation(IntPK, TimestampMixin, Base):
__tablename__ = "referral_relations"
inviter_user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_user.id", ondelete="RESTRICT"),
index=True,
nullable=False,
comment="邀请人用户ID",
)
invitee_user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("sys_user.id", ondelete="RESTRICT"),
unique=True,
nullable=False,
comment="被邀请人用户ID(唯一, 一人只绑一个邀请人)",
)
invite_code: Mapped[str] = mapped_column(
String(16), nullable=False, comment="绑定时使用的邀请码"
)
bind_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="绑定时间"
)
expire_time: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, comment="归属过期时间"
)
status: Mapped[RelationStatus] = mapped_column(
_enum(RelationStatus, "referral_relation_status_enum"),
nullable=False,
default=RelationStatus.ACTIVE,
comment="关系状态",
)
__table_args__ = (
Index(
"ix_referral_relations_inviter_bind_time",
"inviter_user_id",
"bind_time",
),
{"comment": "邀请关系"},
)
class ReferralWithdrawal(IntPK, TimestampMixin, Base):
__tablename__ = "referral_withdrawals"
order_id: Mapped[uuid.UUID] = mapped_column(
PgUUID(as_uuid=True),
ForeignKey("commerce_order.id", ondelete="RESTRICT"),
unique=True,
nullable=False,
comment="commerce 订单ID(唯一, 一单一佣金, 幂等)",
)
tax_rate_bps: Mapped[int] = mapped_column(
Integer,
nullable=False,
default=0,
server_default=text("0"),
comment="申请时快照的代扣税点, 万分比 (后改配置不影响已有单)",
)
auditor_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("sys_user.id", ondelete="SET NULL"),
default=None,
comment="审核人ID",
)
__table_args__ = (
Index("ix_referral_withdrawals_user_status", "user_id", "status"),
{"comment": "提现申请"},
)
class ReferralWithdrawalItem(IntPK, TimestampMixin, Base):
__tablename__ = "referral_withdrawal_items"
withdrawal_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("referral_withdrawals.id", ondelete="CASCADE"),
nullable=False,
index=True,
comment="提现申请ID",
)
commission_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("referral_commissions.id", ondelete="RESTRICT"),
nullable=False,
index=True,
comment="占用的佣金ID",
)
__table_args__ = (
UniqueConstraint(
"withdrawal_id",
"commission_id",
name="uq_referral_withdrawal_item_wd_commission",
),
{"comment": "提现对佣金的占用明细 (FIFO, 末张可拆)"},
)
+3 -1
View File
@@ -10,6 +10,7 @@ import { createTypeScriptAdapter } from './languages/typescript'
import { registerNoteCommands } from './notes/commands'
import { FileNotes } from './notes/fileNotes'
import { CodeReaderRegistry } from './registry'
import { sqlalchemyRule } from './rules/sqlalchemy'
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
import { pythonZhTranslator } from './translators/pythonZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
@@ -41,7 +42,7 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
/**
* 接入代码阅读:注册表、侧栏文件树和文件注释、预览编辑器、编辑器右上角的「预览 / 编辑」切换。
* 内置的语言插件和翻译插件与其他扩展一样通过返回的 API 注册。
* 内置的语言插件、翻译插件和库规则与其他扩展一样通过返回的 API 注册。
*/
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
const registry = new CodeReaderRegistry(context.globalState)
@@ -74,6 +75,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
api.registerLanguage(createPythonAdapter(wasmDir)),
api.registerTranslator(typescriptZhTranslator),
api.registerTranslator(pythonZhTranslator),
api.registerLibraryRule(sqlalchemyRule),
)
return api
}
+166
View File
@@ -0,0 +1,166 @@
import type { BlockView, RuleRun } from './blockView'
import type { CodeBlock, LibraryRule, RuleBlock } from './contract'
import { describe, expect, it } from 'vitest'
import { fileAttributes } from './attributes'
import { toBlockViews } from './blockView'
import { createPythonAdapter } from './languages/python'
import { activeRules, fromLibrary, importsIn } from './libraryRules'
import { WASM_DIR, withBlocks } from './testSupport'
import { pythonZhTranslator } from './translators/pythonZh'
const py = createPythonAdapter(WASM_DIR)
const MODEL = [
'from sqlalchemy.orm import mapped_column',
'',
'class User(Base):',
' __tablename__ = "users"',
' # 基本信息',
' name = mapped_column(String)',
'',
' def greet(self) -> str:',
' return self.name',
'',
].join('\n')
/** 认领每个类块的假规则:并进它的前两个子块。 */
function cardRule(id: string, overrides: Partial<RuleBlock> = {}): LibraryRule {
return {
id,
label: id,
languageIds: ['python'],
libraries: ['sqlalchemy'],
claim: block => block.kind === 'class'
? { kind: `${id}.card`, badge: { label: `${id} 卡片`, color: { dark: '#fff', light: '#000' } }, rows: [], absorbs: block.children.slice(0, 1), ...overrides }
: undefined,
}
}
const throwing: LibraryRule = {
id: 'throwing',
label: '抛错',
languageIds: ['python'],
libraries: ['sqlalchemy'],
claim: () => {
throw new Error('坏了')
},
}
async function views(source: string, rules: LibraryRule[], reports: string[] = []): Promise<BlockView[]> {
const run: RuleRun = { rules, report: message => reports.push(message) }
return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, run))
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
/** 语法树用完即释放,节点要在里面读完。 */
function activates(source: string, rule: LibraryRule): Promise<boolean> {
return withBlocks(py, 'python', source, blocks => activeRules([rule], importsIn(blocks)).length > 0)
}
describe('库规则:启用', () => {
it('来源等于库名、或是它的子模块才算导入了这个库', () => {
expect(fromLibrary('sqlalchemy', 'sqlalchemy')).toBe(true)
expect(fromLibrary('sqlalchemy.orm', 'sqlalchemy')).toBe(true)
expect(fromLibrary('drizzle-orm/pg-core', 'drizzle-orm')).toBe(true)
expect(fromLibrary('sqlalchemy_utils', 'sqlalchemy')).toBe(false)
expect(fromLibrary('.sqlalchemy', 'sqlalchemy')).toBe(false)
})
it.each([
['from sqlalchemy.orm import Mapped, mapped_column', true],
['import sqlalchemy as sa', true],
['import os, sqlalchemy', true],
['def f():\n from sqlalchemy import select\n', true],
['from sqlalchemy_utils import UUIDType', false],
['from .sqlalchemy import Base', false],
['import os', false],
])('%s → %s', async (source, active) => {
expect(await activates(source, cardRule('sqlalchemy'))).toBe(active)
})
it('没导入库的文件不问规则', async () => {
const reports: string[] = []
const list = await views(MODEL.replace('sqlalchemy.orm', 'other'), [throwing], reports)
expect(list.map(view => view.kind)).toEqual(['import', 'class'])
expect(reports).toEqual([])
})
})
describe('库规则:认领', () => {
it('认领的块换成替身:种类、标签、编辑范围到最后一个并进来的子块,夹在中间的注释也并进来,之后的子块留下', async () => {
const rule: LibraryRule = {
...cardRule('orm'),
claim: (block) => {
if (block.kind !== 'class')
return undefined
const [table, , column] = block.children
return { kind: 'orm.card', badge: { label: '卡片', color: { dark: '#fff', light: '#000' } }, rows: [{ label: '名称', value: [{ text: 'User', role: 'type' }] }], absorbs: [table, column] }
},
}
const [, card] = await views(MODEL, [rule])
expect(card).toMatchObject({
kind: 'orm.card',
badge: { label: '卡片' },
startLine: 2,
endLine: 5,
code: 'class User(Base):\n __tablename__ = "users"\n # 基本信息\n name = mapped_column(String)',
attributes: { kind: 'orm.card', access: 'public' },
properties: [{ label: '名称' }],
})
expect(card.children.map(child => [child.id, child.kind, child.startLine])).toEqual([['1.0', 'function', 7]])
})
it('每一行最多在一块里能编辑:并进来的行只在替身里', async () => {
const list = flatten(await views(MODEL, [cardRule('orm', {})]))
const owners = new Map<number, string[]>()
for (const view of list) {
for (let line = view.startLine; line <= view.endLine; line++)
owners.set(line, [...owners.get(line) ?? [], view.id])
}
expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([])
})
it('几个规则都认同一块时先注册的算', async () => {
expect((await views(MODEL, [cardRule('first'), cardRule('second')]))[1].kind).toBe('first.card')
expect((await views(MODEL, [cardRule('second'), cardRule('first')]))[1].kind).toBe('second.card')
})
it('规则抛错:记下来,接着问下一个规则;都没认就照常显示', async () => {
const reports: string[] = []
expect((await views(MODEL, [throwing, cardRule('second')], reports))[1].kind).toBe('second.card')
expect(reports[1]).toBe('库规则 throwing 看第 3 行的 class 块时出错,这块照常显示:坏了')
const alone = await views(MODEL, [throwing])
expect(alone[1]).toMatchObject({ kind: 'class', startLine: 2, endLine: 2 })
expect(alone[1].children.map(child => child.kind)).toEqual(['variable', 'comment', 'variable', 'function'])
})
it('替身不合约定(种类 id 没带规则 id、并进来的不是子块):记下来,照常显示', async () => {
const reports: string[] = []
const stranger: CodeBlock[] = []
await withBlocks(py, 'python', 'x = 1', (blocks) => {
stranger.push(...blocks)
})
const list = await views(MODEL, [cardRule('bad', { kind: 'card' }), cardRule('worse', { absorbs: stranger })], reports)
expect(list[1].kind).toBe('class')
expect(reports).toEqual([
'库规则 bad 给第 3 行的 class 块的替身不合约定,这块照常显示:种类 id「card」没以「bad.」开头',
'库规则 worse 给第 3 行的 class 块的替身不合约定,这块照常显示:要并进来的块不是这块的子块',
])
})
it('筛选:规则的种类和别的种类一样计数,名字取规则给的标签', async () => {
const list = await views(MODEL, [cardRule('orm')])
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values).toEqual([
{ id: 'import', name: '导入', count: 1 },
{ id: 'orm.card', name: 'orm 卡片', count: 1 },
{ id: 'comment', name: '注释', count: 1 },
{ id: 'variable', name: '变量', count: 1 },
{ id: 'function', name: '函数', count: 1 },
{ id: 'return', name: '返回', count: 1 },
])
})
})
+84
View File
@@ -0,0 +1,84 @@
import type { CodeBlock, ImportFacts, LibraryRule, Node, RuleBlock, RuleContext } from './contract'
/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后哪些子块并进去。规则本身只经 contract 拿信息。 */
/** 这份源码里各处导入的 facts,照源码顺序(先父块、再子块)。 */
export function importsIn(blocks: readonly CodeBlock[]): ImportFacts[] {
const found: ImportFacts[] = []
const visit = (list: readonly CodeBlock[]): void => {
for (const block of list) {
if (block.kind === 'import')
found.push(block.facts)
visit(block.children)
}
}
visit(blocks)
return found
}
/** 来源模块的写法去掉首尾引号(TypeScript 的来源是字符串字面量)。 */
function moduleName(source: Node): string {
return source.text.replace(/^(['"`])([\s\S]*)\1$/, '$2')
}
/** 模块是这个库或它的子模块:等于库名,或以「库名.」(Python 的点分模块名)「库名/」(JavaScript 的子路径)开头。 */
export function fromLibrary(module: string, library: string): boolean {
return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`)
}
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库。 */
export function activeRules(rules: readonly LibraryRule[], imports: readonly ImportFacts[]): LibraryRule[] {
const modules = imports.flatMap(facts => facts.sources.map(moduleName))
return rules.filter(rule => rule.libraries.some(library => modules.some(module => fromLibrary(module, library))))
}
/** 一块被哪个规则认领、替身是什么。 */
export interface Claim {
rule: LibraryRule
block: RuleBlock
}
/** 替身不合 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 '要并进来的块不是这块的子块'
return undefined
}
/**
* 按注册顺序问启用的规则,第一个认领的算。规则抛错、给的替身不合约定时交给 report(记日志),当它没认、接着问下一个;
* 都没认领就是 undefined,这块照常显示。
*/
export function claimBlock(block: CodeBlock, rules: readonly LibraryRule[], context: RuleContext, report: (message: string) => void): Claim | undefined {
const where = `第 ${block.header.startRow + 1} 行的 ${block.kind} 块`
for (const rule of rules) {
let claimed: RuleBlock | undefined
try {
claimed = rule.claim(block, context)
}
catch (err) {
report(`库规则 ${rule.id} 看${where}时出错,这块照常显示:${err instanceof Error ? err.message : String(err)}`)
continue
}
if (!claimed)
continue
const problem = problemOf(rule, block, claimed)
if (problem) {
report(`库规则 ${rule.id} 给${where}的替身不合约定,这块照常显示:${problem}`)
continue
}
return { rule, block: claimed }
}
return undefined
}
/**
* 替身并进了哪几行、留下哪些子块:编辑范围从原块头部开始,到最后一个并进来的子块(整条语句)结束;
* 起始行落在这段里的子块都并进来(包括夹在中间、规则没点名的),之后的留下。
*/
export function absorbedRange(block: CodeBlock, absorbs: readonly CodeBlock[]): { endRow: number, kept: CodeBlock[] } {
const endRow = Math.max(block.header.endRow, ...absorbs.map(child => Math.max(child.header.endRow, child.node.endPosition.row)))
return { endRow, kept: block.children.filter(child => child.header.startRow > endRow) }
}
+25 -2
View File
@@ -1,5 +1,5 @@
import type { Memento } from 'vscode'
import type { LanguageAdapter, Translator } from './contract'
import type { LanguageAdapter, LibraryRule, Translator } from './contract'
import { describe, expect, it } from 'vitest'
import { CodeReaderRegistry, TRANSLATOR_CHOICE_KEY } from './registry'
@@ -17,7 +17,11 @@ function adapter(id: string, languageIds: string[]): LanguageAdapter {
}
function translator(id: string, languageIds: string[]): Translator {
return { id, label: id, languageIds, translate: () => [], describe: () => [] }
return { id, label: id, languageIds, displayLanguage: 'zh', translate: () => [], describe: () => [], expression: () => [], doc: () => undefined }
}
function rule(id: string, languageIds: string[]): LibraryRule {
return { id, label: id, languageIds, libraries: [id], claim: () => undefined }
}
describe('codeReaderRegistry', () => {
@@ -73,6 +77,25 @@ describe('codeReaderRegistry', () => {
expect(registry.selectedTranslator('typescript')?.id).toBe('ts.zh')
})
it('库规则按语言过滤、保持注册顺序;注销后不再给,注册注销都触发变化事件', () => {
const registry = new CodeReaderRegistry(fakeMemento())
let changes = 0
registry.onDidChange(() => {
changes++
})
registry.registerLibraryRule(rule('sqlalchemy', ['python']))
const django = registry.registerLibraryRule(rule('django', ['python']))
registry.registerLibraryRule(rule('typeorm', ['typescript']))
expect(registry.rulesFor('python').map(r => r.id)).toEqual(['sqlalchemy', 'django'])
expect(registry.rulesFor('typescript').map(r => r.id)).toEqual(['typeorm'])
expect(registry.rulesFor('rust')).toEqual([])
django.dispose()
django.dispose()
expect(registry.rulesFor('python').map(r => r.id)).toEqual(['sqlalchemy'])
expect(changes).toBe(4)
})
it('变化事件可退订,也能放进 disposables', () => {
const registry = new CodeReaderRegistry(fakeMemento())
let changes = 0
+12 -2
View File
@@ -1,5 +1,5 @@
import type { Disposable, Event, Memento } from 'vscode'
import type { CodeReaderApi, LanguageAdapter, Translator } from './contract'
import type { CodeReaderApi, LanguageAdapter, LibraryRule, Translator } from './contract'
/** globalState 里按 languageId 记选中的翻译插件 id:Record<languageId, translatorId>。 */
export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguage'
@@ -7,12 +7,13 @@ export const TRANSLATOR_CHOICE_KEY = 'superpowers.codeReader.translatorByLanguag
interface Entry<T> { value: T }
/**
* 语言插件、翻译插件的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。
* 语言插件、翻译插件、库规则的注册表。内置实现和其他扩展都通过它注册(它本身就是对外的 CodeReaderApi)。
* 不依赖 vscode 运行时:Disposable 和事件都是结构兼容的普通对象,便于单元测试。
*/
export class CodeReaderRegistry implements CodeReaderApi {
private readonly adapters: Entry<LanguageAdapter>[] = []
private readonly translators: Entry<Translator>[] = []
private readonly rules: Entry<LibraryRule>[] = []
private readonly listeners = new Set<() => void>()
constructor(private readonly state: Pick<Memento, 'get' | 'update'>) {}
@@ -36,6 +37,10 @@ export class CodeReaderRegistry implements CodeReaderApi {
return this.add(this.translators, translator)
}
registerLibraryRule(rule: LibraryRule): Disposable {
return this.add(this.rules, rule)
}
/** 当前语言插件覆盖的全部 languageId(去重、排序),给 when 子句用。 */
languageIds(): string[] {
return [...new Set(this.adapters.flatMap(entry => entry.value.languageIds))].sort()
@@ -51,6 +56,11 @@ export class CodeReaderRegistry implements CodeReaderApi {
return this.translators.map(entry => entry.value).filter(translator => translator.languageIds.includes(languageId))
}
/** 某 languageId 的库规则,按注册顺序:几个规则都认同一块时先注册的算。 */
rulesFor(languageId: string): LibraryRule[] {
return this.rules.map(entry => entry.value).filter(rule => rule.languageIds.includes(languageId))
}
/** 选中的翻译:globalState 里记的那个还注册着就用它,否则用第一个。 */
selectedTranslator(languageId: string): Translator | undefined {
const candidates = this.translatorsFor(languageId)
@@ -0,0 +1,21 @@
import { readdirSync, readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
/** 库规则(这个目录下的非测试文件)import 的模块:只许 contract 和同目录的共用代码。 */
function importsOf(file: string): string[] {
const text = readFileSync(path.join(__dirname, file), 'utf8')
return [...text.matchAll(/^\s*(?:import|export)\s(?:[^'"]*?from\s+)?['"]([^'"]+)['"]/gm)].map(match => match[1])
}
describe('库规则只经 contract 拿信息', () => {
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
it('目录里有规则', () => {
expect(files).toContain('sqlalchemy.ts')
})
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
expect(importsOf(file).filter(source => source !== '../contract' && !source.startsWith('./'))).toEqual([])
})
})
@@ -0,0 +1,216 @@
import type { BlockView } from '../blockView'
import type { Inline, PropertyRow, Segment } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { sqlalchemyRule } from './sqlalchemy'
const py = createPythonAdapter(WASM_DIR)
const SNAPSHOT = readFixture('sqlalchemyModels.py')
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: message => reports.push(message) }))
}
function flatten(list: BlockView[]): BlockView[] {
return list.flatMap(view => [view, ...flatten(view.children)])
}
function properties(view: BlockView): PropertyRow[] {
if (!('properties' in view))
throw new Error(`块 ${view.id} 不是属性表`)
return view.properties
}
async function model(source: string, name: string): Promise<BlockView> {
const found = (await views(source)).find(view => 'properties' in view && view.properties[0]?.value && !('markdown' in view.properties[0].value)
&& view.properties[0].value.some(piece => 'text' in piece && piece.text === 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)
}
describe('sqlAlchemy 库规则', () => {
it('referralRelation:和原型一样的数据表卡片', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(table.kind).toBe('sqlalchemy.table')
expect(table.badge).toEqual({ label: '数据表', color: { dark: '#e8a860', light: '#a3590d' } })
expect(propertyLines(properties(table))).toEqual([
'名称: ReferralRelation',
'表名: referral_relations',
'说明: 邀请关系',
'继承: IntPK, TimestampMixin, Base',
'字段: 6 个',
' inviter_user_id | 整数 | 索引 · 外键 sys_user.id(删除时禁止) | 邀请人用户ID',
' invitee_user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 被邀请人用户ID(唯一, 一人只绑一个邀请人)',
' invite_code | 字符串(16) | | 绑定时使用的邀请码',
' bind_time | 日期时间(带时区) | | 绑定时间',
' expire_time | 日期时间(带时区) | | 归属过期时间',
' status | 枚举 RelationStatus | 默认 RelationStatus.ACTIVE | 关系状态',
'索引: 1 个',
' ix_referral_relations_inviter_bind_time | inviter_user_id, bind_time',
])
const rows = properties(table)
expect(rows.find(row => row.label === '字段')?.columns).toEqual(['字段', '类型', '约束', '说明'])
expect(rows.find(row => row.label === '索引')?.columns).toEqual(['名称', '列'])
expect(rows.find(row => row.label === '说明')?.value).toEqual({ markdown: '邀请关系' })
})
it('其余模型:Mapped 类型、可空、默认值、数据库默认、别名、关系、唯一约束', async () => {
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralRole'))).slice(4)).toEqual([
'字段: 4 个',
' role_code | 字符串(32) | 唯一 | 角色代码: bronze/silver/gold, 见 init_referral_roles.sql',
' role_name | 字符串(64) | | 角色显示名',
' is_active | 布尔 | 默认 真 | 是否启用',
' upgrade_min_invites | 整数 | 可空 | 自动升到本级所需累计邀请人数; 空=该条件不启用',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'UserReferralProfile'))).slice(2)).toEqual([
'说明: 用户推广资料',
'继承: IntPK, TimestampMixin, Base',
'字段: 4 个',
' user_id | 整数 | 唯一 · 外键 sys_user.id(删除时禁止) | 平台用户ID',
' invite_code | 字符串(16) | 唯一 · 索引 | 用户唯一邀请码',
' custom_commission_rate_bps | 整数 | 可空 · 默认 空 | 单独佣金费率bps, 空则用角色默认',
' referral_status | 枚举 ReferralStatus | 默认 ReferralStatus.NORMAL | 推广状态',
'关系: 1 个',
' role | ReferralRole | ',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawal'))).slice(4)).toEqual([
'字段: 3 个',
' order_id | UUID | 唯一 · 外键 commerce_order.id(删除时禁止) | commerce 订单ID(唯一, 一单一佣金, 幂等)',
' tax_rate_bps | 整数 | 默认 0 · 数据库默认 text("0") | 申请时快照的代扣税点, 万分比 (后改配置不影响已有单)',
' auditor_id | 整数 | 可空 · 默认 空 · 外键 sys_user.id(删除时置空) | 审核人ID',
'索引: 1 个',
' ix_referral_withdrawals_user_status | user_id, status',
])
expect(propertyLines(properties(await model(SNAPSHOT, 'ReferralWithdrawalItem'))).slice(4)).toEqual([
'字段: 2 个',
' withdrawal_id | 整数 | 索引 · 外键 referral_withdrawals.id(级联删除) | 提现申请ID',
' commission_id | 整数 | 索引 · 外键 referral_commissions.id(删除时禁止) | 占用的佣金ID',
'唯一约束: 1 个',
' uq_referral_withdrawal_item_wd_commission | withdrawal_id, commission_id',
])
})
it('只认模型类:枚举类、函数照常显示;模型类的赋值都并进卡片,不再单独成块', async () => {
const list = await views(SNAPSHOT)
expect(list.map(view => view.kind)).toEqual([
'comment',
...Array.from({ length: 9 }, () => 'import'),
'function',
'class',
'class',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
'sqlalchemy.table',
])
for (const view of list.filter(item => item.kind === 'sqlalchemy.table'))
expect(view.children, view.id).toEqual([])
})
it('并进来的行只在卡片里能编辑,卡片的编辑范围是整个类', async () => {
const lines = SNAPSHOT.split('\n')
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(lines[table.startLine]).toBe('class ReferralRelation(IntPK, TimestampMixin, Base):')
expect(lines[table.endLine]).toBe(' )')
expect(lines[table.endLine + 1]).toBe('')
const owners = new Map<number, string[]>()
for (const view of flatten(await views(SNAPSHOT))) {
for (let line = view.startLine; line <= view.endLine; line++)
owners.set(line, [...owners.get(line) ?? [], view.id])
}
expect([...owners.values()].filter(ids => ids.length > 1)).toEqual([])
expect(owners.get(lines.indexOf(' __tablename__ = "referral_relations"'))).toEqual([table.id])
})
it('跳到定义、高亮出现处:卡片自己是类名的定义,字段行是字段名的定义,带下标的词都指着源码里的这个名字', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(SNAPSHOT.slice(table.defines?.startIndex, table.defines?.endIndex)).toBe('ReferralRelation')
const fields = properties(table).find(row => row.label === '字段')?.items ?? []
expect(fields.map(item => SNAPSHOT.slice(item.defines?.startIndex, item.defines?.endIndex))).toEqual([
'inviter_user_id',
'invitee_user_id',
'invite_code',
'bind_time',
'expire_time',
'status',
])
const [name] = fields[0].cells[0] as Segment[]
expect(name).toEqual({ text: 'inviter_user_id', role: 'variable', offset: fields[0].defines?.startIndex })
const linked = linkedSegments(properties(table))
expect(linked.map(segment => segment.text)).toEqual(expect.arrayContaining(['IntPK', 'RelationStatus', 'ACTIVE', 'status']))
for (const segment of linked)
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
})
it('筛选:种类是 sqlalchemy.table,访问级别同原来的类', async () => {
const table = await model(SNAPSHOT, 'ReferralRelation')
expect(table.attributes).toEqual({ kind: 'sqlalchemy.table', access: 'public' })
})
it('import sqlalchemy as sa:老写法 Column、类型全名、只写注解的字段、不认得的类型照常翻、docstring 当说明;方法照常是子块,夹在中间的注释并进来', async () => {
const source = [
'import sqlalchemy as sa',
'from sqlalchemy.orm import DeclarativeBase, relationship',
'',
'',
'class Base(DeclarativeBase):',
' pass',
'',
'',
'class User(Base):',
' """用户。"""',
'',
' __tablename__ = "users"',
' # 基本信息',
' id = sa.Column(sa.Integer, primary_key=True)',
' name = sa.Column("user_name", sa.String(50), sa.ForeignKey(Org.name, onupdate="cascade"))',
' orders = relationship("Order", back_populates="user", uselist=False)',
' price = sa.Column(sa.Numeric(10, 2), nullable=False)',
' nickname: Mapped[Optional[str]]',
' extra = sa.Column(CustomJson(3), comment=NOTE)',
'',
' def __repr__(self) -> str:',
' return self.name',
'',
].join('\n')
const list = await views(source)
expect(list.map(view => view.kind)).toEqual(['import', 'import', 'class', 'sqlalchemy.table'])
const user = list[3]
expect(propertyLines(properties(user))).toEqual([
'名称: User',
'表名: users',
'说明: 用户。',
'继承: Base',
'字段: 5 个',
' id | 整数 | 主键 | ',
' name | 字符串(50) | 可空 · 外键 Org.name(级联更新) | ',
' price | 定点数(10,2) | | ',
' nickname | 字符串 | 可空 | ',
' extra | CustomJson(3) | 可空 | NOTE',
'关系: 1 个',
' orders | Order | 对方字段 user · 单个对象',
])
expect([user.startLine, user.endLine]).toEqual([8, 18])
expect(user.children.map(child => [child.kind, child.startLine])).toEqual([['function', 20]])
})
it('没导入 SQLAlchemy 的文件不认', async () => {
const list = await views('class User(Base):\n __tablename__ = "users"\n')
expect(list.map(view => view.kind)).toEqual(['class'])
})
})
+557
View File
@@ -0,0 +1,557 @@
import type { ClassBlock, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, SourceRange, ThemeColor, TokenRole } from '../contract'
/**
* SQLAlchemy 库规则:声明式模型类(有 `__tablename__`,或主体里用 `mapped_column(...)` / `Column(...)` 定义字段)整块换成「数据表」卡片:
* 名称 / 表名 / 说明 / 继承 / 装饰器,字段、关系、索引、唯一约束各一张表,都是有才显示。
* `__tablename__`、`__table_args__`、字段和关系的赋值并进卡片,方法等别的成员照常是子块。
* 规则只经 contract 拿信息,直接读的是 Python 语法树(tree-sitter-python 的节点类型);用词都在词表里,按翻译插件的显示语言挑。
*/
/** 文件导入了这几个库之一才启用;Flask-SQLAlchemy 的模型写成 `db.Column(...)`,文件里可能只导入了它。 */
const LIBRARIES = ['sqlalchemy', 'flask_sqlalchemy']
const COLUMN_CALLS = new Set(['mapped_column', 'Column'])
/** 字段定义的位置参数里不是类型的那些(外键、序列、计算列……)。 */
const SCHEMA_ITEMS = new Set(['ForeignKey', 'Sequence', 'Identity', 'Computed', 'CheckConstraint', 'FetchedValue', 'DefaultClause'])
/** 写成关键字参数的类型大小:`String(length=16)`、`Numeric(precision=10, scale=2)`。 */
const SIZE_KEYWORDS = ['length', 'precision', 'scale']
const TABLE_KIND = 'sqlalchemy.table'
const TABLE_COLOR: ThemeColor = { dark: '#e8a860', light: '#a3590d' }
/** 一种显示语言的用词。 */
interface Words {
kind: string
labels: Record<'name' | 'table' | 'doc' | 'bases' | 'decorators' | 'columns' | 'relationships' | 'indexes' | 'uniques', string>
columnHeads: string[]
relationshipHeads: string[]
indexHeads: string[]
count: (n: number) => string
/** SQL 类型(SQLAlchemy 的类名;导入时起的别名已换回原名)→ 译名。 */
types: Record<string, string>
withTimezone: string
enumType: string
unannotated: string
constraints: Record<'primaryKey' | 'unique' | 'index' | 'nullable' | 'default' | 'serverDefault' | 'foreignKey', string>
/** 外键 ondelete / onupdate 的动作(大写)→ 译名;表里没有的用 otherOn… 拼。 */
onDelete: Record<string, string>
onUpdate: Record<string, string>
otherOnDelete: (action: string) => string
otherOnUpdate: (action: string) => string
/** 外键后面括起来的删除、更新规则。 */
actions: (texts: string[]) => string
unnamed: string
uniqueIndex: string
relationship: Record<'backPopulates' | 'single' | 'secondary', string>
}
const ZH: Words = {
kind: '数据表',
labels: { name: '名称', table: '表名', doc: '说明', bases: '继承', decorators: '装饰器', columns: '字段', relationships: '关系', indexes: '索引', uniques: '唯一约束' },
columnHeads: ['字段', '类型', '约束', '说明'],
relationshipHeads: ['名称', '目标', '说明'],
indexHeads: ['名称', '列'],
count: n => `${n} 个`,
types: {
Integer: '整数',
INTEGER: '整数',
INT: '整数',
BigInteger: '长整数',
BIGINT: '长整数',
SmallInteger: '短整数',
SMALLINT: '短整数',
String: '字符串',
Unicode: '字符串',
VARCHAR: '字符串',
NVARCHAR: '字符串',
CHAR: '字符串',
Text: '长文本',
UnicodeText: '长文本',
TEXT: '长文本',
Boolean: '布尔',
BOOLEAN: '布尔',
DateTime: '日期时间',
DATETIME: '日期时间',
TIMESTAMP: '日期时间',
Date: '日期',
DATE: '日期',
Time: '时间',
TIME: '时间',
Interval: '时间间隔',
Numeric: '定点数',
NUMERIC: '定点数',
DECIMAL: '定点数',
Float: '浮点数',
FLOAT: '浮点数',
REAL: '浮点数',
Double: '浮点数',
DOUBLE_PRECISION: '浮点数',
JSON: 'JSON',
JSONB: 'JSON',
UUID: 'UUID',
Uuid: 'UUID',
LargeBinary: '二进制',
BYTEA: '二进制',
ARRAY: '数组',
},
withTimezone: '(带时区)',
enumType: '枚举',
unannotated: '未标注',
constraints: { primaryKey: '主键', unique: '唯一', index: '索引', nullable: '可空', default: '默认', serverDefault: '数据库默认', foreignKey: '外键' },
onDelete: { 'CASCADE': '级联删除', 'RESTRICT': '删除时禁止', 'SET NULL': '删除时置空', 'SET DEFAULT': '删除时置默认值', 'NO ACTION': '删除时不处理' },
onUpdate: { 'CASCADE': '级联更新', 'RESTRICT': '更新时禁止', 'SET NULL': '更新时置空', 'SET DEFAULT': '更新时置默认值', 'NO ACTION': '更新时不处理' },
otherOnDelete: action => `删除时 ${action}`,
otherOnUpdate: action => `更新时 ${action}`,
actions: texts => `(${texts.join(',')})`,
unnamed: '未命名',
uniqueIndex: '(唯一)',
relationship: { backPopulates: '对方字段', single: '单个对象', secondary: '中间表' },
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
const WORDS: Record<string, Words> = { zh: ZH }
/** 认一个模型类要用的:宿主给的 context、挑好的用词、别名表。 */
interface Env {
context: RuleContext
words: Words
aliases: Map<string, string>
}
/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */
interface Call {
name: string | undefined
positional: Node[]
keywords: Map<string, Node>
}
/** 类主体里一条给名字赋值(或只写注解)的语句。 */
interface Member {
block: CodeBlock
name: Node
annotation?: Node
value?: Node
}
/** 一个字段:只写 `Mapped[...]` 注解的没有 call。 */
interface Column {
member: Member
call?: Call
}
interface Model {
table?: Member
args?: Member
columns: Column[]
relationships: { member: Member, call: Call }[]
}
// ─── 读语法树 ─────────────────────────────────────────────────────────────────
/** 具名子节点,注释不算。 */
function syntaxChildren(node: Node | null | undefined): Node[] {
return node ? node.namedChildren.filter((child): child is Node => child !== null && !child.isExtra) : []
}
/** 名字的最后一节:`sa.Column` → Column。 */
function lastName(node: Node | null | undefined): string | undefined {
if (node?.type === 'identifier')
return node.text
if (node?.type === 'attribute')
return node.childForFieldName('attribute')?.text
return undefined
}
function isTrue(node: Node | undefined): boolean {
return node?.type === 'true'
}
/** 字符串字面量的内容;f-string、拼接的字符串不算。 */
function stringValue(node: Node | null | undefined): string | undefined {
if (node?.type !== 'string')
return undefined
const parts = syntaxChildren(node)
if (parts.some(part => part.type === 'interpolation'))
return undefined
return parts.filter(part => part.type === 'string_content').map(part => part.text).join('')
}
function fromLibrary(source: Node): boolean {
return LIBRARIES.some(library => source.text === library || source.text.startsWith(`${library}.`))
}
/** 从 SQLAlchemy 导入时起的别名 → 原名:`from sqlalchemy import Enum as SQLEnum`。 */
function aliasesOf(imports: ImportFacts[]): Map<string, string> {
const aliases = new Map<string, string>()
for (const facts of imports) {
if (!facts.sources.some(fromLibrary))
continue
for (const item of facts.items) {
if (item.kind === 'named' && item.alias)
aliases.set(item.alias.text, item.name.text)
}
}
return aliases
}
/** 类型、函数的原名:直接写的名字按别名表换回,`sa.Integer` 这种取最后一节。 */
function canonicalName(node: Node | null | undefined, aliases: Map<string, string>): string | undefined {
const name = lastName(node)
return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name
}
function callOf(node: Node | null | undefined, aliases: Map<string, string>): Call | undefined {
if (node?.type !== 'call')
return undefined
const args = node.childForFieldName('arguments')
const positional: Node[] = []
const keywords = new Map<string, Node>()
for (const arg of args?.type === 'argument_list' ? syntaxChildren(args) : []) {
if (arg.type === 'keyword_argument') {
const key = arg.childForFieldName('name')
const value = arg.childForFieldName('value')
if (key && value)
keywords.set(key.text, value)
}
else if (arg.type !== 'list_splat' && arg.type !== 'dictionary_splat') {
positional.push(arg)
}
}
return { name: canonicalName(node.childForFieldName('function'), aliases), positional, keywords }
}
function memberOf(block: CodeBlock): Member | undefined {
if (block.kind !== 'field' && block.kind !== 'variable' && block.kind !== 'const')
return undefined
const [assignment] = syntaxChildren(block.node)
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 }
}
/** 注解里 `type` 节点只包着一个类型时取里面那个。 */
function unwrapType(node: Node): Node {
const inner = node.type === 'type' ? syntaxChildren(node) : []
return inner.length === 1 ? inner[0] : node
}
/** `Mapped[X]`(`so.Mapped[X]`)里的 X;注解不是 Mapped[...] 时没有。 */
function mappedType(annotation: Node | undefined): Node | undefined {
const node = annotation && unwrapType(annotation)
if (node?.type === 'generic_type') {
const [head, parameters] = syntaxChildren(node)
return lastName(head) === 'Mapped' ? syntaxChildren(parameters)[0] : undefined
}
if (node?.type === 'subscript' && lastName(node.childForFieldName('value')) === 'Mapped')
return node.childForFieldName('subscript') ?? undefined
return undefined
}
function isNone(node: Node): boolean {
return unwrapType(node).type === 'none'
}
/** 类型去掉 None:`X | None`、`Optional[X]`、`Union[X, None]` → X,并说它可空;拆不出单个类型时留着整个。 */
function withoutNone(type: Node): { type: Node, optional: boolean } {
const node = unwrapType(type)
if (node.type === 'binary_operator' && node.childForFieldName('operator')?.type === '|') {
const left = node.childForFieldName('left')
const right = node.childForFieldName('right')
if (left && right && isNone(right))
return { type: left, optional: true }
if (left && right && isNone(left))
return { type: right, optional: true }
return { type, optional: [left, right].some(side => side && withoutNone(side).optional) }
}
if (node.type === 'generic_type') {
const [head, parameters] = syntaxChildren(node)
const args = syntaxChildren(parameters)
if (lastName(head) === 'Optional' && args.length === 1)
return { type: args[0], optional: true }
const rest = args.filter(arg => !isNone(arg))
if (lastName(head) === 'Union' && rest.length < args.length)
return { type: rest.length === 1 ? rest[0] : type, optional: true }
}
return { type, optional: false }
}
/** 类主体里的表名、表参数、字段和关系;既没有表名也没有 mapped_column / Column 字段的类不是模型。 */
function modelOf(block: ClassBlock, aliases: Map<string, string>): Model | undefined {
const model: Model = { columns: [], relationships: [] }
for (const child of block.children) {
const member = memberOf(child)
if (!member)
continue
if (member.name.text === '__tablename__') {
model.table = member
continue
}
if (member.name.text === '__table_args__') {
model.args = member
continue
}
const call = callOf(member.value, aliases)
if (call?.name && COLUMN_CALLS.has(call.name))
model.columns.push({ member, call })
else if (call?.name === 'relationship')
model.relationships.push({ member, call })
else if (!member.value && mappedType(member.annotation))
model.columns.push({ member })
}
return model.table || model.columns.some(column => column.call) ? model : undefined
}
/** `__table_args__`:元组 / 列表里的约束、索引,最后一项可以是选项字典;也可以只写一个字典。 */
function tableArgs(value: Node | undefined): { options?: Node, items: Node[] } {
let node = value
while (node?.type === 'parenthesized_expression')
node = syntaxChildren(node)[0]
if (node?.type === 'dictionary')
return { options: node, items: [] }
if (node?.type !== 'tuple' && node?.type !== 'list')
return { items: [] }
const items = syntaxChildren(node)
const last = items.at(-1)
return last?.type === 'dictionary' ? { options: last, items: items.slice(0, -1) } : { items }
}
function dictValue(dictionary: Node | undefined, key: string): Node | undefined {
for (const pair of syntaxChildren(dictionary)) {
if (pair.type === 'pair' && stringValue(pair.childForFieldName('key')) === key)
return pair.childForFieldName('value') ?? undefined
}
return undefined
}
// ─── 写成属性表 ───────────────────────────────────────────────────────────────
function range(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
function plain(text: string): Segment {
return { text, role: 'plain' }
}
function hint(text: string): Segment {
return { text, role: 'hint' }
}
function word(text: string): Segment {
return { text, role: 'storage' }
}
/** 源码里的一个名字照抄,带源码下标(能跳到定义、高亮各处出现)。 */
function name(node: Node, role: TokenRole): Segment {
return { text: node.text, role, offset: node.startIndex }
}
function joined(parts: Inline[][], separator: Segment): Inline[] {
return parts.flatMap((part, i) => i > 0 ? [{ ...separator }, ...part] : part)
}
/** 字符串字面量写内容(不带引号)、按 role 着色;别的表达式交给翻译插件翻。 */
function textOr(node: Node, role: TokenRole, env: Env): Inline[] {
const text = stringValue(node)
return text === undefined ? env.context.translate(node) : [{ text, role }]
}
/** 类名、枚举类这种名字:是单个名字就按类型着色、带下标,别的写法交给翻译插件。 */
function typeName(node: Node, env: Env): Inline[] {
return node.type === 'identifier' ? [name(node, 'type')] : env.context.translate(node)
}
/** 枚举:`Enum(X)`、`SQLEnum(X)`、包着枚举类的辅助函数(`_enum(X, "…")`)写「枚举 X」;只列了取值的写「枚举 "a", "b"」。 */
function enumType(call: Call | undefined, env: Env): Inline[] {
const out: Inline[] = [{ text: env.words.enumType, role: 'type' }]
const [first] = call?.positional ?? []
const values = first && first.type !== 'string' ? [first] : (call?.positional ?? []).filter(arg => arg.type === 'string')
return values.length > 0 ? [...out, plain(' '), ...joined(values.map(value => typeName(value, env)), plain(', '))] : out
}
/** 字段的 SQL 类型:认得的换成译名,带上大小(`字符串(16)`、`定点数(10,2)`)和时区;不认得的照常翻。 */
function sqlType(node: Node, env: Env): Inline[] {
const call = callOf(node, env.aliases)
const original = call ? call.name : canonicalName(node, env.aliases)
if (original && /enum/i.test(original))
return enumType(call, env)
const translated = original && env.words.types[original]
if (!translated)
return env.context.translate(node)
const out: Inline[] = [{ text: translated, role: 'type' }]
if (!call)
return out
const sizes = [...call.positional, ...SIZE_KEYWORDS.flatMap(key => call.keywords.get(key) ?? [])]
if (sizes.length > 0)
out.push(plain('('), ...joined(sizes.map(size => sqlType(size, env)), plain(',')), plain(')'))
if (isTrue(call.keywords.get('timezone')))
out.push({ text: env.words.withTimezone, role: 'type' })
return out
}
function referentialAction(node: Node | undefined, known: Record<string, string>, other: (action: string) => string): string | undefined {
if (!node)
return undefined
const action = stringValue(node)?.trim().toUpperCase()
return action === undefined ? other(node.text) : known[action] ?? other(action)
}
/** 外键:「外键 sys_user.id(删除时禁止)」。 */
function foreignKey(call: Call, env: Env): Inline[] {
const { words } = env
const [target] = call.positional
const out: Inline[] = [word(words.constraints.foreignKey)]
if (target)
out.push(plain(' '), ...textOr(target, 'string', env))
const actions = [
referentialAction(call.keywords.get('ondelete'), words.onDelete, words.otherOnDelete),
referentialAction(call.keywords.get('onupdate'), words.onUpdate, words.otherOnUpdate),
].filter((text): text is string => text !== undefined)
if (actions.length > 0)
out.push(hint(words.actions(actions)))
return out
}
/**
* 可空:写了 nullable 按它;主键不可空;Column 默认可空;mapped_column 和只写注解的按 `Mapped[...]` 里有没有 None,没注解时同 Column。
* 非空是常态,不写。
*/
function isNullable(call: Call | undefined, optional: boolean | undefined): boolean {
const explicit = call?.keywords.get('nullable')
if (explicit)
return isTrue(explicit)
if (isTrue(call?.keywords.get('primary_key')))
return false
return call?.name === 'Column' || optional === undefined ? true : optional
}
/** 字段表的一行:字段 | 类型 | 约束 | 说明。讲的定义是字段名。 */
function columnItem({ member, call }: Column, env: Env): PropertyItem {
const { words, context } = env
const mapped = mappedType(member.annotation)
const annotated = mapped && withoutNone(mapped)
// 第一个位置参数是字符串时是库里的列名,不是类型
const args = (call?.positional ?? []).filter((arg, i) => !(i === 0 && arg.type === 'string'))
const calls = args.map(arg => callOf(arg, env.aliases))
const typeArg = args.find((_, i) => !SCHEMA_ITEMS.has(calls[i]?.name ?? ''))
const keywords = call?.keywords ?? new Map<string, Node>()
const constraints: Inline[][] = []
const flag = (key: string, text: string): void => {
if (isTrue(keywords.get(key)))
constraints.push([word(text)])
}
flag('primary_key', words.constraints.primaryKey)
flag('unique', words.constraints.unique)
flag('index', words.constraints.index)
if (isNullable(call, annotated ? annotated.optional : undefined))
constraints.push([word(words.constraints.nullable)])
for (const [key, text] of [['default', words.constraints.default], ['server_default', words.constraints.serverDefault]]) {
const value = keywords.get(key)
if (value)
constraints.push([word(text), plain(' '), ...context.translate(value)])
}
for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey'))
constraints.push(foreignKey(foreign, env))
const comment = keywords.get('comment')
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) : [],
],
defines: range(member.name),
}
}
/** 关系表的一行:名称 | 目标 | 说明(对方字段、单个对象、中间表)。目标取第一个位置参数,没写时取 `Mapped[...]` 里的类型。 */
function relationshipItem({ member, call }: { member: Member, call: Call }, env: Env): PropertyItem {
const { words, context } = env
const [target] = call.positional
const mapped = mappedType(member.annotation)
const notes: Inline[][] = []
const back = call.keywords.get('back_populates') ?? call.keywords.get('backref')
if (back)
notes.push([word(words.relationship.backPopulates), plain(' '), ...textOr(back, 'variable', env)])
if (call.keywords.get('uselist')?.type === 'false')
notes.push([word(words.relationship.single)])
const secondary = call.keywords.get('secondary')
if (secondary)
notes.push([word(words.relationship.secondary), plain(' '), ...textOr(secondary, 'string', env)])
return {
cells: [
[name(member.name, 'variable')],
target ? textOr(target, 'type', env) : mapped ? context.translate(withoutNone(mapped).type) : [],
joined(notes, hint(' · ')),
],
defines: range(member.name),
}
}
/** 索引 / 唯一约束表的一行:名称 | 列。名称是字符串就写内容,没给写「未命名」;唯一索引的名称后面写「(唯一)」。 */
function constraintItem(nameNode: Node | undefined, columns: Node[], unique: boolean, env: Env): PropertyItem {
const title = nameNode && nameNode.type !== 'none' ? textOr(nameNode, 'string', env) : [hint(env.words.unnamed)]
return {
cells: [
unique ? [...title, hint(env.words.uniqueIndex)] : title,
joined(columns.map(column => textOr(column, 'variable', env)), plain(', ')),
],
}
}
function tableRow(label: string, heads: string[], items: PropertyItem[], env: Env): PropertyRow[] {
return items.length > 0 ? [{ label, value: [hint(env.words.count(items.length))], columns: heads, items }] : []
}
function tableBlock(block: ClassBlock, model: Model, env: Env): RuleBlock {
const { words, context } = env
const { facts } = block
const { labels } = words
const { options, items } = tableArgs(model.args?.value)
const comment = dictValue(options, 'comment')
const doc = (comment && context.doc(comment)) ?? (facts.doc && context.doc(facts.doc))
const indexes: PropertyItem[] = []
const uniques: PropertyItem[] = []
for (const item of items) {
const call = callOf(item, env.aliases)
if (call?.name === 'Index') {
const [indexName, ...columns] = call.positional
indexes.push(constraintItem(indexName, columns, isTrue(call.keywords.get('unique')), env))
}
else if (call?.name === 'UniqueConstraint') {
uniques.push(constraintItem(call.keywords.get('name'), call.positional, false, env))
}
}
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(model.table?.value ? [{ label: labels.table, value: textOr(model.table.value, 'string', env) }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(facts.decorators.length > 0 ? [{ label: labels.decorators, value: joined(facts.decorators.map(decorator => context.translate(decorator)), plain('\n')) }] : []),
...tableRow(labels.columns, words.columnHeads, model.columns.map(column => columnItem(column, env)), env),
...tableRow(labels.relationships, words.relationshipHeads, model.relationships.map(relationship => relationshipItem(relationship, env)), env),
...tableRow(labels.indexes, words.indexHeads, indexes, env),
...tableRow(labels.uniques, words.indexHeads, uniques, env),
]
const members = [model.table, model.args, ...model.columns.map(column => column.member), ...model.relationships.map(relationship => relationship.member)]
return {
kind: TABLE_KIND,
badge: { label: words.kind, color: TABLE_COLOR },
rows,
...(facts.name && { defines: range(facts.name) }),
absorbs: members.filter((member): member is Member => member !== undefined).map(member => member.block),
}
}
export const sqlalchemyRule: LibraryRule = {
id: 'sqlalchemy',
label: 'SQLAlchemy 数据表',
languageIds: ['python'],
libraries: LIBRARIES,
claim: (block, context) => {
if (block.kind !== 'class')
return undefined
const aliases = aliasesOf(context.imports)
const model = modelOf(block, aliases)
return model && tableBlock(block, model, { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases })
},
}
@@ -397,6 +397,15 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return joinTokens(tokens)
}
function expression(node: Node, document: SourceDocument): Inline[] {
return joinTokens(tokensIn(node, nodeSpan(node), document))
}
function doc(node: Node): Markdown | undefined {
const markdown = docText([table.commentText(node)])
return markdown ? { markdown } : undefined
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
const { header } = block
const { language } = document
@@ -406,7 +415,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return joinTokens(tokensIn(node, span, document))
}
const phrases: Phrases = {
translated: node => within(node, { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }),
translated: node => within(node, nodeSpan(node)),
within,
entryCount: (literal, size) => {
const name = table.mapLiterals[literal.type]
@@ -416,10 +425,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
// 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样
module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'),
name: (node, role) => [segment(node.text, role, offsetOf(node, language))],
doc: (node) => {
const markdown = docText([table.commentText(node)])
return markdown ? { markdown } : undefined
},
doc,
}
switch (block.kind) {
case 'function':
@@ -433,7 +439,21 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
}
}
return { id: table.id, label: table.label, languageIds: table.languageIds, translate, describe, attributeNames: ZH_ATTRIBUTE_NAMES }
return {
id: table.id,
label: table.label,
languageIds: table.languageIds,
displayLanguage: 'zh',
translate,
describe,
expression,
doc,
attributeNames: ZH_ATTRIBUTE_NAMES,
}
}
function nodeSpan(node: Node): SourceSpan {
return { startIndex: node.startIndex, endIndex: node.endIndex, startRow: node.startPosition.row, endRow: node.endPosition.row }
}
function segment(text: string, role: TokenRole, offset: number | undefined): Segment {