445 lines
19 KiB
TypeScript
445 lines
19 KiB
TypeScript
/**
|
||
* 「重写注释」:用写注释的技能给一块代码重写它的注释(没有就新写一段)。模型按 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
|
||
}
|