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 /** 调度是个写成秒数的表达式时前后的字:「每 X 秒」 */ everySeconds: [string, string] frequency: (frequency: Frequency) => string } const ZH_UNITS: Record = { 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 = { zh: ZH } /** 认一块要用的:宿主给的 context、挑好的用词、别名表。 */ interface Env { context: RuleContext words: Words aliases: Map } // ─── 频率:从调度写法换算 ────────────────────────────────────────────────────── /** 只由数字字面量和 + - * / // % ** 组成的算式的值;带名字、调用的算不出来。 */ 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() 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): 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 = {}): 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.` 里的 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() 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(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), }