✨ feat(vscode): 代码阅读加 Celery 库规则,定时任务表里的任务名能跳到任务函数

This commit is contained in:
cruldra committed 2026-09-26 19:41:07 +08:00
1 parent 905d7845b5
commit 68104e2cba
22 files changed
+2228 -139

No files matched your search

+378
View File
@@ -0,0 +1,378 @@
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, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { WorkspaceIndexes } from '../workspaceIndex'
import { celeryRule } from './celery'
const py = createPythonAdapter(WASM_DIR)
const SNAPSHOT = readFixture('celeryApp.py')
async function views(source: string, reports: string[] = []): Promise<BlockView[]> {
return withBlocks(py, 'python', source, blocks =>
toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [celeryRule], report: message => reports.push(message) }))
}
function properties(view: BlockView | undefined): PropertyRow[] {
if (!view || !('properties' in view))
throw new Error(`块 ${view?.id} 不是属性表`)
return view.properties
}
async function card(source: string, kind: string): Promise<BlockView> {
const found = (await views(source)).find(view => view.kind === kind)
if (!found)
throw new Error(`没有 ${kind} 卡片`)
return found
}
/** 任务表每一行写成文字。 */
async function beatLines(entries: string, header = 'from celery import Celery\nfrom celery.schedules import crontab\nfrom datetime import timedelta\n\napp = Celery("x")\n'): Promise<string[]> {
return propertyLines(properties(await card(`${header}app.conf.beat_schedule = {\n${entries}\n}\n`, 'celery.beat'))).slice(2)
}
/** 属性表里所有的词(连表格里的格子)。 */
function segmentsOf(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)
}
describe('celery 库规则:应用', () => {
it('luffy 的 celery_app:名称 / 应用名 / 消息队列 / 结果存储', async () => {
const app = await card(SNAPSHOT, 'celery.app')
expect(app.badge).toEqual({ label: 'Celery 应用', color: { dark: '#98c379', light: '#4d7f17' } })
expect(propertyLines(properties(app))).toEqual([
'名称: celery_app',
'应用名: "luffy_agent"',
'消息队列: settings.CELERY_BROKER_URL',
'结果存储: settings.CELERY_RESULT_BACKEND',
])
expect(SNAPSHOT.slice(app.defines?.startIndex, app.defines?.endIndex)).toBe('celery_app')
expect(app.code.split('\n')[0]).toBe('celery_app = Celery(')
expect(app.attributes).toMatchObject({ kind: 'celery.app', access: 'public' })
})
it('位置参数按 Celery 的参数顺序认,别的参数各一行、行名是参数名,展开的写「展开」,属性 docstring 是说明;别名换回原名', async () => {
const source = [
'import celery',
'from celery import Celery as App',
'',
'app = App("proj", None, "redis://result", include=["proj.tasks"], broker="redis://queue", **extra)',
'"""项目的 Celery 应用。"""',
'other = celery.Celery(main="other")',
].join('\n')
const [first, second] = (await views(source)).filter(view => view.kind === 'celery.app')
expect(propertyLines(properties(first))).toEqual([
'名称: app',
'说明: 项目的 Celery 应用。',
'应用名: "proj"',
'消息队列: "redis://queue"',
'结果存储: "redis://result"',
'loader: 空',
'include: ["proj.tasks"]',
'展开: extra',
])
expect(propertyLines(properties(second))).toEqual(['名称: other', '应用名: "other"'])
})
it('没导入 celery 的文件、不是 Celery(...) 的赋值不认', async () => {
expect((await views('from other import Celery\napp = Celery("x")\n')).map(view => view.kind)).toEqual(['import', 'variable'])
expect((await views('import celery\napp = make_app()\n')).map(view => view.kind)).toEqual(['import', 'variable'])
})
})
describe('celery 库规则:定时任务', () => {
it('luffy 的 beat_schedule:应用、任务表,频率换算成人话,说明取注释,展开另起一行', async () => {
const beat = await card(SNAPSHOT, 'celery.beat')
expect(beat.badge).toEqual({ label: '定时任务', color: { dark: '#56b6c2', light: '#127f8c' } })
expect(propertyLines(properties(beat))).toEqual([
'应用: celery_app',
'任务: 12 条,另有展开 1 处',
' commerce.cancel_timeout_pending_orders | 每 5 分钟 | ',
' commerce.retry_failed_fulfillments | 每 10 分钟 | ',
' commerce.check_pending_refunds | 每 30 分钟 | ',
' commerce.check_channel_pending_manual_slices | 每 10 分钟 | 手动退款切片对账: 微信回调丢失兜底',
' deployment.reconcile_inflight | 每 5 分钟 | 发布管线 in-flight 行卡死兜底: 按阶段重新派发',
' deployment.reclaim_expired_slot_deployments | 每 10 分钟 | 到期部署位对账: 换票或拆除 (#159)',
' deployment.notify_expiring_deployments | 每天 | 每天一轮到期提醒扫描 (#274)',
' deployment.sweep_terminal_garbage | 每 10 分钟 | 终态垃圾兜底: failed 行 teardown 重试 + OSS 败方补清 (#320)',
' session.prune_langgraph_checkpoints | 每 5 分钟 | 高频小批量 sweep: 单轮 ≤200 thread + 批间限速 + 270s 预算, 防磁盘吃满 与清理 IO 风暴 (#316); 频率对齐 LangGraph Platform TTL sweeper 量级',
' referral.settle_due_commissions | 每小时 | 每小时扫一次到期佣金',
' membership.grant_monthly_entitlements | 每小时 | ',
' storage.poll_app_data_usage | 每 get_settings().STORAGE_USAGE_POLL_INTERVAL_SEC 秒 | ',
' 展开 | _ops_beat_entries(settings.OPS_DASHBOARD_ENABLED) | ',
])
const rows = properties(beat)
expect(rows[1].columns).toEqual(['任务', '频率', '说明'])
expect(rows[1].value).toEqual([{ text: '12 条,另有展开 1 处', role: 'hint' }])
const [task, frequency, note] = rows[1].items?.[3].cells ?? []
expect(frequency).toEqual([{ text: '每 10 分钟', role: 'keyword' }])
expect(note).toEqual([{ text: '手动退款切片对账: 微信回调丢失兜底', role: 'comment' }])
expect(task).toEqual([{
text: 'commerce.check_channel_pending_manual_slices',
role: 'string',
offset: SNAPSHOT.indexOf('commerce.check_channel_pending_manual_slices",\n "schedule"'),
reference: { rule: 'celery', key: 'commerce.check_channel_pending_manual_slices' },
}])
expect(rows[1].items?.[11].cells[1]).toMatchObject([
{ text: '每 ', role: 'keyword' },
{ text: 'get_settings', role: 'function' },
{ text: '().' },
{ text: 'STORAGE_USAGE_POLL_INTERVAL_SEC', role: 'variable' },
{ text: ' 秒', role: 'keyword' },
])
expect(beat.code.split('\n')[0]).toBe('celery_app.conf.beat_schedule = {')
expect(beat.endLine - beat.startLine).toBe(SNAPSHOT.split('\n').indexOf('}') - SNAPSHOT.split('\n').indexOf('celery_app.conf.beat_schedule = {'))
})
it('频率:秒数算式、timedelta、crontab、schedule,换算不了的秒数表达式写「每 X 秒」,别的照写法', async () => {
const schedules = [
'5 * 60',
'10 * 60',
'60 * 60',
'24 * 60 * 60',
'15',
'90',
'0.5',
'7 * 24 * 3600',
'crontab(hour=4, minute=0)',
'crontab(minute="*/15")',
'crontab(hour="*/2", minute=0)',
'crontab(minute=30, hour="*/3")',
'crontab()',
'crontab(0, 0)',
'crontab(minute=0, hour=[4, 16])',
'crontab(minute="0,30", hour=9)',
'crontab(minute=30, hour=7, day_of_week="mon-fri")',
'crontab(minute=0, hour=9, day_of_week="sat,sun")',
'crontab(minute=0, hour=0, day_of_week=1)',
'crontab(minute=0, hour=3, day_of_month="1")',
'crontab(minute=0, hour=0, day_of_month="1-7")',
'crontab(minute=0, hour=0, day_of_month=1, month_of_year="1")',
'crontab(minute=0, day_of_week="mon")',
'crontab(hour=4)',
'crontab(minute="1-10/2")',
'timedelta(minutes=5)',
'timedelta(hours=2)',
'timedelta(1)',
'timedelta(seconds=30)',
'timedelta(minutes=settings.X)',
'schedule(run_every=timedelta(hours=6))',
'get_settings().X',
'INTERVAL',
'my_schedule()',
]
const lines = await beatLines(schedules.map((schedule, i) => ` "t${i}": {"task": "t${i}", "schedule": ${schedule}},`).join('\n'))
expect(lines.map(line => line.split(' | ')[1])).toEqual([
'每 5 分钟',
'每 10 分钟',
'每小时',
'每天',
'每 15 秒',
'每 90 秒',
'每 0.5 秒',
'每 7 天',
'每天 04:00',
'每 15 分钟',
'每 2 小时整点',
'每 3 小时的第 30 分',
'每分钟',
'每天 00:00',
'每天 04:00、16:00',
'每天 09:00、09:30',
'每周一至周五 07:30',
'每周六、周日 09:00',
'每周一 00:00',
'每月 1 日 03:00',
'每月 1 至 7 日 00:00',
'每年 1 月 1 日 00:00',
'每周一 每小时整点',
'crontab(hour=4)',
'crontab(minute="1-10/2")',
'每 5 分钟',
'每 2 小时',
'每天',
'每 30 秒',
'timedelta(minutes=settings.X)',
'每 6 小时',
'每 get_settings().X 秒',
'每 INTERVAL 秒',
'my_schedule()',
])
})
it('说明:条目里的注释、条目行尾的注释、紧挨在上面的注释;最后一条后面单独的注释不算', async () => {
const lines = await beatLines([
' "a": {"task": "a", "schedule": 60}, # 行尾',
' # 上面一行',
' "b": {',
' "task": "b",',
' # 里面',
' "schedule": 60, # 调度行尾',
' },',
' "c": {"task": "c", "schedule": 60},',
' # 收尾',
].join('\n'))
expect(lines).toEqual([
' a | 每分钟 | 行尾',
' b | 每分钟 | 上面一行 里面 调度行尾',
' c | 每分钟 | ',
])
})
it('键和任务名不一样时键淡色跟在后面;没写任务名、任务名不是字符串、值不是字典的照样一行;选项有才多一列', async () => {
const beat = await card([
'from celery import Celery',
'app = Celery("x")',
'app.conf.beat_schedule = {',
' "nightly-report": {"task": "reports.build", "schedule": 60, "args": (1, 2), "options": {"expires": 14}},',
' "no-task": {"schedule": 60},',
' "named": {"task": TASK_NAME, "schedule": 60},',
' "built": make_entry("x"),',
' **extra,',
'}',
].join('\n'), 'celery.beat')
const rows = properties(beat)
expect(rows[1].columns).toEqual(['任务', '频率', '选项', '说明'])
expect(propertyLines(rows).slice(1)).toEqual([
'任务: 4 条,另有展开 1 处',
' reports.build(条目名 nightly-report) | 每分钟 | 位置参数 (1, 2) · 执行选项 {"expires": 14} | ',
' 没写任务名(条目名 no-task) | 每分钟 | | ',
' TASK_NAME(条目名 named) | 每分钟 | | ',
' built | make_entry("x") | | ',
' 展开 | extra | | ',
])
const [task] = rows[1].items?.[0].cells ?? []
expect(task).toMatchObject([{ text: 'reports.build', reference: { rule: 'celery', key: 'reports.build' } }, { text: '(条目名 nightly-report)', role: 'hint' }])
const references = segmentsOf(rows).filter(segment => segment.reference)
expect(references.map(segment => segment.reference?.key)).toEqual(['reports.build'])
})
it('conf.update(beat_schedule=...):也是定时任务卡片,别的配置各一行', async () => {
const beat = await card([
'from celery import Celery',
'app = Celery("x")',
'app.conf.update(timezone="UTC", beat_schedule={"a": {"task": "a", "schedule": 3600}})',
'app.conf.update(timezone="UTC")',
].join('\n'), 'celery.beat')
expect(propertyLines(properties(beat))).toEqual([
'应用: app',
'timezone: "UTC"',
'任务: 1 条',
' a | 每小时 | ',
])
const list = await views('from celery import Celery\napp = Celery("x")\napp.conf.update(timezone="UTC")\napp.autodiscover_tasks(["x"])\n')
expect(list.map(view => view.kind)).toEqual(['import', 'celery.app', 'statement', 'statement'])
})
it('任务名、应用名等名字带下标、指回源码原文;卡片默认折叠用的 badge 有,编辑范围是整条语句', async () => {
const beat = await card(SNAPSHOT, 'celery.beat')
for (const segment of segmentsOf(properties(beat)).filter(piece => piece.offset !== undefined))
expect(SNAPSHOT.slice(segment.offset, (segment.offset ?? 0) + segment.text.length)).toBe(segment.text)
})
})
describe('celery 库规则:任务名 → 定义', () => {
const TASKS = [
'from luffy_agent.celery_app import celery_app',
'',
'',
'@celery_app.task(name="commerce.cancel_timeout_pending_orders")',
'def cancel_timeout_pending_orders_task() -> None:',
' """celery 入口: 关闭 20 分钟以上未付款的订单."""',
'',
'',
'@celery_app.task',
'def plain_task():',
' pass',
'',
'',
'@celery_app.task(bind=True)',
'async def bound_task(self):',
' pass',
'',
'',
'@celery_app.task(name=TASK_NAME)',
'def unknown_name():',
' pass',
'',
'',
'def outer():',
' @celery_app.task',
' def inner():',
' pass',
'',
' @celery_app.task(name="nested.explicit")',
' def explicit():',
' pass',
'',
].join('\n')
function workspace(extra: Record<string, string> = {}): { host: MemoryIndexHost, indexes: WorkspaceIndexes } {
const host = new MemoryIndexHost({
'backend/src/luffy_agent/__init__.py': '',
'backend/src/luffy_agent/commerce/__init__.py': '',
'backend/src/luffy_agent/commerce/tasks.py': TASKS,
'backend/src/luffy_agent/ops/tasks.py': 'from celery import shared_task\n\n@shared_task\ndef sample():\n pass\n',
'worker/jobs.py': 'from celery import shared_task\n\n@shared_task()\ndef cleanup():\n pass\n',
'pkg/__init__.py': '',
'pkg/sub/__init__.py': '',
'pkg/sub/mod.py': 'from celery.decorators import task\n\n@task\ndef legacy():\n pass\n',
...extra,
}, py)
return { host, indexes: new WorkspaceIndexes(host) }
}
async function resolve(indexes: WorkspaceIndexes, key: string): Promise<string[]> {
const found = await celeryRule.resolve?.(key, indexes) ?? []
return found.map(({ uri, start, end }) => `${uri.replace(`${MemoryIndexHost.FOLDER}/`, '')}:${start.line}:${start.character}-${end.character}`)
}
it('装饰器的 name= 是任务名,定义处是 def 那一行的函数名', async () => {
const { indexes } = workspace()
expect(await resolve(indexes, 'commerce.cancel_timeout_pending_orders')).toEqual(['backend/src/luffy_agent/commerce/tasks.py:4:4-38'])
})
it('没写 name= 是「模块路径.函数名」:往上找 __init__.py,src 下面的命名空间包从 src 下面算', async () => {
const { indexes } = workspace()
expect(await resolve(indexes, 'luffy_agent.commerce.tasks.plain_task')).toEqual(['backend/src/luffy_agent/commerce/tasks.py:9:4-14'])
expect(await resolve(indexes, 'luffy_agent.commerce.tasks.bound_task')).toEqual(['backend/src/luffy_agent/commerce/tasks.py:14:10-20'])
expect(await resolve(indexes, 'luffy_agent.ops.tasks.sample')).toEqual(['backend/src/luffy_agent/ops/tasks.py:3:4-10'])
expect(await resolve(indexes, 'jobs.cleanup')).toEqual(['worker/jobs.py:3:4-11'])
expect(await resolve(indexes, 'pkg.sub.mod.legacy')).toEqual(['pkg/sub/mod.py:3:4-10'])
})
it('函数里的任务只认写了 name= 的;name= 不是字符串的认不出来', async () => {
const { indexes } = workspace()
expect(await resolve(indexes, 'nested.explicit')).toEqual(['backend/src/luffy_agent/commerce/tasks.py:29:8-16'])
expect(await resolve(indexes, 'luffy_agent.commerce.tasks.inner')).toEqual([])
expect(await resolve(indexes, 'luffy_agent.commerce.tasks.unknown_name')).toEqual([])
})
it('几处定义都给出来;文件改了跟着变', async () => {
const { host, indexes } = workspace({ 'other/dup.py': '@app.task(name="commerce.cancel_timeout_pending_orders")\ndef dup():\n pass\n' })
expect(await resolve(indexes, 'commerce.cancel_timeout_pending_orders')).toEqual([
'backend/src/luffy_agent/commerce/tasks.py:4:4-38',
'other/dup.py:1:4-7',
])
host.emit('change', 'other/dup.py', '@app.task(name="renamed")\ndef dup():\n pass\n')
expect(await resolve(indexes, 'commerce.cancel_timeout_pending_orders')).toEqual(['backend/src/luffy_agent/commerce/tasks.py:4:4-38'])
expect(await resolve(indexes, 'renamed')).toEqual(['other/dup.py:1:4-7'])
host.emit('create', 'backend/src/luffy_agent/ops/__init__.py', '')
expect(await resolve(indexes, 'luffy_agent.ops.tasks.sample')).toEqual(['backend/src/luffy_agent/ops/tasks.py:3:4-10'])
})
it('没有任务装饰器的文件不解析', async () => {
const { host, indexes } = workspace({ 'plain.py': 'import luffy_agent.commerce.tasks\n' })
await resolve(indexes, 'x')
expect(host.reports).toEqual([])
expect(host.reads).toContain('plain.py')
})
})
+639
View File
@@ -0,0 +1,639 @@
import type { IndexedFile, IndexEntry, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
import type { Call } from './pythonTree'
import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
/**
* Celery 库规则(Python),文件导入了 celery 才启用:
* - 创建应用 `X = Celery(...)` → 「Celery 应用」卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数各一行、行名就是参数名。
* - 定时任务表 `X.conf.beat_schedule = {...}`、`X.conf.update(beat_schedule={...})` → 「定时任务」卡片:应用 + 任务表(任务 / 频率 / 选项 / 说明)。
* 频率按调度写法换算成人话(秒数算式、timedelta、crontab),说明取条目里、紧挨着条目的 # 注释。
* - 任务表里的任务名是规则自己查定义的引用:在整个工作区里找 `@….task`、`@shared_task`、`@task` 装饰的函数,
* 任务名取装饰器的 name=,没写就是 Celery 默认的「模块路径.函数名」。
* 规则只经 contract 拿信息,直接读的是 Python 语法树;用词都在词表里,按翻译插件的显示语言挑。
*/
const RULE_ID = 'celery'
const LIBRARIES = ['celery']
/** 调度写法里的 crontab、timedelta 可能带着导入时起的别名。 */
const SCHEDULE_LIBRARIES = ['celery', 'datetime']
const APP_KIND = 'celery.app'
const BEAT_KIND = 'celery.beat'
const APP_COLOR: ThemeColor = { dark: '#98c379', light: '#4d7f17' }
const BEAT_COLOR: ThemeColor = { dark: '#56b6c2', light: '#127f8c' }
/** `Celery(...)` 的位置参数依次是哪个参数(celery.app.base.Celery.__init__)。 */
const APP_PARAMETERS = ['main', 'loader', 'backend', 'amqp', 'events', 'log', 'control', 'set_as_current', 'tasks', 'broker', 'include', 'changes', 'config_source', 'fixups', 'task_cls', 'autofinalize', 'namespace', 'strict_typing']
/** 有专门行名的应用参数,按这个顺序排在别的参数前面。 */
const APP_NAMED = ['main', 'broker', 'backend'] as const
/** 条目字典里讲任务名、调度的键;别的键(args、kwargs、options……)都是选项。 */
const TASK_KEY = 'task'
const SCHEDULE_KEY = 'schedule'
/** timedelta 的参数依次是哪个单位、一个单位多少秒。 */
const TIMEDELTA_UNITS: [string, number][] = [['days', 86400], ['seconds', 1], ['microseconds', 1e-6], ['milliseconds', 1e-3], ['minutes', 60], ['hours', 3600], ['weeks', 604800]]
/** crontab 的位置参数依次是哪个字段。 */
const CRONTAB_FIELDS = ['minute', 'hour', 'day_of_week', 'day_of_month', 'month_of_year']
/** crontab 的 day_of_week 能写的英文名(前三个字母),下标就是它的数字,0 是周日。 */
const WEEKDAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
/** 写成秒数的调度可能长这样:名字、属性、下标、算式(`每 X 秒`);调用等别的写法照写法翻。 */
const NUMBER_LIKE = new Set(['identifier', 'attribute', 'subscript', 'binary_operator', 'unary_operator', 'parenthesized_expression', 'integer', 'float'])
// ─── 频率:换算出来的结构,词表按它说成人话 ─────────────────────────────────────
type Unit = 'second' | 'minute' | 'hour' | 'day'
/** crontab 字段里的一段取值:一个数,或 from 到 to。 */
interface Span { from: number, to?: number }
/** 哪些天:每天、每周几、每月几号、每年几月几号。 */
type Days
= | { kind: 'daily' }
| { kind: 'weekly', days: Span[] }
| { kind: 'monthly', days: Span[] }
| { kind: 'yearly', months: Span[], days: Span[] }
/** 一天里什么时候:每 step 分钟、每 step 小时的第几分、几点几分。 */
type Time
= | { kind: 'minutes', step: number }
| { kind: 'hourly', step: number, minutes: number[] }
| { kind: 'at', times: { hour: number, minute: number }[] }
type Frequency
= | { kind: 'interval', count: number, unit: Unit }
| { kind: 'crontab', days: Days, time: Time }
// ─── 用词 ─────────────────────────────────────────────────────────────────────
/** 一种显示语言的用词。 */
interface Words {
appKind: string
beatKind: string
labels: Record<'name' | 'doc' | 'main' | 'broker' | 'backend' | 'app' | 'tasks', string>
/** `**x` 展开的条目、参数 */
spread: string
heads: Record<'task' | 'frequency' | 'options' | 'doc', string>
/** 任务表那一行的值:几条,另有几处展开 */
entries: (count: number, spreads: number) => string
/** 条目的键和任务名不一样时跟在任务名后面 */
entryKey: (key: string) => string
noTask: string
/** 条目里的选项键 → 名字;表里没有的写键本身 */
options: Record<string, string>
/** 调度是个写成秒数的表达式时前后的字:「每 X 秒」 */
everySeconds: [string, string]
frequency: (frequency: Frequency) => string
}
const ZH_UNITS: Record<Unit, [string, string]> = { second: ['每秒', '秒'], minute: ['每分钟', '分钟'], hour: ['每小时', '小时'], day: ['每天', '天'] }
const ZH_WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function zhSpans(spans: Span[], one: (n: number) => string, between: (from: number, to: number) => string): string {
return spans.map(span => span.to === undefined ? one(span.from) : between(span.from, span.to)).join('、')
}
function zhDays(days: Days): string {
const weekday = (n: number): string => ZH_WEEKDAYS[n % 7]
const day = (n: number): string => `${n} 日`
const dayRange = (from: number, to: number): string => `${from} 至 ${to} 日`
switch (days.kind) {
case 'daily':
return '每天'
case 'weekly':
return `每${zhSpans(days.days, weekday, (from, to) => `${weekday(from)}至${weekday(to)}`)}`
case 'monthly':
return `每月 ${zhSpans(days.days, day, dayRange)}`
case 'yearly':
return `每年 ${zhSpans(days.months, n => `${n} 月`, (from, to) => `${from} 至 ${to} 月`)} ${zhSpans(days.days, day, dayRange)}`
}
}
function zhTime(time: Time): string {
const pad = (n: number): string => String(n).padStart(2, '0')
switch (time.kind) {
case 'minutes':
return time.step === 1 ? '每分钟' : `每 ${time.step} 分钟`
case 'hourly': {
const every = time.step === 1 ? '每小时' : `每 ${time.step} 小时`
return time.minutes.length === 1 && time.minutes[0] === 0 ? `${every}整点` : `${every}的第 ${time.minutes.join('、')} 分`
}
case 'at':
return time.times.map(({ hour, minute }) => `${pad(hour)}:${pad(minute)}`).join('、')
}
}
const ZH: Words = {
appKind: 'Celery 应用',
beatKind: '定时任务',
labels: { name: '名称', doc: '说明', main: '应用名', broker: '消息队列', backend: '结果存储', app: '应用', tasks: '任务' },
spread: '展开',
heads: { task: '任务', frequency: '频率', options: '选项', doc: '说明' },
entries: (count, spreads) => spreads > 0 ? `${count} 条,另有展开 ${spreads} 处` : `${count} 条`,
entryKey: key => `(条目名 ${key})`,
noTask: '没写任务名',
options: { args: '位置参数', kwargs: '关键字参数', options: '执行选项' },
everySeconds: ['每 ', ' 秒'],
frequency: (frequency) => {
if (frequency.kind === 'interval') {
const [one, unit] = ZH_UNITS[frequency.unit]
return frequency.count === 1 ? one : `每 ${frequency.count} ${unit}`
}
const { days, time } = frequency
if (time.kind === 'at')
return `${zhDays(days)} ${zhTime(time)}`
return days.kind === 'daily' ? zhTime(time) : `${zhDays(days)} ${zhTime(time)}`
},
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
const WORDS: Record<string, Words> = { zh: ZH }
/** 认一块要用的:宿主给的 context、挑好的用词、别名表。 */
interface Env {
context: RuleContext
words: Words
aliases: Map<string, string>
}
// ─── 频率:从调度写法换算 ──────────────────────────────────────────────────────
/** 只由数字字面量和 + - * / // % ** 组成的算式的值;带名字、调用的算不出来。 */
function numberOf(node: Node | null | undefined): number | undefined {
switch (node?.type) {
case 'integer':
case 'float': {
const value = Number(node.text.replace(/_/g, ''))
return Number.isFinite(value) ? value : undefined
}
case 'parenthesized_expression': {
const inner = syntaxChildren(node)
return inner.length === 1 ? numberOf(inner[0]) : undefined
}
case 'unary_operator': {
const value = numberOf(node.childForFieldName('argument'))
const operator = node.childForFieldName('operator')?.type
return value === undefined ? undefined : operator === '-' ? -value : operator === '+' ? value : undefined
}
case 'binary_operator': {
const left = numberOf(node.childForFieldName('left'))
const right = numberOf(node.childForFieldName('right'))
if (left === undefined || right === undefined)
return undefined
switch (node.childForFieldName('operator')?.type) {
case '+': return left + right
case '-': return left - right
case '*': return left * right
case '/': return right === 0 ? undefined : left / right
case '//': return right === 0 ? undefined : Math.floor(left / right)
case '%': return right === 0 ? undefined : left % right
case '**': return left ** right
default: return undefined
}
}
default:
return undefined
}
}
/** 秒数换成最大的整单位:86400 → 每天,7200 → 每 2 小时,90 → 每 90 秒。 */
function interval(seconds: number): Frequency | undefined {
if (!Number.isFinite(seconds) || seconds <= 0)
return undefined
for (const [unit, size] of [['day', 86400], ['hour', 3600], ['minute', 60]] as const) {
if (Number.isInteger(seconds / size))
return { kind: 'interval', count: seconds / size, unit }
}
return { kind: 'interval', count: seconds, unit: 'second' }
}
/** `timedelta(...)` 一共多少秒;有参数算不出来就没有。 */
function timedeltaSeconds(call: Call): number | undefined {
if (call.splats.length > 0)
return undefined
const units = new Map(TIMEDELTA_UNITS)
const parts: [Node, number | undefined][] = [
...call.positional.map((arg, i): [Node, number | undefined] => [arg, TIMEDELTA_UNITS[i]?.[1]]),
...[...call.keywords].map(([key, value]): [Node, number | undefined] => [value, units.get(key)]),
]
let total = 0
for (const [node, size] of parts) {
const value = numberOf(node)
if (value === undefined || size === undefined)
return undefined
total += value * size
}
return total
}
/** crontab 的一个字段:*、*\/n、一串数(逗号隔开,可以是 a-b 一段);列表 / 集合 / 元组里的整数也算一串数。 */
type Field = { kind: 'any' } | { kind: 'step', step: number } | { kind: 'spans', spans: Span[] }
function parseField(text: string, names: readonly string[] | undefined): Field | undefined {
if (text === '*')
return { kind: 'any' }
const step = /^\*\/(\d+)$/.exec(text)
if (step)
return Number(step[1]) > 0 ? { kind: 'step', step: Number(step[1]) } : undefined
const value = (part: string): number | undefined => {
if (/^\d+$/.test(part))
return Number(part)
const index = names?.indexOf(part.slice(0, 3)) ?? -1
return part.length >= 3 && index >= 0 ? index : undefined
}
const spans: Span[] = []
for (const part of text.split(',')) {
const [from, to, ...rest] = part.trim().split('-')
const start = value(from)
const end = to === undefined ? undefined : value(to)
if (rest.length > 0 || start === undefined || (to !== undefined && end === undefined))
return undefined
spans.push(end === undefined ? { from: start } : { from: start, to: end })
}
return { kind: 'spans', spans }
}
function fieldOf(node: Node | undefined, names?: readonly string[]): Field | undefined {
if (!node)
return { kind: 'any' }
if (node.type === 'integer')
return parseField(node.text, undefined)
if (node.type === 'list' || node.type === 'set' || node.type === 'tuple') {
const items = syntaxChildren(node)
return items.length > 0 && items.every(item => item.type === 'integer') ? parseField(items.map(item => item.text).join(','), undefined) : undefined
}
const text = stringValue(node)
return text === undefined ? undefined : parseField(text.trim().toLowerCase(), names)
}
/** 一个个单独的数(没有 a-b 一段),都在 0..max 里。 */
function singles(field: Field, max: number): number[] | undefined {
if (field.kind !== 'spans' || field.spans.some(span => span.to !== undefined || span.from > max))
return undefined
return field.spans.map(span => span.from).sort((a, b) => a - b)
}
/** 取值都在 min..max 里的几段。 */
function spansWithin(field: Field, min: number, max: number): Span[] | undefined {
if (field.kind !== 'spans' || field.spans.some(span => span.from < min || (span.to ?? span.from) > max))
return undefined
return field.spans
}
function timeOf(minute: Field, hour: Field): Time | undefined {
if (hour.kind === 'any' && minute.kind === 'any')
return { kind: 'minutes', step: 1 }
if (hour.kind === 'any' && minute.kind === 'step')
return { kind: 'minutes', step: minute.step }
const minutes = singles(minute, 59)
if (!minutes)
return undefined
if (hour.kind === 'any')
return { kind: 'hourly', step: 1, minutes }
if (hour.kind === 'step')
return { kind: 'hourly', step: hour.step, minutes }
const hours = singles(hour, 23)
return hours && { kind: 'at', times: hours.flatMap(h => minutes.map(m => ({ hour: h, minute: m }))) }
}
function daysOf(week: Field, month: Field, year: Field): Days | undefined {
const any = (field: Field): boolean => field.kind === 'any'
if (any(week) && any(month) && any(year))
return { kind: 'daily' }
if (any(month) && any(year)) {
const days = spansWithin(week, 0, 7)
return days && { kind: 'weekly', days }
}
if (!any(week))
return undefined
const days = spansWithin(month, 1, 31)
if (!days)
return undefined
if (any(year))
return { kind: 'monthly', days }
const months = spansWithin(year, 1, 12)
return months && { kind: 'yearly', months, days }
}
function crontabFrequency(call: Call): Frequency | undefined {
if (call.splats.length > 0)
return undefined
const fields = new Map<string, Node>()
call.positional.forEach((arg, i) => fields.set(CRONTAB_FIELDS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
fields.set(key, value)
if (call.positional.length > CRONTAB_FIELDS.length)
return undefined
const [minute, hour, week, month, year] = CRONTAB_FIELDS.map(field => fieldOf(fields.get(field), field === 'day_of_week' ? WEEKDAY_NAMES : undefined))
if (!minute || !hour || !week || !month || !year)
return undefined
const days = daysOf(week, month, year)
const time = timeOf(minute, hour)
return days && time && { kind: 'crontab', days, time }
}
/** 调度写法换算成频率:秒数算式、timedelta、crontab、schedule(run_every=…);换算不了的没有。 */
function frequencyOf(node: Node, aliases: ReadonlyMap<string, string>): Frequency | undefined {
const seconds = numberOf(node)
if (seconds !== undefined)
return interval(seconds)
const call = callOf(node, aliases)
switch (call?.name) {
case 'timedelta': {
const total = timedeltaSeconds(call)
return total === undefined ? undefined : interval(total)
}
case 'crontab':
return crontabFrequency(call)
case 'schedule': {
const every = call.keywords.get('run_every') ?? call.positional[0]
return every && frequencyOf(every, aliases)
}
default:
return undefined
}
}
/** 频率一格:换算得出的说成人话;写成秒数的表达式写「每 X 秒」;别的照写法翻。 */
function frequencyCell(node: Node, env: Env): Inline[] {
const frequency = frequencyOf(node, env.aliases)
if (frequency)
return [{ text: env.words.frequency(frequency), role: 'keyword' }]
if (!NUMBER_LIKE.has(node.type))
return env.context.translate(node)
const [before, after] = env.words.everySeconds
return [{ text: before, role: 'keyword' }, ...env.context.translate(node), { text: after, role: 'keyword' }]
}
// ─── 定时任务表 ───────────────────────────────────────────────────────────────
/** 定时任务表里的一条:`"键": {...}`(或别的值),或 `**x` 展开;comments 是讲它的注释。 */
interface Entry {
node: Node
key?: Node
value: Node
spread: boolean
comments: Node[]
}
/**
* 字典的各条,照源码顺序。讲一条的注释:写在它里面的、写在它最后一行行尾的、紧挨在它上面单独成行的;
* 最后一条后面单独成行的注释不讲哪一条。
*/
function entriesOf(dictionary: Node): Entry[] {
const entries: Entry[] = []
let pending: Node[] = []
for (const child of dictionary.namedChildren) {
if (!child)
continue
if (child.type === 'comment') {
const last = entries.at(-1)
if (last && pending.length === 0 && child.startPosition.row === last.node.endPosition.row)
last.comments.push(child)
else
pending.push(child)
continue
}
const spread = child.type === 'dictionary_splat'
const key = child.type === 'pair' ? child.childForFieldName('key') : null
const value = spread ? syntaxChildren(child)[0] : child.childForFieldName('value')
if (value && (spread || key)) {
const inside = spread ? [] : child.descendantsOfType('comment').filter((comment): comment is Node => comment !== null)
entries.push({ node: child, key: key ?? undefined, value, spread, comments: [...pending, ...inside] })
}
pending = []
}
return entries
}
/** 条目字典里的一项:键是字符串时 name 是它的内容。 */
interface Option { name?: string, key?: Node, value: Node }
function optionsOf(dictionary: Node): Option[] {
return syntaxChildren(dictionary).flatMap((child): Option[] => {
if (child.type === 'dictionary_splat')
return syntaxChildren(child).slice(0, 1).map(value => ({ value }))
const key = child.childForFieldName('key')
const value = child.childForFieldName('value')
return child.type === 'pair' && key && value ? [{ name: stringValue(key), key, value }] : []
})
}
/** 条目里除了任务名、调度的项(args、kwargs、options……)。 */
function extraOptions(entry: Entry): Option[] {
if (entry.spread || entry.value.type !== 'dictionary')
return []
return optionsOf(entry.value).filter(option => option.name !== TASK_KEY && option.name !== SCHEDULE_KEY)
}
/** 字符串照写的内容(不带引号),带源码下标;别的写法交给翻译插件。 */
function stringOr(node: Node, env: Env, extra: Partial<Segment> = {}): Inline[] {
const content = stringContent(node)
return content?.node ? [{ text: content.text, role: 'string', offset: content.node.startIndex, ...extra }] : env.context.translate(node)
}
/** 任务名一格:字符串写的任务名是规则自己能查定义的引用;条目的键和任务名不一样时键跟在后面(淡色)。 */
function taskCell(entry: Entry, task: Node | undefined, env: Env): Inline[] {
const taskName = stringContent(task)
const cell: Inline[] = task
? stringOr(task, env, taskName?.node ? { reference: { rule: RULE_ID, key: taskName.text } } : {})
: [hint(env.words.noTask)]
const key = entry.key && stringValue(entry.key)
return key !== undefined && key !== taskName?.text ? [...cell, hint(env.words.entryKey(key))] : cell
}
function optionsCell(options: Option[], env: Env): Inline[] {
const { words, context } = env
return joined(options.map(({ name: optionName, key, value }) => {
const label = optionName === undefined ? (key ? context.translate(key) : [hint(words.spread)]) : [word(words.options[optionName] ?? optionName)]
return [...label, plain(' '), ...context.translate(value)]
}), hint(' · '))
}
/** 注释的原文(翻译插件去掉 #),几条连成一段。 */
function commentText(comments: Node[], env: Env): Inline[] {
const text = comments.map(comment => env.context.doc(comment)?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
}
/** 任务表的一行:任务 | 频率 | (选项)| 说明。展开的条目写「展开」和展开的表达式;值不是字典的条目写键和值。 */
function entryItem(entry: Entry, withOptions: boolean, env: Env): PropertyItem {
const { context, words } = env
const row = (task: Inline[], frequency: Inline[], options: Inline[]): PropertyItem =>
({ cells: [task, frequency, ...(withOptions ? [options] : []), commentText(entry.comments, env)] })
if (entry.spread)
return row([hint(words.spread)], context.translate(entry.value), [])
if (entry.value.type !== 'dictionary')
return row(entry.key ? stringOr(entry.key, env) : [], context.translate(entry.value), [])
const options = optionsOf(entry.value)
const task = options.find(option => option.name === TASK_KEY)?.value
const schedule = options.find(option => option.name === SCHEDULE_KEY)?.value
return row(taskCell(entry, task, env), schedule ? frequencyCell(schedule, env) : [], optionsCell(extraOptions(entry), env))
}
/** `X.conf.<attribute>` 里的 X;不是这种写法就没有。 */
function appOfConf(node: Node | null | undefined, attribute: string): Node | undefined {
if (node?.type !== 'attribute' || node.childForFieldName('attribute')?.text !== attribute)
return undefined
const conf = node.childForFieldName('object')
if (conf?.type !== 'attribute' || conf.childForFieldName('attribute')?.text !== 'conf')
return undefined
return conf.childForFieldName('object') ?? undefined
}
/** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */
function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): RuleBlock {
const { words, context } = env
const { labels, heads } = words
const entries = entriesOf(schedule)
const spreads = entries.filter(entry => entry.spread).length
const withOptions = entries.some(entry => extraOptions(entry).length > 0)
const markdown = doc && context.doc(doc)
const rows: PropertyRow[] = [
{ label: labels.app, value: context.translate(app) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
...settings.map(([label, value]) => ({ label, value: context.translate(value) })),
{
label: labels.tasks,
value: [hint(words.entries(entries.length - spreads, spreads))],
columns: [heads.task, heads.frequency, ...(withOptions ? [heads.options] : []), heads.doc],
items: entries.map(entry => entryItem(entry, withOptions, env)),
},
]
return { kind: BEAT_KIND, badge: { label: words.beatKind, color: BEAT_COLOR }, rows, absorbs: [] }
}
// ─── Celery 应用 ──────────────────────────────────────────────────────────────
/** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleBlock {
const { words, context } = env
const { labels } = words
const parameters = new Map<string, Node>()
call.positional.forEach((arg, i) => parameters.set(APP_PARAMETERS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
parameters.set(key, value)
const markdown = doc && context.doc(doc)
const named = new Set<string>(APP_NAMED)
const rows: PropertyRow[] = [
{ label: labels.name, value: target.type === 'identifier' ? [name(target, 'variable')] : context.translate(target) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
...APP_NAMED.flatMap((key) => {
const value = parameters.get(key)
return value ? [{ label: labels[key], value: context.translate(value) }] : []
}),
...[...parameters].filter(([key]) => !named.has(key)).map(([key, value]) => ({ label: key, value: context.translate(value) })),
...call.splats.map(value => ({ label: words.spread, value: context.translate(value) })),
]
return {
kind: APP_KIND,
badge: { label: words.appKind, color: APP_COLOR },
rows,
...(target.type === 'identifier' && { defines: range(target) }),
absorbs: [],
}
}
// ─── 任务名 → 定义:工作区索引 ─────────────────────────────────────────────────
const TASK_DECORATORS = new Set(['task', 'shared_task'])
/**
* 文件的模块路径:从它所在的目录往上,有 `__init__.py` 的都是包;包外面还套着 `src` 目录时(命名空间包)从 `src` 下面算起。
* `backend/src/luffy_agent/commerce/tasks.py` → luffy_agent.commerce.tasks。
*/
function moduleOf(file: IndexedFile): string {
const directories = file.path.split('/')
const fileName = directories.pop() ?? ''
let start = directories.length
while (start > 0 && file.exists([...directories.slice(0, start), '__init__.py'].join('/')))
start--
const src = start > 0 ? directories.lastIndexOf('src', start - 1) : -1
if (src >= 0)
start = src + 1
const stem = fileName.replace(/\.py$/, '')
return [...directories.slice(start), ...(stem === '__init__' ? [] : [stem])].join('.')
}
/** 写在模块里(不在函数、类里面):Celery 默认的任务名只对这样的函数是「模块路径.函数名」。 */
function atModuleLevel(node: Node): boolean {
for (let parent = node.parent; parent; parent = parent.parent) {
if (parent.type === 'function_definition' || parent.type === 'class_definition')
return false
}
return true
}
/** 装饰器是 `@<任何东西>.task`、`@shared_task`、`@task`(带不带括号都算)时,给出任务名;name= 写的不是字符串就认不出来。 */
function taskNameOf(decorator: Node, fn: Node, module: string, moduleLevel: boolean): string | undefined {
const [expression] = syntaxChildren(decorator)
const call = expression?.type === 'call' ? expression : undefined
if (!TASK_DECORATORS.has(lastName(call ? call.childForFieldName('function') : expression) ?? ''))
return undefined
const explicit = callOf(call, new Map())?.keywords.get('name')
if (explicit)
return stringValue(explicit)
if (!moduleLevel)
return undefined
return module ? `${module}.${fn.text}` : fn.text
}
function taskEntries(root: Node, file: IndexedFile): IndexEntry[] {
const module = moduleOf(file)
return root.descendantsOfType('decorated_definition').flatMap((decorated): IndexEntry[] => {
const definition = decorated?.childForFieldName('definition')
const fn = definition?.type === 'function_definition' ? definition.childForFieldName('name') : null
if (!decorated || !fn)
return []
const moduleLevel = atModuleLevel(decorated)
const key = syntaxChildren(decorated)
.filter(child => child.type === 'decorator')
.map(decorator => taskNameOf(decorator, fn, module, moduleLevel))
.find(found => found !== undefined)
return key ? [{ key, name: range(fn) }] : []
})
}
/** 整个工作区里的 Celery 任务:任务名 → 定义处的函数名。任务文件常常只导入自己的应用实例,不要求导入 celery。 */
const TASK_INDEX: WorkspaceIndex = {
id: 'celery.tasks',
languageId: 'python',
include: '**/*.py',
mentions: source => /\.task\b|\bshared_task\b|@task\b/.test(source),
entries: taskEntries,
}
export const celeryRule: LibraryRule = {
id: RULE_ID,
label: 'Celery 应用与定时任务',
languageIds: ['python'],
libraries: LIBRARIES,
claim: (block, context) => {
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases: aliasesOf(context.imports, SCHEDULE_LIBRARIES) }
if (block.kind === 'variable' || block.kind === 'const') {
const [declarator, ...rest] = block.facts.declarators
if (!declarator?.value || rest.length > 0)
return undefined
const call = callOf(declarator.value, env.aliases)
if (call?.name === 'Celery')
return appBlock(declarator.name, call, block.facts.doc, env)
const app = appOfConf(declarator.name, 'beat_schedule')
return app && declarator.value.type === 'dictionary' ? beatBlock(app, declarator.value, [], block.facts.doc, env) : undefined
}
if (block.kind !== 'statement')
return undefined
const [expression] = syntaxChildren(block.node)
const call = callOf(expression, env.aliases)
const app = expression?.type === 'call' ? appOfConf(expression.childForFieldName('function'), 'update') : undefined
const schedule = call?.keywords.get('beat_schedule')
if (!app || !call || schedule?.type !== 'dictionary')
return undefined
const settings: [string, Node][] = [
...call.positional.map((value): [string, Node] => [env.words.spread, value]),
...[...call.keywords].filter(([key]) => key !== 'beat_schedule'),
...call.splats.map((value): [string, Node] => [env.words.spread, value]),
]
return beatBlock(app, schedule, settings, undefined, env)
},
resolve: (key, workspace) => workspace.lookup(TASK_INDEX, key),
}
@@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => {
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
it('目录里有规则', () => {
expect(files).toContain('sqlalchemy.ts')
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts']))
})
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
+94
View File
@@ -0,0 +1,94 @@
import type { ImportFacts, Node } from '../contract'
/** Python 库规则共用的读语法树的办法(tree-sitter-python 的节点类型)。 */
/** 具名子节点,注释不算。 */
export function syntaxChildren(node: Node | null | undefined): Node[] {
return node ? node.namedChildren.filter((child): child is Node => child !== null && !child.isExtra) : []
}
/** 名字的最后一节:`sa.Column` → Column。 */
export 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
}
/** 字符串字面量里写的那段(`string_content` 节点,带源码下标用);f-string、拼接的字符串不算。空字符串没有这段,内容是空的。 */
export function stringContent(node: Node | null | undefined): { text: string, node?: Node } | undefined {
if (node?.type !== 'string')
return undefined
const parts = syntaxChildren(node).filter(part => part.type !== 'string_start' && part.type !== 'string_end')
if (parts.some(part => part.type !== 'string_content') || parts.length > 1)
return undefined
return parts.length === 0 ? { text: '' } : { text: parts[0].text, node: parts[0] }
}
/** 字符串字面量的内容;f-string、拼接的字符串不算。 */
export 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 fromLibraries(source: Node, libraries: readonly string[]): boolean {
return libraries.some(library => source.text === library || source.text.startsWith(`${library}.`))
}
/** 从这几个库导入时起的别名 → 原名:`from sqlalchemy import Enum as SQLEnum`。 */
export function aliasesOf(imports: readonly ImportFacts[], libraries: readonly string[]): Map<string, string> {
const aliases = new Map<string, string>()
for (const facts of imports) {
if (!facts.sources.some(source => fromLibraries(source, libraries)))
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` 这种取最后一节。 */
export function canonicalName(node: Node | null | undefined, aliases: ReadonlyMap<string, string>): string | undefined {
const name = lastName(node)
return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name
}
/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数、展开的参数(`*args`、`**kwargs` 里的表达式)。 */
export interface Call {
name: string | undefined
positional: Node[]
keywords: Map<string, Node>
splats: Node[]
}
export function callOf(node: Node | null | undefined, aliases: ReadonlyMap<string, string>): Call | undefined {
if (node?.type !== 'call')
return undefined
const args = node.childForFieldName('arguments')
const positional: Node[] = []
const keywords = new Map<string, Node>()
const splats: 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') {
splats.push(...syntaxChildren(arg).slice(0, 1))
}
else {
positional.push(arg)
}
}
return { name: canonicalName(node.childForFieldName('function'), aliases), positional, keywords, splats }
}
+30
View File
@@ -0,0 +1,30 @@
import type { Inline, Node, Segment, SourceRange, TokenRole } from '../contract'
/** 库规则共用的拼属性表文字的办法。 */
export function range(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
export function plain(text: string): Segment {
return { text, role: 'plain' }
}
/** 说明词:N 个、未标注、展开……。 */
export function hint(text: string): Segment {
return { text, role: 'hint' }
}
/** 规则自己的用词里当关键字着色的:主键、外键、默认……。 */
export function word(text: string): Segment {
return { text, role: 'storage' }
}
/** 源码里的一个名字照抄,带源码下标(能跳到定义、高亮各处出现)。 */
export function name(node: Node, role: TokenRole): Segment {
return { text: node.text, role, offset: node.startIndex }
}
export function joined(parts: Inline[][], separator: Segment): Inline[] {
return parts.flatMap((part, i) => i > 0 ? [{ ...separator }, ...part] : part)
}
+5 -102
View File
@@ -1,4 +1,7 @@
import type { ClassBlock, CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, Segment, SourceRange, ThemeColor, TokenRole } from '../contract'
import type { ClassBlock, CodeBlock, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, ThemeColor, TokenRole } from '../contract'
import type { Call } from './pythonTree'
import { aliasesOf, callOf, canonicalName, lastName, stringValue, syntaxChildren } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
/**
* SQLAlchemy 库规则:声明式模型类(有 `__tablename__`,或主体里用 `mapped_column(...)` / `Column(...)` 定义字段)整块换成「数据表」卡片:
@@ -116,13 +119,6 @@ interface Env {
aliases: Map<string, string>
}
/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */
interface Call {
name: string | undefined
positional: Node[]
keywords: Map<string, Node>
}
/** 类主体里一条给名字赋值(或只写注解)的语句;doc 是它的属性 docstring(字段的 doc、变量的 facts.doc)。 */
interface Member {
block: CodeBlock
@@ -147,78 +143,10 @@ interface Model {
// ─── 读语法树 ─────────────────────────────────────────────────────────────────
/** 具名子节点,注释不算。 */
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
@@ -331,31 +259,6 @@ function dictValue(dictionary: Node | undefined, key: string): Node | 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)
@@ -558,7 +461,7 @@ export const sqlalchemyRule: LibraryRule = {
claim: (block, context) => {
if (block.kind !== 'class')
return undefined
const aliases = aliasesOf(context.imports)
const aliases = aliasesOf(context.imports, LIBRARIES)
const model = modelOf(block, aliases)
return model && tableBlock(block, model, { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, aliases })
},