✨ feat(vscode): 代码阅读支持库规则,内置 SQLAlchemy 模型类显示成数据表卡片
This commit is contained in:
1 parent
32be7d54b6
commit
5ece24cef9
26 files changed
+1768
-81
No files matched your search
@@ -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 })),
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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, 末张可拆)"},
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user