✨ feat(vscode): 注释卡片可按规范重写注释

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent 724c81188c
commit 299acb979f
11 files changed
+717 -5

No files matched your search

+355
View File
@@ -0,0 +1,355 @@
/**
* 按注释规范重写一段注释:模型只给新正文,这里套回原来的定界符和缩进。
* 认 Python 的三引号、井号行注释,以及块注释和双斜杠行注释。认不出就不改。
*/
import { readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { commonIndent, splitLines } from './editText'
/** 贴给模型的代码最多这么多行,再长就截断并在提示里说明。 */
export const COMMENT_CONTEXT_LINES = 120
/** 运行时按这个顺序找技能,用到的第一份。不把技能正文放进仓库。 */
export const COMMENT_SKILL_PATHS = [
join(homedir(), '.claude/plugins/marketplaces/ecc/skills/writing-code-comments/SKILL.md'),
join(homedir(), '.claude/writing-code-comments/SKILL.md'),
] as const
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')
}
/** 去掉技能文件开头的 frontmatter,剩下的才是给模型的规范。 */
export function skillBody(markdown: string): string {
const text = markdown.charCodeAt(0) === 0xFEFF ? markdown.slice(1) : markdown
const fence = text.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/)
return (fence ? text.slice(fence[0].length) : text).trim()
}
function missingFile(err: unknown): boolean {
return typeof err === 'object' && err !== null && 'code' in err && err.code === 'ENOENT'
}
/** 读到的第一份技能正文;两处都没有就是 undefined。读失败(不是没有文件)照样抛。 */
export async function loadCommentSkill(read: (path: string) => Promise<string> = path => readFile(path, 'utf8')): Promise<string | undefined> {
for (const path of COMMENT_SKILL_PATHS) {
try {
return skillBody(await read(path))
}
catch (err) {
if (!missingFile(err))
throw err
}
}
return undefined
}
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 docProse(shell: DocShell): string {
let inner = shell.inner
if (inner.startsWith('\n'))
inner = inner.slice(1)
if (inner.endsWith('\n'))
inner = inner.slice(0, -1)
const lines = inner.split('\n')
const extra = commonIndent(lines)
return lines.map(line => line.startsWith(extra) ? line.slice(extra.length) : line).join('\n').trim()
}
/** 收尾那一行、引号前面的空白。正文和引号写在同一行时没有。 */
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
prose: string
}
/** 一行 `#` 或 `//` 注释:记号、记号后面有没有空格、空格后面的字。 */
function lineParts(line: string): { token: string, spaced: boolean, text: string } | undefined {
let end = 0
if (line.startsWith('//')) {
end = 2
}
else if (line.startsWith('#')) {
while (line[end] === '#')
end++
}
else {
return undefined
}
const gap = line[end]
const spaced = gap === ' ' || gap === '\t'
return { token: line.slice(0, end), spaced, text: line.slice(spaced ? end + 1 : end) }
}
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), prose: rows.map(item => item.text).join('\n') }
}
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}*/`
}
/** 去掉定界符之后的正文。认不出这种注释就是 undefined。 */
export function commentProse(comment: string): string | undefined {
const doc = docShell(comment)
if (doc)
return docProse(doc)
return lineShell(comment)?.prose ?? blockProse(comment)
}
/**
* 把新正文套回这段注释原来的壳。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)
}
/**
* 文件顶上的模块说明:前面没有别的代码(可以有别的注释),而且是三引号,或没挂到声明上的 `/**`。
* `#`、`//`、普通块注释不算,它们讲的是后面那一块。
*/
export function isModuleComment(comment: string, earlierKinds: readonly string[]): boolean {
if (earlierKinds.some(kind => kind !== 'comment'))
return false
if (comment.trimStart().startsWith('/**'))
return true
return docShell(comment) !== undefined
}
export interface CommentPlace<T extends { anchor: string, children: readonly T[] }> {
block: T
siblings: readonly T[]
index: number
}
/** 按记号在块树里找到这块,以及它和哪些块排在同一层。 */
export function findPlaced<T extends { anchor: string, children: readonly T[] }>(blocks: readonly T[], anchor: string): CommentPlace<T> | undefined {
const index = blocks.findIndex(block => block.anchor === anchor)
const found = index >= 0 ? blocks[index] : undefined
if (found)
return { block: found, siblings: blocks, index }
for (const block of blocks) {
const inner = findPlaced(block.children, anchor)
if (inner)
return inner
}
return undefined
}
function clip(text: string, what: string): { text: string, note: string } {
const lines = splitLines(text)
if (lines.length <= COMMENT_CONTEXT_LINES)
return { text, note: '' }
return {
text: lines.slice(0, COMMENT_CONTEXT_LINES).join('\n'),
note: `\n(${what}一共 ${lines.length} 行,这里只贴了前 ${COMMENT_CONTEXT_LINES} 行,后面截断了)`,
}
}
export interface CommentRewriteInput {
/** 去掉 frontmatter 的技能正文;没有技能就不组提示 */
skill: string | undefined
/** 去掉共同缩进的注释原文 */
comment: string
/** 整份文件,用来在是模块说明时拿掉这段注释 */
source: string
startLine: number
endLine: number
earlierKinds: readonly string[]
/** 普通注释后面紧挨着的那一块的原文;没有就不填 */
following?: string
}
/** 发给模型的整段话。注释认不出,或没有技能,就不组。 */
export function commentRewritePrompt(input: CommentRewriteInput): string | undefined {
if (input.skill === undefined)
return undefined
const body = commentProse(input.comment)
if (body === undefined)
return undefined
const fileComment = isModuleComment(input.comment, input.earlierKinds)
const context = fileComment
? clip(splitLines(input.source).filter((_, index) => index < input.startLine || index > input.endLine).join('\n').trim(), '文件其余部分')
: input.following
? clip(input.following, '这块代码')
: { text: '(后面没有紧挨着的代码)', note: '' }
return [
input.skill,
'只按上面的技能重写这一段注释。用中文。不要改代码,不要加技能没要求的中文名记号。只输出新注释的正文,不要定界符,不要解释。',
`这段注释现在的正文:\n${body}`,
`它说明的代码:\n${context.text}${context.note}`,
].join('\n\n')
}
/** 模型偶尔会套一层代码围栏。剥掉;剥完是空的就当没给。 */
export function modelCommentBody(text: string): string | undefined {
let body = text.trim()
if (body.startsWith('```') && body.endsWith('```')) {
const nl = body.indexOf('\n')
if (nl >= 0)
body = body.slice(nl + 1, -3).trim()
}
if (!body)
return undefined
return body
}