642 lines
30 KiB
TypeScript
642 lines
30 KiB
TypeScript
import type { IndexedFile, IndexEntry, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
|
||
import type { Call } from './pythonTree'
|
||
import { aliasesOf, callOf, lastName, stringContent, stringValue, syntaxChildren } from './pythonTree'
|
||
import { hint, joined, name, plain, range, word } from './segments'
|
||
|
||
/**
|
||
* 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' }
|
||
const APP_ICON: KindIconName = 'server-cog'
|
||
const BEAT_ICON: KindIconName = 'calendar-clock'
|
||
|
||
/** `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)?.body?.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): RuleCard {
|
||
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)?.body
|
||
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, icon: BEAT_ICON }, rows, absorbs: [] }
|
||
}
|
||
|
||
// ─── Celery 应用 ──────────────────────────────────────────────────────────────
|
||
|
||
/** Celery 应用卡片:名称 / 说明 / 应用名 / 消息队列 / 结果存储,别的参数照写的顺序各一行,行名是参数名。 */
|
||
function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): RuleCard {
|
||
const { words, context } = env
|
||
const { labels } = words
|
||
const parameters = new Map<string, Node>()
|
||
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)?.body
|
||
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, icon: APP_ICON },
|
||
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),
|
||
}
|