diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index e345685..ed5a4ee 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -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 // 第一次查时建,照文件、行排好 +} + 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 // 查自己标的引用;没给就不能标引用 } 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 子句对得上。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 9ba08fb..a7d4b5b 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -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 +} + /** * 库规则:认出某个库的一种写法(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 } export interface CodeReaderApi { diff --git a/vscode/src/codeReader/editor/definitionSources.test.ts b/vscode/src/codeReader/editor/definitionSources.test.ts new file mode 100644 index 0000000..6f9e4ec --- /dev/null +++ b/vscode/src/codeReader/editor/definitionSources.test.ts @@ -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 { + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [celeryRule], report: () => {} })) +} + +/** 记下问了哪边:规则(引用的键)还是语言扩展(下标)。 */ +function recorder(): { asked: string[], sources: DefinitionSources } { + const asked: string[] = [] + const sources = new DefinitionSources({ + 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}`]) + }) +}) diff --git a/vscode/src/codeReader/editor/definitionSources.ts b/vscode/src/codeReader/editor/definitionSources.ts new file mode 100644 index 0000000..c5698fd --- /dev/null +++ b/vscode/src/codeReader/editor/definitionSources.ts @@ -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 { + const found = new Map() + 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 { + rule: (reference: RuleReference) => Promise + language: (offset: number) => Promise +} + +/** + * 预览里一个名字的定义去哪儿查:发出去的文档里库规则标了引用的问规则,其余问语言扩展。 + * webview 只报名字的源码下标,⌘ / Ctrl 悬停、点击都走这一条路,链接样子、跳转、挑选、闪都一样。 + * 引用按最近发出去的那份文档记,只对它的版本有效。 + */ +export class DefinitionSources { + private version: number | undefined + private references = new Map() + + constructor(private readonly providers: DefinitionProviders) {} + + /** 发出去了一份文档:记下它里面的引用。 */ + remember(version: number, blocks: readonly BlockView[]): void { + this.version = version + this.references = referencesIn(blocks) + } + + /** version 这份文档里 offset 处名字的各处定义。 */ + locate(version: number, offset: number): Promise { + const reference = version === this.version ? this.references.get(offset) : undefined + return reference ? this.providers.rule(reference) : this.providers.language(offset) + } +} diff --git a/vscode/src/codeReader/editor/definitions.ts b/vscode/src/codeReader/editor/definitions.ts index d1f49ed..9bbd654 100644 --- a/vscode/src/codeReader/editor/definitions.ts +++ b/vscode/src/codeReader/editor/definitions.ts @@ -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 { + 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 { - 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 { - const found = await Promise.all((await provided(uri, position)).map(async (item): Promise => { - 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 { + const found = await Promise.all(locations.map(async (location): Promise => { const { start } = location.range try { const document = await workspace.openTextDocument(location.uri) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 303e4fa..4fbce1b 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -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, ) {} 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 /** 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) diff --git a/vscode/src/codeReader/fixtures/celeryApp.py b/vscode/src/codeReader/fixtures/celeryApp.py new file mode 100644 index 0000000..9fb3ed8 --- /dev/null +++ b/vscode/src/codeReader/fixtures/celeryApp.py @@ -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 diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index b96727b..e30a782 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -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 { 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 => 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 } diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 05c354d..483002f 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -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 => 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', + ]) + }) +}) diff --git a/vscode/src/codeReader/libraryRules.ts b/vscode/src/codeReader/libraryRules.ts index 3a0149a..a6ebb34 100644 --- a/vscode/src/codeReader/libraryRules.ts +++ b/vscode/src/codeReader/libraryRules.ts @@ -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 { + 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 [] + } +} diff --git a/vscode/src/codeReader/registry.ts b/vscode/src/codeReader/registry.ts index d96e2f4..edea70e 100644 --- a/vscode/src/codeReader/registry.ts +++ b/vscode/src/codeReader/registry.ts @@ -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) diff --git a/vscode/src/codeReader/rules/celery.test.ts b/vscode/src/codeReader/rules/celery.test.ts new file mode 100644 index 0000000..290cf34 --- /dev/null +++ b/vscode/src/codeReader/rules/celery.test.ts @@ -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 { + 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 { + 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 { + 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 = {}): { 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 { + 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') + }) +}) diff --git a/vscode/src/codeReader/rules/celery.ts b/vscode/src/codeReader/rules/celery.ts new file mode 100644 index 0000000..76fe31c --- /dev/null +++ b/vscode/src/codeReader/rules/celery.ts @@ -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 + /** 调度是个写成秒数的表达式时前后的字:「每 X 秒」 */ + everySeconds: [string, string] + frequency: (frequency: Frequency) => string +} + +const ZH_UNITS: Record = { second: ['每秒', '秒'], minute: ['每分钟', '分钟'], hour: ['每小时', '小时'], day: ['每天', '天'] } +const ZH_WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] + +function zhSpans(spans: Span[], one: (n: number) => string, between: (from: number, to: number) => string): string { + return spans.map(span => span.to === undefined ? one(span.from) : between(span.from, span.to)).join('、') +} + +function zhDays(days: Days): string { + const weekday = (n: number): string => ZH_WEEKDAYS[n % 7] + const day = (n: number): string => `${n} 日` + const dayRange = (from: number, to: number): string => `${from} 至 ${to} 日` + switch (days.kind) { + case 'daily': + return '每天' + case 'weekly': + return `每${zhSpans(days.days, weekday, (from, to) => `${weekday(from)}至${weekday(to)}`)}` + case 'monthly': + return `每月 ${zhSpans(days.days, day, dayRange)}` + case 'yearly': + return `每年 ${zhSpans(days.months, n => `${n} 月`, (from, to) => `${from} 至 ${to} 月`)} ${zhSpans(days.days, day, dayRange)}` + } +} + +function zhTime(time: Time): string { + const pad = (n: number): string => String(n).padStart(2, '0') + switch (time.kind) { + case 'minutes': + return time.step === 1 ? '每分钟' : `每 ${time.step} 分钟` + case 'hourly': { + const every = time.step === 1 ? '每小时' : `每 ${time.step} 小时` + return time.minutes.length === 1 && time.minutes[0] === 0 ? `${every}整点` : `${every}的第 ${time.minutes.join('、')} 分` + } + case 'at': + return time.times.map(({ hour, minute }) => `${pad(hour)}:${pad(minute)}`).join('、') + } +} + +const ZH: Words = { + appKind: 'Celery 应用', + beatKind: '定时任务', + labels: { name: '名称', doc: '说明', main: '应用名', broker: '消息队列', backend: '结果存储', app: '应用', tasks: '任务' }, + spread: '展开', + heads: { task: '任务', frequency: '频率', options: '选项', doc: '说明' }, + entries: (count, spreads) => spreads > 0 ? `${count} 条,另有展开 ${spreads} 处` : `${count} 条`, + entryKey: key => `(条目名 ${key})`, + noTask: '没写任务名', + options: { args: '位置参数', kwargs: '关键字参数', options: '执行选项' }, + everySeconds: ['每 ', ' 秒'], + frequency: (frequency) => { + if (frequency.kind === 'interval') { + const [one, unit] = ZH_UNITS[frequency.unit] + return frequency.count === 1 ? one : `每 ${frequency.count} ${unit}` + } + const { days, time } = frequency + if (time.kind === 'at') + return `${zhDays(days)} ${zhTime(time)}` + return days.kind === 'daily' ? zhTime(time) : `${zhDays(days)} ${zhTime(time)}` + }, +} + +/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */ +const WORDS: Record = { zh: ZH } + +/** 认一块要用的:宿主给的 context、挑好的用词、别名表。 */ +interface Env { + context: RuleContext + words: Words + aliases: Map +} + +// ─── 频率:从调度写法换算 ────────────────────────────────────────────────────── + +/** 只由数字字面量和 + - * / // % ** 组成的算式的值;带名字、调用的算不出来。 */ +function numberOf(node: Node | null | undefined): number | undefined { + switch (node?.type) { + case 'integer': + case 'float': { + const value = Number(node.text.replace(/_/g, '')) + return Number.isFinite(value) ? value : undefined + } + case 'parenthesized_expression': { + const inner = syntaxChildren(node) + return inner.length === 1 ? numberOf(inner[0]) : undefined + } + case 'unary_operator': { + const value = numberOf(node.childForFieldName('argument')) + const operator = node.childForFieldName('operator')?.type + return value === undefined ? undefined : operator === '-' ? -value : operator === '+' ? value : undefined + } + case 'binary_operator': { + const left = numberOf(node.childForFieldName('left')) + const right = numberOf(node.childForFieldName('right')) + if (left === undefined || right === undefined) + return undefined + switch (node.childForFieldName('operator')?.type) { + case '+': return left + right + case '-': return left - right + case '*': return left * right + case '/': return right === 0 ? undefined : left / right + case '//': return right === 0 ? undefined : Math.floor(left / right) + case '%': return right === 0 ? undefined : left % right + case '**': return left ** right + default: return undefined + } + } + default: + return undefined + } +} + +/** 秒数换成最大的整单位:86400 → 每天,7200 → 每 2 小时,90 → 每 90 秒。 */ +function interval(seconds: number): Frequency | undefined { + if (!Number.isFinite(seconds) || seconds <= 0) + return undefined + for (const [unit, size] of [['day', 86400], ['hour', 3600], ['minute', 60]] as const) { + if (Number.isInteger(seconds / size)) + return { kind: 'interval', count: seconds / size, unit } + } + return { kind: 'interval', count: seconds, unit: 'second' } +} + +/** `timedelta(...)` 一共多少秒;有参数算不出来就没有。 */ +function timedeltaSeconds(call: Call): number | undefined { + if (call.splats.length > 0) + return undefined + const units = new Map(TIMEDELTA_UNITS) + const parts: [Node, number | undefined][] = [ + ...call.positional.map((arg, i): [Node, number | undefined] => [arg, TIMEDELTA_UNITS[i]?.[1]]), + ...[...call.keywords].map(([key, value]): [Node, number | undefined] => [value, units.get(key)]), + ] + let total = 0 + for (const [node, size] of parts) { + const value = numberOf(node) + if (value === undefined || size === undefined) + return undefined + total += value * size + } + return total +} + +/** crontab 的一个字段:*、*\/n、一串数(逗号隔开,可以是 a-b 一段);列表 / 集合 / 元组里的整数也算一串数。 */ +type Field = { kind: 'any' } | { kind: 'step', step: number } | { kind: 'spans', spans: Span[] } + +function parseField(text: string, names: readonly string[] | undefined): Field | undefined { + if (text === '*') + return { kind: 'any' } + const step = /^\*\/(\d+)$/.exec(text) + if (step) + return Number(step[1]) > 0 ? { kind: 'step', step: Number(step[1]) } : undefined + const value = (part: string): number | undefined => { + if (/^\d+$/.test(part)) + return Number(part) + const index = names?.indexOf(part.slice(0, 3)) ?? -1 + return part.length >= 3 && index >= 0 ? index : undefined + } + const spans: Span[] = [] + for (const part of text.split(',')) { + const [from, to, ...rest] = part.trim().split('-') + const start = value(from) + const end = to === undefined ? undefined : value(to) + if (rest.length > 0 || start === undefined || (to !== undefined && end === undefined)) + return undefined + spans.push(end === undefined ? { from: start } : { from: start, to: end }) + } + return { kind: 'spans', spans } +} + +function fieldOf(node: Node | undefined, names?: readonly string[]): Field | undefined { + if (!node) + return { kind: 'any' } + if (node.type === 'integer') + return parseField(node.text, undefined) + if (node.type === 'list' || node.type === 'set' || node.type === 'tuple') { + const items = syntaxChildren(node) + return items.length > 0 && items.every(item => item.type === 'integer') ? parseField(items.map(item => item.text).join(','), undefined) : undefined + } + const text = stringValue(node) + return text === undefined ? undefined : parseField(text.trim().toLowerCase(), names) +} + +/** 一个个单独的数(没有 a-b 一段),都在 0..max 里。 */ +function singles(field: Field, max: number): number[] | undefined { + if (field.kind !== 'spans' || field.spans.some(span => span.to !== undefined || span.from > max)) + return undefined + return field.spans.map(span => span.from).sort((a, b) => a - b) +} + +/** 取值都在 min..max 里的几段。 */ +function spansWithin(field: Field, min: number, max: number): Span[] | undefined { + if (field.kind !== 'spans' || field.spans.some(span => span.from < min || (span.to ?? span.from) > max)) + return undefined + return field.spans +} + +function timeOf(minute: Field, hour: Field): Time | undefined { + if (hour.kind === 'any' && minute.kind === 'any') + return { kind: 'minutes', step: 1 } + if (hour.kind === 'any' && minute.kind === 'step') + return { kind: 'minutes', step: minute.step } + const minutes = singles(minute, 59) + if (!minutes) + return undefined + if (hour.kind === 'any') + return { kind: 'hourly', step: 1, minutes } + if (hour.kind === 'step') + return { kind: 'hourly', step: hour.step, minutes } + const hours = singles(hour, 23) + return hours && { kind: 'at', times: hours.flatMap(h => minutes.map(m => ({ hour: h, minute: m }))) } +} + +function daysOf(week: Field, month: Field, year: Field): Days | undefined { + const any = (field: Field): boolean => field.kind === 'any' + if (any(week) && any(month) && any(year)) + return { kind: 'daily' } + if (any(month) && any(year)) { + const days = spansWithin(week, 0, 7) + return days && { kind: 'weekly', days } + } + if (!any(week)) + return undefined + const days = spansWithin(month, 1, 31) + if (!days) + return undefined + if (any(year)) + return { kind: 'monthly', days } + const months = spansWithin(year, 1, 12) + return months && { kind: 'yearly', months, days } +} + +function crontabFrequency(call: Call): Frequency | undefined { + if (call.splats.length > 0) + return undefined + const fields = new Map() + call.positional.forEach((arg, i) => fields.set(CRONTAB_FIELDS[i] ?? String(i), arg)) + for (const [key, value] of call.keywords) + fields.set(key, value) + if (call.positional.length > CRONTAB_FIELDS.length) + return undefined + const [minute, hour, week, month, year] = CRONTAB_FIELDS.map(field => fieldOf(fields.get(field), field === 'day_of_week' ? WEEKDAY_NAMES : undefined)) + if (!minute || !hour || !week || !month || !year) + return undefined + const days = daysOf(week, month, year) + const time = timeOf(minute, hour) + return days && time && { kind: 'crontab', days, time } +} + +/** 调度写法换算成频率:秒数算式、timedelta、crontab、schedule(run_every=…);换算不了的没有。 */ +function frequencyOf(node: Node, aliases: ReadonlyMap): Frequency | undefined { + const seconds = numberOf(node) + if (seconds !== undefined) + return interval(seconds) + const call = callOf(node, aliases) + switch (call?.name) { + case 'timedelta': { + const total = timedeltaSeconds(call) + return total === undefined ? undefined : interval(total) + } + case 'crontab': + return crontabFrequency(call) + case 'schedule': { + const every = call.keywords.get('run_every') ?? call.positional[0] + return every && frequencyOf(every, aliases) + } + default: + return undefined + } +} + +/** 频率一格:换算得出的说成人话;写成秒数的表达式写「每 X 秒」;别的照写法翻。 */ +function frequencyCell(node: Node, env: Env): Inline[] { + const frequency = frequencyOf(node, env.aliases) + if (frequency) + return [{ text: env.words.frequency(frequency), role: 'keyword' }] + if (!NUMBER_LIKE.has(node.type)) + return env.context.translate(node) + const [before, after] = env.words.everySeconds + return [{ text: before, role: 'keyword' }, ...env.context.translate(node), { text: after, role: 'keyword' }] +} + +// ─── 定时任务表 ─────────────────────────────────────────────────────────────── + +/** 定时任务表里的一条:`"键": {...}`(或别的值),或 `**x` 展开;comments 是讲它的注释。 */ +interface Entry { + node: Node + key?: Node + value: Node + spread: boolean + comments: Node[] +} + +/** + * 字典的各条,照源码顺序。讲一条的注释:写在它里面的、写在它最后一行行尾的、紧挨在它上面单独成行的; + * 最后一条后面单独成行的注释不讲哪一条。 + */ +function entriesOf(dictionary: Node): Entry[] { + const entries: Entry[] = [] + let pending: Node[] = [] + for (const child of dictionary.namedChildren) { + if (!child) + continue + if (child.type === 'comment') { + const last = entries.at(-1) + if (last && pending.length === 0 && child.startPosition.row === last.node.endPosition.row) + last.comments.push(child) + else + pending.push(child) + continue + } + const spread = child.type === 'dictionary_splat' + const key = child.type === 'pair' ? child.childForFieldName('key') : null + const value = spread ? syntaxChildren(child)[0] : child.childForFieldName('value') + if (value && (spread || key)) { + const inside = spread ? [] : child.descendantsOfType('comment').filter((comment): comment is Node => comment !== null) + entries.push({ node: child, key: key ?? undefined, value, spread, comments: [...pending, ...inside] }) + } + pending = [] + } + return entries +} + +/** 条目字典里的一项:键是字符串时 name 是它的内容。 */ +interface Option { name?: string, key?: Node, value: Node } + +function optionsOf(dictionary: Node): Option[] { + return syntaxChildren(dictionary).flatMap((child): Option[] => { + if (child.type === 'dictionary_splat') + return syntaxChildren(child).slice(0, 1).map(value => ({ value })) + const key = child.childForFieldName('key') + const value = child.childForFieldName('value') + return child.type === 'pair' && key && value ? [{ name: stringValue(key), key, value }] : [] + }) +} + +/** 条目里除了任务名、调度的项(args、kwargs、options……)。 */ +function extraOptions(entry: Entry): Option[] { + if (entry.spread || entry.value.type !== 'dictionary') + return [] + return optionsOf(entry.value).filter(option => option.name !== TASK_KEY && option.name !== SCHEDULE_KEY) +} + +/** 字符串照写的内容(不带引号),带源码下标;别的写法交给翻译插件。 */ +function stringOr(node: Node, env: Env, extra: Partial = {}): Inline[] { + const content = stringContent(node) + return content?.node ? [{ text: content.text, role: 'string', offset: content.node.startIndex, ...extra }] : env.context.translate(node) +} + +/** 任务名一格:字符串写的任务名是规则自己能查定义的引用;条目的键和任务名不一样时键跟在后面(淡色)。 */ +function taskCell(entry: Entry, task: Node | undefined, env: Env): Inline[] { + const taskName = stringContent(task) + const cell: Inline[] = task + ? stringOr(task, env, taskName?.node ? { reference: { rule: RULE_ID, key: taskName.text } } : {}) + : [hint(env.words.noTask)] + const key = entry.key && stringValue(entry.key) + return key !== undefined && key !== taskName?.text ? [...cell, hint(env.words.entryKey(key))] : cell +} + +function optionsCell(options: Option[], env: Env): Inline[] { + const { words, context } = env + return joined(options.map(({ name: optionName, key, value }) => { + const label = optionName === undefined ? (key ? context.translate(key) : [hint(words.spread)]) : [word(words.options[optionName] ?? optionName)] + return [...label, plain(' '), ...context.translate(value)] + }), hint(' · ')) +} + +/** 注释的原文(翻译插件去掉 #),几条连成一段。 */ +function commentText(comments: Node[], env: Env): Inline[] { + const text = comments.map(comment => env.context.doc(comment)?.markdown).filter(Boolean).join(' ') + return text ? [{ text, role: 'comment' }] : [] +} + +/** 任务表的一行:任务 | 频率 | (选项)| 说明。展开的条目写「展开」和展开的表达式;值不是字典的条目写键和值。 */ +function entryItem(entry: Entry, withOptions: boolean, env: Env): PropertyItem { + const { context, words } = env + const row = (task: Inline[], frequency: Inline[], options: Inline[]): PropertyItem => + ({ cells: [task, frequency, ...(withOptions ? [options] : []), commentText(entry.comments, env)] }) + if (entry.spread) + return row([hint(words.spread)], context.translate(entry.value), []) + if (entry.value.type !== 'dictionary') + return row(entry.key ? stringOr(entry.key, env) : [], context.translate(entry.value), []) + const options = optionsOf(entry.value) + const task = options.find(option => option.name === TASK_KEY)?.value + const schedule = options.find(option => option.name === SCHEDULE_KEY)?.value + return row(taskCell(entry, task, env), schedule ? frequencyCell(schedule, env) : [], optionsCell(extraOptions(entry), env)) +} + +/** `X.conf.` 里的 X;不是这种写法就没有。 */ +function appOfConf(node: Node | null | undefined, attribute: string): Node | undefined { + if (node?.type !== 'attribute' || node.childForFieldName('attribute')?.text !== attribute) + return undefined + const conf = node.childForFieldName('object') + if (conf?.type !== 'attribute' || conf.childForFieldName('attribute')?.text !== 'conf') + return undefined + return conf.childForFieldName('object') ?? undefined +} + +/** 定时任务卡片:应用 / 说明 / (conf.update 里别的配置各一行)/ 任务表。 */ +function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: Node | undefined, env: Env): 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() + 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(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), +} diff --git a/vscode/src/codeReader/rules/contractOnly.test.ts b/vscode/src/codeReader/rules/contractOnly.test.ts index 954f401..b9953da 100644 --- a/vscode/src/codeReader/rules/contractOnly.test.ts +++ b/vscode/src/codeReader/rules/contractOnly.test.ts @@ -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) => { diff --git a/vscode/src/codeReader/rules/pythonTree.ts b/vscode/src/codeReader/rules/pythonTree.ts new file mode 100644 index 0000000..f55e65e --- /dev/null +++ b/vscode/src/codeReader/rules/pythonTree.ts @@ -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 { + const aliases = new Map() + 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 | 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 + splats: Node[] +} + +export function callOf(node: Node | null | undefined, aliases: ReadonlyMap): Call | undefined { + if (node?.type !== 'call') + return undefined + const args = node.childForFieldName('arguments') + const positional: Node[] = [] + const keywords = new Map() + 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 } +} diff --git a/vscode/src/codeReader/rules/segments.ts b/vscode/src/codeReader/rules/segments.ts new file mode 100644 index 0000000..c042dce --- /dev/null +++ b/vscode/src/codeReader/rules/segments.ts @@ -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) +} diff --git a/vscode/src/codeReader/rules/sqlalchemy.ts b/vscode/src/codeReader/rules/sqlalchemy.ts index c6c5fda..4fd594e 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.ts @@ -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 } -/** 一个调用:函数名(最后一节,别名换回原名)、位置参数、关键字参数。 */ -interface Call { - name: string | undefined - positional: Node[] - keywords: Map -} - /** 类主体里一条给名字赋值(或只写注解)的语句;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 { - const aliases = new Map() - 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 | undefined { - const name = lastName(node) - return node?.type === 'identifier' && name ? aliases.get(name) ?? name : name -} - -function callOf(node: Node | null | undefined, aliases: Map): Call | undefined { - if (node?.type !== 'call') - return undefined - const args = node.childForFieldName('arguments') - const positional: Node[] = [] - const keywords = new Map() - 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 }) }, diff --git a/vscode/src/codeReader/testSupport.ts b/vscode/src/codeReader/testSupport.ts index 89432c5..a59dfa7 100644 --- a/vscode/src/codeReader/testSupport.ts +++ b/vscode/src/codeReader/testSupport.ts @@ -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 { }) return byLine } + +/** 内存里的工作区(只有一个文件夹 file:///w),给工作区索引用:记下读过哪些文件,能手动发文件改动。 */ +export class MemoryIndexHost implements IndexHost { + static readonly FOLDER = 'file:///w' + readonly files: Map + readonly reads: string[] = [] + readonly reports: string[] = [] + lists = 0 + private readonly listeners = new Set<(event: FileEvent) => void>() + + constructor(files: Record, 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 { + 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 { + 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 { + 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) }) + } +} diff --git a/vscode/src/codeReader/workspaceFiles.ts b/vscode/src/codeReader/workspaceFiles.ts new file mode 100644 index 0000000..d99fcda --- /dev/null +++ b/vscode/src/codeReader/workspaceFiles.ts @@ -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>('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, + } +} diff --git a/vscode/src/codeReader/workspaceIndex.test.ts b/vscode/src/codeReader/workspaceIndex.test.ts new file mode 100644 index 0000000..4696544 --- /dev/null +++ b/vscode/src/codeReader/workspaceIndex.test.ts @@ -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): { 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) + }) +}) diff --git a/vscode/src/codeReader/workspaceIndex.ts b/vscode/src/codeReader/workspaceIndex.ts new file mode 100644 index 0000000..4b8dd3a --- /dev/null +++ b/vscode/src/codeReader/workspaceIndex.ts @@ -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 + read: (file: WorkspaceFile) => Promise + /** 盯着文件改动:include 以外和被排除的文件改了、新建了不报,删除都报。 */ + watch: (include: string, listener: (event: FileEvent) => void) => { dispose: () => void } + /** 用 languageId 的语言插件解析;返回的语法树用完要 delete。 */ + parse: (languageId: string, source: string) => Promise + 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 +} + +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() + /** 预筛过了、解析过的文件 */ + private readonly indexed = new Map() + private readonly watcher: { dispose: () => void } + private queue: Promise + + 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 { + 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 { + this.queue = this.queue.then(() => this.guard(task)) + } + + private async guard(task: () => Promise): Promise { + try { + await task() + } + catch (err) { + this.host.report(`索引 ${this.index.id} 更新失败:${err instanceof Error ? err.message : String(err)}`) + } + } + + private async build(): Promise { + 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 { + 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() + 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 { + 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() + + constructor(private readonly host: IndexHost) {} + + lookup(index: WorkspaceIndex, key: string): Promise { + 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() + } +} diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 2939479..a319b1b 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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'