diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index ff668af..63c0180 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -513,8 +513,8 @@ - 仓库路径链接(`[名字](从仓库根起的路径)`,可带 `#类名` 或 `#类名.成员`)能点,点了打开文件并跳到符号,和 ⌘ / Ctrl + 点击跳到别的文件里的定义是同一条路(`editor/openTarget.ts` 的 `openingFor`,见「跳到定义」):语言有代码阅读的在预览所在的编辑器组用预览打开、滚过去、闪符号那处(不带符号的只打开预览),没有代码阅读的用 VS Code 默认的打开方式(普通编辑器选中符号的名字,图片看图);指到这份文件里的符号时在本预览里滚过去、闪一下(同下面的引用)。指到目录的在代码阅读侧栏的树上定位(展开并选中,和侧栏跟着当前文件走是同一个定位;侧栏没露着时亮出来),不切到 VS Code 资源管理器;树上没有(开着筛选时没摆它)时提示「侧栏的树上没有 …」,不退出筛选。 - 不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)能点: - 认哪些:只在注释的 Markdown 里认(说明正文、参数说明、`@return`、「异常」表、「注释」一行、文件开头的说明,连带增删的;问 AI 的回答不认)。方括号里是标识符(ASCII 字母、数字、下划线、`$`,点分开的几段);中文这类不是标识符的(`[会话进展]`)照原样是文字。反引号里的是代码;紧跟在名字、`]`、`)` 后面的方括号是下标(`items[i]`、`a[0][1]`、`f()[k]`);后面紧跟 `(…)` 的是完整链接、紧跟 `[` 的是参考式链接,都不算。webview 在 `markdown.ts` 加一条行内规则(排在 markdown-it 的 link 后面,渲染时 `env.symbolLinks` 为真才认),写成地址为 `symbol:名字` 的链接(作者写不出这种地址,`validateLink` 不放行)。 - - 指到哪(扩展端,`symbolLinks.ts` 的 `resolveReference`,按 KDoc 的规矩):① 本文件定义的:文档符号(`vscode.executeDocumentSymbolProvider`,同仓库链接找符号那一套)顶层名字对上的(类、函数、常量、变量;落在导入语句里的名字不算),`Class.member` 在它底下找成员;② 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的(预览已有的导入块 facts,`ImportFacts.items`:起了别名的按别名认,问原来那个名字;整模块导入按第一节认,起了别名的问最后一节),在导入语句里这个名字的位置上问语言扩展定义在哪(`vscode.executeDefinitionProvider`,同跳到定义,几处取第一处),`Class.member` 再在目标文件的文档符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找;③ 都没有就找不到。本文件定义了这个名字、却没有这个成员时也算找不到,不再去导入里找。 - - 问答同仓库路径链接(`useRepoLinks`、`RepoLinks`):画出来后把页面上的仓库路径和引用一起发去问(`checkRepoLinks { paths, symbols }` → `repoLinksChecked { missing, unresolved }`),问到之前是普通链接,找不到的画成坏链接(红色波浪线,悬停「找不到」);文档换了版本(收到 `document`)再问一次,引用跟着源码变。点了发 `openRepoLink`(不带 `path`,只带 `symbol`):找到了同完整链接的后半段打开目标(语言有代码阅读的用预览、滚过去、闪,没有的用默认的打开方式选中名字),目标在这份文件里时在本预览里滚过去、闪一下(同跳到定义的闪);找不到就标坏。 + - 指到哪(扩展端,`symbolLinks.ts` 的 `resolveReference`,按 KDoc 的规矩):① 本文件定义的:文档符号(`vscode.executeDocumentSymbolProvider`,同仓库链接找符号那一套)顶层名字对上的(类、函数、常量、变量;落在导入语句里的名字不算),`Class.member` 在它底下找成员;② 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的(预览已有的导入块 facts,`ImportFacts.items`:起了别名的按别名认,问原来那个名字;整模块导入按第一节认,起了别名的问最后一节),在导入语句里这个名字的位置上问语言扩展定义在哪(`vscode.executeDefinitionProvider`,同跳到定义,几处取第一处),`Class.member` 再在目标文件的文档符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找;③ 本文件代码里用到、本文件又没定义的(语言自带的 `ValueError`、`str`……):语言插件认的名字绑定(`LanguageAdapter.bindings`)里不是本文件定义的那些,按名字记下用在代码里的各处(`outsideUses`,注释、字符串、`obj.name` 里的 `name` 不算),先挑用在这段注释说明的那个声明里面的,没有再挑整个文件的第一处,在那里问语言扩展定义(同代码里 ⌘ / Ctrl + 点击它,Python 的内置名跳到 Pylance 自带的 `builtins.pyi`),`Class.member` 同 ② 再找成员;④ 前面都没问到定义、名字(第一段)又是这门语言自带的(`LanguageAdapter.builtins`:Python 是 `dir(builtins)` 里公开的名字,TypeScript 是 ECMAScript 标准的全局对象):是 `builtin`,页面当普通文字;⑤ 都不是就找不到。本文件定义了这个名字、或问到了定义,却没有这个成员时也算找不到,不再往后找。 + - 问答同仓库路径链接(`useRepoLinks`、`RepoLinks`):画出来后把页面上的仓库路径和引用一起发去问(`checkRepoLinks { paths, symbols }` → `repoLinksChecked { missing, unresolved, builtin }`),问到之前是普通链接,找不到的画成坏链接(红色波浪线,悬停「找不到」),`builtin` 的(语言自带、又问不到定义的名字)画成普通文字(`cr-link-plain`:颜色同旁边的字、不带下划线,点了不发消息);文档换了版本(收到 `document`)再问一次,引用跟着源码变。点了发 `openRepoLink`(不带 `path`,只带 `symbol`):找到了同完整链接的后半段打开目标(语言有代码阅读的用预览、滚过去、闪,没有的用默认的打开方式选中名字),目标在这份文件里时在本预览里滚过去、闪一下(同跳到定义的闪);找不到就标坏;是语言自带的名字就不打开,回 `repoLinkBuiltin`,页面改当普通文字。 - 样子:正文用界面字体(`--vscode-font-family`、`--vscode-font-size`)、comment 色、行高 20px,一行注释和一行译文一样高;行内代码用编辑器字体、`--vscode-textCodeBlock-background` 底;围栏代码块用编辑器字体和字号、同样的底,长行折行;段落、列表、代码块、表格之间 6px;标题只比正文稍大(h1 1.15em、h2 1.08em,其余同正文,加粗);链接 `--vscode-textLink-foreground`;引用左边 3px `--vscode-textBlockQuote-border` 竖线、`--vscode-textBlockQuote-background` 底;表格 1px 细线。 - 「编辑」状态照旧是源码,编辑写回不变;Markdown 里的名字(反引号里的代码)不能点,不参与跳到定义,能点的只有上面的链接和引用。 - 定义卡片浮窗里的注释(别的文件的)按 `peek` 渲染(`TextPreview` 读 `MarkdownKindContext`):不认不带路径的引用,别的同注释。 @@ -2117,7 +2117,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, items?: PropertyIt - `{ type: 'answer', askId, text, activity? }`:`askId` 那个问题的回答长了(`text` 是到现在为止的全文)或换了在做的事。webview 攒着、最多每 80ms 画一次。 - `{ type: 'answerEnd', askId, status: 'done' | 'failed' | 'stopped', text?, error?, entryId? }`:答完了。`done` 带最终回答和存下的那条的 id(历史里不再重复显示它),`failed` 带给人看的原因,`stopped` 是被叫停、没存。 - `{ type: 'rewriteProgress', anchor, question, text, activity? }`:`anchor` 那块正在重写注释:`question` 是发给模型的那一句,`text` 是正文到现在为止写出来的,`activity` 是正在读什么。`{ type: 'rewriteEnd', anchor, status: 'done' | 'failed' | 'stopped', error? }`:重写完了(见「重写注释」)。 - - `{ type: 'repoLinksChecked', id, missing, unresolved }`:回答第 `id` 次 `checkRepoLinks`,`missing` 是仓库里没有的路径,`unresolved` 是找不到的不带路径的引用。`{ type: 'repoLinkBroken' | 'repoLinkOpened', path?, symbol? }`:点了以后打不开 / 打开了;没有 `path` 的是不带路径的引用。见「注释的 Markdown 排版」。 + - `{ type: 'repoLinksChecked', id, missing, unresolved, builtin }`:回答第 `id` 次 `checkRepoLinks`,`missing` 是仓库里没有的路径,`unresolved` 是找不到的不带路径的引用,`builtin` 是语言自带、又问不到定义的不带路径的引用(当普通文字)。`{ type: 'repoLinkBroken' | 'repoLinkOpened', path?, symbol? }`:点了以后打不开 / 打开了;没有 `path` 的是不带路径的引用。`{ type: 'repoLinkBuiltin', symbol, block? }`:点的不带路径的引用是语言自带的名字,没打开,改当普通文字。见「注释的 Markdown 排版」。 - `{ type: 'openedFrom', from: 'uncommitted' | 'other' }`:这个标签页是从哪儿打开的(见「代码差异」),页面记进 webview 状态。`ready` 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发。 - webview → 扩展: - `{ type: 'probeDefinition', id, version, offset }`:按住修饰键指到带 `offset` 的名字上时问有没有定义,`id` 每问一次加一;`version` 不是当前文档版本就不答。 @@ -2204,7 +2204,7 @@ vscode/src/codeReader/ rules/children.ts 库规则共用的挑子块的办法(类主体里带注解的赋值、成员的说明节点:挂在成员上的注释、行尾注释、属性 docstring) docTags.ts 说明的原文按 KDoc 拆成正文和块标签(Doc:@param / @return / @throws),翻译插件的 doc 用它(纯函数) rules/docRows.ts 讲说明的几行,中文直译和库规则共用:正文、对不上的参数、参数按名字对说明(paramDocs)、输出后面的 @return、异常表 - symbolLinks.ts 注释里不带路径的引用指到哪:导入引进来的名字(importedNames,readBlockViews 交出)、本文件定义的优先再是导入的(resolveReference,符号和定义由预览去问)(不碰 VS Code) + symbolLinks.ts 注释里不带路径的引用指到哪:导入引进来的名字(importedNames,readBlockViews 交出)、代码里用到的本文件没定义的名字(outsideUses,取自名字绑定)、本文件定义的优先再是导入的、再是代码里用到的语言自带的名字,语言自带又问不到定义的当普通文字(resolveReference,符号和定义由预览去问)(不碰 VS Code) languages/shared.ts 语言插件共用的建树工具:一串语句分块(listBlocks)、注释挂到它讲的块上(attachComments);异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数) commentRewrite.ts 重写注释:发给模型的那一句、读结构化结果(含逐字的进度)、新正文套回定界符 / 新写一段、核对后写回(不碰 VS Code) languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor) @@ -2306,7 +2306,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 块标签:拆法(`docTags.test.ts`:多行续写、空行结束、`@returns` / `@exception` 同义、JSDoc 的 `{类型}`、`[名字=默认值]`、横杠、标签前后的正文、没有标签时原样、别的标签和围栏代码块里的行留在正文、异常名写成引用);卡片(`translators/zhTemplate.test.ts` 的「说明里的块标签」):`fixtures/conversationBrief.py`(luffy 的 `render_conversation_brief`)说明里没有 `@param` / `@return`,输入参数表「说明」列四个参数的原文,输出参数后面跟 `@return`;`fixtures/localRuntimeGate.py`(luffy 的 `gate.py`)「异常」表两行,第一行是完整链接、第二行是不带路径的引用;对不上的 `@param` 排在说明下面、提示色、悬停「没有这个参数」;类的 `@param` 照写成表、异常类的对到构造参数(`*args` 去掉星号对)。React 组件(字段没注释时用 `@param`、`props.x`、讲整个参数的跟在构造参数后面、对不上的、异常表)和事件处理函数(`rules/react.test.ts`)、FastAPI 请求表的说明列和响应后面的 `@return`(`rules/fastapi.test.ts`)、Rust 函数和结构体(`languages/rust.test.ts`)、数据类字段(`rules/dataclass.test.ts`)。 - 注释挂到它讲的块上(各语言的 `languages/*.test.ts`「注释挂到它讲的块上」,`outline` 的 `notes` 是挂着的各段注释在哪):Python、TypeScript、Rust、Shell、Dockerfile 各一份,覆盖隔着空行的注释挂到后面那块、主体最后的挂到前面那块、主体里只有注释时挂到父块、文件最后的挂到最后一块、文件开头隔着空行的是文件的说明而紧挨着第一块代码的挂到它上面、`#!` / 写编码的 / `//!` / `@file` / 模块 docstring 是文件的、说明和注释并存、函数体里语句前面的注释;luffy 的 `fixtures/acquisitionRepository.py` 导入后面的注释挂到类上(`blockView.test.ts`:类卡片「名称、注释、访问级别」、编辑范围连着注释、写回原样);TypeScript 的「名称、说明、注释」、几段注释是几段 Markdown、一行译文的卡片的 `rows`;嵌着的代码里的块没有 `commentTarget`;改了注释那块算修改、「注释」一行逐词标(`diff/blockDiff.test.ts`);规则略去挂着注释的块不合约定(`libraryRules.test.ts`)。webview(`BlockTree.test.tsx`):「注释」一行照属性表画,一行译文的卡片排在译文下面,有没有「注释」一行按钮都只有一个、排在右上角那排最前面,没有注释时叫「按注释规范写注释」,不能写注释的块没有按钮。 - 重写注释(`commentRewrite.test.ts`、`qa/claudeAsk.test.ts`):发给模型的就是 `/ecc:writing-code-comments 路径:起-止` 一句;读结构化结果(正文逐字长出来、读文件是在做的事、只回一段话没有正文、报错);命令行在只读那套后面加 `--json-schema`、stdin 里只有那一句;交回结构化正文 → 已有说明换说明(Python docstring 填回三引号、JSDoc 填回 `/** */`,不在声明上面再插一行)、只有前面的普通注释时换掉那几段(套回 `#` 和缩进)、文件开头的说明换它自己、说明和普通注释都有时只换说明;都没有时 Python 函数在主体里插 docstring、TypeScript 在声明上面插 `/** */`、Rust 在项上面插 `///`、只有行注释的语言仍插在块上面;没交回正文、出错、重写期间代码变了 → 失败不写,叫停 → 不写;套回壳的几种写法(三引号、`#`、`/** */`、`//`、`///`)。 -- 注释的 Markdown:「注释」一行、文件开头的说明、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。不带路径的引用:行内规则(`webview-ui/.../markdown.test.ts`:`[Name]`、`[A.b]` 是 `symbol:` 链接,`` `[x]` ``、`items[i]`、`[会话进展]`、`[名字](路径)`、`[Agent](javascript:x)` 各自的结果,问 AI 的回答不认、带增删的注释照样认);问答(`repoLink.test.ts`:`symbol:` 地址只认标识符、点了发不带 `path` 的 `openRepoLink`、和仓库路径一起问、找不到的标坏、打开成功不再标、同样的链接不再问、文档换了版本再问);扩展端(`symbolLinks.test.ts`:本文件优先于导入且不问语言扩展、本文件的 `Class.member`、类有成员没有就是找不到、导入的名字问定义再在那个类底下找成员、整模块导入从顶层找、找不到;Python / TypeScript 各种导入写法引进来的名字和问定义的位置,`gate.py` 的导入)。 +- 注释的 Markdown:「注释」一行、文件开头的说明、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。不带路径的引用:行内规则(`webview-ui/.../markdown.test.ts`:`[Name]`、`[A.b]` 是 `symbol:` 链接,`` `[x]` ``、`items[i]`、`[会话进展]`、`[名字](路径)`、`[Agent](javascript:x)` 各自的结果,问 AI 的回答不认、带增删的注释照样认);问答(`repoLink.test.ts`:`symbol:` 地址只认标识符、点了发不带 `path` 的 `openRepoLink`、和仓库路径一起问、找不到的标坏、打开成功不再标、语言自带的名字当普通文字且点了不发、点了才知道是语言自带的也改当普通文字、同样的链接不再问、文档换了版本再问);扩展端(`symbolLinks.test.ts`:本文件优先于导入且不问语言扩展、本文件的 `Class.member`、类有成员没有就是找不到、导入的名字问定义再在那个类底下找成员、整模块导入从顶层找、找不到;代码里用到的语言自带的名字先挑用在说明的那个声明里的那处问定义、`Class.member` 在定义所在文件里找成员、没写到的语言自带的名字是 `builtin`、语言扩展答不上的也是;Python / TypeScript 代码里用到的本文件没定义的名字不含注释、字符串、属性名里的;两门语言自带的名字;Python / TypeScript 各种导入写法引进来的名字和问定义的位置,`gate.py` 的导入)。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 别的文件 / 挑选框,别的文件用哪种编辑器(`editor/openTarget.test.ts`:有代码阅读的用预览、带位置时滚到那一行闪定义处,没有的用默认的打开方式选中名字,打不开成文本的用默认的打开方式);webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。悬停看定义卡片:取哪块(`editor/definitionPeek.test.ts`:Python 源码走完整的语言插件 + 翻译插件,类名 → 类那块连同子块,参数 → 所在的函数,变量 → 变量那块,块外面 → 没有,挂在子行上的子块去掉 `slot`;卡片上的中文名只带画出来的、长度对得上的,别的文件只认定义本身),什么时候弹、收(`definitionPeek.test.ts`:停够延迟才问、没到时间移开不问、没有卡片不弹、迟到的回答丢掉、链接问 `peekLink` 点标题发 `openRepoLink`、没收到延迟不弹、移开过 `hidingDelay` 才收、移进浮窗不收、扫过别的名字不往后推、浮窗里不弹别的、换成新名字的卡片、Esc / 滚动后不再弹、换文档版本收起;指到符号的链接才弹;放在下方 / 上方 / 空间大的那边、钳在视口内),只读的卡片(`DefinitionPeekPopup.test.tsx`:类收起子块、没有编辑 / 问 AI / 重写按钮、中文名用卡片带的、引用不成链接;`markdown.test.ts` 的 `peek`)。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,讲别处名字的段(`refers`)按那个名字算、自己照抄的位置不算(`occurrences.test.ts` 的 `sourceOf`),点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。 diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 2a31bc7..a6d2e77 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -775,6 +775,11 @@ export interface LanguageAdapter { * 只报认得准的:拿不准的(模式匹配捕获的、跳过不看的声明里的、同一个名字既导入又赋值的……)不报,宿主照旧逐处问。不填就逐处问。 */ bindings?: (root: Node, source: string, languageId: string) => NameBinding[] + /** + * 这门语言自带、不用导入就能用的名字(Python 的 `ValueError`、`str`、`len`、`None`,TypeScript 的 `Error`、`Promise`、`JSON`), + * 注释里引用了它、预览却找不到定义时靠它认出来,当普通文字显示,不标成找不到;没有的语言不填。 + */ + builtins?: ReadonlySet /** 这份源码里嵌着的别的语言的代码(见 EmbeddedCode),块里的(TextBlock.embedded)、属性表里的(HealthFacts.command)都算;没有的语言不填。 */ embedded?: (root: Node, source: string, languageId: string) => EmbeddedCode[] /** 这门语言的代码被别的语言嵌着、当一串步骤执行时怎么分块(见 StepBlocks);不填就按 toBlocks 分、不算一步失败就停。 */ diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 0a675d8..5bb56cd 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -211,9 +211,11 @@ export interface StickyScrollSettings { * diff:这份文件相比对比基准改了什么(审查工作副本里的、用「未提交的更改」文件行上的「打开更改」打开的有改动的才有,见 [对比基准](vscode/src/codeReader/diff/diffBase.ts#DiffBase)):每发一份文档之后算好了补发, * 基准变了(提交后 HEAD 动了、换了审查的工单、文件有了改动、这个标签页换了从哪儿打开)再发;null 是不比了(退出了审查、改动提交了、从别处点开了) * openedFrom:这个标签页是从哪儿打开的,页面记进自己的状态,VS Code 重启后恢复这个标签页时随 ready 报回来;ready 后发一次,开着时又从侧栏点开、换了从哪儿打开的再发 - * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用(原样带回问的那几条) + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用, + * builtin 是语言自带、又问不到定义的不带路径的引用(Python 的 `ValueError`、`str`,页面当普通文字);两样都原样带回问的那几条 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到, * block 同 openRepoLink + * repoLinkBuiltin:点的不带路径的引用是语言自带、又问不到定义的名字,没打开,页面当普通文字;symbol、block 同 openRepoLink * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * callTreeCounts:version 这份文档里,每个能看调用树的块(BlockView.callTree)落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 @@ -242,8 +244,9 @@ export type ExtensionToWebview | { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string } | { type: 'diff', diff: DocumentDiff | null } | { type: 'openedFrom', from: OpenedFrom } - | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[] } + | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[], builtin: SymbolReference[] } | { type: 'repoLinkBroken', path?: string, symbol?: string, block?: string } + | ({ type: 'repoLinkBuiltin' } & SymbolReference) | { type: 'repoLinkOpened', path?: string, symbol?: string, block?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } diff --git a/vscode/src/codeReader/editor/previewEditor.test.ts b/vscode/src/codeReader/editor/previewEditor.test.ts index 5c55d53..4d945c1 100644 --- a/vscode/src/codeReader/editor/previewEditor.test.ts +++ b/vscode/src/codeReader/editor/previewEditor.test.ts @@ -567,11 +567,26 @@ describe('注释里不带路径的引用按它写在哪块的注释里找', () = await page.send({ type: 'ready' }) await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'get_agent_admitted', block: METHOD }, { symbol: 'get_agent_admitted', block: HELPER }] }) - expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'get_agent_admitted', block: HELPER }] }) + expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'get_agent_admitted', block: HELPER }], builtin: [] }) await page.send({ type: 'openRepoLink', symbol: 'get_agent_admitted', block: HELPER }) expect(page.posted()).toContainEqual({ type: 'repoLinkBroken', symbol: 'get_agent_admitted', block: HELPER }) }) + + it('语言自带、代码里没写到的名字:检查时报成 builtin,点了报 repoLinkBuiltin,不标坏', async () => { + vi.mocked(documentSymbolNodes).mockResolvedValue([]) + const { provider, document } = opening(undefined, undefined, () => ({ builtins: new Set(['Error']) })) + const page = tab() + provider.resolveCustomTextEditor(document as never, page.panel) + await page.send({ type: 'ready' }) + + await page.send({ type: 'checkRepoLinks', id: 1, paths: [], references: [{ symbol: 'Error', block: HELPER }, { symbol: 'Missing', block: HELPER }] }) + expect(page.posted()).toContainEqual({ type: 'repoLinksChecked', id: 1, missing: [], unresolved: [{ symbol: 'Missing', block: HELPER }], builtin: [{ symbol: 'Error', block: HELPER }] }) + + await page.send({ type: 'openRepoLink', symbol: 'Error', block: HELPER }) + expect(page.posted()).toContainEqual({ type: 'repoLinkBuiltin', symbol: 'Error', block: HELPER }) + expect(page.posted()).not.toContainEqual(expect.objectContaining({ type: 'repoLinkBroken' })) + }) }) describe('点了带几行的仓库链接', () => { @@ -624,7 +639,7 @@ describe('点了带几行的仓库链接', () => { it('问哪些路径没有时,带几行的链接按文件问', async () => { const { page } = await linked() await page.send({ type: 'checkRepoLinks', id: 3, paths: ['web/a.ts', 'web/gone.ts'], references: [] }) - expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 3, missing: ['web/gone.ts'], unresolved: [] }]) + expect(page.posted()).toEqual([{ type: 'repoLinksChecked', id: 3, missing: ['web/gone.ts'], unresolved: [], builtin: [] }]) }) }) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 1fecccd..348839c 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -1,7 +1,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, WebviewPanel } from 'vscode' import type { ClaudeImage } from '../../cc/spawnClaude' import type { CommentEdit, CommentJob, WriteResult } from '../commentRewrite' -import type { BlockKind, LocalSymbol, RuleLocation, RuleReference } from '../contract' +import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, SourceRange } from '../contract' import type { OpenedFrom, PreviewDiffBases } from '../diff/diffBase' import type { OldDocument, SentDocument } from '../diff/previewDiff' import type { WorkspaceEntries } from '../entries/workspaceEntries' @@ -10,7 +10,7 @@ import type { CodeQa, TopRequest } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' import type { LookupSources } from '../ruleLookups' import type { TreeSitterRuntime } from '../runtime' -import type { ImportedName, LinkTarget, SymbolNode } from '../symbolLinks' +import type { ImportedName, LinkTarget, ReferenceTarget, SymbolNode } from '../symbolLinks' import type { ThemeColors } from '../theme/codeColors' import type { IndexFind } from '../workspaceIndex' import type { BlockFilter } from './blockFilter' @@ -37,7 +37,7 @@ import { NOT_REWRITABLE } from '../qa/codeQa' import { PATH_ASK_ANCHOR } from '../qa/pathQa' import { askPrompt, followUpPrompt } from '../qa/prompt' import { DocumentLookups } from '../ruleLookups' -import { referenceScope, resolveReference } from '../symbolLinks' +import { outsideUses, referenceScope, resolveReference } from '../symbolLinks' import { definitionAction } from './definitionAction' import { definitionSiteNames, namesOnCard, pagePeekBlock, peekBlock } from './definitionPeek' import { definitionKind, findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' @@ -245,6 +245,8 @@ class Preview { private symbols: { version: number, list: LocalSymbol[] } | undefined /** 最近一次发出去的文档里各条导入引进来的名字:注释里不带路径的引用按它找导入的定义 */ private imports: ImportedName[] = [] + /** 最近一次发出去的文档里,代码用到、本文件又没定义的名字各在哪几处(见 [outsideUses]):注释里的引用按它找语言自带的名字的定义 */ + private uses = new Map() /** 这份文档各版本显示成中文名的名字 */ private readonly names: DocumentNames /** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */ @@ -443,6 +445,7 @@ class Preview { this.sent = message.error === undefined ? { version: message.version, blocks: message.blocks, source } : undefined this.symbols = { version, list: symbols } this.imports = imports + this.uses = outsideUses(bindings, source) const reveal = this.pendingReveal this.pendingReveal = undefined this.shown = version @@ -852,12 +855,12 @@ class Preview { this.post({ type: 'definitionPeeked', id, ...(peek && { peek }) }) } - /** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到没有。 */ + /** 回答 webview 第 id 次要注释里的链接指的符号的卡片:符号同点这个链接时找的那个(symbolTarget),找不到、是语言自带的名字时没有。 */ private async peekLink(id: number, rel: string | undefined, symbol: string, block: string | undefined): Promise { let peek: DefinitionPeek | undefined try { const target = await this.symbolTarget(rel, symbol, block) - peek = target && await this.peekAt(Uri.parse(target.uri), target.selection.startIndex) + peek = target && target !== 'builtin' ? await this.peekAt(Uri.parse(target.uri), target.selection.startIndex) : undefined } catch (err) { const link = rel === undefined ? `[${symbol}]` : `${rel}#${symbol}` @@ -927,7 +930,7 @@ class Preview { } } - /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到。 */ + /** 预览里这批仓库路径,哪些文件或目录不在;这批不带路径的引用,各按写在哪块找,哪些找不到、哪些是语言自带的名字(页面当普通文字)。 */ private async checkRepoLinks(id: number, paths: string[], references: SymbolReference[]): Promise { const root = await repoRootOf(this.document.uri.fsPath) const asked = new Map>() @@ -936,15 +939,21 @@ class Preview { asked.set(uri, known) return known } - const [missing, unresolved] = await Promise.all([ + const [missing, resolved] = await Promise.all([ Promise.all(paths.map(async (rel) => { if (root === undefined || await resolveRepoEntry(root, rel) === undefined) return rel return undefined })), - Promise.all(references.map(async reference => await this.resolveSymbolLink(reference, symbolsOf) ? undefined : reference)), + Promise.all(references.map(async reference => ({ reference, target: await this.resolveSymbolLink(reference, symbolsOf) }))), ]) - this.post({ type: 'repoLinksChecked', id, missing: missing.filter(rel => rel !== undefined), unresolved: unresolved.filter(reference => reference !== undefined) }) + this.post({ + type: 'repoLinksChecked', + id, + missing: missing.filter(rel => rel !== undefined), + unresolved: resolved.filter(({ target }) => target === undefined).map(({ reference }) => reference), + builtin: resolved.filter(({ target }) => target === 'builtin').map(({ reference }) => reference), + }) } /** 仓库根放进这个预览能读的本地目录,图片地址才能加载。真实位置和 git 给的路径不一样时两个都放。 */ @@ -988,13 +997,17 @@ class Preview { /** * 打开注释、问 AI 的回答里的仓库链接:目录在代码阅读侧栏的树上定位,文件用编辑器打开。 * 带了符号就按文档符号的名字跳过去,带了几行(`lines`)就跳到这几行(见 [linesSpot]);文件或符号没有就不打开。 - * 没有路径的是不带路径的引用,按这份文件、从它写在的那块(`block`)找(见 [resolveSymbolLink])。 + * 没有路径的是不带路径的引用,按这份文件、从它写在的那块(`block`)找(见 [resolveSymbolLink]);它是语言自带的名字时不打开,告诉页面当普通文字。 */ private async openRepoLink(rel: string | undefined, symbol?: string, block?: string, lines?: LineRange): Promise { const link = rel ?? `[${symbol}]` try { if (symbol !== undefined) { const target = await this.symbolTarget(rel, symbol, block) + if (target === 'builtin') { + this.post({ type: 'repoLinkBuiltin', symbol, ...(block !== undefined && { block }) }) + return + } if (!target) { this.markRepoLinkBroken(rel, symbol, block) return @@ -1031,9 +1044,10 @@ class Preview { /** * 链接指的符号在哪:没有 path 的是不带路径的引用,按这份文件、从它写在的那块(block)找(resolveSymbolLink); - * 有 path 的在那个文件的文档符号里找(locateRepoSymbol)。文件没有、是目录、符号没有时没有。点链接、悬停链接看定义卡片都按它找。 + * 有 path 的在那个文件的文档符号里找(locateRepoSymbol)。文件没有、是目录、符号没有时没有;不带路径的引用是语言自带的名字时是 `'builtin'`。 + * 点链接、悬停链接看定义卡片都按它找。 */ - private async symbolTarget(rel: string | undefined, symbol: string, block: string | undefined): Promise { + private async symbolTarget(rel: string | undefined, symbol: string, block: string | undefined): Promise { if (rel === undefined) return this.resolveSymbolLink({ symbol, block }) const root = await repoRootOf(this.document.uri.fsPath) @@ -1048,13 +1062,15 @@ class Preview { } /** - * 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:从它写在的那块(block,在眼下这份块树里找)的声明起一层层往外找本文件定义的, - * 再是导入进来的(见 symbolLinks.ts)。检查坏链接、点击、悬停看定义卡片都走这里。 - * 文档符号同仓库链接找符号那一套(symbols,一批引用一起问时共用一份),导入的名字问语言扩展它的定义(同跳到定义),定义几处取第一处。 + * 注释里不带路径的引用(`[Name]`、`[Class.member]`)指到哪:从它写在的那块(`block`,在眼下这份块树里找)的声明起一层层往外找本文件定义的, + * 再是导入进来的、代码里用到的语言自带的名字,语言自带又问不到定义的是 `'builtin'`(见 [resolveReference])。检查坏链接、点击、悬停看定义卡片都走这里。 + * 文档符号同仓库链接找符号那一套(`symbols`,一批引用一起问时共用一份),导入的、代码里用到的名字问语言扩展它的定义(同跳到定义),定义几处取第一处; + * 哪些名字是语言自带的问这门语言的语言插件([LanguageAdapter.builtins](vscode/src/codeReader/contract.ts#LanguageAdapter.builtins))。 */ - private async resolveSymbolLink({ symbol, block }: SymbolReference, symbols: (uri: string) => Promise = documentSymbolNodes): Promise { - const { document, sent } = this + private async resolveSymbolLink({ symbol, block }: SymbolReference, symbols: (uri: string) => Promise = documentSymbolNodes): Promise { + const { document, sent, uses } = this const at = block === undefined || !sent ? undefined : referenceScope(sent.blocks, block) + const builtins = this.registry.languageFor(document.languageId)?.builtins try { return await resolveReference(symbol, at, this.imports, { uri: document.uri.toString(), @@ -1066,6 +1082,8 @@ class Preview { const doc = await workspace.openTextDocument(found.uri) return { uri: found.uri.toString(), selection: { startIndex: doc.offsetAt(found.range.start), endIndex: doc.offsetAt(found.range.end) } } }, + uses: name => uses.get(name) ?? [], + builtin: name => builtins?.has(name) ?? false, }) } catch (err) { diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index 3939107..bea525c 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -541,6 +541,96 @@ const BUILTIN_EXCEPTIONS = new Set([ 'UnicodeWarning', 'UserWarning', ]) +/** Python 自带的其余名字(`dir(builtins)` 里公开的):内置类型、内置函数、常量。 */ +const BUILTIN_OTHERS = [ + // 内置类型 + 'bool', + 'bytearray', + 'bytes', + 'classmethod', + 'complex', + 'dict', + 'enumerate', + 'filter', + 'float', + 'frozenset', + 'int', + 'list', + 'map', + 'memoryview', + 'object', + 'property', + 'range', + 'reversed', + 'set', + 'slice', + 'staticmethod', + 'str', + 'super', + 'tuple', + 'type', + 'zip', + // 内置函数,含启动时 site 加进来的 help、exit、quit、copyright、credits、license + 'abs', + 'aiter', + 'all', + 'anext', + 'any', + 'ascii', + 'bin', + 'breakpoint', + 'callable', + 'chr', + 'compile', + 'copyright', + 'credits', + 'delattr', + 'dir', + 'divmod', + 'eval', + 'exec', + 'exit', + 'format', + 'getattr', + 'globals', + 'hasattr', + 'hash', + 'help', + 'hex', + 'id', + 'input', + 'isinstance', + 'issubclass', + 'iter', + 'len', + 'license', + 'locals', + 'max', + 'min', + 'next', + 'oct', + 'open', + 'ord', + 'pow', + 'print', + 'quit', + 'repr', + 'round', + 'setattr', + 'sorted', + 'sum', + 'vars', + // 常量 + 'None', + 'True', + 'False', + 'Ellipsis', + 'NotImplemented', +] + +/** Python 不用导入就能用的名字([LanguageAdapter.builtins]):自带的异常类加上 [BUILTIN_OTHERS]。 */ +const PYTHON_BUILTINS: ReadonlySet = new Set([...BUILTIN_EXCEPTIONS, ...BUILTIN_OTHERS]) + /** PEP 8 的命名习惯:异常类名以 Error 结尾(别的文件里的 `AppException`、`*Warning` 也常见)。 */ const EXCEPTION_NAME = /(?:Error|Exception|Warning)$/ @@ -672,6 +762,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { call: pythonCall, collection: pythonCollection, bindings: root => pythonBindings(root), + builtins: PYTHON_BUILTINS, displayNames: pythonNameReader, } } diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index 57b2b41..d9b103d 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -530,6 +530,76 @@ const TYPESCRIPT_EXCEPTIONS: ExceptionRules = { constructorParameters: block => block.kind === 'function' && block.facts.name?.text === 'constructor' ? block.facts.parameters : undefined, } +// ─── 自带的名字 ─────────────────────────────────────────────────────────────── + +/** ECMAScript 标准的全局对象([LanguageAdapter.builtins]),国际化的 `Intl` 也算;浏览器、Node 另加的不算。 */ +const TYPESCRIPT_BUILTINS: ReadonlySet = new Set([ + // 值 + 'globalThis', + 'Infinity', + 'NaN', + 'undefined', + // 函数 + 'eval', + 'isFinite', + 'isNaN', + 'parseFloat', + 'parseInt', + 'decodeURI', + 'decodeURIComponent', + 'encodeURI', + 'encodeURIComponent', + // 构造函数 + 'AggregateError', + 'Array', + 'ArrayBuffer', + 'BigInt', + 'BigInt64Array', + 'BigUint64Array', + 'Boolean', + 'DataView', + 'Date', + 'Error', + 'EvalError', + 'FinalizationRegistry', + 'Float16Array', + 'Float32Array', + 'Float64Array', + 'Function', + 'Int8Array', + 'Int16Array', + 'Int32Array', + 'Iterator', + 'Map', + 'Number', + 'Object', + 'Promise', + 'Proxy', + 'RangeError', + 'ReferenceError', + 'RegExp', + 'Set', + 'SharedArrayBuffer', + 'String', + 'Symbol', + 'SyntaxError', + 'TypeError', + 'Uint8Array', + 'Uint8ClampedArray', + 'Uint16Array', + 'Uint32Array', + 'URIError', + 'WeakMap', + 'WeakRef', + 'WeakSet', + // 命名空间对象 + 'Atomics', + 'Intl', + 'JSON', + 'Math', + 'Reflect', +]) + // ─── 源码着色 ───────────────────────────────────────────────────────────────── /** 流程关键字;其余字母关键字都算声明关键字(storage)。 */ @@ -664,6 +734,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { call: typescriptCall, collection: typescriptCollection, bindings: root => typescriptBindings(root), + builtins: TYPESCRIPT_BUILTINS, displayNames: typescriptNameReader, } } diff --git a/vscode/src/codeReader/symbolLinks.test.ts b/vscode/src/codeReader/symbolLinks.test.ts index ee3bc00..3d032de 100644 --- a/vscode/src/codeReader/symbolLinks.test.ts +++ b/vscode/src/codeReader/symbolLinks.test.ts @@ -1,11 +1,12 @@ import type { BlockView } from './blockView' +import type { LanguageAdapter, SourceRange } from './contract' import type { ReferenceSources, SymbolNode } from './symbolLinks' import { describe, expect, it } from 'vitest' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { importsIn } from './libraryRules' -import { importedNames, referenceScope, resolveReference, splitReference } from './symbolLinks' -import { readFixture, WASM_DIR, withBlocks } from './testSupport' +import { importedNames, outsideUses, referenceScope, resolveReference, splitReference } from './symbolLinks' +import { readFixture, runtime, WASM_DIR, withBlocks } from './testSupport' const py = createPythonAdapter(WASM_DIR) const ts = createTypeScriptAdapter(WASM_DIR) @@ -14,8 +15,16 @@ function symbol(name: string, start: number, end: number, children: SymbolNode[] return { name, range: { startIndex: start, endIndex: end }, selection: { startIndex: start, endIndex: start + name.length }, children } } -/** 假的语言扩展:几个文件的文档符号,本文件几处名字的定义;记下问过哪些。 */ -function sources(files: Record, definitions: Record = {}): ReferenceSources & { asked: number[] } { +/** + * 假的语言扩展:几个文件的文档符号,本文件几处名字的定义,本文件代码里用到、本文件又没定义的名字(名字 → 各处的起点), + * 语言自带的名字;记下问过哪些。 + */ +function sources( + files: Record, + definitions: Record = {}, + uses: Record = {}, + builtins: string[] = [], +): ReferenceSources & { asked: number[] } { const asked: number[] = [] return { uri: 'file:///here.py', @@ -26,6 +35,8 @@ function sources(files: Record, definitions: Record (uses[name] ?? []).map(start => ({ startIndex: start, endIndex: start + name.length })), + builtin: name => builtins.includes(name), } } @@ -131,6 +142,132 @@ describe('不带路径的引用:从被注释的声明起一层层往外找(K }) }) +describe('不带路径的引用:本文件代码里用到的、语言自带的名字', () => { + const imports = [{ name: 'Agent', offset: 20, range: { startIndex: 20, endIndex: 25 } }] + const BUILTINS = 'file:///typeshed/builtins.pyi' + /** load、save 两个函数,helper 里没用到 ValueError;ValueError 在文件顶层、load 里、save 里各用了一处,Agent 是导入的、代码里也用到了。 */ + const here = (definitions: Record = { + 50: { uri: BUILTINS, at: 1000, length: 10 }, + 150: { uri: BUILTINS, at: 1000, length: 10 }, + 450: { uri: BUILTINS, at: 1000, length: 10 }, + 20: { uri: 'file:///models.py', at: 10, length: 5 }, + }): ReturnType => sources({ + 'file:///here.py': [symbol('Agent', 20, 25), symbol('load', 100, 300), symbol('save', 400, 600), symbol('helper', 700, 800)], + [BUILTINS]: [symbol('ValueError', 1000, 1200, [symbol('args', 1100, 1150)])], + }, definitions, { ValueError: [50, 150, 450], Agent: [20, 900] }, ['ValueError', 'str']) + const LOAD = 100 + const SAVE = 400 + const HELPER = 700 + const valueError = { uri: BUILTINS, selection: { startIndex: 1000, endIndex: 1010 } } + + it('问写在代码里的那处的定义(同 ⌘ + 点击它):先挑用在这段注释说明的那个声明里面的,没有再挑整个文件的第一处', async () => { + const inLoad = here() + expect(await resolveReference('ValueError', LOAD, imports, inLoad)).toEqual(valueError) + expect(inLoad.asked).toEqual([150]) + const inSave = here() + expect(await resolveReference('ValueError', SAVE, imports, inSave)).toEqual(valueError) + expect(inSave.asked).toEqual([450]) + const elsewhere = here() + expect(await resolveReference('ValueError', HELPER, imports, elsewhere)).toEqual(valueError) + expect(await resolveReference('ValueError', undefined, imports, elsewhere)).toEqual(valueError) + expect(elsewhere.asked).toEqual([50, 50]) + }) + + it('写成 Class.member 的:在定义所在文件的那个类底下找成员,没有这个成员是找不到,不当语言自带的', async () => { + expect(await resolveReference('ValueError.args', LOAD, imports, here())).toEqual({ uri: BUILTINS, selection: { startIndex: 1100, endIndex: 1104 } }) + expect(await resolveReference('ValueError.missing', LOAD, imports, here())).toBeUndefined() + }) + + it('本文件定义的、导入的照旧先认:导入的名字代码里也用到了,问的还是导入语句里那处', async () => { + const found = here() + expect(await resolveReference('Agent', LOAD, imports, found)).toEqual({ uri: 'file:///models.py', selection: { startIndex: 10, endIndex: 15 } }) + expect(await resolveReference('load', SAVE, imports, found)).toEqual({ uri: 'file:///here.py', selection: { startIndex: 100, endIndex: 104 } }) + expect(found.asked).toEqual([20]) + }) + + it('代码里没写到的语言自带的名字是 builtin(当普通文字),Class.member 看第一段;不是语言自带的找不到', async () => { + const found = here() + expect(await resolveReference('str', LOAD, imports, found)).toBe('builtin') + expect(await resolveReference('str.join', LOAD, imports, found)).toBe('builtin') + expect(await resolveReference('Nowhere', LOAD, imports, found)).toBeUndefined() + expect(found.asked).toEqual([]) + }) + + it('代码里写到了、语言扩展却答不上:语言自带的是 builtin,不标成找不到', async () => { + expect(await resolveReference('ValueError', LOAD, imports, here({}))).toBe('builtin') + }) +}) + +describe('本文件代码里用到、本文件又没定义的名字(取自语言插件认的绑定)', () => { + async function usesIn(adapter: LanguageAdapter, languageId: string, source: string): Promise> { + const tree = await runtime.parse(adapter, languageId, source) + try { + return outsideUses(adapter.bindings!(tree.rootNode, source, languageId), source) + } + finally { + tree.delete() + } + } + const at = (source: string, text: string, name: string): SourceRange => { + const start = source.indexOf(text) + return { startIndex: start, endIndex: start + name.length } + } + + it('python:内置异常、内置函数照源码顺序记下各处;注释、字符串、属性名里的不算,本文件定义的不在', async () => { + const source = [ + 'import os', + '', + 'def load(path):', + ' """读 path,不对时 @throws [ValueError]"""', + ' # ValueError 也写在注释里', + ' if not os.path.exists(path):', + ' raise FileNotFoundError(path)', + ' data = config.ValueError', + ' raise ValueError(len(data))', + '', + 'def check():', + ' raise ValueError("ValueError")', + '', + ].join('\n') + const uses = await usesIn(py, 'python', source) + expect(uses.get('ValueError')).toEqual([at(source, 'ValueError(len', 'ValueError'), at(source, 'ValueError("', 'ValueError')]) + expect(uses.get('FileNotFoundError')).toEqual([at(source, 'FileNotFoundError', 'FileNotFoundError')]) + expect(uses.get('len')).toEqual([at(source, 'len(', 'len')]) + expect(['path', 'data', 'load', 'check'].filter(name => uses.has(name))).toEqual([]) + }) + + it('typeScript:throw 的 Error、用到的 JSON 记下;注释、字符串、属性名里的不算,本文件定义的不在', async () => { + const source = [ + '// Error 写在注释里', + 'export function parse(text: string): unknown {', + ' const data = JSON.parse(text)', + ' if (data.Error)', + ' throw new Error(\'Error\')', + ' return data', + '}', + '', + ].join('\n') + const uses = await usesIn(ts, 'typescript', source) + expect(uses.get('Error')).toEqual([at(source, 'Error(', 'Error')]) + expect(uses.get('JSON')).toEqual([at(source, 'JSON', 'JSON')]) + expect(['parse', 'text', 'data'].filter(name => uses.has(name))).toEqual([]) + }) +}) + +describe('语言插件给的语言自带的名字', () => { + it('python 的是 dir(builtins) 里公开的:内置异常、类型、函数、常量;认异常类照旧只看异常那部分', async () => { + expect(['ValueError', 'FileNotFoundError', 'str', 'dict', 'len', 'print', 'None', 'NotImplemented'].filter(name => !py.builtins?.has(name))).toEqual([]) + expect(['os', 'Path', 'self', '__import__'].filter(name => py.builtins?.has(name))).toEqual([]) + const exceptions = await withBlocks(py, 'python', 'class A(ValueError):\n pass\n\nclass B(str):\n pass\n', blocks => blocks.map(block => block.kind)) + expect(exceptions).toEqual(['exception', 'class']) + }) + + it('typeScript 的是 ECMAScript 标准的全局对象,浏览器、Node 另加的不算', () => { + expect(['Error', 'TypeError', 'RangeError', 'Promise', 'Array', 'Map', 'Set', 'Object', 'JSON', 'Math'].filter(name => !ts.builtins?.has(name))).toEqual([]) + expect(['window', 'document', 'process', 'Buffer'].filter(name => ts.builtins?.has(name))).toEqual([]) + }) +}) + describe('注释写在哪块里:从哪个声明起往外找', () => { function block(anchor: string, defines: number | undefined, children: BlockView[] = []): BlockView { return { anchor, children, ...(defines !== undefined && { defines: { startIndex: defines, endIndex: defines + 1 } }) } as BlockView diff --git a/vscode/src/codeReader/symbolLinks.ts b/vscode/src/codeReader/symbolLinks.ts index bcfa8eb..99580d1 100644 --- a/vscode/src/codeReader/symbolLinks.ts +++ b/vscode/src/codeReader/symbolLinks.ts @@ -1,10 +1,10 @@ import type { BlockView } from './blockView' -import type { ImportFacts, ImportItem, Node, SourceRange } from './contract' +import type { ImportFacts, ImportItem, NameBinding, Node, SourceRange } from './contract' import { findBlock } from './qa/prompt' /** * 注释里不带路径的引用(KDoc 的 `[Name]`、`[Class.member]`)指到哪:从这段注释说明的那个声明起,一层层往外找本文件定义的, - * 再找本文件导入进来的,都没有就找不到。 + * 再找本文件导入进来的,再找本文件代码里用到的语言自带的名字;语言自带、又问不到定义的当普通文字,别的都没有就找不到。 * 符号、定义问语言扩展,交给调用方(预览)去问;这里只管从哪儿起、按什么顺序找、怎么拆名字(不碰 VS Code)。 */ @@ -29,13 +29,21 @@ export interface LinkTarget { selection: SourceRange } -/** 解析一个引用要问的:本文件是哪个、它的文档符号,本文件某处名字的定义(几处取第一处),别的文件的文档符号。 */ +/** + * 解析一个引用要问的:本文件是哪个、它的文档符号,本文件某处名字的定义(几处取第一处),别的文件的文档符号, + * 本文件代码里用到某个本文件没定义的名字的各处(见 [outsideUses]),某个名字是不是这门语言自带的。 + */ export interface ReferenceSources { uri: string symbols: (uri: string) => Promise definition: (offset: number) => Promise + uses: (name: string) => readonly SourceRange[] + builtin: (name: string) => boolean } +/** 一个引用解析出来是什么:指到的地方;`'builtin'` 是这门语言自带、又问不到定义的名字,预览当普通文字显示;找不到是 `undefined`。 */ +export type ReferenceTarget = LinkTarget | 'builtin' | undefined + function spanOf(nodes: readonly (Node | undefined)[]): SourceRange { const present = nodes.filter((node): node is Node => node !== undefined) return { startIndex: Math.min(...present.map(node => node.startIndex)), endIndex: Math.max(...present.map(node => node.endIndex)) } @@ -63,6 +71,22 @@ export function importedNames(imports: readonly ImportFacts[]): ImportedName[] { return imports.flatMap(facts => facts.items.flatMap(item => importedName(item) ?? [])) } +/** + * 本文件代码里用到、本文件又没定义的名字(语言自带的、导入的,见 [NameBinding] 的 `local`)各在哪几处:名字 → 各处的范围,照源码顺序。 + * 绑定是语言插件照语法树认的,注释、字符串里的字和 `obj.name` 里的 `name` 都不在里面;导入的名字含导入语句里那一处。 + */ +export function outsideUses(bindings: readonly NameBinding[], source: string): Map { + const uses = new Map() + for (const { occurrences, local } of bindings) { + const [first] = occurrences + if (local || !first) + continue + const name = source.slice(first.startIndex, first.endIndex) + uses.set(name, [...uses.get(name) ?? [], ...occurrences].sort((a, b) => a.startIndex - b.startIndex)) + } + return uses +} + /** 引用拆成名字和成员:`Agent.owner_id` 是 Agent 和 [owner_id]。 */ export function splitReference(reference: string): { head: string, members: string[] } { const [head = '', ...members] = reference.split('.') @@ -107,24 +131,11 @@ function definedNear(symbols: readonly SymbolNode[], at: number | undefined, nam } /** - * 引用指到哪,按 KDoc 的规矩(不带限定的名字,当它写在这段注释说明的那个声明里面来认): - * 1. 本文件定义的,由近到远一层层找:at 是注释说明的那个声明的名字在源码里的下标(见 referenceScope)。先看盖住 at 的最里层符号 - * (就是这个声明)的直接子符号,再一层层往外看包着它的符号的直接子符号,最后看文件顶层;哪层先有这个名字(`Class.member` 看第一段) - * 就是哪个,`Class.member` 再在它底下找成员。导入语句里的名字不算本文件定义的。没有 at(不在哪个声明的注释里)只看文件顶层。 - * 2. 本文件导入进来的:名字(`Class.member` 的第一段)是某条导入引进来的,问导入语句里那个名字的定义;`Class.member` 再在定义所在文件的 - * 符号里找:定义落在哪个符号里就在它底下找,落在文件开头(导入的是模块)就从顶层找。 - * 3. 都没有:找不到。本文件某层有这个名字、却没有这个成员时也是找不到,不再去更外层、导入里找。 + * 问到的定义 `target` 底下的成员 `members`,没有成员就是 `target` 自己:在定义所在文件的符号里找,定义落在哪个符号里就在它底下找, + * 落在文件开头(导入的是模块)就从顶层找。 */ -export async function resolveReference(reference: string, at: number | undefined, imports: readonly ImportedName[], sources: ReferenceSources): Promise { - const { head, members } = splitReference(reference) - const own = definedNear(await sources.symbols(sources.uri), at, head, imports) - if (own) { - const found = members.length > 0 ? memberOf(own.children, members) : own - return found && { uri: sources.uri, selection: found.selection } - } - const imported = imports.find(item => item.name === head) - const target = imported && await sources.definition(imported.offset) - if (!target || members.length === 0) +async function memberAt(target: LinkTarget, members: readonly string[], sources: ReferenceSources): Promise { + if (members.length === 0) return target const symbols = await sources.symbols(target.uri) const holder = enclosing(symbols, target.selection.startIndex).at(-1) @@ -132,6 +143,43 @@ export async function resolveReference(reference: string, at: number | undefined return found && { uri: target.uri, selection: found.selection } } +/** 名字用在代码里的各处 `uses` 里挑一处问定义:先挑用在 `at` 那个声明(盖住 `at` 的最里层符号)里面的第一处,没有再挑整个文件的第一处。 */ +function useNear(symbols: readonly SymbolNode[], at: number | undefined, uses: readonly SourceRange[]): SourceRange | undefined { + const declared = at === undefined ? undefined : enclosing(symbols, at).at(-1) + return (declared && uses.find(range => covers(declared.range, range.startIndex))) ?? uses[0] +} + +/** + * 引用指到哪,按 KDoc 的规矩(不带限定的名字,当它写在这段注释说明的那个声明里面来认): + * 1. 本文件定义的,由近到远一层层找:`at` 是注释说明的那个声明的名字在源码里的下标(见 [referenceScope])。先看盖住 `at` 的最里层符号 + * (就是这个声明)的直接子符号,再一层层往外看包着它的符号的直接子符号,最后看文件顶层;哪层先有这个名字(`Class.member` 看第一段) + * 就是哪个,`Class.member` 再在它底下找成员。导入语句里的名字不算本文件定义的。没有 `at`(不在哪个声明的注释里)只看文件顶层。 + * 2. 本文件导入进来的:名字(第一段)是某条导入引进来的,问导入语句里那个名字的定义;`Class.member` 再在定义所在文件的符号里找成员(见 [memberAt])。 + * 3. 本文件代码里用到、本文件又没定义的(语言自带的 `ValueError`、`str`……):在名字(第一段)用在代码里的各处([ReferenceSources.uses])挑一处 + * 问定义(见 [useNear]),同在代码里 ⌘ + 点击它,Python 的内置名跳到 Pylance 自带的 builtins.pyi;`Class.member` 同第 2 步再找成员。 + * 4. 前面都没问到定义、名字(第一段)又是这门语言自带的([ReferenceSources.builtin]):是 `'builtin'`,预览当普通文字,不标成找不到。 + * 5. 都不是就找不到。本文件某层有这个名字、或问到了定义,却没有这个成员时也是找不到,不再往后找。 + */ +export async function resolveReference(reference: string, at: number | undefined, imports: readonly ImportedName[], sources: ReferenceSources): Promise { + const { head, members } = splitReference(reference) + const symbols = await sources.symbols(sources.uri) + const own = definedNear(symbols, at, head, imports) + if (own) { + const found = members.length > 0 ? memberOf(own.children, members) : own + return found && { uri: sources.uri, selection: found.selection } + } + const imported = imports.find(item => item.name === head) + if (imported) { + const target = await sources.definition(imported.offset) + return target && memberAt(target, members, sources) + } + const use = useNear(symbols, at, sources.uses(head)) + const target = use && await sources.definition(use.startIndex) + if (target) + return memberAt(target, members, sources) + return sources.builtin(head) ? 'builtin' : undefined +} + /** * 注释写在 anchor 那块里时,引用从哪个声明起找(resolveReference 的 at):这块有名字(是个声明)就是它的名字在源码里的下标; * 没名字的(注释、语句……)取外层最近的有名字的块。外层也都没名字、块树里没有这块时没有,只从文件顶层找。 diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 4e9987b..c240161 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -260,6 +260,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) .cr-colored { color: var(--c text-decoration: underline wavy; text-underline-offset: 2px; } +/* 不带路径的引用是语言自带的名字(Python 的 ValueError、str),跳不过去:和旁边的字一样,不像链接 */ +.cr-markdown a.cr-link-plain, +.cr-markdown a.cr-link-plain:hover { + color: inherit; + text-decoration: none; + cursor: text; +} /* 仓库图片:按原图比例,宽不超过注释区。点了是仓库链接,打开原图 */ .cr-markdown .cr-repo-image img { max-width: 100%; diff --git a/vscode/webview-ui/src/codeReader/repoLink.test.ts b/vscode/webview-ui/src/codeReader/repoLink.test.ts index 224e5d2..27a6998 100644 --- a/vscode/webview-ui/src/codeReader/repoLink.test.ts +++ b/vscode/webview-ui/src/codeReader/repoLink.test.ts @@ -94,11 +94,11 @@ describe('repoLinks', () => { links.scan([link('backend/a.py#Agent'), link('vendor/agents')]) expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py', 'vendor/agents'], references: [] }]) - expect(links.checked(0, ['vendor/agents'], [])).toBe(false) - expect(links.isBroken(link('vendor/agents'))).toBe(false) - expect(links.checked(1, ['vendor/agents'], [])).toBe(true) - expect(links.isBroken(link('vendor/agents'))).toBe(true) - expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false) + expect(links.checked(0, ['vendor/agents'], [], [])).toBe(false) + expect(links.state(link('vendor/agents'))).toBe('link') + expect(links.checked(1, ['vendor/agents'], [], [])).toBe(true) + expect(links.state(link('vendor/agents'))).toBe('broken') + expect(links.state(link('backend/a.py#Agent'))).toBe('link') }) it('带几行的链接:点了发 openRepoLink 带上这几行;文件在不在和别的仓库路径一起问,文件没有就标坏', () => { @@ -108,18 +108,18 @@ describe('repoLinks', () => { links.scan([link('backend/app/service.py#L357-L364'), link('backend/app/service.py#L3'), link('web/a.ts')]) expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/app/service.py', 'web/a.ts'], references: [] }) - links.checked(1, ['backend/app/service.py'], []) - expect(links.isBroken(link('backend/app/service.py#L3'))).toBe(true) - expect(links.isBroken(link('web/a.ts'))).toBe(false) + links.checked(1, ['backend/app/service.py'], [], []) + expect(links.state(link('backend/app/service.py#L3'))).toBe('broken') + expect(links.state(link('web/a.ts'))).toBe('link') }) it('符号没有才标坏;打开成功就不再标', () => { const { links } = setup() links.broken({ path: 'backend/a.py', symbol: 'Agent' }) - expect(links.isBroken(link('backend/a.py#Agent'))).toBe(true) - expect(links.isBroken(link('backend/a.py'))).toBe(false) + expect(links.state(link('backend/a.py#Agent'))).toBe('broken') + expect(links.state(link('backend/a.py'))).toBe('link') links.opened({ path: 'backend/a.py', symbol: 'Agent' }) - expect(links.isBroken(link('backend/a.py#Agent'))).toBe(false) + expect(links.state(link('backend/a.py#Agent'))).toBe('link') }) }) @@ -140,13 +140,13 @@ describe('不带路径的引用', () => { links.scan([{ symbol: 'USER', block: 'a' }, link('backend/a.py'), { symbol: 'Missing', block: 'a' }, { symbol: 'USER', block: 'a' }]) expect(sent.at(-1)).toEqual({ type: 'checkRepoLinks', id: 1, paths: ['backend/a.py'], references: [{ symbol: 'Missing', block: 'a' }, { symbol: 'USER', block: 'a' }] }) - expect(links.checked(1, [], [{ symbol: 'Missing', block: 'a' }])).toBe(true) - expect(links.isBroken({ symbol: 'Missing', block: 'a' })).toBe(true) - expect(links.isBroken({ symbol: 'USER', block: 'a' })).toBe(false) + expect(links.checked(1, [], [{ symbol: 'Missing', block: 'a' }], [])).toBe(true) + expect(links.state({ symbol: 'Missing', block: 'a' })).toBe('broken') + expect(links.state({ symbol: 'USER', block: 'a' })).toBe('link') links.opened({ symbol: 'Missing', block: 'a' }) - expect(links.isBroken({ symbol: 'Missing', block: 'a' })).toBe(false) + expect(links.state({ symbol: 'Missing', block: 'a' })).toBe('link') links.broken({ symbol: 'USER', block: 'a' }) - expect(links.isBroken({ symbol: 'USER', block: 'a' })).toBe(true) + expect(links.state({ symbol: 'USER', block: 'a' })).toBe('broken') }) it('同一个名字写在两块的注释里:分开问,一块找得到一块找不到时各标各的', () => { @@ -157,16 +157,35 @@ describe('不带路径的引用', () => { links.scan([inMethod, inFunction, outside, inMethod]) expect(sent).toEqual([{ type: 'checkRepoLinks', id: 1, paths: [], references: [outside, inMethod, inFunction] }]) - links.checked(1, [], [inFunction, outside]) - expect(links.isBroken(inMethod)).toBe(false) - expect(links.isBroken(inFunction)).toBe(true) - expect(links.isBroken(outside)).toBe(true) + links.checked(1, [], [inFunction, outside], []) + expect(links.state(inMethod)).toBe('link') + expect(links.state(inFunction)).toBe('broken') + expect(links.state(outside)).toBe('broken') links.opened(inFunction) links.broken(inMethod) - expect(links.isBroken(inFunction)).toBe(false) - expect(links.isBroken(inMethod)).toBe(true) - expect(links.isBroken(outside)).toBe(true) + expect(links.state(inFunction)).toBe('link') + expect(links.state(inMethod)).toBe('broken') + expect(links.state(outside)).toBe('broken') + }) + + it('语言自带的名字当普通文字、点了不发 openRepoLink;点了才知道是语言自带的(repoLinkBuiltin)也改当普通文字,不再标坏', () => { + const { links, sent } = setup() + const thrown = { symbol: 'ValueError', block: 'function load' } + const typed = { symbol: 'str', block: 'function load' } + links.scan([thrown, typed]) + expect(links.checked(1, [], [typed], [thrown])).toBe(true) + expect(links.state(thrown)).toBe('plain') + expect(links.state(typed)).toBe('broken') + expect(links.state({ symbol: 'ValueError', block: 'function other' })).toBe('link') + + expect(links.click(thrown)).toBe(true) + expect(sent.filter(msg => msg.type === 'openRepoLink')).toEqual([]) + + links.builtin(typed) + expect(links.state(typed)).toBe('plain') + links.opened(typed) + expect(links.state(typed)).toBe('link') }) it('同样的链接不再问;文档换了版本(stale)再问一次', () => { diff --git a/vscode/webview-ui/src/codeReader/repoLink.ts b/vscode/webview-ui/src/codeReader/repoLink.ts index 9010d4e..47591a8 100644 --- a/vscode/webview-ui/src/codeReader/repoLink.ts +++ b/vscode/webview-ui/src/codeReader/repoLink.ts @@ -145,9 +145,12 @@ function referenceKey({ symbol, block }: SymbolReference): string { return `${symbol}\u0000${block ?? ''}` } +/** 页面上一个链接眼下怎么显示:照常的链接;坏的(文件、符号没有,引用找不到);普通文字(语言自带、又问不到定义的名字,点了不打开)。 */ +export type LinkState = 'link' | 'broken' | 'plain' + /** - * 页面上的仓库链接和不带路径的引用:点了发给扩展端打开;预览出来后把路径、引用发去问哪些文件没有、哪些引用找不到; - * 扩展端说没有的,标成坏的。仓库链接的符号在不在要点了才知道。不带路径的引用按(写在哪块,名字)分开问、分开标。 + * 页面上的仓库链接和不带路径的引用:点了发给扩展端打开;预览出来后把路径、引用发去问哪些文件没有、哪些引用找不到、哪些是语言自带的名字; + * 扩展端说没有的标成坏的,说是语言自带的当普通文字。仓库链接的符号在不在要点了才知道。不带路径的引用按(写在哪块,名字)分开问、分开标。 */ export class RepoLinks { private request = 0 @@ -155,16 +158,19 @@ export class RepoLinks { private sent = '' private missingPaths = new Set() private missingSymbols = new Set() - /** 找不到的不带路径的引用(referenceKey) */ + /** 找不到的不带路径的引用([referenceKey]) */ private unresolved = new Set() + /** 语言自带、又问不到定义的不带路径的引用([referenceKey]):当普通文字 */ + private builtins = new Set() constructor(private readonly post: (msg: WebviewToExtension) => void) {} - /** 点在仓库链接或不带路径的引用上(见 commentLinkOf):发给扩展端打开,返回 true。别的链接(没认出来的)不管。 */ + /** 点在仓库链接或不带路径的引用上(见 [commentLinkOf]):发给扩展端打开,返回 `true`;当普通文字的不发,也返回 `true`,免得被当外部链接打开。别的链接(没认出来的)不管。 */ click(link: CommentLink | undefined): boolean { if (!link) return false - this.post({ type: 'openRepoLink', ...link }) + if (this.state(link) !== 'plain') + this.post({ type: 'openRepoLink', ...link }) return true } @@ -192,20 +198,23 @@ export class RepoLinks { this.sent = '' } - /** id 那次询问的回答。不是最近一次的丢掉,返回这次有没有用上。 */ - checked(id: number, missing: readonly string[], unresolved: readonly SymbolReference[]): boolean { + /** `id` 那次询问的回答:仓库里没有的路径 `missing`,找不到的引用 `unresolved`,语言自带的名字 `builtin`。不是最近一次的丢掉,返回这次有没有用上。 */ + checked(id: number, missing: readonly string[], unresolved: readonly SymbolReference[], builtin: readonly SymbolReference[]): boolean { if (id !== this.request) return false this.missingPaths = new Set(missing) this.unresolved = new Set(unresolved.map(referenceKey)) + this.builtins = new Set(builtin.map(referenceKey)) return true } /** 扩展端打开时发现文件或符号没有;没有路径的是写在 block 那块里的不带路径的引用找不到。 */ broken({ path, symbol, block }: ReportedLink): void { if (path === undefined) { - if (symbol !== undefined) + if (symbol !== undefined) { this.unresolved.add(referenceKey({ symbol, block })) + this.builtins.delete(referenceKey({ symbol, block })) + } return } if (symbol !== undefined) @@ -217,8 +226,10 @@ export class RepoLinks { /** 打开成功:这个路径在,这个符号也在;没有路径的是写在 block 那块里的不带路径的引用找到了。 */ opened({ path, symbol, block }: ReportedLink): void { if (path === undefined) { - if (symbol !== undefined) + if (symbol !== undefined) { this.unresolved.delete(referenceKey({ symbol, block })) + this.builtins.delete(referenceKey({ symbol, block })) + } return } this.missingPaths.delete(path) @@ -226,14 +237,22 @@ export class RepoLinks { this.missingSymbols.delete(symbolKey(path, symbol)) } - /** 这个链接现在是不是坏的(文件没有,这个符号没有,或写在这块里的引用找不到)。还没问到的不算。 */ - isBroken(link: CommentLink | undefined): boolean { + /** 扩展端打开时发现写在 `block` 那块里的不带路径的引用是语言自带、又问不到定义的名字:改当普通文字,不再标坏。 */ + builtin(reference: SymbolReference): void { + this.unresolved.delete(referenceKey(reference)) + this.builtins.add(referenceKey(reference)) + } + + /** 这个链接现在怎么显示(见 [LinkState]):文件没有、这个符号没有、写在这块里的引用找不到是坏的,写在这块里的引用是语言自带的名字是普通文字。还没问到的照常是链接。 */ + state(link: CommentLink | undefined): LinkState { if (!link) - return false - if (link.path === undefined) - return this.unresolved.has(referenceKey(link)) + return 'link' + if (link.path === undefined) { + const key = referenceKey(link) + return this.unresolved.has(key) ? 'broken' : this.builtins.has(key) ? 'plain' : 'link' + } if (this.missingPaths.has(link.path)) - return true - return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol)) + return 'broken' + return link.symbol !== undefined && this.missingSymbols.has(symbolKey(link.path, link.symbol)) ? 'broken' : 'link' } } diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 4e27d6b..9feef42 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -536,9 +536,11 @@ export interface CodeColors { * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 * diff:这份文件相比对比基准改了什么(审查工作副本里的、用「未提交的更改」文件行上的「打开更改」打开的有改动的文件才有);每来一份文档之后补来,基准变了、换了从哪儿打开再来;null 是不比了 * openedFrom:这个标签页是从哪儿打开的,记进页面状态;ready 后来一次,开着时又从侧栏点开、换了从哪儿打开的再来 - * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用(原样带回问的那几条) + * repoLinksChecked:id 那次 checkRepoLinks 的回答,missing 是仓库里没有的路径,unresolved 是找不到的不带路径的引用, + * builtin 是语言自带、又问不到定义的不带路径的引用(Python 的 `ValueError`、`str`),当普通文字显示;两样都原样带回问的那几条 * repoLinkBroken:这个仓库链接打不开。没有 symbol 是文件或目录没有;有 symbol 是文件在、这个符号没有;没有 path 是不带路径的引用找不到, * block 同 openRepoLink + * repoLinkBuiltin:点的不带路径的引用是语言自带、又问不到定义的名字,没打开,改当普通文字显示;symbol、block 同 openRepoLink * repoLinkOpened:这个仓库链接打开了。路径在;带了 symbol 时这个符号也在;没有 path 是不带路径的引用找到了,block 同 openRepoLink * repoImagesChecked:id 那次 checkRepoImages 的回答。src 是预览里能直接用的图片地址;没有 src 是文件不在 * callTreeCounts:version 这份文档里,每个能看调用树的块落在几棵调用树里(只包括大于 0 的)。不是页面上这个版本的不理 @@ -606,8 +608,9 @@ export type ExtensionToWebview | RewriteEnd | { type: 'diff', diff: DocumentDiff | null } | { type: 'openedFrom', from: OpenedFrom } - | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[] } + | { type: 'repoLinksChecked', id: number, missing: string[], unresolved: SymbolReference[], builtin: SymbolReference[] } | { type: 'repoLinkBroken', path?: string, symbol?: string, block?: string } + | ({ type: 'repoLinkBuiltin' } & SymbolReference) | { type: 'repoLinkOpened', path?: string, symbol?: string, block?: string } | { type: 'repoImagesChecked', id: number, images: { path: string, src?: string }[] } | { type: 'focusPathAsk' } diff --git a/vscode/webview-ui/src/codeReader/useRepoLinks.ts b/vscode/webview-ui/src/codeReader/useRepoLinks.ts index b005477..5cfa5b7 100644 --- a/vscode/webview-ui/src/codeReader/useRepoLinks.ts +++ b/vscode/webview-ui/src/codeReader/useRepoLinks.ts @@ -1,3 +1,4 @@ +import type { LinkState } from './repoLink' import { useEffect, useState } from 'react' import { commentLinkOf, RepoLinks } from './repoLink' import { onMessage, postMessage } from './vscode' @@ -5,10 +6,14 @@ import { onMessage, postMessage } from './vscode' /** 坏链接悬停上的字,走页面上统一的 data-tooltip,不用原生 title。 */ const MISSING = '找不到' -/** 文件或符号没有的仓库链接、找不到的不带路径的引用:红色波浪线,悬停「找不到」。问到之前先是普通链接。 */ -function paintLink(anchor: HTMLAnchorElement, broken: boolean): void { +/** + * 按链接眼下的状态(见 [LinkState])画:文件或符号没有的仓库链接、找不到的不带路径的引用是红色波浪线,悬停「找不到」; + * 语言自带的名字画成普通文字。问到之前先是普通链接。 + */ +function paintLink(anchor: HTMLAnchorElement, state: LinkState): void { + anchor.classList.toggle('cr-link-plain', state === 'plain') const tip = anchor.getAttribute('data-tooltip') - if (broken) { + if (state === 'broken') { anchor.classList.add('cr-link-broken') if (tip !== MISSING) { if (tip) @@ -32,12 +37,12 @@ function paintLink(anchor: HTMLAnchorElement, broken: boolean): void { function paint(links: RepoLinks): void { for (const anchor of document.querySelectorAll('.cr-markdown a')) - paintLink(anchor, links.isBroken(commentLinkOf(anchor))) + paintLink(anchor, links.state(commentLinkOf(anchor))) } /** * 注释、说明、问 AI 的回答里的仓库路径链接,和注释里不带路径的引用:点了发给扩展端打开(不走 webview 把 http 链接交给外部打开的那条), - * 画出来之后把路径、引用发去问哪些文件没有、哪些引用找不到;文档换了版本再问一次(引用跟着源码变)。 + * 画出来之后把路径、引用发去问哪些文件没有、哪些引用找不到、哪些是语言自带的名字;文档换了版本再问一次(引用跟着源码变)。 * 不带路径的引用都带着它写在哪块(见 commentLinkOf),扩展端从那块的声明起找。 */ export function useRepoLinks(): void { @@ -50,7 +55,7 @@ export function useRepoLinks(): void { timer = window.setTimeout(() => { const found = [...document.querySelectorAll('.cr-markdown a')].map(anchor => ({ anchor, link: commentLinkOf(anchor) })) for (const { anchor, link } of found) - paintLink(anchor, links.isBroken(link)) + paintLink(anchor, links.state(link)) links.scan(found.flatMap(({ link }) => link ?? [])) }, 50) } @@ -61,10 +66,15 @@ export function useRepoLinks(): void { return } if (msg.type === 'repoLinksChecked') { - if (links.checked(msg.id, msg.missing, msg.unresolved)) + if (links.checked(msg.id, msg.missing, msg.unresolved, msg.builtin)) paint(links) return } + if (msg.type === 'repoLinkBuiltin') { + links.builtin(msg) + paint(links) + return + } if (msg.type === 'repoLinkBroken') { links.broken(msg) paint(links)