From 7f39345d031cdb2fd2b09b8e7520bb7e1e7a6d84 Mon Sep 17 00:00:00 2001 From: cruldra Date: Thu, 8 Oct 2026 09:55:36 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(vscode):=20=E5=86=99?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=E6=97=B6=E6=94=B9=E5=B7=B2=E6=9C=89=E8=AF=B4?= =?UTF-8?q?=E6=98=8E=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=9C=A8=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E4=B8=8A=E9=9D=A2=E5=8F=A6=E6=8F=92=E4=B8=80=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/specs/code-reader/spec.md | 20 +++-- vscode/src/codeReader/blockView.test.ts | 4 +- vscode/src/codeReader/blockView.ts | 23 +++-- vscode/src/codeReader/commentRewrite.test.ts | 86 ++++++++++++++++--- vscode/src/codeReader/commentRewrite.ts | 32 +++++-- vscode/src/codeReader/contract.ts | 11 ++- vscode/src/codeReader/editor/previewEditor.ts | 1 - vscode/src/codeReader/languages/python.ts | 13 +++ vscode/src/codeReader/languages/rust.ts | 8 ++ vscode/src/codeReader/languages/shared.ts | 26 ++++++ vscode/src/codeReader/languages/typescript.ts | 8 ++ 11 files changed, 196 insertions(+), 36 deletions(-) diff --git a/docs/superpowers/specs/code-reader/spec.md b/docs/superpowers/specs/code-reader/spec.md index 867d256..ab0699e 100644 --- a/docs/superpowers/specs/code-reader/spec.md +++ b/docs/superpowers/specs/code-reader/spec.md @@ -635,14 +635,14 @@ ### 重写注释 -按写注释的技能(`/ecc:writing-code-comments`)给一块代码重写讲它的普通注释,没有就新写一段。 +按写注释的技能(`/ecc:writing-code-comments`)给一块代码重写它的说明。没有说明、但块前面有普通注释时换掉那段;都没有就按这门语言的说明新写一段。 -- 按钮(`MessageSquareText` 图标,只有图标,悬停 / 读屏「按注释规范重写」,块还没有注释时是「按注释规范写注释」):每块代码都有,不管卡片有没有「注释」一行都排在右上角那排按钮的最前面,依次是它、「问 AI」、「编辑」;文件开头的说明卡片也一样。正在重写时不能再点。定义卡片浮窗里、删掉的块没有;嵌着的代码里的块(Dockerfile RUN 里的 shell)、语言没给行注释记号(`LanguageAdapter.lineComment`)又还没有注释的块没有(`BlockView.commentTarget` 没有)。 -- 改哪儿(`BlockView.commentTarget`,扩展端 `blockView.ts` 算):块前面挂着普通注释时换掉它们(从第一段开头到最后一段结尾,几段中间隔着的空行一起换掉,写成一段);文件开头的说明卡片换掉它自己;块还没有前面的注释时新写一段,插在块的第一行(连同它的说明、装饰器)上面。说明(docstring、JSDoc、`///`)不动;挂在块后面、子块交上来的注释不动。 +- 按钮(`MessageSquareText` 图标,只有图标,悬停 / 读屏「按注释规范重写」,块还没有说明、也没有前面的普通注释时是「按注释规范写注释」):每块代码都有,不管卡片有没有「注释」一行都排在右上角那排按钮的最前面,依次是它、「问 AI」、「编辑」;文件开头的说明卡片也一样。正在重写时不能再点。定义卡片浮窗里、删掉的块没有;嵌着的代码里的块(Dockerfile RUN 里的 shell)、语言既没有说明的写法(`LanguageAdapter.freshDoc`)也没给行注释记号(`LanguageAdapter.lineComment`)、又还没有可换的注释的块没有(`BlockView.commentTarget` 没有)。 +- 改哪儿(`BlockView.commentTarget`,扩展端 `blockView.ts` 算):块已经有说明(Python 函数 / 类主体里第一条语句的 docstring、紧贴声明的 JSDoc、Rust 的 `///` / `//!`,以及别的语言记在块上的说明)时换掉这段说明的那几行,正文填回原来的定界符(`"""`、`/** */`、`///`),不在声明上面再插一行行注释;说明和块前面的普通注释都有时只换说明,普通注释留着。没有说明、块前面挂着普通注释时换掉它们(从第一段开头到最后一段结尾,几段中间隔着的空行一起换掉,写成一段);文件开头的说明卡片换掉它自己。都没有时新写:Python 的函数或类在主体里插一段 docstring(缩进同主体,作为第一条语句;声明和主体写在同一行时从主体那一列拆开);JavaScript / TypeScript 在声明紧上面插 `/** */`;Rust 在项紧上面插 `///`;只有行注释的语言(shell、Dockerfile)仍在块的第一行上面插一行行注释。挂在块后面、子块交上来的注释不动。 - 问模型:同「问 AI」用 Claude Code 命令行、在这份文件所在的工作区文件夹里跑、用选的 profile、只读(`--tools Read,Grep,Glob`、`--allowedTools Read,Grep,Glob`、`--permission-mode dontAsk`、`--strict-mcp-config`:它能读那个文件,改不了,写回归扩展),从 stdin 发的只有一句:`/ecc:writing-code-comments <路径>:<起>-<止>`,路径相对 claude 的工作目录、用 `/` 分隔,行号 1 起,是这块代码的完整范围(连同挂在它上面的注释;文件开头的说明卡片是它自己那几行),别的不说(`commentRewrite.ts` 的 `commentRewritePrompt`)。文件有没保存的修改时不问(模型读的是磁盘上的文件),直接失败「先保存再重写注释」。 - 结果按 JSON Schema 交(`--json-schema '{"type":"object","properties":{"body":{"type":"string"}},"required":["body"],"additionalProperties":false}'`):模型调 Claude Code 的 `StructuredOutput` 工具交结果,result 行的 `structured_output.body` 是注释正文(不带注释符号)。不读模型的自由文字:只回了一段话、`structured_output` 没有或 `body` 是空的,都算失败,文件不动。 - 过程(`rewriteProgress`):问的那一句、正文逐字长出来(`--include-partial-messages` 时 `StructuredOutput` 的输入逐段来,按写到一半的 JSON 读出已经写出来的正文)、正在读什么(「读取 a.py」);块下面同问答区的样子显示(`RewriteThread`),能叫停。 -- 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段注释的定界符和缩进(Python 三引号、`#` / `//` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用语言的行注释记号(`#`、`//`)一行一行写,缩进同块的第一行,空行写成只有记号的一行。写之前核对这块代码的那几行和问的时候一样,变了就不写(「这块代码在重写期间变了」);定界符认不出、编辑器没接受也不写。结束发 `rewriteEnd`(done / failed 带原因 / stopped),失败另弹一条错误提示。 +- 写回(扩展端,`WorkspaceEdit`,能撤销):换掉时套回原来那段的定界符和缩进(Python 三引号、`#` / `//` / `///` / `//!` 行注释、`/* */` 块注释,`refillComment`;几段隔着空行的照第一段的写法);新写时用这块说明的壳(docstring、`/** */`、`///`,或只有行注释的语言用 `#` / `//`)一行一行写,缩进同插入的那一行,空行写成只有记号的一行(docstring 的空行仍是空行)。写之前核对这块代码的那几行和问的时候一样,变了就不写(「这块代码在重写期间变了」);定界符认不出、编辑器没接受也不写。结束发 `rewriteEnd`(done / failed 带原因 / stopped),失败另弹一条错误提示。 ## 通用契约 @@ -990,8 +990,10 @@ export interface LanguageAdapter { toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] /** 给叶子节点定词性:源码着色用它,译文里照抄的词也按它着色、按它认出能查定义的名字。 */ tokenRole: (leaf: Node) => TokenRole - /** 行注释的记号(`#`、`//`):「重写注释」给还没有注释的块新写一段时用它;不填就不能新写。 */ + /** 行注释的记号(`#`、`//`):还没有说明、而这门语言的说明就是行注释时(shell、Dockerfile),「重写注释」用它新写一段;不填、又没有 freshDoc,就不能新写。 */ lineComment?: string + /** 还没有说明时新写的说明插在哪、用什么壳(正文占位 `_`)。不填就用 lineComment 写在块上面。 */ + freshDoc?: (block: CodeBlock) => { line: number, shell: string, breakAt?: number } | undefined /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined /** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */ @@ -2038,12 +2040,12 @@ interface BlockViewBase { concepts: BlockConcept[] // 认出来的概念,从左到右;webview 按图标名画,不自己用种类查 badge?: KindBadge // 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色;图标在 concepts 里。和类块一样默认折叠 slot?: { row: number, item: number } // 库规则挂在父卡片子行上的子块才有(RuleSlot):挂在父卡片第 row 行的第 item 个子行上,点它才在这一行下面展开(它的块 id 记成折叠着就是收着) - commentTarget?: CommentTarget // 「重写注释」写到哪(见「重写注释」);嵌着的代码里的块、语言没给行注释记号又还没有注释的块没有,不给按钮 + commentTarget?: CommentTarget // 「重写注释」写到哪(见「重写注释」);嵌着的代码里的块、语言没给出新写的办法又还没有注释的块没有,不给按钮 children: BlockView[] } -/** 「重写注释」写到哪,行号 0 起:换掉块前面那几段普通注释(文件开头的说明卡片是它自己),或块还没有注释时新写在 line 这一行上面。 */ -type CommentTarget = { kind: 'replace', startLine: number, endLine: number } | { kind: 'insert', line: number } +/** 「重写注释」写到哪,行号 0 起:换掉说明或块前面的普通注释(文件开头的说明卡片是它自己),或都没有时按 shell 新写在 line 这一行上面。 */ +type CommentTarget = { kind: 'replace', startLine: number, endLine: number } | { kind: 'insert', line: number, shell: string, breakAt?: number } /** * 属性表块(有 facts)和库规则认领的块只带 properties(「注释」一行由宿主插在里面),其余块只带 translation(文件开头的说明是 `{ markdown }`);没有翻译插件时是空数组。 @@ -2247,7 +2249,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit - `blockView`:`codeTokens` 拼起来等于 `code`;去缩进正确;函数、类、导入、常量、变量块只带属性表,其余块只带译文。 - 块标签:拆法(`docTags.test.ts`:多行续写、空行结束、`@returns` / `@exception` 同义、JSDoc 的 `{类型}`、`[名字=默认值]`、横杠、标签前后的正文、没有标签时原样、别的标签和围栏代码块里的行留在正文、异常名写成引用);卡片(`translators/zhTemplate.test.ts` 的「说明里的块标签」):`fixtures/conversationBrief.py`(luffy 的 `render_conversation_brief`)说明里没有 `@param` / `@return`,输入参数表「说明」列四个参数的原文,输出参数后面跟 `@return`;`fixtures/localRuntimeGate.py`(luffy 的 `gate.py`)「异常」表两行,第一行是完整链接、第二行是不带路径的引用;对不上的 `@param` 排在说明下面、提示色、悬停「没有这个参数」;类的 `@param` 照写成表、异常类的对到构造参数(`*args` 去掉星号对)。React 组件(字段没注释时用 `@param`、`props.x`、讲整个参数的跟在构造参数后面、对不上的、异常表)和事件处理函数(`rules/react.test.ts`)、FastAPI 请求表的说明列和响应后面的 `@return`(`rules/fastapi.test.ts`)、Rust 函数和结构体(`languages/rust.test.ts`)、数据类字段(`rules/dataclass.test.ts`)。 - 注释挂到它讲的块上(各语言的 `languages/*.test.ts`「注释挂到它讲的块上」,`outline` 的 `notes` 是挂着的各段注释在哪):Python、TypeScript、Rust、Shell、Dockerfile 各一份,覆盖隔着空行的注释挂到后面那块、主体最后的挂到前面那块、主体里只有注释时挂到父块、文件最后的挂到最后一块、文件开头隔着空行的是文件的说明而紧挨着第一块代码的挂到它上面、`#!` / 写编码的 / `//!` / `@file` / 模块 docstring 是文件的、说明和注释并存、函数体里语句前面的注释;luffy 的 `fixtures/acquisitionRepository.py` 导入后面的注释挂到类上(`blockView.test.ts`:类卡片「名称、注释、访问级别」、编辑范围连着注释、写回原样);TypeScript 的「名称、说明、注释」、几段注释是几段 Markdown、一行译文的卡片的 `rows`;嵌着的代码里的块没有 `commentTarget`;改了注释那块算修改、「注释」一行逐词标(`diff/blockDiff.test.ts`);规则略去挂着注释的块不合约定(`libraryRules.test.ts`)。webview(`BlockTree.test.tsx`):「注释」一行照属性表画,一行译文的卡片排在译文下面,有没有「注释」一行按钮都只有一个、排在右上角那排最前面,没有注释时叫「按注释规范写注释」,不能写注释的块没有按钮。 -- 重写注释(`commentRewrite.test.ts`、`qa/claudeAsk.test.ts`):发给模型的就是 `/ecc:writing-code-comments 路径:起-止` 一句;读结构化结果(正文逐字长出来、读文件是在做的事、只回一段话没有正文、报错);命令行在只读那套后面加 `--json-schema`、stdin 里只有那一句;交回结构化正文 → 换掉块前面几段注释(套回 `#` 和缩进)、文件开头的说明换它自己;没有注释的块新写一段插在上面(缩进同块,docstring 不动);没交回正文、出错、重写期间代码变了 → 失败不写,叫停 → 不写;套回壳的几种写法(三引号、`#`、`/** */`、`//`)。 +- 重写注释(`commentRewrite.test.ts`、`qa/claudeAsk.test.ts`):发给模型的就是 `/ecc:writing-code-comments 路径:起-止` 一句;读结构化结果(正文逐字长出来、读文件是在做的事、只回一段话没有正文、报错);命令行在只读那套后面加 `--json-schema`、stdin 里只有那一句;交回结构化正文 → 已有说明换说明(Python docstring 填回三引号、JSDoc 填回 `/** */`,不在声明上面再插一行)、只有前面的普通注释时换掉那几段(套回 `#` 和缩进)、文件开头的说明换它自己、说明和普通注释都有时只换说明;都没有时 Python 函数在主体里插 docstring、TypeScript 在声明上面插 `/** */`、Rust 在项上面插 `///`、只有行注释的语言仍插在块上面;没交回正文、出错、重写期间代码变了 → 失败不写,叫停 → 不写;套回壳的几种写法(三引号、`#`、`/** */`、`//`、`///`)。 - 注释的 Markdown:「注释」一行、文件开头的说明、函数和类「说明」的视图数据是 `{ markdown }`,原文照抄(reST 角色、双反引号、`{@link}`、HTML 都不转换,块标签拆出去),行注释连成的块留得住缩进四格的代码块;渲染时 HTML 当文字、带协议的网址和邮箱成链接、不带协议的文件名不成链接,`javascript:` / `command:` / `./` / `..` / 绝对路径的链接和 http 图片不渲染,仓库内图片按原图比例显示,点了打开原图,没有就写「找不到图片」,仓库路径链接能点、点了打开文件并跳到符号;跳到定义不认 Markdown 里的名字。不带路径的引用:行内规则(`webview-ui/.../markdown.test.ts`:`[Name]`、`[A.b]` 是 `symbol:` 链接,`` `[x]` ``、`items[i]`、`[会话进展]`、`[名字](路径)`、`[Agent](javascript:x)` 各自的结果,问 AI 的回答不认、带增删的注释照样认);问答(`repoLink.test.ts`:`symbol:` 地址只认标识符、点了发不带 `path` 的 `openRepoLink`、和仓库路径一起问、找不到的标坏、打开成功不再标、同样的链接不再问、文档换了版本再问);扩展端(`symbolLinks.test.ts`:本文件优先于导入且不问语言扩展、本文件的 `Class.member`、类有成员没有就是找不到、导入的名字问定义再在那个类底下找成员、整模块导入从顶层找、找不到;Python / TypeScript 各种导入写法引进来的名字和问定义的位置,`gate.py` 的导入)。 - 行替换:多行块替换后缩进正确。 - 跳到定义:TS / Python 译文和属性表里的名字、点分模块名每一节、模块字符串带下标且指回源码原文,关键字、运算符、说明词不带(前面有中文注释也对得上);查到的定义 → 不动 / 原地 / 别的文件 / 挑选框,别的文件用哪种编辑器(`editor/openTarget.test.ts`:有代码阅读的用预览、带位置时滚到那一行闪定义处,没有的用默认的打开方式选中名字,打不开成文本的用默认的打开方式);webview 里按下标找目标块、要展开的祖先块和属性行,滚动位置。悬停看定义卡片:取哪块(`editor/definitionPeek.test.ts`:Python 源码走完整的语言插件 + 翻译插件,类名 → 类那块连同子块,参数 → 所在的函数,变量 → 变量那块,块外面 → 没有,挂在子行上的子块去掉 `slot`;卡片上的中文名只带画出来的、长度对得上的,别的文件只认定义本身),什么时候弹、收(`definitionPeek.test.ts`:停够延迟才问、没到时间移开不问、没有卡片不弹、迟到的回答丢掉、链接问 `peekLink` 点标题发 `openRepoLink`、没收到延迟不弹、移开过 `hidingDelay` 才收、移进浮窗不收、扫过别的名字不往后推、浮窗里不弹别的、换成新名字的卡片、Esc / 滚动后不再弹、换文档版本收起;指到符号的链接才弹;放在下方 / 上方 / 空间大的那边、钳在视口内),只读的卡片(`DefinitionPeekPopup.test.tsx`:类收起子块、没有编辑 / 问 AI / 重写按钮、中文名用卡片带的、引用不成链接;`markdown.test.ts` 的 `peek`)。闪哪一处(TS、Python 源码走完整的语言插件 + 翻译插件 + 视图数据):参数 → 它那个子行,导入项 → 它那一项,多变量声明里的一个 → 它的名称行,类的泛型参数 → 「类型参数」那一行,函数名 / 类名 / 单个变量名(含解构模式)→ 整块,循环变量 → 所在的块,翻译插件没填 `defines` → 整块(照样展开属性行);翻译插件给参数、导入项、声明的变量、泛型参数标的范围指回源码原文;块自己的名字只有函数、类和单变量声明有。 diff --git a/vscode/src/codeReader/blockView.test.ts b/vscode/src/codeReader/blockView.test.ts index 78ac664..eec979b 100644 --- a/vscode/src/codeReader/blockView.test.ts +++ b/vscode/src/codeReader/blockView.test.ts @@ -435,7 +435,7 @@ describe('卡片上的「注释」一行', () => { const [, load] = await views(ts, 'typescript', typescriptZhTranslator, source) expect('properties' in load && load.properties.map(row => row.label).slice(0, 4)).toEqual(['名称', '说明', '注释', '访问级别']) expect(commentText(load)).toBe('讲 load\n\n第二段') - expect(load).toMatchObject({ startLine: 2, commentTarget: { kind: 'replace', startLine: 2, endLine: 4 } }) + expect(load).toMatchObject({ startLine: 2, commentTarget: { kind: 'replace', startLine: 5, endLine: 5 } }) const [check, returned] = load.children expect(check).toMatchObject({ kind: 'if', startLine: 7, rows: [{ label: '注释', value: { markdown: '先检查' } }] }) expect(returned).toMatchObject({ kind: 'return', commentTarget: { kind: 'insert', line: 11 } }) @@ -449,7 +449,7 @@ describe('卡片上的「注释」一行', () => { embedded: languageId => languageId === 'shellscript' ? { adapter: shell, translator: bashZhTranslator } : undefined, }) const run = flatten(blocks).find(view => view.kind === 'run') - expect(run?.commentTarget).toEqual({ kind: 'insert', line: 1 }) + expect(run?.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '# _' }) expect(run?.children.length).toBeGreaterThan(0) expect(run?.children.every(step => step.commentTarget === undefined)).toBe(true) }) diff --git a/vscode/src/codeReader/blockView.ts b/vscode/src/codeReader/blockView.ts index 1f40833..c1219dd 100644 --- a/vscode/src/codeReader/blockView.ts +++ b/vscode/src/codeReader/blockView.ts @@ -5,7 +5,7 @@ import type { ImportedName } from './symbolLinks' import { blockAttributes } from './attributes' import { KIND_ICONS } from './contract' import { commonIndent, splitLines } from './editText' -import { isCommentBlock } from './languages/shared' +import { explanationSpan, isCommentBlock } from './languages/shared' import { activeRules, claimBlock, importsIn } from './libraryRules' import { BlockAnchors } from './qa/anchor' import { importedNames } from './symbolLinks' @@ -38,7 +38,7 @@ interface BlockViewBase { * 同一个 id 只留第一个。项目入口那一块的第一个换成入口(见 entries/entryMarks.ts)。webview 按这个画图标,不自己用种类查。 */ concepts: BlockConcept[] - /** 「重写注释」写到哪(见 CommentTarget);不能写注释的块(嵌着的代码里的、语言没说行注释怎么写又还没有注释的)没有。 */ + /** 「重写注释」写到哪(见 CommentTarget);不能写注释的块(嵌着的代码里的、语言没给出新写的办法又还没有注释的)没有。 */ commentTarget?: CommentTarget /** 库规则的卡片才有:筛选栏里这个种类的名字,卡片边框的颜色(RuleCard.badge)。图标在 concepts 里。预览同类块一样默认收起它的子块。 */ badge?: KindBadge @@ -54,12 +54,14 @@ export interface BlockSlot { } /** - * 「重写注释」写到哪,行号 0 起:replace 是换掉 startLine..endLine 这几行(块前面那几段普通注释;文件开头的说明卡片是它自己), - * insert 是块还没有注释,新写一段插在 line 这一行上面(块的第一行),缩进同这一行。 + * 「重写注释」写到哪,行号 0 起。 + * replace 换掉这几行:块的说明(docstring、JSDoc、`///`),没有说明时是块前面的普通注释,文件开头的说明卡片是它自己。 + * insert 是都没有,新写一段插在 line 这一行上面。shell 是带定界符的空壳(正文占位 `_`),缩进同这一行。 + * breakAt 是主体和声明写在同一行:从这一行的这一列拆开,说明插进主体里。 */ export type CommentTarget = | { kind: 'replace', startLine: number, endLine: number } - | { kind: 'insert', line: number } + | { kind: 'insert', line: number, shell: string, breakAt?: number } /** * 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,文件开头的说明是 Markdown,其余是一行译文。 @@ -394,17 +396,26 @@ export function toBlockViews( return { properties: row ? withCommentRow(rows, row) : rows } } /** 「重写注释」写到哪(见 CommentTarget):块前面的普通注释换掉,没有就新写在块上面。 */ + /** 「重写注释」写到哪:有说明就换说明,否则换块前面的普通注释,都没有就按这门语言的说明新写。 */ function commentTarget(block: CodeBlock): Pick { if (!writable) return {} if (isCommentBlock(block)) return { commentTarget: { kind: 'replace', startLine: block.header.startRow, endLine: block.header.endRow } } const leading = (block.comments ?? []).filter(comment => comment.header.startIndex < block.header.startIndex) + const doc = explanationSpan(block) + const covered = doc !== undefined && leading.some(comment => comment.header.startIndex <= doc.startIndex && comment.header.endIndex >= doc.endIndex) + if (doc && !covered) + return { commentTarget: { kind: 'replace', startLine: doc.startRow, endLine: doc.endRow } } const [first] = leading const last = leading.at(-1) if (first && last) return { commentTarget: { kind: 'replace', startLine: first.header.startRow, endLine: last.header.endRow } } - return layerDocument.language.lineComment ? { commentTarget: { kind: 'insert', line: block.header.startRow } } : {} + const fresh = layerDocument.language.freshDoc?.(block) + if (fresh) + return { commentTarget: { kind: 'insert', line: fresh.line, shell: fresh.shell, ...(fresh.breakAt !== undefined && { breakAt: fresh.breakAt }) } } + const marker = layerDocument.language.lineComment + return marker ? { commentTarget: { kind: 'insert', line: block.header.startRow, shell: `${marker} _` } } : {} } /** 一行译文的卡片:译文、实参表、说明,有注释时下面再排「注释」一行。 */ function textCard(block: TextBlock): ReturnType & { rows?: PropertyRow[] } { diff --git a/vscode/src/codeReader/commentRewrite.test.ts b/vscode/src/codeReader/commentRewrite.test.ts index 46e3437..6e5d1be 100644 --- a/vscode/src/codeReader/commentRewrite.test.ts +++ b/vscode/src/codeReader/commentRewrite.test.ts @@ -5,9 +5,11 @@ import { toBlockViews } from './blockView' import { COMMENT_SKILL, commentEdit, commentRewritePrompt, commentSource, createCommentParser, findBlock, refillComment, rewriteBlockComment } from './commentRewrite' import { commonIndent, replaceLines, splitLines } from './editText' import { createPythonAdapter } from './languages/python' +import { createRustAdapter } from './languages/rust' import { createTypeScriptAdapter } from './languages/typescript' import { WASM_DIR, withBlocks } from './testSupport' import { pythonZhTranslator } from './translators/pythonZh' +import { rustZhTranslator } from './translators/rustZh' import { typescriptZhTranslator } from './translators/typescriptZh' /** 和预览写回同一条路:正文套回壳,再按原来的缩进替换这几行。 */ @@ -90,6 +92,11 @@ describe('重写注释后套回原来的壳', () => { ].join('\n')) }) + it('三斜杠和模块内文档注释:记号留着,不收成 //', () => { + expect(refillComment('/// 旧说明', '新说明')).toBe('/// 新说明') + expect(refillComment('//! 旧说明', '新说明')).toBe('//! 新说明') + }) + it('双斜杠行注释同样只换正文', () => { const file = [ 'function keep() {', @@ -194,10 +201,10 @@ async function pythonBlock(source: string, line: number): Promise { return found } -function job(block: BlockView, path = 'src/a.py', lineComment = '#'): CommentRewriteJob { +function job(block: BlockView, path = 'src/a.py'): CommentRewriteJob { if (!block.commentTarget) throw new Error('这块不能写注释') - return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path, lineComment } + return { target: block.commentTarget, startLine: block.startLine, endLine: block.endLine, path } } const SOURCE = [ @@ -227,13 +234,54 @@ describe('重写一块代码的注释', () => { expect(file.text()).toBe(SOURCE.replace('# 旧的说明\n\n# 第二段', '# 仓库:按名字找文件。')) }) - it('还没有注释的块:用行注释记号新写一段,插在块上面,缩进同块的第一行;说明(docstring)不动', async () => { + it('函数已经有 docstring、上面没有 #:换掉 docstring 的正文,不在 def 上面再插一行', async () => { const source = 'class Repo:\n def find(self):\n """找。"""\n return 1\n' const find = await pythonBlock(source, 2) - expect(find.commentTarget).toEqual({ kind: 'insert', line: 1 }) + expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) const file = memoryFile(source) - expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找\n\n找不到时返回 1'))).toEqual({ status: 'done' }) - expect(file.text()).toBe('class Repo:\n # 按名字找\n #\n # 找不到时返回 1\n def find(self):\n """找。"""\n return 1\n') + const prompts: string[] = [] + expect(await rewriteBlockComment(job(find), file.deps(async (prompt) => { + prompts.push(prompt) + return '按名字找\n\n找不到时返回 1' + }))).toEqual({ status: 'done' }) + expect(prompts).toEqual(['/ecc:writing-code-comments src/a.py:2-4']) + expect(file.text()).toBe('class Repo:\n def find(self):\n """按名字找\n\n 找不到时返回 1\n """\n return 1\n') + }) + + it('函数既没有 docstring 也没有前面的 #:在主体里插一段 docstring,不在 def 上面写 #', async () => { + const source = 'def find(self):\n return 1\n' + const find = await pythonBlock(source, 1) + expect(find.commentTarget).toEqual({ kind: 'insert', line: 1, shell: '"""_"""' }) + const file = memoryFile(source) + expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(file.text()).toBe('def find(self):\n """按名字找。"""\n return 1\n') + }) + + it('声明和主体写在同一行:docstring 插进主体,不写在 def 上面', async () => { + const source = 'def find(): return 1\n' + const find = await pythonBlock(source, 1) + expect(find.commentTarget).toMatchObject({ kind: 'insert', line: 0, shell: '"""_"""' }) + const file = memoryFile(source) + expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(file.text()).toBe('def find():\n """按名字找。"""\n return 1\n') + }) + + it('函数只有前面的 #、没有 docstring:仍换掉那段 #', async () => { + const source = '# 旧的说明\ndef find():\n return 1\n' + const find = await pythonBlock(source, 1) + expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 }) + const file = memoryFile(source) + expect(await rewriteBlockComment(job(find), file.deps(async () => '按名字找。'))).toEqual({ status: 'done' }) + expect(file.text()).toBe('# 按名字找。\ndef find():\n return 1\n') + }) + + it('docstring 和前面的 # 都有:只换 docstring,# 留着', async () => { + const source = '# 旧的说明\ndef find():\n """找。"""\n return 1\n' + const find = await pythonBlock(source, 1) + expect(find.commentTarget).toEqual({ kind: 'replace', startLine: 2, endLine: 2 }) + const file = memoryFile(source) + expect(await rewriteBlockComment(job(find), file.deps(async () => '新的说明。'))).toEqual({ status: 'done' }) + expect(file.text()).toBe('# 旧的说明\ndef find():\n """新的说明。"""\n return 1\n') }) it('文件开头的说明卡片换的是它自己:范围就是这段注释', async () => { @@ -284,13 +332,31 @@ describe('重写一块代码的注释', () => { expect(stopped.writes).toEqual([]) }) - it('用 // 写 TypeScript 的块:JSDoc 是说明,不当成要换掉的注释', async () => { + it('typescript 已经有 JSDoc:换掉这段说明,不在声明上面再写 //', async () => { const ts = createTypeScriptAdapter(WASM_DIR) const source = '/** 读配置。 */\nexport function load() {}\n' const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) - expect(view.commentTarget).toEqual({ kind: 'insert', line: 0 }) - const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次', ts.lineComment) - expect(edit).toEqual({ kind: 'insert', line: 0, text: '// 启动时读一次' }) + expect(view.commentTarget).toEqual({ kind: 'replace', startLine: 0, endLine: 0 }) + const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') + expect(edit).toEqual({ kind: 'replace', startLine: 0, endLine: 0, text: '/** 启动时读一次 */' }) + }) + + it('typescript 还没有说明:在声明上面插 /** */,不写 //', async () => { + const ts = createTypeScriptAdapter(WASM_DIR) + const source = 'export function load() {}\n' + const [view] = await withBlocks(ts, 'typescript', source, blocks => toBlockViews(blocks, { source, language: ts, markers: [] }, typescriptZhTranslator)) + expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/** _ */' }) + const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') + expect(edit).toEqual({ kind: 'insert', line: 0, text: '/** 启动时读一次 */' }) + }) + + it('rust 还没有说明:在项上面插 ///,不写 //', async () => { + const rust = createRustAdapter(WASM_DIR) + const source = 'fn load() {}\n' + const [view] = await withBlocks(rust, 'rust', source, blocks => toBlockViews(blocks, { source, language: rust, markers: [] }, rustZhTranslator)) + expect(view.commentTarget).toEqual({ kind: 'insert', line: 0, shell: '/// _' }) + const edit = commentEdit(view.commentTarget as CommentTarget, index => splitLines(source)[index], '启动时读一次') + expect(edit).toEqual({ kind: 'insert', line: 0, text: '/// 启动时读一次' }) }) }) diff --git a/vscode/src/codeReader/commentRewrite.ts b/vscode/src/codeReader/commentRewrite.ts index 526b1b5..b2f69b0 100644 --- a/vscode/src/codeReader/commentRewrite.ts +++ b/vscode/src/codeReader/commentRewrite.ts @@ -102,11 +102,13 @@ interface LineShell { spaced: boolean } -/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格。 */ +/** 一行 `#`、`//`、`///`、`//!` 注释:记号、记号后面有没有空格。`///` 和 `//!` 整段都是记号,不能收成 `//`。 */ function lineParts(line: string): LineShell | undefined { let end = 0 if (line.startsWith('//')) { end = 2 + if (line[end] === '/' || line[end] === '!') + end++ } else if (line.startsWith('#')) { while (line[end] === '#') @@ -337,17 +339,34 @@ export type CommentEdit * 新正文落到文件里的样子:换掉原来那段注释时套回它的定界符(几段注释中间隔着空行时照第一段的写法写成一段); * 块还没有注释时用语言的行注释记号新写一段,缩进同块的第一行。认不出定界符、没有行注释记号、正文是空的时没有。 */ -export function commentEdit(target: CommentTarget, lineAt: (line: number) => string, body: string, lineComment: string | undefined): CommentEdit | undefined { +/** 把模型交回的正文套回目标:换掉原来那几行,或按 shell 新写一段。正文是空的、定界符认不出,就不写。 */ +export function commentEdit(target: CommentTarget, lineAt: (line: number) => string, body: string): CommentEdit | undefined { if (target.kind === 'replace') { const comment = commentSource(Array.from({ length: target.endLine - target.startLine + 1 }, (_, i) => lineAt(target.startLine + i))) const text = refillComment(comment, body) ?? refillComment(comment.split(/\n[ \t]*\n/)[0], body) return text === undefined ? undefined : { kind: 'replace', startLine: target.startLine, endLine: target.endLine, text } } - if (!lineComment) + const text = refillComment(target.shell, body) + if (text === undefined) return undefined + if (target.breakAt !== undefined) + return splitSameLine(lineAt(target.line), target.line, target.breakAt, text) const indent = /^[ \t]*/.exec(lineAt(target.line))?.[0] ?? '' - const text = refillComment(`${lineComment} _`, body) - return text === undefined ? undefined : { kind: 'insert', line: target.line, text: text.split('\n').map(line => indent + line).join('\n') } + return { kind: 'insert', line: target.line, text: text.split('\n').map(line => indent + line).join('\n') } +} + +/** 声明和主体写在同一行:从 breakAt 那一列拆开,说明插进主体,缩进比声明多一层。交给写回时再补上这一行原来的缩进。 */ +function splitSameLine(line: string, atLine: number, breakAt: number, doc: string): CommentEdit | undefined { + if (breakAt < 0 || breakAt > line.length) + return undefined + const base = /^[ \t]*/.exec(line)?.[0] ?? '' + const head = line.slice(base.length, breakAt).trimEnd() + const tail = line.slice(breakAt).trim() + if (!head || !tail) + return undefined + const unit = base.includes('\t') && !base.includes(' ') ? '\t' : ' ' + const body = doc.split('\n').map(row => row ? unit + row : '') + return { kind: 'replace', startLine: atLine, endLine: atLine, text: [head, ...body, unit + tail].join('\n') } } /** 写回文件的结果:written 写进去了,unchanged 和文件里一样不用写,missing 行号已经不在,rejected 编辑器没接受。 */ @@ -365,7 +384,6 @@ export interface CommentRewriteJob { startLine: number endLine: number path: string - lineComment?: string } /** 重写时要用的:读文件现在的行,问模型(拿到结构化结果里的正文,没有正文时 undefined;失败时抛错),出错时给人看的话,写回文件。 */ @@ -403,7 +421,7 @@ export async function rewriteBlockComment(job: CommentRewriteJob, deps: CommentR return failed('模型没有按要求交回注释正文,没有改文件。') if (rangeText() !== snapshot) return failed('这块代码在重写期间变了,没有写回。') - const edit = commentEdit(job.target, deps.lineAt, body, job.lineComment) + const edit = commentEdit(job.target, deps.lineAt, body) if (!edit) return failed('这段注释的定界符认不出来,没有改文件。') const wrote = await deps.write(edit) diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index adeecfc..4b39024 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -720,8 +720,17 @@ export interface LanguageAdapter { toBlocks: (root: Node, source: string, languageId: string) => CodeBlock[] /** 给语法树叶子节点定词性:源码着色用它,译文里照抄的词也按它着色、按它认出能查定义的名字。 */ tokenRole: (leaf: Node) => TokenRole - /** 行注释的记号(`#`、`//`):「重写注释」给还没有注释的块新写一段时用它;不填就不能新写。 */ + /** + * 行注释的记号(`#`、`//`):块还没有说明、也没有前面的普通注释,而这门语言的说明就是行注释时(shell、Dockerfile), + * 「写注释」用它在块上面新写一段。不填、又没有 freshDoc,就不能新写。 + */ lineComment?: string + /** + * 还没有说明、也没有块前面的普通注释时,「写注释」新写的说明插在哪。 + * shell 是带定界符的空壳,正文用 `_` 占位。breakAt 是主体和声明写在同一行:从这一行的这一列拆开,说明插进主体里。 + * 不填就用 lineComment 写在块的第一行上面。 + */ + freshDoc?: (block: CodeBlock) => { line: number, shell: string, breakAt?: number } | undefined /** node 是写在表达式里的遍历(推导式、生成器表达式)时给出它的结构,别的节点没有;没有这种写法的语言不填。 */ iteration?: (node: Node) => IterationFacts | undefined /** node 是一次调用(含新建对象)时给出它的结构,别的节点没有;没给的语言不展开调用,照常一行译文。 */ diff --git a/vscode/src/codeReader/editor/previewEditor.ts b/vscode/src/codeReader/editor/previewEditor.ts index 9cc848f..40c5072 100644 --- a/vscode/src/codeReader/editor/previewEditor.ts +++ b/vscode/src/codeReader/editor/previewEditor.ts @@ -982,7 +982,6 @@ class Preview { startLine: block.startLine, endLine: block.endLine, path: relative(cwd, document.uri.fsPath).split(sep).join('/'), - lineComment: this.registry.languageFor(this.languageId)?.lineComment, }, { lineAt: line => document.lineAt(line).text, lineCount: () => document.lineCount, diff --git a/vscode/src/codeReader/languages/python.ts b/vscode/src/codeReader/languages/python.ts index df47b3a..f71ccb7 100644 --- a/vscode/src/codeReader/languages/python.ts +++ b/vscode/src/codeReader/languages/python.ts @@ -648,6 +648,18 @@ function pythonTokenRole(leaf: Node): TokenRole { } /** Python 语言插件。@param wasmDir 语法包 .wasm 所在目录 */ +/** 函数、类还没有说明时,在主体里插一段 docstring。主体跟声明写在同一行时从第一条语句那一列拆开。 */ +function freshDoc(block: CodeBlock): { line: number, shell: string, breakAt?: number } | undefined { + if (block.kind !== 'function' && block.kind !== 'class') + return undefined + const shell = '"""_"""' + const below = block.children.find(child => child.header.startRow > block.header.endRow) + if (below) + return { line: below.header.startRow, shell } + const same = block.children.find(child => child.node.startPosition.row === block.header.endRow) + return same ? { line: block.header.endRow, shell, breakAt: same.node.startPosition.column } : undefined +} + export function createPythonAdapter(wasmDir: string): LanguageAdapter { return { id: 'python', @@ -656,6 +668,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter { toBlocks: root => withExceptions(attachComments(statements(named(root), 'module', -1), aboutModule), PYTHON_EXCEPTIONS), tokenRole: pythonTokenRole, lineComment: '#', + freshDoc, iteration: pythonIteration, call: pythonCall, displayNames: pythonNameReader, diff --git a/vscode/src/codeReader/languages/rust.ts b/vscode/src/codeReader/languages/rust.ts index 0908f81..2376a64 100644 --- a/vscode/src/codeReader/languages/rust.ts +++ b/vscode/src/codeReader/languages/rust.ts @@ -14,6 +14,7 @@ export function createRustAdapter(wasmDir: string): LanguageAdapter { toBlocks: root => attachComments(topBlocks(named(root), -1), aboutModule), tokenRole: rustTokenRole, lineComment: '//', + freshDoc, } } @@ -35,6 +36,13 @@ const COMMENTS: CommentRules = { documents: new Set(['function', 'class', 'trait', 'impl', 'module', 'enum', 'const', 'variable']), } +/** 能带说明的项还没有说明时,在它紧上面插 `///`。 */ +function freshDoc(block: CodeBlock): { line: number, shell: string } | undefined { + if (!(COMMENTS.documents as ReadonlySet).has(block.kind)) + return undefined + return { line: block.header.startRow, shell: '/// _' } +} + /** 讲所在模块或 crate 的内部文档注释(`//!`、`/*!`):写在文件开头是文件的说明,写在 `mod` 身体里交给那个模块。 */ function aboutModule(comment: CommentBlock): boolean { return named(comment.node).some(child => child.type === 'inner_doc_comment_marker') diff --git a/vscode/src/codeReader/languages/shared.ts b/vscode/src/codeReader/languages/shared.ts index 82eeb8d..a7de5e2 100644 --- a/vscode/src/codeReader/languages/shared.ts +++ b/vscode/src/codeReader/languages/shared.ts @@ -328,6 +328,32 @@ function ownDoc(block: CodeBlock): Node | undefined { } } +/** 连着几行的外部文档注释(Rust 的 `///`)是同一段说明,第一条节点带着后面紧挨着的几行;别的说明就是它那个节点。 */ +export function docRun(first: Node): Node[] { + const outer = (node: Node): boolean => node.type === 'line_comment' && named(node).some(child => child.type === 'outer_doc_comment_marker') + if (!outer(first)) + return [first] + const nodes = [first] + for (let next = first.nextSibling; next && outer(next) && next.startPosition.row === nodes.at(-1)!.startPosition.row + 1; next = next.nextSibling) + nodes.push(next) + return nodes +} + +/** 块自己的说明占的那几行(见 ownDoc,Rust 连着的 `///` 算一整段)。没有说明就没有。 */ +export function explanationSpan(block: CodeBlock): SourceSpan | undefined { + const doc = ownDoc(block) + if (!doc) + return undefined + const nodes = docRun(doc) + const first = nodes[0] + const last = nodes.at(-1) + if (!first || !last) + return undefined + const start = isCommentNode(first) ? commentNodeSpan(first) : nodeSpan(first) + const end = isCommentNode(last) ? commentNodeSpan(last) : nodeSpan(last) + return { startIndex: start.startIndex, endIndex: end.endIndex, startRow: start.startRow, endRow: end.endRow } +} + /** 类属性的说明,照源码顺序:讲它的注释(BlockShape.comments)、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */ function fieldDocs(block: CodeBlock): Node[] { const trailing = block.node.nextNamedSibling diff --git a/vscode/src/codeReader/languages/typescript.ts b/vscode/src/codeReader/languages/typescript.ts index a5e8aca..858b370 100644 --- a/vscode/src/codeReader/languages/typescript.ts +++ b/vscode/src/codeReader/languages/typescript.ts @@ -636,6 +636,13 @@ function typescriptTokenRole(leaf: Node): TokenRole { } /** TypeScript / TSX / JavaScript / JSX 语言插件。@param wasmDir 语法包 .wasm 所在目录 */ +/** 能带说明的块还没有说明时,在声明紧上面插一段 JSDoc。 */ +function freshDoc(block: CodeBlock): { line: number, shell: string } | undefined { + if (!(COMMENTS.documents as ReadonlySet).has(block.kind)) + return undefined + return { line: block.header.startRow, shell: '/** _ */' } +} + export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { return { id: 'typescript', @@ -649,6 +656,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter { toBlocks: (root, _source, languageId) => withExceptions(attachComments(blockReader(TYPED_LANGUAGES.has(languageId))(root), aboutFile), TYPESCRIPT_EXCEPTIONS), tokenRole: typescriptTokenRole, lineComment: '//', + freshDoc, call: typescriptCall, displayNames: typescriptNameReader, }