✨ feat(vscode): 代码阅读加 Celery 库规则,定时任务表里的任务名能跳到任务函数
This commit is contained in:
1 parent
905d7845b5
commit
68104e2cba
22 files changed
+2228
-139
No files matched your search
@@ -32,7 +32,8 @@
|
||||
| 函数、类、导入、常量、变量块 | 预览不显示一行译文,显示树形属性表;属性值里的类型、表达式照常直译 |
|
||||
| 字典 / 对象字面量 | 常量、变量的值是 Python 字典 / TypeScript 对象字面量时不挤成一长行:值写「字典 N 项」/「对象 N 项」,各项展开成「键 \| 值」两列的子行,值又是字典 / 对象时一层层往下展开;参数默认值照旧一行 |
|
||||
| 输入参数 | 排成带表头和边框的表格:名称 \| 类型 \| 默认值,表头写了「默认值」,格子里不再写「默认 」 |
|
||||
| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;用词归规则自己,webview 只画结构。见「库规则」 |
|
||||
| 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;用词归规则自己,webview 只画结构。见「库规则」 |
|
||||
| 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 |
|
||||
| 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 |
|
||||
|
||||
## 用户可见行为
|
||||
@@ -161,7 +162,7 @@
|
||||
### 跳到定义
|
||||
|
||||
- 能点的:预览里(属性表的名称、类型、来源、导入项、默认值、值、字典 / 对象各层子行的键和值、类型参数、父类、实现的接口,以及一行译文)照抄自源码的名字——标识符、属性名、类型名、点分模块名的每一节(`luffy_agent.core.db` 三节各自能点,和 VS Code 一样)、字符串(TypeScript 的模块路径 `'./x'` 就是字符串)。翻出来的关键字、运算符、说明词不能点。
|
||||
- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。
|
||||
- 链接和编辑器里一样:只有按住修饰键(macOS ⌘,其余平台 Ctrl)、鼠标停在**查得到定义**的名字上时,名字加下划线、换成链接色(`--vscode-editorLink-activeForeground`)、光标变手形;松开修饰键或移开就恢复。有没有定义问扩展端(`vscode.executeDefinitionProvider`,文档 uri + 名字在源码里的位置;库规则标了引用的名字问规则,见「规则自己查定义」,webview 不分这两种);和编辑器一样,每回指到一个名字上(或指着名字按下修饰键、换了文档版本)都现问,回答不留到下次用——Pylance、tsserver 刚启动那几秒会答「没有」;只认最近一次询问的回答,迟到的丢掉。
|
||||
- ⌘ / Ctrl + 点击:
|
||||
- 一处定义,在当前文件:原地滚过去;
|
||||
- 一处定义,另一个文件,语言有代码阅读:在同一个编辑器组用预览打开(`vscode.openWith`),滚过去;这个文件的预览已经在这一组开着就切过去、发给它,不重新打开;
|
||||
@@ -335,8 +336,9 @@ export type TokenRole =
|
||||
* offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时,它在源码里的起始下标,
|
||||
* 预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。由从语法树叶子生成这段的一方填(模板直译引擎的译文和属性表);
|
||||
* 第三方翻译插件可以不填,不填就不能跳。
|
||||
* reference:只有库规则给的段有,这段讲的东西由规则自己查定义(LibraryRule.resolve),不问语言扩展;这样的段必须带 offset。
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference }
|
||||
|
||||
/** 框的样式,决定框头图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
|
||||
export type GroupStyle = 'iteration'
|
||||
@@ -436,12 +438,39 @@ export interface RuleContext {
|
||||
doc: (node: Node) => Markdown | undefined // 借当前翻译插件的 doc
|
||||
}
|
||||
|
||||
// ── 规则自己查定义(见「规则自己查定义」)──
|
||||
|
||||
export interface RuleReference { rule: string, key: string } // 哪个规则、规则自己认的键(Celery 的任务名)
|
||||
export interface RulePosition { line: number, character: number } // 0 起,列按 UTF-16
|
||||
export interface RuleLocation { uri: string, start: RulePosition, end: RulePosition } // 定义处那个名字
|
||||
|
||||
export interface IndexedFile {
|
||||
path: string // 相对所在工作区文件夹,/ 分隔
|
||||
source: string
|
||||
exists: (path: string) => boolean // 同一个工作区文件夹里有没有这个文件(只认 include 找到的)
|
||||
}
|
||||
export interface IndexEntry { key: string, name: SourceRange }
|
||||
|
||||
/** 规则在整个工作区里建的索引:规则只说看哪些文件、认出什么;找文件、预筛、解析、缓存、跟着改动更新归宿主。 */
|
||||
export interface WorkspaceIndex {
|
||||
id: string // 'celery.tasks',同一个 id 只建一份
|
||||
languageId: string // 用这个语言插件解析
|
||||
include: string // '**/*.py'
|
||||
mentions: (source: string) => boolean // 便宜的文字预筛
|
||||
entries: (root: Node, file: IndexedFile) => IndexEntry[]
|
||||
}
|
||||
|
||||
export interface RuleWorkspace {
|
||||
lookup: (index: WorkspaceIndex, key: string) => Promise<RuleLocation[]> // 第一次查时建,照文件、行排好
|
||||
}
|
||||
|
||||
export interface LibraryRule {
|
||||
id: string // 'sqlalchemy'
|
||||
label: string // 'SQLAlchemy 数据表'
|
||||
languageIds: string[]
|
||||
libraries: string[] // 关心的库:导入来源等于它或是它的子模块才启用
|
||||
claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined
|
||||
resolve?: (key: string, workspace: RuleWorkspace) => Promise<RuleLocation[]> // 查自己标的引用;没给就不能标引用
|
||||
}
|
||||
|
||||
export interface CodeReaderApi {
|
||||
@@ -452,7 +481,7 @@ export interface CodeReaderApi {
|
||||
```
|
||||
|
||||
- `activate()` 返回 `{ codeReader: CodeReaderApi }`;其他扩展声明 `extensionDependencies` 后用 `extensions.getExtension('clurdra.superpowers-vscode-clurdra')!.exports.codeReader` 注册。
|
||||
- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy 库规则在 `activate()` 里通过同一个 API 注册,没有旁路。
|
||||
- 内置的 TypeScript、Python 语言插件、两个中文直译翻译插件和 SQLAlchemy、Celery 库规则在 `activate()` 里通过同一个 API 注册,没有旁路。
|
||||
- 注册表变化时更新 `superpowers.codeReader.languageIds` 上下文,并让已打开的预览刷新。
|
||||
- 分工:语言插件只从语法树读出和语言无关的事实(facts,引用源码节点,不做正则匹配文字);翻译插件决定属性名、枚举词(公共 / 是 / 默认导出)和值怎么译,也给筛选属性和值起名(`attributeNames`)。筛选属性的通用部分由宿主从 facts 推出(`attributes.ts` 的 `blockAttributes`),语言插件只在 `BlockShape.attributes` 里补自己语言特有的。第三方语言插件给函数、类、导入、常量、变量块填 facts 就能用上任何翻译插件的属性表;自己语言的字典 / 映射字面量填进 `DeclaratorFacts.entries` 就能展开成子行(中文模板直译按字面量的节点类型查叫法,见 `ZhTable.mapLiterals`)。写在表达式里的遍历(推导式这类)由语言插件的 `iteration` 给结构(节点 → `IterationFacts`),翻译插件排成一个 `SegmentGroup`(中文直译是样式 `iteration`、框头是结果类型的译名);webview 只认框的样式 id,不认语言。
|
||||
- 语言插件的信息只经契约到翻译插件:宿主翻每一块时把 `{ source, language }`(`SourceDocument`,language 是解析这份源码的语言插件)交给 `translate` / `describe`,翻译插件要词性、遍历结构都从 `language` 取,不 import 任何语言插件的模块(lint 规则挡住 `translators/` 下的非测试文件 import `languages/`)。所以第三方翻译插件(英文、AI 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。
|
||||
@@ -475,10 +504,22 @@ export interface CodeReaderApi {
|
||||
|
||||
- 启用:宿主收集这份源码里各层导入块的 `ImportFacts.sources`(去掉引号),有一个等于规则的某个库、或以「库.」「库/」开头(子模块),规则才对这份源码启用。规则不用自己读导入语句。
|
||||
- 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。
|
||||
- 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。
|
||||
- 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。
|
||||
- 并进来的子块:不再单独成块。替身的编辑范围从原块头部开始,到最后一个并进来的子块(整条语句)结束,连成一段;夹在这段中间的子块(注释,或者写在字段中间的方法)也跟着并进来,保证每一行只在一块里能改;这段以后的子块(方法等)照常是替身的子块。
|
||||
- 替身在预览里:种类标签的字、颜色取 `badge`(暗色、亮色主题各取一个),内容是 `rows`;筛选的 `kind` 是替身的种类 id,其余属性从原块推出(原来的类是公共的,数据表也是公共的),叠上规则给的;筛选栏里这个种类叫 `badge.label`;和类块一样默认收起子块。
|
||||
|
||||
### 规则自己查定义
|
||||
|
||||
有些名字语言扩展查不到定义,只有懂这个库的规则知道它指向哪儿(Celery 任务表里的 `"commerce.cancel_timeout_pending_orders"` 是个字符串,指向工作区里某个 `@celery_app.task(name=…)` 装饰的函数)。规则可以把自己给的这种名字标成引用,自己查。别的库(路由名 → 处理函数、配置键 → 定义处……)照同样的办法用。
|
||||
|
||||
- 标:规则在属性表的段上填 `reference: { rule: 自己的 id, key: 规则自己认的键 }`,段照常带 `offset`(名字在源码里的位置,高亮各处出现、webview 认名字都用它);规则要给 `resolve(key, workspace)`,异步返回各处定义(`RuleLocation`:文件 uri 和定义处那个名字的起止行列)。不合这几条时宿主当替身不合约定(见上面「出错」)。
|
||||
- 查:webview 不认引用,⌘ / Ctrl 悬停、点击照旧只报名字的下标(`probeDefinition` / `goToDefinition`)。扩展端记着最近发出去的那份文档里哪些下标是引用(只对那个文档版本有效),是引用的交给标它的规则的 `resolve`,其余照旧问语言扩展(`vscode.executeDefinitionProvider`);两边查到的位置走同一条路:链接样子、一处原地滚 / 在预览里打开 / 普通编辑器打开、几处弹挑选框、闪「就是这处定义」的那块或那一行,都和「跳到定义」一节一样。规则注销了、`resolve` 出错时记日志,当作没有定义。
|
||||
- 在整个工作区里找:规则不能碰 VS Code,只描述一个 `WorkspaceIndex`(看哪些文件、便宜的文字预筛、从一份语法树里认出「键 → 定义处名字」),`resolve` 里调 `workspace.lookup(索引, 键)`。宿主(`workspaceIndex.ts`,VS Code 那一层在 `workspaceFiles.ts`):
|
||||
- 第一次查时才建,同一个 id 只建一份,建的时候不挡预览(查的那一下等它建好);
|
||||
- 按 `include` 在各工作区文件夹里找文件,`files.exclude`、`search.exclude` 里开着的和 `.venv`、`node_modules`、`__pycache__` 不看;每次读 32 份,预筛不过的不解析,用 `languageId` 的语言插件解析,语法树用完释放;
|
||||
- 盯着文件改动:改了只重看那一份;新建的加进来;删掉的(目录连里面的)去掉;规则认的时候问过在不在(`IndexedFile.exists`)的文件新建、删掉时,问过它的那几份跟着重看(Python 包路径看 `__init__.py`);建索引期间的改动建好后补上;
|
||||
- 读不了、规则认的时候出错的文件记日志、跳过,别的照常。
|
||||
|
||||
### 内置:SQLAlchemy 数据表(Python)
|
||||
|
||||
`vscode/src/codeReader/rules/sqlalchemy.ts`,`libraries: ['sqlalchemy', 'flask_sqlalchemy']`。类块主体里有 `__tablename__`,或有赋值是 `mapped_column(...)` / `Column(...)`(`sa.Column`、`db.Column` 这类取最后一节)时认领,种类 `sqlalchemy.table`,标签「数据表」(暗 #e8a860 / 亮 #a3590d)。导入时起的别名按 `context.imports` 换回原名(`from sqlalchemy import Enum as SQLEnum`、`UUID as PgUUID`)。
|
||||
@@ -556,6 +597,82 @@ export interface CodeReaderApi {
|
||||
|
||||
配色:字段名 variable、类型译名 type、约束的词(主键、外键、默认……)storage、外键目标 string、删除规则和 ` · ` hint、说明 comment、索引名 string、索引列 variable。
|
||||
|
||||
### 内置:Celery(Python)
|
||||
|
||||
`vscode/src/codeReader/rules/celery.ts`,`libraries: ['celery']`。原型:`code.js` 的「库规则:Celery」一节(`CELERY_APP`、`beatCard`),画板「库规则 · Celery」;样例是 luffy 的 `backend/src/luffy_agent/celery_app.py`(节选在 `fixtures/celeryApp.py`)。两张卡片,都不并子块(语句本身没有子块);改配置(`conf.update(...)` 不带 `beat_schedule`)、`autodiscover_tasks`、信号处理函数照常显示。导入时起的别名按 `context.imports` 换回原名(`from celery import Celery as App`、`crontab`、`timedelta`)。
|
||||
|
||||
**Celery 应用**:常量、变量块的唯一一个赋值的值是 `Celery(...)`(`celery.Celery(...)` 也算),种类 `celery.app`,标签「Celery 应用」(暗 #98c379 / 亮 #4d7f17);块自己的名字是赋值目标(跳到 `celery_app` 的定义闪整张卡片)。属性表(有才显示):
|
||||
|
||||
| 行 | 内容 |
|
||||
| --- | --- |
|
||||
| 名称 | 赋值目标(variable 色,带下标;不是单个名字时照写法翻) |
|
||||
| 说明 | 赋值后面的属性 docstring;Markdown |
|
||||
| 应用名 | `main`(第一个位置参数,或 `main=`),翻译插件翻(`"luffy_agent"` 带引号) |
|
||||
| 消息队列 | `broker` |
|
||||
| 结果存储 | `backend` |
|
||||
| 别的参数 | 照写的顺序各一行,行名是参数名(`include`、`task_cls`……),值翻译插件翻;位置参数按 `Celery.__init__` 的参数顺序认名字(第三个位置参数是 `backend`);`*x`、`**x` 行名「展开」 |
|
||||
|
||||
**定时任务**:`X.conf.beat_schedule = {...}`(值是字典字面量),或 `X.conf.update(beat_schedule={...})`(后者其余的配置各一行,行名是配置名,排在「应用」后面),种类 `celery.beat`,标签「定时任务」(暗 #56b6c2 / 亮 #127f8c)。属性表:
|
||||
|
||||
| 行 | 内容 |
|
||||
| --- | --- |
|
||||
| 应用 | `.conf` 前面的表达式(翻译插件翻,名字带下标) |
|
||||
| 说明 | 赋值后面的属性 docstring;Markdown |
|
||||
| 任务 | `N 条`,有展开时 `N 条,另有展开 M 处`(hint);表格:任务 \| 频率 \|(选项)\| 说明,每条一行,照源码顺序 |
|
||||
|
||||
每一条:
|
||||
|
||||
| 条目写法 | 任务 | 频率 | 选项 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `"键": {"task": "名", "schedule": …, …}` | `"task"` 的字符串内容(string 色,不带引号,带下标,是规则的引用 `{ rule: 'celery', key: 名 }`);不是字符串照写法翻(不是引用);没写写「没写任务名」;键和任务名不一样时后面淡色写「(条目名 键)」 | 见下表 | 除 `task`、`schedule` 以外的项,` · ` 隔开:`args` 位置参数、`kwargs` 关键字参数、`options` 执行选项,别的写键本身,值翻译插件翻 | 见下 |
|
||||
| `"键": 别的表达式` | 键(string 色,带下标) | 值照写法翻 | 空 | 见下 |
|
||||
| `**表达式` | 「展开」(hint) | 展开的表达式照写法翻 | 空 | 见下 |
|
||||
|
||||
- 「选项」一列只在有条目带别的项时才有(`celery_app.py` 的主表没有,只有三列)。
|
||||
- 说明:写在这条里面的 `#` 注释(调度那一行行尾的、单独成行的)、写在这条最后一行行尾的、紧挨在它上面单独成行的,按源码顺序,取翻译插件给的原文(`context.doc`)用空格连起来,comment 色;最后一条后面单独成行的注释不算。
|
||||
|
||||
频率(keyword 色;换算不了时照下表最后两行):
|
||||
|
||||
| 调度写法 | 频率 |
|
||||
| --- | --- |
|
||||
| 只由数字和 `+ - * / // % **` 组成的算式(秒数):`5 * 60`、`60 * 60`、`24 * 60 * 60`、`15`、`10⏎ * 60` | 换成最大的整单位:每 5 分钟、每小时、每天、每 15 秒;1 个单位写「每分钟 / 每小时 / 每天 / 每秒」,天数不再往上换(`7 * 24 * 3600` → 每 7 天),不是整数的秒照写(每 0.5 秒) |
|
||||
| `timedelta(...)`(位置参数按 days, seconds, microseconds, milliseconds, minutes, hours, weeks,关键字同名),每个参数都是上面那种算式 | 同上:`timedelta(minutes=5)` → 每 5 分钟 |
|
||||
| `schedule(run_every=X)` / `schedule(X)` | 按 X 换算 |
|
||||
| `crontab(...)`(位置参数按 minute, hour, day_of_week, day_of_month, month_of_year;每个字段是整数、字符串 `*` / `*/n` / 逗号隔开的数或 `a-b`,星期还能写 `mon`…`sun`,或整数的列表 / 集合 / 元组) | 时间:分、时都是 `*` → 每分钟;分 `*/n`、时 `*` → 每 n 分钟;分是几个数、时 `*` → 每小时整点 / 每小时的第 0、30 分;时 `*/n` → 每 n 小时整点 / 每 n 小时的第 m 分;时是几个数 → 几点几分(`04:00`,几个时刻「、」隔开,按先后排)。哪些天:都是 `*` → 每天;只有星期 → 每周一、每周一至周五、每周六、周日(0 和 7 是周日);只有日 → 每月 1 日、每月 1 至 7 日;日和月 → 每年 1 月 1 日。连起来:`每天 04:00`、`每周一至周五 07:30`、`每月 1 日 03:00`;一天里不止一次的写「哪些天 + 时间」(`每周一 每小时整点`),每天的只写时间(`每 15 分钟`、`每 2 小时整点`) |
|
||||
| 名字、属性、下标、带名字的算式(`get_settings().STORAGE_USAGE_POLL_INTERVAL_SEC`、`INTERVAL`) | 当作秒数:`每 〈翻译插件翻的表达式〉 秒`,表达式里的名字带下标 |
|
||||
| 别的(换算不了的 crontab,如 `crontab(hour=4)` 分钟是 `*`、带 `1-10/2` 这种步长;别的调用、`timedelta(minutes=settings.X)`) | 照写法(翻译插件翻) |
|
||||
|
||||
样例(`fixtures/celeryApp.py`,测试里逐字核对;子行各列用 ` | ` 隔开):
|
||||
|
||||
```
|
||||
名称: celery_app
|
||||
应用名: "luffy_agent"
|
||||
消息队列: settings.CELERY_BROKER_URL
|
||||
结果存储: settings.CELERY_RESULT_BACKEND
|
||||
|
||||
应用: celery_app
|
||||
任务: 12 条,另有展开 1 处
|
||||
commerce.cancel_timeout_pending_orders | 每 5 分钟 |
|
||||
commerce.check_channel_pending_manual_slices | 每 10 分钟 | 手动退款切片对账: 微信回调丢失兜底
|
||||
deployment.notify_expiring_deployments | 每天 | 每天一轮到期提醒扫描 (#274)
|
||||
deployment.sweep_terminal_garbage | 每 10 分钟 | 终态垃圾兜底: failed 行 teardown 重试 + OSS 败方补清 (#320)
|
||||
session.prune_langgraph_checkpoints | 每 5 分钟 | 高频小批量 sweep: … 防磁盘吃满 与清理 IO 风暴 (#316); …
|
||||
referral.settle_due_commissions | 每小时 | 每小时扫一次到期佣金
|
||||
storage.poll_app_data_usage | 每 get_settings().STORAGE_USAGE_POLL_INTERVAL_SEC 秒 |
|
||||
展开 | _ops_beat_entries(settings.OPS_DASHBOARD_ENABLED) |
|
||||
(其余几条同理)
|
||||
```
|
||||
|
||||
**任务名 → 定义**(规则的 `resolve`,索引 `celery.tasks`):
|
||||
|
||||
- 看工作区里所有 `**/*.py`(排除的见「规则自己查定义」),预筛:源码里有 `.task`(后面不接字母数字下划线)、`shared_task` 或 `@task` 才解析。不要求文件导入 celery(luffy 的任务文件只 `from luffy_agent.celery_app import celery_app`)。
|
||||
- 认:装饰器是 `@<任何东西>.task`、`@shared_task`、`@task`(`x.shared_task` 也算),带不带括号都算,装饰的是函数(含 `async def`)。
|
||||
- 任务名:装饰器的 `name="…"`;`name=` 写的不是字符串(常量、表达式)就认不出来,不收。没写 `name=` 时是 Celery 默认的「模块路径.函数名」,只对写在模块里(不在函数、类里面)的函数算。
|
||||
- 模块路径:从文件所在目录往上,有 `__init__.py` 的目录都是包;包外面(上面)还有 `src` 目录时从 `src` 下面算起(`src` 布局下的命名空间包)。`backend/src/luffy_agent/commerce/tasks.py` → `luffy_agent.commerce.tasks`;`worker/jobs.py`(没有包)→ `jobs`;`__init__.py` 本身是包名。
|
||||
- 定义处:`def` 那一行的函数名,所以目标预览闪整个函数块。几处定义(同名任务写在几个文件里)弹挑选框。
|
||||
|
||||
配色:Celery 应用的名称 variable、别的值按翻译插件;任务名 string,「(条目名 …)」「N 条…」「展开」hint,频率的换算词 keyword,选项名 storage、` · ` hint,说明 comment。
|
||||
|
||||
## 解析运行时
|
||||
|
||||
- 依赖 `@vscode/tree-sitter-wasm`(VS Code 团队维护,包含 web-tree-sitter 运行时和 TypeScript / TSX / JavaScript / Python 语法包,版本配套)。
|
||||
@@ -711,7 +828,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } |
|
||||
- `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。
|
||||
- webview → 扩展:
|
||||
- `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。
|
||||
- `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。
|
||||
- `{ type: 'goToDefinition', version, offset }`:修饰键 + 点击,按「跳到定义」一节跳。这两条都只带下标:扩展端按发出去的文档认出规则标了引用的下标交给规则查(见「规则自己查定义」),其余问语言扩展。
|
||||
- `{ type: 'findOccurrences', id, version, offset }`:单击带 `offset` 的名字,问它在这份文档里的各处出现;`id` 每问一次加一,`version` 不是当前文档版本就不答。
|
||||
- `{ type: 'ready' }`
|
||||
- `{ type: 'edit', startLine, endLine, text }`:用 `WorkspaceEdit` 把这几行整行替换成 `text`(每行补回原来第一行的缩进)。
|
||||
@@ -738,7 +855,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } |
|
||||
|
||||
属性表里:说明按 comment,访问级别(公共 / 私有 / 受保护)和「作为」按 keyword,是 / 否按 storage,函数名称按 function,类名称按 type,未标注、无、N 个、字典 N 项、对象 N 项、展开、默认导出、全部、整个模块、匿名按 hint;输入参数表格的列名是界面文字(`descriptionForeground`),不是着色的词。推导式框里的「遍历」「的每个」「仅当」「取」「异步遍历」按 keyword,框头按「循环」块的种类色。父类名(TypeScript `extends` 后面、JavaScript `class_heritage` 里、Python 类名后面括号里直接写的名字)源码着色和属性表里都按 type。
|
||||
|
||||
种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
|
||||
种类(标签字色 + 标签底 18% + 块底 7% + 边框 35%;亮色主题同色系):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
|
||||
|
||||
## 代码位置
|
||||
|
||||
@@ -749,8 +866,13 @@ vscode/src/codeReader/
|
||||
runtime.ts web-tree-sitter 初始化、语法包加载、解析
|
||||
blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译)
|
||||
attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数
|
||||
libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、并进来的范围
|
||||
rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract)
|
||||
libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、并进来的范围、查规则标的引用
|
||||
workspaceIndex.ts 库规则的工作区索引(WorkspaceIndex)的宿主部分:建、缓存、跟着文件改动更新(不碰 VS Code)
|
||||
workspaceFiles.ts 工作区索引在 VS Code 里怎么找文件、读文件、盯改动(排除规则)
|
||||
rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract 和同目录的共用代码)
|
||||
rules/celery.ts 内置 Celery 库规则:两张卡片、频率换算、任务名索引
|
||||
rules/pythonTree.ts Python 库规则共用的读语法树的办法(名字最后一节、字符串内容、调用的参数、别名)
|
||||
rules/segments.ts 库规则共用的拼属性表文字的办法
|
||||
languages/typescript.ts
|
||||
languages/python.ts (iteration:推导式的结构)
|
||||
translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup)
|
||||
@@ -765,7 +887,8 @@ vscode/src/codeReader/
|
||||
editor/previewEditor.ts CustomTextEditorProvider
|
||||
editor/messages.ts 消息类型
|
||||
editor/blockFilter.ts 块筛选:workspaceState 读写、通知所有预览
|
||||
editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、几处定义的挑选框
|
||||
editor/definitions.ts 查定义(vscode.executeDefinitionProvider)、规则查到的位置换成 VS Code 的、几处定义的挑选框
|
||||
editor/definitionSources.ts 名字的定义去哪儿查:文档里规则标的引用问规则,其余问语言扩展
|
||||
editor/definitionAction.ts 查到的定义 → 怎么跳(纯函数)
|
||||
editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights)
|
||||
index.ts registerCodeReader(context): CodeReaderApi
|
||||
@@ -791,6 +914,9 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
|
||||
- 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。
|
||||
- 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围到最后一个并进来的子块、夹在中间的注释并进来、之后的子块留下,每一行最多在一块里能改;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。
|
||||
- SQLAlchemy 规则:luffy `referral/models.py` 的节选(`fixtures/sqlalchemyModels.py`)里 `ReferralRelation` 的属性表逐字同上面的样例,其余模型覆盖 `Mapped[bool]`、`int | None` 可空、`default=None`、`server_default`、别名的 `PgUUID`、`_enum` 辅助函数、关系、唯一约束、删除时置空 / 级联删除;枚举类、函数照常显示;卡片的编辑范围是整个类、每行只在一块里;类名、字段名的定义范围和带下标的词都指回源码原文;webview 里跳到类名闪整块、跳到字段名只闪字段表里那一行并先展开「字段」那一行,卡片默认折叠;`import sqlalchemy as sa` 的老写法 `Column`、类型全名、只写注解的字段、不认得的类型照写法、docstring 当说明、方法留作子块。
|
||||
- 库规则的引用:引用标的不是自己、规则没给 `resolve`、段没带下标时当替身不合约定;查引用交给标它的规则,规则不在、没给 `resolve`、出错时记下来、当作没有定义;视图数据里的引用(下标 → 引用)只从规则认领的块的属性表里取,查定义时是引用的问规则、其余问语言扩展,只对记下它的那个文档版本有效(`editor/definitionSources.test.ts`)。
|
||||
- 工作区索引(内存里的工作区,`MemoryIndexHost`):第一次查才建、同一个 id 只建一份;查到的是名字的行列(列按 UTF-16)、照文件和行排好;预筛不过的不解析;改了只重看那一份、新建加进来、删目录连里面的去掉;新建 / 删掉问过在不在的文件(`__init__.py`)时问过它的文件重看;建索引期间的改动建好后补上;读不了、规则出错的记下来、别的照常;dispose 后不再盯着。
|
||||
- Celery 规则:`fixtures/celeryApp.py` 的应用卡片和定时任务卡片逐字同上面的样例(列名、「12 条,另有展开 1 处」、行尾注释、条目里单独成行的注释、跨行的 `10⏎ * 60`、`每 get_settings().… 秒`、展开一行);任务名是引用、下标指回源码原文;应用卡片的位置参数按参数顺序认、别的参数各一行、展开、属性 docstring、别名;没导入 celery、不是 `Celery(...)` 的不认;频率换算(秒数算式、`timedelta`、`crontab` 各种写法、`schedule`、换算不了的「每 X 秒」和照写法);说明取哪些注释;条目名和任务名不同、没写任务名、任务名不是字符串、值不是字典、选项一列有才有;`conf.update(beat_schedule=…)`、不带 `beat_schedule` 的 `conf.update` 和 `autodiscover_tasks` 照常。任务名索引:显式 `name=`、默认「模块路径.函数名」(包、`src` 下的命名空间包、没有包)、`@shared_task` / `@shared_task()` / `@app.task` 不带括号 / 老写法 `@task`、`async def`、函数里的只认显式名字、`name=` 不是字符串的不认、同名几处都给、文件改了和新建 `__init__.py` 后跟着变、没有任务装饰器的文件不解析。
|
||||
- 筛选:块的属性(TS、TSX 有抽象、仅类型,JavaScript / JSX 没有这两个属性、属性表也没有这两行,Python 有访问级别、异步);语言插件自己报的属性叠上去、盖过推出来的,kind 不变;文件里出现过的属性按契约顺序排、语言的排后面,值按第一次出现排、带所有层的块数,名字取翻译插件的、没有的显示 id,中文直译的名字和属性表一致;下拉框只给两种以上值的属性(或一种值正被去掉的),按钮上的字;同一属性内「或」、不同属性「且」、没有这个属性不受影响、藏整棵子树、「已隐藏」只数直接筛掉的;筛选记在 `workspaceState`、新实例读得回来、所有订阅的预览收到、形状不对的当没有;跳到被筛掉的块临时露出路径、目标的子块照常筛、文档刷新只留正在编辑的。
|
||||
- 文件注释:键相对工作区文件夹、`/` 分隔,根和外面的路径没有键,手写的 `./`、结尾 `/` 规范掉;保存去空白、清空即删;文件改名、目录挪位置里面各层一起走、名字只是前缀相同的不动、跨工作区文件夹先取出再放进去;删目录连带里面的;读写:文件缺失 / 解析失败 / 不是对象返回空表、跳过不合法条目、建 `.spx`、键排序、删光留 `{}`;有注释 / 没注释的行的描述、悬停提示(Markdown 转义)、contextValue,和 `package.json` 里菜单的 when 子句对得上。
|
||||
|
||||
|
||||
@@ -206,8 +206,9 @@ export type TokenRole
|
||||
/**
|
||||
* 一段着色的文字。offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时,
|
||||
* 它在源码里的起始下标(同 SourceSpan 的下标),预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。
|
||||
* reference:只有库规则给的段有,这段讲的东西由规则自己查定义(LibraryRule.resolve),不问语言扩展;这样的段必须带 offset。
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
|
||||
export type GroupStyle = 'iteration'
|
||||
@@ -350,6 +351,50 @@ export interface RuleContext {
|
||||
doc: (node: Node) => Markdown | undefined
|
||||
}
|
||||
|
||||
/** 规则自己查得到定义的一个名字:哪个规则(LibraryRule.id),和规则自己认的键(Celery 的任务名)。 */
|
||||
export interface RuleReference { rule: string, key: string }
|
||||
|
||||
/** 文件里的一个位置:行、列从 0 起,列按 UTF-16 计(同 VS Code)。 */
|
||||
export interface RulePosition { line: number, character: number }
|
||||
|
||||
/** 规则查到的一处定义:文件(uri 字符串)和定义处那个名字的起止位置。 */
|
||||
export interface RuleLocation { uri: string, start: RulePosition, end: RulePosition }
|
||||
|
||||
/** 建索引时交给规则的一份文件。 */
|
||||
export interface IndexedFile {
|
||||
/** 相对它所在的工作区文件夹的路径,用 / 分隔。 */
|
||||
path: string
|
||||
source: string
|
||||
/** 同一个工作区文件夹里有没有这个文件(相对路径);只认按 WorkspaceIndex.include 找到的文件。算 Python 包路径时问 `__init__.py` 用。 */
|
||||
exists: (path: string) => boolean
|
||||
}
|
||||
|
||||
/** 规则从一份文件里认出的一项:键,和定义处那个名字在源码里的范围。 */
|
||||
export interface IndexEntry { key: string, name: SourceRange }
|
||||
|
||||
/**
|
||||
* 规则在整个工作区里建的索引(键 → 定义处):规则只说看哪些文件、从一份源码里认出什么;
|
||||
* 找文件、预筛、解析、缓存、跟着文件改动更新都归宿主。第一次查时才建,之后只重看改了的文件。
|
||||
*/
|
||||
export interface WorkspaceIndex {
|
||||
/** 同一个 id 宿主只建一份,写成「规则 id.名字」(celery.tasks)。 */
|
||||
id: string
|
||||
/** 按哪种语言解析:宿主用这个 languageId 的语言插件。 */
|
||||
languageId: string
|
||||
/** 看哪些文件:相对各工作区文件夹的 glob('**\/*.py');files.exclude、search.exclude 排除的,和 .venv、node_modules、__pycache__ 里的不看。 */
|
||||
include: string
|
||||
/** 便宜的文字预筛:源码里不可能有要找的东西就不解析。 */
|
||||
mentions: (source: string) => boolean
|
||||
/** 从一份文件的语法树里认出的各项;语法树只在调用期间有效。 */
|
||||
entries: (root: Node, file: IndexedFile) => IndexEntry[]
|
||||
}
|
||||
|
||||
/** 宿主给规则查定义用的工作区。 */
|
||||
export interface RuleWorkspace {
|
||||
/** 在 index 建的索引里查 key 的各处定义,照文件、行排好。 */
|
||||
lookup: (index: WorkspaceIndex, key: string) => Promise<RuleLocation[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 库规则:认出某个库的一种写法(SQLAlchemy 的模型类),把那块整个换成一张专门的卡片;文件导入了它关心的库才启用。
|
||||
* 规则只经契约拿信息:块(kind、node、header、children、facts)和 RuleContext;用词(属性名、种类名、类型的译名)属于规则自己。
|
||||
@@ -368,6 +413,11 @@ export interface LibraryRule {
|
||||
* 几个规则都认同一块时先注册的算;规则抛错、或替身不合上面的约定时,宿主记日志、当它没认,接着问下一个规则。
|
||||
*/
|
||||
claim: (block: CodeBlock, context: RuleContext) => RuleBlock | undefined
|
||||
/**
|
||||
* 查规则自己标的引用(Segment.reference 里 rule 是这个规则的)的定义:预览里 ⌘ / Ctrl 悬停、点击这样的名字时宿主问它,不问语言扩展;
|
||||
* 查到几处就跳、挑,和语言扩展查到的一样。没给就不能在段上标引用。
|
||||
*/
|
||||
resolve?: (key: string, workspace: RuleWorkspace) => Promise<RuleLocation[]>
|
||||
}
|
||||
|
||||
export interface CodeReaderApi {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { RuleReference } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { celeryRule } from '../rules/celery'
|
||||
import { readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { DefinitionSources, referencesIn } from './definitionSources'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const SNAPSHOT = readFixture('celeryApp.py')
|
||||
|
||||
function views(source: string): Promise<BlockView[]> {
|
||||
return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [celeryRule], report: () => {} }))
|
||||
}
|
||||
|
||||
/** 记下问了哪边:规则(引用的键)还是语言扩展(下标)。 */
|
||||
function recorder(): { asked: string[], sources: DefinitionSources<string> } {
|
||||
const asked: string[] = []
|
||||
const sources = new DefinitionSources<string>({
|
||||
rule: async (reference: RuleReference) => {
|
||||
asked.push(`规则 ${reference.rule} ${reference.key}`)
|
||||
return ['tasks.py']
|
||||
},
|
||||
language: async (offset: number) => {
|
||||
asked.push(`语言扩展 ${offset}`)
|
||||
return []
|
||||
},
|
||||
})
|
||||
return { asked, sources }
|
||||
}
|
||||
|
||||
describe('名字的定义去哪儿查', () => {
|
||||
it('视图数据里规则标了引用的名字:下标 → 引用(任务表里的任务名)', async () => {
|
||||
const references = referencesIn(await views(SNAPSHOT))
|
||||
expect(references.size).toBe(12)
|
||||
const offset = SNAPSHOT.indexOf('commerce.retry_failed_fulfillments",\n "schedule"')
|
||||
expect(references.get(offset)).toEqual({ rule: 'celery', key: 'commerce.retry_failed_fulfillments' })
|
||||
})
|
||||
|
||||
it('规则标了引用的问规则,其余(同一张卡片里的应用名、别的块里的名字)问语言扩展', async () => {
|
||||
const blocks = await views(SNAPSHOT)
|
||||
const { asked, sources } = recorder()
|
||||
sources.remember(3, blocks)
|
||||
const task = SNAPSHOT.indexOf('commerce.retry_failed_fulfillments",\n "schedule"')
|
||||
const app = SNAPSHOT.indexOf('celery_app.conf.beat_schedule')
|
||||
|
||||
expect(await sources.locate(3, task)).toEqual(['tasks.py'])
|
||||
expect(await sources.locate(3, app)).toEqual([])
|
||||
expect(asked).toEqual([`规则 celery commerce.retry_failed_fulfillments`, `语言扩展 ${app}`])
|
||||
})
|
||||
|
||||
it('引用只对记下它的那个文档版本有效;没记过文档时都问语言扩展', async () => {
|
||||
const blocks = await views(SNAPSHOT)
|
||||
const task = SNAPSHOT.indexOf('commerce.retry_failed_fulfillments",\n "schedule"')
|
||||
const { asked, sources } = recorder()
|
||||
await sources.locate(1, task)
|
||||
sources.remember(3, blocks)
|
||||
await sources.locate(4, task)
|
||||
sources.remember(4, [])
|
||||
await sources.locate(4, task)
|
||||
expect(asked).toEqual([`语言扩展 ${task}`, `语言扩展 ${task}`, `语言扩展 ${task}`])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { RuleReference } from '../contract'
|
||||
import { referencedSegments } from '../libraryRules'
|
||||
|
||||
/** 视图数据里库规则标了引用的名字:源码下标 → 引用。引用只出在规则认领的块的属性表里。 */
|
||||
export function referencesIn(blocks: readonly BlockView[]): Map<number, RuleReference> {
|
||||
const found = new Map<number, RuleReference>()
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
for (const block of list) {
|
||||
if ('properties' in block) {
|
||||
for (const { offset, reference } of referencedSegments(block.properties)) {
|
||||
if (offset !== undefined && reference)
|
||||
found.set(offset, reference)
|
||||
}
|
||||
}
|
||||
visit(block.children)
|
||||
}
|
||||
}
|
||||
visit(blocks)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 两处查定义的地方:库规则(查它自己标的引用)和语言扩展(其余的名字,和普通编辑器同一套)。 */
|
||||
export interface DefinitionProviders<L> {
|
||||
rule: (reference: RuleReference) => Promise<L[]>
|
||||
language: (offset: number) => Promise<L[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览里一个名字的定义去哪儿查:发出去的文档里库规则标了引用的问规则,其余问语言扩展。
|
||||
* webview 只报名字的源码下标,⌘ / Ctrl 悬停、点击都走这一条路,链接样子、跳转、挑选、闪都一样。
|
||||
* 引用按最近发出去的那份文档记,只对它的版本有效。
|
||||
*/
|
||||
export class DefinitionSources<L> {
|
||||
private version: number | undefined
|
||||
private references = new Map<number, RuleReference>()
|
||||
|
||||
constructor(private readonly providers: DefinitionProviders<L>) {}
|
||||
|
||||
/** 发出去了一份文档:记下它里面的引用。 */
|
||||
remember(version: number, blocks: readonly BlockView[]): void {
|
||||
this.version = version
|
||||
this.references = referencesIn(blocks)
|
||||
}
|
||||
|
||||
/** version 这份文档里 offset 处名字的各处定义。 */
|
||||
locate(version: number, offset: number): Promise<L[]> {
|
||||
const reference = version === this.version ? this.references.get(offset) : undefined
|
||||
return reference ? this.providers.rule(reference) : this.providers.language(offset)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,44 @@
|
||||
import type { Location, LocationLink, Position, Range, Uri } from 'vscode'
|
||||
import type { Location, LocationLink, Position } from 'vscode'
|
||||
import type { RuleLocation } from '../contract'
|
||||
import type { DefinitionTarget } from './definitionAction'
|
||||
import { commands, window, workspace } from 'vscode'
|
||||
import { commands, Range, Uri, window, workspace } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
|
||||
/** 查到的一处定义:文件和定义处那个名字的范围。 */
|
||||
export interface FoundLocation { uri: Uri, range: Range }
|
||||
|
||||
/** VS Code 查到的一处定义,加上打开目标文件才知道的信息。 */
|
||||
export interface Definition extends DefinitionTarget {
|
||||
location: { uri: Uri, range: Range }
|
||||
location: FoundLocation
|
||||
/** 定义处在目标文件里的下标,预览靠它找到是哪块、哪一行属性 */
|
||||
offset: number
|
||||
/** 定义所在那一行的源码,列在挑选框里 */
|
||||
lineText: string
|
||||
}
|
||||
|
||||
/** 交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
|
||||
async function provided(uri: Uri, position: Position): Promise<(Location | LocationLink)[]> {
|
||||
/** uri 里 position 处名字的定义:交给 VS Code 注册的定义提供者(Pylance、TypeScript 语言服务……),和普通编辑器里 ⌘ + 点击查的是同一套。 */
|
||||
export async function languageDefinitions(uri: Uri, position: Position): Promise<FoundLocation[]> {
|
||||
let provided: (Location | LocationLink)[]
|
||||
try {
|
||||
return await commands.executeCommand<(Location | LocationLink)[] | undefined>('vscode.executeDefinitionProvider', uri, position) ?? []
|
||||
provided = await commands.executeCommand<(Location | LocationLink)[] | undefined>('vscode.executeDefinitionProvider', uri, position) ?? []
|
||||
}
|
||||
catch (err) {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `查定义失败 ${uri.toString()}:${position.line + 1}:${err instanceof Error ? err.message : String(err)}` })
|
||||
return []
|
||||
}
|
||||
return provided.map(item => 'targetUri' in item
|
||||
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
|
||||
: { uri: item.uri, range: item.range })
|
||||
}
|
||||
|
||||
export async function hasDefinition(uri: Uri, position: Position): Promise<boolean> {
|
||||
return (await provided(uri, position)).length > 0
|
||||
/** 库规则查到的定义(RuleLocation)换成 VS Code 的位置。 */
|
||||
export function ruleDefinitions(locations: readonly RuleLocation[]): FoundLocation[] {
|
||||
return locations.map(({ uri, start, end }) => ({ uri: Uri.parse(uri), range: new Range(start.line, start.character, end.line, end.character) }))
|
||||
}
|
||||
|
||||
/** uri 里 position 处名字的所有定义;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
|
||||
export async function findDefinitions(uri: Uri, position: Position, supports: (languageId: string) => boolean): Promise<Definition[]> {
|
||||
const found = await Promise.all((await provided(uri, position)).map(async (item): Promise<Definition[]> => {
|
||||
const location = 'targetUri' in item
|
||||
? { uri: item.targetUri, range: item.targetSelectionRange ?? item.targetRange }
|
||||
: { uri: item.uri, range: item.range }
|
||||
/** 查到的各处定义打开目标文件补上行文字、下标;目标打不开成文本的(例如 Python 命名空间包指向的目录)略过。 */
|
||||
export async function findDefinitions(locations: readonly FoundLocation[], supports: (languageId: string) => boolean): Promise<Definition[]> {
|
||||
const found = await Promise.all(locations.map(async (location): Promise<Definition[]> => {
|
||||
const { start } = location.range
|
||||
try {
|
||||
const document = await workspace.openTextDocument(location.uri)
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
|
||||
import type { RuleLocation, RuleReference } from '../contract'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { Definition } from './definitions'
|
||||
import type { Definition, FoundLocation } from './definitions'
|
||||
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
|
||||
import { commands, Range, window, workspace, WorkspaceEdit } from 'vscode'
|
||||
import { logger } from '../../logging/logger'
|
||||
import { webviewHtml, webviewUiRoot } from '../../webview/html'
|
||||
import { blockReplacement } from '../editText'
|
||||
import { definitionAction } from './definitionAction'
|
||||
import { findDefinitions, hasDefinition, pickDefinition } from './definitions'
|
||||
import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
|
||||
import { DefinitionSources } from './definitionSources'
|
||||
import { documentMessage } from './documentMessage'
|
||||
import { occurrencesOf } from './occurrences'
|
||||
|
||||
@@ -32,6 +34,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
private readonly registry: CodeReaderRegistry,
|
||||
private readonly runtime: TreeSitterRuntime,
|
||||
private readonly filter: BlockFilter,
|
||||
/** 库规则标的引用去哪儿查定义(交给标它的规则) */
|
||||
readonly resolveReference: (reference: RuleReference) => Promise<RuleLocation[]>,
|
||||
) {}
|
||||
|
||||
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
|
||||
@@ -90,6 +94,8 @@ class Preview {
|
||||
private latest = 0
|
||||
private timer: NodeJS.Timeout | undefined
|
||||
private readonly disposables: Disposable[]
|
||||
/** 名字的定义去哪儿查:库规则标了引用的问规则,其余问语言扩展 */
|
||||
private readonly definitions: DefinitionSources<FoundLocation>
|
||||
/** webview 报来的视口顶部那块的起始行 */
|
||||
visibleLine: number | undefined
|
||||
|
||||
@@ -106,6 +112,10 @@ class Preview {
|
||||
*/
|
||||
private pendingReveal: Reveal | undefined,
|
||||
) {
|
||||
this.definitions = new DefinitionSources({
|
||||
rule: async reference => ruleDefinitions(await owner.resolveReference(reference)),
|
||||
language: offset => languageDefinitions(document.uri, document.positionAt(offset)),
|
||||
})
|
||||
this.disposables = [
|
||||
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
|
||||
panel.onDidChangeViewState(() => {
|
||||
@@ -146,6 +156,7 @@ class Preview {
|
||||
})
|
||||
if (request !== this.latest || !this.ready)
|
||||
return
|
||||
this.definitions.remember(message.version, message.blocks)
|
||||
const reveal = this.pendingReveal
|
||||
this.pendingReveal = undefined
|
||||
this.post(reveal === undefined ? message : { ...message, reveal })
|
||||
@@ -221,7 +232,7 @@ class Preview {
|
||||
const { document } = this
|
||||
if (version !== document.version)
|
||||
return
|
||||
const found = await hasDefinition(document.uri, document.positionAt(offset))
|
||||
const found = (await this.definitions.locate(version, offset)).length > 0
|
||||
if (version === document.version && this.ready)
|
||||
this.post({ type: 'definitionProbed', id, found })
|
||||
}
|
||||
@@ -233,7 +244,7 @@ class Preview {
|
||||
return
|
||||
const current = document.uri.toString()
|
||||
const supports = (languageId: string): boolean => this.registry.languageFor(languageId) !== undefined
|
||||
let action = definitionAction(await findDefinitions(document.uri, document.positionAt(offset), supports), current)
|
||||
let action = definitionAction(await findDefinitions(await this.definitions.locate(version, offset), supports), current)
|
||||
if (action.kind === 'pick') {
|
||||
const picked = await pickDefinition(action.targets)
|
||||
action = definitionAction(picked ? [picked] : [], current)
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# luffy-agent-platform 的 backend/src/luffy_agent/celery_app.py 节选:Celery 库规则的样例
|
||||
"""Celery 应用实例.
|
||||
|
||||
使用 Redis 作为 broker 和 result backend。
|
||||
通过 autodiscover_tasks 自动发现各模块的 tasks.py。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
from celery.signals import task_postrun
|
||||
|
||||
from luffy_agent.core.setting import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_role = os.getenv("CELERY_ROLE", "worker")
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
celery_app = Celery(
|
||||
"luffy_agent",
|
||||
broker=settings.CELERY_BROKER_URL,
|
||||
backend=settings.CELERY_RESULT_BACKEND,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
timezone="UTC",
|
||||
task_acks_late=True,
|
||||
result_expires=3600,
|
||||
)
|
||||
|
||||
|
||||
@task_postrun.connect
|
||||
def _on_task_postrun(task_id: str, **kwargs: object) -> None:
|
||||
logger.debug("task %s done", task_id)
|
||||
|
||||
|
||||
celery_app.autodiscover_tasks(
|
||||
[
|
||||
"luffy_agent.user",
|
||||
"luffy_agent.deployment",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _ops_beat_entries(enabled: bool) -> dict[str, dict]:
|
||||
"""OPS_DASHBOARD_ENABLED=false 时两条 ops 任务不进 beat_schedule."""
|
||||
if not enabled:
|
||||
return {}
|
||||
return {
|
||||
"ops.sample_realtime": {
|
||||
"task": "ops.sample_realtime",
|
||||
"schedule": 15,
|
||||
"options": {"expires": 14},
|
||||
},
|
||||
"ops.sample_daily": {
|
||||
"task": "ops.sample_daily",
|
||||
"schedule": crontab(hour=4, minute=0),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
celery_app.conf.beat_schedule = {
|
||||
"commerce.cancel_timeout_pending_orders": {
|
||||
"task": "commerce.cancel_timeout_pending_orders",
|
||||
"schedule": 5 * 60,
|
||||
},
|
||||
"commerce.retry_failed_fulfillments": {
|
||||
"task": "commerce.retry_failed_fulfillments",
|
||||
"schedule": 10 * 60,
|
||||
},
|
||||
"commerce.check_pending_refunds": {
|
||||
"task": "commerce.check_pending_refunds",
|
||||
"schedule": 30 * 60,
|
||||
},
|
||||
"commerce.check_channel_pending_manual_slices": {
|
||||
"task": "commerce.check_channel_pending_manual_slices",
|
||||
"schedule": 10 * 60, # 手动退款切片对账: 微信回调丢失兜底
|
||||
},
|
||||
"deployment.reconcile_inflight": {
|
||||
"task": "deployment.reconcile_inflight",
|
||||
"schedule": 5 * 60, # 发布管线 in-flight 行卡死兜底: 按阶段重新派发
|
||||
},
|
||||
"deployment.reclaim_expired_slot_deployments": {
|
||||
"task": "deployment.reclaim_expired_slot_deployments",
|
||||
"schedule": 10 * 60, # 到期部署位对账: 换票或拆除 (#159)
|
||||
},
|
||||
"deployment.notify_expiring_deployments": {
|
||||
"task": "deployment.notify_expiring_deployments",
|
||||
"schedule": 24 * 60 * 60, # 每天一轮到期提醒扫描 (#274)
|
||||
},
|
||||
"deployment.sweep_terminal_garbage": {
|
||||
"task": "deployment.sweep_terminal_garbage",
|
||||
"schedule": 10
|
||||
* 60, # 终态垃圾兜底: failed 行 teardown 重试 + OSS 败方补清 (#320)
|
||||
},
|
||||
"session.prune_langgraph_checkpoints": {
|
||||
"task": "session.prune_langgraph_checkpoints",
|
||||
# 高频小批量 sweep: 单轮 ≤200 thread + 批间限速 + 270s 预算, 防磁盘吃满
|
||||
# 与清理 IO 风暴 (#316); 频率对齐 LangGraph Platform TTL sweeper 量级
|
||||
"schedule": 5 * 60,
|
||||
},
|
||||
"referral.settle_due_commissions": {
|
||||
"task": "referral.settle_due_commissions",
|
||||
"schedule": 60 * 60, # 每小时扫一次到期佣金
|
||||
},
|
||||
"membership.grant_monthly_entitlements": {
|
||||
"task": "membership.grant_monthly_entitlements",
|
||||
"schedule": 60 * 60,
|
||||
},
|
||||
"storage.poll_app_data_usage": {
|
||||
"task": "storage.poll_app_data_usage",
|
||||
"schedule": get_settings().STORAGE_USAGE_POLL_INTERVAL_SEC,
|
||||
},
|
||||
**_ops_beat_entries(settings.OPS_DASHBOARD_ENABLED),
|
||||
}
|
||||
|
||||
import luffy_agent.commerce.tasks # noqa: E402, F401
|
||||
@@ -1,19 +1,24 @@
|
||||
import type { ExtensionContext, Uri } from 'vscode'
|
||||
import type { CodeReaderApi } from './contract'
|
||||
import type { CodeReaderApi, RuleLocation, RuleReference } from './contract'
|
||||
import { commands, window, workspace } from 'vscode'
|
||||
import { logger } from '../logging/logger'
|
||||
import { BlockFilter } from './editor/blockFilter'
|
||||
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
|
||||
import { showPreview, showSource } from './editor/switchEditor'
|
||||
import { FileTreeProvider } from './explorer/fileTree'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { createTypeScriptAdapter } from './languages/typescript'
|
||||
import { resolveReference } from './libraryRules'
|
||||
import { registerNoteCommands } from './notes/commands'
|
||||
import { FileNotes } from './notes/fileNotes'
|
||||
import { CodeReaderRegistry } from './registry'
|
||||
import { celeryRule } from './rules/celery'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
import { typescriptZhTranslator } from './translators/typescriptZh'
|
||||
import { workspaceIndexHost } from './workspaceFiles'
|
||||
import { WorkspaceIndexes } from './workspaceIndex'
|
||||
|
||||
/** 当前语言插件覆盖的 languageId,给 editor/title 的 when 子句判断要不要显示「预览」。 */
|
||||
const LANGUAGE_IDS_CONTEXT = 'superpowers.codeReader.languageIds'
|
||||
@@ -47,12 +52,21 @@ async function openFile(uri: Uri, registry: CodeReaderRegistry): Promise<void> {
|
||||
export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const registry = new CodeReaderRegistry(context.globalState)
|
||||
const runtime = new TreeSitterRuntime(bundledWasmDir())
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState))
|
||||
const report = (message: string): void => logger.add({ level: 'warn', source: 'codeReader', message })
|
||||
const indexes = new WorkspaceIndexes(workspaceIndexHost(async (languageId, source) => {
|
||||
const adapter = registry.languageFor(languageId)
|
||||
if (!adapter)
|
||||
throw new Error(`没有「${languageId}」的语言插件`)
|
||||
return runtime.parse(adapter, languageId, source)
|
||||
}, report))
|
||||
const resolve = (reference: RuleReference): Promise<RuleLocation[]> => resolveReference(reference, registry.libraryRules(), indexes, report)
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve)
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
|
||||
context.subscriptions.push(
|
||||
notes,
|
||||
indexes,
|
||||
registerNoteCommands(notes),
|
||||
registry.onDidChange(() => {
|
||||
void commands.executeCommand('setContext', LANGUAGE_IDS_CONTEXT, registry.languageIds())
|
||||
@@ -76,6 +90,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerTranslator(typescriptZhTranslator),
|
||||
api.registerTranslator(pythonZhTranslator),
|
||||
api.registerLibraryRule(sqlalchemyRule),
|
||||
api.registerLibraryRule(celeryRule),
|
||||
)
|
||||
return api
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { BlockView, RuleRun } from './blockView'
|
||||
import type { CodeBlock, LibraryRule, RuleBlock } from './contract'
|
||||
import type { CodeBlock, LibraryRule, RuleBlock, RuleLocation, RuleWorkspace, Segment } from './contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileAttributes } from './attributes'
|
||||
import { toBlockViews } from './blockView'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { activeRules, fromLibrary, importsIn } from './libraryRules'
|
||||
import { activeRules, fromLibrary, importsIn, resolveReference } from './libraryRules'
|
||||
import { WASM_DIR, withBlocks } from './testSupport'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
|
||||
@@ -164,3 +164,60 @@ describe('库规则:认领', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('库规则:自己查定义的引用', () => {
|
||||
const workspace: RuleWorkspace = { lookup: async () => [] }
|
||||
const found: RuleLocation[] = [{ uri: 'file:///w/tasks.py', start: { line: 3, character: 4 }, end: { line: 3, character: 8 } }]
|
||||
|
||||
/** 认领类块、在名称那一行标一个引用的规则。 */
|
||||
function referencing(id: string, reference: Segment, resolve?: LibraryRule['resolve']): LibraryRule {
|
||||
return {
|
||||
...cardRule(id, { rows: [{ label: '名称', value: [reference] }] }),
|
||||
...(resolve && { resolve }),
|
||||
}
|
||||
}
|
||||
|
||||
it('引用要标自己的规则、规则要给 resolve、段要带下标,不然这块照常显示', async () => {
|
||||
const reports: string[] = []
|
||||
const resolve = async (): Promise<RuleLocation[]> => found
|
||||
const list = await views(MODEL, [
|
||||
referencing('other', { text: 'x', role: 'string', offset: 20, reference: { rule: 'celery', key: 'x' } }, resolve),
|
||||
referencing('lazy', { text: 'x', role: 'string', offset: 20, reference: { rule: 'lazy', key: 'x' } }),
|
||||
referencing('loose', { text: 'x', role: 'string', reference: { rule: 'loose', key: 'x' } }, resolve),
|
||||
], reports)
|
||||
expect(list[1].kind).toBe('class')
|
||||
expect(reports.map(report => report.replace(/^.*照常显示:/, ''))).toEqual([
|
||||
'引用「x」标的规则是「celery」,不是自己',
|
||||
'标了引用「x」却没给 resolve',
|
||||
'引用「x」没带源码下标',
|
||||
])
|
||||
|
||||
const good = await views(MODEL, [referencing('good', { text: 'x', role: 'string', offset: 20, reference: { rule: 'good', key: 'x' } }, resolve)])
|
||||
expect(good[1].kind).toBe('good.card')
|
||||
})
|
||||
|
||||
it('查引用交给标它的规则;规则不在、没给 resolve、出错时当作没有定义并记下来', async () => {
|
||||
const reports: string[] = []
|
||||
const asked: [string, RuleWorkspace][] = []
|
||||
const rule = referencing('good', { text: 'x', role: 'string', offset: 20 }, async (key, given) => {
|
||||
asked.push([key, given])
|
||||
return found
|
||||
})
|
||||
const broken = referencing('broken', { text: 'x', role: 'string', offset: 20 }, async () => {
|
||||
throw new Error('索引坏了')
|
||||
})
|
||||
const rules = [rule, broken, cardRule('plain')]
|
||||
const report = (message: string): number => reports.push(message)
|
||||
|
||||
expect(await resolveReference({ rule: 'good', key: 'a.b' }, rules, workspace, report)).toEqual(found)
|
||||
expect(asked).toEqual([['a.b', workspace]])
|
||||
expect(await resolveReference({ rule: 'broken', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||||
expect(await resolveReference({ rule: 'plain', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||||
expect(await resolveReference({ rule: 'gone', key: 'a.b' }, rules, workspace, report)).toEqual([])
|
||||
expect(reports).toEqual([
|
||||
'库规则 broken 查「a.b」的定义时出错:索引坏了',
|
||||
'没有能查引用「a.b」的库规则 plain',
|
||||
'没有能查引用「a.b」的库规则 gone',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CodeBlock, ImportFacts, LibraryRule, Node, RuleBlock, RuleContext } from './contract'
|
||||
import type { CodeBlock, ImportFacts, Inline, LibraryRule, Node, PropertyItem, PropertyRow, RuleBlock, RuleContext, RuleLocation, RuleReference, RuleWorkspace, Segment } from './contract'
|
||||
|
||||
/** 宿主这边怎么用库规则:什么时候启用、问哪个规则、认领了之后哪些子块并进去。规则本身只经 contract 拿信息。 */
|
||||
|
||||
@@ -44,6 +44,14 @@ function problemOf(rule: LibraryRule, block: CodeBlock, claimed: RuleBlock): str
|
||||
return `种类 id「${claimed.kind}」没以「${rule.id}.」开头`
|
||||
if (claimed.absorbs.some(child => !block.children.includes(child)))
|
||||
return '要并进来的块不是这块的子块'
|
||||
for (const { reference, offset } of referencedSegments(claimed.rows)) {
|
||||
if (reference?.rule !== rule.id)
|
||||
return `引用「${reference?.key}」标的规则是「${reference?.rule}」,不是自己`
|
||||
if (!rule.resolve)
|
||||
return `标了引用「${reference.key}」却没给 resolve`
|
||||
if (offset === undefined)
|
||||
return `引用「${reference.key}」没带源码下标`
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -82,3 +90,36 @@ export function absorbedRange(block: CodeBlock, absorbs: readonly CodeBlock[]):
|
||||
const endRow = Math.max(block.header.endRow, ...absorbs.map(child => Math.max(child.header.endRow, child.node.endPosition.row)))
|
||||
return { endRow, kept: block.children.filter(child => child.header.startRow > endRow) }
|
||||
}
|
||||
|
||||
/** 文字里(连框里)带引用的段。 */
|
||||
function referencesInText(pieces: readonly Inline[]): Segment[] {
|
||||
return pieces.flatMap(piece => 'role' in piece ? (piece.reference ? [piece] : []) : referencesInText(piece.segments))
|
||||
}
|
||||
|
||||
function referencesInItems(items: readonly PropertyItem[] | undefined): Segment[] {
|
||||
return (items ?? []).flatMap(item => [...item.cells.flatMap(referencesInText), ...referencesInItems(item.items)])
|
||||
}
|
||||
|
||||
/** 属性表里(值、各层子行的格子)带引用的段。 */
|
||||
export function referencedSegments(rows: readonly PropertyRow[]): Segment[] {
|
||||
return rows.flatMap(row => [...('markdown' in row.value ? [] : referencesInText(row.value)), ...referencesInItems(row.items)])
|
||||
}
|
||||
|
||||
/**
|
||||
* 查规则引用的定义:交给标它的那个规则的 resolve。规则已经注销、没给 resolve、查的时候出错,都记日志、当作没有定义,
|
||||
* 预览照常不动。
|
||||
*/
|
||||
export async function resolveReference(reference: RuleReference, rules: readonly LibraryRule[], workspace: RuleWorkspace, report: (message: string) => void): Promise<RuleLocation[]> {
|
||||
const rule = rules.find(candidate => candidate.id === reference.rule)
|
||||
if (!rule?.resolve) {
|
||||
report(`没有能查引用「${reference.key}」的库规则 ${reference.rule}`)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
return await rule.resolve(reference.key, workspace)
|
||||
}
|
||||
catch (err) {
|
||||
report(`库规则 ${rule.id} 查「${reference.key}」的定义时出错:${err instanceof Error ? err.message : String(err)}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,11 @@ export class CodeReaderRegistry implements CodeReaderApi {
|
||||
return this.rules.map(entry => entry.value).filter(rule => rule.languageIds.includes(languageId))
|
||||
}
|
||||
|
||||
/** 注册着的全部库规则,按注册顺序:查规则标的引用时按 id 找。 */
|
||||
libraryRules(): LibraryRule[] {
|
||||
return this.rules.map(entry => entry.value)
|
||||
}
|
||||
|
||||
/** 选中的翻译:globalState 里记的那个还注册着就用它,否则用第一个。 */
|
||||
selectedTranslator(languageId: string): Translator | undefined {
|
||||
const candidates = this.translatorsFor(languageId)
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Tree } from '@vscode/tree-sitter-wasm'
|
||||
import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
|
||||
import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
import { Node } from '@vscode/tree-sitter-wasm'
|
||||
@@ -89,3 +91,67 @@ export function factsByLine(blocks: CodeBlock[]): Record<number, unknown> {
|
||||
})
|
||||
return byLine
|
||||
}
|
||||
|
||||
/** 内存里的工作区(只有一个文件夹 file:///w),给工作区索引用:记下读过哪些文件,能手动发文件改动。 */
|
||||
export class MemoryIndexHost implements IndexHost {
|
||||
static readonly FOLDER = 'file:///w'
|
||||
readonly files: Map<string, string>
|
||||
readonly reads: string[] = []
|
||||
readonly reports: string[] = []
|
||||
lists = 0
|
||||
private readonly listeners = new Set<(event: FileEvent) => void>()
|
||||
|
||||
constructor(files: Record<string, string>, private readonly adapter: LanguageAdapter) {
|
||||
this.files = new Map(Object.entries(files))
|
||||
}
|
||||
|
||||
file(relative: string): WorkspaceFile {
|
||||
return { uri: `${MemoryIndexHost.FOLDER}/${relative}`, folder: MemoryIndexHost.FOLDER, path: relative }
|
||||
}
|
||||
|
||||
async list(include: string): Promise<WorkspaceFile[]> {
|
||||
this.lists++
|
||||
const extension = include.slice(include.lastIndexOf('.'))
|
||||
return [...this.files.keys()].filter(relative => relative.endsWith(extension)).map(relative => this.file(relative))
|
||||
}
|
||||
|
||||
async read(file: WorkspaceFile): Promise<string> {
|
||||
this.reads.push(file.path)
|
||||
const source = this.files.get(file.path)
|
||||
if (source === undefined)
|
||||
throw new Error(`没有 ${file.path}`)
|
||||
return source
|
||||
}
|
||||
|
||||
watch(_include: string, listener: (event: FileEvent) => void): { dispose: () => void } {
|
||||
this.listeners.add(listener)
|
||||
return { dispose: () => this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
parse(languageId: string, source: string): Promise<Tree> {
|
||||
return runtime.parse(this.adapter, languageId, source)
|
||||
}
|
||||
|
||||
report(message: string): void {
|
||||
this.reports.push(message)
|
||||
}
|
||||
|
||||
get watching(): number {
|
||||
return this.listeners.size
|
||||
}
|
||||
|
||||
/** 改文件(source 为 undefined 是删掉)并发出改动。 */
|
||||
emit(kind: FileEvent['kind'], relative: string, source?: string): void {
|
||||
if (source === undefined && kind === 'delete') {
|
||||
for (const known of [...this.files.keys()]) {
|
||||
if (known === relative || known.startsWith(`${relative}/`))
|
||||
this.files.delete(known)
|
||||
}
|
||||
}
|
||||
else if (source !== undefined) {
|
||||
this.files.set(relative, source)
|
||||
}
|
||||
for (const listener of this.listeners)
|
||||
listener({ kind, file: this.file(relative) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { WorkspaceFolder } from 'vscode'
|
||||
import type { IndexHost, WorkspaceFile } from './workspaceIndex'
|
||||
import * as path from 'node:path'
|
||||
import { minimatch } from 'minimatch'
|
||||
import { RelativePattern, Uri, workspace } from 'vscode'
|
||||
|
||||
/** 建索引时总不看的目录:虚拟环境、依赖、Python 字节码缓存。 */
|
||||
const ALWAYS_EXCLUDED = ['**/.venv', '**/node_modules', '**/__pycache__']
|
||||
|
||||
/** 这个工作区文件夹里不看的:files.exclude、search.exclude 里开着的(带 when 条件的不算),加上总不看的目录。 */
|
||||
function excludedPatterns(folder: WorkspaceFolder): string[] {
|
||||
const enabled = (section: string): string[] =>
|
||||
Object.entries(workspace.getConfiguration(section, folder.uri).get<Record<string, unknown>>('exclude') ?? {})
|
||||
.filter(([, on]) => on === true)
|
||||
.map(([pattern]) => pattern)
|
||||
return [...new Set([...enabled('files'), ...enabled('search'), ...ALWAYS_EXCLUDED])]
|
||||
}
|
||||
|
||||
/** 排除规则写的可能是目录(`**\/.git`),目录里的文件也算排除。 */
|
||||
function isExcluded(relative: string, patterns: readonly string[]): boolean {
|
||||
return patterns.some(pattern => minimatch(relative, pattern, { dot: true }) || minimatch(relative, `${pattern}/**`, { dot: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 交给 findFiles 的排除 glob:让它别去翻 .venv 这种大目录。带花括号、逗号的写法拼不进一个 `{…}`,
|
||||
* 留给找到之后再按 isExcluded 筛。
|
||||
*/
|
||||
function excludeGlob(patterns: readonly string[]): string {
|
||||
return `{${patterns.filter(pattern => !/[{},]/.test(pattern)).flatMap(pattern => [pattern, `${pattern}/**`]).join(',')}}`
|
||||
}
|
||||
|
||||
function workspaceFile(uri: Uri): { file: WorkspaceFile, folder: WorkspaceFolder } | undefined {
|
||||
const folder = workspace.getWorkspaceFolder(uri)
|
||||
if (!folder)
|
||||
return undefined
|
||||
return { folder, file: { uri: uri.toString(), folder: folder.uri.toString(), path: path.posix.relative(folder.uri.path, uri.path) } }
|
||||
}
|
||||
|
||||
/** 库规则的工作区索引在 VS Code 里怎么找文件、读文件、盯改动;解析交给调用方(语言插件 + 解析运行时)。 */
|
||||
export function workspaceIndexHost(parse: IndexHost['parse'], report: IndexHost['report']): IndexHost {
|
||||
return {
|
||||
async list(include) {
|
||||
const found = await Promise.all((workspace.workspaceFolders ?? []).map(async (folder) => {
|
||||
const patterns = excludedPatterns(folder)
|
||||
const uris = await workspace.findFiles(new RelativePattern(folder, include), excludeGlob(patterns))
|
||||
return uris.flatMap((uri) => {
|
||||
const file = workspaceFile(uri)?.file
|
||||
return file && !isExcluded(file.path, patterns) ? [file] : []
|
||||
})
|
||||
}))
|
||||
return found.flat()
|
||||
},
|
||||
async read(file) {
|
||||
return new TextDecoder('utf-8').decode(await workspace.fs.readFile(Uri.parse(file.uri)))
|
||||
},
|
||||
watch(include, listener) {
|
||||
const forward = (kind: 'change' | 'create') => (uri: Uri): void => {
|
||||
const found = workspaceFile(uri)
|
||||
if (found && minimatch(found.file.path, include, { dot: true }) && !isExcluded(found.file.path, excludedPatterns(found.folder)))
|
||||
listener({ kind, file: found.file })
|
||||
}
|
||||
// 删目录时 VS Code 只报目录本身,匹配不上 include,所以删除另用一个什么都盯的
|
||||
const files = workspace.createFileSystemWatcher(include, false, false, true)
|
||||
const deletions = workspace.createFileSystemWatcher('**/*', true, true, false)
|
||||
const subscriptions = [
|
||||
files,
|
||||
deletions,
|
||||
files.onDidChange(forward('change')),
|
||||
files.onDidCreate(forward('create')),
|
||||
deletions.onDidDelete((uri) => {
|
||||
const found = workspaceFile(uri)
|
||||
if (found)
|
||||
listener({ kind: 'delete', file: found.file })
|
||||
}),
|
||||
]
|
||||
return { dispose: () => subscriptions.forEach(subscription => subscription.dispose()) }
|
||||
},
|
||||
parse,
|
||||
report,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { IndexedFile, IndexEntry, Node, WorkspaceIndex } from './contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createPythonAdapter } from './languages/python'
|
||||
import { MemoryIndexHost, WASM_DIR } from './testSupport'
|
||||
import { WorkspaceIndexes } from './workspaceIndex'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
|
||||
/** 测试用的索引:每个函数一项,键是「包路径:函数名」,文件所在目录有 __init__.py 时包路径是目录,否则是空的。 */
|
||||
function functionIndex(seen: string[] = []): WorkspaceIndex {
|
||||
return {
|
||||
id: 'test.functions',
|
||||
languageId: 'python',
|
||||
include: '**/*.py',
|
||||
mentions: source => source.includes('def '),
|
||||
entries: (root: Node, file: IndexedFile): IndexEntry[] => {
|
||||
seen.push(file.path)
|
||||
const directory = file.path.split('/').slice(0, -1).join('/')
|
||||
const prefix = file.exists(`${directory}/./__init__.py`) ? directory : ''
|
||||
return root.descendantsOfType('function_definition').flatMap((fn) => {
|
||||
const name = fn?.childForFieldName('name')
|
||||
return name ? [{ key: `${prefix}:${name.text}`, name: { startIndex: name.startIndex, endIndex: name.endIndex } }] : []
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function setup(files: Record<string, string>): { host: MemoryIndexHost, indexes: WorkspaceIndexes } {
|
||||
const host = new MemoryIndexHost(files, py)
|
||||
return { host, indexes: new WorkspaceIndexes(host) }
|
||||
}
|
||||
|
||||
describe('工作区索引', () => {
|
||||
it('第一次查才建;查到的是名字的行列(列按 UTF-16 算),照文件、行排好', async () => {
|
||||
const { host, indexes } = setup({
|
||||
'b.py': '# 中文注释\ndef load():\n pass\n',
|
||||
'a.py': 'def load():\n pass\n\n\ndef load():\n pass\n',
|
||||
'notes.txt': 'def load():',
|
||||
})
|
||||
expect(host.lists).toBe(0)
|
||||
expect(await indexes.lookup(functionIndex(), ':load')).toEqual([
|
||||
{ uri: 'file:///w/a.py', start: { line: 0, character: 4 }, end: { line: 0, character: 8 } },
|
||||
{ uri: 'file:///w/a.py', start: { line: 4, character: 4 }, end: { line: 4, character: 8 } },
|
||||
{ uri: 'file:///w/b.py', start: { line: 1, character: 4 }, end: { line: 1, character: 8 } },
|
||||
])
|
||||
expect(await indexes.lookup(functionIndex(), ':missing')).toEqual([])
|
||||
expect(host.lists).toBe(1)
|
||||
})
|
||||
|
||||
it('预筛不过的文件不解析', async () => {
|
||||
const seen: string[] = []
|
||||
const { indexes } = setup({ 'a.py': 'def a(): pass\n', 'b.py': 'x = 1\n' })
|
||||
await indexes.lookup(functionIndex(seen), ':a')
|
||||
expect(seen).toEqual(['a.py'])
|
||||
})
|
||||
|
||||
it('文件改了只重看那一份;新建的加进来;删掉的目录连里面的文件一起去掉', async () => {
|
||||
const { host, indexes } = setup({ 'a.py': 'def a(): pass\n', 'pkg/b.py': 'def b(): pass\n', 'pkg/deep/c.py': 'def c(): pass\n' })
|
||||
const index = functionIndex()
|
||||
await indexes.lookup(index, ':a')
|
||||
host.reads.length = 0
|
||||
|
||||
host.emit('change', 'a.py', 'def renamed(): pass\n')
|
||||
expect(await indexes.lookup(index, ':a')).toEqual([])
|
||||
expect(await indexes.lookup(index, ':renamed')).toHaveLength(1)
|
||||
expect(host.reads).toEqual(['a.py'])
|
||||
|
||||
host.emit('create', 'd.py', '\n\ndef d(): pass\n')
|
||||
expect((await indexes.lookup(index, ':d'))[0].start).toEqual({ line: 2, character: 4 })
|
||||
|
||||
host.emit('delete', 'pkg')
|
||||
expect(await indexes.lookup(index, ':b')).toEqual([])
|
||||
expect(await indexes.lookup(index, ':c')).toEqual([])
|
||||
expect(host.reads).toEqual(['a.py', 'd.py'])
|
||||
})
|
||||
|
||||
it('新建、删掉别的文件问过在不在的文件(__init__.py)时,问过它的文件跟着重看', async () => {
|
||||
const { host, indexes } = setup({ 'pkg/mod.py': 'def run(): pass\n', 'other.py': 'def other(): pass\n' })
|
||||
const index = functionIndex()
|
||||
expect(await indexes.lookup(index, ':run')).toHaveLength(1)
|
||||
host.reads.length = 0
|
||||
|
||||
host.emit('create', 'pkg/__init__.py', '')
|
||||
expect(await indexes.lookup(index, 'pkg:run')).toHaveLength(1)
|
||||
expect(host.reads.sort()).toEqual(['pkg/__init__.py', 'pkg/mod.py'])
|
||||
|
||||
host.emit('delete', 'pkg/__init__.py')
|
||||
expect(await indexes.lookup(index, ':run')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('建索引期间的改动建好后补上', async () => {
|
||||
const { host, indexes } = setup({ 'a.py': 'def a(): pass\n' })
|
||||
const index = functionIndex()
|
||||
const first = indexes.lookup(index, ':a')
|
||||
host.emit('change', 'a.py', 'def b(): pass\n')
|
||||
await first
|
||||
expect(await indexes.lookup(index, ':b')).toHaveLength(1)
|
||||
expect(await indexes.lookup(index, ':a')).toEqual([])
|
||||
})
|
||||
|
||||
it('读不了、规则出错的文件记下来,别的照常', async () => {
|
||||
const { host, indexes } = setup({ 'a.py': 'def a(): pass\n', 'bad.py': 'def boom(): pass\n' })
|
||||
host.files.delete('bad.py')
|
||||
host.files.set('b.py', 'def b(): pass\n')
|
||||
const index = functionIndex()
|
||||
const broken: WorkspaceIndex = {
|
||||
...index,
|
||||
id: 'test.broken',
|
||||
entries: (root, file) => {
|
||||
if (file.path === 'a.py')
|
||||
throw new Error('认不了')
|
||||
return index.entries(root, file)
|
||||
},
|
||||
}
|
||||
// bad.py 在找到的文件里,读的时候已经没了
|
||||
host.list = async () => ['a.py', 'bad.py', 'b.py'].map(relative => host.file(relative))
|
||||
expect(await indexes.lookup(broken, ':b')).toHaveLength(1)
|
||||
expect([...host.reports].sort()).toEqual([
|
||||
'索引 test.broken 看 file:///w/a.py 时出错:认不了',
|
||||
'索引 test.broken 读不了 file:///w/bad.py:没有 bad.py',
|
||||
])
|
||||
})
|
||||
|
||||
it('同一个 id 只建一份;dispose 后不再盯着文件', async () => {
|
||||
const { host, indexes } = setup({ 'a.py': 'def a(): pass\n' })
|
||||
await indexes.lookup(functionIndex(), ':a')
|
||||
await indexes.lookup(functionIndex(), ':a')
|
||||
expect(host.lists).toBe(1)
|
||||
expect(host.watching).toBe(1)
|
||||
indexes.dispose()
|
||||
expect(host.watching).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { Tree } from '@vscode/tree-sitter-wasm'
|
||||
import type { RuleLocation, RulePosition, RuleWorkspace, WorkspaceIndex } from './contract'
|
||||
import * as path from 'node:path'
|
||||
|
||||
/**
|
||||
* 库规则的工作区索引(WorkspaceIndex)的宿主部分:找文件、预筛、解析、缓存,跟着文件改动只重看改了的文件。
|
||||
* 规则只说要什么;这里不碰 VS Code,工作区的能力由 IndexHost 给(VS Code 里是 workspaceFiles.ts,测试里是内存里的文件)。
|
||||
*/
|
||||
|
||||
/** 工作区里的一份文件:uri,它所在的工作区文件夹(uri)和相对这个文件夹的路径(/ 分隔)。 */
|
||||
export interface WorkspaceFile { uri: string, folder: string, path: string }
|
||||
|
||||
/** 文件改动:改了、新建了、删了(删的可能是目录,里面的文件一起算删了)。 */
|
||||
export interface FileEvent { kind: 'change' | 'create' | 'delete', file: WorkspaceFile }
|
||||
|
||||
export interface IndexHost {
|
||||
/** include 在各工作区文件夹里找到的文件,排除的不算。 */
|
||||
list: (include: string) => Promise<WorkspaceFile[]>
|
||||
read: (file: WorkspaceFile) => Promise<string>
|
||||
/** 盯着文件改动:include 以外和被排除的文件改了、新建了不报,删除都报。 */
|
||||
watch: (include: string, listener: (event: FileEvent) => void) => { dispose: () => void }
|
||||
/** 用 languageId 的语言插件解析;返回的语法树用完要 delete。 */
|
||||
parse: (languageId: string, source: string) => Promise<Tree>
|
||||
report: (message: string) => void
|
||||
}
|
||||
|
||||
/** 一次读几份文件:建索引时不一口气把整个工作区的文件都读进内存。 */
|
||||
const READ_BATCH = 32
|
||||
|
||||
function keyOf(folder: string, relative: string): string {
|
||||
return `${folder}\n${path.posix.normalize(relative)}`
|
||||
}
|
||||
|
||||
/** 每一行开头在源码里的下标。 */
|
||||
function lineStarts(source: string): number[] {
|
||||
const starts = [0]
|
||||
for (let i = source.indexOf('\n'); i >= 0; i = source.indexOf('\n', i + 1))
|
||||
starts.push(i + 1)
|
||||
return starts
|
||||
}
|
||||
|
||||
function positionAt(starts: number[], index: number): RulePosition {
|
||||
let low = 0
|
||||
let high = starts.length - 1
|
||||
while (low < high) {
|
||||
const middle = (low + high + 1) >> 1
|
||||
if (starts[middle] <= index)
|
||||
low = middle
|
||||
else
|
||||
high = middle - 1
|
||||
}
|
||||
return { line: low, character: index - starts[low] }
|
||||
}
|
||||
|
||||
/** 一份看过的文件:认出的各项,和建索引时问过在不在的文件(它们新建、删掉时这份要重看)。 */
|
||||
interface Indexed {
|
||||
file: WorkspaceFile
|
||||
entries: { key: string, location: RuleLocation }[]
|
||||
asked: Set<string>
|
||||
}
|
||||
|
||||
function compareLocations(a: RuleLocation, b: RuleLocation): number {
|
||||
return a.uri.localeCompare(b.uri) || a.start.line - b.start.line || a.start.character - b.start.character
|
||||
}
|
||||
|
||||
/** 一个 WorkspaceIndex 建出来的索引:建好之前的查询、文件改动都排在建索引后面,一件件来。 */
|
||||
class BuiltIndex {
|
||||
/** include 找到的全部文件(按 keyOf),IndexedFile.exists 查的就是它 */
|
||||
private readonly files = new Map<string, WorkspaceFile>()
|
||||
/** 预筛过了、解析过的文件 */
|
||||
private readonly indexed = new Map<string, Indexed>()
|
||||
private readonly watcher: { dispose: () => void }
|
||||
private queue: Promise<void>
|
||||
|
||||
constructor(private readonly index: WorkspaceIndex, private readonly host: IndexHost) {
|
||||
// 先盯着再找文件:找文件期间的改动排在建好之后补上
|
||||
this.watcher = host.watch(index.include, event => this.enqueue(() => this.apply(event)))
|
||||
this.queue = this.guard(() => this.build())
|
||||
}
|
||||
|
||||
async lookup(key: string): Promise<RuleLocation[]> {
|
||||
await this.queue
|
||||
return [...this.indexed.values()]
|
||||
.flatMap(indexed => indexed.entries.filter(entry => entry.key === key).map(entry => entry.location))
|
||||
.sort(compareLocations)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.watcher.dispose()
|
||||
}
|
||||
|
||||
private enqueue(task: () => Promise<void>): void {
|
||||
this.queue = this.queue.then(() => this.guard(task))
|
||||
}
|
||||
|
||||
private async guard(task: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await task()
|
||||
}
|
||||
catch (err) {
|
||||
this.host.report(`索引 ${this.index.id} 更新失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async build(): Promise<void> {
|
||||
const files = await this.host.list(this.index.include)
|
||||
for (const file of files)
|
||||
this.files.set(keyOf(file.folder, file.path), file)
|
||||
for (let i = 0; i < files.length; i += READ_BATCH)
|
||||
await Promise.all(files.slice(i, i + READ_BATCH).map(file => this.visit(file)))
|
||||
}
|
||||
|
||||
/** 重看一份文件:读、预筛、解析、交给规则认。 */
|
||||
private async visit(file: WorkspaceFile): Promise<void> {
|
||||
const key = keyOf(file.folder, file.path)
|
||||
this.indexed.delete(key)
|
||||
let source: string
|
||||
let tree: Tree
|
||||
try {
|
||||
source = await this.host.read(file)
|
||||
if (!this.index.mentions(source))
|
||||
return
|
||||
tree = await this.host.parse(this.index.languageId, source)
|
||||
}
|
||||
catch (err) {
|
||||
this.host.report(`索引 ${this.index.id} 读不了 ${file.uri}:${err instanceof Error ? err.message : String(err)}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const asked = new Set<string>()
|
||||
const exists = (relative: string): boolean => {
|
||||
const other = keyOf(file.folder, relative)
|
||||
asked.add(other)
|
||||
return this.files.has(other)
|
||||
}
|
||||
const starts = lineStarts(source)
|
||||
const entries = this.index.entries(tree.rootNode, { path: file.path, source, exists }).map(({ key: entryKey, name }) => ({
|
||||
key: entryKey,
|
||||
location: { uri: file.uri, start: positionAt(starts, name.startIndex), end: positionAt(starts, name.endIndex) },
|
||||
}))
|
||||
this.indexed.set(key, { file, entries, asked })
|
||||
}
|
||||
catch (err) {
|
||||
this.host.report(`索引 ${this.index.id} 看 ${file.uri} 时出错:${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
finally {
|
||||
tree.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private async apply({ kind, file }: FileEvent): Promise<void> {
|
||||
const key = keyOf(file.folder, file.path)
|
||||
if (kind === 'change') {
|
||||
if (this.files.has(key))
|
||||
await this.visit(file)
|
||||
return
|
||||
}
|
||||
const touched = kind === 'create' ? this.add(file) : this.remove(file)
|
||||
// 问过这些文件在不在的(Python 包路径看 __init__.py)跟着重看
|
||||
const dependents = [...this.indexed.values()]
|
||||
.filter(indexed => touched.some(other => indexed.asked.has(other)))
|
||||
.map(indexed => indexed.file)
|
||||
.filter(dependent => keyOf(dependent.folder, dependent.path) !== key)
|
||||
await Promise.all([...(kind === 'create' ? [file] : []), ...dependents].map(target => this.visit(target)))
|
||||
}
|
||||
|
||||
private add(file: WorkspaceFile): string[] {
|
||||
const key = keyOf(file.folder, file.path)
|
||||
this.files.set(key, file)
|
||||
return [key]
|
||||
}
|
||||
|
||||
/** 删掉文件,或目录连同里面的文件。 */
|
||||
private remove(file: WorkspaceFile): string[] {
|
||||
const prefix = `${path.posix.normalize(file.path)}/`
|
||||
const gone = [...this.files.entries()]
|
||||
.filter(([, known]) => known.folder === file.folder && (known.path === file.path || known.path.startsWith(prefix)))
|
||||
.map(([key]) => key)
|
||||
for (const key of gone) {
|
||||
this.files.delete(key)
|
||||
this.indexed.delete(key)
|
||||
}
|
||||
return gone
|
||||
}
|
||||
}
|
||||
|
||||
/** 各库规则的工作区索引:第一次查某个索引时才建,同一个 id 只建一份。 */
|
||||
export class WorkspaceIndexes implements RuleWorkspace {
|
||||
private readonly built = new Map<string, BuiltIndex>()
|
||||
|
||||
constructor(private readonly host: IndexHost) {}
|
||||
|
||||
lookup(index: WorkspaceIndex, key: string): Promise<RuleLocation[]> {
|
||||
let built = this.built.get(index.id)
|
||||
if (!built) {
|
||||
built = new BuiltIndex(index, this.host)
|
||||
this.built.set(index.id, built)
|
||||
}
|
||||
return built.lookup(key)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const built of this.built.values())
|
||||
built.dispose()
|
||||
this.built.clear()
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,14 @@ export type TokenRole
|
||||
| 'hint' // 属性表里的说明词:未标注 无 N 个 默认导出 …
|
||||
| 'variable' | 'function' | 'type' | 'string' | 'number' | 'literal' | 'comment' | 'plain'
|
||||
|
||||
/** offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有 */
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number }
|
||||
/**
|
||||
* offset:照抄自源码里的名字、字符串时它在源码里的起始下标,⌘ / Ctrl 悬停、点击时拿它查定义;译词、提示词没有。
|
||||
* reference:库规则自己查定义的名字(Celery 的任务名)才有;webview 不看它,照样只报 offset,扩展端按 offset 认出来交给规则查
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference }
|
||||
|
||||
/** 规则自己查得到定义的一个名字:哪个规则、规则自己认的键 */
|
||||
export interface RuleReference { rule: string, key: string }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块 */
|
||||
export type GroupStyle = 'iteration'
|
||||
|
||||
Reference in new issue
Block a user