Files
superwork/vscode/src/codeReader/commentRewrite.ts
T

445 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 「重写注释」:用写注释的技能给一块代码重写它的注释(没有就新写一段)。模型按 JSON Schema 交回注释正文,
* 这里套回原来的定界符和缩进(没有注释时用语言给的空壳),由扩展写回文件。
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
*/
import type { CommentTarget } from './blockView'
import { describeToolUse } from '../cc/claudeProgress'
import { commonIndent } from './editText'
/** 写注释的技能在哪个 Claude Code 插件里 */
export const COMMENT_PLUGIN = 'ecc'
/** 跟在对话框里输入这个命令一样,由 Claude Code 自己加载写注释的技能。 */
export const COMMENT_SKILL = `/${COMMENT_PLUGIN}:writing-code-comments`
/** 模型交回的结构(`--json-schema`):body 是注释正文,不带注释符号。 */
export const COMMENT_SCHEMA = JSON.stringify({
type: 'object',
properties: { body: { type: 'string' } },
required: ['body'],
additionalProperties: false,
})
/** Claude Code 交结构化结果时调的工具:它的输入就是按 COMMENT_SCHEMA 写的结果。 */
const STRUCTURED_TOOL = 'StructuredOutput'
const DOUBLE_DOC = '"""'
const SINGLE_DOC = '\'' + '\'' + '\''
/** 写在三引号前面的字符串前缀,长的排前面,免得 `r` 吃掉 `rf`。 */
const DOC_PREFIXES = ['rf', 'fr', 'rb', 'br', 'r', 'b', 'u', 'f']
/** 一块注释占的几行,去掉共同缩进。和块编辑框里的字一样。 */
export function commentSource(lines: readonly string[]): string {
const width = commonIndent([...lines]).length
return lines.map(line => line.slice(Math.min(width, line.length))).join('\n')
}
interface DocShell {
prefix: string
quote: string
/** 引号里面的原文,含换行 */
inner: string
}
function docShell(comment: string): DocShell | undefined {
const trimmed = comment.trimEnd()
const lower = trimmed.toLowerCase()
let prefix = ''
for (const candidate of DOC_PREFIXES) {
if (!lower.startsWith(candidate))
continue
const after = trimmed.slice(candidate.length)
if (after.startsWith(DOUBLE_DOC) || after.startsWith(SINGLE_DOC)) {
prefix = trimmed.slice(0, candidate.length)
break
}
}
const rest = trimmed.slice(prefix.length)
const quote = rest.startsWith(DOUBLE_DOC) ? DOUBLE_DOC : rest.startsWith(SINGLE_DOC) ? SINGLE_DOC : undefined
if (!quote || rest.length < quote.length * 2 || !rest.endsWith(quote))
return undefined
return { prefix, quote, inner: rest.slice(quote.length, -quote.length) }
}
/** 收尾那一行、引号前面的空白。正文和引号写在同一行时没有。 */
function closerIndent(comment: string, quote: string): string {
const nl = comment.lastIndexOf('\n')
if (nl < 0)
return ''
const last = comment.slice(nl + 1)
const at = last.lastIndexOf(quote)
if (at < 0 || last.slice(at + quote.length).trim() !== '')
return ''
return last.slice(0, at)
}
function refillDoc(comment: string, body: string): string | undefined {
const shell = docShell(comment)
if (!shell)
return undefined
const single = !shell.inner.includes('\n')
if (single && !body.includes('\n'))
return `${shell.prefix}${shell.quote}${body}${shell.quote}`
let inner = shell.inner
if (inner.startsWith('\n'))
inner = inner.slice(1)
if (inner.endsWith('\n'))
inner = inner.slice(0, -1)
const extra = commonIndent(inner.split('\n'))
const closeIndent = closerIndent(comment, shell.quote)
const rendered = body.split('\n').map(line => line ? extra + line : '')
// 原来正文就写在开头引号那一行时,第一行仍写在那里
if (shell.inner.length > 0 && !shell.inner.startsWith('\n')) {
const [first, ...rest] = rendered
if (rest.length === 0)
return `${shell.prefix}${shell.quote}${first ?? ''}${shell.quote}`
return `${shell.prefix}${shell.quote}${first ?? ''}\n${rest.join('\n')}\n${closeIndent}${shell.quote}`
}
return `${shell.prefix}${shell.quote}\n${rendered.join('\n')}\n${closeIndent}${shell.quote}`
}
interface LineShell {
token: string
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] === '#')
end++
}
else {
return undefined
}
const gap = line[end]
return { token: line.slice(0, end), spaced: gap === ' ' || gap === '\t' }
}
function lineShell(comment: string): LineShell | undefined {
const lines = comment.split('\n')
if (lines.length === 0 || lines.some(line => line.length === 0))
return undefined
const parsed = lines.map(lineParts)
if (parsed.some(item => !item))
return undefined
const rows = parsed.flatMap(item => item ? [item] : [])
const token = rows[0]?.token
if (!token || rows.some(item => item.token !== token))
return undefined
return { token, spaced: rows.some(item => item.spaced) }
}
function refillLine(comment: string, body: string): string | undefined {
const shell = lineShell(comment)
if (!shell)
return undefined
return body.split('\n').map(line => line ? shell.token + (shell.spaced ? ' ' : '') + line : shell.token).join('\n')
}
function blockOpener(comment: string): '/**' | '/*' | undefined {
if (comment.startsWith('/**'))
return '/**'
if (comment.startsWith('/*'))
return '/*'
return undefined
}
/** 块注释中间一行 ` * 正文` 里的正文。不是星号行就 undefined。 */
function starText(line: string): string | undefined {
const trimmed = line.trimStart()
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
return undefined
const rest = trimmed.slice(1)
if (rest.startsWith(' ') || rest.startsWith('\t'))
return rest.slice(1)
return rest
}
/** 写回时套在每行正文前面的 ` * `,沿用原来那一行的缩进和空格。 */
function starPrefix(line: string): string | undefined {
const trimmed = line.trimStart()
if (!trimmed.startsWith('*') || trimmed.startsWith('*/'))
return undefined
const indent = line.slice(0, line.length - trimmed.length)
const rest = trimmed.slice(1)
const gap = rest.startsWith(' ') || rest.startsWith('\t') ? rest[0] : ''
return `${indent}*${gap}`
}
function blockProse(comment: string): string | undefined {
const open = blockOpener(comment)
if (!open || !comment.trimEnd().endsWith('*/'))
return undefined
if (!comment.includes('\n'))
return comment.trimEnd().slice(open.length, -2).trim()
const lines = comment.split('\n')
const first = lines[0] ?? ''
const last = lines[lines.length - 1] ?? ''
if (!first.startsWith(open) || last.trim() !== '*/')
return undefined
const body: string[] = []
const head = first.slice(open.length).trim()
if (head)
body.push(head)
for (const line of lines.slice(1, -1)) {
const text = starText(line)
body.push(text ?? line.trim())
}
while (body[0] === '')
body.shift()
while (body.length > 0 && body[body.length - 1] === '')
body.pop()
return body.join('\n')
}
function refillBlock(comment: string, body: string): string | undefined {
const open = blockOpener(comment)
if (!open || blockProse(comment) === undefined)
return undefined
if (!comment.includes('\n') && !body.includes('\n'))
return `${open} ${body} */`
const lines = comment.split('\n')
const prefix = lines.slice(1, -1).map(starPrefix).find(item => item !== undefined) ?? ' * '
const rendered = body.split('\n').map(line => line ? `${prefix}${line}` : prefix.trimEnd())
const closeIndent = (lines[lines.length - 1] ?? '').trim() === '*/'
? (lines[lines.length - 1] ?? '').slice(0, (lines[lines.length - 1] ?? '').indexOf('*/'))
: ' '
return `${open}\n${rendered.join('\n')}\n${closeIndent}*/`
}
/**
* 把新正文套回这段注释原来的壳。comment 是去掉共同缩进的原文。
* 认不出定界符,或新正文是空的,就返回 undefined,调用方不写文件。
*/
export function refillComment(comment: string, body: string): string | undefined {
const text = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n').trim()
if (!text)
return undefined
return refillDoc(comment, text) ?? refillLine(comment, text) ?? refillBlock(comment, text)
}
/** 代码块在哪:`路径:起-止`(路径相对 claude 的工作目录,行号 1 起,连同讲它的注释)。startLine、endLine 0 起。 */
function commentRange(path: string, startLine: number, endLine: number): string {
return `${path}:${startLine + 1}-${endLine + 1}`
}
/** 开新会话时发给模型的:技能命令后面跟代码块在哪;用户说了话(request)就空一行跟在后面。 */
export function commentRewritePrompt(path: string, startLine: number, endLine: number, request?: string): string {
const command = `${COMMENT_SKILL} ${commentRange(path, startLine, endLine)}`
return request ? `${command}\n\n${request}` : command
}
/** 接着这块的重写会话说话时发的:块现在在哪(写回注释后行号会变),空一行跟着用户的话。 */
export function commentFollowUp(path: string, startLine: number, endLine: number, request: string): string {
return `${commentRange(path, startLine, endLine)}\n\n${request}`
}
/**
* 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」),
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。
*/
export type CommentEvent
= | { kind: 'text', text: string }
| { kind: 'activity', label: string }
| { kind: 'result', body?: string }
| { kind: 'error', message: string }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function str(value: unknown): string {
return typeof value === 'string' ? value : ''
}
/** 写到一半的结构化结果(`{"body": "…`)里已经写出来的正文;还没写到正文、转义写到一半解不开时没有。 */
const PARTIAL_BODY = /^\s*\{\s*"body"\s*:\s*"((?:[^"\\]|\\.)*)/
function partialBody(json: string): string | undefined {
const match = PARTIAL_BODY.exec(json)
if (!match)
return undefined
try {
return JSON.parse(`"${match[1].replace(/\\u[0-9a-f]{0,3}$/i, '')}"`) as string
}
catch {
return undefined
}
}
/** 正文:结构化结果里的 body,去掉首尾空白是空的不算。 */
function bodyOf(output: unknown): string | undefined {
return isRecord(output) && typeof output.body === 'string' && output.body.trim() !== '' ? output.body : undefined
}
/**
* 一次重写一个解析器(它记着结构化结果写到哪了):`claude -p --output-format stream-json --verbose --include-partial-messages --json-schema`
* 的每行 → CommentEvent。结构化结果是模型调 StructuredOutput 工具交的,它的输入逐段(input_json_delta)来,读出正文给进度;
* 读文件、搜索这些工具调用是 activity;结果以 result 行的 structured_output 为准,模型只回了一段话(没调这个工具)就没有正文。
*/
export function createCommentParser(): (line: string) => CommentEvent[] {
/** 正在写的结构化结果(工具输入的 JSON 原文);不在写时没有 */
let json: string | undefined
let text = ''
const grow = (body: string | undefined): CommentEvent[] => {
if (body === undefined || body === text)
return []
text = body
return [{ kind: 'text', text }]
}
const streamEvent = (event: Record<string, unknown>): CommentEvent[] => {
if (event.type === 'content_block_start' && isRecord(event.content_block)) {
json = event.content_block.type === 'tool_use' && event.content_block.name === STRUCTURED_TOOL ? '' : undefined
return []
}
if (event.type === 'content_block_delta' && json !== undefined && isRecord(event.delta) && event.delta.type === 'input_json_delta') {
json += str(event.delta.partial_json)
return grow(partialBody(json))
}
if (event.type === 'content_block_stop')
json = undefined
return []
}
const assistant = (content: unknown[]): CommentEvent[] => content.flatMap((block): CommentEvent[] => {
if (!isRecord(block) || block.type !== 'tool_use' || typeof block.name !== 'string')
return []
const input = isRecord(block.input) ? block.input : {}
return block.name === STRUCTURED_TOOL ? grow(bodyOf(input)) : [{ kind: 'activity', label: describeToolUse(block.name, input) }]
})
return (raw) => {
let line: unknown
try {
line = JSON.parse(raw)
}
catch {
return []
}
// 子代理的消息(parent_tool_use_id 不空)不是这次的结果
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
if (line.type === 'stream_event')
return isRecord(line.event) ? streamEvent(line.event) : []
if (line.type === 'assistant')
return isRecord(line.message) && Array.isArray(line.message.content) ? assistant(line.message.content) : []
if (line.type !== 'result')
return []
if (line.is_error === true) {
const errors = Array.isArray(line.errors) ? line.errors.filter(item => typeof item === 'string').join(';') : ''
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
}
const body = bodyOf(line.structured_output)
return [{ kind: 'result', ...(body !== undefined && { body }) }]
}
}
/**
* 要改文件的哪儿(行号 0 起):replace 换掉 startLine..endLine 这几行,text 去掉了共同缩进(写回时每行补回第一行的缩进,同块编辑);
* insert 在 line 这一行上面插进几行,text 已经带着缩进、不带结尾的换行。
*/
export type CommentEdit
= | { kind: 'replace', startLine: number, endLine: number, text: string }
| { kind: 'insert', line: number, text: string }
/**
* 新正文落到文件里的样子:换掉原来那段注释时套回它的定界符(几段注释中间隔着空行时照第一段的写法写成一段);
* 块还没有注释时用语言的行注释记号新写一段,缩进同块的第一行。认不出定界符、没有行注释记号、正文是空的时没有。
*/
/** 把模型交回的正文套回目标:换掉原来那几行,或按 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 }
}
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] ?? ''
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 编辑器没接受。 */
export type WriteResult = 'written' | 'unchanged' | 'missing' | 'rejected'
/** 开始重写时记下的这块:「重写注释」写到哪,块的起止行(0 起,连同讲它的注释),这几行当时的原文(写回前比对,变了就不写)。 */
export interface CommentJob {
target: CommentTarget
startLine: number
endLine: number
snapshot: string
}
/** 读这几行当时的原文,记成一次重写;行号已经不在文件里时没有。 */
export function commentJob(target: CommentTarget, startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): CommentJob | undefined {
const snapshot = rangeText(startLine, endLine, lineAt, lineCount)
return snapshot === undefined ? undefined : { target, startLine, endLine, snapshot }
}
function rangeText(startLine: number, endLine: number, lineAt: (line: number) => string, lineCount: () => number): string | undefined {
if (startLine < 0 || endLine < startLine || endLine >= lineCount())
return undefined
return Array.from({ length: endLine - startLine + 1 }, (_, i) => lineAt(startLine + i)).join('\n')
}
/** 写回时要用的:读文件现在的行,写回文件。 */
export interface CommentWriteDeps {
lineAt: (line: number) => string
lineCount: () => number
write: (edit: CommentEdit) => Promise<WriteResult>
}
/**
* 把模型交回的正文套回这块的注释写进文件:写进去了(或和文件里一样)返回 undefined;没写成返回给人看的原因,文件不动。
* 没交回正文、这块代码在重写期间变了、定界符认不出、编辑器没接受,都不写。
*/
export async function writeCommentBody(job: CommentJob, body: string | undefined, deps: CommentWriteDeps): Promise<string | undefined> {
if (body === undefined)
return '模型没有按要求交回注释正文,没有改文件。'
if (rangeText(job.startLine, job.endLine, deps.lineAt, deps.lineCount) !== job.snapshot)
return '这块代码在重写期间变了,没有写回。'
const edit = commentEdit(job.target, deps.lineAt, body)
if (!edit)
return '这段注释的定界符认不出来,没有改文件。'
const wrote = await deps.write(edit)
if (wrote === 'written' || wrote === 'unchanged')
return undefined
return wrote === 'rejected' ? '编辑器没接受这次修改,没有写回。' : '这块代码在重写期间变了,没有写回。'
}
/** 按记号在块树里找到这块。 */
export function findBlock<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): T | undefined {
for (const block of blocks) {
if (block.anchor === anchor)
return block
const inner = findBlock(block.children, anchor)
if (inner)
return inner
}
return undefined
}