✨ feat(vscode): 代码阅读加 Celery 库规则,定时任务表里的任务名能跳到任务函数
This commit is contained in:
1 parent
905d7845b5
commit
68104e2cba
22 files changed
+2228
-139
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user