diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 28a9dfe..4c3dbc9 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -36,6 +36,7 @@ | 库规则 | 第四种插件:按库认出一种写法,把那块整个换成专门的卡片(内置 SQLAlchemy 规则:模型类 → 「数据表」卡片;内置 Celery 规则:`Celery(...)` → 「Celery 应用」卡片、`beat_schedule` → 「定时任务」卡片),或把那块认成契约里现成的一种属性表块(内置 enum 规则:Python 自带 enum 库的类 → 枚举块)。和语言插件、翻译插件一样写代码注册、只经契约拿信息;文件导入了规则关心的库才启用,由宿主按语言插件给的导入判断;卡片的用词归规则自己,认成的现成块照常由翻译插件排,webview 只画结构。见「库规则」 | | 枚举 | 枚举是一种块(种类「枚举」,紫色 #b180d7 / 亮色 #652d90),属性表:名称 / 说明 / 访问级别 / 装饰器 / 继承(有才显示)/ 成员 N 个,成员排成表格「成员 \| 值 \| 说明」。TypeScript 的 `enum` 是语言自己的写法,由语言插件报;Python 没有枚举语法,自带 enum 库的写法由库规则认成同一种块。两边同一种块、同一张表、同一个筛选种类,webview 只有一种画法 | | 规则自己查定义 | 规则可以把自己给的名字标成「引用」(Celery 任务表里的任务名),⌘ / Ctrl 悬停、点击这样的名字时由规则查定义(Celery 在整个工作区里找任务函数),其余名字照旧问语言扩展;webview 只有一条链接 / 跳转的路,样子、闪、挑选框、在预览里打开都一样。规则要在全工作区找东西时只描述要找什么(`WorkspaceIndex`),找文件、解析、缓存、跟着改动更新归宿主。见「规则自己查定义」 | +| 中文名 | 代码里给定义起的中文名(`Annotated[T, Comment("当前用户")]`、`@Comment("…")`、行尾 `# @Comment 当前用户`,没有时取参数在 docstring / JSDoc 里的说明):预览里这个定义和用到它的各处都显示中文名,悬停看原名,定义处本身在中文名后面淡淡写原名。语言插件认写法,用到它的各处由语言扩展按语义认(同一文件问 document highlights,别的文件定义的先查工作区索引、再问定义提供者),webview 只按源码下标换字。见「中文名」 | | 推导式(写在表达式里的遍历) | Python 的列表 / 集合 / 字典推导式、生成器表达式出现在译文任何地方(一行译文、属性表的值、默认值、字典项的值),都原地换成一个带循环图标和结果类型(生成器 / 列表 / 集合 / 字典)的小框,颜色同「循环」块,框里按读的顺序重排:「遍历 来源 的每个 变量,仅当 条件,取 值」。语言插件报结构,翻译插件定用词,webview 只认「框」不认语言 | ## 用户可见行为 @@ -171,7 +172,7 @@ ### 注释的 Markdown 排版 - 用在:注释块(`#`、`//`、`/* */`、不挂在函数、类上的 JSDoc、Python 模块开头的 docstring 和别的当注释用的单独字符串)的预览,函数、类、常量、变量属性表的「说明」,字段译文下面的说明。视图数据里它们是 `{ markdown }`(见「通用契约」的 `Markdown`),webview 按数据决定怎么渲染,不猜。 -- 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)和引号,再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 +- 原文:翻译插件去掉注释符号(`#`、`//`、`/*` `*/` 和每行开头的 `*`)、引号和中文名记号(`SourceDocument.markers`,见「中文名」),再去掉行尾空白、首尾空行和共同缩进,保留换行,不做别的转换。一个注释节点(块注释、docstring)的第一行紧跟在注释符号 / 引号后面,不参与算共同缩进(同 PEP 257);几条行注释连成的一块每行地位相同,第一行也算,缩进四格的代码块才留得住。 - 渲染:webview 用 markdown-it(`html: false`、`linkify: true`),CommonMark 加 GFM 的表格、删除线:段落、强调、行内代码(含双反引号)、代码块(围栏、缩进四格)、列表、链接、标题、引用、表格。段内单个换行是软换行,空行分段。同一段文字只渲染一次(缓存最近 500 段)。 - 安全:源码里的 HTML 当文字显示;链接只认 http / https / mailto,带协议的网址和邮箱自动成链接(`sample.py` 这种不带协议的不算),别的写法(`javascript:`、`command:`、相对路径)照原文显示。点链接由 VS Code 的 webview 自己拦下、交给宿主在外部打开,不用另发消息。图片不渲染(webview 的 CSP 不放行外部图片),`![alt](url)` 显示成 `!` 加链接。 - 样子:正文用界面字体(`--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 细线。 @@ -214,6 +215,40 @@ - 清掉(连同筛选栏上那句):点空白处或不是名字的字、按 Esc(输入框、下拉列表自己用掉的 Esc 不算)、文档换了版本、换了翻译。 - 不抢别的点击:⌘ / Ctrl(以及 Alt、Shift)+ 点击、双击的第二下、拖着选字都不动高亮(双击的第一下照样算一次单击,和编辑器一样);点在按钮、链接、输入框、下拉列表、筛选栏、属性名上照旧,高亮不动——折叠箭头、「编辑」、Markdown 里的链接都照旧;名字本身在按钮里(带下一层的子行的键)时照旧折叠,同时高亮。 +### 中文名 + +代码几乎都是英文名字,读的人要一个个在脑子里翻。代码里给定义起了中文名(显示名)的,预览里直接写中文名:`if current_user.role != UserRole.ADMIN` 读成「如果 当前用户.role 不等于 UserRole.管理员」。 + +从哪来(语言插件认,契约里只有一种事实:定义处的名字 + 中文名,`DisplayName`): + +| 语言 | 写法 | 能起名的定义 | +| --- | --- | --- | +| Python | 注解 `Annotated[T, …, Comment("当前用户")]`(`typing.Annotated` 也算;调用的名字最后一节是 `Comment` 就算,不管从哪导入、是不是本地定义的,参数是普通字符串) | 参数、变量、类字段 | +| Python | 装饰器 `@Comment("…")`(项目里自己定义的什么都不做的函数) | 函数、类 | +| Python | 定义那一行行尾的注释 `# @Comment 当前用户`(记号从 `@Comment` 到行尾,前面可以还有别的字) | 参数(多行参数表里每行一个)、变量、类字段、枚举成员、`self.x = …` 的 x | +| Python | 兜底:函数 docstring 里这个参数的说明(谷歌写法 `Args:` 一节的 `name (type): 说明`,Sphinx 写法 `:param name:` / `:param type name:`),取第一小句(到第一个 ,。,.;;:: 为止),去掉首尾空白后不超过 12 个字才算,否则没有中文名 | 参数 | +| TypeScript / JavaScript | 装饰器 `@Comment("…")`(名字最后一节是 Comment) | 类(`@d export class` 也算)、方法、字段、方法和构造函数的参数 | +| TypeScript / JavaScript | 定义那一行行尾的 `// @Comment 当前用户`(块注释不算) | 参数、变量、枚举成员、类字段、接口属性 | +| TypeScript / JavaScript | 兜底:函数的 JSDoc 里 `@param [{类型}] 名字 [-] 说明`(`[名字]`、`[名字=默认]` 也认,`opts.x` 这种属性不认),同上取第一小句、不超过 12 个字 | 参数(函数声明、方法、值是函数的变量和类字段) | + +- 行尾记号给那一行第一个能带行尾记号的定义(上表第三行、第六行那些;函数、类用装饰器),单独成行的注释、那一行没有这种定义的不算。 +- 同一个定义几处都写了:写在代码里的记号(注解、装饰器)优先,其次行尾记号,最后才是说明文字。 +- 行尾记号不是说明文字:语言插件把记号的源码范围也交出来(`DisplayNameFacts.markers`,不管这个定义最后取没取它),宿主放进 `SourceDocument.markers`,翻译插件翻注释、说明时把它去掉(`# @Comment 管理员` 这样只有记号的注释就没有说明,枚举成员表里没有这一列)。docstring、JSDoc 照原样显示。 + +落到哪些名字上(用到它的各处由语言扩展按语义认,同名的别的东西不算;没有语言扩展时一个都不换,和高亮、跳到定义一样只走这一条路): + +- 本文件定义的:每个起了中文名的定义在定义处问一次 `vscode.executeDocumentHighlights`,回答里落在视图数据里带下标的名字(段的下标、长度都对得上)上的都换成中文名。 +- 别的文件定义的(`UserRole.ADMIN`,ADMIN 在 models.py):宿主给每种语言建一份工作区中文名索引(同库规则的 `WorkspaceIndex`,找文件、排除、嵌套 checkout、忽略文件、盯改动、第一次查才建都是同一套;文件是语言插件给的 `DisplayNameReader.files`,预筛是 `mentions`),键是原名;这份文档里原名在索引里(别的文件)有的名字才问 `vscode.executeDefinitionProvider`,定义处正好是索引里那个定义(同一文件、同一行列)才换。 +- 按文档版本查一次,不挡第一次画:文档先发,查到本文件的先发一次,别的文件的查完再发(`displayNames` 消息);页面重新加载(标签页切回来)时随文档带上已经查到的。语言扩展刚启动时答不上来(定义处自己都查不到出现,或要问的定义一个都没答),隔 1、2、4、8、16 秒再查,还答不上就算了。 +- 改文档时不闪:已经查到的中文名跟着改动挪位置(改动在前面的往后挪,碰到的丢掉),随新版本的文档先带过去;新版本查完一起换(手上有挪过来的就不先发只有本文件的那一半)。查到一半文档又改了,查到的丢掉。 + +怎么显示(webview 不认识任何语言,只按下标和长度换字): + +- 预览里所有照抄源码名字的地方(一行译文、属性表、表格、枚举和库规则的卡片、推导式的框):名字写成中文名,悬停提示「原名 current_user」;下标不变,⌘ / Ctrl + 点击跳到定义、单击高亮各处出现、⌘↓ 跳都照旧,筛选栏上的名字写中文名。 +- 定义处本身(输入参数表格的名称格子、名称行、枚举成员格子这些):中文名后面淡淡写原名(`descriptionForeground`、字小一号,`.cr-original`),例如「当前用户 current_user」。 +- 只换名字那一段:`UserRole.ADMIN` 读成「UserRole.管理员」,前面的类名照写。 +- 「编辑」状态的源码、Markdown 说明里的名字不换。 + ### 问 AI 原型画板「问 AI」、组件 `Reader / AskPanel`(`State=history | asking`)。读代码时对一块有疑问,直接在块下面问 Claude Code,答完存起来,下次打开还在,理解了就删。 @@ -477,10 +512,26 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined + /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ + displayNames?: DisplayNameReader } -/** 翻译插件翻一块时拿到的这份源码:源码字符串,和解析它的语言插件。 */ -export interface SourceDocument { source: string, language: LanguageAdapter } +/** 代码里给一个定义起的中文名:name 是定义处那个名字,text 是中文名。 */ +export interface DisplayName { name: SourceRange, text: string } + +export interface DisplayNameFacts { + names: DisplayName[] // 起了中文名的定义,照源码顺序;一个定义几处都写了时语言插件定取哪个 + markers: SourceRange[] // 写在注释里给代码阅读看的记号(`@Comment 当前用户`):翻注释、说明时去掉 +} + +export interface DisplayNameReader { + files: Record // languageId → 工作区里这种语言的文件(glob):宿主建中文名索引 + mentions: (source: string) => boolean // 便宜的文字预筛 + find: (root: Node, source: string, languageId: string) => DisplayNameFacts +} + +/** 翻译插件翻一块时拿到的这份源码:源码字符串,解析它的语言插件,和要从注释、说明里去掉的记号。 */ +export interface SourceDocument { source: string, language: LanguageAdapter, markers: SourceRange[] } /** 筛选属性和值的显示名;没给名字的显示 id。 */ export interface AttributeNames { @@ -584,7 +635,8 @@ export interface CodeReaderApi { - 注册表变化时更新 `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 翻译)拿得到和内置翻译插件一样的信息,第三方语言插件也能配上任何翻译插件。 -- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻。 +- 第三方语言插件要给的:`id`、`languageIds`、`grammarPath`、`toBlocks`(块的种类、头部、子块;函数、类、枚举、导入、常量、变量块带 facts)、`tokenRole`。可以给的:facts 里语言有才填的项(async、abstract、typeOnly、access、doc、`DeclaratorFacts.entries`)、`BlockShape.attributes`(通用词表以外的筛选属性)、`iteration`(有写在表达式里的遍历的语言)、`displayNames`(这门语言里给定义起中文名的写法)。没给的就少那一样:没有 entries 的值不展开,没有 `iteration` 的遍历照源码结构逐词翻,没有 `displayNames` 就没有中文名。 +- 中文名的分工:语言插件只认写法(`DisplayNameReader.find`:定义处的名字、中文名、注释里的记号);宿主解析一次源码时顺带问它,记号放进 `SourceDocument.markers` 交给翻译插件(翻注释、说明时去掉,中文模板直译在 `ZhTable.commentText` 之前去掉),定义交给预览找用到它的各处(`nameResolution.ts`);翻译插件不认识 `@Comment`,webview 不认识任何语言。 - 翻译插件拿到的:代码块(kind、node、header、children,属性表块还有 facts)和 `SourceDocument`。按语法节点类型写的叫法(关键字表、按语境换的译法、节点模板、注解位置这类只关乎措辞的判断)属于翻译插件自己,写在它的表里。 ## 库规则 @@ -993,11 +1045,12 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | ``` - 扩展 → webview: - - `{ type: 'document', version, languageId, blocks: BlockView[], attributes: FileAttribute[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal }`,文档每次变化(防抖 150ms)重发。`attributes` 是这份文件里出现过的筛选属性,按筛选栏的先后排:`{ id, name, values: { id, name, count }[] }`,名字取翻译插件的 `attributeNames`,没有的用 id。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 + - `{ type: 'document', version, languageId, blocks: BlockView[], attributes: FileAttribute[], translators: { id, label }[], translatorId, error?: string, reveal?: Reveal, names: ShownName[] }`,文档每次变化(防抖 150ms)重发。`names` 是这个版本眼下已知的中文名(`{ offset, length, name, definition? }`:源码下标 [offset, offset + length) 的名字显示成 name,definition 是定义处本身),见「中文名」。`attributes` 是这份文件里出现过的筛选属性,按筛选栏的先后排:`{ id, name, values: { id, name, count }[] }`,名字取翻译插件的 `attributeNames`,没有的用 id。`reveal = { line, definition? }` 只随新预览的第一份文档走:从文本编辑器切过来时是它顶部那一行(滚到包含这行的块、顶到顶部,折叠的不展开);跳到定义时还带 `definition`(定义处名字在源码里的下标),按「跳到定义」一节展开、滚动、闪。 - `{ type: 'reveal', line, definition? }`:同上,发给已经开着的预览。页面正在重新加载(标签页切回来)时收不到,扩展端改成随它 `ready` 后的第一份文档带过去。 - `{ type: 'definitionProbed', id, found }`:回答第 `id` 次 `probeDefinition`。 - `{ type: 'occurrencesFound', id, occurrences: { start, end, kind: 'text' | 'read' | 'write' }[] }`:回答第 `id` 次 `findOccurrences`,`[start, end)` 是源码下标,`kind` 同 `DocumentHighlightKind`;语言扩展没答就是空数组。 - `{ type: 'moveOccurrence', step: 1 | -1 }`:两条命令发来,跳到下一处 / 上一处出现。 + - `{ type: 'displayNames', version, names: ShownName[] }`:version 这份文档里显示成中文名的名字,查到一步发一步;页面只收自己眼下这个版本的。 - `{ type: 'filter', excluded: Record<属性 id, 值 id[]> }`:整个工作区共用的块筛选。webview 发 `ready` 后先于第一份文档发一次(第一份文档按筛选画),之后任何一个预览改了都发给所有开着的预览(包括改的那个)。 - `{ type: 'qa', available, threads: { anchor, entries: { id, question, answer, createdAt, profile?, images? }[] }[], running: { askId, anchor, question, text, activity? }[] }`:这份文件的问答(见「问 AI」)。`ready` 后发一次,存的问答、正在答的问题变了再发给开着这份文件的所有预览;`available` 为假时文件不在工作区文件夹里,不给「问 AI」;`images` 是问题带的图片在预览里能直接用的地址(`webview.asWebviewUri`);`running` 让重新加载的页面接上还在答的问题(不带图片,接上的这一问不显示缩略图,答完回到历史里照常有)。 - `{ type: 'askProfiles', options: { id, label }[], selected }`:「问 AI」的 profile 下拉(`id` 是 profile 名,「默认」的 `id` 是 `''`、`label` 是「默认」)。`ready` 后发一次(每次现读 profile 目录),任何一个预览选了都发给所有开着的预览(包括选的那个)。 @@ -1049,7 +1102,8 @@ vscode/src/codeReader/ blockView.ts CodeBlock → BlockView(源码着色、去缩进、调用翻译) attributes.ts 块的筛选属性(从 facts 推出通用属性 + 语言自己报的)、文件里出现过的属性和块数 libraryRules.ts 库规则的宿主部分:按导入启用、按注册顺序认领、出错退回、编辑范围和留下的子块、查规则标的引用 - workspaceIndex.ts 库规则的工作区索引(WorkspaceIndex)的宿主部分:建、缓存、跟着文件改动更新(不碰 VS Code) + workspaceIndex.ts 工作区索引(库规则的 WorkspaceIndex,和宿主自己的中文名索引)的宿主部分:建、缓存、跟着文件改动更新,查到的项带着认的时候给的东西(不碰 VS Code) + nameResolution.ts 中文名落到预览里哪些名字上:本文件问各处出现、别的文件先查中文名索引再问定义,按文档版本查一次、没答上再查、跟着改动挪位置(不碰 VS Code) workspaceFiles.ts 工作区索引在 VS Code 里怎么找文件、读文件、盯改动(排除规则) workspaceScope.ts 工作区索引还要跳过的文件:嵌套的 checkout、忽略文件忽略的(不碰 VS Code) rules/sqlalchemy.ts 内置 SQLAlchemy 库规则(只 import contract 和同目录的共用代码) @@ -1061,6 +1115,9 @@ vscode/src/codeReader/ rules/children.ts 库规则共用的挑子块的办法(成员连同紧挨在它前面的注释块、成员的说明节点) languages/typescript.ts languages/python.ts (iteration:推导式的结构) + languages/displayNames.ts 中文名的共用部分:第一小句、行尾记号、按优先级汇总 + languages/pythonNames.ts Python 的中文名写法(Annotated、@Comment、行尾记号、docstring) + languages/typescriptNames.ts TypeScript / JavaScript 的中文名写法(@Comment 装饰器、行尾记号、JSDoc) translators/zhTemplate.ts 模板直译引擎(按语言的表驱动,词性、遍历结构从 SourceDocument.language 取;推导式排进框见 iterationGroup) translators/typescriptZh.ts TS 表 translators/pythonZh.ts Python 表(含注解位置的判断) @@ -1093,7 +1150,7 @@ vscode/src/codeReader/ editor/occurrences.ts 查名字的各处出现(vscode.executeDocumentHighlights) editor/occurrenceNavigation.ts 跳到下一处 / 上一处高亮:命令、上下文键 index.ts registerCodeReader(context): CodeReaderApi -vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) +vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vite 第二个入口;块右上角的图标按钮在 BlockTools.tsx,属性表在 PropertyGrid.tsx,子行排成表格还是列表在 subRows.ts,着色的词和框在 SegmentText.tsx,中文名换字在 displayNames.ts,Markdown 渲染在 markdown.ts / TextPreview.tsx,筛选栏在 FilterBar.tsx,筛选规则在 filter.ts,编辑时盖住哪些块、点「编辑」退出哪些块在 editing.ts,高亮各处出现的规则在 occurrences.ts、接到页面上在 useOccurrences.ts,问 AI 的问答区在 AskThread.tsx、状态和规则在 qa.ts、贴图拖图的规则在 qaImages.ts、收发在 useQa.ts) ``` ## 测试 @@ -1112,6 +1169,13 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 预览 / 普通编辑器 / 挑选框;webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 - 高亮各处出现:段的源码范围和出现重叠才亮、边界相接不算、没下标的不亮,点分模块名每一节各按自己的范围认(真实的 Python 译文和属性表:定义处按写入、用到处按读取);写入 → 强高亮色,读取、按文字 → 普通高亮色,同时碰上按写入;单击名字问、点空白处清、点按钮 / 链接 / 筛选栏不动、名字在按钮里照样问,拖选、双击第二下、带修饰键的点击不动,⌘ / Ctrl + 点击照旧发跳到定义、不问出现处;回答连同名字显示、空回答不亮、换名字时回答到前旧的留着、迟到的回答丢掉、答过「没有」再点照样再问、清掉后才到的回答丢掉、换文档版本或翻译清掉而同版本同翻译重发不清、没有文档时不问。键盘跳(`occurrences.test.ts`、`reveal.test.ts`、`editor/occurrenceNavigation.test.ts`):回答照源码先后排、点中的那处是当前那一处、「2/5」和「N 处」;往后 / 往前、到头接着跳,找不到的跳过、总数照旧、一处都找不到不动,点中的不在回答里从点中的地方数起;只有当前那一处描框;能不能跳(高亮、焦点、输入框)第一次必报、之后变了才报;页面上找第一处显示着它的地方、要展开的块 / 属性行 / 子行、跳过筛掉 / 编辑 / 盖住的块、Markdown 不算;`package.json` 的两条命令、默认键、`when`、命令面板,扩展和页面的代码里不写键、不拦键,上下文键有一个预览能跳就真、变了才设,命令发给能跳的预览、没有就发给活动的;按键那一刻的位置跳转时回一次、隔太久 / 点过鼠标 / 没按过键不回,跳完守住跳到的位置、过了 `KEY_SCROLL_MS` 或又按键、点鼠标不守。 +- 中文名: + - 语言插件认写法(`languages/pythonNames.test.ts`、`languages/typescriptNames.test.ts`,真实解析):Python 注解 `Annotated`(`typing.Annotated`、`t.Comment`,f-string、别的调用不算)、装饰器(函数、类)、行尾记号(多行参数表、`*args`、`self.x`、变量、字段、枚举成员,记号前面有别的字,记号的范围)、单独成行 / 那一行没有定义的不算、函数那一行给参数、docstring 的谷歌写法和 Sphinx 写法(带类型、星号、续行、别的段落不算)、第一小句和 12 个字的限制、明写的记号盖过说明文字、没取的记号照样去掉;TypeScript 装饰器(类、`@d export class`、字段、构造函数和方法的参数、方法隔着别的装饰器、名字最后一节)、行尾 `//` 记号(参数、变量、枚举成员、类字段、接口属性,块注释不算)、JSDoc `@param`(类型、短横、可选、属性不算、export 函数、方法、箭头函数变量、类字段)、JavaScript 默认值参数;预筛和各方言的文件。 + - 记号不是说明(`nameResolution.test.ts`,走 `readBlockViews`):Python、TypeScript 枚举成员的行尾记号去掉、前面的字照留、只有记号的没有说明列。 + - 落到哪些名字上(假的语言扩展):画出来的名字按下标认、本文件的定义处标 definition、长度对不上 / 没画出来的不换;别的文件的只问原名在索引里的、定义处要对上、本文件的索引项不算;语言扩展没答上算没查完;先交本文件的再交全部。工作区中文名索引(内存里的工作区):按原名查出位置和中文名、没写中文名的文件照常读但不认、`lookup` 照旧只给位置、一种 languageId 一份。 + - 跟着版本走:每个版本查一次、手上有挪过来的就不先交一半、查到一半文档又改了丢掉、跟着改动挪位置(前面的挪、碰到的丢、后面的不动)、没答上照间隔再查到次数为止。 + - 页面(`webview-ui/.../displayNames.test.tsx`,渲染成 HTML):只收这个版本的、下标和长度都对上才换、用到的地方写中文名和「原名 x」悬停提示、下标照旧、定义处后面淡淡的原名、框里也换、单击时筛选栏写中文名且当前那一处按下标认。 + - 真 VS Code(Pylance):routes.py 的条件读成「如果 当前用户.role 不等于 UserRole.管理员」(管理员来自 models.py),参数表「当前用户 current_user」,docstring 兜底的「要删的那一项的编号」,单击高亮、⌘↓ 跳、⌘ 点击「管理员」跳进 models.py 的预览(「管理员 ADMIN」,没有说明列),改文档时中文名一帧都没少。 - 注册表:注册 / 注销后 `languageIds` 与可选翻译正确;同一语言多个翻译时按 `globalState` 选中;库规则按语言过滤、保持注册顺序,注销后不再给。 - 库规则(宿主):导入了库(`from sqlalchemy.orm import …`、`import sqlalchemy as sa`、`import os, sqlalchemy`、函数里的导入)才启用,`sqlalchemy_utils`、相对导入 `.sqlalchemy` 不算;先注册的规则优先;规则抛错、替身不合约定时记下来、接着问下一个、都没认照常显示,document 消息不带 error;编辑范围是整个类、没并进来的子块(夹在中间的注释、两个字段中间的方法)照源码顺序留下,卡片「编辑」写回整个类,能编辑的范围只在块和它的子孙之间重叠;筛选的种类计数、名字取规则的标签;`rules/` 下的非测试文件只 import contract。 - 同一行不在两处能改(webview `editing.test.ts`):卡片编辑时盖住它留下的子块(字段中间的方法连同主体),跳到被盖住的块改闪卡片;只编辑头部的块不盖住子块,一行写完的块盖住同一行的子块;点「编辑」时重叠的块退出编辑;任意点法下正在编辑、画出来的块两两不重叠。 diff --git a/vscode/src/codeReader/attributes.test.ts b/vscode/src/codeReader/attributes.test.ts index 06480d9..db14dc0 100644 --- a/vscode/src/codeReader/attributes.test.ts +++ b/vscode/src/codeReader/attributes.test.ts @@ -13,7 +13,7 @@ const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) function views(adapter: LanguageAdapter, languageId: string, source: string, translator?: Translator): Promise { - return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) + return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) } /** 每块的筛选属性,按 header 起始行(1 起)索引;同一行有几块取外层那块。 */ diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 85e001e..f7aa571 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -13,7 +13,7 @@ const ts = createTypeScriptAdapter(WASM_DIR) const py = createPythonAdapter(WASM_DIR) function views(adapter: LanguageAdapter, languageId: string, translator: Translator | undefined, source: string): Promise { - return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) + return withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) } function flatten(list: BlockView[]): BlockView[] { @@ -245,7 +245,7 @@ describe('blockView', () => { }) it('readBlockViews:解析、转视图、释放语法树', async () => { - const list = await readBlockViews(runtime, py, pythonZhTranslator, 'python', 'def f(x: int) -> bool:\n return not x\n') + const { blocks: list } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', 'def f(x: int) -> bool:\n return not x\n') expect(list).toHaveLength(1) expect('properties' in list[0] && propertyLines(list[0].properties)).toEqual([ '名称: f', diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 6fefbe6..558b868 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -1,4 +1,4 @@ -import type { BlockAttributes, CodeBlock, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' +import type { BlockAttributes, CodeBlock, DisplayName, KindBadge, LanguageAdapter, LibraryRule, Markdown, Node, PreviewText, PropertyRow, RuleContext, Segment, SourceDocument, SourceRange, TextBlock, TokenRole, Translator } from './contract' import type { TreeSitterRuntime } from './runtime' import { blockAttributes } from './attributes' import { commonIndent, splitLines } from './editText' @@ -129,10 +129,10 @@ const NO_RULES: RuleRun = { rules: [], report: () => {} } * 每块先问启用的库规则,认领了就换成规则给的替身(见 RuleBlock):规则的卡片,或认成的属性表块(交给翻译插件排,同语言插件报的这种块), * 并进去的子块不再单独成块。 */ -export function toBlockViews(blocks: CodeBlock[], source: string, adapter: LanguageAdapter, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] { +export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, translator: Translator | undefined, rules: RuleRun = NO_RULES): BlockView[] { + const { source, language: adapter } = document const lines = lineRanges(source) const texts = splitLines(source) - const document: SourceDocument = { source, language: adapter } const imports = importsIn(blocks) const active = activeRules(rules.rules, imports) const context: RuleContext = { @@ -203,7 +203,13 @@ export function toBlockViews(blocks: CodeBlock[], source: string, adapter: Langu return blocks.map((block, i) => build(block, String(i), '')) } -/** 解析一份源码并转成视图数据;语法树用完即释放。 */ +/** 一份源码的视图数据,和它里面起了中文名的定义(预览找用到它们的各处要用,见 displayNames.ts)。 */ +export interface DocumentViews { + blocks: BlockView[] + displayNames: DisplayName[] +} + +/** 解析一份源码并转成视图数据(中文名记号交给翻译插件去掉);语法树用完即释放。 */ export async function readBlockViews( runtime: Pick, adapter: LanguageAdapter, @@ -211,10 +217,12 @@ export async function readBlockViews( languageId: string, source: string, rules: RuleRun = NO_RULES, -): Promise { +): Promise { const tree = await runtime.parse(adapter, languageId, source) try { - return toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), source, adapter, translator, rules) + const { names, markers } = adapter.displayNames?.find(tree.rootNode, source, languageId) ?? { names: [], markers: [] } + const blocks = toBlockViews(adapter.toBlocks(tree.rootNode, source, languageId), { source, language: adapter, markers }, translator, rules) + return { blocks, displayNames: names } } finally { tree.delete() diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 564f1d2..eea637b 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -312,12 +312,43 @@ export interface LanguageAdapter { tokenRole: (leaf: Node) => TokenRole /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined + /** 认代码里给定义起的中文名(见「中文名」);不填就没有中文名。 */ + displayNames?: DisplayNameReader +} + +// ── 中文名(见「中文名」)── + +/** 代码里给一个定义起的中文名:预览里这个定义和用到它的各处都显示中文名,悬停看原名。 */ +export interface DisplayName { + /** 定义处那个名字(函数名、类名、参数名、变量名、字段名、枚举成员名……) */ + name: SourceRange + /** 中文名 */ + text: string +} + +/** 一份源码里认出的中文名。 */ +export interface DisplayNameFacts { + /** 起了中文名的定义,照源码顺序;一个定义几处都写了时语言插件定取哪个(明写的记号优先于说明文字) */ + names: DisplayName[] + /** 写在注释里给代码阅读看的记号(`# @Comment 当前用户` 里的 `@Comment 当前用户`):宿主交给翻译插件,翻注释、说明时去掉 */ + markers: SourceRange[] +} + +/** 语言插件认中文名:写法(记号、说明文字里的哪一句)归语言插件;找用到它的各处、显示归宿主和预览。 */ +export interface DisplayNameReader { + /** languageId → 工作区里这种语言的文件(glob,Python 是所有 .py 文件):宿主建工作区索引,认出别的文件里定义的中文名 */ + files: Record + /** 便宜的文字预筛:可能写了中文名的源码才解析 */ + mentions: (source: string) => boolean + find: (root: Node, source: string, languageId: string) => DisplayNameFacts } /** 翻译插件翻一块时拿到的这份源码:源码字符串,和解析它的语言插件。 */ export interface SourceDocument { source: string language: LanguageAdapter + /** 写在注释里给代码阅读看的记号(中文名记号,见 DisplayNameFacts.markers):不是说明文字,翻注释、说明时去掉 */ + markers: SourceRange[] } /** 筛选属性和值的显示名,筛选栏、种类标签用它;没给名字的显示 id。 */ diff --git a/vscode/src/codeReader/editText.test.ts b/vscode/src/codeReader/editText.test.ts index 51d06ef..4ec742a 100644 --- a/vscode/src/codeReader/editText.test.ts +++ b/vscode/src/codeReader/editText.test.ts @@ -43,7 +43,7 @@ describe('行替换', () => { [createPythonAdapter(WASM_DIR), 'python', 'classes.py'], ] as const)('%#: 把预览里的 code 原样写回,文件不变(去缩进与补缩进互逆,函数、类头部连着说明也一样)', async (adapter, languageId, fixture) => { const source = readFixture(fixture) - const views = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, undefined)) + const views = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, undefined)) const all = views.flatMap(function flat(view): typeof views { return [view, ...view.children.flatMap(flat)] }) diff --git a/vscode/src/codeReader/editor/definitionSources.test.ts b/vscode/src/codeReader/editor/definitionSources.test.ts index 6f9e4ec..fa8121a 100644 --- a/vscode/src/codeReader/editor/definitionSources.test.ts +++ b/vscode/src/codeReader/editor/definitionSources.test.ts @@ -12,7 +12,7 @@ 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: () => {} })) + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [celeryRule], report: () => {} })) } /** 记下问了哪边:规则(引用的键)还是语言扩展(下标)。 */ diff --git a/vscode/src/codeReader/editor/documentMessage.test.ts b/vscode/src/codeReader/editor/documentMessage.test.ts index e307a53..fd029f4 100644 --- a/vscode/src/codeReader/editor/documentMessage.test.ts +++ b/vscode/src/codeReader/editor/documentMessage.test.ts @@ -49,7 +49,7 @@ function doc(languageId: string, text: string, version = 3): { languageId: strin describe('预览的 document 消息', () => { it('受支持的语言:带文档版本、代码块、可选翻译和选中的翻译', async () => { - const message = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime, ignore) + const { message } = await documentMessage(doc('typescript', 'if (!folder)\n return []\n'), registryWithTypeScript(), runtime, ignore) expect(message).toMatchObject({ type: 'document', version: 3, @@ -66,7 +66,7 @@ describe('预览的 document 消息', () => { }) it('没有语言插件的语言:带 error,不解析', async () => { - const message = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime, ignore) + const { message } = await documentMessage(doc('rust', 'fn main() {}'), registryWithTypeScript(), runtime, ignore) expect(message.blocks).toEqual([]) expect(message.attributes).toEqual([]) expect(message.translators).toEqual([]) @@ -74,7 +74,7 @@ describe('预览的 document 消息', () => { }) it('库规则认领的块:种类是规则的种类 id,筛选栏里叫规则给的名字', async () => { - const message = await documentMessage(doc('python', MODEL), registryWithPython(sqlalchemyRule), runtime, ignore) + const { message } = await documentMessage(doc('python', MODEL), registryWithPython(sqlalchemyRule), runtime, ignore) expect(message.blocks.map(b => b.kind)).toEqual(['import', 'sqlalchemy.table']) expect(message.attributes.find(attribute => attribute.id === 'kind')?.values).toEqual([ { id: 'import', name: '导入', count: 1 }, @@ -93,7 +93,7 @@ describe('预览的 document 消息', () => { }, } const reports: string[] = [] - const message = await documentMessage(doc('python', MODEL), registryWithPython(broken), runtime, text => reports.push(text)) + const { message } = await documentMessage(doc('python', MODEL), registryWithPython(broken), runtime, text => reports.push(text)) expect(message.error).toBeUndefined() expect(message.blocks.map(b => b.kind)).toEqual(['import', 'class']) expect(reports).toEqual([1, 3, 4, 5].map((line, i) => @@ -102,7 +102,7 @@ describe('预览的 document 消息', () => { it('解析失败:把原因放进 error', async () => { const failing = { parse: async () => Promise.reject(new Error('语法包加载失败')) } - const message = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore) + const { message } = await documentMessage(doc('typescript', 'const a = 1'), registryWithTypeScript(), failing, ignore) expect(message.blocks).toEqual([]) expect(message.error).toBe('语法包加载失败') }) diff --git a/vscode/src/codeReader/editor/documentMessage.ts b/vscode/src/codeReader/editor/documentMessage.ts index 986413a..279def0 100644 --- a/vscode/src/codeReader/editor/documentMessage.ts +++ b/vscode/src/codeReader/editor/documentMessage.ts @@ -1,3 +1,4 @@ +import type { DisplayName } from '../contract' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' import type { DocumentMessage } from './messages' @@ -11,13 +12,19 @@ export interface DocumentSnapshot { getText: () => string } +/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)。 */ +export interface DocumentRead { + message: DocumentMessage + displayNames: DisplayName[] +} + /** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */ export async function documentMessage( document: DocumentSnapshot, registry: Pick, runtime: Pick, report: (message: string) => void, -): Promise { +): Promise { const { languageId, version } = document const source = document.getText() const translator = registry.selectedTranslator(languageId) @@ -29,15 +36,16 @@ export async function documentMessage( attributes: [], translators: registry.translatorsFor(languageId).map(({ id, label }) => ({ id, label })), translatorId: translator?.id ?? '', + names: [], } const adapter = registry.languageFor(languageId) if (!adapter) - return { ...message, error: `代码阅读还不支持「${languageId}」这种语言` } + return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] } try { - const blocks = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report }) - return { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) } + const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report }) + return { message: { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }, displayNames } } catch (err) { - return { ...message, error: err instanceof Error ? err.message : String(err) } + return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [] } } } diff --git a/vscode/src/codeReader/editor/messages.ts b/vscode/src/codeReader/editor/messages.ts index 2b2d71b..1b3aefb 100644 --- a/vscode/src/codeReader/editor/messages.ts +++ b/vscode/src/codeReader/editor/messages.ts @@ -5,11 +5,12 @@ import type { ClaudeImage } from '../../cc/spawnClaude' import type { FileAttribute } from '../attributes' import type { BlockView } from '../blockView' +import type { ShownName } from '../nameResolution' import type { ProfileChoice } from '../qa/askProfiles' import type { RunningAsk } from '../qa/asks' import type { Excluded } from './blockFilter' -export type { Excluded } +export type { Excluded, ShownName } export interface TranslatorOption { id: string, label: string } @@ -45,6 +46,8 @@ export interface DocumentMessage { error?: string /** 新预览只在第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */ reveal?: Reveal + /** 这个版本眼下已知的中文名:查过的,或上一版查到的跟着改动挪了位置的;查完再发 displayNames 换掉 */ + names: ShownName[] } /** 问过的一次(给预览看的):问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的。 */ @@ -86,6 +89,7 @@ export interface QaMessage { * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停 * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览 + * displayNames:version 这份文档里显示成中文名的名字,查到一步发一步(先本文件定义的,再别的文件定义的);不是页面上这个版本的不理 */ export type ExtensionToWebview = | DocumentMessage @@ -93,6 +97,7 @@ export type ExtensionToWebview | { type: 'definitionProbed', id: number, found: boolean } | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'moveOccurrence', step: 1 | -1 } + | { type: 'displayNames', version: number, names: ShownName[] } | { type: 'filter', excluded: Excluded } | QaMessage | ({ type: 'askProfiles' } & ProfileChoice) diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 2907f5c..26c6fdb 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -2,6 +2,7 @@ import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentSh import type { ClaudeImage } from '../../cc/spawnClaude' import type { BlockView } from '../blockView' import type { RuleLocation, RuleReference } from '../contract' +import type { IndexedName, NameSources } from '../nameResolution' import type { CodeQa } from '../qa/codeQa' import type { CodeReaderRegistry } from '../registry' import type { TreeSitterRuntime } from '../runtime' @@ -13,6 +14,7 @@ import { logger } from '../../logging/logger' import { webviewHtml, webviewUiRoot } from '../../webview/html' import { blockReplacement } from '../editText' import { locate } from '../folderStore' +import { DocumentNames, resolveNames } from '../nameResolution' import { askPrompt, followUpPrompt } from '../qa/prompt' import { definitionAction } from './definitionAction' import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions' @@ -46,6 +48,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider { readonly resolveReference: (reference: RuleReference) => Promise, /** 问 AI:各预览共用,同一份文件开在几处时问答、正在答的问题都一样 */ readonly qa: CodeQa, + /** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */ + readonly indexedNames: (languageId: string, name: string) => Promise, ) {} resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void { @@ -118,6 +122,8 @@ class Preview { visibleLine: number | undefined /** 最近一次发出去的块树和它的源码:问 AI 时按块的记号在里面找那块代码 */ private sent: { blocks: BlockView[], source: string } | undefined + /** 这份文档各版本显示成中文名的名字 */ + private readonly names: DocumentNames constructor( private readonly document: TextDocument, @@ -136,6 +142,10 @@ class Preview { rule: async reference => ruleDefinitions(await owner.resolveReference(reference)), language: offset => languageDefinitions(document.uri, document.positionAt(offset)), }) + this.names = new DocumentNames((version, names) => { + if (this.ready && version === document.version) + this.post({ type: 'displayNames', version, names }) + }) this.disposables = [ panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)), panel.onDidChangeViewState(() => { @@ -146,8 +156,10 @@ class Preview { owner.navigation.update(this, false) }), workspace.onDidChangeTextDocument((e) => { - if (e.document === document && e.contentChanges.length > 0) - this.schedulePush() + if (e.document !== document || e.contentChanges.length === 0) + return + this.names.edited(document.version, e.contentChanges.map(change => ({ offset: change.rangeOffset, length: change.rangeLength, text: change.text }))) + this.schedulePush() }), // 页面没 ready 时收不到,等它 ready 再随第一份文档之前补发 filter.subscribe((excluded) => { @@ -202,7 +214,7 @@ class Preview { const request = ++this.latest const { languageId, version } = this.document const source = this.document.getText() - const message = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { + const { message, displayNames } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => { logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` }) }) if (request !== this.latest || !this.ready) @@ -211,7 +223,22 @@ class Preview { this.sent = { blocks: message.blocks, source } const reveal = this.pendingReveal this.pendingReveal = undefined - this.post(reveal === undefined ? message : { ...message, reveal }) + this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) }) + // 文档在解析期间又改了的,等下一次发文档再查 + if (version === this.document.version) + this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report)) + } + + /** 找中文名落在哪些名字上时问的:这份文档的语言扩展、工作区里的中文名索引。 */ + private nameSources(): NameSources { + const { document } = this + return { + uri: document.uri.toString(), + occurrences: async offset => (await occurrencesOf(document, offset)).map(({ start, end }) => ({ start, end })), + definitions: async offset => (await languageDefinitions(document.uri, document.positionAt(offset))) + .map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character })), + indexed: name => this.owner.indexedNames(document.languageId, name), + } } /** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */ @@ -230,6 +257,7 @@ class Preview { dispose(): void { clearTimeout(this.timer) + this.names.dispose() for (const disposable of this.disposables) disposable.dispose() } diff --git a/vscode/src/codeReader/index.ts b/vscode/src/codeReader/index.ts index 24b9d10..7a51dc0 100644 --- a/vscode/src/codeReader/index.ts +++ b/vscode/src/codeReader/index.ts @@ -1,5 +1,6 @@ import type { ExtensionContext, Uri } from 'vscode' import type { CodeReaderApi, RuleLocation, RuleReference } from './contract' +import type { IndexedName } from './nameResolution' import { commands, window, workspace } from 'vscode' import { logger } from '../logging/logger' import { BlockFilter } from './editor/blockFilter' @@ -11,6 +12,7 @@ import { FileTreeProvider } from './explorer/fileTree' import { createPythonAdapter } from './languages/python' import { createTypeScriptAdapter } from './languages/typescript' import { resolveReference } from './libraryRules' +import { indexedNames } from './nameResolution' import { registerNoteCommands } from './notes/commands' import { FileNotes } from './notes/fileNotes' import { CodeQa } from './qa/codeQa' @@ -66,7 +68,11 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi { }, report)) const resolve = (reference: RuleReference): Promise => resolveReference(reference, registry.libraryRules(), indexes, report) const qa = new CodeQa(context.globalState) - const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa) + const namesIn = async (languageId: string, name: string): Promise => { + const adapter = registry.languageFor(languageId) + return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : [] + } + const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn) const notes = new FileNotes() const files = new FileTreeProvider(OPEN_COMMAND, notes) diff --git a/vscode/src/codeReader/languages/displayNames.ts b/vscode/src/codeReader/languages/displayNames.ts new file mode 100644 index 0000000..98223a8 --- /dev/null +++ b/vscode/src/codeReader/languages/displayNames.ts @@ -0,0 +1,74 @@ +import type { DisplayName, DisplayNameFacts, Node, SourceRange } from '../contract' + +/** 给定义起中文名的那个调用 / 装饰器 / 注释记号的名字:`Comment("当前用户")`、`@Comment("…")`、`# @Comment 当前用户`。 */ +export const COMMENT_MARK = 'Comment' + +/** 说明文字里的一句话最多几个字才当中文名 */ +export const CLAUSE_LIMIT = 12 + +/** 说明文字的第一小句(到第一个 ,。,.;;:: 为止)去掉首尾空白;空的、超过 CLAUSE_LIMIT 个字的不当中文名。 */ +export function firstClause(text: string): string | undefined { + const clause = text.split(/[,。,.;;::]/, 1)[0].trim() + return clause && [...clause].length <= CLAUSE_LIMIT ? clause : undefined +} + +/** 行注释里的中文名记号 `@Comment 名字`(名字一直到注释结尾):名字,和记号从注释文字的第几个字开始。 */ +export function commentTag(text: string): { text: string, start: number } | undefined { + const match = new RegExp(`@${COMMENT_MARK}[ \\t]+(\\S.*)$`).exec(text) + return match ? { text: match[1].trimEnd(), start: match.index } : undefined +} + +/** 一个定义和语言插件从代码里读到的中文名。 */ +export interface NameCandidate { + /** 定义处那个名字 */ + name: Node + /** 写在代码里的记号给的:`Annotated[T, Comment("…")]`、`@Comment("…")` */ + marked?: string + /** 说明文字里取的:docstring 的参数说明、JSDoc 的 @param */ + described?: string + /** 行尾的 `@Comment` 记号能给它:参数、变量、字段、枚举成员、属性(函数、类这些用装饰器) */ + trailing?: boolean +} + +function rangeOf(node: Node): SourceRange { + return { startIndex: node.startIndex, endIndex: node.endIndex } +} + +/** 注释前面同一行有代码:是写在行尾的注释。 */ +function isTrailing(comment: Node, source: string): boolean { + const lineStart = source.lastIndexOf('\n', comment.startIndex - 1) + 1 + return source.slice(lineStart, comment.startIndex).trim() !== '' +} + +/** + * 汇总一份源码里的中文名。行尾注释里的记号(`x = 1 # @Comment 数量`)给那一行第一个能带行尾记号的定义(多行的参数表里就是那一行的参数), + * 记号都算 markers(翻注释、说明时去掉),不管这个定义最后取没取它。 + * 每个定义取哪个:写在代码里的记号,其次行尾记号,最后才是说明文字。 + */ +export function displayNameFacts(candidates: readonly NameCandidate[], lineComments: readonly Node[], source: string): DisplayNameFacts { + const byStart = new Map() + for (const candidate of candidates) { + const known = byStart.get(candidate.name.startIndex) + byStart.set(candidate.name.startIndex, { + name: candidate.name, + marked: known?.marked ?? candidate.marked, + described: known?.described ?? candidate.described, + trailing: known?.trailing || candidate.trailing, + }) + } + const ordered = [...byStart.values()].sort((a, b) => a.name.startIndex - b.name.startIndex) + const markers: SourceRange[] = [] + for (const comment of lineComments) { + const tag = isTrailing(comment, source) ? commentTag(comment.text) : undefined + const owner = tag && ordered.find(candidate => candidate.trailing && candidate.name.startPosition.row === comment.startPosition.row) + if (!tag || !owner) + continue + owner.tagged ??= tag.text + markers.push({ startIndex: comment.startIndex + tag.start, endIndex: comment.endIndex }) + } + const names = ordered.flatMap((candidate): DisplayName[] => { + const text = candidate.marked ?? candidate.tagged ?? candidate.described + return text ? [{ name: rangeOf(candidate.name), text }] : [] + }) + return { names, markers } +} diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index d3684ac..b5c786b 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -1,6 +1,7 @@ import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract' import type { CommentRules } from './shared' import * as path from 'node:path' +import { pythonNameReader } from './pythonNames' import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared' /** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */ @@ -486,5 +487,6 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { toBlocks: root => statements(named(root), 'module', -1), tokenRole: pythonTokenRole, iteration: pythonIteration, + displayNames: pythonNameReader, } } diff --git a/vscode/src/codeReader/languages/pythonNames.test.ts b/vscode/src/codeReader/languages/pythonNames.test.ts new file mode 100644 index 0000000..6831b5b --- /dev/null +++ b/vscode/src/codeReader/languages/pythonNames.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { runtime, WASM_DIR } from '../testSupport' +import { createPythonAdapter } from './python' +import { docstringParameters } from './pythonNames' + +const py = createPythonAdapter(WASM_DIR) + +/** 认出的中文名写成「原名 → 中文名」(原名前面加行号,1 起),记号写成源码原文。 */ +async function names(source: string): Promise<{ names: string[], markers: string[] }> { + const tree = await runtime.parse(py, 'python', source) + try { + const facts = py.displayNames!.find(tree.rootNode, source, 'python') + const row = (index: number): number => source.slice(0, index).split('\n').length + return { + names: facts.names.map(({ name, text }) => `${row(name.startIndex)}:${source.slice(name.startIndex, name.endIndex)} → ${text}`), + markers: facts.markers.map(({ startIndex, endIndex }) => source.slice(startIndex, endIndex)), + } + } + finally { + tree.delete() + } +} + +describe('python 中文名:写在代码里的记号', () => { + it('注解 Annotated[T, Comment("…")]:参数、变量、类字段;typing.Annotated、t.Comment 也算,调用的名字最后一节是 Comment 就算', async () => { + const source = [ + 'def get(current_user: Annotated[User, Depends(auth), Comment("当前用户")], db: typing.Annotated[Session, t.Comment("数据库")] = None):', + ' count: Annotated[int, Comment("数量")] = 0', + 'class Order:', + ' total: Annotated[Decimal, Comment("总价")]', + ' note: Annotated[str, Other("不是")]', + ' tag: Annotated[str, Comment(f"{x}")]', + ].join('\n') + expect((await names(source)).names).toEqual(['1:current_user → 当前用户', '1:db → 数据库', '2:count → 数量', '4:total → 总价']) + }) + + it('@Comment("…") 装饰器:函数、类', async () => { + const source = '@Comment("取用户")\n@router.get("/")\nasync def get_user():\n pass\n\n@Comment("订单")\nclass Order:\n pass\n\n@other("x")\ndef plain():\n pass\n' + expect((await names(source)).names).toEqual(['3:get_user → 取用户', '7:Order → 订单']) + }) + + it('行尾的 # @Comment 名字:多行参数表里的参数、变量、字段、枚举成员、self.x;记号从 @Comment 到行尾', async () => { + const source = [ + 'async def delete_item(', + ' item_id: int,', + ' current_user: User = Depends(get_current_user), # @Comment 当前用户', + ' *args, # @Comment 其余参数', + '):', + ' self.owner = current_user # 持有人 @Comment 拥有者', + 'LIMIT = 10 # @Comment 上限', + 'class Role(str, Enum):', + ' ADMIN = "admin" # @Comment 管理员', + ' name: str # @Comment 名称 ', + ].join('\n') + const found = await names(source) + expect(found.names).toEqual(['3:current_user → 当前用户', '4:args → 其余参数', '6:owner → 拥有者', '7:LIMIT → 上限', '9:ADMIN → 管理员', '10:name → 名称']) + expect(found.markers).toEqual(['@Comment 当前用户', '@Comment 其余参数', '@Comment 拥有者', '@Comment 上限', '@Comment 管理员', '@Comment 名称 ']) + }) + + it('单独成行的注释、行尾没有名字的记号、函数名那一行的记号不算(函数、类用装饰器);那一行第一个能带记号的定义拿到它', async () => { + const source = [ + '# @Comment 单独一行', + 'x = 1', + 'print(x) # @Comment 没有定义', + 'def f(a, b): # @Comment 甲', + ' pass', + ].join('\n') + const found = await names(source) + expect(found.names).toEqual(['4:a → 甲']) + expect(found.markers).toEqual(['@Comment 甲']) + }) +}) + +describe('python 中文名:函数 docstring 里的参数说明(没有明写的记号时)', () => { + it('谷歌写法 Args: 一节、Sphinx 写法 :param:取第一小句,不超过 12 个字才算', async () => { + const source = [ + 'def create(user_id, amount, note, *items, **extra):', + ' """建订单。', + '', + ' Args:', + ' user_id (int): 下单用户的编号,从 token 里取', + ' amount: 金额。单位是分', + ' note: 这是一段特别特别特别长的备注说明文字', + ' 续行不算一项', + ' *items: 商品; 可以有多个', + '', + ' Returns:', + ' extra: 不在 Args 里', + ' """', + '', + 'def update(order_id, status):', + ' """:param order_id: 订单编号,必填', + ' :param str status: 新状态', + ' """', + ].join('\n') + expect((await names(source)).names).toEqual([ + '1:user_id → 下单用户的编号', + '1:amount → 金额', + '1:items → 商品', + '15:order_id → 订单编号', + '15:status → 新状态', + ]) + }) + + it('明写的记号优先:Annotated / 行尾记号盖过 docstring;同一个参数两种记号都写了取 Annotated,行尾那个照样算记号(显示说明时去掉)', async () => { + const source = [ + 'def get(', + ' a: Annotated[int, Comment("甲")], # @Comment 乙', + ' b: int, # @Comment 丙', + ' c: int,', + '):', + ' """Args:', + ' a: 丁', + ' b: 戊', + ' c: 己', + ' """', + ].join('\n') + const found = await names(source) + expect(found.names).toEqual(['2:a → 甲', '3:b → 丙', '4:c → 己']) + expect(found.markers).toEqual(['@Comment 乙', '@Comment 丙']) + }) + + it('docstringParameters:名字去掉星号,第一个说明为准,段落结束就不再认', () => { + expect([...docstringParameters('Args:\n x: 甲\n x: 乙\nNotes:\n y: 丙\n')]).toEqual([['x', '甲']]) + }) +}) + +describe('python 中文名:预筛和工作区里的文件', () => { + it('写了 Comment、Args:、:param 的源码才解析;文件是所有 .py', () => { + const reader = py.displayNames! + expect(reader.files).toEqual({ python: '**/*.py' }) + expect(reader.mentions('x = 1 # @Comment 数量')).toBe(true) + expect(reader.mentions('"""\nArgs:\n x: 甲\n"""')).toBe(true) + expect(reader.mentions(':param x: 甲')).toBe(true) + expect(reader.mentions('x = 1\n')).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/languages/pythonNames.ts b/vscode/src/codeReader/languages/pythonNames.ts new file mode 100644 index 0000000..b47da84 --- /dev/null +++ b/vscode/src/codeReader/languages/pythonNames.ts @@ -0,0 +1,189 @@ +import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract' +import type { NameCandidate } from './displayNames' +import { COMMENT_MARK, displayNameFacts, firstClause } from './displayNames' +import { children, named } from './shared' + +/** 名字的最后一节:`Comment`、`typing.Annotated` 的 `Annotated`。 */ +function lastName(node: Node | null | undefined): string | undefined { + if (node?.type === 'identifier') + return node.text + return node?.type === 'attribute' ? node.childForFieldName('attribute')?.text : undefined +} + +/** 普通字符串字面量的内容(不带引号、前缀);f-string 这类带插值的没有。 */ +function plainString(node: Node | undefined): string | undefined { + if (node?.type !== 'string' || named(node).some(part => part.type === 'interpolation')) + return undefined + const start = named(node).find(part => part.type === 'string_start') + const end = named(node).find(part => part.type === 'string_end') + const text = start && end ? node.text.slice(start.endIndex - node.startIndex, end.startIndex - node.startIndex).trim() : '' + return text || undefined +} + +/** `Comment("当前用户")`(调用的名字最后一节是 Comment,不管从哪导入、是不是本地定义的)的第一个参数。 */ +function commentCall(node: Node | undefined): string | undefined { + if (node?.type !== 'call' || lastName(node.childForFieldName('function')) !== COMMENT_MARK) + return undefined + return plainString(named(node.childForFieldName('arguments'))[0]) +} + +/** 注解 `Annotated[T, …, Comment("…")]`(`typing.Annotated` 也算)里的中文名。 */ +function annotatedName(annotation: Node | null): string | undefined { + const inner = named(annotation)[0] + let head: Node | undefined + let items: Node[] = [] + if (inner?.type === 'generic_type') { + head = named(inner)[0] + items = named(named(inner).find(part => part.type === 'type_parameter')).map(item => item.type === 'type' ? named(item)[0] : item) + } + else if (inner?.type === 'subscript') { + head = inner.childForFieldName('value') ?? undefined + items = inner.childrenForFieldName('subscript').filter((item): item is Node => item !== null) + } + if (lastName(head) !== 'Annotated') + return undefined + return items.slice(1).map(commentCall).find(text => text !== undefined) +} + +/** 装饰器 `@Comment("…")` 给的中文名。 */ +function decoratorName(definition: Node): string | undefined { + const outer = definition.parent?.type === 'decorated_definition' ? definition.parent : undefined + return named(outer).filter(child => child.type === 'decorator').map(decorator => commentCall(named(decorator)[0])).find(text => text !== undefined) +} + +/** 参数节点里定义的那个名字:`x`、`x: int`、`x=1`、`*args`、`**kw`;`*`、`/` 分隔符没有。 */ +function parameterName(param: Node): Node | undefined { + switch (param.type) { + case 'identifier': + return param + case 'typed_parameter': + return parameterName(named(param)[0]) + case 'default_parameter': + case 'typed_default_parameter': + return param.childForFieldName('name') ?? undefined + case 'list_splat_pattern': + case 'dictionary_splat_pattern': + return named(param).find(child => child.type === 'identifier') + default: + return undefined + } +} + +/** + * docstring 里各参数的说明取第一小句:Google 写法 `Args:` 一节里的 `name (type): 说明`,Sphinx 写法 `:param name: 说明` + * (`:param type name:` 也算)。名字去掉 `*` / `**`。 + */ +export function docstringParameters(doc: string): Map { + const found = new Map() + const put = (name: string, text: string): void => { + const clause = firstClause(text) + if (clause && !found.has(name)) + found.set(name, clause) + } + const lines = doc.split('\n') + const indentOf = (line: string): number => line.length - line.trimStart().length + for (let i = 0; i < lines.length; i++) { + const sphinx = /^\s*:param\s+(?:[^\s:]+\s+)?\*{0,2}(\w+)\s*:(.*)$/.exec(lines[i]) + if (sphinx) { + put(sphinx[1], sphinx[2]) + continue + } + if (!/^\s*(?:Args|Arguments):\s*$/.test(lines[i])) + continue + const sectionIndent = indentOf(lines[i]) + let entryIndent: number | undefined + for (i++; i < lines.length; i++) { + const line = lines[i] + if (line.trim() === '') + continue + const indent = indentOf(line) + if (indent <= sectionIndent) { + i-- + break + } + entryIndent ??= indent + const entry = indent === entryIndent ? /^\s*\*{0,2}(\w+)(?:\s*\([^)]*\))?\s*:(.*)$/.exec(line) : null + if (entry) + put(entry[1], entry[2]) + } + } + return found +} + +/** 函数主体开头的 docstring 的原文。 */ +function docstringOf(fn: Node): string | undefined { + const first = named(fn.childForFieldName('body')).find(child => child.type !== 'comment') + const [expression, ...rest] = first?.type === 'expression_statement' ? named(first) : [] + return rest.length === 0 ? plainString(expression) : undefined +} + +function functionCandidates(fn: Node): NameCandidate[] { + const name = fn.childForFieldName('name') + const doc = docstringOf(fn) + const described = doc === undefined ? new Map() : docstringParameters(doc) + const params = named(fn.childForFieldName('parameters')).flatMap((param): NameCandidate[] => { + const paramName = parameterName(param) + if (!paramName) + return [] + return [{ name: paramName, marked: annotatedName(param.childForFieldName('type')), described: described.get(paramName.text), trailing: true }] + }) + return [...(name ? [{ name, marked: decoratorName(fn) }] : []), ...params] +} + +/** 赋值里定义的名字:`x = …`、`x: T = …`、只写注解的 `x: T`、`self.x = …` 的 x、解构的各个名字。 */ +function targetNames(target: Node | null): Node[] { + if (!target) + return [] + if (target.type === 'identifier') + return [target] + if (target.type === 'attribute') { + const attribute = target.childForFieldName('attribute') + return attribute ? [attribute] : [] + } + if (['pattern_list', 'tuple_pattern', 'list_pattern', 'list_splat_pattern'].includes(target.type)) + return named(target).flatMap(targetNames) + return [] +} + +function assignmentCandidates(assignment: Node): NameCandidate[] { + const marked = annotatedName(assignment.childForFieldName('type')) + return targetNames(assignment.childForFieldName('left')).map(name => ({ name, marked, trailing: true })) +} + +/** + * Python 的中文名:`Annotated[T, Comment("…")]`(参数、变量、类字段)、`@Comment("…")`(函数、类)、 + * 定义那一行行尾的 `# @Comment 名字`(多行参数表里的参数、变量、字段、枚举成员),都没有时参数取函数 docstring 里它的说明。 + */ +export function pythonDisplayNames(root: Node, source: string): DisplayNameFacts { + const candidates: NameCandidate[] = [] + const comments: Node[] = [] + const visit = (node: Node): void => { + switch (node.type) { + case 'comment': + comments.push(node) + return + case 'function_definition': + candidates.push(...functionCandidates(node)) + break + case 'class_definition': { + const name = node.childForFieldName('name') + if (name) + candidates.push({ name, marked: decoratorName(node) }) + break + } + case 'assignment': + candidates.push(...assignmentCandidates(node)) + break + } + for (const child of children(node)) + visit(child) + } + visit(root) + return displayNameFacts(candidates, comments, source) +} + +export const pythonNameReader: DisplayNameReader = { + files: { python: '**/*.py' }, + mentions: source => source.includes(COMMENT_MARK) || /\b(?:Args|Arguments):|:param\s/.test(source), + find: (root, source) => pythonDisplayNames(root, source), +} diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index b0ec8d8..a0c0cad 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -2,6 +2,7 @@ import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFa import type { CommentRules } from './shared' import * as path from 'node:path' import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared' +import { typescriptNameReader } from './typescriptNames' const GRAMMARS: Record = { typescript: 'tree-sitter-typescript.wasm', @@ -585,5 +586,6 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { }, toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root), tokenRole: typescriptTokenRole, + displayNames: typescriptNameReader, } } diff --git a/vscode/src/codeReader/languages/typescriptNames.test.ts b/vscode/src/codeReader/languages/typescriptNames.test.ts new file mode 100644 index 0000000..5a18c96 --- /dev/null +++ b/vscode/src/codeReader/languages/typescriptNames.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { runtime, WASM_DIR } from '../testSupport' +import { createTypeScriptAdapter } from './typescript' +import { jsdocParameters } from './typescriptNames' + +const ts = createTypeScriptAdapter(WASM_DIR) + +/** 认出的中文名写成「行号:原名 → 中文名」(行号 1 起),记号写成源码原文。 */ +async function names(source: string, languageId = 'typescript'): Promise<{ names: string[], markers: string[] }> { + const tree = await runtime.parse(ts, languageId, source) + try { + const facts = ts.displayNames!.find(tree.rootNode, source, languageId) + const row = (index: number): number => source.slice(0, index).split('\n').length + return { + names: facts.names.map(({ name, text }) => `${row(name.startIndex)}:${source.slice(name.startIndex, name.endIndex)} → ${text}`), + markers: facts.markers.map(({ startIndex, endIndex }) => source.slice(startIndex, endIndex)), + } + } + finally { + tree.delete() + } +} + +describe('typescript 中文名:@Comment 装饰器', () => { + it('类(@d export class 也算)、方法、字段、构造函数和方法的参数;调用的名字最后一节是 Comment 就算', async () => { + const source = [ + '@Comment(\'服务\')', + 'export class UserService {', + ' @Comment(\'仓库\') repo = new Repo()', + ' constructor(@Comment(\'配置\') private readonly config: Config, other: number) {}', + ' @decorators.Comment(`取用户`)', + ' @Get()', + ' find(@Comment("编号") id: string) {}', + ' @Other(\'不是\') plain() {}', + '}', + ].join('\n') + expect((await names(source)).names).toEqual(['2:UserService → 服务', '3:repo → 仓库', '4:config → 配置', '7:find → 取用户', '7:id → 编号']) + }) +}) + +describe('typescript 中文名:行尾的 // @Comment 名字', () => { + it('参数、变量、枚举成员、类字段、接口属性;块注释、单独成行的注释不算;函数那一行给那一行的参数(函数、类用装饰器)', async () => { + const source = [ + 'function remove(', + ' itemId: number, // @Comment 编号', + ' user?: User, // 当前登录的 @Comment 当前用户', + ') {}', + 'const total = items.map(item => item.price) // @Comment 总价', + 'enum Role {', + ' ADMIN = \'admin\', // @Comment 管理员', + ' USER, // @Comment 普通用户', + '}', + 'class Cart {', + ' count = 0 // @Comment 件数', + '}', + 'interface Page {', + ' size: number // @Comment 每页条数', + '}', + 'let a = 1 /* @Comment 块注释不算 */', + '// @Comment 单独一行', + 'let b = 2', + 'function f(x: number) { // @Comment 函数那一行', + '}', + ].join('\n') + const found = await names(source) + expect(found.names).toEqual([ + '2:itemId → 编号', + '3:user → 当前用户', + '5:total → 总价', + '7:ADMIN → 管理员', + '8:USER → 普通用户', + '11:count → 件数', + '14:size → 每页条数', + '19:x → 函数那一行', + ]) + expect(found.markers).toEqual(['@Comment 编号', '@Comment 当前用户', '@Comment 总价', '@Comment 管理员', '@Comment 普通用户', '@Comment 件数', '@Comment 每页条数', '@Comment 函数那一行']) + }) +}) + +describe('typescript 中文名:JSDoc 的 @param(没有明写的记号时)', () => { + it('export 的函数、方法(隔着装饰器)、值是箭头函数的变量、类字段;{类型}、短横、[可选] 都认,取第一小句、不超过 12 个字', async () => { + const source = [ + '/**', + ' * 删一项。', + ' * @param {number} itemId - 要删的编号,从路径取', + ' * @param [force=false] 强制删除', + ' * @param opts.x 属性不算', + ' * @param note 一段特别特别特别长的备注说明文字', + ' */', + 'export function remove(itemId: number, force = false, note = \'\') {}', + 'class Api {', + ' /** @param id 编号 */', + ' @Get()', + ' load(id: string) {}', + ' /** @param e 事件 */', + ' onClick = (e: Event) => {}', + '}', + '/** @param a 甲 */', + 'const add = (a: number) => a', + '/** @param a 丁 */', + 'const other = 1', + 'function g(a: number) {}', + ].join('\n') + expect((await names(source)).names).toEqual([ + '8:itemId → 要删的编号', + '8:force → 强制删除', + '12:id → 编号', + '14:e → 事件', + '17:a → 甲', + ]) + }) + + it('明写的记号优先:装饰器、行尾记号盖过 JSDoc;JavaScript 的默认值参数也认', async () => { + const source = [ + '/**', + ' * @param a 甲', + ' * @param b 乙', + ' */', + 'function f(', + ' a, // @Comment 丙', + ' b = 1,', + ') {}', + ].join('\n') + expect((await names(source, 'javascript')).names).toEqual(['6:a → 丙', '7:b → 乙']) + }) + + it('jsdocParameters:同名的取第一个', () => { + expect([...jsdocParameters('@param x 甲\n@param x 乙')]).toEqual([['x', '甲']]) + }) +}) + +describe('typescript 中文名:预筛和工作区里的文件', () => { + it('写了 Comment、@param 的源码才解析;四种方言各自的文件', () => { + const reader = ts.displayNames! + expect(reader.files).toEqual({ + typescript: '**/*.{ts,mts,cts}', + typescriptreact: '**/*.tsx', + javascript: '**/*.{js,mjs,cjs}', + javascriptreact: '**/*.jsx', + }) + expect(reader.mentions('const a = 1 // @Comment 甲')).toBe(true) + expect(reader.mentions('/** @param a 甲 */')).toBe(true) + expect(reader.mentions('const a = 1')).toBe(false) + }) +}) diff --git a/vscode/src/codeReader/languages/typescriptNames.ts b/vscode/src/codeReader/languages/typescriptNames.ts new file mode 100644 index 0000000..c66729e --- /dev/null +++ b/vscode/src/codeReader/languages/typescriptNames.ts @@ -0,0 +1,172 @@ +import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract' +import type { NameCandidate } from './displayNames' +import { COMMENT_MARK, displayNameFacts, firstClause } from './displayNames' +import { children, named } from './shared' + +const DECLARED_FUNCTIONS = new Set(['function_declaration', 'generator_function_declaration', 'function_signature']) +const METHODS = new Set(['method_definition', 'method_signature', 'abstract_method_signature']) +const FUNCTION_VALUES = new Set(['function_expression', 'function', 'generator_function', 'arrow_function']) +const CLASSES = new Set(['class_declaration', 'abstract_class_declaration', 'class']) +const DECLARATIONS = new Set(['interface_declaration', 'type_alias_declaration', 'enum_declaration']) +const PROPERTIES = new Set(['public_field_definition', 'property_signature']) +const NAMES = new Set(['identifier', 'type_identifier', 'property_identifier', 'private_property_identifier']) + +/** 字符串字面量的内容(不带引号);带 `${}` 的模板字符串没有。 */ +function plainString(node: Node | undefined): string | undefined { + if (!node || (node.type !== 'string' && node.type !== 'template_string') || named(node).some(part => part.type === 'template_substitution')) + return undefined + return node.text.slice(1, -1).trim() || undefined +} + +/** `@Comment('…')`(调用的名字最后一节是 Comment)给的中文名。 */ +function decoratorName(decorators: readonly Node[]): string | undefined { + for (const decorator of decorators) { + const call = named(decorator)[0] + const callee = call?.type === 'call_expression' ? call.childForFieldName('function') : null + const last = callee?.type === 'member_expression' ? callee.childForFieldName('property') : callee + if (last?.text === COMMENT_MARK) { + const text = plainString(named(call?.childForFieldName('arguments'))[0]) + if (text) + return text + } + } + return undefined +} + +/** 节点自己带的装饰器(参数、字段、类声明)。 */ +function ownDecorators(node: Node | null | undefined): Node[] { + return named(node).filter(child => child.type === 'decorator') +} + +/** 类主体里排在成员前面的装饰器(方法的装饰器是它前面的兄弟节点)。 */ +function precedingDecorators(member: Node): Node[] { + const found: Node[] = [] + for (let prev = member.previousNamedSibling; prev?.type === 'decorator'; prev = prev.previousNamedSibling) + found.push(prev) + return found +} + +/** 声明外面包着的 export 语句(没有就是它自己):JSDoc、`@d export class` 的装饰器挂在它上面。 */ +function outerStatement(node: Node): Node { + return node.parent?.type === 'export_statement' ? node.parent : node +} + +/** 紧贴在前面的 JSDoc(中间隔着装饰器也算)的原文,去掉开头结尾的注释符号和每行开头的星号。 */ +function jsdocBefore(node: Node): string | undefined { + let prev = node.previousNamedSibling + while (prev?.type === 'decorator') + prev = prev.previousNamedSibling + if (prev?.type !== 'comment' || !prev.text.startsWith('/**') || prev.text.startsWith('/**/')) + return undefined + return prev.text.replace(/^\/\*+/, '').replace(/\*+\/$/, '').split('\n').map(line => line.replace(/^\s*\*(?!\/)\s?/, '')).join('\n') +} + +/** JSDoc 里各参数 `@param [{类型}] 名字 [-] 说明` 的说明取第一小句;`[名字]`、`[名字=默认]` 也算,`opts.x` 这种属性不算。 */ +export function jsdocParameters(doc: string): Map { + const found = new Map() + for (const line of doc.split('\n')) { + const at = line.indexOf('@param') + let rest = at < 0 ? '' : line.slice(at + '@param'.length).trimStart() + if (rest.startsWith('{')) + rest = rest.includes('}') ? rest.slice(rest.indexOf('}') + 1).trimStart() : '' + const token = rest.split(/\s/, 1)[0] + const name = token.replace(/^\[/, '').replace(/\]$/, '').split('=')[0] + const clause = /^[A-Z_$][\w$]*$/i.test(name) ? firstClause(rest.slice(token.length).trim().replace(/^-\s*/, '')) : undefined + if (clause && !found.has(name)) + found.set(name, clause) + } + return found +} + +/** 函数的 JSDoc 挂在哪:声明(连 export)、方法本身、值是这个函数的变量声明(连 export)或类字段。 */ +function functionDoc(fn: Node): string | undefined { + if (METHODS.has(fn.type)) + return jsdocBefore(fn) + if (DECLARED_FUNCTIONS.has(fn.type)) + return jsdocBefore(outerStatement(fn)) + const holder = fn.parent + if (holder?.type === 'public_field_definition') + return jsdocBefore(holder) + const declaration = holder?.type === 'variable_declarator' ? holder.parent : null + return declaration ? jsdocBefore(outerStatement(declaration)) : undefined +} + +/** 参数节点里定义的那个名字:`x`、`x?: T`、`x = 1`(JavaScript)、不带括号的箭头函数参数;解构的不算。 */ +function parameterName(param: Node): Node | undefined { + switch (param.type) { + case 'identifier': + return param + case 'required_parameter': + case 'optional_parameter': { + const pattern = param.childForFieldName('pattern') + return pattern?.type === 'identifier' ? pattern : undefined + } + case 'assignment_pattern': { + const left = param.childForFieldName('left') + return left?.type === 'identifier' ? left : undefined + } + default: + return undefined + } +} + +function functionCandidates(fn: Node): NameCandidate[] { + const doc = functionDoc(fn) + const described = doc === undefined ? new Map() : jsdocParameters(doc) + const single = fn.childForFieldName('parameter') + const params = (single ? [single] : named(fn.childForFieldName('parameters'))).flatMap((param): NameCandidate[] => { + const name = parameterName(param) + return name ? [{ name, marked: decoratorName(ownDecorators(param)), described: described.get(name.text), trailing: true }] : [] + }) + const name = FUNCTION_VALUES.has(fn.type) ? null : fn.childForFieldName('name') + const own = name ? [{ name, marked: METHODS.has(fn.type) ? decoratorName(precedingDecorators(fn)) : undefined }] : [] + return [...own, ...params] +} + +/** + * TypeScript / JavaScript 的中文名:`@Comment('…')` 装饰器(类、方法、字段、方法和构造函数的参数)、 + * 定义那一行行尾的 `// @Comment 名字`(参数、变量、枚举成员、属性),都没有时参数取 JSDoc 里 `@param` 的说明。 + */ +export function typescriptDisplayNames(root: Node, source: string): DisplayNameFacts { + const candidates: NameCandidate[] = [] + const comments: Node[] = [] + const nameOf = (node: Node): Node | undefined => { + const name = node.childForFieldName('name') + return name && NAMES.has(name.type) ? name : undefined + } + const visit = (node: Node): void => { + const name = nameOf(node) + if (node.type === 'comment') { + if (node.text.startsWith('//')) + comments.push(node) + return + } + if (DECLARED_FUNCTIONS.has(node.type) || METHODS.has(node.type) || FUNCTION_VALUES.has(node.type)) + candidates.push(...functionCandidates(node)) + else if (CLASSES.has(node.type) && name) + candidates.push({ name, marked: decoratorName([...ownDecorators(node), ...ownDecorators(outerStatement(node) === node ? undefined : node.parent)]) }) + else if (PROPERTIES.has(node.type) && name) + candidates.push({ name, marked: decoratorName(ownDecorators(node)), trailing: true }) + else if ((node.type === 'variable_declarator' || node.type === 'enum_assignment') && name) + candidates.push({ name, trailing: true }) + else if (DECLARATIONS.has(node.type) && name) + candidates.push({ name }) + else if (node.type === 'property_identifier' && node.parent?.type === 'enum_body') + candidates.push({ name: node, trailing: true }) + for (const child of children(node)) + visit(child) + } + visit(root) + return displayNameFacts(candidates, comments, source) +} + +export const typescriptNameReader: DisplayNameReader = { + files: { + typescript: '**/*.{ts,mts,cts}', + typescriptreact: '**/*.tsx', + javascript: '**/*.{js,mjs,cjs}', + javascriptreact: '**/*.jsx', + }, + mentions: source => source.includes(COMMENT_MARK) || source.includes('@param'), + find: (root, source) => typescriptDisplayNames(root, source), +} diff --git a/vscode/src/codeReader/libraryRules.test.ts b/vscode/src/codeReader/libraryRules.test.ts index 595600b..3f7f87b 100644 --- a/vscode/src/codeReader/libraryRules.test.ts +++ b/vscode/src/codeReader/libraryRules.test.ts @@ -49,7 +49,7 @@ const throwing: LibraryRule = { async function views(source: string, rules: LibraryRule[], reports: string[] = []): Promise { const run: RuleRun = { rules, report: message => reports.push(message) } - return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator, run)) + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, run)) } function flatten(list: BlockView[]): BlockView[] { diff --git a/vscode/src/codeReader/nameResolution.test.ts b/vscode/src/codeReader/nameResolution.test.ts new file mode 100644 index 0000000..2b41f58 --- /dev/null +++ b/vscode/src/codeReader/nameResolution.test.ts @@ -0,0 +1,250 @@ +import type { BlockView } from './blockView' +import type { DefinitionStart, IndexedName, NameSources, ShownName, TextRange } from './nameResolution' +import { describe, expect, it } from 'vitest' +import { readBlockViews } from './blockView' +import { createPythonAdapter } from './languages/python' +import { createTypeScriptAdapter } from './languages/typescript' +import { displayNameIndex, DocumentNames, indexedNames, renderedNames, resolveNames, shiftNames } from './nameResolution' +import { enumRule } from './rules/enum' +import { MemoryIndexHost, propertyLines, runtime, WASM_DIR } from './testSupport' +import { pythonZhTranslator } from './translators/pythonZh' +import { typescriptZhTranslator } from './translators/typescriptZh' +import { WorkspaceIndexes } from './workspaceIndex' + +const py = createPythonAdapter(WASM_DIR) +const ts = createTypeScriptAdapter(WASM_DIR) + +const ROUTES = [ + 'async def delete_item(', + ' item_id: int,', + ' current_user: User = Depends(get_current_user), # @Comment 当前用户', + '):', + ' if current_user.role != UserRole.ADMIN:', + ' raise Forbidden()', + '', +].join('\n') + +function read(source: string): ReturnType { + return readBlockViews(runtime, py, pythonZhTranslator, 'python', source, { rules: [enumRule], report: () => {} }) +} + +function allProperties(blocks: readonly BlockView[]): string[] { + return blocks.flatMap(block => [...('properties' in block ? propertyLines(block.properties) : []), ...allProperties(block.children)]) +} + +/** 源码里第 n 个 text 的下标 */ +function at(source: string, text: string, n = 0): number { + let index = -1 + for (let i = 0; i <= n; i++) + index = source.indexOf(text, index + 1) + return index +} + +describe('中文名记号不是说明文字:宿主把语言插件认出的记号交给翻译插件去掉', () => { + it('python 枚举成员的行尾注释:只有记号时没有说明,记号前面的字照常是说明;readBlockViews 交出这份源码起了中文名的定义', async () => { + const source = [ + 'import enum', + 'class Role(str, enum.Enum):', + ' ADMIN = "admin" # 管理员账号 @Comment 管理员', + ' MEMBER = "member" # @Comment 普通成员', + ' GUEST = "guest" # 游客', + ].join('\n') + const { blocks, displayNames } = await read(source) + expect(allProperties(blocks).filter(line => line.startsWith(' ') && line.includes('|'))).toEqual([ + ' ADMIN | "admin" | 管理员账号', + ' MEMBER | "member" | ', + ' GUEST | "guest" | 游客', + ]) + expect(displayNames.map(({ name, text }) => `${source.slice(name.startIndex, name.endIndex)} → ${text}`)).toEqual(['ADMIN → 管理员', 'MEMBER → 普通成员']) + }) + + it('typescript 枚举成员同样去掉;一个成员都没有说明时没有说明这一列', async () => { + const source = 'export enum Role {\n ADMIN = \'admin\', // @Comment 管理员\n USER = \'user\', // @Comment 普通用户\n}\n' + const { blocks } = await readBlockViews(runtime, ts, typescriptZhTranslator, 'typescript', source) + expect(allProperties(blocks).filter(line => line.startsWith(' '))).toEqual([' ADMIN | \'admin\'', ' USER | \'user\'']) + }) +}) + +/** 假的语言扩展:各处出现按名字文字认(测试里的名字都不重名),定义按表查。 */ +function fakeSources(source: string, options: { + highlights?: Record + definitions?: Record + indexed?: Record + asked?: string[] +} = {}): NameSources { + return { + uri: 'file:///w/routes.py', + occurrences: async offset => options.highlights?.[offset] ?? [], + definitions: async (offset) => { + options.asked?.push(`definition ${offset}`) + return options.definitions?.[offset] ?? [] + }, + indexed: async (name) => { + options.asked?.push(`indexed ${name}`) + return options.indexed?.[name] ?? [] + }, + } +} + +describe('中文名落到哪些名字上', () => { + it('renderedNames:一行译文、属性表的值和各层子行里带下标的名字,下标 → 原文', async () => { + const { blocks } = await read(ROUTES) + const rendered = renderedNames(blocks) + expect(rendered.get(at(ROUTES, 'current_user'))).toBe('current_user') + expect(rendered.get(at(ROUTES, 'current_user.role'))).toBe('current_user') + expect(rendered.get(at(ROUTES, 'ADMIN'))).toBe('ADMIN') + expect(rendered.get(at(ROUTES, 'delete_item'))).toBe('delete_item') + }) + + it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => { + const { blocks, displayNames } = await read(ROUTES) + const definition = at(ROUTES, 'current_user') + const use = at(ROUTES, 'current_user.role') + const sources = fakeSources(ROUTES, { highlights: { [definition]: [{ start: definition, end: definition + 12 }, { start: use, end: use + 12 }, { start: 3, end: 5 }, { start: at(ROUTES, 'ADMIN'), end: at(ROUTES, 'ADMIN') + 2 }] } }) + const reports: [ShownName[], boolean][] = [] + const settled = await resolveNames(displayNames, blocks, sources, (names, final) => reports.push([names, final])) + expect(settled).toBe(true) + const expected = [ + { offset: definition, length: 12, name: '当前用户', definition: true }, + { offset: use, length: 12, name: '当前用户' }, + ] + expect(reports).toEqual([[expected, false], [expected, true]]) + }) + + it('别的文件定义的:原名在索引里(别的文件)的名字才问定义,定义处正好是索引里那个定义才换', async () => { + const { blocks } = await read(ROUTES) + const admin = at(ROUTES, 'ADMIN') + const role = at(ROUTES, 'role') + const asked: string[] = [] + const models = 'file:///w/models.py' + const sources = fakeSources(ROUTES, { + asked, + indexed: { + ADMIN: [{ location: { uri: models, start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }], + role: [{ location: { uri: models, start: { line: 11, character: 4 }, end: { line: 11, character: 8 } }, text: '角色' }], + User: [{ location: { uri: 'file:///w/routes.py', start: { line: 0, character: 0 }, end: { line: 0, character: 4 } }, text: '本文件的不算' }], + }, + definitions: { + [admin]: [{ uri: models, line: 6, character: 4 }], + // 定义提供者说 role 在别处(同名的另一个定义) + [role]: [{ uri: models, line: 20, character: 4 }], + }, + }) + const reports: ShownName[][] = [] + const settled = await resolveNames([], blocks, sources, names => reports.push(names)) + expect(settled).toBe(true) + expect(reports.at(-1)).toEqual([{ offset: admin, length: 5, name: '管理员' }]) + expect(asked.filter(line => line.startsWith('definition')).sort()).toEqual([`definition ${admin}`, `definition ${role}`].sort()) + }) + + it('语言扩展还没起来:定义处自己都查不到出现,或要问的定义一个都没答,算没答完(过一会儿再查)', async () => { + const { blocks, displayNames } = await read(ROUTES) + expect(await resolveNames(displayNames, blocks, fakeSources(ROUTES), () => {})).toBe(false) + const admin = at(ROUTES, 'ADMIN') + const indexed = { ADMIN: [{ location: { uri: 'file:///w/models.py', start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }] } + expect(await resolveNames([], blocks, fakeSources(ROUTES, { indexed }), () => {})).toBe(false) + expect(await resolveNames([], blocks, fakeSources(ROUTES, { indexed, definitions: { [admin]: [{ uri: 'file:///w/other.py', line: 0, character: 0 }] } }), () => {})).toBe(true) + }) +}) + +describe('中文名跟着文档版本走', () => { + const name = (offset: number, text = '甲', length = 3): ShownName => ({ offset, length, name: text }) + + it('shiftNames:改动在前面的往后挪,碰到名字的丢掉,后面的不动;紧贴在名字前面插字也往后挪', () => { + const names = [name(10), name(20), name(30)] + expect(shiftNames(names, [{ offset: 0, length: 0, text: 'xx' }])).toEqual([name(12), name(22), name(32)]) + expect(shiftNames(names, [{ offset: 21, length: 1, text: '' }])).toEqual([name(10), name(29)]) + expect(shiftNames(names, [{ offset: 33, length: 5, text: '' }, { offset: 10, length: 0, text: 'y' }])).toEqual([name(11), name(21), name(31)]) + }) + + it('每个版本查一次;手上没有时查到一步交一步,有上一版挪过来的就等查完再换;查的时候文档又改了,查到的丢掉', async () => { + const delivered: string[] = [] + const names = new DocumentNames((version, list) => delivered.push(`${version}:${list.map(item => item.offset).join(',')}`), []) + let runs = 0 + let finish: (() => void) | undefined + const run = (list: ShownName[]) => async (report: (names: ShownName[], final: boolean) => void): Promise => { + runs++ + report(list.slice(0, 1), false) + await new Promise((resolve) => { + finish = resolve + }) + report(list, true) + return true + } + names.resolve(1, run([name(10), name(20)])) + names.resolve(1, run([])) + expect(runs).toBe(1) + finish?.() + await Promise.resolve() + await Promise.resolve() + expect(delivered).toEqual(['1:10', '1:10,20']) + expect(names.known(1)).toEqual([name(10), name(20)]) + + names.edited(2, [{ offset: 0, length: 0, text: 'x' }]) + expect(names.known(2)).toEqual([name(11), name(21)]) + expect(names.known(1)).toEqual([]) + names.resolve(2, run([name(11), name(21), name(40)])) + // 版本 2 查到一半文档又改了:查到的丢掉,版本 3 先用挪过的 + names.edited(3, [{ offset: 0, length: 1, text: '' }]) + finish?.() + await Promise.resolve() + await Promise.resolve() + expect(delivered).toEqual(['1:10', '1:10,20']) + expect(names.known(3)).toEqual([name(10), name(20)]) + }) + + it('没答完隔一会儿再查,按给的间隔、最多那么多次;答完了就不再查', async () => { + const scheduled: number[] = [] + const pending: (() => void)[] = [] + const names = new DocumentNames(() => {}, [5, 10], (task, ms) => { + scheduled.push(ms) + pending.push(task) + return setTimeout(() => {}, 0) + }) + let runs = 0 + names.resolve(1, async () => { + runs++ + return false + }) + for (let i = 0; i < 4; i++) { + await Promise.resolve() + pending.shift()?.() + } + await Promise.resolve() + expect(runs).toBe(3) + expect(scheduled).toEqual([5, 10]) + names.dispose() + }) +}) + +describe('工作区里的中文名索引', () => { + it('python:别的文件里起了中文名的定义按原名查,给出定义处名字的位置和中文名;没写中文名的文件不解析', async () => { + const host = new MemoryIndexHost({ + 'app/models.py': 'import enum\n\n\nclass UserRole(str, enum.Enum):\n """角色。"""\n\n ADMIN = "admin" # @Comment 管理员\n', + 'app/plain.py': 'ADMIN = 1\n', + }, py) + const indexes = new WorkspaceIndexes(host) + const found = await indexedNames((index, key) => indexes.find(index, key), py, 'ADMIN') + expect(found).toEqual([{ location: { uri: 'file:///w/app/models.py', start: { line: 6, character: 4 }, end: { line: 6, character: 9 } }, text: '管理员' }]) + expect(await indexedNames((index, key) => indexes.find(index, key), py, 'UserRole')).toEqual([]) + expect(host.reads.sort()).toEqual(['app/models.py', 'app/plain.py']) + indexes.dispose() + }) + + it('一种 languageId 一份索引;语言插件不认中文名、不管这种文件时没有', () => { + expect(displayNameIndex(py, 'python')?.id).toBe('displayNames:python') + expect(displayNameIndex(ts, 'typescriptreact')?.include).toBe('**/*.tsx') + expect(displayNameIndex(py, 'typescript')).toBeUndefined() + const { displayNames: _, ...plain } = py + expect(displayNameIndex(plain, 'python')).toBeUndefined() + }) + + it('查到的项带着认的时候给的中文名,库规则的 lookup 照旧只给位置', async () => { + const host = new MemoryIndexHost({ 'm.py': 'LIMIT = 1 # @Comment 上限\n' }, py) + const indexes = new WorkspaceIndexes(host) + const index = displayNameIndex(py, 'python')! + expect((await indexes.find(index, 'LIMIT')).map(({ entry }) => entry.text)).toEqual(['上限']) + expect(await indexes.lookup(index, 'LIMIT')).toEqual([{ uri: 'file:///w/m.py', start: { line: 0, character: 0 }, end: { line: 0, character: 5 } }]) + indexes.dispose() + }) +}) diff --git a/vscode/src/codeReader/nameResolution.ts b/vscode/src/codeReader/nameResolution.ts new file mode 100644 index 0000000..3fa0bfa --- /dev/null +++ b/vscode/src/codeReader/nameResolution.ts @@ -0,0 +1,250 @@ +import type { BlockView } from './blockView' +import type { DisplayName, IndexEntry, Inline, LanguageAdapter, PreviewText, PropertyItem, RuleLocation } from './contract' +import type { IndexedEntry, WorkspaceIndexOf } from './workspaceIndex' + +/** + * 中文名落到预览里的哪些名字上(见「中文名」):语言插件只认出定义和它的中文名, + * 用到它的各处由语言扩展按语义认(同一文件问 document highlights,别的文件定义的问定义提供者),这里不碰 VS Code。 + */ + +/** 预览里显示成中文名的一个名字:源码下标 [offset, offset + length) 的名字显示成 name;definition 是定义处本身(名称格子、名称行后面淡淡写原名)。 */ +export interface ShownName { + offset: number + length: number + name: string + definition?: true +} + +/** 视图数据里照抄自源码、带下标的名字(一行译文、属性表的值、各层子行的格子、框里):下标 → 原文。 */ +export function renderedNames(blocks: readonly BlockView[]): Map { + const found = new Map() + const inline = (pieces: readonly Inline[]): void => { + for (const piece of pieces) { + if (!('role' in piece)) + inline(piece.segments) + else if (piece.offset !== undefined) + found.set(piece.offset, piece.text) + } + } + const text = (value: PreviewText): void => { + if (!('markdown' in value)) + inline(value) + } + const items = (list: readonly PropertyItem[] | undefined): void => { + for (const item of list ?? []) { + item.cells.forEach(inline) + items(item.items) + } + } + const visit = (list: readonly BlockView[]): void => { + for (const block of list) { + if ('properties' in block) { + for (const row of block.properties) { + text(row.value) + items(row.items) + } + } + else { + text(block.translation) + } + visit(block.children) + } + } + visit(blocks) + return found +} + +/** 源码里的一段 [start, end)。 */ +export interface TextRange { start: number, end: number } + +/** 定义处名字的起点(行列 0 起,列按 UTF-16)。 */ +export interface DefinitionStart { uri: string, line: number, character: number } + +/** 工作区索引里别的文件定义的一个中文名。 */ +export interface IndexedName { location: RuleLocation, text: string } + +/** 找各处要问的:这份文档的语言扩展、工作区索引。 */ +export interface NameSources { + /** 这份文档的 uri */ + uri: string + /** offset 处的名字在这份文档里的各处出现(document highlights) */ + occurrences: (offset: number) => Promise + /** offset 处名字的定义(定义提供者) */ + definitions: (offset: number) => Promise + /** 工作区里定义的、原名叫 name 的中文名 */ + indexed: (name: string) => Promise +} + +/** + * 找这份文档里显示成中文名的各处,分两步交出(先交本文件的,不等工作区索引建好;最后一次 final): + * 1. 本文件定义的:每个定义问一次它的各处出现,落在视图数据里带下标的名字上的显示成中文名,定义处本身标 definition; + * 2. 别的文件定义的:原名和工作区索引里某个中文名一样的名字才问定义提供者,定义处正是索引里那个定义才算。 + * 返回语言扩展答没答(settled):定义处自己都查不到出现,或要问的定义一个都没答,是语言扩展还没起来,过一会儿再查。 + */ +export async function resolveNames( + names: readonly DisplayName[], + blocks: readonly BlockView[], + sources: NameSources, + report: (shown: ShownName[], final: boolean) => void, +): Promise { + const rendered = renderedNames(blocks) + const shown = new Map() + let settled = true + await Promise.all(names.map(async ({ name, text }) => { + const ranges = await sources.occurrences(name.startIndex) + if (!ranges.some(range => range.start === name.startIndex)) + settled = false + for (const { start, end } of ranges) { + if (!shown.has(start) && rendered.get(start)?.length === end - start) + shown.set(start, { offset: start, length: end - start, name: text, ...(start === name.startIndex && { definition: true as const }) }) + } + })) + report([...shown.values()], false) + + const byName = new Map() + for (const [offset, original] of rendered) { + if (!shown.has(offset)) + byName.set(original, [...(byName.get(original) ?? []), offset]) + } + let asked = 0 + let answered = 0 + await Promise.all([...byName].map(async ([original, offsets]) => { + const elsewhere = (await sources.indexed(original)).filter(entry => entry.location.uri !== sources.uri) + if (elsewhere.length === 0) + return + await Promise.all(offsets.map(async (offset) => { + asked++ + const found = await sources.definitions(offset) + if (found.length > 0) + answered++ + const entry = elsewhere.find(({ location }) => found.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character)) + if (entry) + shown.set(offset, { offset, length: original.length, name: entry.text }) + })) + })) + if (asked > 0 && answered === 0) + settled = false + report([...shown.values()], true) + return settled +} + +/** 中文名索引的一项:键是原名。 */ +export interface NameEntry extends IndexEntry { text: string } + +/** 一种语言的文件在整个工作区里的中文名索引(同一个 languageId 只建一份);语言插件不认中文名、不管这种文件时没有。 */ +export function displayNameIndex(adapter: LanguageAdapter, languageId: string): WorkspaceIndexOf | undefined { + const reader = adapter.displayNames + const include = reader?.files[languageId] + if (!reader || !include) + return undefined + return { + id: `displayNames:${languageId}`, + languageId, + include, + mentions: reader.mentions, + entries: (root, file) => reader.find(root, file.source, languageId).names.map(({ name, text }) => ({ + key: file.source.slice(name.startIndex, name.endIndex), + name, + text, + })), + } +} + +/** 这个语言插件管的各种文件里,原名叫 name 的中文名。 */ +export async function indexedNames( + find: (index: WorkspaceIndexOf, key: string) => Promise[]>, + adapter: LanguageAdapter, + name: string, +): Promise { + const indexes = Object.keys(adapter.displayNames?.files ?? {}).flatMap(languageId => displayNameIndex(adapter, languageId) ?? []) + const found = await Promise.all(indexes.map(index => find(index, name))) + return found.flat().map(({ location, entry }) => ({ location, text: entry.text })) +} + +/** 文档的一处改动(TextDocumentContentChangeEvent 的下标写法):从 offset 起 length 个字换成 text。 */ +export interface TextChange { offset: number, length: number, text: string } + +/** + * 中文名跟着改动挪位置:改动整个在名字前面的往后挪,整个在后面的不动,碰到名字的丢掉;一次的几处改动按顺序一个个套。 + * 紧贴着名字插的字把名字改长了(`foo` → `xfoo`)也不会认错:页面只把中文名放在长度对得上的名字上。 + */ +export function shiftNames(names: readonly ShownName[], changes: readonly TextChange[]): ShownName[] { + let current = [...names] + for (const { offset, length, text } of changes) { + const delta = text.length - length + current = current.flatMap((name) => { + if (offset + length <= name.offset) + return [{ ...name, offset: name.offset + delta }] + return offset >= name.offset + name.length ? [name] : [] + }) + } + return current +} + +/** 语言扩展还没起来时隔多久再查(毫秒),查到最后一次还没答就算了 */ +export const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000] + +/** + * 一份打开着的文档的中文名:每个版本查一次(语言扩展还没起来时照 RETRY_DELAYS 隔一会儿再查),只交出眼下这个版本的; + * 文档改了,已知的中文名先跟着改动挪位置当新版本的用,新版本查完再换掉,免得每改一下预览里的中文名闪一下。 + */ +export class DocumentNames { + private version: number | undefined + private names: ShownName[] = [] + /** 眼下这个版本查过了(或正在查) */ + private resolving: number | undefined + private timer: ReturnType | undefined + + constructor( + private readonly deliver: (version: number, names: ShownName[]) => void, + private readonly delays: readonly number[] = RETRY_DELAYS, + private readonly schedule: (run: () => void, ms: number) => ReturnType = setTimeout, + ) {} + + /** version 这份文档已知的中文名(发文档时带上);不是眼下这个版本的没有。 */ + known(version: number): ShownName[] { + return version === this.version ? this.names : [] + } + + /** 文档改成了 version:已知的中文名跟着改动挪位置,等新版本查完再换。 */ + edited(version: number, changes: readonly TextChange[]): void { + clearTimeout(this.timer) + this.names = shiftNames(this.names, changes) + this.version = version + this.resolving = undefined + } + + /** + * 发出去了 version 这份文档:查它的中文名;同一个版本只查一次(页面重新加载时照旧用查到的)。 + * 手上还没有这个版本的中文名时查到一步交一步(先交本文件的),有(上一版挪过来的)就等查完一起换,免得少了的那些闪一下。 + * 查的过程中文档又改了的,查到的丢掉。 + */ + resolve(version: number, run: (report: (names: ShownName[], final: boolean) => void) => Promise): void { + if (this.resolving === version) + return + clearTimeout(this.timer) + if (this.version !== version) { + this.version = version + this.names = [] + } + this.resolving = version + const report = (names: ShownName[], final: boolean): void => { + if (this.resolving !== version || (!final && this.names.length > 0)) + return + this.names = names + this.deliver(version, names) + } + const attempt = (tries: number): void => { + void run(report).then((settled) => { + if (!settled && this.resolving === version && tries < this.delays.length) + this.timer = this.schedule(() => attempt(tries + 1), this.delays[tries]) + }) + } + attempt(0) + } + + dispose(): void { + clearTimeout(this.timer) + this.resolving = undefined + } +} diff --git a/vscode/src/codeReader/qa/anchor.test.ts b/vscode/src/codeReader/qa/anchor.test.ts index 2e5e619..3a36f58 100644 --- a/vscode/src/codeReader/qa/anchor.test.ts +++ b/vscode/src/codeReader/qa/anchor.test.ts @@ -8,7 +8,7 @@ import { BlockAnchors } from './anchor' const py = createPythonAdapter(WASM_DIR) function views(source: string): Promise { - return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, undefined)) + return withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, undefined)) } function flatten(list: BlockView[]): BlockView[] { diff --git a/vscode/src/codeReader/qa/prompt.test.ts b/vscode/src/codeReader/qa/prompt.test.ts index 4032072..a2f69b4 100644 --- a/vscode/src/codeReader/qa/prompt.test.ts +++ b/vscode/src/codeReader/qa/prompt.test.ts @@ -21,7 +21,7 @@ class ReferralRelation(Base): ` async function subject(source: string, pick: (views: BlockView[]) => BlockView): Promise { - const blocks = await withBlocks(py, 'python', source, list => toBlockViews(list, source, py, undefined)) + const blocks = await withBlocks(py, 'python', source, list => toBlockViews(list, { source, language: py, markers: [] }, undefined)) return { path: 'app/models.py', languageId: 'python', source, blocks, anchor: pick(blocks).anchor } } diff --git a/vscode/src/codeReader/rules/celery.test.ts b/vscode/src/codeReader/rules/celery.test.ts index 290cf34..d29f89f 100644 --- a/vscode/src/codeReader/rules/celery.test.ts +++ b/vscode/src/codeReader/rules/celery.test.ts @@ -13,7 +13,7 @@ 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) })) + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [celeryRule], report: message => reports.push(message) })) } function properties(view: BlockView | undefined): PropertyRow[] { diff --git a/vscode/src/codeReader/rules/dataclass.test.ts b/vscode/src/codeReader/rules/dataclass.test.ts index 848dac4..b0156fb 100644 --- a/vscode/src/codeReader/rules/dataclass.test.ts +++ b/vscode/src/codeReader/rules/dataclass.test.ts @@ -19,7 +19,7 @@ const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule] async function views(source: string, reports: string[] = []): Promise { return withBlocks(py, 'python', source, blocks => - toBlockViews(blocks, source, py, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) })) + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) })) } function properties(view: BlockView | undefined): PropertyRow[] { diff --git a/vscode/src/codeReader/rules/enum.test.ts b/vscode/src/codeReader/rules/enum.test.ts index d6e248b..7c88628 100644 --- a/vscode/src/codeReader/rules/enum.test.ts +++ b/vscode/src/codeReader/rules/enum.test.ts @@ -21,11 +21,11 @@ const RULES = [sqlalchemyRule, celeryRule, enumRule] async function views(source: string, reports: string[] = []): Promise { return withBlocks(py, 'python', source, blocks => - toBlockViews(blocks, source, py, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) })) + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: RULES, report: message => reports.push(message) })) } function tsViews(source: string): Promise { - return withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator, { rules: RULES, report: () => {} })) + return withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator, { rules: RULES, report: () => {} })) } function properties(view: BlockView | undefined): PropertyRow[] { diff --git a/vscode/src/codeReader/rules/sqlalchemy.test.ts b/vscode/src/codeReader/rules/sqlalchemy.test.ts index f8f5539..7917064 100644 --- a/vscode/src/codeReader/rules/sqlalchemy.test.ts +++ b/vscode/src/codeReader/rules/sqlalchemy.test.ts @@ -13,7 +13,7 @@ const SNAPSHOT = readFixture('sqlalchemyModels.py') async function views(source: string, reports: string[] = []): Promise { return withBlocks(py, 'python', source, blocks => - toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: message => reports.push(message) })) + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: message => reports.push(message) })) } function flatten(list: BlockView[]): BlockView[] { diff --git a/vscode/src/codeReader/translators/contractOnly.test.ts b/vscode/src/codeReader/translators/contractOnly.test.ts index dafcbec..b6bf848 100644 --- a/vscode/src/codeReader/translators/contractOnly.test.ts +++ b/vscode/src/codeReader/translators/contractOnly.test.ts @@ -53,7 +53,7 @@ const translator = createZhTemplateTranslator({ function translateFirst(language: LanguageAdapter, source: string): Promise { return withBlocks(language, 'fake', source, ([block]) => { - const translation = translator.translate(block as TextBlock, { source, language }) + const translation = translator.translate(block as TextBlock, { source, language, markers: [] }) return 'markdown' in translation ? translation.markdown : segmentText(translation) }) } diff --git a/vscode/src/codeReader/translators/pythonZh.ts b/vscode/src/codeReader/translators/pythonZh.ts index 3873ac9..60bbfa1 100644 --- a/vscode/src/codeReader/translators/pythonZh.ts +++ b/vscode/src/codeReader/translators/pythonZh.ts @@ -19,8 +19,8 @@ function stringContent(node: Node): string { } /** `#` 注释去掉井号;docstring 只取引号里的内容。 */ -function commentText(node: Node): string { - return node.type === 'comment' ? node.text.replace(/^#+\s?/, '') : stringContent(node) +function commentText(node: Node, text: string): string { + return node.type === 'comment' ? text.replace(/^#+\s?/, '') : stringContent(node) } export const pythonZhTranslator: Translator = createZhTemplateTranslator({ diff --git a/vscode/src/codeReader/translators/typescriptZh.ts b/vscode/src/codeReader/translators/typescriptZh.ts index 807f46c..be3553e 100644 --- a/vscode/src/codeReader/translators/typescriptZh.ts +++ b/vscode/src/codeReader/translators/typescriptZh.ts @@ -1,8 +1,7 @@ import type { Node, Translator } from '../contract' import { createZhTemplateTranslator } from './zhTemplate' -function commentText(node: Node): string { - const text = node.text +function commentText(_node: Node, text: string): string { if (text.startsWith('//')) return text.replace(/^\/{2,}\s?/, '') return text diff --git a/vscode/src/codeReader/translators/zhTemplate.test.ts b/vscode/src/codeReader/translators/zhTemplate.test.ts index 75a9ed3..1de1f22 100644 --- a/vscode/src/codeReader/translators/zhTemplate.test.ts +++ b/vscode/src/codeReader/translators/zhTemplate.test.ts @@ -22,7 +22,7 @@ function translateAll(adapter: LanguageAdapter, languageId: string, translator: const line = block.header.startRow + 1 if (byLine.has(line) || 'facts' in block) return - const translation = translator.translate(block, { source, language: adapter }) + const translation = translator.translate(block, { source, language: adapter, markers: [] }) byLine.set(line, 'markdown' in translation ? { kind: block.kind, segments: [], markdown: translation.markdown } : { kind: block.kind, segments: translation }) @@ -38,7 +38,7 @@ function describeAll(adapter: LanguageAdapter, languageId: string, translator: T walk(blocks, (block) => { const line = block.header.startRow + 1 if (!byLine.has(line) && 'facts' in block) - byLine.set(line, translator.describe(block, { source, language: adapter })) + byLine.set(line, translator.describe(block, { source, language: adapter, markers: [] })) }) return byLine }) diff --git a/vscode/src/codeReader/translators/zhTemplate.ts b/vscode/src/codeReader/translators/zhTemplate.ts index 9b84e1f..f2873e0 100644 --- a/vscode/src/codeReader/translators/zhTemplate.ts +++ b/vscode/src/codeReader/translators/zhTemplate.ts @@ -1,4 +1,4 @@ -import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' +import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract' import { commonIndent, splitLines } from '../editText' /** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */ @@ -44,8 +44,8 @@ export interface ZhTable { mapLiterals: Record /** 整段原样照抄、按字符串着色的节点。 */ stringNodes: string[] - /** 注释(或 docstring)节点去掉注释符号后的原文,可以多行。 */ - commentText: (node: Node) => string + /** 注释(或 docstring)节点去掉注释符号后的原文,可以多行;text 是节点的源码去掉代码阅读的记号(SourceDocument.markers)以后剩下的。 */ + commentText: (node: Node, text: string) => string /** * 节点模板:节点类型 → 按中文语序重排的译文。命中的节点只输出模板里的译词和槽位,节点自己的关键字、标点不再输出; * 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。 @@ -95,6 +95,17 @@ function commentsWithin(node: Node, span: SourceSpan): Node[] { return node.children.flatMap(child => child ? commentsWithin(child, span) : []) } +/** 节点的源码去掉整个落在它里面的记号(中文名记号 `@Comment 当前用户` 不是说明文字)。 */ +function withoutMarkers(node: Node, markers: readonly SourceRange[]): string { + let text = '' + let cursor = node.startIndex + for (const marker of markers.filter(m => m.startIndex >= node.startIndex && m.endIndex <= node.endIndex).sort((a, b) => a.startIndex - b.startIndex)) { + text += node.text.slice(cursor - node.startIndex, marker.startIndex - node.startIndex) + cursor = Math.max(cursor, marker.endIndex) + } + return text + node.text.slice(cursor - node.startIndex) +} + function isComment(token: Token): boolean { return token.text === '' && !token.group } @@ -380,16 +391,21 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return segments } - function translateComment(block: TextBlock): Markdown { + /** 注释节点去掉注释符号和记号后的原文。 */ + function commentOf(node: Node, { markers }: SourceDocument): string { + return table.commentText(node, withoutMarkers(node, markers)) + } + + function translateComment(block: TextBlock, document: SourceDocument): Markdown { const { startIndex, endIndex } = block.header const root = block.node.tree.rootNode const nodes = block.node.type === 'comment' ? commentsWithin(root.descendantForIndex(startIndex, endIndex) ?? root, block.header) : [block.node] - return { markdown: docText(nodes.map(table.commentText)) } + return { markdown: docText(nodes.map(node => commentOf(node, document))) } } function translate(block: TextBlock, document: SourceDocument): PreviewText { if (block.kind === 'comment') - return translateComment(block) + return translateComment(block, document) const tokens = tokensIn(block.node, block.header, document) const compound = block.children.length > 0 || block.header.endIndex < block.node.endIndex const last = tokens.at(-1) @@ -402,8 +418,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { return joinTokens(tokensIn(node, nodeSpan(node), document)) } - function doc(node: Node): Markdown | undefined { - const markdown = docText([table.commentText(node)]) + function doc(node: Node, document: SourceDocument): Markdown | undefined { + const markdown = docText([commentOf(node, document)]) return markdown ? { markdown } : undefined } @@ -426,7 +442,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator { // 点分模块名逐节照抄,每一节各自能跳到定义,和 VS Code 里一样 module: node => stringNodes.has(node.type) ? [segment(node.text, 'string', offsetOf(node, language))] : verbatim(node, document, 'type'), name: (node, role) => [segment(node.text, role, offsetOf(node, language))], - doc, + doc: node => doc(node, document), } switch (block.kind) { case 'function': diff --git a/vscode/src/codeReader/workspaceIndex.ts b/vscode/src/codeReader/workspaceIndex.ts index 4b8dd3a..8aee90c 100644 --- a/vscode/src/codeReader/workspaceIndex.ts +++ b/vscode/src/codeReader/workspaceIndex.ts @@ -1,9 +1,9 @@ import type { Tree } from '@vscode/tree-sitter-wasm' -import type { RuleLocation, RulePosition, RuleWorkspace, WorkspaceIndex } from './contract' +import type { IndexedFile, IndexEntry, Node, RuleLocation, RulePosition, RuleWorkspace, WorkspaceIndex } from './contract' import * as path from 'node:path' /** - * 库规则的工作区索引(WorkspaceIndex)的宿主部分:找文件、预筛、解析、缓存,跟着文件改动只重看改了的文件。 + * 工作区索引(WorkspaceIndex:库规则的,和宿主自己认中文名的)的宿主部分:找文件、预筛、解析、缓存,跟着文件改动只重看改了的文件。 * 规则只说要什么;这里不碰 VS Code,工作区的能力由 IndexHost 给(VS Code 里是 workspaceFiles.ts,测试里是内存里的文件)。 */ @@ -52,10 +52,18 @@ function positionAt(starts: number[], index: number): RulePosition { return { line: low, character: index - starts[low] } } +/** 认出的一项在哪:定义处名字的位置,连同认的时候给的这一项(宿主自己的索引在项里多带东西,例如中文名)。 */ +export interface IndexedEntry { location: RuleLocation, entry: E } + +/** 认出的项是 E 的索引。 */ +export interface WorkspaceIndexOf extends WorkspaceIndex { + entries: (root: Node, file: IndexedFile) => E[] +} + /** 一份看过的文件:认出的各项,和建索引时问过在不在的文件(它们新建、删掉时这份要重看)。 */ -interface Indexed { +interface Indexed { file: WorkspaceFile - entries: { key: string, location: RuleLocation }[] + entries: IndexedEntry[] asked: Set } @@ -64,25 +72,25 @@ function compareLocations(a: RuleLocation, b: RuleLocation): number { } /** 一个 WorkspaceIndex 建出来的索引:建好之前的查询、文件改动都排在建索引后面,一件件来。 */ -class BuiltIndex { +class BuiltIndex { /** include 找到的全部文件(按 keyOf),IndexedFile.exists 查的就是它 */ private readonly files = new Map() /** 预筛过了、解析过的文件 */ - private readonly indexed = new Map() + private readonly indexed = new Map>() private readonly watcher: { dispose: () => void } private queue: Promise - constructor(private readonly index: WorkspaceIndex, private readonly host: IndexHost) { + constructor(private readonly index: WorkspaceIndexOf, 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 { + 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) + .flatMap(indexed => indexed.entries.filter(({ entry }) => entry.key === key)) + .sort((a, b) => compareLocations(a.location, b.location)) } dispose(): void { @@ -134,9 +142,9 @@ class BuiltIndex { 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) }, + const entries = this.index.entries(tree.rootNode, { path: file.path, source, exists }).map(entry => ({ + entry, + location: { uri: file.uri, start: positionAt(starts, entry.name.startIndex), end: positionAt(starts, entry.name.endIndex) }, })) this.indexed.set(key, { file, entries, asked }) } @@ -186,12 +194,17 @@ class BuiltIndex { /** 各库规则的工作区索引:第一次查某个索引时才建,同一个 id 只建一份。 */ export class WorkspaceIndexes implements RuleWorkspace { - private readonly built = new Map() + private readonly built = new Map>() constructor(private readonly host: IndexHost) {} - lookup(index: WorkspaceIndex, key: string): Promise { - let built = this.built.get(index.id) + async lookup(index: WorkspaceIndex, key: string): Promise { + return (await this.find(index, key)).map(({ location }) => location) + } + + /** 同 lookup,连同认出的那一项(第一次查时建,同一个 id 只建一份)。 */ + find(index: WorkspaceIndexOf, key: string): Promise[]> { + let built = this.built.get(index.id) as BuiltIndex | undefined if (!built) { built = new BuiltIndex(index, this.host) this.built.set(index.id, built) diff --git a/vscode/webview-ui/src/codeReader/App.tsx b/vscode/webview-ui/src/codeReader/App.tsx index 4e03980..8be4775 100644 --- a/vscode/webview-ui/src/codeReader/App.tsx +++ b/vscode/webview-ui/src/codeReader/App.tsx @@ -1,11 +1,13 @@ import type { ReactElement } from 'react' import type { BlockTreeState } from './BlockTree' +import type { NameMap, VersionNames } from './displayNames' import type { DocumentMessage, Excluded, Reveal } from './types' import type { OccurrencePage } from './useOccurrences' import type { ViewState } from './viewState' import { TriangleAlert } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { BlockLevel } from './BlockTree' +import { acceptNames, DisplayNameContext, documentNames } from './displayNames' import { coveredByEditing, startEditing } from './editing' import { applyFilter, keptWhileEditing } from './filter' import { FilterBar } from './FilterBar' @@ -20,6 +22,7 @@ import { onMessage, postMessage, viewState } from './vscode' type BlockModes = Pick const NONE: ReadonlySet = new Set() +const NO_NAMES: NameMap = new Map() function savedBlockModes(): BlockModes { const { editing, folds } = viewState.get() @@ -38,6 +41,8 @@ export function App(): ReactElement | null { const [excluded, setExcluded] = useState({}) /** 跳到定义时临时露出来的块(目标和它的各层祖先),筛选一变、文档一刷新就回到筛选状态 */ const [keep, setKeep] = useState>(NONE) + /** 页面上这个文档版本的中文名:文档带来的,扩展端查到了再换 */ + const [names, setNames] = useState() /** 收到消息时读眼下哪些块在编辑(消息回调只在挂载时注册一次) */ const editing = useRef(view.editing) const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds), [doc, view.folds]) @@ -69,6 +74,7 @@ export function App(): ReactElement | null { switch (msg.type) { case 'document': setDoc(msg) + setNames(documentNames(msg.version, msg.names)) setKeep(prev => keptWhileEditing(prev, editing.current)) if (msg.reveal) setReveal(msg.reveal) @@ -76,6 +82,9 @@ export function App(): ReactElement | null { case 'reveal': setReveal({ line: msg.line, definition: msg.definition }) return + case 'displayNames': + setNames(prev => acceptNames(prev, msg.version, msg.names)) + return case 'filter': setExcluded(msg.excluded) setKeep(NONE) @@ -119,35 +128,37 @@ export function App(): ReactElement | null { return ( - postMessage({ type: 'setFilter', excluded: next })} - translators={doc.translators} - translatorId={doc.translatorId} - onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} - /> -
- {doc.error !== undefined && ( -
- -
-
- 解析失败: - {doc.error} + + postMessage({ type: 'setFilter', excluded: next })} + translators={doc.translators} + translatorId={doc.translatorId} + onSelectTranslator={id => postMessage({ type: 'selectTranslator', id })} + /> +
+ {doc.error !== undefined && ( +
+ +
+
+ 解析失败: + {doc.error} +
+
点编辑器右上角的「编辑」查看源码。
-
点编辑器右上角的「编辑」查看源码。
-
- )} - {doc.blocks.length === 0 - ? doc.error === undefined &&
这个文件里没有代码。
- : doc.blocks.some(block => !filter.hidden.has(block.id)) - ? - :
所有块都被筛选隐藏了。
} -
+ )} + {doc.blocks.length === 0 + ? doc.error === undefined &&
这个文件里没有代码。
+ : doc.blocks.some(block => !filter.hidden.has(block.id)) + ? + :
所有块都被筛选隐藏了。
} +
+ ) } diff --git a/vscode/webview-ui/src/codeReader/SegmentText.tsx b/vscode/webview-ui/src/codeReader/SegmentText.tsx index 04becfe..1d5bbd6 100644 --- a/vscode/webview-ui/src/codeReader/SegmentText.tsx +++ b/vscode/webview-ui/src/codeReader/SegmentText.tsx @@ -3,31 +3,40 @@ import type { ReactElement } from 'react' import type { GroupStyle, Inline, SegmentGroup } from './types' import { Repeat } from 'lucide-react' import { useContext } from 'react' +import { DisplayNameContext, shownName } from './displayNames' import { isCurrent, segmentOccurrence } from './occurrences' import { HighlightContext } from './useOccurrences' /** * 按词性给每一段上色,颜色在 codeReader.css 的 .cr-role-* 里按主题切换;带源码下标的名字记在 data-offset 上, * 跳到定义、高亮各处出现用;是眼下高亮着的名字的一处出现时 data-occurrence 标出底色,是当前那一处(点中的、用键盘跳到的)时 - * 再标 data-current。框起来的一组词画成小框,框里的词同样处理。 + * 再标 data-current。起了中文名的名字显示中文名(data-name),悬停看原名,定义处本身在后面淡淡写原名;下标照旧,跳到定义、高亮照常。 + * 框起来的一组词画成小框,框里的词同样处理。 */ export function SegmentText({ segments }: { segments: Inline[] }): ReactElement { const highlight = useContext(HighlightContext) + const names = useContext(DisplayNameContext) return ( <> - {segments.map((piece, index) => 'role' in piece - ? ( - - {piece.text} - - ) - : )} + {segments.map((piece, index) => { + if (!('role' in piece)) + return + const shown = shownName(piece, names) + return ( + + {shown ? shown.name : piece.text} + {shown?.definition && {piece.text}} + + ) + })} ) } diff --git a/vscode/webview-ui/src/codeReader/codeReader.css b/vscode/webview-ui/src/codeReader/codeReader.css index 415e75b..e066beb 100644 --- a/vscode/webview-ui/src/codeReader/codeReader.css +++ b/vscode/webview-ui/src/codeReader/codeReader.css @@ -283,6 +283,13 @@ body:is(.vscode-light, .vscode-high-contrast-light) [data-badge] { --kind: var(- border-top: 1px solid color-mix(in srgb, currentColor 30%, transparent); } +/* 起了中文名的定义处:中文名后面淡淡写原名,字小一号,悬停看完整的「原名 x」 */ +.cr-original { + margin-left: 0.4em; + font-size: 0.86em; + color: var(--vscode-descriptionForeground); +} + /* 按住 ⌘ / Ctrl 停在查得到定义的名字上:和编辑器里的链接一样 */ .cr-code [data-link] { color: var(--vscode-editorLink-activeForeground); diff --git a/vscode/webview-ui/src/codeReader/displayNames.test.tsx b/vscode/webview-ui/src/codeReader/displayNames.test.tsx new file mode 100644 index 0000000..6c028c5 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/displayNames.test.tsx @@ -0,0 +1,81 @@ +import type { Inline, ShownName, WebviewToExtension } from './types' +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it, vi } from 'vitest' +import { acceptNames, DisplayNameContext, documentNames, nameMap, shownName } from './displayNames' +import { OccurrenceHighlight } from './occurrences' +import { SegmentText } from './SegmentText' + +// 高亮的上下文所在的模块连着和 VS Code 收发消息的那一套,渲染成 HTML 用不着 +vi.mock('./useOccurrences', async () => { + const { createContext } = await import('react') + return { HighlightContext: createContext(undefined) } +}) + +const CURRENT_USER: ShownName = { offset: 40, length: 12, name: '当前用户', definition: true } +const USE: ShownName = { offset: 90, length: 12, name: '当前用户' } +const ADMIN: ShownName = { offset: 130, length: 5, name: '管理员' } + +function render(segments: Inline[], names: ShownName[]): string { + return renderToStaticMarkup( + + + , + ) +} + +describe('页面上的中文名', () => { + it('收到新文档换成它带来的;查到的是页面上这个版本的才换上,迟到的旧版本不理', () => { + const current = documentNames(3, [USE]) + expect([...current.names.values()]).toEqual([USE]) + expect(acceptNames(current, 2, [ADMIN])).toBe(current) + expect([...acceptNames(current, 3, [USE, ADMIN])!.names.values()]).toEqual([USE, ADMIN]) + expect(acceptNames(undefined, 3, [ADMIN])).toBeUndefined() + }) + + it('只认下标和长度都对得上的名字:没下标的译词、长度不一样的(名字改长了)不换', () => { + const names = nameMap([USE]) + expect(shownName({ text: 'current_user', role: 'variable', offset: 90 }, names)).toBe(USE) + expect(shownName({ text: 'current_userx', role: 'variable', offset: 90 }, names)).toBeUndefined() + expect(shownName({ text: 'current_user', role: 'variable' }, names)).toBeUndefined() + expect(shownName({ text: 'current_user', role: 'variable', offset: 91 }, names)).toBeUndefined() + }) + + it('用到的地方显示中文名,悬停写「原名 x」,下标照旧(⌘ 点击跳到定义、单击高亮都认它)', () => { + const html = render([ + { text: '如果', role: 'keyword' }, + { text: ' ', role: 'plain' }, + { text: 'current_user', role: 'variable', offset: 90 }, + { text: '.', role: 'plain' }, + { text: 'role', role: 'variable', offset: 103 }, + { text: ' 不等于 ', role: 'operator' }, + { text: 'UserRole', role: 'variable', offset: 120 }, + { text: '.', role: 'plain' }, + { text: 'ADMIN', role: 'variable', offset: 130 }, + ], [USE, ADMIN]) + expect(html.replace(/<[^>]*>/g, '')).toBe('如果 当前用户.role 不等于 UserRole.管理员') + expect(html).toContain('当前用户') + expect(html).toContain('role') + expect(html).not.toContain('cr-original') + }) + + it('定义处本身(名称格子、名称行):中文名后面跟着淡淡的原名;框里的名字同样换', () => { + const html = render([ + { text: 'current_user', role: 'variable', offset: 40 }, + { style: 'iteration', label: '列表', segments: [{ text: 'ADMIN', role: 'variable', offset: 130 }] }, + ], [CURRENT_USER, ADMIN]) + expect(html).toContain('data-offset="40" data-name="当前用户" title="原名 current_user">当前用户current_user') + expect(html).toContain('data-offset="130" data-name="管理员" title="原名 ADMIN">管理员') + }) + + it('单击显示成中文名的名字:筛选栏写中文名,当前那一处按点中的下标认', () => { + const sent: WebviewToExtension[] = [] + let shown: string | undefined + const highlight = new OccurrenceHighlight(msg => sent.push(msg), next => shown = next && `${next.name} ${next.current}`) + highlight.setDocument(1, 'zh') + highlight.click({ kind: 'find', offset: 90, name: '当前用户' }) + const asked = sent.at(-1) + expect(asked).toMatchObject({ type: 'findOccurrences', offset: 90 }) + highlight.answer(asked?.type === 'findOccurrences' ? asked.id : -1, [{ start: 40, end: 52, kind: 'write' }, { start: 90, end: 102, kind: 'read' }]) + expect(shown).toBe('当前用户 1') + }) +}) diff --git a/vscode/webview-ui/src/codeReader/displayNames.ts b/vscode/webview-ui/src/codeReader/displayNames.ts new file mode 100644 index 0000000..f68a2f6 --- /dev/null +++ b/vscode/webview-ui/src/codeReader/displayNames.ts @@ -0,0 +1,34 @@ +import type { Segment, ShownName } from './types' +import { createContext } from 'react' + +/** 页面上这个版本的中文名:下标 → 显示成什么。 */ +export type NameMap = ReadonlyMap + +/** 眼下页面上显示成中文名的名字,SegmentText 按它换字。 */ +export const DisplayNameContext = createContext(new Map()) + +/** 某个文档版本的中文名。 */ +export interface VersionNames { + version: number + names: NameMap +} + +export function nameMap(names: readonly ShownName[]): NameMap { + return new Map(names.map(name => [name.offset, name])) +} + +/** 收到新文档:换成它带来的中文名(上一版查到的跟着改动挪过位置的,或这一版查过的)。 */ +export function documentNames(version: number, names: readonly ShownName[]): VersionNames { + return { version, names: nameMap(names) } +} + +/** 收到查到的中文名:是页面上这个版本的才换上,迟到的旧版本的不理。 */ +export function acceptNames(current: VersionNames | undefined, version: number, names: readonly ShownName[]): VersionNames | undefined { + return current?.version === version ? { version, names: nameMap(names) } : current +} + +/** 这段名字显示成什么中文名:照抄自源码、下标和长度都对得上才算。 */ +export function shownName(segment: Segment, names: NameMap): ShownName | undefined { + const shown = segment.offset === undefined ? undefined : names.get(segment.offset) + return shown?.length === segment.text.length ? shown : undefined +} diff --git a/vscode/webview-ui/src/codeReader/occurrences.test.ts b/vscode/webview-ui/src/codeReader/occurrences.test.ts index 2fc7f86..c094b12 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.test.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.test.ts @@ -65,7 +65,7 @@ describe('occurrences:哪些段画高亮', () => { ' return "".join(secrets.choice(ALPHABET) for _ in range(8))', ].join('\n') const py = createPythonAdapter(WASM_DIR) - const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator)) + const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) const highlighted = (occurrences: Occurrence[]): string[] => allSegments(views).flatMap((segment) => { const style = segmentOccurrence(segment, occurrences) return style ? [`${segment.text}:${style}`] : [] diff --git a/vscode/webview-ui/src/codeReader/occurrences.ts b/vscode/webview-ui/src/codeReader/occurrences.ts index 819b620..2f14a83 100644 --- a/vscode/webview-ui/src/codeReader/occurrences.ts +++ b/vscode/webview-ui/src/codeReader/occurrences.ts @@ -42,7 +42,7 @@ export interface Click { count: number /** 点完页面上有选中的文字:这是拖着选字,不是点 */ selecting: boolean - /** 点中的带下标的名字 */ + /** 点中的带下标的名字:源码下标,页面上显示的字(起了中文名的是中文名) */ name?: { offset: number, text: string } /** 点在自己管点击的东西上:按钮、链接、输入框、下拉列表、筛选栏、属性名 */ control: boolean @@ -175,8 +175,8 @@ export class OccurrenceHighlight { private translatorId: string | undefined /** 最近一次询问的 id;别的回答(点了别处、清掉之后才到的)都丢掉 */ private request = 0 - /** 最近一次询问点中的那段名字;答过或清掉后没有 */ - private asked: Segment | undefined + /** 最近一次询问点中的名字:源码下标和页面上显示的字;答过或清掉后没有 */ + private asked: { offset: number, name: string } | undefined /** 高亮着的名字没有当前那一处时,从这个源码下标(点中的名字)数起跳到哪一处 */ private from = 0 private current: Highlight | undefined @@ -213,9 +213,9 @@ export class OccurrenceHighlight { return this.asked = undefined const sorted = [...occurrences].sort((a, b) => a.start - b.start || a.end - b.end) - const current = sorted.findIndex(occurrence => overlaps(asked, occurrence)) - this.from = asked.offset ?? 0 - this.show(sorted.length > 0 ? { name: asked.text, occurrences: sorted, ...(current >= 0 && { current }) } : undefined) + const current = sorted.findIndex(occurrence => occurrence.start <= asked.offset && asked.offset < occurrence.end) + this.from = asked.offset + this.show(sorted.length > 0 ? { name: asked.name, occurrences: sorted, ...(current >= 0 && { current }) } : undefined) } /** @@ -240,7 +240,7 @@ export class OccurrenceHighlight { private find(offset: number, name: string): void { if (this.version === undefined) return - this.asked = { text: name, role: 'plain', offset } + this.asked = { offset, name } this.post({ type: 'findOccurrences', id: ++this.request, version: this.version, offset }) } diff --git a/vscode/webview-ui/src/codeReader/reveal.test.ts b/vscode/webview-ui/src/codeReader/reveal.test.ts index dcb0f7c..e460163 100644 --- a/vscode/webview-ui/src/codeReader/reveal.test.ts +++ b/vscode/webview-ui/src/codeReader/reveal.test.ts @@ -235,7 +235,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { async function jump(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string, needle: string): Promise<{ plan: RevealPlan | undefined, flashed: string }> { const offset = source.indexOf(needle) expect(offset, needle).toBeGreaterThanOrEqual(0) - const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, source, adapter, translator)) + const views: BlockView[] = await withBlocks(adapter, languageId, source, blocks => toBlockViews(blocks, { source, language: adapter, markers: [] }, translator)) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) return { plan, flashed: flashed(views, plan) } } @@ -323,7 +323,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { expect(await at('T>')).toEqual({ plan: { id: '0', row: '0/p3', expand: [] }, flashed: '类型参数: T' }) expect(await at('load')).toEqual({ plan: { id: '0.0', expand: ['0'] }, flashed: 'load(id: T) {' }) expect(await at('id: T')).toEqual({ plan: { id: '0.0', row: '0.0/p3/0', expand: ['0', '0.0/p3'] }, flashed: 'id | T | ' }) - const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, source, ts, typescriptZhTranslator)) + const views = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) expect([...collapsedIds(views, {})].sort()).toEqual(['0', '0.0']) const pySource = ['class NotFound(AppException):', ' """找不到。"""', ' def run(self):', ' pass'].join('\n') @@ -344,7 +344,7 @@ describe('跳到定义:TS / Python 源码里闪哪一处', () => { ' return self.name', ].join('\n') const views = await withBlocks(py, 'python', source, blocks => - toBlockViews(blocks, source, py, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) + toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [sqlalchemyRule], report: () => {} })) const at = (needle: string): { plan: RevealPlan | undefined, flashed: string } => { const offset = source.indexOf(needle) const plan = revealPlan(views, source.slice(0, offset).split('\n').length - 1, offset) diff --git a/vscode/webview-ui/src/codeReader/subRows.test.ts b/vscode/webview-ui/src/codeReader/subRows.test.ts index edc9ae2..4e31107 100644 --- a/vscode/webview-ui/src/codeReader/subRows.test.ts +++ b/vscode/webview-ui/src/codeReader/subRows.test.ts @@ -34,7 +34,7 @@ describe('属性行下面的子行怎么排', () => { 'D = {"k": 1}', ].join('\n') const py = createPythonAdapter(WASM_DIR) - const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, source, py, pythonZhTranslator)) + const views = await withBlocks(py, 'python', source, blocks => toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator)) const layouts = views.flatMap(view => 'properties' in view ? view.properties.flatMap((row: PropertyRow) => subRowsLayout(row) ? [[row.label, subRowsLayout(row)]] : []) : []) expect(layouts).toEqual([['导入项', 'list'], ['输入参数', 'table'], ['值', 'list']]) }) diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index 3150bed..2d4f97b 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -154,6 +154,19 @@ export interface DocumentMessage { error?: string /** 只在新预览的第一份 document 里带:从文本编辑器切过来时是它顶部那一行,跳到定义时是定义处 */ reveal?: Reveal + /** 这个版本眼下已知的中文名;查完扩展端再发 displayNames 换掉 */ + names: ShownName[] +} + +/** + * 显示成中文名的一个名字:源码下标 [offset, offset + length) 的名字显示成 name,悬停看原名; + * definition 是定义处本身,中文名后面淡淡写原名。页面只按下标认,不认识任何语言。 + */ +export interface ShownName { + offset: number + length: number + name: string + definition?: true } /** 问过的一次:问题、回答(Markdown)、什么时候问的(ISO 时间)、用哪个 profile 答的 */ @@ -235,6 +248,7 @@ export interface AnswerEnd { * answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事 * answerEnd:askId 那个问题答完了,见 AnswerEnd * moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现 + * displayNames:version 这份文档里显示成中文名的名字,查到一步来一步;不是页面上这个版本的不理 */ export type ExtensionToWebview = | DocumentMessage @@ -242,6 +256,7 @@ export type ExtensionToWebview | { type: 'definitionProbed', id: number, found: boolean } | { type: 'occurrencesFound', id: number, occurrences: Occurrence[] } | { type: 'moveOccurrence', step: 1 | -1 } + | { type: 'displayNames', version: number, names: ShownName[] } | { type: 'filter', excluded: Excluded } | QaMessage | ({ type: 'askProfiles' } & ProfileChoice) diff --git a/vscode/webview-ui/src/codeReader/useOccurrences.ts b/vscode/webview-ui/src/codeReader/useOccurrences.ts index fca2670..8ad65af 100644 --- a/vscode/webview-ui/src/codeReader/useOccurrences.ts +++ b/vscode/webview-ui/src/codeReader/useOccurrences.ts @@ -34,7 +34,8 @@ function clickOf(event: MouseEvent): Click { modified: event.metaKey || event.ctrlKey || event.altKey || event.shiftKey, count: event.detail, selecting: selection !== null && !selection.isCollapsed, - name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.textContent ?? '' }, + // 起了中文名的名字写的是中文名(定义处后面还跟着原名),筛选栏上写中文名 + name: name?.dataset.offset === undefined ? undefined : { offset: Number(name.dataset.offset), text: name.dataset.name ?? name.textContent ?? '' }, control: target?.closest(CONTROLS) != null, } }